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.
Files changed (26) hide show
  1. simple_module_keycloak-0.0.24/.gitignore +64 -0
  2. simple_module_keycloak-0.0.24/PKG-INFO +59 -0
  3. simple_module_keycloak-0.0.24/README.md +30 -0
  4. simple_module_keycloak-0.0.24/keycloak/__init__.py +1 -0
  5. simple_module_keycloak-0.0.24/keycloak/contracts/__init__.py +1 -0
  6. simple_module_keycloak-0.0.24/keycloak/endpoints/__init__.py +1 -0
  7. simple_module_keycloak-0.0.24/keycloak/endpoints/api.py +92 -0
  8. simple_module_keycloak-0.0.24/keycloak/endpoints/views.py +40 -0
  9. simple_module_keycloak-0.0.24/keycloak/jwks.py +113 -0
  10. simple_module_keycloak-0.0.24/keycloak/locales/en.json +15 -0
  11. simple_module_keycloak-0.0.24/keycloak/models.py +21 -0
  12. simple_module_keycloak-0.0.24/keycloak/module.py +83 -0
  13. simple_module_keycloak-0.0.24/keycloak/oidc.py +85 -0
  14. simple_module_keycloak-0.0.24/keycloak/pages/LoggedOut.tsx +13 -0
  15. simple_module_keycloak-0.0.24/keycloak/pages/Login.tsx +14 -0
  16. simple_module_keycloak-0.0.24/keycloak/provider.py +138 -0
  17. simple_module_keycloak-0.0.24/keycloak/settings.py +49 -0
  18. simple_module_keycloak-0.0.24/keycloak/state.py +18 -0
  19. simple_module_keycloak-0.0.24/package.json +7 -0
  20. simple_module_keycloak-0.0.24/pyproject.toml +54 -0
  21. simple_module_keycloak-0.0.24/tests/conftest.py +1 -0
  22. simple_module_keycloak-0.0.24/tests/test_jwks.py +157 -0
  23. simple_module_keycloak-0.0.24/tests/test_keycloak_module.py +22 -0
  24. simple_module_keycloak-0.0.24/tests/test_keycloak_provider.py +94 -0
  25. simple_module_keycloak-0.0.24/tests/test_oidc.py +70 -0
  26. 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,7 @@
1
+ {
2
+ "name": "@simple-module/keycloak",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "dependencies": {}
7
+ }
@@ -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"
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../host/client_app/tsconfig.json",
3
+ "include": ["keycloak/**/*.ts", "keycloak/**/*.tsx"]
4
+ }