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 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()
@@ -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,7 @@
1
+ {
2
+ "name": "@simple-module/keycloak",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "dependencies": {}
7
+ }
@@ -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
+ }
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ keycloak = keycloak.module:KeycloakModule