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.
- fastapi_keycloak_admin/__init__.py +102 -0
- fastapi_keycloak_admin/challenges.py +226 -0
- fastapi_keycloak_admin/cli.py +73 -0
- fastapi_keycloak_admin/client.py +362 -0
- fastapi_keycloak_admin/config.py +179 -0
- fastapi_keycloak_admin/dependencies.py +117 -0
- fastapi_keycloak_admin/errors.py +267 -0
- fastapi_keycloak_admin/exceptions.py +88 -0
- fastapi_keycloak_admin/integration.py +71 -0
- fastapi_keycloak_admin/models.py +53 -0
- fastapi_keycloak_admin/preferences.py +44 -0
- fastapi_keycloak_admin/py.typed +0 -0
- fastapi_keycloak_admin/routes.py +419 -0
- fastapi_keycloak_admin/stores.py +57 -0
- fastapi_keycloak_admin/token.py +134 -0
- fastapi_keycloak_admin/verification.py +63 -0
- fastapi_keycloak_admin-0.1.0.dist-info/METADATA +607 -0
- fastapi_keycloak_admin-0.1.0.dist-info/RECORD +21 -0
- fastapi_keycloak_admin-0.1.0.dist-info/WHEEL +4 -0
- fastapi_keycloak_admin-0.1.0.dist-info/entry_points.txt +3 -0
- fastapi_keycloak_admin-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|