simple-module-keycloak 0.0.24__tar.gz
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.
- simple_module_keycloak-0.0.24/.gitignore +64 -0
- simple_module_keycloak-0.0.24/PKG-INFO +59 -0
- simple_module_keycloak-0.0.24/README.md +30 -0
- simple_module_keycloak-0.0.24/keycloak/__init__.py +1 -0
- simple_module_keycloak-0.0.24/keycloak/contracts/__init__.py +1 -0
- simple_module_keycloak-0.0.24/keycloak/endpoints/__init__.py +1 -0
- simple_module_keycloak-0.0.24/keycloak/endpoints/api.py +92 -0
- simple_module_keycloak-0.0.24/keycloak/endpoints/views.py +40 -0
- simple_module_keycloak-0.0.24/keycloak/jwks.py +113 -0
- simple_module_keycloak-0.0.24/keycloak/locales/en.json +15 -0
- simple_module_keycloak-0.0.24/keycloak/models.py +21 -0
- simple_module_keycloak-0.0.24/keycloak/module.py +83 -0
- simple_module_keycloak-0.0.24/keycloak/oidc.py +85 -0
- simple_module_keycloak-0.0.24/keycloak/pages/LoggedOut.tsx +13 -0
- simple_module_keycloak-0.0.24/keycloak/pages/Login.tsx +14 -0
- simple_module_keycloak-0.0.24/keycloak/provider.py +138 -0
- simple_module_keycloak-0.0.24/keycloak/settings.py +49 -0
- simple_module_keycloak-0.0.24/keycloak/state.py +18 -0
- simple_module_keycloak-0.0.24/package.json +7 -0
- simple_module_keycloak-0.0.24/pyproject.toml +54 -0
- simple_module_keycloak-0.0.24/tests/conftest.py +1 -0
- simple_module_keycloak-0.0.24/tests/test_jwks.py +157 -0
- simple_module_keycloak-0.0.24/tests/test_keycloak_module.py +22 -0
- simple_module_keycloak-0.0.24/tests/test_keycloak_provider.py +94 -0
- simple_module_keycloak-0.0.24/tests/test_oidc.py +70 -0
- simple_module_keycloak-0.0.24/tsconfig.json +4 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# VitePress (docs)
|
|
40
|
+
docs/.vitepress/cache/
|
|
41
|
+
docs/.vitepress/dist/
|
|
42
|
+
|
|
43
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
44
|
+
# or via `make gen-pages`).
|
|
45
|
+
host/client_app/modules.manifest.json
|
|
46
|
+
host/client_app/modules.generated.ts
|
|
47
|
+
host/client_app/modules.generated.css
|
|
48
|
+
|
|
49
|
+
# Worktrees
|
|
50
|
+
.worktrees/
|
|
51
|
+
|
|
52
|
+
# Performance profiles
|
|
53
|
+
.memray/
|
|
54
|
+
.benchmarks/
|
|
55
|
+
|
|
56
|
+
# OS
|
|
57
|
+
.DS_Store
|
|
58
|
+
Thumbs.db
|
|
59
|
+
|
|
60
|
+
.playwright-cli/*
|
|
61
|
+
.playwright-mcp/*
|
|
62
|
+
host/client_app/.playwright-cli/*
|
|
63
|
+
.superpowers/
|
|
64
|
+
.qa/
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_keycloak
|
|
3
|
+
Version: 0.0.24
|
|
4
|
+
Summary: Keycloak OIDC authentication provider for simple_module — swap with simple_module_users
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: authentication,fastapi,keycloak,oidc,simple-module
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pyjwt[crypto]>=2.8
|
|
23
|
+
Requires-Dist: simple-module-auth==0.0.24
|
|
24
|
+
Requires-Dist: simple-module-core==0.0.24
|
|
25
|
+
Requires-Dist: simple-module-db==0.0.24
|
|
26
|
+
Requires-Dist: simple-module-hosting==0.0.24
|
|
27
|
+
Requires-Dist: simple-module-settings==0.0.24
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# simple_module_keycloak
|
|
31
|
+
|
|
32
|
+
Keycloak OIDC authentication provider for simple_module. Swap with `simple_module_users` for Keycloak-backed identity management — users, roles, and login all handled by Keycloak.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Add to your app's `pyproject.toml` dependencies instead of `simple_module_users`:
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
dependencies = [
|
|
40
|
+
"simple_module_keycloak==0.0.17",
|
|
41
|
+
]
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run `uv sync --all-packages` to install.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Set the required environment variables (or configure via the settings admin UI after first boot):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
SM_KEYCLOAK_SERVER_URL=https://keycloak.example.com
|
|
52
|
+
SM_KEYCLOAK_REALM=my-realm
|
|
53
|
+
SM_KEYCLOAK_CLIENT_ID=my-app
|
|
54
|
+
SM_KEYCLOAK_CLIENT_SECRET=my-secret
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The module auto-registers as the auth provider. Browser users are redirected to Keycloak's hosted login page. Mobile clients authenticate directly with Keycloak and send bearer tokens to the framework API.
|
|
58
|
+
|
|
59
|
+
Keycloak realm roles are mapped to framework permissions via the `role_mapping` setting (default: `admin` and `user` map 1:1).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# simple_module_keycloak
|
|
2
|
+
|
|
3
|
+
Keycloak OIDC authentication provider for simple_module. Swap with `simple_module_users` for Keycloak-backed identity management — users, roles, and login all handled by Keycloak.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Add to your app's `pyproject.toml` dependencies instead of `simple_module_users`:
|
|
8
|
+
|
|
9
|
+
```toml
|
|
10
|
+
dependencies = [
|
|
11
|
+
"simple_module_keycloak==0.0.17",
|
|
12
|
+
]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run `uv sync --all-packages` to install.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Set the required environment variables (or configure via the settings admin UI after first boot):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
SM_KEYCLOAK_SERVER_URL=https://keycloak.example.com
|
|
23
|
+
SM_KEYCLOAK_REALM=my-realm
|
|
24
|
+
SM_KEYCLOAK_CLIENT_ID=my-app
|
|
25
|
+
SM_KEYCLOAK_CLIENT_SECRET=my-secret
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The module auto-registers as the auth provider. Browser users are redirected to Keycloak's hosted login page. Mobile clients authenticate directly with Keycloak and send bearer tokens to the framework API.
|
|
29
|
+
|
|
30
|
+
Keycloak realm roles are mapped to framework permissions via the `role_mapping` setting (default: `admin` and `user` map 1:1).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Keycloak OIDC authentication provider for simple_module."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Keycloak module contracts."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Keycloak endpoint routers."""
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Keycloak OIDC API endpoints — login redirect, callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
10
|
+
from starlette.responses import RedirectResponse
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from keycloak.settings import KeycloakSettings
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
router = APIRouter(prefix="/auth", tags=["keycloak-auth"])
|
|
17
|
+
|
|
18
|
+
_SESSION_OIDC_STATE = "keycloak_oidc_state"
|
|
19
|
+
_SESSION_OIDC_NONCE = "keycloak_oidc_nonce"
|
|
20
|
+
_SESSION_USER_CTX = "user_ctx"
|
|
21
|
+
_SESSION_ID_TOKEN = "keycloak_id_token"
|
|
22
|
+
_SESSION_NEXT = "next"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_settings(request: Request) -> KeycloakSettings:
|
|
26
|
+
return request.app.state.keycloak.settings
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_oidc_client(request: Request):
|
|
30
|
+
from keycloak.oidc import OIDCClient
|
|
31
|
+
|
|
32
|
+
s = _get_settings(request)
|
|
33
|
+
return OIDCClient(
|
|
34
|
+
server_url=s.server_url,
|
|
35
|
+
realm=s.realm,
|
|
36
|
+
client_id=s.client_id,
|
|
37
|
+
client_secret=s.client_secret,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/login")
|
|
42
|
+
async def oidc_login(request: Request):
|
|
43
|
+
client = _get_oidc_client(request)
|
|
44
|
+
callback_url = str(request.url_for("oidc_callback"))
|
|
45
|
+
nonce = secrets.token_urlsafe(32)
|
|
46
|
+
url, state = client.build_authorization_url(
|
|
47
|
+
redirect_uri=callback_url,
|
|
48
|
+
nonce=nonce,
|
|
49
|
+
)
|
|
50
|
+
request.session[_SESSION_OIDC_STATE] = state
|
|
51
|
+
request.session[_SESSION_OIDC_NONCE] = nonce
|
|
52
|
+
return RedirectResponse(url, status_code=302)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@router.get("/callback")
|
|
56
|
+
async def oidc_callback(request: Request):
|
|
57
|
+
code = request.query_params.get("code")
|
|
58
|
+
state = request.query_params.get("state")
|
|
59
|
+
|
|
60
|
+
expected_state = request.session.pop(_SESSION_OIDC_STATE, None)
|
|
61
|
+
request.session.pop(_SESSION_OIDC_NONCE, None)
|
|
62
|
+
|
|
63
|
+
if not code or not state or state != expected_state:
|
|
64
|
+
raise HTTPException(status_code=400, detail="Invalid OIDC state")
|
|
65
|
+
|
|
66
|
+
client = _get_oidc_client(request)
|
|
67
|
+
callback_url = str(request.url_for("oidc_callback"))
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
tokens = await client.exchange_code(code=code, redirect_uri=callback_url)
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.exception("Token exchange failed")
|
|
73
|
+
raise HTTPException(status_code=502, detail="Token exchange failed") from None
|
|
74
|
+
|
|
75
|
+
id_token = tokens.get("id_token", "")
|
|
76
|
+
access_token = tokens.get("access_token", "")
|
|
77
|
+
|
|
78
|
+
jwks_cache = request.app.state.keycloak.jwks_cache
|
|
79
|
+
claims = await jwks_cache.validate_jwt(access_token) if jwks_cache else None
|
|
80
|
+
if claims is None:
|
|
81
|
+
raise HTTPException(status_code=401, detail="Token validation failed")
|
|
82
|
+
|
|
83
|
+
provider = request.app.state.auth.auth_provider
|
|
84
|
+
cache_id = await provider._upsert_user_cache(request, claims)
|
|
85
|
+
user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id)
|
|
86
|
+
|
|
87
|
+
request.session[_SESSION_USER_CTX] = user_ctx.to_session_dict()
|
|
88
|
+
request.session[_SESSION_ID_TOKEN] = id_token
|
|
89
|
+
|
|
90
|
+
s = _get_settings(request)
|
|
91
|
+
next_url = request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url
|
|
92
|
+
return RedirectResponse(next_url, status_code=303)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Keycloak Inertia view routes — login page, logout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Request
|
|
6
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
7
|
+
from starlette.responses import RedirectResponse
|
|
8
|
+
|
|
9
|
+
router = APIRouter(tags=["keycloak-views"])
|
|
10
|
+
|
|
11
|
+
_SESSION_ID_TOKEN = "keycloak_id_token"
|
|
12
|
+
_PAGE_LOGIN = "Keycloak/Login"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get("/login")
|
|
16
|
+
async def login_page(request: Request, inertia: InertiaDep):
|
|
17
|
+
return await inertia.render(_PAGE_LOGIN)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("/logout")
|
|
21
|
+
async def logout(request: Request):
|
|
22
|
+
from keycloak.oidc import OIDCClient
|
|
23
|
+
|
|
24
|
+
s = request.app.state.keycloak.settings
|
|
25
|
+
id_token = request.session.get(_SESSION_ID_TOKEN)
|
|
26
|
+
|
|
27
|
+
request.session.clear()
|
|
28
|
+
|
|
29
|
+
client = OIDCClient(
|
|
30
|
+
server_url=s.server_url,
|
|
31
|
+
realm=s.realm,
|
|
32
|
+
client_id=s.client_id,
|
|
33
|
+
client_secret=s.client_secret,
|
|
34
|
+
)
|
|
35
|
+
base_url = str(request.base_url).rstrip("/")
|
|
36
|
+
logout_url = client.build_logout_url(
|
|
37
|
+
post_logout_redirect_uri=f"{base_url}/keycloak/login",
|
|
38
|
+
id_token_hint=id_token,
|
|
39
|
+
)
|
|
40
|
+
return RedirectResponse(logout_url, status_code=303)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""JWKS key cache and JWT validation for Keycloak tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import jwt
|
|
11
|
+
from jwt.algorithms import RSAAlgorithm
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class JWKSCache:
|
|
17
|
+
"""Caches Keycloak's public signing keys and validates JWTs.
|
|
18
|
+
|
|
19
|
+
On validation failure with cached keys, refetches JWKS once before
|
|
20
|
+
rejecting -- this handles Keycloak key rotation gracefully.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
jwks_url: str,
|
|
26
|
+
ttl_seconds: int = 3600,
|
|
27
|
+
*,
|
|
28
|
+
issuer: str,
|
|
29
|
+
audience: str,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not issuer:
|
|
32
|
+
raise ValueError("issuer is required for JWT validation")
|
|
33
|
+
if not audience:
|
|
34
|
+
raise ValueError("audience is required for JWT validation")
|
|
35
|
+
self._jwks_url = jwks_url
|
|
36
|
+
self._ttl = ttl_seconds
|
|
37
|
+
self._issuer = issuer
|
|
38
|
+
self._audience = audience
|
|
39
|
+
self._keys: dict[str, Any] = {}
|
|
40
|
+
self._fetched_at: float = 0
|
|
41
|
+
|
|
42
|
+
async def validate_jwt(self, token: str) -> dict[str, Any] | None:
|
|
43
|
+
"""Decode and validate a JWT. Returns claims dict or None."""
|
|
44
|
+
try:
|
|
45
|
+
unverified = jwt.get_unverified_header(token)
|
|
46
|
+
except jwt.exceptions.DecodeError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
kid = unverified.get("kid")
|
|
50
|
+
if kid is None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
key = await self._get_key(kid)
|
|
54
|
+
if key is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return self._decode(token, key)
|
|
58
|
+
|
|
59
|
+
def _decode(self, token: str, key: Any) -> dict[str, Any] | None:
|
|
60
|
+
try:
|
|
61
|
+
return jwt.decode(
|
|
62
|
+
token,
|
|
63
|
+
key,
|
|
64
|
+
algorithms=["RS256"],
|
|
65
|
+
issuer=self._issuer,
|
|
66
|
+
audience=self._audience,
|
|
67
|
+
)
|
|
68
|
+
except (
|
|
69
|
+
jwt.ExpiredSignatureError,
|
|
70
|
+
jwt.InvalidIssuerError,
|
|
71
|
+
jwt.InvalidAudienceError,
|
|
72
|
+
):
|
|
73
|
+
return None
|
|
74
|
+
except jwt.PyJWTError:
|
|
75
|
+
logger.exception("JWT validation failed")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
async def _get_key(self, kid: str) -> Any | None:
|
|
79
|
+
if self._is_stale() or kid not in self._keys:
|
|
80
|
+
await self._fetch_keys()
|
|
81
|
+
|
|
82
|
+
if kid in self._keys:
|
|
83
|
+
return self._keys[kid]
|
|
84
|
+
|
|
85
|
+
await self._fetch_keys(force=True)
|
|
86
|
+
return self._keys.get(kid)
|
|
87
|
+
|
|
88
|
+
def _is_stale(self) -> bool:
|
|
89
|
+
return time.monotonic() - self._fetched_at > self._ttl
|
|
90
|
+
|
|
91
|
+
async def _fetch_keys(self, *, force: bool = False) -> None:
|
|
92
|
+
if not force and not self._is_stale():
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
async with httpx.AsyncClient() as client:
|
|
96
|
+
resp = await client.get(self._jwks_url, timeout=10)
|
|
97
|
+
resp.raise_for_status()
|
|
98
|
+
jwks_data = resp.json()
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.exception("Failed to fetch JWKS from %s", self._jwks_url)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
new_keys: dict[str, Any] = {}
|
|
104
|
+
for key_data in jwks_data.get("keys", []):
|
|
105
|
+
kid = key_data.get("kid")
|
|
106
|
+
if kid and key_data.get("alg") == "RS256":
|
|
107
|
+
try:
|
|
108
|
+
public_key = RSAAlgorithm.from_jwk(key_data)
|
|
109
|
+
new_keys[kid] = public_key
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.warning("Failed to parse JWK kid=%s", kid)
|
|
112
|
+
self._keys = new_keys
|
|
113
|
+
self._fetched_at = time.monotonic()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"login": {
|
|
3
|
+
"redirecting": "Redirecting to identity provider…",
|
|
4
|
+
"title": "Sign In"
|
|
5
|
+
},
|
|
6
|
+
"logout": {
|
|
7
|
+
"title": "Signed Out",
|
|
8
|
+
"message": "You have been signed out successfully."
|
|
9
|
+
},
|
|
10
|
+
"errors": {
|
|
11
|
+
"callback_failed": "Authentication failed. Please try again.",
|
|
12
|
+
"invalid_state": "Invalid authentication state. Please try again.",
|
|
13
|
+
"token_validation_failed": "Token validation failed."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Keycloak user cache -- maps Keycloak sub to a stable framework UUID."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid as uuid_mod
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from simple_module_db.base import create_module_base
|
|
9
|
+
from sqlmodel import Field
|
|
10
|
+
|
|
11
|
+
Base = create_module_base("keycloak")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KeycloakUserCache(Base, table=True): # ty: ignore[unsupported-base]
|
|
15
|
+
__tablename__ = "keycloak_user_cache"
|
|
16
|
+
|
|
17
|
+
id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True)
|
|
18
|
+
keycloak_sub: str = Field(unique=True, index=True)
|
|
19
|
+
email: str = ""
|
|
20
|
+
full_name: str | None = None
|
|
21
|
+
last_login_at: datetime | None = None
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Keycloak OIDC authentication module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
10
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from fastapi import APIRouter, FastAPI
|
|
14
|
+
|
|
15
|
+
_MODULE_DEPENDENCY_AUTH = "Auth"
|
|
16
|
+
_MODULE_DEPENDENCY_SETTINGS = "Settings"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KeycloakModule(ModuleBase):
|
|
20
|
+
meta = ModuleMeta(
|
|
21
|
+
name="Keycloak",
|
|
22
|
+
route_prefix="/api/keycloak",
|
|
23
|
+
view_prefix="/keycloak",
|
|
24
|
+
depends_on=[_MODULE_DEPENDENCY_AUTH, _MODULE_DEPENDENCY_SETTINGS],
|
|
25
|
+
)
|
|
26
|
+
_is_auth_provider = True
|
|
27
|
+
|
|
28
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
29
|
+
import importlib
|
|
30
|
+
|
|
31
|
+
from keycloak.provider import KeycloakAuthProvider
|
|
32
|
+
from keycloak.settings import KeycloakSettings
|
|
33
|
+
from keycloak.state import KeycloakState
|
|
34
|
+
|
|
35
|
+
register_module_settings = importlib.import_module(
|
|
36
|
+
"settings.registration"
|
|
37
|
+
).register_module_settings
|
|
38
|
+
|
|
39
|
+
register_module_settings(
|
|
40
|
+
app,
|
|
41
|
+
"keycloak",
|
|
42
|
+
KeycloakSettings,
|
|
43
|
+
lambda s: KeycloakState(settings=s),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
app.state.auth.auth_provider = KeycloakAuthProvider(app.state.keycloak.settings)
|
|
47
|
+
|
|
48
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
49
|
+
registry.add(
|
|
50
|
+
MenuItem(
|
|
51
|
+
label="Logout",
|
|
52
|
+
url="/keycloak/logout",
|
|
53
|
+
icon="log-out",
|
|
54
|
+
order=999,
|
|
55
|
+
section=MenuSection.USER_DROPDOWN,
|
|
56
|
+
method="post",
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
61
|
+
from keycloak.endpoints.api import router as api
|
|
62
|
+
from keycloak.endpoints.views import router as views
|
|
63
|
+
|
|
64
|
+
api_router.include_router(api)
|
|
65
|
+
view_router.include_router(views)
|
|
66
|
+
|
|
67
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
68
|
+
from keycloak.jwks import JWKSCache
|
|
69
|
+
|
|
70
|
+
state = app.state.keycloak
|
|
71
|
+
s = state.settings
|
|
72
|
+
if s.server_url and s.realm:
|
|
73
|
+
state.jwks_cache = JWKSCache(
|
|
74
|
+
jwks_url=(f"{s.server_url}/realms/{s.realm}/protocol/openid-connect/certs"),
|
|
75
|
+
ttl_seconds=s.jwks_cache_ttl_seconds,
|
|
76
|
+
issuer=f"{s.server_url}/realms/{s.realm}",
|
|
77
|
+
audience=s.client_id,
|
|
78
|
+
)
|
|
79
|
+
provider = app.state.auth.auth_provider
|
|
80
|
+
provider.jwks_cache = state.jwks_cache
|
|
81
|
+
|
|
82
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
83
|
+
return {"keycloak": Path(str(importlib.resources.files(__package__) / "locales"))}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""OIDC helpers for Keycloak -- authorization URL, token exchange, logout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlencode
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OIDCClient:
|
|
13
|
+
"""Thin wrapper around Keycloak's OIDC endpoints."""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
server_url: str,
|
|
18
|
+
realm: str,
|
|
19
|
+
client_id: str,
|
|
20
|
+
client_secret: str,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._base = f"{server_url.rstrip('/')}/realms/{realm}/protocol/openid-connect"
|
|
23
|
+
self._client_id = client_id
|
|
24
|
+
self._client_secret = client_secret
|
|
25
|
+
self._server_url = server_url.rstrip("/")
|
|
26
|
+
self._realm = realm
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def issuer(self) -> str:
|
|
30
|
+
return f"{self._server_url}/realms/{self._realm}"
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def token_endpoint(self) -> str:
|
|
34
|
+
return f"{self._base}/token"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def jwks_url(self) -> str:
|
|
38
|
+
return f"{self._base}/certs"
|
|
39
|
+
|
|
40
|
+
def build_authorization_url(
|
|
41
|
+
self,
|
|
42
|
+
redirect_uri: str,
|
|
43
|
+
nonce: str,
|
|
44
|
+
scope: str = "openid email profile",
|
|
45
|
+
) -> tuple[str, str]:
|
|
46
|
+
state = secrets.token_urlsafe(32)
|
|
47
|
+
params = {
|
|
48
|
+
"client_id": self._client_id,
|
|
49
|
+
"redirect_uri": redirect_uri,
|
|
50
|
+
"response_type": "code",
|
|
51
|
+
"scope": scope,
|
|
52
|
+
"state": state,
|
|
53
|
+
"nonce": nonce,
|
|
54
|
+
}
|
|
55
|
+
url = f"{self._base}/auth?{urlencode(params)}"
|
|
56
|
+
return url, state
|
|
57
|
+
|
|
58
|
+
async def exchange_code(
|
|
59
|
+
self,
|
|
60
|
+
code: str,
|
|
61
|
+
redirect_uri: str,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
data = {
|
|
64
|
+
"grant_type": "authorization_code",
|
|
65
|
+
"code": code,
|
|
66
|
+
"redirect_uri": redirect_uri,
|
|
67
|
+
"client_id": self._client_id,
|
|
68
|
+
"client_secret": self._client_secret,
|
|
69
|
+
}
|
|
70
|
+
async with httpx.AsyncClient() as client:
|
|
71
|
+
resp = await client.post(self.token_endpoint, data=data, timeout=10)
|
|
72
|
+
resp.raise_for_status()
|
|
73
|
+
return resp.json()
|
|
74
|
+
|
|
75
|
+
def build_logout_url(
|
|
76
|
+
self,
|
|
77
|
+
post_logout_redirect_uri: str,
|
|
78
|
+
id_token_hint: str | None = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
params: dict[str, str] = {
|
|
81
|
+
"post_logout_redirect_uri": post_logout_redirect_uri,
|
|
82
|
+
}
|
|
83
|
+
if id_token_hint:
|
|
84
|
+
params["id_token_hint"] = id_token_hint
|
|
85
|
+
return f"{self._base}/logout?{urlencode(params)}"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Link } from '@inertiajs/react';
|
|
2
|
+
|
|
3
|
+
export default function LoggedOut() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
|
|
6
|
+
<h1 className="text-2xl font-bold">Signed Out</h1>
|
|
7
|
+
<p className="text-muted-foreground">You have been signed out successfully.</p>
|
|
8
|
+
<Link href="/keycloak/login" className="text-primary underline">
|
|
9
|
+
Sign in again
|
|
10
|
+
</Link>
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { router } from '@inertiajs/react';
|
|
2
|
+
import { useEffect } from 'react';
|
|
3
|
+
|
|
4
|
+
export default function Login() {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
router.get('/api/keycloak/auth/login');
|
|
7
|
+
}, []);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex min-h-screen items-center justify-center">
|
|
11
|
+
<p className="text-muted-foreground">Redirecting to identity provider...</p>
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""KeycloakAuthProvider — resolves users from Keycloak JWTs or session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from auth.contracts.schemas import UserContext
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from keycloak.jwks import JWKSCache
|
|
14
|
+
from keycloak.settings import KeycloakSettings
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_SESSION_USER_CTX_KEY = "user_ctx"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KeycloakAuthProvider:
|
|
22
|
+
"""OIDC auth provider backed by Keycloak."""
|
|
23
|
+
|
|
24
|
+
name = "keycloak"
|
|
25
|
+
_is_auth_provider = True
|
|
26
|
+
|
|
27
|
+
def __init__(self, settings: KeycloakSettings | None = None) -> None:
|
|
28
|
+
self._settings = settings
|
|
29
|
+
self.jwks_cache: JWKSCache | None = None
|
|
30
|
+
|
|
31
|
+
async def resolve_user(self, request: Request) -> UserContext | None:
|
|
32
|
+
auth_header = request.headers.get("authorization", "")
|
|
33
|
+
if auth_header.startswith("Bearer "):
|
|
34
|
+
return await self._resolve_bearer(request, auth_header[7:])
|
|
35
|
+
|
|
36
|
+
session = request.scope.get("session", {})
|
|
37
|
+
return UserContext.from_session_dict(session.get(_SESSION_USER_CTX_KEY))
|
|
38
|
+
|
|
39
|
+
def get_login_url(self, request: Request | None, next_url: str | None = None) -> str:
|
|
40
|
+
return "/keycloak/login"
|
|
41
|
+
|
|
42
|
+
def get_logout_url(self, request: Request | None) -> str:
|
|
43
|
+
return "/keycloak/logout"
|
|
44
|
+
|
|
45
|
+
def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
46
|
+
return (
|
|
47
|
+
("/keycloak/login", "/keycloak/logout", "/api/keycloak/auth/"),
|
|
48
|
+
(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def is_bearer_request(self, request: Request | None) -> bool:
|
|
52
|
+
if request is None:
|
|
53
|
+
return False
|
|
54
|
+
return request.headers.get("authorization", "").startswith("Bearer ")
|
|
55
|
+
|
|
56
|
+
async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None:
|
|
57
|
+
if self.jwks_cache is None:
|
|
58
|
+
logger.warning("JWKS cache not initialized; rejecting bearer token")
|
|
59
|
+
return None
|
|
60
|
+
claims = await self.jwks_cache.validate_jwt(token)
|
|
61
|
+
if claims is None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
cache_id = await self._upsert_user_cache(request, claims)
|
|
65
|
+
return self._claims_to_user_context(claims, cache_id=cache_id)
|
|
66
|
+
|
|
67
|
+
def _claims_to_user_context(
|
|
68
|
+
self,
|
|
69
|
+
claims: dict[str, Any],
|
|
70
|
+
*,
|
|
71
|
+
cache_id: str,
|
|
72
|
+
) -> UserContext:
|
|
73
|
+
roles_raw = (
|
|
74
|
+
_extract_nested(claims, self._settings.roles_claim_path) if self._settings else None
|
|
75
|
+
)
|
|
76
|
+
mapped = [
|
|
77
|
+
self._settings.role_mapping[r]
|
|
78
|
+
for r in (roles_raw or [])
|
|
79
|
+
if self._settings and r in self._settings.role_mapping
|
|
80
|
+
]
|
|
81
|
+
return UserContext(
|
|
82
|
+
id=cache_id,
|
|
83
|
+
email=claims.get("email", ""),
|
|
84
|
+
name=(claims.get("preferred_username") or claims.get("name", "")),
|
|
85
|
+
roles=mapped,
|
|
86
|
+
tenant_id=claims.get("tenant_id"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def _upsert_user_cache(self, request: Request, claims: dict) -> str:
|
|
90
|
+
try:
|
|
91
|
+
from sqlalchemy import select
|
|
92
|
+
|
|
93
|
+
from keycloak.models import KeycloakUserCache
|
|
94
|
+
|
|
95
|
+
session_factory = request.app.state.sm.db.session_factory
|
|
96
|
+
sub = claims["sub"]
|
|
97
|
+
async with session_factory() as db:
|
|
98
|
+
stmt = select(KeycloakUserCache).where(KeycloakUserCache.keycloak_sub == sub)
|
|
99
|
+
row = (await db.execute(stmt)).scalar_one_or_none()
|
|
100
|
+
if row is None:
|
|
101
|
+
import uuid as uuid_mod
|
|
102
|
+
from datetime import datetime
|
|
103
|
+
|
|
104
|
+
row = KeycloakUserCache(
|
|
105
|
+
id=uuid_mod.uuid4(),
|
|
106
|
+
keycloak_sub=sub,
|
|
107
|
+
email=claims.get("email", ""),
|
|
108
|
+
full_name=claims.get("preferred_username"),
|
|
109
|
+
last_login_at=datetime.now(UTC),
|
|
110
|
+
)
|
|
111
|
+
db.add(row)
|
|
112
|
+
await db.flush()
|
|
113
|
+
else:
|
|
114
|
+
from datetime import datetime
|
|
115
|
+
|
|
116
|
+
row.email = claims.get("email", row.email)
|
|
117
|
+
row.full_name = claims.get("preferred_username", row.full_name)
|
|
118
|
+
row.last_login_at = datetime.now(UTC)
|
|
119
|
+
await db.flush()
|
|
120
|
+
return str(row.id)
|
|
121
|
+
except Exception:
|
|
122
|
+
logger.exception(
|
|
123
|
+
"Failed to upsert KeycloakUserCache for sub=%s",
|
|
124
|
+
claims.get("sub"),
|
|
125
|
+
)
|
|
126
|
+
return claims.get("sub", "unknown")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_nested(data: dict, path: str) -> list[str] | None:
|
|
130
|
+
parts = path.split(".")
|
|
131
|
+
current: Any = data
|
|
132
|
+
for part in parts:
|
|
133
|
+
if not isinstance(current, dict):
|
|
134
|
+
return None
|
|
135
|
+
current = current.get(part)
|
|
136
|
+
if current is None:
|
|
137
|
+
return None
|
|
138
|
+
return current if isinstance(current, list) else None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Keycloak module settings -- DB-backed via ``register_module_settings``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, model_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
from simple_module_core.dotenv import env_str
|
|
8
|
+
from simple_module_core.environments import NON_PROD_ENVIRONMENTS
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KeycloakSettings(BaseSettings):
|
|
12
|
+
"""Keycloak OIDC configuration."""
|
|
13
|
+
|
|
14
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
15
|
+
|
|
16
|
+
server_url: str = env_str("SM_KEYCLOAK_SERVER_URL", "")
|
|
17
|
+
realm: str = env_str("SM_KEYCLOAK_REALM", "")
|
|
18
|
+
client_id: str = env_str("SM_KEYCLOAK_CLIENT_ID", "")
|
|
19
|
+
client_secret: str = env_str("SM_KEYCLOAK_CLIENT_SECRET", "")
|
|
20
|
+
|
|
21
|
+
roles_claim_path: str = "realm_access.roles"
|
|
22
|
+
admin_role: str = "admin"
|
|
23
|
+
login_redirect_url: str = "/dashboard/"
|
|
24
|
+
jwks_cache_ttl_seconds: int = 3600
|
|
25
|
+
|
|
26
|
+
role_mapping: dict[str, str] = Field(
|
|
27
|
+
default_factory=lambda: {"admin": "admin", "user": "user"},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@model_validator(mode="after")
|
|
31
|
+
def _check_required_in_production(self) -> KeycloakSettings:
|
|
32
|
+
import os
|
|
33
|
+
|
|
34
|
+
env = os.environ.get("SM_ENVIRONMENT", "development")
|
|
35
|
+
if env in NON_PROD_ENVIRONMENTS:
|
|
36
|
+
return self
|
|
37
|
+
missing = []
|
|
38
|
+
if not self.server_url:
|
|
39
|
+
missing.append("SM_KEYCLOAK_SERVER_URL")
|
|
40
|
+
if not self.realm:
|
|
41
|
+
missing.append("SM_KEYCLOAK_REALM")
|
|
42
|
+
if not self.client_id:
|
|
43
|
+
missing.append("SM_KEYCLOAK_CLIENT_ID")
|
|
44
|
+
if not self.client_secret:
|
|
45
|
+
missing.append("SM_KEYCLOAK_CLIENT_SECRET")
|
|
46
|
+
if missing:
|
|
47
|
+
msg = f"Keycloak settings required in production: {', '.join(missing)}"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
return self
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Module-scoped state container for the keycloak module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from keycloak.jwks import JWKSCache
|
|
10
|
+
from keycloak.settings import KeycloakSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class KeycloakState:
|
|
15
|
+
"""Keycloak-module singletons. Single slot at ``app.state.keycloak``."""
|
|
16
|
+
|
|
17
|
+
settings: KeycloakSettings
|
|
18
|
+
jwks_cache: JWKSCache | None = None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_keycloak"
|
|
3
|
+
version = "0.0.24"
|
|
4
|
+
description = "Keycloak OIDC authentication provider for simple_module — swap with simple_module_users"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
9
|
+
keywords = ["simple-module", "keycloak", "oidc", "authentication", "fastapi"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Framework :: FastAPI",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"simple_module_core==0.0.24",
|
|
24
|
+
"simple_module_db==0.0.24",
|
|
25
|
+
"simple_module_hosting==0.0.24",
|
|
26
|
+
"simple_module_settings==0.0.24",
|
|
27
|
+
"simple_module_auth==0.0.24",
|
|
28
|
+
"PyJWT[crypto]>=2.8",
|
|
29
|
+
"httpx>=0.27",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.entry-points.simple_module]
|
|
33
|
+
keycloak = "keycloak.module:KeycloakModule"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
37
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["keycloak"]
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
47
|
+
"package.json" = "keycloak/package.json"
|
|
48
|
+
|
|
49
|
+
[tool.uv.sources]
|
|
50
|
+
simple_module_core = { workspace = true }
|
|
51
|
+
simple_module_db = { workspace = true }
|
|
52
|
+
simple_module_hosting = { workspace = true }
|
|
53
|
+
simple_module_settings = { workspace = true }
|
|
54
|
+
simple_module_auth = { workspace = true }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Keycloak module test fixtures."""
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Tests for JWKS key cache and JWT validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import jwt
|
|
9
|
+
import pytest
|
|
10
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
11
|
+
from keycloak.jwks import JWKSCache
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _generate_rsa_keypair():
|
|
15
|
+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
16
|
+
public_key = private_key.public_key()
|
|
17
|
+
return private_key, public_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _make_jwks_response(public_key, kid="test-key-1"):
|
|
21
|
+
from jwt.algorithms import RSAAlgorithm
|
|
22
|
+
|
|
23
|
+
jwk = json.loads(RSAAlgorithm.to_jwk(public_key))
|
|
24
|
+
jwk["kid"] = kid
|
|
25
|
+
jwk["use"] = "sig"
|
|
26
|
+
jwk["alg"] = "RS256"
|
|
27
|
+
return {"keys": [jwk]}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _sign_token(private_key, payload, kid="test-key-1"):
|
|
31
|
+
return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def rsa_keys():
|
|
36
|
+
return _generate_rsa_keypair()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture
|
|
40
|
+
def valid_payload():
|
|
41
|
+
now = int(time.time())
|
|
42
|
+
return {
|
|
43
|
+
"sub": "user-123",
|
|
44
|
+
"email": "test@example.com",
|
|
45
|
+
"preferred_username": "testuser",
|
|
46
|
+
"iss": "https://auth.example.com/realms/test",
|
|
47
|
+
"aud": "my-client",
|
|
48
|
+
"exp": now + 3600,
|
|
49
|
+
"iat": now,
|
|
50
|
+
"realm_access": {"roles": ["admin", "user"]},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def test_validate_jwt_valid_token(rsa_keys, valid_payload, httpx_mock):
|
|
55
|
+
private_key, public_key = rsa_keys
|
|
56
|
+
jwks_data = _make_jwks_response(public_key)
|
|
57
|
+
httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data)
|
|
58
|
+
|
|
59
|
+
cache = JWKSCache(
|
|
60
|
+
jwks_url="https://auth.example.com/jwks",
|
|
61
|
+
ttl_seconds=3600,
|
|
62
|
+
issuer="https://auth.example.com/realms/test",
|
|
63
|
+
audience="my-client",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
token = _sign_token(private_key, valid_payload)
|
|
67
|
+
claims = await cache.validate_jwt(token)
|
|
68
|
+
assert claims is not None
|
|
69
|
+
assert claims["sub"] == "user-123"
|
|
70
|
+
assert claims["email"] == "test@example.com"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_validate_jwt_expired_token(rsa_keys, valid_payload, httpx_mock):
|
|
74
|
+
private_key, public_key = rsa_keys
|
|
75
|
+
valid_payload["exp"] = int(time.time()) - 100
|
|
76
|
+
jwks_data = _make_jwks_response(public_key)
|
|
77
|
+
httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data)
|
|
78
|
+
|
|
79
|
+
cache = JWKSCache(
|
|
80
|
+
jwks_url="https://auth.example.com/jwks",
|
|
81
|
+
ttl_seconds=3600,
|
|
82
|
+
issuer="https://auth.example.com/realms/test",
|
|
83
|
+
audience="my-client",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
token = _sign_token(private_key, valid_payload)
|
|
87
|
+
claims = await cache.validate_jwt(token)
|
|
88
|
+
assert claims is None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_validate_jwt_wrong_issuer(rsa_keys, valid_payload, httpx_mock):
|
|
92
|
+
private_key, public_key = rsa_keys
|
|
93
|
+
jwks_data = _make_jwks_response(public_key)
|
|
94
|
+
httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data)
|
|
95
|
+
|
|
96
|
+
cache = JWKSCache(
|
|
97
|
+
jwks_url="https://auth.example.com/jwks",
|
|
98
|
+
ttl_seconds=3600,
|
|
99
|
+
issuer="https://wrong-issuer.com/realms/test",
|
|
100
|
+
audience="my-client",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
token = _sign_token(private_key, valid_payload)
|
|
104
|
+
claims = await cache.validate_jwt(token)
|
|
105
|
+
assert claims is None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock):
|
|
109
|
+
private_key, public_key = rsa_keys
|
|
110
|
+
jwks_data = _make_jwks_response(public_key)
|
|
111
|
+
httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data)
|
|
112
|
+
|
|
113
|
+
cache = JWKSCache(
|
|
114
|
+
jwks_url="https://auth.example.com/jwks",
|
|
115
|
+
ttl_seconds=3600,
|
|
116
|
+
issuer="https://auth.example.com/realms/test",
|
|
117
|
+
audience="wrong-client",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
token = _sign_token(private_key, valid_payload)
|
|
121
|
+
claims = await cache.validate_jwt(token)
|
|
122
|
+
assert claims is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload):
|
|
126
|
+
"""When a token has a kid not in cache, refetch JWKS once before rejecting."""
|
|
127
|
+
private_key, public_key = rsa_keys
|
|
128
|
+
jwks_data = _make_jwks_response(public_key, kid="rotated-key")
|
|
129
|
+
|
|
130
|
+
call_count = 0
|
|
131
|
+
|
|
132
|
+
async def counting_fetch(self, *, force=False):
|
|
133
|
+
nonlocal call_count
|
|
134
|
+
call_count += 1
|
|
135
|
+
if call_count == 1:
|
|
136
|
+
self._keys = {}
|
|
137
|
+
self._fetched_at = __import__("time").monotonic()
|
|
138
|
+
else:
|
|
139
|
+
from jwt.algorithms import RSAAlgorithm
|
|
140
|
+
|
|
141
|
+
for kd in jwks_data["keys"]:
|
|
142
|
+
self._keys[kd["kid"]] = RSAAlgorithm.from_jwk(kd)
|
|
143
|
+
self._fetched_at = __import__("time").monotonic()
|
|
144
|
+
|
|
145
|
+
cache = JWKSCache(
|
|
146
|
+
jwks_url="https://auth.example.com/jwks",
|
|
147
|
+
ttl_seconds=3600,
|
|
148
|
+
issuer="https://auth.example.com/realms/test",
|
|
149
|
+
audience="my-client",
|
|
150
|
+
)
|
|
151
|
+
cache._fetch_keys = counting_fetch.__get__(cache, JWKSCache)
|
|
152
|
+
|
|
153
|
+
token = _sign_token(private_key, valid_payload, kid="rotated-key")
|
|
154
|
+
claims = await cache.validate_jwt(token)
|
|
155
|
+
assert claims is not None
|
|
156
|
+
assert claims["sub"] == "user-123"
|
|
157
|
+
assert call_count == 2
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Tests for KeycloakModule lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from auth.contracts.provider import AuthProvider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_keycloak_module_meta():
|
|
9
|
+
from keycloak.module import KeycloakModule
|
|
10
|
+
|
|
11
|
+
mod = KeycloakModule()
|
|
12
|
+
assert mod.meta.name == "Keycloak"
|
|
13
|
+
assert mod.meta.depends_on == ["Auth", "Settings"]
|
|
14
|
+
assert mod._is_auth_provider is True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_keycloak_provider_satisfies_protocol():
|
|
18
|
+
from keycloak.provider import KeycloakAuthProvider
|
|
19
|
+
|
|
20
|
+
provider = KeycloakAuthProvider()
|
|
21
|
+
assert isinstance(provider, AuthProvider)
|
|
22
|
+
assert provider.name == "keycloak"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tests for KeycloakAuthProvider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from auth.contracts.provider import AuthProvider
|
|
9
|
+
from auth.contracts.schemas import UserContext
|
|
10
|
+
from keycloak.provider import KeycloakAuthProvider
|
|
11
|
+
from keycloak.settings import KeycloakSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def settings():
|
|
16
|
+
return KeycloakSettings(
|
|
17
|
+
server_url="https://auth.example.com",
|
|
18
|
+
realm="test",
|
|
19
|
+
client_id="my-app",
|
|
20
|
+
client_secret="secret",
|
|
21
|
+
role_mapping={
|
|
22
|
+
"admin": "admin",
|
|
23
|
+
"user": "user",
|
|
24
|
+
"editor": "editor",
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def provider(settings):
|
|
31
|
+
return KeycloakAuthProvider(settings)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_satisfies_protocol(provider):
|
|
35
|
+
assert isinstance(provider, AuthProvider)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_name(provider):
|
|
39
|
+
assert provider.name == "keycloak"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_login_url(provider):
|
|
43
|
+
assert provider.get_login_url(None) == "/keycloak/login"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_logout_url(provider):
|
|
47
|
+
assert provider.get_logout_url(None) == "/keycloak/logout"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_public_paths(provider):
|
|
51
|
+
prefixes, _exact = provider.get_public_paths()
|
|
52
|
+
assert "/keycloak/login" in prefixes
|
|
53
|
+
assert "/api/keycloak/auth/" in prefixes
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_is_bearer_request(provider):
|
|
57
|
+
req = MagicMock()
|
|
58
|
+
req.headers = {"authorization": "Bearer abc"}
|
|
59
|
+
assert provider.is_bearer_request(req) is True
|
|
60
|
+
|
|
61
|
+
req.headers = {}
|
|
62
|
+
assert provider.is_bearer_request(req) is False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_claims_to_user_context(provider):
|
|
66
|
+
claims = {
|
|
67
|
+
"sub": "kc-user-123",
|
|
68
|
+
"email": "test@example.com",
|
|
69
|
+
"preferred_username": "testuser",
|
|
70
|
+
"realm_access": {"roles": ["admin", "unknown_role", "user"]},
|
|
71
|
+
}
|
|
72
|
+
ctx = provider._claims_to_user_context(claims, cache_id="aaaaaaaa-0000-0000-0000-000000000001")
|
|
73
|
+
assert isinstance(ctx, UserContext)
|
|
74
|
+
assert ctx.id == "aaaaaaaa-0000-0000-0000-000000000001"
|
|
75
|
+
assert ctx.email == "test@example.com"
|
|
76
|
+
assert ctx.name == "testuser"
|
|
77
|
+
assert sorted(ctx.roles) == ["admin", "user"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_claims_to_user_context_no_roles(provider):
|
|
81
|
+
claims = {"sub": "kc-user-456", "email": "noroles@example.com"}
|
|
82
|
+
ctx = provider._claims_to_user_context(claims, cache_id="bbbb")
|
|
83
|
+
assert ctx.roles == []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_extract_roles_custom_claim_path(settings):
|
|
87
|
+
settings.roles_claim_path = "resource_access.my-app.roles"
|
|
88
|
+
provider = KeycloakAuthProvider(settings)
|
|
89
|
+
claims = {
|
|
90
|
+
"sub": "x",
|
|
91
|
+
"resource_access": {"my-app": {"roles": ["admin"]}},
|
|
92
|
+
}
|
|
93
|
+
ctx = provider._claims_to_user_context(claims, cache_id="cccc")
|
|
94
|
+
assert ctx.roles == ["admin"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Tests for OIDC discovery and token exchange helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from keycloak.oidc import OIDCClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def oidc_client():
|
|
11
|
+
return OIDCClient(
|
|
12
|
+
server_url="https://auth.example.com",
|
|
13
|
+
realm="test",
|
|
14
|
+
client_id="my-app",
|
|
15
|
+
client_secret="secret123",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_authorization_url(oidc_client):
|
|
20
|
+
url, state = oidc_client.build_authorization_url(
|
|
21
|
+
redirect_uri="https://app.example.com/callback",
|
|
22
|
+
nonce="test-nonce",
|
|
23
|
+
)
|
|
24
|
+
assert "auth.example.com/realms/test/protocol/openid-connect/auth" in url
|
|
25
|
+
assert "client_id=my-app" in url
|
|
26
|
+
assert "redirect_uri=" in url
|
|
27
|
+
assert "response_type=code" in url
|
|
28
|
+
assert "scope=openid" in url
|
|
29
|
+
assert "nonce=test-nonce" in url
|
|
30
|
+
assert state is not None
|
|
31
|
+
assert len(state) > 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_token_endpoint_url(oidc_client):
|
|
35
|
+
assert oidc_client.token_endpoint == (
|
|
36
|
+
"https://auth.example.com/realms/test/protocol/openid-connect/token"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_logout_url(oidc_client):
|
|
41
|
+
url = oidc_client.build_logout_url(
|
|
42
|
+
post_logout_redirect_uri="https://app.example.com/login",
|
|
43
|
+
id_token_hint="token123",
|
|
44
|
+
)
|
|
45
|
+
assert "auth.example.com/realms/test/protocol/openid-connect/logout" in url
|
|
46
|
+
assert "post_logout_redirect_uri=" in url
|
|
47
|
+
assert "id_token_hint=token123" in url
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_issuer(oidc_client):
|
|
51
|
+
assert oidc_client.issuer == "https://auth.example.com/realms/test"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def test_exchange_code(oidc_client, httpx_mock):
|
|
55
|
+
httpx_mock.add_response(
|
|
56
|
+
url=oidc_client.token_endpoint,
|
|
57
|
+
json={
|
|
58
|
+
"access_token": "at-123",
|
|
59
|
+
"id_token": "id-123",
|
|
60
|
+
"refresh_token": "rt-123",
|
|
61
|
+
"token_type": "Bearer",
|
|
62
|
+
"expires_in": 300,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
tokens = await oidc_client.exchange_code(
|
|
66
|
+
code="auth-code-xyz",
|
|
67
|
+
redirect_uri="https://app.example.com/callback",
|
|
68
|
+
)
|
|
69
|
+
assert tokens["access_token"] == "at-123"
|
|
70
|
+
assert tokens["id_token"] == "id-123"
|