simple-module-keycloak 0.0.24__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.
- keycloak/__init__.py +1 -0
- keycloak/contracts/__init__.py +1 -0
- keycloak/endpoints/__init__.py +1 -0
- keycloak/endpoints/api.py +92 -0
- keycloak/endpoints/views.py +40 -0
- keycloak/jwks.py +113 -0
- keycloak/locales/en.json +15 -0
- keycloak/models.py +21 -0
- keycloak/module.py +83 -0
- keycloak/oidc.py +85 -0
- keycloak/package.json +7 -0
- keycloak/pages/LoggedOut.tsx +13 -0
- keycloak/pages/Login.tsx +14 -0
- keycloak/provider.py +138 -0
- keycloak/settings.py +49 -0
- keycloak/state.py +18 -0
- simple_module_keycloak-0.0.24.dist-info/METADATA +59 -0
- simple_module_keycloak-0.0.24.dist-info/RECORD +20 -0
- simple_module_keycloak-0.0.24.dist-info/WHEEL +4 -0
- simple_module_keycloak-0.0.24.dist-info/entry_points.txt +2 -0
keycloak/__init__.py
ADDED
|
@@ -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)
|
keycloak/jwks.py
ADDED
|
@@ -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()
|
keycloak/locales/en.json
ADDED
|
@@ -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
|
+
}
|
keycloak/models.py
ADDED
|
@@ -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
|
keycloak/module.py
ADDED
|
@@ -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"))}
|
keycloak/oidc.py
ADDED
|
@@ -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)}"
|
keycloak/package.json
ADDED
|
@@ -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
|
+
}
|
keycloak/pages/Login.tsx
ADDED
|
@@ -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
|
+
}
|
keycloak/provider.py
ADDED
|
@@ -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
|
keycloak/settings.py
ADDED
|
@@ -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
|
keycloak/state.py
ADDED
|
@@ -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,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,20 @@
|
|
|
1
|
+
keycloak/__init__.py,sha256=fwprBcURWW9H9_AZ0qq1_HyKkz6t9e2IrKUs4M47SFo,63
|
|
2
|
+
keycloak/jwks.py,sha256=tYWEgcj_y_1OvJicqT2ydqqbzt1Yz3X51i2gGJ67Jkk,3442
|
|
3
|
+
keycloak/models.py,sha256=MOX-aZE6RuxUksVblqUglHiojtuwuefgWBbZuKyp8LU,636
|
|
4
|
+
keycloak/module.py,sha256=q7IrMTUOXRsL5RLJsRVOA684BWJhlqUiuE9qfWwGy-A,2709
|
|
5
|
+
keycloak/oidc.py,sha256=UldTMdq58P8ca3xmwRTplu9eu6B1R2BR6fobb9DGzKk,2398
|
|
6
|
+
keycloak/provider.py,sha256=hE3Um1njU6XYFVlvkNK-Gwm2p3WfPl_0fazE5unw3Nk,4893
|
|
7
|
+
keycloak/settings.py,sha256=vQbRX5gBT5KFrcjTAM3pMMmS2NIl6K_Yf5HHYnpBfEc,1743
|
|
8
|
+
keycloak/state.py,sha256=16b2FN6Nq4HWmyrxXA5oJueSgmAHg6-uWdM6m0nKw70,458
|
|
9
|
+
keycloak/contracts/__init__.py,sha256=-u1EUS42D4ppuyCsk_JUb4IQNeVVahyAzorHBBRwgH8,33
|
|
10
|
+
keycloak/endpoints/__init__.py,sha256=CSNcRfO6KidoCB91FhZE4-eiHe9A5ZFS5OaJiRVWkyA,33
|
|
11
|
+
keycloak/endpoints/api.py,sha256=wDkAIn47v84yMx70ufDntP70YTTq1r3z9Lp0PJjjJ8Q,3023
|
|
12
|
+
keycloak/endpoints/views.py,sha256=bmxI0oVp3o2X8xzdEvmETgai30IIcJwhmmQUE7NcbTY,1127
|
|
13
|
+
keycloak/locales/en.json,sha256=pUG6eYSjXz49p2v3ECikcje3Sr0Wp0vqtYoMGskMa3s,420
|
|
14
|
+
keycloak/pages/LoggedOut.tsx,sha256=_CFMcYguiMN84h6pmdldGQBaUKF2gCCbW6arEzixH_k,442
|
|
15
|
+
keycloak/pages/Login.tsx,sha256=l85qBrbY6D9evgRsxxNxM3ZutS8uAULbJmMTBrXgCCc,368
|
|
16
|
+
keycloak/package.json,sha256=lb3jCpfV8lriqPRtE767SAPoVZvHbx9TqM3R2MGv1jk,123
|
|
17
|
+
simple_module_keycloak-0.0.24.dist-info/METADATA,sha256=ctmmAQjv7RHB8ELYO23UWp0hXdh74Lq_v5XwKv0uRhE,2275
|
|
18
|
+
simple_module_keycloak-0.0.24.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
19
|
+
simple_module_keycloak-0.0.24.dist-info/entry_points.txt,sha256=aiqnDG3AGTjz9babDtbuH-mWm_9o1VUfsddO1Qp6DNQ,58
|
|
20
|
+
simple_module_keycloak-0.0.24.dist-info/RECORD,,
|