fastapi-keycloak-admin 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ from collections.abc import Iterable, Mapping
5
+ from dataclasses import dataclass
6
+ from http import HTTPStatus
7
+ from typing import Any
8
+
9
+ import aiohttp
10
+
11
+ from fastapi_keycloak_admin.config import KeycloakAdminConfig
12
+ from fastapi_keycloak_admin.exceptions import (
13
+ KeycloakAdminError,
14
+ KeycloakLoginError,
15
+ KeycloakUnavailableError,
16
+ PasswordChangeRequiredError,
17
+ )
18
+
19
+ PASSWORD_CHANGE_REQUIRED_DESCRIPTION = "Account is not fully set up"
20
+ """Keycloak's ``error_description`` for a password grant on an account with pending required actions."""
21
+
22
+ BUILT_IN_ROLES = frozenset({"offline_access", "uma_authorization"})
23
+ """Roles every Keycloak realm has; not application roles, so ``list_roles`` leaves them out."""
24
+
25
+ _ADMIN_TOKEN_LEEWAY_SECONDS = 15
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class _Response:
30
+ status: int
31
+ text: str
32
+ headers: Mapping[str, str]
33
+
34
+ def json(self) -> Any: # noqa: ANN401
35
+ return json.loads(self.text)
36
+
37
+ def error_description(self) -> str | None:
38
+ try:
39
+ payload = self.json()
40
+ except ValueError:
41
+ return None
42
+ if not isinstance(payload, dict):
43
+ return None
44
+ return (
45
+ payload.get("error_description") or payload.get("errorMessage") or payload.get("error")
46
+ )
47
+
48
+
49
+ class KeycloakAdminClient:
50
+ """Calls Keycloak on behalf of the backend: the OIDC token endpoints (password grant,
51
+ refresh, logout) and the Admin REST API.
52
+
53
+ One instance lives per application (see ``KeycloakAdmin``); it reuses one aiohttp
54
+ session and one admin token until it expires.
55
+ """
56
+
57
+ def __init__(self, config: KeycloakAdminConfig) -> None:
58
+ self.config = config
59
+ self._session: aiohttp.ClientSession | None = None
60
+ self._session_lock = asyncio.Lock()
61
+ self._admin_token: str | None = None
62
+ self._admin_token_expires_at = 0.0
63
+
64
+ async def _get_session(self) -> aiohttp.ClientSession:
65
+ # Created lazily inside the running loop - there is none yet when the app is built.
66
+ if self._session is None or self._session.closed:
67
+ async with self._session_lock:
68
+ if self._session is None or self._session.closed:
69
+ self._session = aiohttp.ClientSession(
70
+ timeout=aiohttp.ClientTimeout(total=self.config.timeout)
71
+ )
72
+ return self._session
73
+
74
+ async def close(self) -> None:
75
+ """Close the HTTP session; ``KeycloakAdmin`` calls it at application shutdown."""
76
+ if self._session is not None and not self._session.closed:
77
+ await self._session.close()
78
+ self._session = None
79
+
80
+ async def _request(self, method: str, url: str, **kwargs: Any) -> _Response: # noqa: ANN401
81
+ session = await self._get_session()
82
+ try:
83
+ async with session.request(method, url, **kwargs) as response:
84
+ return _Response(response.status, await response.text(), dict(response.headers))
85
+ except (aiohttp.ClientError, TimeoutError) as exc:
86
+ raise KeycloakUnavailableError(f"Keycloak is unreachable: {exc!r}") from exc
87
+
88
+ # --- tokens ---------------------------------------------------------------------------
89
+
90
+ async def _token_grant(self, data: dict[str, str]) -> dict[str, Any]:
91
+ response = await self._request(
92
+ "POST",
93
+ self.config.keycloak.token_url,
94
+ data={
95
+ "client_id": self.config.client_id,
96
+ "client_secret": self.config.client_secret,
97
+ **data,
98
+ },
99
+ )
100
+ if response.status == HTTPStatus.OK:
101
+ return response.json()
102
+ if response.status >= HTTPStatus.INTERNAL_SERVER_ERROR:
103
+ raise KeycloakUnavailableError(f"Keycloak token endpoint failed: {response.text}")
104
+ description = response.error_description() or "Invalid credentials"
105
+ if description == PASSWORD_CHANGE_REQUIRED_DESCRIPTION:
106
+ raise PasswordChangeRequiredError(description)
107
+ raise KeycloakLoginError(description)
108
+
109
+ async def login(self, username: str, password: str) -> dict[str, Any]:
110
+ """Resource Owner Password Credentials grant - the password passes through to
111
+ Keycloak and is never stored. Returns Keycloak's token response as is.
112
+
113
+ Raises:
114
+ PasswordChangeRequiredError: the account still has a temporary password.
115
+ KeycloakLoginError: wrong credentials.
116
+ """
117
+ return await self._token_grant(
118
+ {"grant_type": "password", "username": username, "password": password}
119
+ )
120
+
121
+ async def refresh(self, refresh_token: str) -> dict[str, Any]:
122
+ """New tokens for a live session (Keycloak rotates the refresh token too).
123
+
124
+ Raises:
125
+ KeycloakLoginError: the refresh token is expired, revoked or malformed.
126
+ """
127
+ return await self._token_grant(
128
+ {"grant_type": "refresh_token", "refresh_token": refresh_token}
129
+ )
130
+
131
+ async def logout(self, refresh_token: str) -> None:
132
+ """End the session behind ``refresh_token``. An already dead session is fine."""
133
+ await self._request(
134
+ "POST",
135
+ self.config.keycloak.logout_url,
136
+ data={
137
+ "client_id": self.config.client_id,
138
+ "client_secret": self.config.client_secret,
139
+ "refresh_token": refresh_token,
140
+ },
141
+ )
142
+
143
+ async def check_password(self, username: str, password: str) -> bool:
144
+ """Whether ``password`` is the user's current one; the session this opens is closed again."""
145
+ try:
146
+ tokens = await self.login(username, password)
147
+ except PasswordChangeRequiredError:
148
+ return True
149
+ except KeycloakLoginError:
150
+ return False
151
+ if refresh_token := tokens.get("refresh_token"):
152
+ await self.logout(refresh_token)
153
+ return True
154
+
155
+ async def complete_initial_password(
156
+ self, username: str, password: str, new_password: str
157
+ ) -> dict[str, Any]:
158
+ """Swap a temporary password for a permanent one and log in with it.
159
+
160
+ Raises:
161
+ KeycloakLoginError: ``password`` is wrong.
162
+ KeycloakAdminError: the account has no temporary password (``conflict``), or the
163
+ new one breaks the realm's password policy (``invalid``).
164
+ """
165
+ try:
166
+ tokens = await self.login(username, password)
167
+ except PasswordChangeRequiredError:
168
+ subject = await self.find_user_id(username)
169
+ if subject is None:
170
+ raise KeycloakLoginError("Invalid credentials") from None
171
+ await self.set_password(subject, new_password)
172
+ await self.update_user(subject, requiredActions=[])
173
+ return await self.login(username, new_password)
174
+ if refresh_token := tokens.get("refresh_token"):
175
+ await self.logout(refresh_token)
176
+ raise KeycloakAdminError("This account has no temporary password", conflict=True)
177
+
178
+ async def signing_keys(self) -> dict[str, Any]:
179
+ """The realm's JWKS, which access tokens are verified against."""
180
+ response = await self._request(
181
+ "GET", self.config.keycloak.jwks_url, headers={"Accept": "application/json"}
182
+ )
183
+ if response.status != HTTPStatus.OK:
184
+ raise KeycloakUnavailableError(f"Keycloak JWKS endpoint failed: {response.status}")
185
+ try:
186
+ return response.json()
187
+ except ValueError as exc:
188
+ raise KeycloakUnavailableError("Keycloak returned a malformed JWKS") from exc
189
+
190
+ # --- Admin REST API -------------------------------------------------------------------
191
+
192
+ async def _fetch_admin_token(self) -> str:
193
+ if self._admin_token and time.monotonic() < self._admin_token_expires_at:
194
+ return self._admin_token
195
+ if self.config.admin_username and self.config.admin_password:
196
+ url = self.config.master_token_url
197
+ data = {
198
+ "grant_type": "password",
199
+ "client_id": "admin-cli",
200
+ "username": self.config.admin_username,
201
+ "password": self.config.admin_password,
202
+ }
203
+ else:
204
+ url = self.config.keycloak.token_url
205
+ data = {
206
+ "grant_type": "client_credentials",
207
+ "client_id": self.config.client_id,
208
+ "client_secret": self.config.client_secret,
209
+ }
210
+ response = await self._request("POST", url, data=data)
211
+ if response.status != HTTPStatus.OK:
212
+ raise KeycloakAdminError(f"Could not obtain a Keycloak admin token: {response.text}")
213
+ payload = response.json()
214
+ self._admin_token = token = payload["access_token"]
215
+ self._admin_token_expires_at = (
216
+ time.monotonic() + payload.get("expires_in", 60) - _ADMIN_TOKEN_LEEWAY_SECONDS
217
+ )
218
+ return token
219
+
220
+ async def _admin(self, method: str, path: str, **kwargs: Any) -> _Response: # noqa: ANN401
221
+ token = await self._fetch_admin_token()
222
+ response = await self._request(
223
+ method,
224
+ self.config.admin_realm_url + path,
225
+ headers={"Authorization": f"Bearer {token}"},
226
+ **kwargs,
227
+ )
228
+ if response.status == HTTPStatus.UNAUTHORIZED:
229
+ # Revoked or expired early (e.g. Keycloak restarted) - drop it so the next call re-fetches.
230
+ self._admin_token = None
231
+ return response
232
+
233
+ async def list_roles(self) -> list[dict[str, Any]]:
234
+ """The realm's own roles (``name``, ``description``), without Keycloak's built-in ones."""
235
+ found = await self._admin("GET", "/roles", params={"briefRepresentation": "true"})
236
+ if found.status != HTTPStatus.OK:
237
+ raise KeycloakAdminError(f"Could not list realm roles: {found.text}")
238
+ default_roles = f"default-roles-{self.config.keycloak.realm}"
239
+ return sorted(
240
+ (
241
+ role
242
+ for role in found.json()
243
+ if role["name"] not in BUILT_IN_ROLES and role["name"] != default_roles
244
+ ),
245
+ key=lambda role: role["name"],
246
+ )
247
+
248
+ async def create_user(
249
+ self,
250
+ *,
251
+ username: str,
252
+ password: str,
253
+ email: str | None = None,
254
+ first_name: str | None = None,
255
+ last_name: str | None = None,
256
+ roles: Iterable[str] = (),
257
+ temporary_password: bool = True,
258
+ ) -> str:
259
+ """Create a realm user, assign ``roles`` and return the new subject id. A temporary
260
+ password must be changed at first login (see ``complete_initial_password``)."""
261
+ role_representations = [await self._role(role) for role in roles]
262
+ created = await self._admin(
263
+ "POST",
264
+ "/users",
265
+ json={
266
+ "username": username,
267
+ "email": email,
268
+ "firstName": first_name,
269
+ "lastName": last_name,
270
+ "enabled": True,
271
+ "emailVerified": email is not None,
272
+ "requiredActions": ["UPDATE_PASSWORD"] if temporary_password else [],
273
+ "credentials": [
274
+ {"type": "password", "value": password, "temporary": temporary_password}
275
+ ],
276
+ },
277
+ )
278
+ if created.status == HTTPStatus.CONFLICT:
279
+ raise KeycloakAdminError(f"User {username!r} already exists", conflict=True)
280
+ if created.status == HTTPStatus.BAD_REQUEST:
281
+ raise KeycloakAdminError(
282
+ created.error_description() or "User rejected by Keycloak", invalid=True
283
+ )
284
+ if created.status != HTTPStatus.CREATED:
285
+ raise KeycloakAdminError(f"Could not create Keycloak user: {created.text}")
286
+ subject = created.headers["Location"].rsplit("/", 1)[-1]
287
+ if role_representations:
288
+ assigned = await self._admin(
289
+ "POST", f"/users/{subject}/role-mappings/realm", json=role_representations
290
+ )
291
+ if assigned.status != HTTPStatus.NO_CONTENT:
292
+ raise KeycloakAdminError(f"Could not assign roles: {assigned.text}")
293
+ return subject
294
+
295
+ async def _role(self, role: str) -> dict[str, Any]:
296
+ found = await self._admin("GET", f"/roles/{role}")
297
+ if found.status == HTTPStatus.NOT_FOUND:
298
+ raise KeycloakAdminError(f"Unknown realm role {role!r}", invalid=True)
299
+ if found.status != HTTPStatus.OK:
300
+ raise KeycloakAdminError(f"Could not look up role {role!r}: {found.text}")
301
+ return found.json()
302
+
303
+ async def update_user(self, subject: str, **representation: Any) -> None: # noqa: ANN401
304
+ """Partial update with Keycloak's own ``UserRepresentation`` field names
305
+ (``firstName``, ``email``, ``requiredActions``, ...)."""
306
+ updated = await self._admin("PUT", f"/users/{subject}", json=representation)
307
+ if updated.status == HTTPStatus.CONFLICT:
308
+ raise KeycloakAdminError(
309
+ "This e-mail is already used by another account", conflict=True
310
+ )
311
+ if updated.status == HTTPStatus.BAD_REQUEST:
312
+ raise KeycloakAdminError(
313
+ updated.error_description() or "Update rejected by Keycloak", invalid=True
314
+ )
315
+ if updated.status != HTTPStatus.NO_CONTENT:
316
+ raise KeycloakAdminError(f"Could not update Keycloak user: {updated.text}")
317
+
318
+ async def set_password(self, subject: str, password: str, *, temporary: bool = False) -> None:
319
+ """Set the user's password; a ``temporary`` one has to be replaced at the next login.
320
+
321
+ Raises:
322
+ KeycloakAdminError: the password breaks the realm's password policy (``invalid``).
323
+ """
324
+ reset = await self._admin(
325
+ "PUT",
326
+ f"/users/{subject}/reset-password",
327
+ json={"type": "password", "value": password, "temporary": temporary},
328
+ )
329
+ if reset.status == HTTPStatus.BAD_REQUEST:
330
+ detail = reset.error_description() or "Password rejected by the password policy"
331
+ raise KeycloakAdminError(detail, invalid=True)
332
+ if reset.status != HTTPStatus.NO_CONTENT:
333
+ raise KeycloakAdminError(f"Could not set Keycloak password: {reset.text}")
334
+
335
+ async def get_user(self, subject: str) -> dict[str, Any] | None:
336
+ """The full ``UserRepresentation`` (attributes included), or ``None``."""
337
+ found = await self._admin("GET", f"/users/{subject}")
338
+ if found.status == HTTPStatus.NOT_FOUND:
339
+ return None
340
+ if found.status != HTTPStatus.OK:
341
+ raise KeycloakAdminError(f"Could not read Keycloak user: {found.text}")
342
+ return found.json()
343
+
344
+ async def set_user_attribute(self, subject: str, name: str, values: list[str]) -> None:
345
+ """Replace one user attribute and keep the others.
346
+
347
+ Keycloak replaces the whole ``attributes`` map (and drops omitted fields) on update,
348
+ so the user is read first and sent back merged.
349
+ """
350
+ user = await self.get_user(subject)
351
+ if user is None:
352
+ raise KeycloakAdminError(f"User {subject!r} not found", invalid=True)
353
+ attributes = {**(user.get("attributes") or {}), name: values}
354
+ await self.update_user(subject, **{**user, "attributes": attributes})
355
+
356
+ async def find_user_id(self, username: str) -> str | None:
357
+ """The subject id of the user with exactly this username, or ``None``."""
358
+ found = await self._admin("GET", "/users", params={"username": username, "exact": "true"})
359
+ if found.status != HTTPStatus.OK:
360
+ raise KeycloakAdminError(f"Could not look up user {username!r}: {found.text}")
361
+ users = found.json()
362
+ return users[0]["id"] if users else None
@@ -0,0 +1,179 @@
1
+ import os
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass, field, replace
4
+ from typing import Any, Literal
5
+
6
+ from fastapi_keycloak_admin.verification import LoginVerificationConfig
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class KeycloakConfig:
11
+ """Where Keycloak is, which client the backend uses, and how access tokens are checked."""
12
+
13
+ server_url: str
14
+ """Where the backend reaches Keycloak, e.g. ``http://keycloak:8080``."""
15
+ realm: str
16
+ client_id: str
17
+ """The confidential client used for the password grant; also the expected audience."""
18
+ client_secret: str
19
+
20
+ expected_issuer: str | None = None
21
+ """Expected ``iss``; defaults to ``realm_url``. Set it when users reach Keycloak at another
22
+ URL than ``server_url``, e.g. behind a reverse proxy."""
23
+ audience: str | None = None
24
+ """Expected ``aud``; defaults to ``client_id``."""
25
+ optional_audiences: frozenset[str] = frozenset()
26
+ """More audiences to accept, e.g. other clients calling this API."""
27
+ strict_audience: bool = False
28
+ """Accept a token only when ``aud`` holds an accepted audience. By default a token whose
29
+ ``azp`` is accepted passes too, since Keycloak access tokens often lack the client's own
30
+ audience."""
31
+ expected_token_type: str | None = "Bearer"
32
+ """Required ``typ`` claim, which rejects ID and refresh tokens; ``None`` skips the check."""
33
+ algorithms: tuple[str, ...] = ("RS256",)
34
+ jwks_cache_ttl: int = 3600
35
+ """Seconds Keycloak's signing keys are cached; a token with an unknown key fetches them
36
+ again sooner."""
37
+
38
+ def __post_init__(self) -> None:
39
+ if not self.client_secret:
40
+ raise ValueError("KeycloakConfig.client_secret is required")
41
+ if self.jwks_cache_ttl < 0:
42
+ raise ValueError("jwks_cache_ttl must not be negative")
43
+
44
+ @property
45
+ def realm_url(self) -> str:
46
+ return f"{self.server_url.rstrip('/')}/realms/{self.realm}"
47
+
48
+ @property
49
+ def issuer(self) -> str:
50
+ return self.expected_issuer or self.realm_url
51
+
52
+ @property
53
+ def accepted_audiences(self) -> frozenset[str]:
54
+ return frozenset({self.audience or self.client_id}) | self.optional_audiences
55
+
56
+ @property
57
+ def token_url(self) -> str:
58
+ return f"{self.realm_url}/protocol/openid-connect/token"
59
+
60
+ @property
61
+ def logout_url(self) -> str:
62
+ return f"{self.realm_url}/protocol/openid-connect/logout"
63
+
64
+ @property
65
+ def jwks_url(self) -> str:
66
+ return f"{self.realm_url}/protocol/openid-connect/certs"
67
+
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class RefreshCookieConfig:
71
+ """Where the session endpoints keep the refresh token.
72
+
73
+ With ``enabled`` the token lives in an ``httponly`` cookie only and is never returned in
74
+ a response body - what a browser frontend wants. Disable it for non-browser clients:
75
+ the token is then returned in the body and must be sent back in ``refresh_token``.
76
+ """
77
+
78
+ enabled: bool = True
79
+ name: str = "kc_refresh"
80
+ path: str | None = None
81
+ """Defaults to ``auth_path``. Set it when a reverse proxy serves the API under a
82
+ prefix (e.g. ``/api/auth``), or the browser won't send the cookie back."""
83
+ secure: bool = True
84
+ samesite: Literal["lax", "strict", "none"] = "strict"
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class KeycloakAdminConfig:
89
+ """Settings of ``KeycloakAdmin``.
90
+
91
+ Usually built with ``from_env``; build it directly to pass a ``KeycloakConfig`` of your own.
92
+ """
93
+
94
+ keycloak: KeycloakConfig
95
+ """Connection and token validation settings; its ``client_id`` and ``client_secret`` are
96
+ used for every call made here."""
97
+
98
+ admin_username: str | None = None
99
+ admin_password: str | None = None
100
+ """Master-realm admin used for the Admin REST API. Leave both unset to use the client's
101
+ own service account instead (``client_credentials``; give it the ``realm-management``
102
+ roles ``manage-users`` and ``view-realm``)."""
103
+
104
+ admin_role: str = "admin"
105
+ """Realm role allowed to register users and list roles."""
106
+ assignable_roles: tuple[str, ...] = ()
107
+ """Realm roles registration may assign. Empty (the default) allows every role the realm
108
+ defines, so new roles only need to be added in Keycloak."""
109
+
110
+ auth_path: str = "/auth"
111
+ """Path the session and account endpoints are mounted under."""
112
+ refresh_cookie: RefreshCookieConfig = field(default_factory=RefreshCookieConfig)
113
+ min_password_length: int = 8
114
+ """Checked before a password reaches Keycloak, whose own password policy applies too."""
115
+ timeout: float = 10
116
+ """Seconds allowed for each request to Keycloak."""
117
+ login_verification: LoginVerificationConfig | None = None
118
+ """Second login step with a one-time code; off when ``None``."""
119
+
120
+ @classmethod
121
+ def from_env(
122
+ cls,
123
+ prefix: str = "KEYCLOAK_",
124
+ *,
125
+ keycloak: Mapping[str, Any] | None = None,
126
+ **overrides: Any, # noqa: ANN401
127
+ ) -> "KeycloakAdminConfig":
128
+ """Read ``{prefix}INTERNAL_URL``, ``REALM``, ``CLIENT_ID``, ``CLIENT_SECRET`` (required) and
129
+ ``ISSUER``, ``AUDIENCE``, ``ADMIN``, ``ADMIN_PASSWORD``, ``REFRESH_COOKIE_PATH`` (optional).
130
+
131
+ ``keycloak`` holds extra ``KeycloakConfig`` arguments (e.g. ``optional_audiences``);
132
+ ``overrides`` win over the environment for this config.
133
+ """
134
+
135
+ def required(name: str) -> str:
136
+ value = os.environ.get(prefix + name)
137
+ if not value:
138
+ raise ValueError(f"{prefix}{name} is required")
139
+ return value
140
+
141
+ def optional(name: str) -> str | None:
142
+ return os.environ.get(prefix + name) or None
143
+
144
+ keycloak_config = KeycloakConfig(
145
+ server_url=required("INTERNAL_URL"),
146
+ realm=required("REALM"),
147
+ client_id=required("CLIENT_ID"),
148
+ client_secret=required("CLIENT_SECRET"),
149
+ expected_issuer=optional("ISSUER"),
150
+ audience=optional("AUDIENCE"),
151
+ **(keycloak or {}),
152
+ )
153
+ config = cls(
154
+ keycloak=keycloak_config,
155
+ admin_username=optional("ADMIN"),
156
+ admin_password=optional("ADMIN_PASSWORD"),
157
+ refresh_cookie=RefreshCookieConfig(path=optional("REFRESH_COOKIE_PATH")),
158
+ )
159
+ return replace(config, **overrides)
160
+
161
+ @property
162
+ def client_id(self) -> str:
163
+ return self.keycloak.client_id
164
+
165
+ @property
166
+ def client_secret(self) -> str:
167
+ return self.keycloak.client_secret
168
+
169
+ @property
170
+ def admin_realm_url(self) -> str:
171
+ return f"{self.keycloak.server_url.rstrip('/')}/admin/realms/{self.keycloak.realm}"
172
+
173
+ @property
174
+ def master_token_url(self) -> str:
175
+ return f"{self.keycloak.server_url.rstrip('/')}/realms/master/protocol/openid-connect/token"
176
+
177
+ @property
178
+ def cookie_path(self) -> str:
179
+ return self.refresh_cookie.path or self.auth_path
@@ -0,0 +1,117 @@
1
+ """Dependencies for routes: the caller (``CurrentUser``), role and scope checks, and the
2
+ application's ``KeycloakAdminClient``."""
3
+
4
+ import enum
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import TYPE_CHECKING, Annotated
7
+
8
+ from fastapi import Depends, Request
9
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
10
+
11
+ from fastapi_keycloak_admin.client import KeycloakAdminClient
12
+ from fastapi_keycloak_admin.exceptions import (
13
+ InsufficientRoleError,
14
+ InsufficientScopeError,
15
+ MissingTokenError,
16
+ )
17
+ from fastapi_keycloak_admin.models import KeycloakUser
18
+
19
+ if TYPE_CHECKING:
20
+ from fastapi_keycloak_admin.integration import KeycloakAdmin
21
+
22
+ STATE_KEY = "keycloak_admin"
23
+ """``app.state`` attribute holding the application's ``KeycloakAdmin``."""
24
+
25
+ _bearer = HTTPBearer(auto_error=False, description="Keycloak access token")
26
+
27
+
28
+ def get_keycloak(request: Request) -> "KeycloakAdmin":
29
+ keycloak = getattr(request.app.state, STATE_KEY, None)
30
+ if keycloak is None:
31
+ raise RuntimeError("KeycloakAdmin.init_app(app) has not been called")
32
+ return keycloak
33
+
34
+
35
+ def get_keycloak_admin(request: Request) -> KeycloakAdminClient:
36
+ return get_keycloak(request).client
37
+
38
+
39
+ async def get_current_user(
40
+ request: Request,
41
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
42
+ ) -> KeycloakUser:
43
+ """The caller of a route; raises ``AuthenticationError`` (``401``) without a valid token."""
44
+ if credentials is None:
45
+ raise MissingTokenError("Authentication required")
46
+ claims = await get_keycloak(request).verifier.verify(credentials.credentials)
47
+ return KeycloakUser.from_claims(claims)
48
+
49
+
50
+ CurrentUser = Annotated[KeycloakUser, Depends(get_current_user)]
51
+ """The authenticated caller: ``async def route(user: CurrentUser)``."""
52
+
53
+ KeycloakAdminDep = Annotated[KeycloakAdminClient, Depends(get_keycloak_admin)]
54
+ """The application's ``KeycloakAdminClient``."""
55
+
56
+
57
+ class MatchStrategy(enum.Enum):
58
+ ALL = "all"
59
+ """Every required value."""
60
+ ANY = "any"
61
+ """At least one of them."""
62
+
63
+
64
+ def _satisfied(required: frozenset[str], actual: frozenset[str], strategy: MatchStrategy) -> bool:
65
+ return required <= actual if strategy is MatchStrategy.ALL else bool(required & actual)
66
+
67
+
68
+ UserDependency = Callable[[KeycloakUser], Awaitable[KeycloakUser]]
69
+
70
+
71
+ def require_roles(*roles: str, strategy: MatchStrategy = MatchStrategy.ALL) -> UserDependency:
72
+ """A dependency that admits callers holding the realm ``roles`` and returns the caller::
73
+
74
+ @app.get("/staff", dependencies=[Depends(require_roles("admin", "manager",
75
+ strategy=MatchStrategy.ANY))])
76
+ """
77
+ required = frozenset(roles)
78
+
79
+ async def dependency(user: CurrentUser) -> KeycloakUser:
80
+ if not _satisfied(required, user.realm_roles, strategy):
81
+ raise InsufficientRoleError("Insufficient permissions")
82
+ return user
83
+
84
+ return dependency
85
+
86
+
87
+ def require_client_roles(
88
+ client_id: str, *roles: str, strategy: MatchStrategy = MatchStrategy.ALL
89
+ ) -> UserDependency:
90
+ """Like ``require_roles``, for roles of the client ``client_id``."""
91
+ required = frozenset(roles)
92
+
93
+ async def dependency(user: CurrentUser) -> KeycloakUser:
94
+ if not _satisfied(required, user.client_roles.get(client_id, frozenset()), strategy):
95
+ raise InsufficientRoleError("Insufficient permissions")
96
+ return user
97
+
98
+ return dependency
99
+
100
+
101
+ def require_scopes(*scopes: str, strategy: MatchStrategy = MatchStrategy.ALL) -> UserDependency:
102
+ """Like ``require_roles``, for scopes of the token."""
103
+ required = frozenset(scopes)
104
+
105
+ async def dependency(user: CurrentUser) -> KeycloakUser:
106
+ if not _satisfied(required, user.scopes, strategy):
107
+ raise InsufficientScopeError("Insufficient scope")
108
+ return user
109
+
110
+ return dependency
111
+
112
+
113
+ async def requires_admin(request: Request, user: CurrentUser) -> KeycloakUser:
114
+ """The caller holds the configured ``admin_role``."""
115
+ if get_keycloak(request).config.admin_role not in user.realm_roles:
116
+ raise InsufficientRoleError("Insufficient permissions")
117
+ return user