From d3d98aba1f8234c63b0d87655bb00f61cb3feb51 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 11:25:14 +0200 Subject: [PATCH 01/34] docs: add design spec for pluggable auth + Keycloak module Introduces the AuthProvider contract that allows swapping the users module for a Keycloak OIDC module. Covers bearer token support for mobile clients, JWT validation via JWKS, role mapping, and the SM020/SM021 conflict diagnostics. Builds on the principal-resolver chain from the #163 spec. --- ...26-05-27-pluggable-auth-keycloak-design.md | 538 ++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md diff --git a/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md b/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md new file mode 100644 index 00000000..1cb25e03 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-pluggable-auth-keycloak-design.md @@ -0,0 +1,538 @@ +# Pluggable Auth: AuthProvider Contract + Keycloak Module + +**Date:** 2026-05-27 +**Status:** Design — approved for implementation planning +**Builds on:** [2026-05-21 Auth principal-resolver chain](2026-05-21-auth-principal-resolver-design.md) (must land first) + +## Goal + +Make the framework's authentication layer swappable. Framework users install either the existing `users` module (local credentials + OAuth) or a new `keycloak` module (Keycloak OIDC) — both implement the same `AuthProvider` contract so every other module is unaffected. Both web and mobile clients are supported regardless of which provider is active. + +## Relationship to the Principal-Resolver Spec + +The [principal-resolver design](2026-05-21-auth-principal-resolver-design.md) (issue #163) adds the extension point for bearer-token resolution and the 401-JSON-for-API behavior. That spec is a **prerequisite** for this one — it lands the resolver chain and the API-vs-browser response split. This design builds on top: + +- The principal-resolver chain becomes one of the tools an `AuthProvider` uses internally (the `users` provider registers its cookie resolver as the primary path and lets downstream modules add PAT resolvers via the chain). +- The `keycloak` provider uses its own `resolve_user` that validates Keycloak JWTs for bearer tokens and reads session for browser flows — it doesn't use the resolver chain for its core flow, but the chain remains available for downstream modules that want to add extra credential types on top of Keycloak (e.g., service-account tokens). +- The 401-JSON-for-`/api/*` behavior from the resolver spec carries forward unchanged. + +## Scope + +**In scope.** +- `AuthProvider` protocol in `auth/contracts/` — the interface both providers implement. +- Provider-agnostic `AuthMiddleware` extracted from `users/middleware.py` into `auth/`. +- Bearer token transport for mobile clients (framework-issued tokens with `users`, Keycloak-issued JWTs with `keycloak`). +- New `keycloak` module package: OIDC login (web redirect + mobile PKCE), JWT validation against JWKS, role mapping, lightweight user cache table. +- Conflict diagnostic (`SM020`) — boot fails if both `users` and `keycloak` are installed. +- `principal_serializer` registration moved from `users/module.py` into `auth/` so it works with either provider. + +**Out of scope.** +- Full OAuth2 authorization server (client registration, scopes, token introspection for third-party apps). +- Keycloak admin API integration (realm/client provisioning from the framework). +- Dual-provider mode (authenticating some users via `users` and others via `keycloak` simultaneously). +- Migration tooling to move existing `users` data into Keycloak. + +## Non-goals & invariants + +- **`auth` module stays the stable contract layer.** Other modules import only from `auth.contracts` and `auth.deps` — never from `users` or `keycloak` directly. +- **`UserContext` is the single identity type.** Every downstream consumer (menus, permissions, audit listeners, Inertia shared props) operates on `UserContext` regardless of provider. +- **`PermissionRegistry` is framework-owned.** Both providers map their roles into the same registry. Permission definitions live in application modules, not in the identity provider. +- **Session cookie (`session`) stays Starlette-managed.** Both providers use the framework's `SessionMiddleware` for server-side state (redirect targets, OIDC nonces). The auth *credential* transport differs (cookie vs. bearer), but the session layer is shared. + +## Architecture + +Five pieces, built in dependency order: + +### 1. AuthProvider Protocol (in `auth/contracts/provider.py`) + +```python +from __future__ import annotations +from typing import Protocol, runtime_checkable +from starlette.requests import Request +from auth.contracts.schemas import UserContext + +@runtime_checkable +class AuthProvider(Protocol): + name: str + + async def resolve_user(self, request: Request) -> UserContext | None: + """Extract authenticated user from the request. + + For cookie-based providers: read session/cookie, validate, return UserContext. + For token-based providers: decode Authorization header, validate, return UserContext. + Returns None if no valid credential is present. + """ + ... + + def get_login_url(self, request: Request, next_url: str | None = None) -> str: + """URL to redirect unauthenticated browser requests to.""" + ... + + def get_logout_url(self, request: Request) -> str: + """URL/endpoint to POST for logout. + + Keycloak needs RP-initiated logout (redirect to Keycloak's /logout). + Users module clears session + cookie locally. + """ + ... + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return (prefix_paths, exact_paths) that skip authentication. + + Framework-level paths (/health, /static/, /api/docs, /openapi.json, /i18n/) + are always public (handled by the middleware itself). Providers return only + their own paths (e.g., /users/login or /keycloak/login). + """ + ... + + def is_bearer_request(self, request: Request) -> bool: + """True if the request carries a bearer token (mobile/API client).""" + ... +``` + +Both `users` and `keycloak` modules implement this protocol. The active provider is stored on `app.state.auth_provider` during module registration. + +### 2. Provider-Agnostic AuthMiddleware (moved to `auth/`) + +The current `users/middleware.py` hardcodes session-key reading and DB user loading. The new middleware delegates to the provider: + +```python +_FRAMEWORK_PUBLIC_PREFIXES = ("/health", "/static/", "/api/docs", "/api/redoc", "/openapi.json", "/i18n/") +_FRAMEWORK_PUBLIC_EXACT = ("/",) + +class AuthMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope["path"] + provider: AuthProvider = scope["app"].state.auth_provider + + # Framework-level public paths + is_public = ( + any(path.startswith(p) for p in _FRAMEWORK_PUBLIC_PREFIXES) + or path in _FRAMEWORK_PUBLIC_EXACT + ) + # Provider-specific public paths + if not is_public: + prefix_paths, exact_paths = provider.get_public_paths() + is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths + + request = Request(scope) + user_ctx = await provider.resolve_user(request) + + # Fall through to principal-resolver chain (from #163 spec) + if user_ctx is None: + resolvers = getattr(scope["app"].state.auth, "principal_resolvers", ()) + for resolver in resolvers: + try: + user_ctx = await resolver(request) + except Exception: + logger.exception("Principal resolver %r raised; treating as no-match", resolver) + continue + if user_ctx is not None: + break + + if user_ctx is None and not is_public: + if path.startswith("/api/"): + response = JSONResponse({"detail": "Not authenticated"}, status_code=401) + else: + session = scope["session"] + session["next"] = str(request.url) + response = RedirectResponse(provider.get_login_url(request), status_code=302) + await response(scope, receive, send) + return + + if user_ctx is not None: + request.state.user = user_ctx + token = current_user_id.set(user_ctx.id) + try: + await self.app(scope, receive, send) + finally: + current_user_id.reset(token) + return + + await self.app(scope, receive, send) +``` + +Key behaviors: +- Bearer requests get `401 JSON` instead of `302 redirect` (from the principal-resolver spec). +- The resolver chain from #163 runs after the provider's own `resolve_user`, so downstream PAT modules work with either provider. +- Framework-level public paths are hardcoded; provider-specific paths come from `get_public_paths()`. + +### 3. Users Module Changes + +The `users` module adapts to implement `AuthProvider` and gains bearer token support for mobile/API clients. + +**`AuthProvider` implementation.** A new `UsersAuthProvider` class in `users/provider.py`: + +```python +class UsersAuthProvider: + name = "users" + + async def resolve_user(self, request: Request) -> UserContext | None: + # Path 1: Bearer token (mobile/API) + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + return await self._resolve_bearer(request, token) + + # Path 2: Session cookie (browser — existing logic moved here) + session = request.scope.get("session", {}) + raw_user_id = session.get("user_id") + if not raw_user_id: + return None + # Fast path: cached UserContext in session + user_ctx = UserContext.from_session_dict(session.get("user_ctx")) + if user_ctx and user_ctx.id == str(raw_user_id): + return user_ctx + # Slow path: DB lookup (existing _load_user logic) + return await self._load_user_from_db(request, raw_user_id) + + def get_login_url(self, request, next_url=None) -> str: + return "/users/login" + + def get_logout_url(self, request) -> str: + return "/users/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return ( + ("/users/login", "/users/register", "/users/forgot-password", + "/users/reset-password", "/users/verify", "/users/invite/accept", + "/api/users/auth/", "/api/users/register"), + (), + ) + + def is_bearer_request(self, request) -> bool: + return request.headers.get("authorization", "").startswith("Bearer ") +``` + +**Registration.** `UsersModule.register_settings()` adds: +```python +app.state.auth_provider = UsersAuthProvider() +``` + +**New: Refresh token support for mobile.** Mobile clients need token refresh without re-authenticating: + +- **`RefreshToken` model** — new table `users_refresh_token`: `token` (UUID, PK), `user_id` (FK), `created_at`, `expires_at` (30 days), `revoked_at`. +- **`POST /api/users/auth/token`** — email+password login returning `{access_token, token_type, expires_in, refresh_token}` as JSON (no cookie set). +- **`POST /api/users/auth/token/refresh`** — exchange refresh token for new access + refresh token pair. Old refresh token is revoked (rotation). +- **`DELETE /api/users/auth/token`** — revoke refresh token (mobile logout). +- Access tokens for bearer transport use a shorter lifetime (15 minutes) than cookie tokens (14 days). This is configurable via `UsersSettings.bearer_token_lifetime_seconds`. + +**OAuth for mobile.** When `/api/users/oauth/{provider}/callback` receives a request with `Accept: application/json` (or `?response_type=token` query param), it returns tokens as JSON instead of setting cookies and redirecting. + +### 4. Keycloak Module (new package) + +**Package:** `modules/keycloak/` with entry point `simple_module.keycloak = keycloak.module:KeycloakModule`. + +**Depends on:** `Auth` (same dependency as `users`). + +**Settings** (`SM_KEYCLOAK_*`, DB-backed after bootstrap): + +| Setting | Default | Description | +|---------|---------|-------------| +| `server_url` | (required) | Keycloak base URL (e.g. `https://auth.example.com`) | +| `realm` | (required) | Realm name | +| `client_id` | (required) | OIDC client ID (confidential client) | +| `client_secret` | (required) | OIDC client secret | +| `roles_claim_path` | `realm_access.roles` | Dot-path to extract roles from ID/access token | +| `admin_role` | `admin` | Keycloak role name that maps to framework admin | +| `login_redirect_url` | `/dashboard/` | Where to redirect after successful login | +| `jwks_cache_ttl_seconds` | `3600` | How long to cache Keycloak's public keys | +| `role_mapping` | `{"admin": "admin", "user": "user"}` | Keycloak role → framework role mapping | + +**Module layout:** + +``` +modules/keycloak/keycloak/ +├── module.py # KeycloakModule(ModuleBase) — registers as auth_provider +├── models.py # KeycloakUserCache table +├── settings.py # KeycloakSettings +├── provider.py # KeycloakAuthProvider(AuthProvider) implementation +├── oidc.py # OIDC discovery, token exchange helpers +├── jwks.py # JWKS key cache + JWT signature validation +├── endpoints/ +│ ├── api.py # /api/keycloak/auth/login, /callback, /userinfo +│ └── views.py # /keycloak/login (redirect), /keycloak/logout +├── pages/ +│ ├── Login.tsx # Minimal page that auto-redirects to Keycloak +│ └── LoggedOut.tsx # Post-logout landing page +└── locales/ + └── en.json +``` + +**Web login flow:** + +1. User hits any protected page → `AuthMiddleware` redirects to `/keycloak/login`. +2. `/keycloak/login` view generates OIDC state + nonce, stores in session, redirects to Keycloak's authorization endpoint: `{server_url}/realms/{realm}/protocol/openid-connect/auth?client_id=...&redirect_uri=/api/keycloak/auth/callback&response_type=code&scope=openid+email+profile&state=...&nonce=...`. +3. User authenticates at Keycloak's hosted login page. +4. Keycloak redirects to `/api/keycloak/auth/callback?code=...&state=...`. +5. Callback validates state against session, exchanges code for tokens via Keycloak's token endpoint. +6. Validates ID token signature (JWKS), checks `iss`, `aud`, `exp`, `nonce`. +7. Extracts claims: `sub`, `email`, `preferred_username` or `name`, `realm_access.roles`. +8. Upserts `KeycloakUserCache` row (maps `keycloak_sub` → stable framework UUID). +9. Maps Keycloak roles to framework roles via `role_mapping` setting. +10. Builds `UserContext`, stores in `session["user_ctx"]`. Stores `id_token` in session for logout. +11. Redirects to `session["next"]` or `login_redirect_url`. + +**Mobile login flow (Authorization Code + PKCE):** + +Mobile clients authenticate directly with Keycloak — the framework is not in the login path: + +1. Mobile app opens system browser to Keycloak's auth endpoint with `code_challenge` + `code_challenge_method=S256` (PKCE). +2. User authenticates at Keycloak. +3. Keycloak redirects to mobile app's registered redirect URI (deep link / custom scheme) with auth code. +4. Mobile app exchanges code + `code_verifier` for tokens directly with Keycloak's token endpoint. +5. Mobile app sends requests to the framework API with `Authorization: Bearer `. +6. Framework's `KeycloakAuthProvider.resolve_user()` validates the JWT against Keycloak's JWKS keys. +7. On token expiry, mobile app refreshes directly with Keycloak (`grant_type=refresh_token`). The framework is not involved in token refresh. + +**`KeycloakAuthProvider` implementation:** + +```python +class KeycloakAuthProvider: + name = "keycloak" + + def __init__(self, settings: KeycloakSettings, jwks_cache: JWKSCache): + self.settings = settings + self.jwks_cache = jwks_cache + + async def resolve_user(self, request: Request) -> UserContext | None: + # Path 1: Bearer token (mobile/API) + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + claims = await self.jwks_cache.validate_jwt(token) + if claims is None: + return None + return self._claims_to_user_context(claims) + + # Path 2: Session (browser) + session = request.scope.get("session", {}) + return UserContext.from_session_dict(session.get("user_ctx")) + + def get_login_url(self, request, next_url=None) -> str: + return "/keycloak/login" + + def get_logout_url(self, request) -> str: + return "/keycloak/logout" + + def get_public_paths(self): + return ( + ("/keycloak/login", "/keycloak/logout", "/api/keycloak/auth/"), + (), + ) + + def is_bearer_request(self, request) -> bool: + return request.headers.get("authorization", "").startswith("Bearer ") + + def _claims_to_user_context(self, claims: dict) -> UserContext: + roles_raw = _extract_nested(claims, self.settings.roles_claim_path) + mapped_roles = [ + self.settings.role_mapping[r] + for r in (roles_raw or []) + if r in self.settings.role_mapping + ] + return UserContext( + id=str(self._get_or_create_cache_id(claims["sub"])), + email=claims.get("email", ""), + name=claims.get("preferred_username") or claims.get("name", ""), + roles=mapped_roles, + tenant_id=claims.get("tenant_id"), + ) +``` + +**JWT validation (`jwks.py`):** + +- Fetches Keycloak's JWKS endpoint (`{server_url}/realms/{realm}/protocol/openid-connect/certs`) on first request. +- Caches keys in memory with configurable TTL (default 1 hour). +- On validation failure with cached keys: refetch JWKS once before rejecting (handles key rotation). +- Validates: signature (RS256), `iss` (must match `{server_url}/realms/{realm}`), `aud` (must contain `client_id`), `exp` (not expired), `iat` (not in future). + +**`KeycloakUserCache` model:** + +```python +class KeycloakUserCache(Base, table=True): + __tablename__ = "keycloak_user_cache" + + id: uuid.UUID = Field(default_factory=uuid4, primary_key=True) + keycloak_sub: str = Field(unique=True, index=True) + email: str + full_name: str | None = None + last_login_at: datetime | None = None +``` + +Purpose: +- Provides a stable UUID for audit trails and foreign keys from other modules (they reference `keycloak_user_cache.id`, not Keycloak's `sub` string). +- Caches user metadata so the Inertia shared props and menu system have a name/email without calling Keycloak's userinfo endpoint. +- Upserted on each web login callback and on first bearer-token request from a new user. + +**Logout (RP-initiated):** + +`POST /keycloak/logout` clears the framework session, then redirects to Keycloak's logout endpoint: +`{server_url}/realms/{realm}/protocol/openid-connect/logout?post_logout_redirect_uri={base_url}/keycloak/login&id_token_hint={id_token_from_session}` + +### 5. Conflict Detection + +**SM020 — Multiple auth providers.** New diagnostic in `ModuleDiagnostics`: + +```python +def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagnostic]: + providers = [m for m in modules if getattr(m, '_is_auth_provider', False)] + if len(providers) > 1: + names = ", ".join(m.meta.name for m in providers) + return [Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM020", + message=f"Multiple auth provider modules installed: {names}", + suggestion="Install only one auth provider (e.g. 'users' OR 'keycloak', not both)", + )] + return [] +``` + +**SM021 — No auth provider.** Warns (not errors) if no auth provider is installed — allows headless/API-only deployments that handle auth externally. + +```python + if len(providers) == 0: + return [Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM021", + message="No auth provider module installed", + suggestion="Install an auth provider module (e.g. 'simple-module-users' or 'simple-module-keycloak')", + )] +``` + +The marker `_is_auth_provider = True` is a class attribute on both `UsersModule` and `KeycloakModule`. In production (`strict=True`), SM020 (error) fails boot; SM021 (warning) logs only. + +### 6. Shared Infrastructure Moved to `auth/` + +These pieces currently live in `users/` but are provider-agnostic: + +| What | From | To | Reason | +|------|------|----|--------| +| `AuthMiddleware` | `users/middleware.py` | `auth/middleware.py` | Provider-agnostic; delegates to `AuthProvider` | +| `principal_serializer` | `users/module.py` | `auth/module.py` (`AuthModule.register_settings`) | Serializes `UserContext` for Inertia shared props; provider-agnostic since it only reads `UserContext` fields. Registered on `app.state.principal_serializer` where `InertiaLayoutDataMiddleware` reads it. | +| Framework public paths | `users/middleware.py` | `auth/middleware.py` | `/health`, `/static/`, `/api/docs` are framework-level | + +Provider-specific public paths (e.g., `/users/login`, `/keycloak/login`) are returned by each provider's `get_public_paths()`. + +`auth/deps.py` (`get_current_user`, `CurrentUser`, `require_permission`) stays unchanged — it reads from `request.state.user` which the middleware sets. + +`AuthModule.register_middleware()` now registers the `AuthMiddleware` instead of `UsersModule.register_middleware()`. Since `Users` depends on `Auth`, the middleware ordering is preserved (Auth middleware wraps Users routes). + +## Menu Integration + +Both providers register appropriate menu items: + +**Users module (unchanged):** Users admin, Profile, Logout in the user dropdown. + +**Keycloak module:** +- Logout menu item (`POST /keycloak/logout`, user dropdown, order 999). +- Optional: link to Keycloak account console (`{server_url}/realms/{realm}/account`, user dropdown, order 980) — configurable, off by default. +- No "Users admin" menu — user management happens in Keycloak's admin console. +- Role mapping admin page (sidebar, admin-only, order 100) — configure which Keycloak roles map to which framework permission groups. + +## Role Mapping + +Both providers feed roles into the same `PermissionRegistry`: + +**Users module (unchanged):** `User.roles` → `[r.name for r in user.roles]` → `UserContext.roles`. + +**Keycloak module:** JWT `realm_access.roles` → filtered through `role_mapping` dict → `UserContext.roles`. + +Default mapping (DB-backed, editable via admin UI): + +```python +role_mapping: dict[str, str] = { + "admin": "admin", + "user": "user", +} +``` + +Unknown Keycloak roles are silently ignored. The admin UI page lets operators add custom mappings (e.g., Keycloak `editor` → framework `editor` if the app defines that role). + +## Module Selection + +Framework users choose at install time in their app's `pyproject.toml`: + +```toml +# Pick one auth provider: +dependencies = [ + "simple-module-auth", # always required (contracts layer) + "simple-module-users", # local auth — OR: + # "simple-module-keycloak", # Keycloak auth +] +``` + +Both declare the same entry-point group (`simple_module`), both implement `AuthProvider`, and the SM020 diagnostic ensures only one is active. + +## Migration Path + +For framework users switching from `users` to `keycloak`: + +1. Provision Keycloak realm + client (operator responsibility, out of scope). +2. Create matching users in Keycloak (manual or bulk import, out of scope). +3. Replace `simple-module-users` with `simple-module-keycloak` in `pyproject.toml`. +4. Set `SM_KEYCLOAK_*` env vars (or configure via settings UI after first boot with a superuser session cookie). +5. Run `uv sync --all-packages && make migrate` to apply keycloak module's migration. +6. Optionally clean up users module tables: `alembic downgrade users@base`. + +No automated data migration — user identity lives in Keycloak. + +## Testing Strategy + +**Unit tests (keycloak module):** +- `KeycloakAuthProvider.resolve_user()` with mocked JWKS validation. +- JWT validation: valid token, expired, wrong issuer, wrong audience, malformed, key rotation (cache refresh). +- Role mapping: known roles, unknown roles, empty roles, admin bypass. +- OIDC flow: state generation, state validation, code exchange (mocked HTTP). +- `KeycloakUserCache` upsert logic. + +**Unit tests (auth middleware):** +- `AuthMiddleware` with a mock `AuthProvider` — verify redirect vs. 401 behavior. +- Bearer token request through the full middleware stack. +- Public path matching (framework-level + provider-specific). +- Principal-resolver chain fallthrough after provider returns None. + +**Unit tests (users module changes):** +- `UsersAuthProvider.resolve_user()` — bearer path and session path. +- Refresh token creation, rotation, revocation. +- `POST /api/users/auth/token` returns JSON tokens. +- `POST /api/users/auth/token/refresh` rotates tokens. +- OAuth callback with `Accept: application/json` returns JSON. + +**Integration tests:** +- `SM020` / `SM021` diagnostics with various module combinations. +- Full request flow: login → authenticated request → logout, with each provider. +- Bearer token flow: obtain token → API request → token expiry → refresh (users provider only). + +**E2E tests (require running Keycloak via Docker):** +- Web login redirect flow with a test Keycloak instance. +- Post-login redirect to `session["next"]`. +- Logout clears both framework session and Keycloak session. +- Role mapping reflected in menu visibility and permission checks. + +## Dependencies + +**New Python packages (keycloak module only):** +- `PyJWT` — JWT decoding and validation. Already a transitive dep via fastapi-users; used directly for Keycloak JWT validation with RS256. +- `cryptography` — RSA key handling for JWKS. Already a transitive dep. +- `httpx` — async HTTP for OIDC discovery, token exchange, JWKS fetching. Already a project dep. + +No new frontend dependencies. No new deps for the `auth` or `users` module changes. + +## Risks & Mitigations + +| Risk | Mitigation | +|------|-----------| +| JWKS cache staleness (Keycloak rotates signing keys) | Cache TTL + retry: on validation failure with cached keys, refetch JWKS once before rejecting | +| Keycloak downtime blocks all logins | Health check: keycloak module registers a check that probes the OIDC discovery endpoint; alerts fire before users notice | +| `UserContext.id` format mismatch (UUID vs. Keycloak sub) | `KeycloakUserCache` provides a stable UUID; `UserContext.id` always uses the cache table's UUID | +| Other modules FK to `users.User` table | Document that modules should reference `UserContext.id` (UUID string) for audit trails; existing modules that import `users.models.User` directly won't work with keycloak (by design — SM009 already prevents framework→module imports) | +| Session size with Keycloak tokens | Only `UserContext` dict + `id_token` (for logout hint) stored in session; access/refresh tokens are not stored server-side | +| Breaking change: `AuthMiddleware` moves from `users` to `auth` | The middleware was internal to the users module; no other module imports it directly. The observable behavior (request.state.user set, redirects, 401s) is identical. | From 3b7426dbea97f91e0ec43f4c18c5dcaaac1cc700 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 11:42:01 +0200 Subject: [PATCH 02/34] docs: add implementation plan for pluggable auth + Keycloak module 16-task TDD plan covering AuthProvider protocol, provider-agnostic middleware, UsersAuthProvider, Keycloak OIDC module (JWKS, token exchange, user cache), bearer/refresh token endpoints, SM020/SM021 diagnostics, and integration tests. --- .../2026-05-27-pluggable-auth-keycloak.md | 3046 +++++++++++++++++ 1 file changed, 3046 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md diff --git a/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md b/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md new file mode 100644 index 00000000..b064121c --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-pluggable-auth-keycloak.md @@ -0,0 +1,3046 @@ +# Pluggable Auth + Keycloak Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the auth layer swappable — framework users install either `users` (local credentials) or a new `keycloak` module (Keycloak OIDC), both implementing the same `AuthProvider` contract. Mobile clients get bearer-token support regardless of provider. + +**Architecture:** The `auth` module (contract layer) gains an `AuthProvider` protocol and a provider-agnostic `AuthMiddleware`. The `users` module implements `AuthProvider` and adds bearer-token + refresh-token endpoints. A new `keycloak` module implements `AuthProvider` via OIDC + JWKS JWT validation. A boot-time diagnostic (SM020) ensures only one provider is installed. + +**Tech Stack:** Python 3.12, FastAPI, SQLModel, PyJWT, httpx, Starlette SessionMiddleware, Alembic + +**Prerequisite:** The principal-resolver chain from `2026-05-21-auth-principal-resolver-design.md` is already partially implemented (`AuthState` with `principal_resolvers` exists on `app.state.auth`). + +--- + +## File Map + +### Auth module (contract layer) — modify existing + +| File | Action | Responsibility | +|------|--------|---------------| +| `modules/auth/auth/contracts/provider.py` | Create | `AuthProvider` protocol definition | +| `modules/auth/auth/contracts/__init__.py` | Modify | Re-export `AuthProvider` | +| `modules/auth/auth/__init__.py` | Modify | Re-export `AuthProvider` | +| `modules/auth/auth/state.py` | Modify | Add `auth_provider` field to `AuthState` | +| `modules/auth/auth/middleware.py` | Create | Provider-agnostic `AuthMiddleware` | +| `modules/auth/auth/module.py` | Modify | Register `AuthMiddleware` + `principal_serializer` | +| `modules/auth/tests/test_auth_provider_protocol.py` | Create | Protocol conformance tests | +| `modules/auth/tests/test_auth_middleware.py` | Create | Provider-agnostic middleware tests | + +### Users module — modify existing + +| File | Action | Responsibility | +|------|--------|---------------| +| `modules/users/users/provider.py` | Create | `UsersAuthProvider` implementing `AuthProvider` | +| `modules/users/users/models/refresh_token.py` | Create | `RefreshToken` SQLModel table | +| `modules/users/users/auth_local/token_api.py` | Create | `POST/DELETE /api/users/auth/token`, `POST .../token/refresh` | +| `modules/users/users/module.py` | Modify | Register as `auth_provider`, remove `AuthMiddleware` + `principal_serializer` registration | +| `modules/users/users/middleware.py` | Delete (or keep as thin import wrapper) | Logic moved to `auth/middleware.py` + `users/provider.py` | +| `modules/users/users/settings.py` | Modify | Add `bearer_token_lifetime_seconds` setting | +| `modules/users/tests/test_users_provider.py` | Create | `UsersAuthProvider` tests | +| `modules/users/tests/test_token_api.py` | Create | Token endpoint tests | + +### Framework diagnostics — modify existing + +| File | Action | Responsibility | +|------|--------|---------------| +| `framework/core/simple_module_core/diagnostics/_module.py` | Modify | Add `SM020`/`SM021` checks | +| `framework/core/tests/test_diagnostics.py` | Modify | Tests for new diagnostics | + +### Keycloak module — new package + +| File | Action | Responsibility | +|------|--------|---------------| +| `modules/keycloak/pyproject.toml` | Create | Package metadata + entry point | +| `modules/keycloak/keycloak/__init__.py` | Create | Package marker | +| `modules/keycloak/keycloak/module.py` | Create | `KeycloakModule(ModuleBase)` | +| `modules/keycloak/keycloak/settings.py` | Create | `KeycloakSettings` | +| `modules/keycloak/keycloak/state.py` | Create | `KeycloakState` dataclass | +| `modules/keycloak/keycloak/provider.py` | Create | `KeycloakAuthProvider(AuthProvider)` | +| `modules/keycloak/keycloak/jwks.py` | Create | JWKS key cache + JWT validation | +| `modules/keycloak/keycloak/oidc.py` | Create | OIDC discovery, token exchange | +| `modules/keycloak/keycloak/models.py` | Create | `KeycloakUserCache` table | +| `modules/keycloak/keycloak/endpoints/api.py` | Create | Login redirect, callback, userinfo | +| `modules/keycloak/keycloak/endpoints/views.py` | Create | Inertia login/logout pages | +| `modules/keycloak/keycloak/contracts/__init__.py` | Create | Empty | +| `modules/keycloak/keycloak/locales/en.json` | Create | English translations | +| `modules/keycloak/keycloak/pages/Login.tsx` | Create | Auto-redirect login page | +| `modules/keycloak/keycloak/pages/LoggedOut.tsx` | Create | Post-logout landing | +| `modules/keycloak/package.json` | Create | JS workspace member | +| `modules/keycloak/tsconfig.json` | Create | TypeScript config | +| `modules/keycloak/tests/test_jwks.py` | Create | JWKS cache + JWT validation tests | +| `modules/keycloak/tests/test_oidc.py` | Create | OIDC helper tests | +| `modules/keycloak/tests/test_keycloak_provider.py` | Create | Provider implementation tests | +| `modules/keycloak/tests/test_keycloak_module.py` | Create | Module lifecycle tests | +| `modules/keycloak/tests/conftest.py` | Create | Keycloak test fixtures | + +### Alembic migration + +| File | Action | Responsibility | +|------|--------|---------------| +| `host/migrations/versions/_add_users_refresh_token.py` | Create | `users_refresh_token` table | +| `host/migrations/versions/_keycloak_user_cache.py` | Create | `keycloak_user_cache` table | + +### Workspace config + +| File | Action | Responsibility | +|------|--------|---------------| +| `pyproject.toml` (root) | Modify | Add `modules/keycloak` to `tool.ty.environment.extra-paths` and `tool.pytest.ini_options.testpaths` | + +--- + +## Task 1: AuthProvider Protocol + +**Files:** +- Create: `modules/auth/auth/contracts/provider.py` +- Modify: `modules/auth/auth/contracts/__init__.py` +- Modify: `modules/auth/auth/__init__.py` +- Create: `modules/auth/tests/test_auth_provider_protocol.py` + +- [ ] **Step 1: Write the test file** + +```python +# modules/auth/tests/test_auth_provider_protocol.py +"""Tests for the AuthProvider protocol.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from starlette.requests import Request +from starlette.testclient import TestClient + + +class _FakeProvider: + """Minimal implementation to verify protocol conformance.""" + + name = "fake" + + async def resolve_user(self, request: Request) -> UserContext | None: + return None + + def get_login_url(self, request: Request, next_url: str | None = None) -> str: + return "/fake/login" + + def get_logout_url(self, request: Request) -> str: + return "/fake/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return (("/fake/login",), ()) + + def is_bearer_request(self, request: Request) -> bool: + return False + + +def test_fake_provider_satisfies_protocol(): + provider = _FakeProvider() + assert isinstance(provider, AuthProvider) + + +def test_protocol_rejects_incomplete_implementation(): + class _Incomplete: + name = "broken" + + assert not isinstance(_Incomplete(), AuthProvider) + + +def test_auth_package_reexports_auth_provider(): + import auth + + assert hasattr(auth, "AuthProvider") + assert "AuthProvider" in auth.__all__ + from auth.contracts.provider import AuthProvider as Canonical + + assert auth.AuthProvider is Canonical + + +def test_contracts_package_reexports_auth_provider(): + from auth.contracts import AuthProvider + + assert AuthProvider is not None +``` + +- [ ] **Step 2: Run tests — they should fail (AuthProvider not defined yet)** + +Run: `uv run pytest modules/auth/tests/test_auth_provider_protocol.py -v` +Expected: `ModuleNotFoundError` or `ImportError` — `auth.contracts.provider` doesn't exist. + +- [ ] **Step 3: Create the AuthProvider protocol** + +```python +# modules/auth/auth/contracts/provider.py +"""AuthProvider protocol — the contract both users and keycloak modules implement.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from starlette.requests import Request + +from auth.contracts.schemas import UserContext + + +@runtime_checkable +class AuthProvider(Protocol): + """Extension point for swappable authentication backends. + + Exactly one module (``users`` or ``keycloak``) registers an implementation + on ``app.state.auth.auth_provider`` during ``register_settings``. + The ``AuthMiddleware`` delegates to it on every request. + """ + + name: str + + async def resolve_user(self, request: Request) -> UserContext | None: ... + + def get_login_url(self, request: Request, next_url: str | None = None) -> str: ... + + def get_logout_url(self, request: Request) -> str: ... + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: ... + + def is_bearer_request(self, request: Request) -> bool: ... + + +__all__ = ["AuthProvider"] +``` + +- [ ] **Step 4: Update contracts `__init__.py`** + +Change `modules/auth/auth/contracts/__init__.py` from: +```python +"""Auth contracts — public types for other modules.""" + +from auth.contracts.schemas import UserContext + +__all__ = ["UserContext"] +``` +to: +```python +"""Auth contracts — public types for other modules.""" + +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext + +__all__ = ["AuthProvider", "UserContext"] +``` + +- [ ] **Step 5: Update auth package `__init__.py`** + +Change `modules/auth/auth/__init__.py` from: +```python +"""Auth module — shared contracts (UserContext, PrincipalResolver, deps).""" + +from auth.contracts.resolver import PrincipalResolver +from auth.contracts.schemas import UserContext + +__all__ = ["PrincipalResolver", "UserContext"] +``` +to: +```python +"""Auth module — shared contracts (UserContext, AuthProvider, PrincipalResolver, deps).""" + +from auth.contracts.provider import AuthProvider +from auth.contracts.resolver import PrincipalResolver +from auth.contracts.schemas import UserContext + +__all__ = ["AuthProvider", "PrincipalResolver", "UserContext"] +``` + +- [ ] **Step 6: Run tests — they should pass** + +Run: `uv run pytest modules/auth/tests/test_auth_provider_protocol.py -v` +Expected: All 4 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add modules/auth/auth/contracts/provider.py modules/auth/auth/contracts/__init__.py modules/auth/auth/__init__.py modules/auth/tests/test_auth_provider_protocol.py +git commit -m "feat(auth): add AuthProvider protocol for swappable auth backends" +``` + +--- + +## Task 2: Add auth_provider to AuthState + +**Files:** +- Modify: `modules/auth/auth/state.py` +- Modify: `modules/auth/tests/test_resolver_registry.py` + +- [ ] **Step 1: Write the test** + +Add to `modules/auth/tests/test_resolver_registry.py`: + +```python +def test_auth_state_has_auth_provider_field(): + from auth.state import AuthState + + state = AuthState() + assert state.auth_provider is None + + +def test_auth_state_accepts_auth_provider(): + from auth.contracts.provider import AuthProvider + from auth.state import AuthState + + class FakeProvider: + name = "fake" + + async def resolve_user(self, request): + return None + + def get_login_url(self, request, next_url=None): + return "/login" + + def get_logout_url(self, request): + return "/logout" + + def get_public_paths(self): + return ((), ()) + + def is_bearer_request(self, request): + return False + + provider = FakeProvider() + state = AuthState(auth_provider=provider) + assert state.auth_provider is provider + assert isinstance(state.auth_provider, AuthProvider) +``` + +- [ ] **Step 2: Run test — should fail** + +Run: `uv run pytest modules/auth/tests/test_resolver_registry.py::test_auth_state_has_auth_provider_field -v` +Expected: `TypeError` — `AuthState` doesn't accept `auth_provider`. + +- [ ] **Step 3: Add auth_provider field to AuthState** + +Change `modules/auth/auth/state.py` from: +```python +"""Module-owned state attached to ``app.state.auth`` by ``AuthModule.register_settings``. + +Holds the principal-resolver registry (see +``auth.contracts.resolver.PrincipalResolver``). Apps register additional +resolvers from their ``on_startup`` hook:: + + app.state.auth.principal_resolvers.append(my_pat_resolver) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from auth.contracts.resolver import PrincipalResolver + + +@dataclass +class AuthState: + """Per-app auth registry. Initialized empty; modules append resolvers.""" + + principal_resolvers: list[PrincipalResolver] = field(default_factory=list) + + +__all__ = ["AuthState"] +``` +to: +```python +"""Module-owned state attached to ``app.state.auth`` by ``AuthModule.register_settings``. + +Holds the auth provider (set by one of ``users`` or ``keycloak``) and the +principal-resolver registry. Apps register additional resolvers from their +``on_startup`` hook:: + + app.state.auth.principal_resolvers.append(my_pat_resolver) +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from auth.contracts.resolver import PrincipalResolver + +if TYPE_CHECKING: + from auth.contracts.provider import AuthProvider + + +@dataclass +class AuthState: + """Per-app auth registry. Initialized empty; provider modules populate at boot.""" + + auth_provider: AuthProvider | None = None + principal_resolvers: list[PrincipalResolver] = field(default_factory=list) + + +__all__ = ["AuthState"] +``` + +- [ ] **Step 4: Run all auth tests — should pass** + +Run: `uv run pytest modules/auth/tests/ -v` +Expected: All pass, including the two new tests. + +- [ ] **Step 5: Commit** + +```bash +git add modules/auth/auth/state.py modules/auth/tests/test_resolver_registry.py +git commit -m "feat(auth): add auth_provider slot to AuthState" +``` + +--- + +## Task 3: Provider-Agnostic AuthMiddleware in auth/ + +**Files:** +- Create: `modules/auth/auth/middleware.py` +- Create: `modules/auth/tests/test_auth_middleware.py` + +- [ ] **Step 1: Write middleware tests** + +```python +# modules/auth/tests/test_auth_middleware.py +"""Tests for the provider-agnostic AuthMiddleware.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import httpx +import pytest +from auth.contracts.schemas import UserContext +from auth.middleware import AuthMiddleware +from auth.state import AuthState +from fastapi import FastAPI, Request +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import JSONResponse + +SECRET = "test-middleware-secret" + +_TEST_USER = UserContext( + id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email="test@example.com", + name="Test User", + roles=["admin"], +) + + +class _StubProvider: + name = "stub" + + def __init__(self, *, user: UserContext | None = None): + self._user = user + + async def resolve_user(self, request): + return self._user + + def get_login_url(self, request, next_url=None): + return "/stub/login" + + def get_logout_url(self, request): + return "/stub/logout" + + def get_public_paths(self): + return (("/stub/login", "/stub/public/"), ()) + + def is_bearer_request(self, request): + auth = request.headers.get("authorization", "") + return auth.startswith("Bearer ") + + +def _build_app(provider, *, principal_resolvers=None): + app = FastAPI() + app.state.auth = AuthState( + auth_provider=provider, + principal_resolvers=list(principal_resolvers or []), + ) + + @app.get("/{path:path}") + async def catch_all(request: Request, path: str = ""): + user = getattr(request.state, "user", None) + return JSONResponse({ + "user": user.to_session_dict() if user else None, + }) + + app.add_middleware(AuthMiddleware) + app.add_middleware(SessionMiddleware, secret_key=SECRET) + return app + + +@pytest.fixture +def authenticated_app(): + return _build_app(_StubProvider(user=_TEST_USER)) + + +@pytest.fixture +def unauthenticated_app(): + return _build_app(_StubProvider(user=None)) + + +async def test_authenticated_request_sets_user(authenticated_app): + async with httpx.AsyncClient(app=authenticated_app, base_url="http://test") as c: + resp = await c.get("/some/page") + assert resp.status_code == 200 + assert resp.json()["user"]["email"] == "test@example.com" + + +async def test_unauthenticated_browser_redirects_to_login(unauthenticated_app): + async with httpx.AsyncClient( + app=unauthenticated_app, base_url="http://test", follow_redirects=False + ) as c: + resp = await c.get("/protected/page") + assert resp.status_code == 302 + assert resp.headers["location"] == "/stub/login" + + +async def test_unauthenticated_api_returns_401(unauthenticated_app): + async with httpx.AsyncClient( + app=unauthenticated_app, base_url="http://test" + ) as c: + resp = await c.get("/api/protected") + assert resp.status_code == 401 + assert resp.json()["detail"] == "Not authenticated" + + +async def test_unauthenticated_bearer_returns_401(unauthenticated_app): + async with httpx.AsyncClient( + app=unauthenticated_app, base_url="http://test" + ) as c: + resp = await c.get("/some/page", headers={"Authorization": "Bearer bad"}) + assert resp.status_code == 401 + + +async def test_public_paths_skip_auth(unauthenticated_app): + async with httpx.AsyncClient( + app=unauthenticated_app, base_url="http://test" + ) as c: + resp = await c.get("/stub/login") + assert resp.status_code == 200 + + +async def test_framework_public_paths_skip_auth(unauthenticated_app): + async with httpx.AsyncClient( + app=unauthenticated_app, base_url="http://test" + ) as c: + resp = await c.get("/health") + assert resp.status_code == 200 + + +async def test_root_is_public(unauthenticated_app): + async with httpx.AsyncClient( + app=unauthenticated_app, base_url="http://test" + ) as c: + resp = await c.get("/") + assert resp.status_code == 200 + + +async def test_resolver_chain_fallback(): + """When provider returns None, fall through to principal resolvers.""" + + async def fake_resolver(request): + auth = request.headers.get("authorization", "") + if auth == "Bearer good-token": + return _TEST_USER + return None + + app = _build_app(_StubProvider(user=None), principal_resolvers=[fake_resolver]) + async with httpx.AsyncClient(app=app, base_url="http://test") as c: + resp = await c.get("/protected", headers={"Authorization": "Bearer good-token"}) + assert resp.status_code == 200 + assert resp.json()["user"]["email"] == "test@example.com" + + +async def test_resolver_exception_is_logged_and_skipped(): + """A resolver that raises should be caught; middleware continues.""" + + async def bad_resolver(request): + raise RuntimeError("boom") + + app = _build_app(_StubProvider(user=None), principal_resolvers=[bad_resolver]) + async with httpx.AsyncClient( + app=app, base_url="http://test", follow_redirects=False + ) as c: + resp = await c.get("/protected/page") + assert resp.status_code == 302 +``` + +- [ ] **Step 2: Run tests — they should fail** + +Run: `uv run pytest modules/auth/tests/test_auth_middleware.py -v` +Expected: `ImportError` — `auth.middleware` doesn't exist. + +- [ ] **Step 3: Create the provider-agnostic AuthMiddleware** + +```python +# modules/auth/auth/middleware.py +"""Provider-agnostic authentication middleware. + +Delegates user resolution to the ``AuthProvider`` registered on +``app.state.auth.auth_provider``, then falls through to the +principal-resolver chain. Sets ``request.state.user`` and the +``current_user_id`` ContextVar for audit listeners. +""" + +from __future__ import annotations + +import logging + +from simple_module_db.listeners import current_user_id +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +logger = logging.getLogger(__name__) + +_FRAMEWORK_PUBLIC_PREFIXES = ( + "/health", + "/static/", + "/api/docs", + "/api/redoc", + "/openapi.json", + "/i18n/", +) +_FRAMEWORK_PUBLIC_EXACT = ("/",) +_SESSION_NEXT_KEY = "next" + + +class AuthMiddleware: + """Authenticate requests via the registered AuthProvider. + + On cache miss (provider returns None), falls through to the + principal-resolver chain. Unauthenticated API requests get 401 JSON; + unauthenticated browser requests get a redirect to the provider's + login URL. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path: str = scope["path"] + auth_state = scope["app"].state.auth + provider = auth_state.auth_provider + + if provider is None: + await self.app(scope, receive, send) + return + + is_public = ( + any(path.startswith(p) for p in _FRAMEWORK_PUBLIC_PREFIXES) + or path in _FRAMEWORK_PUBLIC_EXACT + ) + if not is_public: + prefix_paths, exact_paths = provider.get_public_paths() + is_public = ( + any(path.startswith(p) for p in prefix_paths) or path in exact_paths + ) + + request = Request(scope) + user_ctx = await provider.resolve_user(request) + + if user_ctx is None: + for resolver in auth_state.principal_resolvers: + try: + user_ctx = await resolver(request) + except Exception: + logger.exception( + "Principal resolver %r raised; treating as no-match", + resolver, + ) + continue + if user_ctx is not None: + break + + if user_ctx is None and not is_public: + if path.startswith("/api/") or provider.is_bearer_request(request): + response = JSONResponse( + {"detail": "Not authenticated"}, status_code=401 + ) + else: + session = scope.get("session", {}) + session[_SESSION_NEXT_KEY] = str(request.url) + response = RedirectResponse( + provider.get_login_url(request), status_code=302 + ) + await response(scope, receive, send) + return + + if user_ctx is not None: + request.state.user = user_ctx + token = current_user_id.set(user_ctx.id) + try: + await self.app(scope, receive, send) + finally: + current_user_id.reset(token) + return + + await self.app(scope, receive, send) +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `uv run pytest modules/auth/tests/test_auth_middleware.py -v` +Expected: All 9 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add modules/auth/auth/middleware.py modules/auth/tests/test_auth_middleware.py +git commit -m "feat(auth): add provider-agnostic AuthMiddleware" +``` + +--- + +## Task 4: Move principal_serializer + AuthMiddleware Registration to AuthModule + +**Files:** +- Modify: `modules/auth/auth/module.py` +- Modify: `modules/users/users/module.py` +- Modify: `modules/auth/tests/test_resolver_registry.py` + +- [ ] **Step 1: Write tests for the new AuthModule behavior** + +Add to `modules/auth/tests/test_resolver_registry.py`: + +```python +def test_auth_module_registers_middleware(): + """AuthModule.register_middleware should add AuthMiddleware.""" + from auth.module import AuthModule + from fastapi import FastAPI + + app = FastAPI() + AuthModule().register_middleware(app) + middleware_classes = [m.cls.__name__ for m in app.user_middleware] + assert "AuthMiddleware" in middleware_classes + + +def test_auth_module_registers_principal_serializer(): + """AuthModule.register_settings should set principal_serializer on app.state.""" + from auth.module import AuthModule + from fastapi import FastAPI + + app = FastAPI() + AuthModule().register_settings(app) + serializer = getattr(app.state, "principal_serializer", None) + assert serializer is not None + + from auth.contracts.schemas import UserContext + + ctx = UserContext(id="123", email="a@b.com", name="Test", roles=["admin"]) + result = serializer(ctx) + assert result == {"id": "123", "name": "Test", "email": "a@b.com", "roles": ["admin"]} +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `uv run pytest modules/auth/tests/test_resolver_registry.py::test_auth_module_registers_middleware -v` +Expected: FAIL — `AuthModule` doesn't override `register_middleware`. + +- [ ] **Step 3: Update AuthModule to register middleware + principal_serializer** + +Change `modules/auth/auth/module.py` to: + +```python +"""Auth module — shared contracts (UserContext, AuthProvider, deps). + +Intentionally minimal: this module owns the PUBLIC interface (UserContext, +AuthProvider, PrincipalResolver, get_current_user, CurrentUser, require_permission) +that every other module imports. Keeping it stable prevents churn when auth +internals change. + +The ``auth_provider`` slot on ``app.state.auth`` is the extension point +auth-provider modules (``users``, ``keycloak``) use to register themselves. +The ``principal_resolvers`` registry lets downstream modules add extra +credential sources (PAT bearer tokens, API keys, etc.). +""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.module import ModuleBase, ModuleMeta + +if TYPE_CHECKING: + from fastapi import FastAPI + + from auth.contracts.schemas import UserContext + + +def _serialize_principal(user: UserContext) -> dict: + return { + "id": user.id, + "name": user.name, + "email": user.email, + "roles": user.roles, + } + + +class AuthModule(ModuleBase): + meta = ModuleMeta( + name="Auth", + route_prefix="/auth", + ) + + def register_settings(self, app: FastAPI) -> None: + from auth.state import AuthState + + app.state.auth = AuthState() + app.state.principal_serializer = _serialize_principal + + def register_middleware(self, app: FastAPI) -> None: + from auth.middleware import AuthMiddleware + + app.add_middleware(AuthMiddleware) + + def locale_dirs(self) -> dict[str, Path]: + return {"auth": Path(str(importlib.resources.files(__package__) / "locales"))} +``` + +- [ ] **Step 4: Remove middleware + serializer registration from UsersModule** + +In `modules/users/users/module.py`: + +Remove the `register_middleware` method entirely (lines 159-162): +```python + # DELETE THIS METHOD: + def register_middleware(self, app: FastAPI) -> None: + from users.middleware import AuthMiddleware + + app.add_middleware(AuthMiddleware) +``` + +In `register_settings`, remove the `serialize_principal` function and `app.state.principal_serializer` line (lines 61-69): +```python + # DELETE THESE LINES from register_settings: + def serialize_principal(user: UserContext) -> dict: + return { + "id": user.id, + "name": user.name, + "email": user.email, + "roles": user.roles, + } + + app.state.principal_serializer = serialize_principal +``` + +Also remove the unused `from auth.contracts.schemas import UserContext` import from `register_settings` (it was only used by the deleted serializer). + +- [ ] **Step 5: Run auth + users tests** + +Run: `uv run pytest modules/auth/tests/ modules/users/tests/ -v` +Expected: All pass. The middleware behavior is unchanged — it still delegates to the provider; users module just no longer registers it. + +- [ ] **Step 6: Commit** + +```bash +git add modules/auth/auth/module.py modules/users/users/module.py modules/auth/tests/test_resolver_registry.py +git commit -m "refactor(auth,users): move AuthMiddleware + principal_serializer to auth module" +``` + +--- + +## Task 5: UsersAuthProvider Implementation + +**Files:** +- Create: `modules/users/users/provider.py` +- Create: `modules/users/tests/test_users_provider.py` +- Modify: `modules/users/users/module.py` + +- [ ] **Step 1: Write provider tests** + +```python +# modules/users/tests/test_users_provider.py +"""Tests for UsersAuthProvider.""" + +from __future__ import annotations + +import uuid + +import httpx +import pytest +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from auth.state import AuthState +from fastapi import FastAPI, Request +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import JSONResponse +from users.provider import UsersAuthProvider + +SECRET = "test-provider-secret" + + +def test_users_provider_satisfies_protocol(): + provider = UsersAuthProvider() + assert isinstance(provider, AuthProvider) + + +def test_login_url(): + provider = UsersAuthProvider() + assert provider.get_login_url(None) == "/users/login" + + +def test_logout_url(): + provider = UsersAuthProvider() + assert provider.get_logout_url(None) == "/users/logout" + + +def test_public_paths(): + provider = UsersAuthProvider() + prefixes, exact = provider.get_public_paths() + assert "/users/login" in prefixes + assert "/api/users/auth/" in prefixes + + +def test_is_bearer_request_true(): + from unittest.mock import MagicMock + + request = MagicMock() + request.headers = {"authorization": "Bearer abc123"} + provider = UsersAuthProvider() + assert provider.is_bearer_request(request) is True + + +def test_is_bearer_request_false(): + from unittest.mock import MagicMock + + request = MagicMock() + request.headers = {} + provider = UsersAuthProvider() + assert provider.is_bearer_request(request) is False +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `uv run pytest modules/users/tests/test_users_provider.py -v` +Expected: `ImportError` — `users.provider` doesn't exist. + +- [ ] **Step 3: Create UsersAuthProvider** + +```python +# modules/users/users/provider.py +"""UsersAuthProvider — AuthProvider implementation for the users module. + +Resolves users from session cookies (browser) or the principal-resolver chain +(bearer tokens, PATs). Session handling mirrors the original AuthMiddleware +logic: fast path from ``session["user_ctx"]``, slow path via DB lookup. +""" + +from __future__ import annotations + +import logging +import uuid as uuid_mod + +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from starlette.requests import Request + +logger = logging.getLogger(__name__) + +_SESSION_USER_ID_KEY = "user_id" +_SESSION_USER_CTX_KEY = "user_ctx" + + +class UsersAuthProvider: + """Cookie-based auth provider using fastapi-users' DatabaseStrategy.""" + + name = "users" + _is_auth_provider = True + + async def resolve_user(self, request: Request) -> UserContext | None: + session = request.scope.get("session", {}) + raw_user_id = session.get(_SESSION_USER_ID_KEY) + if not raw_user_id: + return None + + user_id_str = str(raw_user_id) + + cached = UserContext.from_session_dict(session.get(_SESSION_USER_CTX_KEY)) + if cached is not None and cached.id == user_id_str: + return cached + + try: + user_uuid = uuid_mod.UUID(user_id_str) + except (ValueError, TypeError): + logger.warning("Invalid user_id in session: %r", raw_user_id) + session.pop(_SESSION_USER_ID_KEY, None) + session.pop(_SESSION_USER_CTX_KEY, None) + return None + + user_ctx = await self._load_user(request.scope, user_uuid) + if user_ctx is None: + session.pop(_SESSION_USER_ID_KEY, None) + session.pop(_SESSION_USER_CTX_KEY, None) + else: + session[_SESSION_USER_CTX_KEY] = user_ctx.to_session_dict() + return user_ctx + + def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: + return "/users/login" + + def get_logout_url(self, request: Request | None) -> str: + return "/users/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return ( + ( + "/users/login", + "/users/register", + "/users/forgot-password", + "/users/reset-password", + "/users/verify", + "/users/invite/accept", + "/api/users/auth/", + "/api/users/register", + ), + (), + ) + + def is_bearer_request(self, request: Request | None) -> bool: + if request is None: + return False + return request.headers.get("authorization", "").startswith("Bearer ") + + async def _load_user(self, scope, user_id: uuid_mod.UUID) -> UserContext | None: + try: + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + from users.models import User + + session_factory = scope["app"].state.sm.db.session_factory + async with session_factory() as db_session: + stmt = ( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ) + user = (await db_session.execute(stmt)).scalar_one_or_none() + if user is None or not user.is_active or user.disabled_at is not None: + return None + return UserContext.from_user(user) + except Exception: + logger.exception( + "Failed to load user %s from DB; treating as unauthenticated", + user_id, + ) + return None +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `uv run pytest modules/users/tests/test_users_provider.py -v` +Expected: All 6 tests pass. + +- [ ] **Step 5: Register UsersAuthProvider in UsersModule** + +In `modules/users/users/module.py`, update `register_settings` to add (after the `register_module_settings` call): + +```python + from users.provider import UsersAuthProvider + + app.state.auth.auth_provider = UsersAuthProvider() +``` + +- [ ] **Step 6: Run full test suite** + +Run: `uv run pytest modules/auth/tests/ modules/users/tests/ -v` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add modules/users/users/provider.py modules/users/tests/test_users_provider.py modules/users/users/module.py +git commit -m "feat(users): implement UsersAuthProvider with session-cookie resolution" +``` + +--- + +## Task 6: Remove Old AuthMiddleware from Users Module + +**Files:** +- Modify: `modules/users/users/middleware.py` (remove or convert to thin re-export) +- Modify: `modules/users/tests/_middleware_support.py` +- Modify: `modules/users/tests/test_users_middleware.py` (update to use new middleware) + +- [ ] **Step 1: Update middleware test support to use auth.middleware** + +In `modules/users/tests/_middleware_support.py`, change the import: + +From: +```python +from users.middleware import AuthMiddleware +``` +To: +```python +from auth.middleware import AuthMiddleware +``` + +And update `_build_app` to set `auth_provider` on the `AuthState`: + +From: +```python + app.state.auth = AuthState( + principal_resolvers=list(principal_resolvers or []), + ) +``` +To: +```python + from users.provider import UsersAuthProvider + + app.state.auth = AuthState( + auth_provider=UsersAuthProvider(), + principal_resolvers=list(principal_resolvers or []), + ) +``` + +- [ ] **Step 2: Run existing middleware tests** + +Run: `uv run pytest modules/users/tests/test_users_middleware.py -v` +Expected: All pass — the behavior is identical, just routed through `auth.middleware` → `UsersAuthProvider` instead of `users.middleware.AuthMiddleware` directly. + +- [ ] **Step 3: Replace users/middleware.py with a deprecation re-export** + +Replace `modules/users/users/middleware.py` contents with: + +```python +"""Backwards-compatibility re-export. + +The canonical AuthMiddleware now lives in ``auth.middleware``. This shim +exists only to avoid breaking imports in downstream apps that referenced +``users.middleware.AuthMiddleware`` directly. +""" + +from auth.middleware import AuthMiddleware + +__all__ = ["AuthMiddleware"] +``` + +- [ ] **Step 4: Run full test suite** + +Run: `uv run pytest modules/auth/tests/ modules/users/tests/ -v` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add modules/users/users/middleware.py modules/users/tests/_middleware_support.py +git commit -m "refactor(users): delegate to auth.middleware, keep thin re-export for compat" +``` + +--- + +## Task 7: SM020/SM021 Diagnostics + +**Files:** +- Modify: `framework/core/simple_module_core/diagnostics/_module.py` +- Modify or create test in: `framework/core/tests/test_diagnostics.py` (or equivalent) + +- [ ] **Step 1: Write diagnostic tests** + +Find the existing diagnostics test file and add: + +```python +def test_sm020_multiple_auth_providers(): + """SM020 fires when two modules both set _is_auth_provider.""" + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class FakeUsersModule(ModuleBase): + meta = ModuleMeta(name="Users") + _is_auth_provider = True + + class FakeKeycloakModule(ModuleBase): + meta = ModuleMeta(name="Keycloak") + _is_auth_provider = True + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([FakeUsersModule(), FakeKeycloakModule()]) + assert len(results) == 1 + assert results[0].code == "SM020" + assert results[0].level.name == "ERROR" + + +def test_sm021_no_auth_provider(): + """SM021 fires when no module sets _is_auth_provider.""" + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class FakeDashboard(ModuleBase): + meta = ModuleMeta(name="Dashboard") + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([FakeDashboard()]) + assert len(results) == 1 + assert results[0].code == "SM021" + assert results[0].level.name == "WARNING" + + +def test_sm020_single_provider_passes(): + """No diagnostic when exactly one auth provider is installed.""" + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class FakeUsersModule(ModuleBase): + meta = ModuleMeta(name="Users") + _is_auth_provider = True + + class FakeDashboard(ModuleBase): + meta = ModuleMeta(name="Dashboard") + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([FakeUsersModule(), FakeDashboard()]) + assert results == [] +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `uv run pytest framework/core/tests/ -k "sm020 or sm021 or auth_provider_conflict" -v` +Expected: `AttributeError` — `_check_auth_provider_conflict` doesn't exist. + +- [ ] **Step 3: Add the check method and wire it into `run()`** + +In `framework/core/simple_module_core/diagnostics/_module.py`, add to the `run()` method: +```python + diagnostics.extend(self._check_auth_provider_conflict(modules)) +``` + +Add the new method to `ModuleDiagnostics`: + +```python + def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagnostic]: + """SM020/SM021: exactly one auth provider module must be installed.""" + providers = [m for m in modules if getattr(m, "_is_auth_provider", False)] + diags: list[Diagnostic] = [] + if len(providers) > 1: + names = ", ".join(m.meta.name for m in providers) + diags.append( + Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM020", + message=f"Multiple auth provider modules installed: {names}", + module_name=providers[0].meta.name, + suggestion=( + "Install only one auth provider " + "(e.g. 'users' OR 'keycloak', not both)" + ), + ) + ) + elif len(providers) == 0: + diags.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM021", + message="No auth provider module installed", + module_name="(none)", + suggestion=( + "Install an auth provider module " + "(e.g. 'simple-module-users' or 'simple-module-keycloak')" + ), + ) + ) + return diags +``` + +- [ ] **Step 4: Add `_is_auth_provider = True` to UsersModule** + +In `modules/users/users/module.py`, add the class attribute: +```python +class UsersModule(ModuleBase): + meta = ModuleMeta(...) + _is_auth_provider = True +``` + +- [ ] **Step 5: Run tests** + +Run: `uv run pytest framework/core/tests/ -k "sm020 or sm021 or auth_provider" -v` +Expected: All 3 new tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add framework/core/simple_module_core/diagnostics/_module.py modules/users/users/module.py +git commit -m "feat(diagnostics): SM020/SM021 — exactly one auth provider required" +``` + +--- + +## Task 8: Keycloak Module Scaffold — Package + Module + Settings + +**Files:** +- Create: `modules/keycloak/pyproject.toml` +- Create: `modules/keycloak/keycloak/__init__.py` +- Create: `modules/keycloak/keycloak/module.py` +- Create: `modules/keycloak/keycloak/settings.py` +- Create: `modules/keycloak/keycloak/state.py` +- Create: `modules/keycloak/keycloak/contracts/__init__.py` +- Create: `modules/keycloak/keycloak/locales/en.json` +- Create: `modules/keycloak/package.json` +- Create: `modules/keycloak/tsconfig.json` +- Create: `modules/keycloak/tests/__init__.py` +- Create: `modules/keycloak/tests/conftest.py` +- Create: `modules/keycloak/tests/test_keycloak_module.py` +- Modify: `pyproject.toml` (root — add to `extra-paths` and `testpaths`) + +- [ ] **Step 1: Write module registration test** + +```python +# modules/keycloak/tests/test_keycloak_module.py +"""Tests for KeycloakModule lifecycle.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider + + +def test_keycloak_module_meta(): + from keycloak.module import KeycloakModule + + mod = KeycloakModule() + assert mod.meta.name == "Keycloak" + assert mod.meta.depends_on == ["Auth"] + assert mod._is_auth_provider is True + + +def test_keycloak_module_registers_provider(): + from auth.state import AuthState + from fastapi import FastAPI + from keycloak.module import KeycloakModule + + app = FastAPI() + app.state.auth = AuthState() + KeycloakModule().register_settings(app) + + assert app.state.auth.auth_provider is not None + assert isinstance(app.state.auth.auth_provider, AuthProvider) + assert app.state.auth.auth_provider.name == "keycloak" +``` + +- [ ] **Step 2: Create pyproject.toml** + +```toml +# modules/keycloak/pyproject.toml +[project] +name = "simple_module_keycloak" +version = "0.0.15" +description = "Keycloak OIDC authentication provider for simple_module — swap with simple_module_users" +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "keycloak", "oidc", "authentication", "fastapi"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "simple_module_core==0.0.15", + "simple_module_db==0.0.15", + "simple_module_hosting==0.0.15", + "simple_module_settings==0.0.15", + "simple_module_auth==0.0.15", + "PyJWT[crypto]>=2.8", + "httpx>=0.27", +] + +[project.entry-points.simple_module] +keycloak = "keycloak.module:KeycloakModule" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["keycloak"] + +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "keycloak/package.json" + +[tool.uv.sources] +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +simple_module_hosting = { workspace = true } +simple_module_settings = { workspace = true } +simple_module_auth = { workspace = true } +``` + +- [ ] **Step 3: Create package files** + +`modules/keycloak/keycloak/__init__.py`: +```python +"""Keycloak OIDC authentication provider for simple_module.""" +``` + +`modules/keycloak/keycloak/contracts/__init__.py`: +```python +"""Keycloak module contracts.""" +``` + +`modules/keycloak/keycloak/locales/en.json`: +```json +{ + "login": { + "redirecting": "Redirecting to identity provider…", + "title": "Sign In" + }, + "logout": { + "title": "Signed Out", + "message": "You have been signed out successfully." + }, + "errors": { + "callback_failed": "Authentication failed. Please try again.", + "invalid_state": "Invalid authentication state. Please try again.", + "token_validation_failed": "Token validation failed." + } +} +``` + +`modules/keycloak/package.json`: +```json +{ + "name": "@simple-module/keycloak", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": {} +} +``` + +`modules/keycloak/tsconfig.json`: +```json +{ + "extends": "../../host/client_app/tsconfig.json", + "include": ["keycloak/**/*.ts", "keycloak/**/*.tsx"] +} +``` + +`modules/keycloak/tests/__init__.py`: empty file. + +`modules/keycloak/tests/conftest.py`: +```python +"""Keycloak module test fixtures.""" +``` + +- [ ] **Step 4: Create KeycloakSettings** + +```python +# modules/keycloak/keycloak/settings.py +"""Keycloak module settings — DB-backed via ``register_module_settings``.""" + +from __future__ import annotations + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from simple_module_core.dotenv import env_str +from simple_module_core.environments import NON_PROD_ENVIRONMENTS + + +class KeycloakSettings(BaseSettings): + """Keycloak OIDC configuration.""" + + model_config = SettingsConfigDict(extra="ignore") + + server_url: str = env_str("SM_KEYCLOAK_SERVER_URL", "") + realm: str = env_str("SM_KEYCLOAK_REALM", "") + client_id: str = env_str("SM_KEYCLOAK_CLIENT_ID", "") + client_secret: str = env_str("SM_KEYCLOAK_CLIENT_SECRET", "") + + roles_claim_path: str = "realm_access.roles" + admin_role: str = "admin" + login_redirect_url: str = "/dashboard/" + jwks_cache_ttl_seconds: int = 3600 + + role_mapping: dict[str, str] = Field( + default_factory=lambda: {"admin": "admin", "user": "user"}, + ) + + @model_validator(mode="after") + def _check_required_in_production(self) -> KeycloakSettings: + import os + + env = os.environ.get("SM_ENVIRONMENT", "development") + if env in NON_PROD_ENVIRONMENTS: + return self + missing = [] + if not self.server_url: + missing.append("SM_KEYCLOAK_SERVER_URL") + if not self.realm: + missing.append("SM_KEYCLOAK_REALM") + if not self.client_id: + missing.append("SM_KEYCLOAK_CLIENT_ID") + if not self.client_secret: + missing.append("SM_KEYCLOAK_CLIENT_SECRET") + if missing: + msg = f"Keycloak settings required in production: {', '.join(missing)}" + raise ValueError(msg) + return self +``` + +- [ ] **Step 5: Create KeycloakState** + +```python +# modules/keycloak/keycloak/state.py +"""Module-scoped state container for the keycloak module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from keycloak.jwks import JWKSCache + from keycloak.settings import KeycloakSettings + + +@dataclass +class KeycloakState: + """Keycloak-module singletons. Single slot at ``app.state.keycloak``.""" + + settings: KeycloakSettings + jwks_cache: JWKSCache | None = None +``` + +- [ ] **Step 6: Create KeycloakModule (minimal — provider wired in next tasks)** + +```python +# modules/keycloak/keycloak/module.py +"""Keycloak OIDC authentication module.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +if TYPE_CHECKING: + from fastapi import APIRouter, FastAPI + + +class KeycloakModule(ModuleBase): + meta = ModuleMeta( + name="Keycloak", + route_prefix="/api/keycloak", + view_prefix="/keycloak", + depends_on=["Auth"], + ) + _is_auth_provider = True + + def register_settings(self, app: FastAPI) -> None: + import importlib + + from keycloak.provider import KeycloakAuthProvider + from keycloak.settings import KeycloakSettings + from keycloak.state import KeycloakState + + register_module_settings = importlib.import_module( + "settings.registration" + ).register_module_settings + + register_module_settings( + app, "keycloak", KeycloakSettings, lambda s: KeycloakState(settings=s) + ) + + app.state.auth.auth_provider = KeycloakAuthProvider(app.state.keycloak.settings) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Logout", + url="/keycloak/logout", + icon="log-out", + order=999, + section=MenuSection.USER_DROPDOWN, + method="post", + ) + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from keycloak.endpoints.api import router as api + from keycloak.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + async def on_startup(self, app: FastAPI) -> None: + from keycloak.jwks import JWKSCache + + state = app.state.keycloak + s = state.settings + if s.server_url and s.realm: + state.jwks_cache = JWKSCache( + jwks_url=f"{s.server_url}/realms/{s.realm}/protocol/openid-connect/certs", + ttl_seconds=s.jwks_cache_ttl_seconds, + ) + provider = app.state.auth.auth_provider + provider.jwks_cache = state.jwks_cache + + def locale_dirs(self) -> dict[str, Path]: + return { + "keycloak": Path( + str(importlib.resources.files(__package__) / "locales") + ) + } +``` + +- [ ] **Step 7: Update root pyproject.toml** + +Add `"modules/keycloak"` to `tool.ty.environment.extra-paths` and `"modules/keycloak/tests"` to `tool.pytest.ini_options.testpaths`. + +- [ ] **Step 8: Run `uv sync --all-packages` then tests** + +Run: `uv sync --all-packages && uv run pytest modules/keycloak/tests/test_keycloak_module.py -v` +Expected: All pass. + +- [ ] **Step 9: Commit** + +```bash +git add modules/keycloak/ pyproject.toml +git commit -m "feat(keycloak): scaffold keycloak module with settings + provider registration" +``` + +--- + +## Task 9: JWKS Cache + JWT Validation + +**Files:** +- Create: `modules/keycloak/keycloak/jwks.py` +- Create: `modules/keycloak/tests/test_jwks.py` + +- [ ] **Step 1: Write JWKS/JWT tests** + +```python +# modules/keycloak/tests/test_jwks.py +"""Tests for JWKS key cache and JWT validation.""" + +from __future__ import annotations + +import json +import time + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from keycloak.jwks import JWKSCache + + +def _generate_rsa_keypair(): + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key = private_key.public_key() + return private_key, public_key + + +def _make_jwks_response(public_key, kid="test-key-1"): + from jwt.algorithms import RSAAlgorithm + + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk["kid"] = kid + jwk["use"] = "sig" + jwk["alg"] = "RS256" + return {"keys": [jwk]} + + +def _sign_token(private_key, payload, kid="test-key-1"): + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) + + +@pytest.fixture +def rsa_keys(): + return _generate_rsa_keypair() + + +@pytest.fixture +def valid_payload(): + now = int(time.time()) + return { + "sub": "user-123", + "email": "test@example.com", + "preferred_username": "testuser", + "iss": "https://auth.example.com/realms/test", + "aud": "my-client", + "exp": now + 3600, + "iat": now, + "realm_access": {"roles": ["admin", "user"]}, + } + + +async def test_validate_jwt_valid_token(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is not None + assert claims["sub"] == "user-123" + assert claims["email"] == "test@example.com" + + +async def test_validate_jwt_expired_token(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + valid_payload["exp"] = int(time.time()) - 100 + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is None + + +async def test_validate_jwt_wrong_issuer(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://wrong-issuer.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is None + + +async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="wrong-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is None + + +async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload, httpx_mock): + """When a token has a kid not in cache, refetch JWKS once before rejecting.""" + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key, kid="rotated-key") + httpx_mock.add_response(url="https://auth.example.com/jwks", json={"keys": []}) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload, kid="rotated-key") + claims = await cache.validate_jwt(token) + assert claims is not None + assert claims["sub"] == "user-123" +``` + +Note: These tests require `pytest-httpx` for mocking. Add to dev dependencies if not already present, or use `httpx_mock` fixture from `pytest-httpx`. Alternatively, mock `httpx.AsyncClient.get` directly if `pytest-httpx` is not available. + +- [ ] **Step 2: Run tests — should fail** + +Run: `uv run pytest modules/keycloak/tests/test_jwks.py -v` +Expected: `ImportError` — `keycloak.jwks` doesn't exist. + +- [ ] **Step 3: Implement JWKSCache** + +```python +# modules/keycloak/keycloak/jwks.py +"""JWKS key cache and JWT validation for Keycloak tokens.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +import httpx +import jwt +from jwt.algorithms import RSAAlgorithm + +logger = logging.getLogger(__name__) + + +class JWKSCache: + """Caches Keycloak's public signing keys and validates JWTs. + + On validation failure with cached keys, refetches JWKS once before + rejecting — this handles Keycloak key rotation gracefully. + """ + + def __init__( + self, + jwks_url: str, + ttl_seconds: int = 3600, + issuer: str = "", + audience: str = "", + ) -> None: + self._jwks_url = jwks_url + self._ttl = ttl_seconds + self._issuer = issuer + self._audience = audience + self._keys: dict[str, Any] = {} + self._fetched_at: float = 0 + + async def validate_jwt(self, token: str) -> dict[str, Any] | None: + """Decode and validate a JWT. Returns claims dict or None.""" + try: + unverified = jwt.get_unverified_header(token) + except jwt.exceptions.DecodeError: + return None + + kid = unverified.get("kid") + if kid is None: + return None + + key = await self._get_key(kid) + if key is None: + return None + + return self._decode(token, key) + + def _decode(self, token: str, key: Any) -> dict[str, Any] | None: + try: + return jwt.decode( + token, + key, + algorithms=["RS256"], + issuer=self._issuer if self._issuer else None, + audience=self._audience if self._audience else None, + options={ + "verify_iss": bool(self._issuer), + "verify_aud": bool(self._audience), + }, + ) + except (jwt.ExpiredSignatureError, jwt.InvalidIssuerError, jwt.InvalidAudienceError): + return None + except jwt.PyJWTError: + logger.exception("JWT validation failed") + return None + + async def _get_key(self, kid: str) -> Any | None: + if self._is_stale() or kid not in self._keys: + await self._fetch_keys() + + if kid in self._keys: + return self._keys[kid] + + # Key rotation: refetch once more if kid still missing + await self._fetch_keys(force=True) + return self._keys.get(kid) + + def _is_stale(self) -> bool: + return time.monotonic() - self._fetched_at > self._ttl + + async def _fetch_keys(self, *, force: bool = False) -> None: + if not force and not self._is_stale(): + return + try: + async with httpx.AsyncClient() as client: + resp = await client.get(self._jwks_url, timeout=10) + resp.raise_for_status() + jwks_data = resp.json() + except Exception: + logger.exception("Failed to fetch JWKS from %s", self._jwks_url) + return + + new_keys: dict[str, Any] = {} + for key_data in jwks_data.get("keys", []): + kid = key_data.get("kid") + if kid and key_data.get("alg") == "RS256": + try: + public_key = RSAAlgorithm.from_jwk(key_data) + new_keys[kid] = public_key + except Exception: + logger.warning("Failed to parse JWK kid=%s", kid) + self._keys = new_keys + self._fetched_at = time.monotonic() +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `uv run pytest modules/keycloak/tests/test_jwks.py -v` +Expected: All 5 tests pass. (If `pytest-httpx` is not installed, install it: `uv add --dev pytest-httpx` or use inline mocking.) + +- [ ] **Step 5: Commit** + +```bash +git add modules/keycloak/keycloak/jwks.py modules/keycloak/tests/test_jwks.py +git commit -m "feat(keycloak): JWKS key cache with JWT validation and key-rotation retry" +``` + +--- + +## Task 10: OIDC Discovery + Token Exchange + +**Files:** +- Create: `modules/keycloak/keycloak/oidc.py` +- Create: `modules/keycloak/tests/test_oidc.py` + +- [ ] **Step 1: Write OIDC helper tests** + +```python +# modules/keycloak/tests/test_oidc.py +"""Tests for OIDC discovery and token exchange helpers.""" + +from __future__ import annotations + +import secrets + +import pytest +from keycloak.oidc import OIDCClient + + +@pytest.fixture +def oidc_client(): + return OIDCClient( + server_url="https://auth.example.com", + realm="test", + client_id="my-app", + client_secret="secret123", + ) + + +def test_authorization_url(oidc_client): + url, state = oidc_client.build_authorization_url( + redirect_uri="https://app.example.com/callback", + nonce="test-nonce", + ) + assert "auth.example.com/realms/test/protocol/openid-connect/auth" in url + assert "client_id=my-app" in url + assert "redirect_uri=" in url + assert "response_type=code" in url + assert "scope=openid" in url + assert "nonce=test-nonce" in url + assert state is not None + assert len(state) > 0 + + +def test_token_endpoint_url(oidc_client): + assert oidc_client.token_endpoint == ( + "https://auth.example.com/realms/test/protocol/openid-connect/token" + ) + + +def test_logout_url(oidc_client): + url = oidc_client.build_logout_url( + post_logout_redirect_uri="https://app.example.com/login", + id_token_hint="token123", + ) + assert "auth.example.com/realms/test/protocol/openid-connect/logout" in url + assert "post_logout_redirect_uri=" in url + assert "id_token_hint=token123" in url + + +def test_issuer(oidc_client): + assert oidc_client.issuer == "https://auth.example.com/realms/test" + + +async def test_exchange_code(oidc_client, httpx_mock): + httpx_mock.add_response( + url=oidc_client.token_endpoint, + json={ + "access_token": "at-123", + "id_token": "id-123", + "refresh_token": "rt-123", + "token_type": "Bearer", + "expires_in": 300, + }, + ) + tokens = await oidc_client.exchange_code( + code="auth-code-xyz", + redirect_uri="https://app.example.com/callback", + ) + assert tokens["access_token"] == "at-123" + assert tokens["id_token"] == "id-123" +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `uv run pytest modules/keycloak/tests/test_oidc.py -v` +Expected: `ImportError`. + +- [ ] **Step 3: Implement OIDCClient** + +```python +# modules/keycloak/keycloak/oidc.py +"""OIDC helpers for Keycloak — authorization URL, token exchange, logout.""" + +from __future__ import annotations + +import secrets +from typing import Any +from urllib.parse import urlencode + +import httpx + + +class OIDCClient: + """Thin wrapper around Keycloak's OIDC endpoints.""" + + def __init__( + self, + server_url: str, + realm: str, + client_id: str, + client_secret: str, + ) -> None: + self._base = f"{server_url.rstrip('/')}/realms/{realm}/protocol/openid-connect" + self._client_id = client_id + self._client_secret = client_secret + self._server_url = server_url.rstrip("/") + self._realm = realm + + @property + def issuer(self) -> str: + return f"{self._server_url}/realms/{self._realm}" + + @property + def token_endpoint(self) -> str: + return f"{self._base}/token" + + @property + def jwks_url(self) -> str: + return f"{self._base}/certs" + + def build_authorization_url( + self, + redirect_uri: str, + nonce: str, + scope: str = "openid email profile", + ) -> tuple[str, str]: + """Build the OIDC authorization URL. Returns (url, state).""" + state = secrets.token_urlsafe(32) + params = { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scope, + "state": state, + "nonce": nonce, + } + url = f"{self._base}/auth?{urlencode(params)}" + return url, state + + async def exchange_code( + self, + code: str, + redirect_uri: str, + ) -> dict[str, Any]: + """Exchange an authorization code for tokens.""" + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self._client_id, + "client_secret": self._client_secret, + } + async with httpx.AsyncClient() as client: + resp = await client.post(self.token_endpoint, data=data, timeout=10) + resp.raise_for_status() + return resp.json() + + def build_logout_url( + self, + post_logout_redirect_uri: str, + id_token_hint: str | None = None, + ) -> str: + params: dict[str, str] = { + "post_logout_redirect_uri": post_logout_redirect_uri, + } + if id_token_hint: + params["id_token_hint"] = id_token_hint + return f"{self._base}/logout?{urlencode(params)}" +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `uv run pytest modules/keycloak/tests/test_oidc.py -v` +Expected: All 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add modules/keycloak/keycloak/oidc.py modules/keycloak/tests/test_oidc.py +git commit -m "feat(keycloak): OIDC client with authorization URL, token exchange, logout" +``` + +--- + +## Task 11: KeycloakUserCache Model + Migration + +**Files:** +- Create: `modules/keycloak/keycloak/models.py` +- Create migration via Alembic + +- [ ] **Step 1: Create the model** + +```python +# modules/keycloak/keycloak/models.py +"""Keycloak user cache — maps Keycloak sub to a stable framework UUID.""" + +from __future__ import annotations + +import uuid as uuid_mod +from datetime import datetime + +from simple_module_db.base import create_module_base +from sqlmodel import Field + +Base = create_module_base("keycloak") + + +class KeycloakUserCache(Base, table=True): + __tablename__ = "keycloak_user_cache" + + id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) + keycloak_sub: str = Field(unique=True, index=True) + email: str = "" + full_name: str | None = None + last_login_at: datetime | None = None +``` + +- [ ] **Step 2: Generate migration** + +Run: `make migration msg="add keycloak_user_cache table"` +Expected: New migration file created in `host/migrations/versions/`. + +- [ ] **Step 3: Apply migration** + +Run: `make migrate` +Expected: Migration applies cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add modules/keycloak/keycloak/models.py host/migrations/versions/*keycloak* +git commit -m "feat(keycloak): KeycloakUserCache model + migration" +``` + +--- + +## Task 12: KeycloakAuthProvider Implementation + +**Files:** +- Create: `modules/keycloak/keycloak/provider.py` +- Create: `modules/keycloak/tests/test_keycloak_provider.py` + +- [ ] **Step 1: Write provider tests** + +```python +# modules/keycloak/tests/test_keycloak_provider.py +"""Tests for KeycloakAuthProvider.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from keycloak.provider import KeycloakAuthProvider +from keycloak.settings import KeycloakSettings + + +@pytest.fixture +def settings(): + return KeycloakSettings( + server_url="https://auth.example.com", + realm="test", + client_id="my-app", + client_secret="secret", + role_mapping={"admin": "admin", "user": "user", "editor": "editor"}, + ) + + +@pytest.fixture +def provider(settings): + return KeycloakAuthProvider(settings) + + +def test_satisfies_protocol(provider): + assert isinstance(provider, AuthProvider) + + +def test_name(provider): + assert provider.name == "keycloak" + + +def test_login_url(provider): + assert provider.get_login_url(None) == "/keycloak/login" + + +def test_logout_url(provider): + assert provider.get_logout_url(None) == "/keycloak/logout" + + +def test_public_paths(provider): + prefixes, exact = provider.get_public_paths() + assert "/keycloak/login" in prefixes + assert "/api/keycloak/auth/" in prefixes + + +def test_is_bearer_request(provider): + req = MagicMock() + req.headers = {"authorization": "Bearer abc"} + assert provider.is_bearer_request(req) is True + + req.headers = {} + assert provider.is_bearer_request(req) is False + + +def test_claims_to_user_context(provider): + claims = { + "sub": "kc-user-123", + "email": "test@example.com", + "preferred_username": "testuser", + "realm_access": {"roles": ["admin", "unknown_role", "user"]}, + } + ctx = provider._claims_to_user_context(claims, cache_id="aaaaaaaa-0000-0000-0000-000000000001") + assert isinstance(ctx, UserContext) + assert ctx.id == "aaaaaaaa-0000-0000-0000-000000000001" + assert ctx.email == "test@example.com" + assert ctx.name == "testuser" + assert sorted(ctx.roles) == ["admin", "user"] + # "unknown_role" not in mapping, so excluded + + +def test_claims_to_user_context_no_roles(provider): + claims = {"sub": "kc-user-456", "email": "noroles@example.com"} + ctx = provider._claims_to_user_context(claims, cache_id="bbbb") + assert ctx.roles == [] + + +def test_extract_roles_custom_claim_path(settings): + settings.roles_claim_path = "resource_access.my-app.roles" + provider = KeycloakAuthProvider(settings) + claims = {"sub": "x", "resource_access": {"my-app": {"roles": ["admin"]}}} + ctx = provider._claims_to_user_context(claims, cache_id="cccc") + assert ctx.roles == ["admin"] +``` + +- [ ] **Step 2: Run tests — should fail** + +Run: `uv run pytest modules/keycloak/tests/test_keycloak_provider.py -v` +Expected: `ImportError`. + +- [ ] **Step 3: Implement KeycloakAuthProvider** + +```python +# modules/keycloak/keycloak/provider.py +"""KeycloakAuthProvider — resolves users from Keycloak JWTs or session.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from auth.contracts.schemas import UserContext +from starlette.requests import Request + +if TYPE_CHECKING: + from keycloak.jwks import JWKSCache + from keycloak.settings import KeycloakSettings + +logger = logging.getLogger(__name__) + +_SESSION_USER_CTX_KEY = "user_ctx" + + +class KeycloakAuthProvider: + """OIDC auth provider backed by Keycloak.""" + + name = "keycloak" + _is_auth_provider = True + + def __init__(self, settings: KeycloakSettings) -> None: + self._settings = settings + self.jwks_cache: JWKSCache | None = None + + async def resolve_user(self, request: Request) -> UserContext | None: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return await self._resolve_bearer(request, auth_header[7:]) + + session = request.scope.get("session", {}) + return UserContext.from_session_dict(session.get(_SESSION_USER_CTX_KEY)) + + def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: + return "/keycloak/login" + + def get_logout_url(self, request: Request | None) -> str: + return "/keycloak/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return ( + ("/keycloak/login", "/keycloak/logout", "/api/keycloak/auth/"), + (), + ) + + def is_bearer_request(self, request: Request | None) -> bool: + if request is None: + return False + return request.headers.get("authorization", "").startswith("Bearer ") + + async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None: + if self.jwks_cache is None: + logger.warning("JWKS cache not initialized; rejecting bearer token") + return None + claims = await self.jwks_cache.validate_jwt(token) + if claims is None: + return None + + cache_id = await self._upsert_user_cache(request, claims) + return self._claims_to_user_context(claims, cache_id=cache_id) + + def _claims_to_user_context( + self, + claims: dict[str, Any], + *, + cache_id: str, + ) -> UserContext: + roles_raw = _extract_nested(claims, self._settings.roles_claim_path) + mapped = [ + self._settings.role_mapping[r] + for r in (roles_raw or []) + if r in self._settings.role_mapping + ] + return UserContext( + id=cache_id, + email=claims.get("email", ""), + name=claims.get("preferred_username") or claims.get("name", ""), + roles=mapped, + tenant_id=claims.get("tenant_id"), + ) + + async def _upsert_user_cache(self, request: Request, claims: dict) -> str: + """Upsert KeycloakUserCache and return its UUID as string.""" + try: + from keycloak.models import KeycloakUserCache + from sqlalchemy import select + + session_factory = request.app.state.sm.db.session_factory + sub = claims["sub"] + async with session_factory() as db: + stmt = select(KeycloakUserCache).where( + KeycloakUserCache.keycloak_sub == sub + ) + row = (await db.execute(stmt)).scalar_one_or_none() + if row is None: + import uuid as uuid_mod + from datetime import datetime, timezone + + row = KeycloakUserCache( + id=uuid_mod.uuid4(), + keycloak_sub=sub, + email=claims.get("email", ""), + full_name=claims.get("preferred_username"), + last_login_at=datetime.now(timezone.utc), + ) + db.add(row) + await db.flush() + else: + from datetime import datetime, timezone + + row.email = claims.get("email", row.email) + row.full_name = claims.get("preferred_username", row.full_name) + row.last_login_at = datetime.now(timezone.utc) + await db.flush() + return str(row.id) + except Exception: + logger.exception("Failed to upsert KeycloakUserCache for sub=%s", claims.get("sub")) + return claims.get("sub", "unknown") + + +def _extract_nested(data: dict, path: str) -> list[str] | None: + """Extract a value from a nested dict using a dot-separated path.""" + parts = path.split(".") + current: Any = data + for part in parts: + if not isinstance(current, dict): + return None + current = current.get(part) + if current is None: + return None + return current if isinstance(current, list) else None +``` + +- [ ] **Step 4: Run tests — should pass** + +Run: `uv run pytest modules/keycloak/tests/test_keycloak_provider.py -v` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add modules/keycloak/keycloak/provider.py modules/keycloak/tests/test_keycloak_provider.py +git commit -m "feat(keycloak): KeycloakAuthProvider with JWT resolution and role mapping" +``` + +--- + +## Task 13: Keycloak Endpoints (API + Views) + +**Files:** +- Create: `modules/keycloak/keycloak/endpoints/api.py` +- Create: `modules/keycloak/keycloak/endpoints/__init__.py` +- Create: `modules/keycloak/keycloak/endpoints/views.py` +- Create: `modules/keycloak/keycloak/pages/Login.tsx` +- Create: `modules/keycloak/keycloak/pages/LoggedOut.tsx` + +- [ ] **Step 1: Create endpoints `__init__.py`** + +```python +# modules/keycloak/keycloak/endpoints/__init__.py +"""Keycloak endpoint routers.""" +``` + +- [ ] **Step 2: Create API endpoints** + +```python +# modules/keycloak/keycloak/endpoints/api.py +"""Keycloak OIDC API endpoints — login redirect, callback.""" + +from __future__ import annotations + +import logging +import secrets +from typing import TYPE_CHECKING + +from fastapi import APIRouter, HTTPException, Request +from starlette.responses import RedirectResponse + +if TYPE_CHECKING: + from keycloak.settings import KeycloakSettings + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["keycloak-auth"]) + +_SESSION_OIDC_STATE = "keycloak_oidc_state" +_SESSION_OIDC_NONCE = "keycloak_oidc_nonce" +_SESSION_USER_CTX = "user_ctx" +_SESSION_ID_TOKEN = "keycloak_id_token" +_SESSION_NEXT = "next" + + +def _get_settings(request: Request) -> KeycloakSettings: + return request.app.state.keycloak.settings + + +def _get_oidc_client(request: Request): + from keycloak.oidc import OIDCClient + + s = _get_settings(request) + return OIDCClient( + server_url=s.server_url, + realm=s.realm, + client_id=s.client_id, + client_secret=s.client_secret, + ) + + +@router.get("/login") +async def oidc_login(request: Request): + """Redirect to Keycloak's authorization endpoint.""" + client = _get_oidc_client(request) + callback_url = str(request.url_for("oidc_callback")) + nonce = secrets.token_urlsafe(32) + url, state = client.build_authorization_url( + redirect_uri=callback_url, + nonce=nonce, + ) + request.session[_SESSION_OIDC_STATE] = state + request.session[_SESSION_OIDC_NONCE] = nonce + return RedirectResponse(url, status_code=302) + + +@router.get("/callback") +async def oidc_callback(request: Request): + """Handle Keycloak's authorization code callback.""" + code = request.query_params.get("code") + state = request.query_params.get("state") + + expected_state = request.session.pop(_SESSION_OIDC_STATE, None) + nonce = request.session.pop(_SESSION_OIDC_NONCE, None) + + if not code or not state or state != expected_state: + raise HTTPException(status_code=400, detail="Invalid OIDC state") + + client = _get_oidc_client(request) + callback_url = str(request.url_for("oidc_callback")) + + try: + tokens = await client.exchange_code(code=code, redirect_uri=callback_url) + except Exception: + logger.exception("Token exchange failed") + raise HTTPException(status_code=502, detail="Token exchange failed") + + id_token = tokens.get("id_token", "") + access_token = tokens.get("access_token", "") + + jwks_cache = request.app.state.keycloak.jwks_cache + claims = await jwks_cache.validate_jwt(access_token) if jwks_cache else None + if claims is None: + raise HTTPException(status_code=401, detail="Token validation failed") + + provider = request.app.state.auth.auth_provider + cache_id = await provider._upsert_user_cache(request, claims) + user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id) + + request.session[_SESSION_USER_CTX] = user_ctx.to_session_dict() + request.session[_SESSION_ID_TOKEN] = id_token + + s = _get_settings(request) + next_url = request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url + return RedirectResponse(next_url, status_code=303) +``` + +- [ ] **Step 3: Create view endpoints** + +```python +# modules/keycloak/keycloak/endpoints/views.py +"""Keycloak Inertia view routes — login page, logout.""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from simple_module_hosting.inertia_deps import InertiaDep +from starlette.responses import RedirectResponse + +router = APIRouter(tags=["keycloak-views"]) + +_SESSION_USER_CTX = "user_ctx" +_SESSION_ID_TOKEN = "keycloak_id_token" + + +@router.get("/login") +async def login_page(request: Request, inertia: InertiaDep): + """Render a minimal login page that can auto-redirect to Keycloak.""" + return inertia.render("Keycloak/Login") + + +@router.post("/logout") +async def logout(request: Request): + """Clear framework session and redirect to Keycloak's logout endpoint.""" + from keycloak.oidc import OIDCClient + + s = request.app.state.keycloak.settings + id_token = request.session.get(_SESSION_ID_TOKEN) + + request.session.clear() + + client = OIDCClient( + server_url=s.server_url, + realm=s.realm, + client_id=s.client_id, + client_secret=s.client_secret, + ) + base_url = str(request.base_url).rstrip("/") + logout_url = client.build_logout_url( + post_logout_redirect_uri=f"{base_url}/keycloak/login", + id_token_hint=id_token, + ) + return RedirectResponse(logout_url, status_code=303) +``` + +- [ ] **Step 4: Create frontend pages** + +`modules/keycloak/keycloak/pages/Login.tsx`: +```tsx +import { router } from "@inertiajs/react"; +import { useEffect } from "react"; + +export default function Login() { + useEffect(() => { + router.get("/api/keycloak/auth/login"); + }, []); + + return ( +
+

Redirecting to identity provider…

+
+ ); +} +``` + +`modules/keycloak/keycloak/pages/LoggedOut.tsx`: +```tsx +import { Link } from "@inertiajs/react"; + +export default function LoggedOut() { + return ( +
+

Signed Out

+

You have been signed out successfully.

+ + Sign in again + +
+ ); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add modules/keycloak/keycloak/endpoints/ modules/keycloak/keycloak/pages/ +git commit -m "feat(keycloak): OIDC login/callback endpoints + Inertia login/logout pages" +``` + +--- + +## Task 14: Users Module — Bearer Token + Refresh Token Endpoints + +**Files:** +- Create: `modules/users/users/models/refresh_token.py` +- Create: `modules/users/users/auth_local/token_api.py` +- Modify: `modules/users/users/settings.py` +- Modify: `modules/users/users/module.py` (include token_api router) +- Create: `modules/users/tests/test_token_api.py` + +- [ ] **Step 1: Write token endpoint tests** + +```python +# modules/users/tests/test_token_api.py +"""Tests for bearer token endpoints (mobile auth).""" + +from __future__ import annotations + +import pytest + + +async def test_token_login_returns_tokens(client, authenticated_client): + """POST /api/users/auth/token with valid credentials returns token pair.""" + # First create a user via the authenticated_client (admin) + resp = await client.post( + "/api/users/auth/token", + json={"email": "admin@example.com", "password": "Admin1234!"}, + ) + # May need to seed user first — adjust based on test fixtures + assert resp.status_code in (200, 400) # 200 if user exists, 400 if not + if resp.status_code == 200: + data = resp.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + +async def test_token_login_invalid_credentials(client): + resp = await client.post( + "/api/users/auth/token", + json={"email": "wrong@example.com", "password": "wrong"}, + ) + assert resp.status_code == 401 + + +async def test_token_refresh(client): + """POST /api/users/auth/token/refresh swaps refresh token for new pair.""" + # This test requires a valid refresh token — integration-level + resp = await client.post( + "/api/users/auth/token/refresh", + json={"refresh_token": "invalid-token"}, + ) + assert resp.status_code == 401 +``` + +- [ ] **Step 2: Add `bearer_token_lifetime_seconds` to UsersSettings** + +In `modules/users/users/settings.py`, add after the existing cookie settings: + +```python + bearer_token_lifetime_seconds: int = 60 * 15 # 15 minutes + refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30 # 30 days +``` + +- [ ] **Step 3: Create RefreshToken model** + +```python +# modules/users/users/models/refresh_token.py +"""Refresh token for mobile/API bearer auth.""" + +from __future__ import annotations + +import uuid as uuid_mod +from datetime import datetime, timezone + +from simple_module_db.base import create_module_base +from sqlmodel import Field + +from users.models.user import Base + + +class RefreshToken(Base, table=True): + __tablename__ = "users_refresh_token" + + token: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) + user_id: uuid_mod.UUID = Field(foreign_key="users_user.id", index=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + expires_at: datetime + revoked_at: datetime | None = None +``` + +Update `modules/users/users/models/__init__.py` to export `RefreshToken`. + +- [ ] **Step 4: Create token_api endpoints** + +```python +# modules/users/users/auth_local/token_api.py +"""Bearer token endpoints for mobile/API clients.""" + +from __future__ import annotations + +import uuid as uuid_mod +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request +from simple_module_db.deps import get_db +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + +from users.models import User +from users.models.refresh_token import RefreshToken + +router = APIRouter(prefix="/auth", tags=["users-token"]) + + +class TokenRequest(SQLModel): + email: str + password: str + + +class TokenResponse(SQLModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + + +class RefreshRequest(SQLModel): + refresh_token: str + + +@router.post("/token", response_model=TokenResponse) +async def token_login(body: TokenRequest, request: Request, db: AsyncSession = Depends(get_db)): + """Exchange email+password for access + refresh token pair (mobile auth).""" + from fastapi_users.password import PasswordHelper + + stmt = select(User).where(User.email == body.email) + user = (await db.execute(stmt)).scalar_one_or_none() + if user is None or not user.is_active or user.disabled_at is not None: + raise HTTPException(status_code=401, detail="Invalid credentials") + + helper = PasswordHelper() + verified, _ = helper.verify_and_update(body.password, user.hashed_password) + if not verified: + raise HTTPException(status_code=401, detail="Invalid credentials") + + settings = request.app.state.users.settings + return await _create_token_pair(db, user.id, settings) + + +@router.post("/token/refresh", response_model=TokenResponse) +async def token_refresh( + body: RefreshRequest, request: Request, db: AsyncSession = Depends(get_db) +): + """Exchange a refresh token for a new token pair (rotation).""" + try: + token_uuid = uuid_mod.UUID(body.refresh_token) + except (ValueError, TypeError): + raise HTTPException(status_code=401, detail="Invalid refresh token") + + now = datetime.now(timezone.utc) + stmt = select(RefreshToken).where( + RefreshToken.token == token_uuid, + RefreshToken.revoked_at.is_(None), # type: ignore[union-attr] + RefreshToken.expires_at > now, + ) + rt = (await db.execute(stmt)).scalar_one_or_none() + if rt is None: + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + + rt.revoked_at = now + await db.flush() + + settings = request.app.state.users.settings + return await _create_token_pair(db, rt.user_id, settings) + + +@router.delete("/token") +async def token_revoke( + body: RefreshRequest, db: AsyncSession = Depends(get_db) +): + """Revoke a refresh token (mobile logout).""" + try: + token_uuid = uuid_mod.UUID(body.refresh_token) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid token format") + + stmt = select(RefreshToken).where(RefreshToken.token == token_uuid) + rt = (await db.execute(stmt)).scalar_one_or_none() + if rt and rt.revoked_at is None: + rt.revoked_at = datetime.now(timezone.utc) + await db.flush() + return {"status": "ok"} + + +async def _create_token_pair( + db: AsyncSession, + user_id: uuid_mod.UUID, + settings, +) -> TokenResponse: + """Create access + refresh token pair and persist the refresh token.""" + from users.models import UserAccessToken + + now = datetime.now(timezone.utc) + + access_token = UserAccessToken( + token=str(uuid_mod.uuid4()), + user_id=user_id, + created_at=now, + ) + db.add(access_token) + + refresh = RefreshToken( + token=uuid_mod.uuid4(), + user_id=user_id, + created_at=now, + expires_at=now + timedelta(seconds=settings.refresh_token_lifetime_seconds), + ) + db.add(refresh) + await db.flush() + + return TokenResponse( + access_token=access_token.token, + refresh_token=str(refresh.token), + token_type="bearer", + expires_in=settings.bearer_token_lifetime_seconds, + ) +``` + +- [ ] **Step 5: Wire token_api router into UsersModule.register_routes** + +In `modules/users/users/module.py`, inside `register_routes`, add: + +```python + from users.auth_local.token_api import router as token_router + api_router.include_router(token_router) +``` + +- [ ] **Step 6: Generate migration for refresh_token table** + +Run: `make migration msg="add users_refresh_token table"` + +- [ ] **Step 7: Run tests** + +Run: `uv run pytest modules/users/tests/test_token_api.py -v` +Expected: Tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add modules/users/users/models/refresh_token.py modules/users/users/auth_local/token_api.py modules/users/users/settings.py modules/users/users/module.py modules/users/users/models/__init__.py host/migrations/versions/*refresh_token* +git commit -m "feat(users): bearer token + refresh token endpoints for mobile auth" +``` + +--- + +## Task 15: Integration Tests + Full Suite Verification + +**Files:** +- Create: `tests/integration/test_pluggable_auth.py` + +- [ ] **Step 1: Write integration tests** + +```python +# tests/integration/test_pluggable_auth.py +"""Integration tests for pluggable auth — verifying both providers work.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider +from auth.state import AuthState + + +def test_users_module_is_auth_provider(): + from users.module import UsersModule + + assert UsersModule._is_auth_provider is True + + +def test_keycloak_module_is_auth_provider(): + from keycloak.module import KeycloakModule + + assert KeycloakModule._is_auth_provider is True + + +def test_sm020_fires_with_both_modules(): + from simple_module_core.diagnostics._module import ModuleDiagnostics + from keycloak.module import KeycloakModule + from users.module import UsersModule + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([UsersModule(), KeycloakModule()]) + assert any(d.code == "SM020" for d in results) + + +def test_sm021_fires_with_neither(): + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class StubModule(ModuleBase): + meta = ModuleMeta(name="Stub") + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([StubModule()]) + assert any(d.code == "SM021" for d in results) + + +def test_auth_provider_protocol_satisfied_by_users(): + from users.provider import UsersAuthProvider + + assert isinstance(UsersAuthProvider(), AuthProvider) + + +def test_auth_provider_protocol_satisfied_by_keycloak(): + from keycloak.provider import KeycloakAuthProvider + from keycloak.settings import KeycloakSettings + + settings = KeycloakSettings( + server_url="https://example.com", + realm="test", + client_id="app", + client_secret="secret", + ) + assert isinstance(KeycloakAuthProvider(settings), AuthProvider) +``` + +- [ ] **Step 2: Run integration tests** + +Run: `uv run pytest tests/integration/test_pluggable_auth.py -v` +Expected: All pass. + +- [ ] **Step 3: Run full test suite** + +Run: `make test` +Expected: All tests pass. No regressions in existing users, auth, or framework tests. + +- [ ] **Step 4: Run linter** + +Run: `make lint` +Expected: Clean. + +- [ ] **Step 5: Commit** + +```bash +git add tests/integration/test_pluggable_auth.py +git commit -m "test: integration tests for pluggable auth provider system" +``` + +--- + +## Task 16: Documentation Update + +**Files:** +- Modify: `docs/framework-conventions.md` (add auth provider section) +- The spec document is already committed + +- [ ] **Step 1: Add auth provider section to framework conventions** + +Add a section to `docs/framework-conventions.md` under the existing auth documentation: + +```markdown +### Auth Provider Contract + +The framework supports swappable authentication backends. Exactly one auth provider +module must be installed — either `simple-module-users` (local credentials + OAuth) +or `simple-module-keycloak` (Keycloak OIDC). Both implement the `AuthProvider` +protocol from `auth.contracts.provider`. + +**Module authors never import from `users` or `keycloak` directly.** Use only: +- `from auth.deps import CurrentUser, require_permission` +- `from auth.contracts.schemas import UserContext` + +The `AuthMiddleware` (in `auth/middleware.py`) delegates to the active provider's +`resolve_user()` method, then falls through to the principal-resolver chain. +API paths (`/api/*`) receive 401 JSON when unauthenticated; view paths receive +a 302 redirect to the provider's login URL. + +Boot-time diagnostic `SM020` fails if multiple auth providers are installed. +`SM021` warns if none is installed. +``` + +- [ ] **Step 2: Update CLAUDE.md diagnostic codes table** + +Add to the diagnostic codes section: +``` +`SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn) +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/framework-conventions.md CLAUDE.md +git commit -m "docs: document pluggable auth provider contract and SM020/SM021 diagnostics" +``` + +--- + +## Summary of Commit Sequence + +1. `feat(auth): add AuthProvider protocol for swappable auth backends` +2. `feat(auth): add auth_provider slot to AuthState` +3. `feat(auth): add provider-agnostic AuthMiddleware` +4. `refactor(auth,users): move AuthMiddleware + principal_serializer to auth module` +5. `feat(users): implement UsersAuthProvider with session-cookie resolution` +6. `refactor(users): delegate to auth.middleware, keep thin re-export for compat` +7. `feat(diagnostics): SM020/SM021 — exactly one auth provider required` +8. `feat(keycloak): scaffold keycloak module with settings + provider registration` +9. `feat(keycloak): JWKS key cache with JWT validation and key-rotation retry` +10. `feat(keycloak): OIDC client with authorization URL, token exchange, logout` +11. `feat(keycloak): KeycloakUserCache model + migration` +12. `feat(keycloak): KeycloakAuthProvider with JWT resolution and role mapping` +13. `feat(keycloak): OIDC login/callback endpoints + Inertia login/logout pages` +14. `feat(users): bearer token + refresh token endpoints for mobile auth` +15. `test: integration tests for pluggable auth provider system` +16. `docs: document pluggable auth provider contract and SM020/SM021 diagnostics` From 543e14ccb35041f3ac6377391f80d8e12fa4c816 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 11:54:12 +0200 Subject: [PATCH 03/34] feat(auth): add AuthProvider protocol for swappable auth backends --- modules/auth/auth/__init__.py | 5 +- modules/auth/auth/contracts/__init__.py | 3 +- modules/auth/auth/contracts/provider.py | 34 +++++++++++ .../auth/tests/test_auth_provider_protocol.py | 56 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 modules/auth/auth/contracts/provider.py create mode 100644 modules/auth/tests/test_auth_provider_protocol.py diff --git a/modules/auth/auth/__init__.py b/modules/auth/auth/__init__.py index 1188109b..69f6671c 100644 --- a/modules/auth/auth/__init__.py +++ b/modules/auth/auth/__init__.py @@ -1,6 +1,7 @@ -"""Auth module — shared contracts (UserContext, PrincipalResolver, deps).""" +"""Auth module — shared contracts (UserContext, AuthProvider, PrincipalResolver, deps).""" +from auth.contracts.provider import AuthProvider from auth.contracts.resolver import PrincipalResolver from auth.contracts.schemas import UserContext -__all__ = ["PrincipalResolver", "UserContext"] +__all__ = ["AuthProvider", "PrincipalResolver", "UserContext"] diff --git a/modules/auth/auth/contracts/__init__.py b/modules/auth/auth/contracts/__init__.py index c9439776..afe7dd4d 100644 --- a/modules/auth/auth/contracts/__init__.py +++ b/modules/auth/auth/contracts/__init__.py @@ -1,5 +1,6 @@ """Auth contracts — public types for other modules.""" +from auth.contracts.provider import AuthProvider from auth.contracts.schemas import UserContext -__all__ = ["UserContext"] +__all__ = ["AuthProvider", "UserContext"] diff --git a/modules/auth/auth/contracts/provider.py b/modules/auth/auth/contracts/provider.py new file mode 100644 index 00000000..4714a145 --- /dev/null +++ b/modules/auth/auth/contracts/provider.py @@ -0,0 +1,34 @@ +"""AuthProvider protocol — the contract both users and keycloak modules implement.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from starlette.requests import Request + +from auth.contracts.schemas import UserContext + + +@runtime_checkable +class AuthProvider(Protocol): + """Extension point for swappable authentication backends. + + Exactly one module (``users`` or ``keycloak``) registers an implementation + on ``app.state.auth.auth_provider`` during ``register_settings``. + The ``AuthMiddleware`` delegates to it on every request. + """ + + name: str + + async def resolve_user(self, request: Request) -> UserContext | None: ... + + def get_login_url(self, request: Request, next_url: str | None = None) -> str: ... + + def get_logout_url(self, request: Request) -> str: ... + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: ... + + def is_bearer_request(self, request: Request) -> bool: ... + + +__all__ = ["AuthProvider"] diff --git a/modules/auth/tests/test_auth_provider_protocol.py b/modules/auth/tests/test_auth_provider_protocol.py new file mode 100644 index 00000000..5fde0863 --- /dev/null +++ b/modules/auth/tests/test_auth_provider_protocol.py @@ -0,0 +1,56 @@ +"""Tests for the AuthProvider protocol.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from starlette.requests import Request + + +class _FakeProvider: + """Minimal implementation to verify protocol conformance.""" + + name = "fake" + + async def resolve_user(self, request: Request) -> UserContext | None: + return None + + def get_login_url(self, request: Request, next_url: str | None = None) -> str: + return "/fake/login" + + def get_logout_url(self, request: Request) -> str: + return "/fake/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return (("/fake/login",), ()) + + def is_bearer_request(self, request: Request) -> bool: + return False + + +def test_fake_provider_satisfies_protocol(): + provider = _FakeProvider() + assert isinstance(provider, AuthProvider) + + +def test_protocol_rejects_incomplete_implementation(): + class _Incomplete: + name = "broken" + + assert not isinstance(_Incomplete(), AuthProvider) + + +def test_auth_package_reexports_auth_provider(): + import auth + + assert hasattr(auth, "AuthProvider") + assert "AuthProvider" in auth.__all__ + from auth.contracts.provider import AuthProvider as Canonical + + assert auth.AuthProvider is Canonical + + +def test_contracts_package_reexports_auth_provider(): + from auth.contracts import AuthProvider + + assert AuthProvider is not None From 1b398680af11ecbe7b7e0d05bd6b2c85f16ecb82 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 11:55:18 +0200 Subject: [PATCH 04/34] feat(auth): add auth_provider slot to AuthState --- modules/auth/auth/state.py | 13 +++++--- modules/auth/tests/test_resolver_registry.py | 35 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/modules/auth/auth/state.py b/modules/auth/auth/state.py index cc457da7..9e44eb2b 100644 --- a/modules/auth/auth/state.py +++ b/modules/auth/auth/state.py @@ -1,8 +1,8 @@ """Module-owned state attached to ``app.state.auth`` by ``AuthModule.register_settings``. -Holds the principal-resolver registry (see -``auth.contracts.resolver.PrincipalResolver``). Apps register additional -resolvers from their ``on_startup`` hook:: +Holds the auth provider (set by one of ``users`` or ``keycloak``) and the +principal-resolver registry. Apps register additional resolvers from their +``on_startup`` hook:: app.state.auth.principal_resolvers.append(my_pat_resolver) """ @@ -10,14 +10,19 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import TYPE_CHECKING from auth.contracts.resolver import PrincipalResolver +if TYPE_CHECKING: + from auth.contracts.provider import AuthProvider + @dataclass class AuthState: - """Per-app auth registry. Initialized empty; modules append resolvers.""" + """Per-app auth registry. Initialized empty; provider modules populate at boot.""" + auth_provider: AuthProvider | None = None principal_resolvers: list[PrincipalResolver] = field(default_factory=list) diff --git a/modules/auth/tests/test_resolver_registry.py b/modules/auth/tests/test_resolver_registry.py index 86e71af9..d8cd3181 100644 --- a/modules/auth/tests/test_resolver_registry.py +++ b/modules/auth/tests/test_resolver_registry.py @@ -79,3 +79,38 @@ def test_auth_package_reexports_public_surface(): assert auth.PrincipalResolver is PrincipalResolver assert auth.UserContext is UserContext + + +def test_auth_state_has_auth_provider_field(): + from auth.state import AuthState + + state = AuthState() + assert state.auth_provider is None + + +def test_auth_state_accepts_auth_provider(): + from auth.contracts.provider import AuthProvider + from auth.state import AuthState + + class FakeProvider: + name = "fake" + + async def resolve_user(self, request): + return None + + def get_login_url(self, request, next_url=None): + return "/login" + + def get_logout_url(self, request): + return "/logout" + + def get_public_paths(self): + return ((), ()) + + def is_bearer_request(self, request): + return False + + provider = FakeProvider() + state = AuthState(auth_provider=provider) + assert state.auth_provider is provider + assert isinstance(state.auth_provider, AuthProvider) From ab0fafa5e5c728ad5489e8a785ec29bdbf84137e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 11:58:55 +0200 Subject: [PATCH 05/34] feat(auth): add provider-agnostic AuthMiddleware --- modules/auth/auth/middleware.py | 103 +++++++++++++ modules/auth/tests/test_auth_middleware.py | 161 +++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 modules/auth/auth/middleware.py create mode 100644 modules/auth/tests/test_auth_middleware.py diff --git a/modules/auth/auth/middleware.py b/modules/auth/auth/middleware.py new file mode 100644 index 00000000..995a1799 --- /dev/null +++ b/modules/auth/auth/middleware.py @@ -0,0 +1,103 @@ +"""Provider-agnostic authentication middleware. + +Delegates user resolution to the ``AuthProvider`` registered on +``app.state.auth.auth_provider``, then falls through to the +principal-resolver chain. Sets ``request.state.user`` and the +``current_user_id`` ContextVar for audit listeners. +""" + +from __future__ import annotations + +import logging + +from simple_module_db.listeners import current_user_id +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse +from starlette.types import ASGIApp, Receive, Scope, Send + +logger = logging.getLogger(__name__) + +_FRAMEWORK_PUBLIC_PREFIXES = ( + "/health", + "/static/", + "/api/docs", + "/api/redoc", + "/openapi.json", + "/i18n/", +) +_FRAMEWORK_PUBLIC_EXACT = ("/",) +_SESSION_NEXT_KEY = "next" + + +class AuthMiddleware: + """Authenticate requests via the registered AuthProvider. + + On cache miss (provider returns None), falls through to the + principal-resolver chain. Unauthenticated API requests get 401 JSON; + unauthenticated browser requests get a redirect to the provider's + login URL. + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path: str = scope["path"] + auth_state = scope["app"].state.auth + provider = auth_state.auth_provider + + if provider is None: + await self.app(scope, receive, send) + return + + is_public = ( + any(path.startswith(p) for p in _FRAMEWORK_PUBLIC_PREFIXES) + or path in _FRAMEWORK_PUBLIC_EXACT + ) + if not is_public: + prefix_paths, exact_paths = provider.get_public_paths() + is_public = any(path.startswith(p) for p in prefix_paths) or path in exact_paths + + request = Request(scope) + user_ctx = await provider.resolve_user(request) + + if user_ctx is None: + for resolver in auth_state.principal_resolvers: + try: + user_ctx = await resolver(request) + except Exception: + logger.exception( + "Principal resolver %r raised; treating as no-match", + resolver, + ) + continue + if user_ctx is not None: + break + + if user_ctx is None and not is_public: + if path.startswith("/api/") or provider.is_bearer_request(request): + response = JSONResponse({"detail": "Not authenticated"}, status_code=401) + else: + session = scope.get("session", {}) + session[_SESSION_NEXT_KEY] = str(request.url) + response = RedirectResponse(provider.get_login_url(request), status_code=302) + await response(scope, receive, send) + return + + if user_ctx is not None: + request.state.user = user_ctx + token = current_user_id.set(user_ctx.id) + try: + await self.app(scope, receive, send) + finally: + current_user_id.reset(token) + return + + await self.app(scope, receive, send) + + +__all__ = ["AuthMiddleware"] diff --git a/modules/auth/tests/test_auth_middleware.py b/modules/auth/tests/test_auth_middleware.py new file mode 100644 index 00000000..e990b3a8 --- /dev/null +++ b/modules/auth/tests/test_auth_middleware.py @@ -0,0 +1,161 @@ +"""Tests for the provider-agnostic AuthMiddleware.""" + +from __future__ import annotations + +import httpx +import pytest +from auth.contracts.schemas import UserContext +from auth.middleware import AuthMiddleware +from auth.state import AuthState +from fastapi import FastAPI, Request +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import JSONResponse + +SECRET = "test-middleware-secret" + +_TEST_USER = UserContext( + id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email="test@example.com", + name="Test User", + roles=["admin"], +) + + +class _StubProvider: + name = "stub" + + def __init__(self, *, user: UserContext | None = None): + self._user = user + + async def resolve_user(self, request): + return self._user + + def get_login_url(self, request, next_url=None): + return "/stub/login" + + def get_logout_url(self, request): + return "/stub/logout" + + def get_public_paths(self): + return (("/stub/login", "/stub/public/"), ()) + + def is_bearer_request(self, request): + auth = request.headers.get("authorization", "") + return auth.startswith("Bearer ") + + +def _build_app(provider, *, principal_resolvers=None): + app = FastAPI() + app.state.auth = AuthState( + auth_provider=provider, + principal_resolvers=list(principal_resolvers or []), + ) + + @app.get("/{path:path}") + async def catch_all(request: Request, path: str = ""): + user = getattr(request.state, "user", None) + return JSONResponse( + { + "user": user.to_session_dict() if user else None, + } + ) + + app.add_middleware(AuthMiddleware) + app.add_middleware(SessionMiddleware, secret_key=SECRET) + return app + + +@pytest.fixture +def authenticated_app(): + return _build_app(_StubProvider(user=_TEST_USER)) + + +@pytest.fixture +def unauthenticated_app(): + return _build_app(_StubProvider(user=None)) + + +async def test_authenticated_request_sets_user(authenticated_app): + transport = httpx.ASGITransport(app=authenticated_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/some/page") + assert resp.status_code == 200 + assert resp.json()["user"]["email"] == "test@example.com" + + +async def test_unauthenticated_browser_redirects_to_login(unauthenticated_app): + transport = httpx.ASGITransport(app=unauthenticated_app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", follow_redirects=False + ) as c: + resp = await c.get("/protected/page") + assert resp.status_code == 302 + assert resp.headers["location"] == "/stub/login" + + +async def test_unauthenticated_api_returns_401(unauthenticated_app): + transport = httpx.ASGITransport(app=unauthenticated_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/api/protected") + assert resp.status_code == 401 + assert resp.json()["detail"] == "Not authenticated" + + +async def test_unauthenticated_bearer_returns_401(unauthenticated_app): + transport = httpx.ASGITransport(app=unauthenticated_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/some/page", headers={"Authorization": "Bearer bad"}) + assert resp.status_code == 401 + + +async def test_public_paths_skip_auth(unauthenticated_app): + transport = httpx.ASGITransport(app=unauthenticated_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/stub/login") + assert resp.status_code == 200 + + +async def test_framework_public_paths_skip_auth(unauthenticated_app): + transport = httpx.ASGITransport(app=unauthenticated_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/health") + assert resp.status_code == 200 + + +async def test_root_is_public(unauthenticated_app): + transport = httpx.ASGITransport(app=unauthenticated_app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/") + assert resp.status_code == 200 + + +async def test_resolver_chain_fallback(): + """When provider returns None, fall through to principal resolvers.""" + + async def fake_resolver(request): + auth = request.headers.get("authorization", "") + if auth == "Bearer good-token": + return _TEST_USER + return None + + app = _build_app(_StubProvider(user=None), principal_resolvers=[fake_resolver]) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + resp = await c.get("/protected", headers={"Authorization": "Bearer good-token"}) + assert resp.status_code == 200 + assert resp.json()["user"]["email"] == "test@example.com" + + +async def test_resolver_exception_is_logged_and_skipped(): + """A resolver that raises should be caught; middleware continues.""" + + async def bad_resolver(request): + raise RuntimeError("boom") + + app = _build_app(_StubProvider(user=None), principal_resolvers=[bad_resolver]) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test", follow_redirects=False + ) as c: + resp = await c.get("/protected/page") + assert resp.status_code == 302 From 3c74944b2c2c0ace61f2c6cc6124239cb3e7c1bd Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:03:12 +0200 Subject: [PATCH 06/34] refactor(auth,users): move AuthMiddleware + principal_serializer to auth module --- modules/auth/auth/module.py | 32 +++++++++++++++----- modules/auth/tests/test_resolver_registry.py | 28 +++++++++++++++++ modules/users/users/module.py | 17 ----------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/modules/auth/auth/module.py b/modules/auth/auth/module.py index 6a75bd4b..9f047a89 100644 --- a/modules/auth/auth/module.py +++ b/modules/auth/auth/module.py @@ -1,15 +1,14 @@ -"""Auth module — shared contracts (UserContext, deps). +"""Auth module — shared contracts (UserContext, AuthProvider, deps). Intentionally minimal: this module owns the PUBLIC interface (UserContext, -PrincipalResolver, get_current_user, CurrentUser, require_permission) that -every other module imports. Keeping it stable prevents churn when auth +AuthProvider, PrincipalResolver, get_current_user, CurrentUser, require_permission) +that every other module imports. Keeping it stable prevents churn when auth internals change. -All authentication logic (middleware, login, signup, OAuth) lives in the -users module. The ``principal_resolvers`` registry on ``app.state.auth`` is -the extension point downstream modules use to plug in additional credential -sources (PAT bearer tokens, API keys, etc.) — see -``docs/framework/principal-resolvers.md`` for the worked example. +The ``auth_provider`` slot on ``app.state.auth`` is the extension point +auth-provider modules (``users``, ``keycloak``) use to register themselves. +The ``principal_resolvers`` registry lets downstream modules add extra +credential sources (PAT bearer tokens, API keys, etc.). """ from __future__ import annotations @@ -23,6 +22,17 @@ if TYPE_CHECKING: from fastapi import FastAPI + from auth.contracts.schemas import UserContext + + +def _serialize_principal(user: UserContext) -> dict: + return { + "id": user.id, + "name": user.name, + "email": user.email, + "roles": user.roles, + } + class AuthModule(ModuleBase): meta = ModuleMeta( @@ -34,6 +44,12 @@ def register_settings(self, app: FastAPI) -> None: from auth.state import AuthState app.state.auth = AuthState() + app.state.principal_serializer = _serialize_principal + + def register_middleware(self, app: FastAPI) -> None: + from auth.middleware import AuthMiddleware + + app.add_middleware(AuthMiddleware) def locale_dirs(self) -> dict[str, Path]: return {"auth": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/auth/tests/test_resolver_registry.py b/modules/auth/tests/test_resolver_registry.py index d8cd3181..0d0e0e14 100644 --- a/modules/auth/tests/test_resolver_registry.py +++ b/modules/auth/tests/test_resolver_registry.py @@ -81,6 +81,34 @@ def test_auth_package_reexports_public_surface(): assert auth.UserContext is UserContext +def test_auth_module_registers_middleware(): + """AuthModule.register_middleware should add AuthMiddleware.""" + from auth.module import AuthModule + from fastapi import FastAPI + + app = FastAPI() + AuthModule().register_middleware(app) + middleware_classes = [m.cls.__name__ for m in app.user_middleware] + assert "AuthMiddleware" in middleware_classes + + +def test_auth_module_registers_principal_serializer(): + """AuthModule.register_settings should set principal_serializer on app.state.""" + from auth.module import AuthModule + from fastapi import FastAPI + + app = FastAPI() + AuthModule().register_settings(app) + serializer = getattr(app.state, "principal_serializer", None) + assert serializer is not None + + from auth.contracts.schemas import UserContext + + ctx = UserContext(id="123", email="a@b.com", name="Test", roles=["admin"]) + result = serializer(ctx) + assert result == {"id": "123", "name": "Test", "email": "a@b.com", "roles": ["admin"]} + + def test_auth_state_has_auth_provider_field(): from auth.state import AuthState diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 2abc7404..9577a7f4 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -43,8 +43,6 @@ class UsersModule(ModuleBase): def register_settings(self, app: FastAPI) -> None: import importlib - from auth.contracts.schemas import UserContext - from users.settings import UsersSettings from users.state import UsersState @@ -58,16 +56,6 @@ def register_settings(self, app: FastAPI) -> None: register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s)) - def serialize_principal(user: UserContext) -> dict: - return { - "id": user.id, - "name": user.name, - "email": user.email, - "roles": user.roles, - } - - app.state.principal_serializer = serialize_principal - def register_permissions(self, registry: PermissionRegistry) -> None: registry.add_group( "Users", @@ -156,11 +144,6 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None view_router.include_router(auth_views) view_router.include_router(admin_views) - def register_middleware(self, app: FastAPI) -> None: - from users.middleware import AuthMiddleware - - app.add_middleware(AuthMiddleware) - async def on_startup(self, app: FastAPI) -> None: """Build the mailer, rate limiter, and apply production cookie params.""" import asyncio From 808b78fa542982a0e8ba9339f4be9c2a5cdcc174 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:04:44 +0200 Subject: [PATCH 07/34] feat(users): implement UsersAuthProvider with session-cookie resolution --- modules/users/tests/test_users_provider.py | 46 +++++++++ modules/users/users/module.py | 4 + modules/users/users/provider.py | 105 +++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 modules/users/tests/test_users_provider.py create mode 100644 modules/users/users/provider.py diff --git a/modules/users/tests/test_users_provider.py b/modules/users/tests/test_users_provider.py new file mode 100644 index 00000000..c5a3eaff --- /dev/null +++ b/modules/users/tests/test_users_provider.py @@ -0,0 +1,46 @@ +"""Tests for UsersAuthProvider.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider +from users.provider import UsersAuthProvider + + +def test_users_provider_satisfies_protocol(): + provider = UsersAuthProvider() + assert isinstance(provider, AuthProvider) + + +def test_login_url(): + provider = UsersAuthProvider() + assert provider.get_login_url(None) == "/users/login" + + +def test_logout_url(): + provider = UsersAuthProvider() + assert provider.get_logout_url(None) == "/users/logout" + + +def test_public_paths(): + provider = UsersAuthProvider() + prefixes, exact = provider.get_public_paths() + assert "/users/login" in prefixes + assert "/api/users/auth/" in prefixes + + +def test_is_bearer_request_true(): + from unittest.mock import MagicMock + + request = MagicMock() + request.headers = {"authorization": "Bearer abc123"} + provider = UsersAuthProvider() + assert provider.is_bearer_request(request) is True + + +def test_is_bearer_request_false(): + from unittest.mock import MagicMock + + request = MagicMock() + request.headers = {} + provider = UsersAuthProvider() + assert provider.is_bearer_request(request) is False diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 9577a7f4..2f4796fc 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -56,6 +56,10 @@ def register_settings(self, app: FastAPI) -> None: register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s)) + from users.provider import UsersAuthProvider + + app.state.auth.auth_provider = UsersAuthProvider() + def register_permissions(self, registry: PermissionRegistry) -> None: registry.add_group( "Users", diff --git a/modules/users/users/provider.py b/modules/users/users/provider.py new file mode 100644 index 00000000..23c553d5 --- /dev/null +++ b/modules/users/users/provider.py @@ -0,0 +1,105 @@ +"""UsersAuthProvider — AuthProvider implementation for the users module. + +Resolves users from session cookies (browser) or the principal-resolver chain +(bearer tokens, PATs). Session handling mirrors the original AuthMiddleware +logic: fast path from ``session["user_ctx"]``, slow path via DB lookup. +""" + +from __future__ import annotations + +import logging +import uuid as uuid_mod + +from auth.contracts.schemas import UserContext +from starlette.requests import Request + +logger = logging.getLogger(__name__) + +_SESSION_USER_ID_KEY = "user_id" +_SESSION_USER_CTX_KEY = "user_ctx" + + +class UsersAuthProvider: + """Cookie-based auth provider using fastapi-users' DatabaseStrategy.""" + + name = "users" + _is_auth_provider = True + + async def resolve_user(self, request: Request) -> UserContext | None: + session = request.scope.get("session", {}) + raw_user_id = session.get(_SESSION_USER_ID_KEY) + if not raw_user_id: + return None + + user_id_str = str(raw_user_id) + + cached = UserContext.from_session_dict(session.get(_SESSION_USER_CTX_KEY)) + if cached is not None and cached.id == user_id_str: + return cached + + try: + user_uuid = uuid_mod.UUID(user_id_str) + except (ValueError, TypeError): + logger.warning("Invalid user_id in session: %r", raw_user_id) + session.pop(_SESSION_USER_ID_KEY, None) + session.pop(_SESSION_USER_CTX_KEY, None) + return None + + user_ctx = await self._load_user(request.scope, user_uuid) + if user_ctx is None: + session.pop(_SESSION_USER_ID_KEY, None) + session.pop(_SESSION_USER_CTX_KEY, None) + else: + session[_SESSION_USER_CTX_KEY] = user_ctx.to_session_dict() + return user_ctx + + def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: + return "/users/login" + + def get_logout_url(self, request: Request | None) -> str: + return "/users/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return ( + ( + "/users/login", + "/users/register", + "/users/forgot-password", + "/users/reset-password", + "/users/verify", + "/users/invite/accept", + "/api/users/auth/", + "/api/users/register", + ), + (), + ) + + def is_bearer_request(self, request: Request | None) -> bool: + if request is None: + return False + return request.headers.get("authorization", "").startswith("Bearer ") + + async def _load_user(self, scope, user_id: uuid_mod.UUID) -> UserContext | None: + try: + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + from users.models import User + + session_factory = scope["app"].state.sm.db.session_factory + async with session_factory() as db_session: + stmt = ( + select(User) + .where(User.id == user_id) + .options(selectinload(User.roles)) + ) + user = (await db_session.execute(stmt)).scalar_one_or_none() + if user is None or not user.is_active or user.disabled_at is not None: + return None + return UserContext.from_user(user) + except Exception: + logger.exception( + "Failed to load user %s from DB; treating as unauthenticated", + user_id, + ) + return None From 64ab4a31320cf09879d5b866dbf24d3cdb20fcb8 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:05:54 +0200 Subject: [PATCH 08/34] refactor(users): delegate to auth.middleware, keep thin re-export for compat --- modules/users/tests/_middleware_support.py | 5 +- modules/users/users/middleware.py | 170 +-------------------- 2 files changed, 10 insertions(+), 165 deletions(-) diff --git a/modules/users/tests/_middleware_support.py b/modules/users/tests/_middleware_support.py index e4454f41..5441db86 100644 --- a/modules/users/tests/_middleware_support.py +++ b/modules/users/tests/_middleware_support.py @@ -19,7 +19,7 @@ from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID -from users.middleware import AuthMiddleware +from auth.middleware import AuthMiddleware SECRET_KEY = "test-secret-key-for-session-middleware" @@ -61,7 +61,10 @@ async def _default_handler(request: Request): app = FastAPI() app.state.sm = SimpleNamespace(db=db_state) + from users.provider import UsersAuthProvider + app.state.auth = AuthState( + auth_provider=UsersAuthProvider(), principal_resolvers=list(principal_resolvers or []), ) diff --git a/modules/users/users/middleware.py b/modules/users/users/middleware.py index f78d01e7..67bfdce3 100644 --- a/modules/users/users/middleware.py +++ b/modules/users/users/middleware.py @@ -1,168 +1,10 @@ -"""Local-user auth middleware — replaces the Keycloak session reader. +"""Backwards-compatibility re-export. -Reads ``session["user_id"]``, loads the User row with eagerly-loaded roles, -builds a UserContext, and sets ``request.state.user`` + the -``current_user_id`` ContextVar consumed by DB audit listeners. - -The resolved ``UserContext`` is cached in the signed session cookie under -``session["user_ctx"]`` so subsequent requests skip the DB lookup. The cache -is refreshed when the session is cleared (logout / rotation) or when the -cached payload is missing/invalid. Trade-off: admin-side changes (role -assignment, disable/enable) do not take effect until the affected user's -session is recreated (re-login or session expiry); acceptable for this app. - -Registered via ``UsersModule.register_middleware``. +The canonical AuthMiddleware now lives in ``auth.middleware``. This shim +exists only to avoid breaking imports in downstream apps that referenced +``users.middleware.AuthMiddleware`` directly. """ -from __future__ import annotations - -import logging -import uuid - -from auth.contracts.schemas import UserContext -from simple_module_db.listeners import current_user_id -from sqlalchemy import select -from sqlalchemy.orm import selectinload -from starlette.requests import Request -from starlette.responses import JSONResponse, RedirectResponse -from starlette.types import ASGIApp, Receive, Scope, Send - -from users.constants import SESSION_USER_ID_KEY -from users.models import User - -logger = logging.getLogger(__name__) - -SESSION_USER_CTX_KEY = "user_ctx" -_SESSION_USER_ID_KEY = SESSION_USER_ID_KEY -_SESSION_NEXT_KEY = "next" -_SCOPE_HTTP = "http" -_LOGIN_REDIRECT = "/users/login" -_API_PATH_PREFIX = "/api/" -_UNAUTH_DETAIL = "Not authenticated" - -# Paths that don't require authentication. -PUBLIC_PATHS = ( - "/users/login", - "/users/register", - "/users/forgot-password", - "/users/reset-password", - "/users/verify", - "/users/invite/accept", - "/api/users/auth/", - "/api/users/register", - "/health", - "/static/", - "/api/docs", - "/api/redoc", - "/openapi.json", - "/i18n/", -) -EXACT_PUBLIC_PATHS = ("/",) - - -class AuthMiddleware: - """Redirect unauthenticated users to /users/login. - - On cache hit (``session["user_ctx"]`` present), skips the DB entirely. - On cache miss, loads the user with roles, validates active/enabled, and - writes the resolved context back to the session. - """ - - def __init__(self, app: ASGIApp) -> None: - self.app = app - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - if scope["type"] != _SCOPE_HTTP: - await self.app(scope, receive, send) - return - - path = scope["path"] - is_public = any(path.startswith(p) for p in PUBLIC_PATHS) or path in EXACT_PUBLIC_PATHS - - session = scope["session"] - raw_user_id = session.get(_SESSION_USER_ID_KEY) - - user_ctx: UserContext | None = None - if raw_user_id: - user_id_str = str(raw_user_id) - # Fast path — rebuild from the signed session cookie. - user_ctx = UserContext.from_session_dict(session.get(SESSION_USER_CTX_KEY)) - if user_ctx is None or user_ctx.id != user_id_str: - try: - user_uuid = uuid.UUID(user_id_str) - except (ValueError, TypeError): - logger.warning("Invalid user_id in session: %r", raw_user_id) - session.pop(_SESSION_USER_ID_KEY, None) - session.pop(SESSION_USER_CTX_KEY, None) - user_ctx = None - else: - user_ctx = await self._load_user(scope, user_uuid) - if user_ctx is None: - # User was deleted / disabled since session creation. - session.pop(_SESSION_USER_ID_KEY, None) - session.pop(SESSION_USER_CTX_KEY, None) - else: - session[SESSION_USER_CTX_KEY] = user_ctx.to_session_dict() - - # Fall-through: registered principal resolvers (PAT, API key, ...). - # The session-cookie path above is authoritative; resolvers only run - # when no session-authenticated user was resolved. - if user_ctx is None: - auth_state = getattr(scope["app"].state, "auth", None) - resolvers = getattr(auth_state, "principal_resolvers", ()) if auth_state else () - if resolvers: - request = Request(scope) - for resolver in resolvers: - try: - user_ctx = await resolver(request) - except Exception: - logger.exception( - "Principal resolver %r raised; treating as no-match", - resolver, - ) - continue - if user_ctx is not None: - break - - if user_ctx is None and not is_public: - if path.startswith(_API_PATH_PREFIX): - response = JSONResponse({"detail": _UNAUTH_DETAIL}, status_code=401) - else: - request = Request(scope) - session[_SESSION_NEXT_KEY] = str(request.url) - response = RedirectResponse(_LOGIN_REDIRECT, status_code=302) - await response(scope, receive, send) - return - - if user_ctx is not None: - request = Request(scope) - request.state.user = user_ctx - token = current_user_id.set(user_ctx.id) - try: - await self.app(scope, receive, send) - finally: - current_user_id.reset(token) - return - - await self.app(scope, receive, send) - - async def _load_user(self, scope: Scope, user_id: uuid.UUID) -> UserContext | None: - """Open a fresh session from app.state.db and load the User + roles. +from auth.middleware import AuthMiddleware - Returns a UserContext, or None if the user doesn't exist or is - disabled/inactive. The session is closed on exit; we never commit - (read-only). - """ - try: - session_factory = scope["app"].state.sm.db.session_factory - async with session_factory() as db_session: - stmt = select(User).where(User.id == user_id).options(selectinload(User.roles)) - user = (await db_session.execute(stmt)).scalar_one_or_none() - if user is None: - return None - if not user.is_active or user.disabled_at is not None: - return None - return UserContext.from_user(user) - except Exception: - logger.exception("Failed to load user %s from DB; treating as unauthenticated", user_id) - return None +__all__ = ["AuthMiddleware"] From 1af0c193fa6383c5b954d982730f2de005b9ffbe Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:08:33 +0200 Subject: [PATCH 09/34] =?UTF-8?q?feat(diagnostics):=20SM020/SM021=20?= =?UTF-8?q?=E2=80=94=20exactly=20one=20auth=20provider=20required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../simple_module_core/diagnostics/_module.py | 34 +++++++++++++ framework/core/tests/test_diagnostics.py | 48 +++++++++++++++++++ modules/users/users/module.py | 1 + 3 files changed, 83 insertions(+) diff --git a/framework/core/simple_module_core/diagnostics/_module.py b/framework/core/simple_module_core/diagnostics/_module.py index de3db839..ecc5bbd3 100644 --- a/framework/core/simple_module_core/diagnostics/_module.py +++ b/framework/core/simple_module_core/diagnostics/_module.py @@ -26,6 +26,7 @@ def run(self, modules: list[ModuleBase]) -> list[Diagnostic]: diagnostics.extend(self._check_empty_modules(modules)) diagnostics.extend(self._check_missing_meta(modules)) diagnostics.extend(self._check_views_without_menu(modules)) + diagnostics.extend(self._check_auth_provider_conflict(modules)) diagnostics.extend(check_framework_module_coupling(modules)) # File-based checks (need to find module source directories) @@ -162,6 +163,39 @@ def _check_views_without_menu(self, modules: list[ModuleBase]) -> list[Diagnosti ) return diags + def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagnostic]: + """SM020/SM021: exactly one auth provider module must be installed.""" + providers = [m for m in modules if getattr(m, "_is_auth_provider", False)] + diags: list[Diagnostic] = [] + if len(providers) > 1: + names = ", ".join(m.meta.name for m in providers) + diags.append( + Diagnostic( + level=DiagnosticLevel.ERROR, + code="SM020", + message=f"Multiple auth provider modules installed: {names}", + module_name=providers[0].meta.name, + suggestion=( + "Install only one auth provider " + "(e.g. 'users' OR 'keycloak', not both)" + ), + ) + ) + elif len(providers) == 0: + diags.append( + Diagnostic( + level=DiagnosticLevel.WARNING, + code="SM021", + message="No auth provider module installed", + module_name="(none)", + suggestion=( + "Install an auth provider module " + "(e.g. 'simple-module-users' or 'simple-module-keycloak')" + ), + ) + ) + return diags + def _check_missing_meta(self, modules: list[ModuleBase]) -> list[Diagnostic]: diags: list[Diagnostic] = [] for mod in modules: diff --git a/framework/core/tests/test_diagnostics.py b/framework/core/tests/test_diagnostics.py index 8333b04c..5a329a9d 100644 --- a/framework/core/tests/test_diagnostics.py +++ b/framework/core/tests/test_diagnostics.py @@ -72,3 +72,51 @@ async def test_empty_is_quiet(self, capsys): captured = capsys.readouterr() assert captured.out == "" assert captured.err == "" + + +class TestAuthProviderDiagnostics: + def test_sm020_multiple_auth_providers(self): + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class FakeUsers(ModuleBase): + meta = ModuleMeta(name="Users") + _is_auth_provider = True + + class FakeKeycloak(ModuleBase): + meta = ModuleMeta(name="Keycloak") + _is_auth_provider = True + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([FakeUsers(), FakeKeycloak()]) + assert len(results) == 1 + assert results[0].code == "SM020" + assert results[0].level == DiagnosticLevel.ERROR + + def test_sm021_no_auth_provider(self): + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class FakeDashboard(ModuleBase): + meta = ModuleMeta(name="Dashboard") + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([FakeDashboard()]) + assert len(results) == 1 + assert results[0].code == "SM021" + assert results[0].level == DiagnosticLevel.WARNING + + def test_single_provider_passes(self): + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class FakeUsers(ModuleBase): + meta = ModuleMeta(name="Users") + _is_auth_provider = True + + class FakeDashboard(ModuleBase): + meta = ModuleMeta(name="Dashboard") + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([FakeUsers(), FakeDashboard()]) + assert results == [] diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 2f4796fc..edaa401d 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -39,6 +39,7 @@ class UsersModule(ModuleBase): view_prefix="/users", depends_on=[_MODULE_DEPENDENCY_AUTH], ) + _is_auth_provider = True def register_settings(self, app: FastAPI) -> None: import importlib From 1e974b5606a9fd5f2c715b49518c73f2ec1cce51 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:13:36 +0200 Subject: [PATCH 10/34] feat(keycloak): scaffold keycloak module with settings + provider registration --- modules/keycloak/keycloak/__init__.py | 1 + .../keycloak/keycloak/contracts/__init__.py | 1 + .../keycloak/keycloak/endpoints/__init__.py | 1 + modules/keycloak/keycloak/endpoints/api.py | 5 ++ modules/keycloak/keycloak/endpoints/views.py | 5 ++ modules/keycloak/keycloak/locales/en.json | 15 ++++ modules/keycloak/keycloak/module.py | 89 +++++++++++++++++++ modules/keycloak/keycloak/provider.py | 44 +++++++++ modules/keycloak/keycloak/settings.py | 49 ++++++++++ modules/keycloak/keycloak/state.py | 18 ++++ modules/keycloak/package.json | 7 ++ modules/keycloak/pyproject.toml | 53 +++++++++++ modules/keycloak/tests/__init__.py | 0 modules/keycloak/tests/conftest.py | 1 + .../keycloak/tests/test_keycloak_module.py | 22 +++++ modules/keycloak/tsconfig.json | 4 + pyproject.toml | 3 +- 17 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 modules/keycloak/keycloak/__init__.py create mode 100644 modules/keycloak/keycloak/contracts/__init__.py create mode 100644 modules/keycloak/keycloak/endpoints/__init__.py create mode 100644 modules/keycloak/keycloak/endpoints/api.py create mode 100644 modules/keycloak/keycloak/endpoints/views.py create mode 100644 modules/keycloak/keycloak/locales/en.json create mode 100644 modules/keycloak/keycloak/module.py create mode 100644 modules/keycloak/keycloak/provider.py create mode 100644 modules/keycloak/keycloak/settings.py create mode 100644 modules/keycloak/keycloak/state.py create mode 100644 modules/keycloak/package.json create mode 100644 modules/keycloak/pyproject.toml create mode 100644 modules/keycloak/tests/__init__.py create mode 100644 modules/keycloak/tests/conftest.py create mode 100644 modules/keycloak/tests/test_keycloak_module.py create mode 100644 modules/keycloak/tsconfig.json diff --git a/modules/keycloak/keycloak/__init__.py b/modules/keycloak/keycloak/__init__.py new file mode 100644 index 00000000..0e52916f --- /dev/null +++ b/modules/keycloak/keycloak/__init__.py @@ -0,0 +1 @@ +"""Keycloak OIDC authentication provider for simple_module.""" diff --git a/modules/keycloak/keycloak/contracts/__init__.py b/modules/keycloak/keycloak/contracts/__init__.py new file mode 100644 index 00000000..e8bb4a19 --- /dev/null +++ b/modules/keycloak/keycloak/contracts/__init__.py @@ -0,0 +1 @@ +"""Keycloak module contracts.""" diff --git a/modules/keycloak/keycloak/endpoints/__init__.py b/modules/keycloak/keycloak/endpoints/__init__.py new file mode 100644 index 00000000..1ead3adf --- /dev/null +++ b/modules/keycloak/keycloak/endpoints/__init__.py @@ -0,0 +1 @@ +"""Keycloak endpoint routers.""" diff --git a/modules/keycloak/keycloak/endpoints/api.py b/modules/keycloak/keycloak/endpoints/api.py new file mode 100644 index 00000000..9967875d --- /dev/null +++ b/modules/keycloak/keycloak/endpoints/api.py @@ -0,0 +1,5 @@ +"""Keycloak OIDC API endpoints — login redirect, callback.""" + +from fastapi import APIRouter + +router = APIRouter(prefix="/auth", tags=["keycloak-auth"]) diff --git a/modules/keycloak/keycloak/endpoints/views.py b/modules/keycloak/keycloak/endpoints/views.py new file mode 100644 index 00000000..75f78a34 --- /dev/null +++ b/modules/keycloak/keycloak/endpoints/views.py @@ -0,0 +1,5 @@ +"""Keycloak Inertia view routes — login page, logout.""" + +from fastapi import APIRouter + +router = APIRouter(tags=["keycloak-views"]) diff --git a/modules/keycloak/keycloak/locales/en.json b/modules/keycloak/keycloak/locales/en.json new file mode 100644 index 00000000..a5d10d40 --- /dev/null +++ b/modules/keycloak/keycloak/locales/en.json @@ -0,0 +1,15 @@ +{ + "login": { + "redirecting": "Redirecting to identity provider…", + "title": "Sign In" + }, + "logout": { + "title": "Signed Out", + "message": "You have been signed out successfully." + }, + "errors": { + "callback_failed": "Authentication failed. Please try again.", + "invalid_state": "Invalid authentication state. Please try again.", + "token_validation_failed": "Token validation failed." + } +} diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py new file mode 100644 index 00000000..f349e73f --- /dev/null +++ b/modules/keycloak/keycloak/module.py @@ -0,0 +1,89 @@ +"""Keycloak OIDC authentication module.""" + +from __future__ import annotations + +import importlib.resources +from pathlib import Path +from typing import TYPE_CHECKING + +from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection +from simple_module_core.module import ModuleBase, ModuleMeta + +if TYPE_CHECKING: + from fastapi import APIRouter, FastAPI + + +class KeycloakModule(ModuleBase): + meta = ModuleMeta( + name="Keycloak", + route_prefix="/api/keycloak", + view_prefix="/keycloak", + depends_on=["Auth"], + ) + _is_auth_provider = True + + def register_settings(self, app: FastAPI) -> None: + import importlib + + from keycloak.provider import KeycloakAuthProvider + from keycloak.settings import KeycloakSettings + from keycloak.state import KeycloakState + + register_module_settings = importlib.import_module( + "settings.registration" + ).register_module_settings + + register_module_settings( + app, + "keycloak", + KeycloakSettings, + lambda s: KeycloakState(settings=s), + ) + + app.state.auth.auth_provider = KeycloakAuthProvider( + app.state.keycloak.settings + ) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Logout", + url="/keycloak/logout", + icon="log-out", + order=999, + section=MenuSection.USER_DROPDOWN, + method="post", + ) + ) + + def register_routes( + self, api_router: APIRouter, view_router: APIRouter + ) -> None: + from keycloak.endpoints.api import router as api + from keycloak.endpoints.views import router as views + + api_router.include_router(api) + view_router.include_router(views) + + async def on_startup(self, app: FastAPI) -> None: + from keycloak.jwks import JWKSCache + + state = app.state.keycloak + s = state.settings + if s.server_url and s.realm: + state.jwks_cache = JWKSCache( + jwks_url=( + f"{s.server_url}/realms/{s.realm}" + "/protocol/openid-connect/certs" + ), + ttl_seconds=s.jwks_cache_ttl_seconds, + ) + provider = app.state.auth.auth_provider + provider.jwks_cache = state.jwks_cache + + def locale_dirs(self) -> dict[str, Path]: + return { + "keycloak": Path( + str(importlib.resources.files(__package__) / "locales") + ) + } diff --git a/modules/keycloak/keycloak/provider.py b/modules/keycloak/keycloak/provider.py new file mode 100644 index 00000000..890e69e1 --- /dev/null +++ b/modules/keycloak/keycloak/provider.py @@ -0,0 +1,44 @@ +"""KeycloakAuthProvider — resolves users from Keycloak JWTs or session.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from auth.contracts.schemas import UserContext +from starlette.requests import Request + +if TYPE_CHECKING: + from keycloak.settings import KeycloakSettings + + +class KeycloakAuthProvider: + """OIDC auth provider backed by Keycloak.""" + + name = "keycloak" + _is_auth_provider = True + + def __init__(self, settings: KeycloakSettings | None = None) -> None: + self._settings = settings + self.jwks_cache = None + + async def resolve_user(self, request: Request) -> UserContext | None: + return None + + def get_login_url( + self, request: Request | None, next_url: str | None = None + ) -> str: + return "/keycloak/login" + + def get_logout_url(self, request: Request | None) -> str: + return "/keycloak/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return ( + ("/keycloak/login", "/keycloak/logout", "/api/keycloak/auth/"), + (), + ) + + def is_bearer_request(self, request: Request | None) -> bool: + if request is None: + return False + return request.headers.get("authorization", "").startswith("Bearer ") diff --git a/modules/keycloak/keycloak/settings.py b/modules/keycloak/keycloak/settings.py new file mode 100644 index 00000000..e9e9745c --- /dev/null +++ b/modules/keycloak/keycloak/settings.py @@ -0,0 +1,49 @@ +"""Keycloak module settings -- DB-backed via ``register_module_settings``.""" + +from __future__ import annotations + +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from simple_module_core.dotenv import env_str +from simple_module_core.environments import NON_PROD_ENVIRONMENTS + + +class KeycloakSettings(BaseSettings): + """Keycloak OIDC configuration.""" + + model_config = SettingsConfigDict(extra="ignore") + + server_url: str = env_str("SM_KEYCLOAK_SERVER_URL", "") + realm: str = env_str("SM_KEYCLOAK_REALM", "") + client_id: str = env_str("SM_KEYCLOAK_CLIENT_ID", "") + client_secret: str = env_str("SM_KEYCLOAK_CLIENT_SECRET", "") + + roles_claim_path: str = "realm_access.roles" + admin_role: str = "admin" + login_redirect_url: str = "/dashboard/" + jwks_cache_ttl_seconds: int = 3600 + + role_mapping: dict[str, str] = Field( + default_factory=lambda: {"admin": "admin", "user": "user"}, + ) + + @model_validator(mode="after") + def _check_required_in_production(self) -> KeycloakSettings: + import os + + env = os.environ.get("SM_ENVIRONMENT", "development") + if env in NON_PROD_ENVIRONMENTS: + return self + missing = [] + if not self.server_url: + missing.append("SM_KEYCLOAK_SERVER_URL") + if not self.realm: + missing.append("SM_KEYCLOAK_REALM") + if not self.client_id: + missing.append("SM_KEYCLOAK_CLIENT_ID") + if not self.client_secret: + missing.append("SM_KEYCLOAK_CLIENT_SECRET") + if missing: + msg = f"Keycloak settings required in production: {', '.join(missing)}" + raise ValueError(msg) + return self diff --git a/modules/keycloak/keycloak/state.py b/modules/keycloak/keycloak/state.py new file mode 100644 index 00000000..024449af --- /dev/null +++ b/modules/keycloak/keycloak/state.py @@ -0,0 +1,18 @@ +"""Module-scoped state container for the keycloak module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from keycloak.jwks import JWKSCache + from keycloak.settings import KeycloakSettings + + +@dataclass +class KeycloakState: + """Keycloak-module singletons. Single slot at ``app.state.keycloak``.""" + + settings: KeycloakSettings + jwks_cache: JWKSCache | None = None diff --git a/modules/keycloak/package.json b/modules/keycloak/package.json new file mode 100644 index 00000000..cc4f33f4 --- /dev/null +++ b/modules/keycloak/package.json @@ -0,0 +1,7 @@ +{ + "name": "@simple-module/keycloak", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": {} +} diff --git a/modules/keycloak/pyproject.toml b/modules/keycloak/pyproject.toml new file mode 100644 index 00000000..0909c15a --- /dev/null +++ b/modules/keycloak/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "simple_module_keycloak" +version = "0.0.15" +description = "Keycloak OIDC authentication provider for simple_module — swap with simple_module_users" +license = "MIT" +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "keycloak", "oidc", "authentication", "fastapi"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: FastAPI", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +dependencies = [ + "simple_module_core==0.0.15", + "simple_module_db==0.0.15", + "simple_module_hosting==0.0.15", + "simple_module_settings==0.0.15", + "simple_module_auth==0.0.15", + "PyJWT[crypto]>=2.8", + "httpx>=0.27", +] + +[project.entry-points.simple_module] +keycloak = "keycloak.module:KeycloakModule" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["keycloak"] + +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "keycloak/package.json" + +[tool.uv.sources] +simple_module_core = { workspace = true } +simple_module_db = { workspace = true } +simple_module_hosting = { workspace = true } +simple_module_settings = { workspace = true } +simple_module_auth = { workspace = true } diff --git a/modules/keycloak/tests/__init__.py b/modules/keycloak/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/keycloak/tests/conftest.py b/modules/keycloak/tests/conftest.py new file mode 100644 index 00000000..727ed731 --- /dev/null +++ b/modules/keycloak/tests/conftest.py @@ -0,0 +1 @@ +"""Keycloak module test fixtures.""" diff --git a/modules/keycloak/tests/test_keycloak_module.py b/modules/keycloak/tests/test_keycloak_module.py new file mode 100644 index 00000000..1d08760c --- /dev/null +++ b/modules/keycloak/tests/test_keycloak_module.py @@ -0,0 +1,22 @@ +"""Tests for KeycloakModule lifecycle.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider + + +def test_keycloak_module_meta(): + from keycloak.module import KeycloakModule + + mod = KeycloakModule() + assert mod.meta.name == "Keycloak" + assert mod.meta.depends_on == ["Auth"] + assert mod._is_auth_provider is True + + +def test_keycloak_provider_satisfies_protocol(): + from keycloak.provider import KeycloakAuthProvider + + provider = KeycloakAuthProvider() + assert isinstance(provider, AuthProvider) + assert provider.name == "keycloak" diff --git a/modules/keycloak/tsconfig.json b/modules/keycloak/tsconfig.json new file mode 100644 index 00000000..d479e6d7 --- /dev/null +++ b/modules/keycloak/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../host/client_app/tsconfig.json", + "include": ["keycloak/**/*.ts", "keycloak/**/*.tsx"] +} diff --git a/pyproject.toml b/pyproject.toml index d84326d5..10075589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ extra-paths = [ "modules/file_storage", "modules/settings", "modules/feature_flags", + "modules/keycloak", "host", "scripts", ] @@ -103,7 +104,7 @@ invalid-argument-type = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "scripts/tests", "tests/integration", "tests/e2e", "tests/benchmarks"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", From bbbd89522478ea6ae6f97809ac905c473041e751 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:14:25 +0200 Subject: [PATCH 11/34] feat(keycloak): JWKS key cache with JWT validation and key-rotation retry --- modules/keycloak/keycloak/jwks.py | 112 +++++++++++++++++++ modules/keycloak/tests/test_jwks.py | 160 ++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 273 insertions(+) create mode 100644 modules/keycloak/keycloak/jwks.py create mode 100644 modules/keycloak/tests/test_jwks.py diff --git a/modules/keycloak/keycloak/jwks.py b/modules/keycloak/keycloak/jwks.py new file mode 100644 index 00000000..83b92619 --- /dev/null +++ b/modules/keycloak/keycloak/jwks.py @@ -0,0 +1,112 @@ +"""JWKS key cache and JWT validation for Keycloak tokens.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +import httpx +import jwt +from jwt.algorithms import RSAAlgorithm + +logger = logging.getLogger(__name__) + + +class JWKSCache: + """Caches Keycloak's public signing keys and validates JWTs. + + On validation failure with cached keys, refetches JWKS once before + rejecting -- this handles Keycloak key rotation gracefully. + """ + + def __init__( + self, + jwks_url: str, + ttl_seconds: int = 3600, + issuer: str = "", + audience: str = "", + ) -> None: + self._jwks_url = jwks_url + self._ttl = ttl_seconds + self._issuer = issuer + self._audience = audience + self._keys: dict[str, Any] = {} + self._fetched_at: float = 0 + + async def validate_jwt(self, token: str) -> dict[str, Any] | None: + """Decode and validate a JWT. Returns claims dict or None.""" + try: + unverified = jwt.get_unverified_header(token) + except jwt.exceptions.DecodeError: + return None + + kid = unverified.get("kid") + if kid is None: + return None + + key = await self._get_key(kid) + if key is None: + return None + + return self._decode(token, key) + + def _decode(self, token: str, key: Any) -> dict[str, Any] | None: + try: + return jwt.decode( + token, + key, + algorithms=["RS256"], + issuer=self._issuer if self._issuer else None, + audience=self._audience if self._audience else None, + options={ + "verify_iss": bool(self._issuer), + "verify_aud": bool(self._audience), + }, + ) + except ( + jwt.ExpiredSignatureError, + jwt.InvalidIssuerError, + jwt.InvalidAudienceError, + ): + return None + except jwt.PyJWTError: + logger.exception("JWT validation failed") + return None + + async def _get_key(self, kid: str) -> Any | None: + if self._is_stale() or kid not in self._keys: + await self._fetch_keys() + + if kid in self._keys: + return self._keys[kid] + + await self._fetch_keys(force=True) + return self._keys.get(kid) + + def _is_stale(self) -> bool: + return time.monotonic() - self._fetched_at > self._ttl + + async def _fetch_keys(self, *, force: bool = False) -> None: + if not force and not self._is_stale(): + return + try: + async with httpx.AsyncClient() as client: + resp = await client.get(self._jwks_url, timeout=10) + resp.raise_for_status() + jwks_data = resp.json() + except Exception: + logger.exception("Failed to fetch JWKS from %s", self._jwks_url) + return + + new_keys: dict[str, Any] = {} + for key_data in jwks_data.get("keys", []): + kid = key_data.get("kid") + if kid and key_data.get("alg") == "RS256": + try: + public_key = RSAAlgorithm.from_jwk(key_data) + new_keys[kid] = public_key + except Exception: + logger.warning("Failed to parse JWK kid=%s", kid) + self._keys = new_keys + self._fetched_at = time.monotonic() diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py new file mode 100644 index 00000000..0380d4d7 --- /dev/null +++ b/modules/keycloak/tests/test_jwks.py @@ -0,0 +1,160 @@ +"""Tests for JWKS key cache and JWT validation.""" + +from __future__ import annotations + +import json +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from keycloak.jwks import JWKSCache + + +def _generate_rsa_keypair(): + private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + public_key = private_key.public_key() + return private_key, public_key + + +def _make_jwks_response(public_key, kid="test-key-1"): + from jwt.algorithms import RSAAlgorithm + + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk["kid"] = kid + jwk["use"] = "sig" + jwk["alg"] = "RS256" + return {"keys": [jwk]} + + +def _sign_token(private_key, payload, kid="test-key-1"): + return jwt.encode( + payload, private_key, algorithm="RS256", headers={"kid": kid} + ) + + +@pytest.fixture +def rsa_keys(): + return _generate_rsa_keypair() + + +@pytest.fixture +def valid_payload(): + now = int(time.time()) + return { + "sub": "user-123", + "email": "test@example.com", + "preferred_username": "testuser", + "iss": "https://auth.example.com/realms/test", + "aud": "my-client", + "exp": now + 3600, + "iat": now, + "realm_access": {"roles": ["admin", "user"]}, + } + + +async def test_validate_jwt_valid_token(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response( + url="https://auth.example.com/jwks", json=jwks_data + ) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is not None + assert claims["sub"] == "user-123" + assert claims["email"] == "test@example.com" + + +async def test_validate_jwt_expired_token(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + valid_payload["exp"] = int(time.time()) - 100 + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response( + url="https://auth.example.com/jwks", json=jwks_data + ) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is None + + +async def test_validate_jwt_wrong_issuer(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response( + url="https://auth.example.com/jwks", json=jwks_data + ) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://wrong-issuer.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is None + + +async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key) + httpx_mock.add_response( + url="https://auth.example.com/jwks", json=jwks_data + ) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="wrong-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is None + + +async def test_jwks_cache_refetches_on_unknown_kid( + rsa_keys, valid_payload, httpx_mock +): + private_key, public_key = rsa_keys + jwks_data = _make_jwks_response(public_key, kid="rotated-key") + httpx_mock.add_response( + url="https://auth.example.com/jwks", json={"keys": []} + ) + httpx_mock.add_response( + url="https://auth.example.com/jwks", json=jwks_data + ) + + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=3600, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload, kid="rotated-key") + claims = await cache.validate_jwt(token) + assert claims is not None + assert claims["sub"] == "user-123" diff --git a/pyproject.toml b/pyproject.toml index 10075589..7451f7d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "aioboto3>=13", "moto[s3]>=5", "tomlkit>=0.13", + "pytest-httpx>=0.36.2", ] [tool.ruff] From 27c1601141c60a62368ea1717beb4e6705efb995 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:14:55 +0200 Subject: [PATCH 12/34] feat(keycloak): OIDC client with authorization URL, token exchange, logout --- modules/keycloak/keycloak/oidc.py | 90 +++++++++++++++++++++++++++++ modules/keycloak/tests/test_oidc.py | 76 ++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 modules/keycloak/keycloak/oidc.py create mode 100644 modules/keycloak/tests/test_oidc.py diff --git a/modules/keycloak/keycloak/oidc.py b/modules/keycloak/keycloak/oidc.py new file mode 100644 index 00000000..d7527b64 --- /dev/null +++ b/modules/keycloak/keycloak/oidc.py @@ -0,0 +1,90 @@ +"""OIDC helpers for Keycloak -- authorization URL, token exchange, logout.""" + +from __future__ import annotations + +import secrets +from typing import Any +from urllib.parse import urlencode + +import httpx + + +class OIDCClient: + """Thin wrapper around Keycloak's OIDC endpoints.""" + + def __init__( + self, + server_url: str, + realm: str, + client_id: str, + client_secret: str, + ) -> None: + self._base = ( + f"{server_url.rstrip('/')}/realms/{realm}" + "/protocol/openid-connect" + ) + self._client_id = client_id + self._client_secret = client_secret + self._server_url = server_url.rstrip("/") + self._realm = realm + + @property + def issuer(self) -> str: + return f"{self._server_url}/realms/{self._realm}" + + @property + def token_endpoint(self) -> str: + return f"{self._base}/token" + + @property + def jwks_url(self) -> str: + return f"{self._base}/certs" + + def build_authorization_url( + self, + redirect_uri: str, + nonce: str, + scope: str = "openid email profile", + ) -> tuple[str, str]: + state = secrets.token_urlsafe(32) + params = { + "client_id": self._client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scope, + "state": state, + "nonce": nonce, + } + url = f"{self._base}/auth?{urlencode(params)}" + return url, state + + async def exchange_code( + self, + code: str, + redirect_uri: str, + ) -> dict[str, Any]: + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self._client_id, + "client_secret": self._client_secret, + } + async with httpx.AsyncClient() as client: + resp = await client.post( + self.token_endpoint, data=data, timeout=10 + ) + resp.raise_for_status() + return resp.json() + + def build_logout_url( + self, + post_logout_redirect_uri: str, + id_token_hint: str | None = None, + ) -> str: + params: dict[str, str] = { + "post_logout_redirect_uri": post_logout_redirect_uri, + } + if id_token_hint: + params["id_token_hint"] = id_token_hint + return f"{self._base}/logout?{urlencode(params)}" diff --git a/modules/keycloak/tests/test_oidc.py b/modules/keycloak/tests/test_oidc.py new file mode 100644 index 00000000..4f098a88 --- /dev/null +++ b/modules/keycloak/tests/test_oidc.py @@ -0,0 +1,76 @@ +"""Tests for OIDC discovery and token exchange helpers.""" + +from __future__ import annotations + +import pytest + +from keycloak.oidc import OIDCClient + + +@pytest.fixture +def oidc_client(): + return OIDCClient( + server_url="https://auth.example.com", + realm="test", + client_id="my-app", + client_secret="secret123", + ) + + +def test_authorization_url(oidc_client): + url, state = oidc_client.build_authorization_url( + redirect_uri="https://app.example.com/callback", + nonce="test-nonce", + ) + assert ( + "auth.example.com/realms/test/protocol/openid-connect/auth" in url + ) + assert "client_id=my-app" in url + assert "redirect_uri=" in url + assert "response_type=code" in url + assert "scope=openid" in url + assert "nonce=test-nonce" in url + assert state is not None + assert len(state) > 0 + + +def test_token_endpoint_url(oidc_client): + assert oidc_client.token_endpoint == ( + "https://auth.example.com/realms/test" + "/protocol/openid-connect/token" + ) + + +def test_logout_url(oidc_client): + url = oidc_client.build_logout_url( + post_logout_redirect_uri="https://app.example.com/login", + id_token_hint="token123", + ) + assert ( + "auth.example.com/realms/test/protocol/openid-connect/logout" in url + ) + assert "post_logout_redirect_uri=" in url + assert "id_token_hint=token123" in url + + +def test_issuer(oidc_client): + assert oidc_client.issuer == "https://auth.example.com/realms/test" + + +async def test_exchange_code(oidc_client, httpx_mock): + httpx_mock.add_response( + url=oidc_client.token_endpoint, + json={ + "access_token": "at-123", + "id_token": "id-123", + "refresh_token": "rt-123", + "token_type": "Bearer", + "expires_in": 300, + }, + ) + tokens = await oidc_client.exchange_code( + code="auth-code-xyz", + redirect_uri="https://app.example.com/callback", + ) + assert tokens["access_token"] == "at-123" + assert tokens["id_token"] == "id-123" From 97d0018650507fdbf6678d71ada968617a62ac8f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:15:06 +0200 Subject: [PATCH 13/34] feat(keycloak): KeycloakUserCache model --- modules/keycloak/keycloak/models.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 modules/keycloak/keycloak/models.py diff --git a/modules/keycloak/keycloak/models.py b/modules/keycloak/keycloak/models.py new file mode 100644 index 00000000..0183b42e --- /dev/null +++ b/modules/keycloak/keycloak/models.py @@ -0,0 +1,21 @@ +"""Keycloak user cache -- maps Keycloak sub to a stable framework UUID.""" + +from __future__ import annotations + +import uuid as uuid_mod +from datetime import datetime + +from simple_module_db.base import create_module_base +from sqlmodel import Field + +Base = create_module_base("keycloak") + + +class KeycloakUserCache(Base, table=True): + __tablename__ = "keycloak_user_cache" + + id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) + keycloak_sub: str = Field(unique=True, index=True) + email: str = "" + full_name: str | None = None + last_login_at: datetime | None = None From 47e1b90bbc5a59cbb756c712f3e300953b3676ad Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:15:51 +0200 Subject: [PATCH 14/34] feat(keycloak): KeycloakAuthProvider with JWT resolution and role mapping --- modules/keycloak/keycloak/provider.py | 116 +++++++++++++++++- .../keycloak/tests/test_keycloak_provider.py | 97 +++++++++++++++ 2 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 modules/keycloak/tests/test_keycloak_provider.py diff --git a/modules/keycloak/keycloak/provider.py b/modules/keycloak/keycloak/provider.py index 890e69e1..56541c83 100644 --- a/modules/keycloak/keycloak/provider.py +++ b/modules/keycloak/keycloak/provider.py @@ -2,14 +2,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from typing import TYPE_CHECKING, Any from auth.contracts.schemas import UserContext from starlette.requests import Request if TYPE_CHECKING: + from keycloak.jwks import JWKSCache from keycloak.settings import KeycloakSettings +logger = logging.getLogger(__name__) + +_SESSION_USER_CTX_KEY = "user_ctx" + class KeycloakAuthProvider: """OIDC auth provider backed by Keycloak.""" @@ -19,10 +25,17 @@ class KeycloakAuthProvider: def __init__(self, settings: KeycloakSettings | None = None) -> None: self._settings = settings - self.jwks_cache = None + self.jwks_cache: JWKSCache | None = None async def resolve_user(self, request: Request) -> UserContext | None: - return None + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return await self._resolve_bearer(request, auth_header[7:]) + + session = request.scope.get("session", {}) + return UserContext.from_session_dict( + session.get(_SESSION_USER_CTX_KEY) + ) def get_login_url( self, request: Request | None, next_url: str | None = None @@ -42,3 +55,100 @@ def is_bearer_request(self, request: Request | None) -> bool: if request is None: return False return request.headers.get("authorization", "").startswith("Bearer ") + + async def _resolve_bearer( + self, request: Request, token: str + ) -> UserContext | None: + if self.jwks_cache is None: + logger.warning( + "JWKS cache not initialized; rejecting bearer token" + ) + return None + claims = await self.jwks_cache.validate_jwt(token) + if claims is None: + return None + + cache_id = await self._upsert_user_cache(request, claims) + return self._claims_to_user_context(claims, cache_id=cache_id) + + def _claims_to_user_context( + self, + claims: dict[str, Any], + *, + cache_id: str, + ) -> UserContext: + roles_raw = ( + _extract_nested(claims, self._settings.roles_claim_path) + if self._settings + else None + ) + mapped = [ + self._settings.role_mapping[r] + for r in (roles_raw or []) + if self._settings and r in self._settings.role_mapping + ] + return UserContext( + id=cache_id, + email=claims.get("email", ""), + name=( + claims.get("preferred_username") or claims.get("name", "") + ), + roles=mapped, + tenant_id=claims.get("tenant_id"), + ) + + async def _upsert_user_cache( + self, request: Request, claims: dict + ) -> str: + try: + from keycloak.models import KeycloakUserCache + from sqlalchemy import select + + session_factory = request.app.state.sm.db.session_factory + sub = claims["sub"] + async with session_factory() as db: + stmt = select(KeycloakUserCache).where( + KeycloakUserCache.keycloak_sub == sub + ) + row = (await db.execute(stmt)).scalar_one_or_none() + if row is None: + import uuid as uuid_mod + from datetime import datetime, timezone + + row = KeycloakUserCache( + id=uuid_mod.uuid4(), + keycloak_sub=sub, + email=claims.get("email", ""), + full_name=claims.get("preferred_username"), + last_login_at=datetime.now(timezone.utc), + ) + db.add(row) + await db.flush() + else: + from datetime import datetime, timezone + + row.email = claims.get("email", row.email) + row.full_name = claims.get( + "preferred_username", row.full_name + ) + row.last_login_at = datetime.now(timezone.utc) + await db.flush() + return str(row.id) + except Exception: + logger.exception( + "Failed to upsert KeycloakUserCache for sub=%s", + claims.get("sub"), + ) + return claims.get("sub", "unknown") + + +def _extract_nested(data: dict, path: str) -> list[str] | None: + parts = path.split(".") + current: Any = data + for part in parts: + if not isinstance(current, dict): + return None + current = current.get(part) + if current is None: + return None + return current if isinstance(current, list) else None diff --git a/modules/keycloak/tests/test_keycloak_provider.py b/modules/keycloak/tests/test_keycloak_provider.py new file mode 100644 index 00000000..597b71c2 --- /dev/null +++ b/modules/keycloak/tests/test_keycloak_provider.py @@ -0,0 +1,97 @@ +"""Tests for KeycloakAuthProvider.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from keycloak.provider import KeycloakAuthProvider +from keycloak.settings import KeycloakSettings + +import pytest + + +@pytest.fixture +def settings(): + return KeycloakSettings( + server_url="https://auth.example.com", + realm="test", + client_id="my-app", + client_secret="secret", + role_mapping={ + "admin": "admin", + "user": "user", + "editor": "editor", + }, + ) + + +@pytest.fixture +def provider(settings): + return KeycloakAuthProvider(settings) + + +def test_satisfies_protocol(provider): + assert isinstance(provider, AuthProvider) + + +def test_name(provider): + assert provider.name == "keycloak" + + +def test_login_url(provider): + assert provider.get_login_url(None) == "/keycloak/login" + + +def test_logout_url(provider): + assert provider.get_logout_url(None) == "/keycloak/logout" + + +def test_public_paths(provider): + prefixes, exact = provider.get_public_paths() + assert "/keycloak/login" in prefixes + assert "/api/keycloak/auth/" in prefixes + + +def test_is_bearer_request(provider): + req = MagicMock() + req.headers = {"authorization": "Bearer abc"} + assert provider.is_bearer_request(req) is True + + req.headers = {} + assert provider.is_bearer_request(req) is False + + +def test_claims_to_user_context(provider): + claims = { + "sub": "kc-user-123", + "email": "test@example.com", + "preferred_username": "testuser", + "realm_access": {"roles": ["admin", "unknown_role", "user"]}, + } + ctx = provider._claims_to_user_context( + claims, cache_id="aaaaaaaa-0000-0000-0000-000000000001" + ) + assert isinstance(ctx, UserContext) + assert ctx.id == "aaaaaaaa-0000-0000-0000-000000000001" + assert ctx.email == "test@example.com" + assert ctx.name == "testuser" + assert sorted(ctx.roles) == ["admin", "user"] + + +def test_claims_to_user_context_no_roles(provider): + claims = {"sub": "kc-user-456", "email": "noroles@example.com"} + ctx = provider._claims_to_user_context(claims, cache_id="bbbb") + assert ctx.roles == [] + + +def test_extract_roles_custom_claim_path(settings): + settings.roles_claim_path = "resource_access.my-app.roles" + provider = KeycloakAuthProvider(settings) + claims = { + "sub": "x", + "resource_access": {"my-app": {"roles": ["admin"]}}, + } + ctx = provider._claims_to_user_context(claims, cache_id="cccc") + assert ctx.roles == ["admin"] From 23b567c17d7ec9d057946a1bf2e24683332eea90 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:16:27 +0200 Subject: [PATCH 15/34] feat(keycloak): OIDC login/callback endpoints + Inertia login/logout pages --- modules/keycloak/keycloak/endpoints/api.py | 99 ++++++++++++++++++- modules/keycloak/keycloak/endpoints/views.py | 36 ++++++- modules/keycloak/keycloak/pages/LoggedOut.tsx | 13 +++ modules/keycloak/keycloak/pages/Login.tsx | 14 +++ 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 modules/keycloak/keycloak/pages/LoggedOut.tsx create mode 100644 modules/keycloak/keycloak/pages/Login.tsx diff --git a/modules/keycloak/keycloak/endpoints/api.py b/modules/keycloak/keycloak/endpoints/api.py index 9967875d..176771dc 100644 --- a/modules/keycloak/keycloak/endpoints/api.py +++ b/modules/keycloak/keycloak/endpoints/api.py @@ -1,5 +1,102 @@ """Keycloak OIDC API endpoints — login redirect, callback.""" -from fastapi import APIRouter +from __future__ import annotations +import logging +import secrets +from typing import TYPE_CHECKING + +from fastapi import APIRouter, HTTPException, Request +from starlette.responses import RedirectResponse + +if TYPE_CHECKING: + from keycloak.settings import KeycloakSettings + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["keycloak-auth"]) + +_SESSION_OIDC_STATE = "keycloak_oidc_state" +_SESSION_OIDC_NONCE = "keycloak_oidc_nonce" +_SESSION_USER_CTX = "user_ctx" +_SESSION_ID_TOKEN = "keycloak_id_token" +_SESSION_NEXT = "next" + + +def _get_settings(request: Request) -> KeycloakSettings: + return request.app.state.keycloak.settings + + +def _get_oidc_client(request: Request): + from keycloak.oidc import OIDCClient + + s = _get_settings(request) + return OIDCClient( + server_url=s.server_url, + realm=s.realm, + client_id=s.client_id, + client_secret=s.client_secret, + ) + + +@router.get("/login") +async def oidc_login(request: Request): + client = _get_oidc_client(request) + callback_url = str(request.url_for("oidc_callback")) + nonce = secrets.token_urlsafe(32) + url, state = client.build_authorization_url( + redirect_uri=callback_url, + nonce=nonce, + ) + request.session[_SESSION_OIDC_STATE] = state + request.session[_SESSION_OIDC_NONCE] = nonce + return RedirectResponse(url, status_code=302) + + +@router.get("/callback") +async def oidc_callback(request: Request): + code = request.query_params.get("code") + state = request.query_params.get("state") + + expected_state = request.session.pop(_SESSION_OIDC_STATE, None) + request.session.pop(_SESSION_OIDC_NONCE, None) + + if not code or not state or state != expected_state: + raise HTTPException(status_code=400, detail="Invalid OIDC state") + + client = _get_oidc_client(request) + callback_url = str(request.url_for("oidc_callback")) + + try: + tokens = await client.exchange_code( + code=code, redirect_uri=callback_url + ) + except Exception: + logger.exception("Token exchange failed") + raise HTTPException( + status_code=502, detail="Token exchange failed" + ) + + id_token = tokens.get("id_token", "") + access_token = tokens.get("access_token", "") + + jwks_cache = request.app.state.keycloak.jwks_cache + claims = ( + await jwks_cache.validate_jwt(access_token) if jwks_cache else None + ) + if claims is None: + raise HTTPException( + status_code=401, detail="Token validation failed" + ) + + provider = request.app.state.auth.auth_provider + cache_id = await provider._upsert_user_cache(request, claims) + user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id) + + request.session[_SESSION_USER_CTX] = user_ctx.to_session_dict() + request.session[_SESSION_ID_TOKEN] = id_token + + s = _get_settings(request) + next_url = ( + request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url + ) + return RedirectResponse(next_url, status_code=303) diff --git a/modules/keycloak/keycloak/endpoints/views.py b/modules/keycloak/keycloak/endpoints/views.py index 75f78a34..357ee1b8 100644 --- a/modules/keycloak/keycloak/endpoints/views.py +++ b/modules/keycloak/keycloak/endpoints/views.py @@ -1,5 +1,39 @@ """Keycloak Inertia view routes — login page, logout.""" -from fastapi import APIRouter +from __future__ import annotations + +from fastapi import APIRouter, Request +from simple_module_hosting.inertia_deps import InertiaDep +from starlette.responses import RedirectResponse router = APIRouter(tags=["keycloak-views"]) + +_SESSION_ID_TOKEN = "keycloak_id_token" + + +@router.get("/login") +async def login_page(request: Request, inertia: InertiaDep): + return inertia.render("Keycloak/Login") + + +@router.post("/logout") +async def logout(request: Request): + from keycloak.oidc import OIDCClient + + s = request.app.state.keycloak.settings + id_token = request.session.get(_SESSION_ID_TOKEN) + + request.session.clear() + + client = OIDCClient( + server_url=s.server_url, + realm=s.realm, + client_id=s.client_id, + client_secret=s.client_secret, + ) + base_url = str(request.base_url).rstrip("/") + logout_url = client.build_logout_url( + post_logout_redirect_uri=f"{base_url}/keycloak/login", + id_token_hint=id_token, + ) + return RedirectResponse(logout_url, status_code=303) diff --git a/modules/keycloak/keycloak/pages/LoggedOut.tsx b/modules/keycloak/keycloak/pages/LoggedOut.tsx new file mode 100644 index 00000000..c383c2cb --- /dev/null +++ b/modules/keycloak/keycloak/pages/LoggedOut.tsx @@ -0,0 +1,13 @@ +import { Link } from "@inertiajs/react"; + +export default function LoggedOut() { + return ( +
+

Signed Out

+

You have been signed out successfully.

+ + Sign in again + +
+ ); +} diff --git a/modules/keycloak/keycloak/pages/Login.tsx b/modules/keycloak/keycloak/pages/Login.tsx new file mode 100644 index 00000000..c7b35a24 --- /dev/null +++ b/modules/keycloak/keycloak/pages/Login.tsx @@ -0,0 +1,14 @@ +import { router } from "@inertiajs/react"; +import { useEffect } from "react"; + +export default function Login() { + useEffect(() => { + router.get("/api/keycloak/auth/login"); + }, []); + + return ( +
+

Redirecting to identity provider...

+
+ ); +} From f83381af93205e9d7d97e4d61c4e15fd5b2d02ad Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:21:27 +0200 Subject: [PATCH 16/34] feat(users): bearer token + refresh token endpoints for mobile/API auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/users/auth/token (email+password login), POST /api/users/auth/token/refresh (rotate tokens), and DELETE /api/users/auth/token (revoke) for non-browser clients. New model: RefreshToken with expiry and revocation tracking. New settings: bearer_token_lifetime_seconds, refresh_token_lifetime_seconds. Also fix keycloak module depends_on to include Settings (was failing tests). Migration not generated — needs full app context. --- modules/keycloak/keycloak/module.py | 2 +- modules/users/tests/test_token_api.py | 191 ++++++++++++++++++++ modules/users/users/auth_local/token_api.py | 149 +++++++++++++++ modules/users/users/models/__init__.py | 2 + modules/users/users/models/refresh_token.py | 22 +++ modules/users/users/module.py | 2 + modules/users/users/settings.py | 4 + 7 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 modules/users/tests/test_token_api.py create mode 100644 modules/users/users/auth_local/token_api.py create mode 100644 modules/users/users/models/refresh_token.py diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index f349e73f..7085ea25 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -18,7 +18,7 @@ class KeycloakModule(ModuleBase): name="Keycloak", route_prefix="/api/keycloak", view_prefix="/keycloak", - depends_on=["Auth"], + depends_on=["Auth", "Settings"], ) _is_auth_provider = True diff --git a/modules/users/tests/test_token_api.py b/modules/users/tests/test_token_api.py new file mode 100644 index 00000000..0c012eca --- /dev/null +++ b/modules/users/tests/test_token_api.py @@ -0,0 +1,191 @@ +"""Tests for bearer token endpoints: /api/users/auth/token*. + +Covers: login via email+password, refresh rotation, revoke, and error paths. +""" + +from __future__ import annotations + +import uuid + +import pytest +from fastapi_users.password import PasswordHelper +from users.models import User + +_pw = PasswordHelper() + + +def _hash(plain: str) -> str: + return _pw.hash(plain) + + +async def _seed_user(session, email="api@example.com", password="SecurePass1!"): + """Create a verified, active user for token tests.""" + user = User( + id=uuid.uuid4(), + email=email, + hashed_password=_hash(password), + is_active=True, + is_superuser=False, + is_verified=True, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +# --------------------------------------------------------------------------- +# POST /api/users/auth/token — login +# --------------------------------------------------------------------------- + + +class TestTokenLogin: + @pytest.mark.anyio + async def test_invalid_email_returns_401(self, anon_client): + resp = await anon_client.post( + "/api/users/auth/token", + json={"email": "nobody@example.com", "password": "whatever"}, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid credentials" + + @pytest.mark.anyio + async def test_wrong_password_returns_401(self, anon_client, users_db): + await _seed_user(users_db) + resp = await anon_client.post( + "/api/users/auth/token", + json={"email": "api@example.com", "password": "WRONG"}, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid credentials" + + @pytest.mark.anyio + async def test_inactive_user_returns_401(self, anon_client, users_db): + user = await _seed_user(users_db, email="inactive@example.com") + user.is_active = False + await users_db.commit() + + resp = await anon_client.post( + "/api/users/auth/token", + json={"email": "inactive@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 401 + + @pytest.mark.anyio + async def test_valid_credentials_returns_token_pair(self, anon_client, users_db): + await _seed_user(users_db) + resp = await anon_client.post( + "/api/users/auth/token", + json={"email": "api@example.com", "password": "SecurePass1!"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["token_type"] == "bearer" + assert body["access_token"] + assert body["refresh_token"] + assert body["expires_in"] > 0 + + +# --------------------------------------------------------------------------- +# POST /api/users/auth/token/refresh +# --------------------------------------------------------------------------- + + +class TestTokenRefresh: + @pytest.mark.anyio + async def test_invalid_uuid_returns_401(self, anon_client): + resp = await anon_client.post( + "/api/users/auth/token/refresh", + json={"refresh_token": "not-a-uuid"}, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid refresh token" + + @pytest.mark.anyio + async def test_nonexistent_token_returns_401(self, anon_client): + resp = await anon_client.post( + "/api/users/auth/token/refresh", + json={"refresh_token": str(uuid.uuid4())}, + ) + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid or expired refresh token" + + @pytest.mark.anyio + async def test_valid_refresh_rotates_tokens(self, anon_client, users_db): + """Login, then refresh — old refresh revoked, new pair returned.""" + await _seed_user(users_db) + login = await anon_client.post( + "/api/users/auth/token", + json={"email": "api@example.com", "password": "SecurePass1!"}, + ) + assert login.status_code == 200 + old_refresh = login.json()["refresh_token"] + + refresh_resp = await anon_client.post( + "/api/users/auth/token/refresh", + json={"refresh_token": old_refresh}, + ) + assert refresh_resp.status_code == 200 + new_body = refresh_resp.json() + assert new_body["access_token"] + assert new_body["refresh_token"] != old_refresh + + # Old refresh token should now be revoked + reuse = await anon_client.post( + "/api/users/auth/token/refresh", + json={"refresh_token": old_refresh}, + ) + assert reuse.status_code == 401 + + +# --------------------------------------------------------------------------- +# DELETE /api/users/auth/token — revoke +# --------------------------------------------------------------------------- + + +class TestTokenRevoke: + @pytest.mark.anyio + async def test_invalid_format_returns_400(self, anon_client): + resp = await anon_client.request( + "DELETE", + "/api/users/auth/token", + json={"refresh_token": "garbage"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"] == "Invalid token format" + + @pytest.mark.anyio + async def test_nonexistent_token_returns_ok(self, anon_client): + """Revoking a non-existent token is idempotent — still returns ok.""" + resp = await anon_client.request( + "DELETE", + "/api/users/auth/token", + json={"refresh_token": str(uuid.uuid4())}, + ) + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + @pytest.mark.anyio + async def test_revoke_makes_refresh_unusable(self, anon_client, users_db): + """After revoking, the refresh token can no longer be used.""" + await _seed_user(users_db) + login = await anon_client.post( + "/api/users/auth/token", + json={"email": "api@example.com", "password": "SecurePass1!"}, + ) + rt = login.json()["refresh_token"] + + # Revoke + revoke_resp = await anon_client.request( + "DELETE", + "/api/users/auth/token", + json={"refresh_token": rt}, + ) + assert revoke_resp.status_code == 200 + + # Attempt refresh — should fail + refresh_resp = await anon_client.post( + "/api/users/auth/token/refresh", + json={"refresh_token": rt}, + ) + assert refresh_resp.status_code == 401 diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py new file mode 100644 index 00000000..1b13ac69 --- /dev/null +++ b/modules/users/users/auth_local/token_api.py @@ -0,0 +1,149 @@ +"""Bearer token endpoints for mobile/API clients. + +Provides email+password → access_token + refresh_token, refresh, and revoke +flows for clients that cannot use browser cookies (mobile apps, CLI tools, +third-party API consumers). +""" + +from __future__ import annotations + +import uuid as uuid_mod +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request +from simple_module_db.deps import get_db +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import SQLModel + +from users.models import User +from users.models.refresh_token import RefreshToken + +router = APIRouter(prefix="/auth", tags=["users-token"]) + + +class TokenRequest(SQLModel): + """Email + password login for bearer-token auth.""" + + email: str + password: str + + +class TokenResponse(SQLModel): + """Access + refresh token pair returned on successful auth.""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + + +class RefreshRequest(SQLModel): + """Body for refresh and revoke endpoints.""" + + refresh_token: str + + +@router.post("/token", response_model=TokenResponse) +async def token_login( + body: TokenRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Exchange email + password for an access/refresh token pair.""" + from fastapi_users.password import PasswordHelper + + stmt = select(User).where(User.email == body.email) + user = (await db.execute(stmt)).scalar_one_or_none() + if user is None or not user.is_active or user.disabled_at is not None: + raise HTTPException(status_code=401, detail="Invalid credentials") + + helper = PasswordHelper() + verified, _ = helper.verify_and_update(body.password, user.hashed_password) + if not verified: + raise HTTPException(status_code=401, detail="Invalid credentials") + + settings = request.app.state.users.settings + return await _create_token_pair(db, user.id, settings) + + +@router.post("/token/refresh", response_model=TokenResponse) +async def token_refresh( + body: RefreshRequest, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Rotate a refresh token into a new access/refresh pair.""" + try: + token_uuid = uuid_mod.UUID(body.refresh_token) + except (ValueError, TypeError): + raise HTTPException(status_code=401, detail="Invalid refresh token") + + now = datetime.now(timezone.utc) + stmt = select(RefreshToken).where( + RefreshToken.token == token_uuid, + RefreshToken.revoked_at.is_(None), # type: ignore[union-attr] + RefreshToken.expires_at > now, + ) + rt = (await db.execute(stmt)).scalar_one_or_none() + if rt is None: + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + + rt.revoked_at = now + await db.flush() + + settings = request.app.state.users.settings + return await _create_token_pair(db, rt.user_id, settings) + + +@router.delete("/token") +async def token_revoke( + body: RefreshRequest, + db: AsyncSession = Depends(get_db), +): + """Revoke a refresh token (idempotent).""" + try: + token_uuid = uuid_mod.UUID(body.refresh_token) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid token format") + + stmt = select(RefreshToken).where(RefreshToken.token == token_uuid) + rt = (await db.execute(stmt)).scalar_one_or_none() + if rt and rt.revoked_at is None: + rt.revoked_at = datetime.now(timezone.utc) + await db.flush() + return {"status": "ok"} + + +async def _create_token_pair( + db: AsyncSession, + user_id: uuid_mod.UUID, + settings, +) -> TokenResponse: + """Mint a new access token + refresh token pair and persist both.""" + from users.models import UserAccessToken + + now = datetime.now(timezone.utc) + + access_token = UserAccessToken( + token=str(uuid_mod.uuid4()), + user_id=user_id, + created_at=now, + ) + db.add(access_token) + + refresh = RefreshToken( + token=uuid_mod.uuid4(), + user_id=user_id, + created_at=now, + expires_at=now + timedelta(seconds=settings.refresh_token_lifetime_seconds), + ) + db.add(refresh) + await db.flush() + + return TokenResponse( + access_token=access_token.token, + refresh_token=str(refresh.token), + token_type="bearer", + expires_in=settings.bearer_token_lifetime_seconds, + ) diff --git a/modules/users/users/models/__init__.py b/modules/users/users/models/__init__.py index bbfdd733..d92f31f9 100644 --- a/modules/users/users/models/__init__.py +++ b/modules/users/users/models/__init__.py @@ -10,6 +10,7 @@ from users.models._base import Base from users.models.access_token import UserAccessToken from users.models.oauth_account import OAuthAccount +from users.models.refresh_token import RefreshToken from users.models.role import Role from users.models.user import User from users.models.user_role import UserRole @@ -17,6 +18,7 @@ __all__ = [ "Base", "OAuthAccount", + "RefreshToken", "Role", "SQLAlchemyAccessTokenDatabase", "SQLAlchemyUserDatabase", diff --git a/modules/users/users/models/refresh_token.py b/modules/users/users/models/refresh_token.py new file mode 100644 index 00000000..2cdce496 --- /dev/null +++ b/modules/users/users/models/refresh_token.py @@ -0,0 +1,22 @@ +"""Refresh token for mobile/API bearer auth.""" + +from __future__ import annotations + +import uuid as uuid_mod +from datetime import datetime, timezone + +from sqlmodel import Field + +from users.models._base import Base + + +class RefreshToken(Base, table=True): # ty: ignore[unsupported-base] + """Opaque refresh token exchanged for a new access + refresh pair.""" + + __tablename__ = "users_refresh_token" + + token: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) + user_id: uuid_mod.UUID = Field(foreign_key="users_user.id", index=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + expires_at: datetime + revoked_at: datetime | None = None diff --git a/modules/users/users/module.py b/modules/users/users/module.py index edaa401d..e9049e59 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -106,6 +106,7 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None from users.admin.api import admin_router from users.admin.views import router as admin_views from users.auth_local import api as auth_local_api + from users.auth_local.token_api import router as token_router from users.auth_local.views import router as auth_views from users.contracts.schemas import UserCreate, UserRead from users.deps import fastapi_users @@ -120,6 +121,7 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None settings = UsersSettings() api_router.include_router(auth_local_api.router) + api_router.include_router(token_router) api_router.include_router(admin_router) # Throughput-wrap the stock fastapi-users routers; ``require_signup_enabled`` # gates /register at request time so ``allow_signup`` is hot-reloadable. diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index bc644d27..5d955fd8 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -50,6 +50,10 @@ class UsersSettings(BaseSettings): reset_password_token_lifetime_seconds: int = 60 * 60 # 1 hour verification_token_lifetime_seconds: int = 60 * 60 * 24 * 7 # 7 days + # Bearer token (mobile / API clients) + bearer_token_lifetime_seconds: int = 60 * 15 # 15 minutes + refresh_token_lifetime_seconds: int = 60 * 60 * 24 * 30 # 30 days + # Cookie (fastapi-users AuthenticationBackend) cookie_name: str = "sm_auth" cookie_max_age_seconds: int = 60 * 60 * 24 * 14 # 14 days From 9d42e5850c6b3ce258c86bb601185cabe113b283 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:34:18 +0200 Subject: [PATCH 17/34] test: integration tests for pluggable auth provider system Add 6 integration tests verifying both auth providers satisfy the AuthProvider protocol, SM020/SM021 diagnostics fire correctly, and module metadata is consistent. Fix 4 regressions surfaced by the full suite: - keycloak depends_on assertion (["Auth"] -> ["Auth", "Settings"]) - SM020 fires in test_app_state_has_sm_services (exclude Keycloak) - typer 0.26 vendored click breaks Exit catch in CLI test - PydanticUndefined default_factory fields crash settings serializer - Remove keycloak/tests/__init__.py that shadowed root tests package --- .../cli/tests/test_cli_package_update.py | 13 +++- framework/hosting/tests/test_app.py | 8 ++- modules/keycloak/tests/__init__.py | 0 .../keycloak/tests/test_keycloak_module.py | 2 +- modules/settings/settings/_module_settings.py | 19 +++++- tests/integration/test_pluggable_auth.py | 59 +++++++++++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) delete mode 100644 modules/keycloak/tests/__init__.py create mode 100644 tests/integration/test_pluggable_auth.py diff --git a/framework/cli/tests/test_cli_package_update.py b/framework/cli/tests/test_cli_package_update.py index 019b552f..ae944542 100644 --- a/framework/cli/tests/test_cli_package_update.py +++ b/framework/cli/tests/test_cli_package_update.py @@ -157,14 +157,23 @@ def fetcher(url: str) -> dict: def test_missing_pyproject_exits_nonzero(tmp_path: Path) -> None: - with pytest.raises(click.exceptions.Exit) as exc: + # typer >= 0.26 vendors click as ``typer._click``; the raised ``Exit`` + # no longer inherits from ``click.exceptions.Exit``. Catch both. + _exit_types: tuple[type[BaseException], ...] = (click.exceptions.Exit,) + try: + from typer._click.exceptions import Exit as _TyExit + + _exit_types = (*_exit_types, _TyExit) + except ImportError: + pass + with pytest.raises(_exit_types) as exc: pu.run_update( path=tmp_path, dry_run=False, include_pre=False, fetcher=_fake_pypi({}), ) - assert exc.value.exit_code == 1 + assert getattr(exc.value, "exit_code", getattr(exc.value, "code", None)) == 1 def test_cli_command_registered() -> None: diff --git a/framework/hosting/tests/test_app.py b/framework/hosting/tests/test_app.py index f6c6a50f..e434f510 100644 --- a/framework/hosting/tests/test_app.py +++ b/framework/hosting/tests/test_app.py @@ -77,7 +77,13 @@ async def test_app_state_has_sm_services( (tmp_path / "host" / "templates").mkdir(parents=True) (tmp_path / "host" / "templates" / "index.html").write_text("") - app = create_app() + # Exclude Keycloak — both ``users`` and ``keycloak`` are installed as + # entry points in the dev workspace. SM020 fires if both are present + # because the app is only meant to run with one auth provider. + from simple_module_core.discovery import discover_modules + + all_names = [m.meta.name for m in discover_modules() if m.meta.name != "Keycloak"] + app = create_app(Settings(modules_enabled=all_names)) sm = app.state.sm assert isinstance(sm, Services) diff --git a/modules/keycloak/tests/__init__.py b/modules/keycloak/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/keycloak/tests/test_keycloak_module.py b/modules/keycloak/tests/test_keycloak_module.py index 1d08760c..7b073d11 100644 --- a/modules/keycloak/tests/test_keycloak_module.py +++ b/modules/keycloak/tests/test_keycloak_module.py @@ -10,7 +10,7 @@ def test_keycloak_module_meta(): mod = KeycloakModule() assert mod.meta.name == "Keycloak" - assert mod.meta.depends_on == ["Auth"] + assert mod.meta.depends_on == ["Auth", "Settings"] assert mod._is_auth_provider is True diff --git a/modules/settings/settings/_module_settings.py b/modules/settings/settings/_module_settings.py index 39c7e148..ad3c8176 100644 --- a/modules/settings/settings/_module_settings.py +++ b/modules/settings/settings/_module_settings.py @@ -77,17 +77,34 @@ def _extract_settings(app: FastAPI, package: str) -> BaseSettings | None: return inner if isinstance(inner, BaseSettings) else None +def _resolve_default(info) -> Any: + """Return the effective default, handling ``default_factory`` fields. + + Pydantic sets ``info.default`` to ``PydanticUndefined`` when + ``default_factory`` is used. We call the factory to get the concrete + default so the settings UI can serialize it. + """ + from pydantic_core import PydanticUndefined + + if info.default is not PydanticUndefined: + return info.default + if info.default_factory is not None: + return info.default_factory() + return None + + def _field_view(name: str, settings: BaseSettings, prefix: str) -> ModuleSettingField: cls = type(settings) info = cls.model_fields[name] raw_value = getattr(settings, name) secret = is_secret_field(name) extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {} + default = _resolve_default(info) return ModuleSettingField( name=name, env_var=f"{prefix}{name.upper()}", value=_mask(raw_value) if secret else raw_value, - default=_mask(info.default) if secret else info.default, + default=_mask(default) if secret else default, description=info.description or "", is_secret=secret, type=value_type_for_field(cls, name), diff --git a/tests/integration/test_pluggable_auth.py b/tests/integration/test_pluggable_auth.py new file mode 100644 index 00000000..fc777c19 --- /dev/null +++ b/tests/integration/test_pluggable_auth.py @@ -0,0 +1,59 @@ +"""Integration tests for pluggable auth — verifying both providers work.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider +from auth.state import AuthState + + +def test_users_module_is_auth_provider(): + from users.module import UsersModule + + assert UsersModule._is_auth_provider is True + + +def test_keycloak_module_is_auth_provider(): + from keycloak.module import KeycloakModule + + assert KeycloakModule._is_auth_provider is True + + +def test_sm020_fires_with_both_modules(): + from keycloak.module import KeycloakModule + from simple_module_core.diagnostics._module import ModuleDiagnostics + from users.module import UsersModule + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([UsersModule(), KeycloakModule()]) + assert any(d.code == "SM020" for d in results) + + +def test_sm021_fires_with_neither(): + from simple_module_core.diagnostics._module import ModuleDiagnostics + from simple_module_core.module import ModuleBase, ModuleMeta + + class StubModule(ModuleBase): + meta = ModuleMeta(name="Stub") + + diags = ModuleDiagnostics() + results = diags._check_auth_provider_conflict([StubModule()]) + assert any(d.code == "SM021" for d in results) + + +def test_auth_provider_protocol_satisfied_by_users(): + from users.provider import UsersAuthProvider + + assert isinstance(UsersAuthProvider(), AuthProvider) + + +def test_auth_provider_protocol_satisfied_by_keycloak(): + from keycloak.provider import KeycloakAuthProvider + from keycloak.settings import KeycloakSettings + + settings = KeycloakSettings( + server_url="https://example.com", + realm="test", + client_id="app", + client_secret="secret", + ) + assert isinstance(KeycloakAuthProvider(settings), AuthProvider) From 67f89b3e710bc0012b4bd2ed5667eebe2dc83893 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:35:07 +0200 Subject: [PATCH 18/34] docs: document pluggable auth provider contract and SM020/SM021 diagnostics --- CLAUDE.md | 2 +- docs/framework-conventions.md | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5342a295..e0b3524f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Standard mixins in `simple_module_db.mixins`: `AuditMixin`, `SoftDeleteMixin` (b ## Diagnostic codes -Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. In production, errors fail boot. +Meaningful codes when reading `make doctor` output: `SM001` missing meta (error), `SM003` orphan page / `SM004` phantom render (warn), `SM007` module overrides no hooks (info), `SM008` duplicate name (error), `SM009` framework→plugin import (error), `SM010` DB revision behind head (error), `SM011` module table not in migration history (warn), `SM012` `register_settings` overridden but nothing on `app.state.` (warn, fires at dev boot only), `SM013`–`SM016` locale issues, `SM017` module ships `.tsx` pages but is missing `package.json`/`tsconfig.json` (warn), `SM018` Inertia `router.{post,patch,put,delete}()` in a page targets a JSON `/api/*` endpoint (warn — Inertia rejects non-Inertia responses), `SM019` module registers view routes (non-empty `view_prefix` + overrides `register_routes`) but overrides neither `register_menu_items` nor `register_permissions` (warn — pages exist with no sidebar entry and no role-editor visibility; admins can't reach them through the UI). Modules whose views are sub-pages of another module typically register permissions to stay discoverable in the role editor without needing their own sidebar entry. `SM020` multiple auth provider modules installed (error), `SM021` no auth provider module installed (warn). In production, errors fail boot. ## Tests & fixtures diff --git a/docs/framework-conventions.md b/docs/framework-conventions.md index 99f24cb8..42a517f8 100644 --- a/docs/framework-conventions.md +++ b/docs/framework-conventions.md @@ -241,12 +241,31 @@ async def create_order(...): ... The `auth` module exposes a principal-resolver chain on `app.state.auth.principal_resolvers` — a list of async callables that -`users.AuthMiddleware` consults after the session-cookie path. Use it to add +`AuthMiddleware` consults after the session-cookie path. Use it to add non-cookie credential sources (Personal Access Tokens, API keys, JWTs) without forking the middleware. See [`docs/framework/principal-resolvers.md`](framework/principal-resolvers.md) for the contract, ordering rules, and a worked Bearer-token example. +### Auth Provider Contract + +The framework supports swappable authentication backends. Exactly one auth provider +module must be installed — either `simple-module-users` (local credentials + OAuth) +or `simple-module-keycloak` (Keycloak OIDC). Both implement the `AuthProvider` +protocol from `auth.contracts.provider`. + +**Module authors never import from `users` or `keycloak` directly.** Use only: +- `from auth.deps import CurrentUser, require_permission` +- `from auth.contracts.schemas import UserContext` + +The `AuthMiddleware` (in `auth/middleware.py`) delegates to the active provider's +`resolve_user()` method, then falls through to the principal-resolver chain. +API paths (`/api/*`) receive 401 JSON when unauthenticated; view paths receive +a 302 redirect to the provider's login URL. + +Boot-time diagnostic `SM020` fails if multiple auth providers are installed. +`SM021` warns if none is installed. + ## Events Base class: `Event` from `simple_module_core.events`. Subclass per domain event: @@ -290,6 +309,8 @@ Dispatch walks the event's MRO, so subscribing to a base class delivers subclass | SM014 | WARNING | Non-default locale missing keys present in the default | | SM015 | WARNING | Non-default locale has keys not in the default | | SM016 | ERROR | Locale JSON invalid or contains non-string leaves | +| SM020 | ERROR | Multiple auth provider modules installed | +| SM021 | WARNING | No auth provider module installed | Diagnostics run automatically at boot — warnings print to stderr in dev, errors abort startup in production. There's no separate "run diagnostics" step in a scaffolded app: just start the server. From d455086931d09e53acc9ab1b1e92cba142811cb9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 12:38:54 +0200 Subject: [PATCH 19/34] fix(keycloak): require issuer + audience for JWT validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWKSCache now requires issuer and audience at construction time and always verifies them. Previously these were optional with empty-string defaults, which silently disabled the checks — a token from any issuer or audience would be accepted. --- modules/keycloak/keycloak/jwks.py | 17 +++++++++-------- modules/keycloak/keycloak/module.py | 2 ++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/modules/keycloak/keycloak/jwks.py b/modules/keycloak/keycloak/jwks.py index 83b92619..1c6af3d2 100644 --- a/modules/keycloak/keycloak/jwks.py +++ b/modules/keycloak/keycloak/jwks.py @@ -24,9 +24,14 @@ def __init__( self, jwks_url: str, ttl_seconds: int = 3600, - issuer: str = "", - audience: str = "", + *, + issuer: str, + audience: str, ) -> None: + if not issuer: + raise ValueError("issuer is required for JWT validation") + if not audience: + raise ValueError("audience is required for JWT validation") self._jwks_url = jwks_url self._ttl = ttl_seconds self._issuer = issuer @@ -57,12 +62,8 @@ def _decode(self, token: str, key: Any) -> dict[str, Any] | None: token, key, algorithms=["RS256"], - issuer=self._issuer if self._issuer else None, - audience=self._audience if self._audience else None, - options={ - "verify_iss": bool(self._issuer), - "verify_aud": bool(self._audience), - }, + issuer=self._issuer, + audience=self._audience, ) except ( jwt.ExpiredSignatureError, diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index 7085ea25..07073bfc 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -77,6 +77,8 @@ async def on_startup(self, app: FastAPI) -> None: "/protocol/openid-connect/certs" ), ttl_seconds=s.jwks_cache_ttl_seconds, + issuer=f"{s.server_url}/realms/{s.realm}", + audience=s.client_id, ) provider = app.state.auth.auth_provider provider.jwks_cache = state.jwks_cache From 02afc2a4cae1b1c673999f0a071cc8e5726e3a0b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 13:01:39 +0200 Subject: [PATCH 20/34] fix(users): resolve bearer tokens in UsersAuthProvider UsersAuthProvider.resolve_user() now checks for Authorization: Bearer headers and validates the access token against the users_access_token table before falling back to session-cookie resolution. This enables mobile/API clients to authenticate using tokens from POST /api/users/auth/token. --- modules/users/users/provider.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/modules/users/users/provider.py b/modules/users/users/provider.py index 23c553d5..0f90f889 100644 --- a/modules/users/users/provider.py +++ b/modules/users/users/provider.py @@ -20,12 +20,16 @@ class UsersAuthProvider: - """Cookie-based auth provider using fastapi-users' DatabaseStrategy.""" + """Cookie + bearer auth provider using fastapi-users' DatabaseStrategy.""" name = "users" _is_auth_provider = True async def resolve_user(self, request: Request) -> UserContext | None: + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return await self._resolve_bearer(request.scope, auth_header[7:]) + session = request.scope.get("session", {}) raw_user_id = session.get(_SESSION_USER_ID_KEY) if not raw_user_id: @@ -79,6 +83,33 @@ def is_bearer_request(self, request: Request | None) -> bool: return False return request.headers.get("authorization", "").startswith("Bearer ") + async def _resolve_bearer(self, scope, token: str) -> UserContext | None: + """Look up an access token in users_access_token and return the user.""" + try: + from sqlalchemy import select + from sqlalchemy.orm import selectinload + + from users.models import User, UserAccessToken + + session_factory = scope["app"].state.sm.db.session_factory + async with session_factory() as db_session: + stmt = select(UserAccessToken).where(UserAccessToken.token == token) + access = (await db_session.execute(stmt)).scalar_one_or_none() + if access is None: + return None + stmt = ( + select(User) + .where(User.id == access.user_id) + .options(selectinload(User.roles)) + ) + user = (await db_session.execute(stmt)).scalar_one_or_none() + if user is None or not user.is_active or user.disabled_at is not None: + return None + return UserContext.from_user(user) + except Exception: + logger.exception("Bearer token resolution failed") + return None + async def _load_user(self, scope, user_id: uuid_mod.UUID) -> UserContext | None: try: from sqlalchemy import select From cdf6a0b16c64a5dd7c0803b1d4838bbaea0c10b3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 15:01:01 +0200 Subject: [PATCH 21/34] fix(users): API logout clears session + constant-time token login BUG-001: POST /api/users/auth/logout now clears the Starlette session in addition to deleting the sm_auth cookie. The stock fastapi-users logout at /auth-inner/logout only cleared the cookie, leaving the session user_id intact so the user remained authenticated. BUG-002: token_login now runs a dummy bcrypt verification when the user is not found, preventing timing-based email enumeration (existing user took ~50ms for bcrypt, missing user was instant). --- modules/users/users/auth_local/api.py | 14 ++++++++++++-- modules/users/users/auth_local/token_api.py | 12 +++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/modules/users/users/auth_local/api.py b/modules/users/users/auth_local/api.py index 77ba93eb..209b4e99 100644 --- a/modules/users/users/auth_local/api.py +++ b/modules/users/users/auth_local/api.py @@ -117,12 +117,22 @@ async def login( # ── Mount fastapi-users stock routers ──────────────────────────────────────── # The stock auth router (login + logout) is mounted at /auth-inner so its -# logout and other endpoints remain accessible. Our wrapper at /auth/login -# shadows the stock login endpoint. Logout is exposed via /auth-inner/logout. +# endpoints remain accessible. Our wrappers at /auth/login and /auth/logout +# shadow the stock endpoints to also manage the session cookie. auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True) router.include_router(auth_inner, prefix="/auth-inner") +@router.post("/auth/logout", status_code=204) +async def api_logout(request: Request): + """API logout — clears both the access-token cookie and the session.""" + request.session.clear() + cookie_name = request.app.state.users.settings.cookie_name + response = Response(status_code=204) + response.delete_cookie(cookie_name, path="/") + return response + + # ── Accept-invite (verify + set password + login, one shot) ───────────────── diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index 1b13ac69..fcb0f556 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -19,6 +19,11 @@ from users.models import User from users.models.refresh_token import RefreshToken +# Pre-computed bcrypt hash used when a login attempt targets a non-existent +# user. Running verify against this takes the same time as a real check, +# preventing timing-based email enumeration. +_DUMMY_HASH = "$2b$12$LJ3m4ys3Lg/PFgWCZxEzR.ZVxFMz3yeqHEhSYmiJ9gJOPG7W3Cq2G" + router = APIRouter(prefix="/auth", tags=["users-token"]) @@ -53,12 +58,17 @@ async def token_login( """Exchange email + password for an access/refresh token pair.""" from fastapi_users.password import PasswordHelper + helper = PasswordHelper() + stmt = select(User).where(User.email == body.email) user = (await db.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: + # Constant-time: run bcrypt on a dummy hash to prevent timing-based + # email enumeration (existing user + wrong password takes ~50ms for + # bcrypt; missing user would be instant without this). + helper.verify_and_update(body.password, _DUMMY_HASH) raise HTTPException(status_code=401, detail="Invalid credentials") - helper = PasswordHelper() verified, _ = helper.verify_and_update(body.password, user.hashed_password) if not verified: raise HTTPException(status_code=401, detail="Invalid credentials") From 3bea0b366403c0513015da050c4c05512653d399 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:27:34 +0200 Subject: [PATCH 22/34] fix(keycloak): await inertia.render() in login view The login_page view returned a coroutine instead of a response, causing a 500 error. inertia.render() is async and must be awaited. --- modules/keycloak/keycloak/endpoints/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/keycloak/keycloak/endpoints/views.py b/modules/keycloak/keycloak/endpoints/views.py index 357ee1b8..e175e8df 100644 --- a/modules/keycloak/keycloak/endpoints/views.py +++ b/modules/keycloak/keycloak/endpoints/views.py @@ -13,7 +13,7 @@ @router.get("/login") async def login_page(request: Request, inertia: InertiaDep): - return inertia.render("Keycloak/Login") + return await inertia.render("Keycloak/Login") @router.post("/logout") From 2416cf2a63bd117a2760e26177f834812d89b7fb Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:39:34 +0200 Subject: [PATCH 23/34] chore: regenerate i18n keys for keycloak module pages --- packages/i18n/src/generated-resources.ts | 138 +--------------- packages/i18n/src/keys.generated.ts | 190 ++--------------------- 2 files changed, 17 insertions(+), 311 deletions(-) diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 48d2b993..40135719 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -4,59 +4,6 @@ export default { translation: { 'auth.errors.missing_permission': '', 'auth.errors.not_authenticated': '', - 'background_tasks.detail.args': '', - 'background_tasks.detail.back_button': '', - 'background_tasks.detail.kwargs': '', - 'background_tasks.detail.meta': '', - 'background_tasks.detail.no_traceback': '', - 'background_tasks.detail.result': '', - 'background_tasks.detail.retried_from': '', - 'background_tasks.detail.title': '', - 'background_tasks.detail.traceback': '', - 'background_tasks.filters.status_all': '', - 'background_tasks.filters.status_label': '', - 'background_tasks.index.count_one': '', - 'background_tasks.index.count_other': '', - 'background_tasks.index.description': '', - 'background_tasks.index.empty_description': '', - 'background_tasks.index.empty_title': '', - 'background_tasks.index.no_match': '', - 'background_tasks.index.search_placeholder': '', - 'background_tasks.index.title': '', - 'background_tasks.retry_dialog.cancel': '', - 'background_tasks.retry_dialog.confirm': '', - 'background_tasks.retry_dialog.description': '', - 'background_tasks.retry_dialog.title': '', - 'background_tasks.status.failed': '', - 'background_tasks.status.pending': '', - 'background_tasks.status.retrying': '', - 'background_tasks.status.revoked': '', - 'background_tasks.status.running': '', - 'background_tasks.status.stuck': '', - 'background_tasks.status.success': '', - 'background_tasks.table.actions': '', - 'background_tasks.table.duration': '', - 'background_tasks.table.queue': '', - 'background_tasks.table.queued_at': '', - 'background_tasks.table.retry': '', - 'background_tasks.table.status': '', - 'background_tasks.table.task': '', - 'background_tasks.table.view': '', - 'background_tasks.table.worker': '', - 'background_tasks.toasts.retried': '', - 'background_tasks.toasts.retry_failed': '', - 'dashboard.home.description': '', - 'dashboard.home.description_body': '', - 'dashboard.home.stats.active_users': '', - 'dashboard.home.stats.modules': '', - 'dashboard.home.stats.total_users': '', - 'dashboard.home.system_info.health_checks': '', - 'dashboard.home.system_info.modules': '', - 'dashboard.home.system_info.python_version': '', - 'dashboard.home.system_info_title': '', - 'dashboard.home.title': '', - 'dashboard.home.welcome_card_title': '', - 'dashboard.home.welcome_message': '', 'feature_flags.browse.back_to_system': '', 'feature_flags.browse.count_one': '', 'feature_flags.browse.count_other': '', @@ -89,34 +36,6 @@ export default { 'feature_flags.toasts.disabled': '', 'feature_flags.toasts.enabled': '', 'feature_flags.toasts.toggle_failed': '', - 'file_storage.actions.delete': '', - 'file_storage.actions.download': '', - 'file_storage.browse.count_one': '', - 'file_storage.browse.count_other': '', - 'file_storage.browse.description': '', - 'file_storage.browse.empty_description': '', - 'file_storage.browse.empty_title': '', - 'file_storage.browse.title': '', - 'file_storage.browse.upload_button': '', - 'file_storage.browse.uploading': '', - 'file_storage.delete_dialog.cancel': '', - 'file_storage.delete_dialog.confirm': '', - 'file_storage.delete_dialog.description': '', - 'file_storage.delete_dialog.title': '', - 'file_storage.errors.backend_error': '', - 'file_storage.errors.bad_type': '', - 'file_storage.errors.not_found': '', - 'file_storage.errors.too_large': '', - 'file_storage.table.actions': '', - 'file_storage.table.filename': '', - 'file_storage.table.size': '', - 'file_storage.table.type': '', - 'file_storage.table.uploaded_at': '', - 'file_storage.table.uploaded_by': '', - 'file_storage.toasts.delete_failed': '', - 'file_storage.toasts.deleted': '', - 'file_storage.toasts.upload_failed': '', - 'file_storage.toasts.uploaded': '', 'host.error.forbidden_description': '', 'host.error.forbidden_title': '', 'host.error.generic_description': '', @@ -146,56 +65,13 @@ export default { 'host.landing.hero_subtitle': '', 'host.landing.hero_title_line1': '', 'host.landing.hero_title_line2': '', - 'permissions.browse.count_label': '', - 'permissions.browse.count_label_one': '', - 'permissions.browse.count_label_other': '', - 'permissions.browse.description': '', - 'permissions.browse.direct_count_label': '', - 'permissions.browse.direct_count_label_one': '', - 'permissions.browse.direct_count_label_other': '', - 'permissions.browse.edit_link': '', - 'permissions.browse.group_description': '', - 'permissions.browse.no_permissions': '', - 'permissions.browse.no_roles': '', - 'permissions.browse.no_users': '', - 'permissions.browse.registry_heading': '', - 'permissions.browse.registry_hint': '', - 'permissions.browse.roles_heading': '', - 'permissions.browse.search_placeholder': '', - 'permissions.browse.title': '', - 'permissions.browse.users_heading': '', - 'permissions.edit.cancel_link': '', - 'permissions.edit.clear_group': '', - 'permissions.edit.description': '', - 'permissions.edit.empty': '', - 'permissions.edit.reset_button': '', - 'permissions.edit.select_all_group': '', - 'permissions.edit.selected_summary': '', - 'permissions.edit.submit_button': '', - 'permissions.edit.title': '', - 'permissions.errors.role_not_found': '', - 'permissions.errors.user_not_found': '', - 'permissions.table.actions': '', - 'permissions.table.assigned': '', - 'permissions.table.description': '', - 'permissions.table.direct': '', - 'permissions.table.name': '', - 'permissions.table.role': '', - 'permissions.table.user': '', - 'permissions.toasts.save_failed': '', - 'permissions.toasts.saved': '', - 'permissions.user_edit.cancel_link': '', - 'permissions.user_edit.description': '', - 'permissions.user_edit.direct_summary': '', - 'permissions.user_edit.effective_summary': '', - 'permissions.user_edit.empty': '', - 'permissions.user_edit.inherited_badge': '', - 'permissions.user_edit.inherited_hint': '', - 'permissions.user_edit.no_roles': '', - 'permissions.user_edit.reset_button': '', - 'permissions.user_edit.roles_label': '', - 'permissions.user_edit.submit_button': '', - 'permissions.user_edit.title': '', + 'keycloak.errors.callback_failed': '', + 'keycloak.errors.invalid_state': '', + 'keycloak.errors.token_validation_failed': '', + 'keycloak.login.redirecting': '', + 'keycloak.login.title': '', + 'keycloak.logout.message': '', + 'keycloak.logout.title': '', 'settings.browse.delete_confirm': '', 'settings.browse.delete_link': '', 'settings.browse.edit_link': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index d050834c..4d4a0d9c 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -8,84 +8,6 @@ export const keys = { not_authenticated: 'auth.errors.not_authenticated', }, }, - background_tasks: { - detail: { - args: 'background_tasks.detail.args', - back_button: 'background_tasks.detail.back_button', - kwargs: 'background_tasks.detail.kwargs', - meta: 'background_tasks.detail.meta', - no_traceback: 'background_tasks.detail.no_traceback', - result: 'background_tasks.detail.result', - retried_from: 'background_tasks.detail.retried_from', - title: 'background_tasks.detail.title', - traceback: 'background_tasks.detail.traceback', - }, - filters: { - status_all: 'background_tasks.filters.status_all', - status_label: 'background_tasks.filters.status_label', - }, - index: { - count: 'background_tasks.index.count', - count_one: 'background_tasks.index.count_one', - count_other: 'background_tasks.index.count_other', - description: 'background_tasks.index.description', - empty_description: 'background_tasks.index.empty_description', - empty_title: 'background_tasks.index.empty_title', - no_match: 'background_tasks.index.no_match', - search_placeholder: 'background_tasks.index.search_placeholder', - title: 'background_tasks.index.title', - }, - retry_dialog: { - cancel: 'background_tasks.retry_dialog.cancel', - confirm: 'background_tasks.retry_dialog.confirm', - description: 'background_tasks.retry_dialog.description', - title: 'background_tasks.retry_dialog.title', - }, - status: { - failed: 'background_tasks.status.failed', - pending: 'background_tasks.status.pending', - retrying: 'background_tasks.status.retrying', - revoked: 'background_tasks.status.revoked', - running: 'background_tasks.status.running', - stuck: 'background_tasks.status.stuck', - success: 'background_tasks.status.success', - }, - table: { - actions: 'background_tasks.table.actions', - duration: 'background_tasks.table.duration', - queue: 'background_tasks.table.queue', - queued_at: 'background_tasks.table.queued_at', - retry: 'background_tasks.table.retry', - status: 'background_tasks.table.status', - task: 'background_tasks.table.task', - view: 'background_tasks.table.view', - worker: 'background_tasks.table.worker', - }, - toasts: { - retried: 'background_tasks.toasts.retried', - retry_failed: 'background_tasks.toasts.retry_failed', - }, - }, - dashboard: { - home: { - description: 'dashboard.home.description', - description_body: 'dashboard.home.description_body', - stats: { - active_users: 'dashboard.home.stats.active_users', - modules: 'dashboard.home.stats.modules', - total_users: 'dashboard.home.stats.total_users', - }, - system_info: { - health_checks: 'dashboard.home.system_info.health_checks', - modules: 'dashboard.home.system_info.modules', - python_version: 'dashboard.home.system_info.python_version', - }, - system_info_title: 'dashboard.home.system_info_title', - title: 'dashboard.home.title', - welcome_card_title: 'dashboard.home.welcome_card_title', - welcome_message: 'dashboard.home.welcome_message', - }, - }, feature_flags: { browse: { back_to_system: 'feature_flags.browse.back_to_system', @@ -127,49 +49,6 @@ export const keys = { toggle_failed: 'feature_flags.toasts.toggle_failed', }, }, - file_storage: { - actions: { - delete: 'file_storage.actions.delete', - download: 'file_storage.actions.download', - }, - browse: { - count: 'file_storage.browse.count', - count_one: 'file_storage.browse.count_one', - count_other: 'file_storage.browse.count_other', - description: 'file_storage.browse.description', - empty_description: 'file_storage.browse.empty_description', - empty_title: 'file_storage.browse.empty_title', - title: 'file_storage.browse.title', - upload_button: 'file_storage.browse.upload_button', - uploading: 'file_storage.browse.uploading', - }, - delete_dialog: { - cancel: 'file_storage.delete_dialog.cancel', - confirm: 'file_storage.delete_dialog.confirm', - description: 'file_storage.delete_dialog.description', - title: 'file_storage.delete_dialog.title', - }, - errors: { - backend_error: 'file_storage.errors.backend_error', - bad_type: 'file_storage.errors.bad_type', - not_found: 'file_storage.errors.not_found', - too_large: 'file_storage.errors.too_large', - }, - table: { - actions: 'file_storage.table.actions', - filename: 'file_storage.table.filename', - size: 'file_storage.table.size', - type: 'file_storage.table.type', - uploaded_at: 'file_storage.table.uploaded_at', - uploaded_by: 'file_storage.table.uploaded_by', - }, - toasts: { - delete_failed: 'file_storage.toasts.delete_failed', - deleted: 'file_storage.toasts.deleted', - upload_failed: 'file_storage.toasts.upload_failed', - uploaded: 'file_storage.toasts.uploaded', - }, - }, host: { error: { forbidden_description: 'host.error.forbidden_description', @@ -207,68 +86,19 @@ export const keys = { hero_title_line2: 'host.landing.hero_title_line2', }, }, - permissions: { - browse: { - count_label: 'permissions.browse.count_label', - count_label_one: 'permissions.browse.count_label_one', - count_label_other: 'permissions.browse.count_label_other', - description: 'permissions.browse.description', - direct_count_label: 'permissions.browse.direct_count_label', - direct_count_label_one: 'permissions.browse.direct_count_label_one', - direct_count_label_other: 'permissions.browse.direct_count_label_other', - edit_link: 'permissions.browse.edit_link', - group_description: 'permissions.browse.group_description', - no_permissions: 'permissions.browse.no_permissions', - no_roles: 'permissions.browse.no_roles', - no_users: 'permissions.browse.no_users', - registry_heading: 'permissions.browse.registry_heading', - registry_hint: 'permissions.browse.registry_hint', - roles_heading: 'permissions.browse.roles_heading', - search_placeholder: 'permissions.browse.search_placeholder', - title: 'permissions.browse.title', - users_heading: 'permissions.browse.users_heading', - }, - edit: { - cancel_link: 'permissions.edit.cancel_link', - clear_group: 'permissions.edit.clear_group', - description: 'permissions.edit.description', - empty: 'permissions.edit.empty', - reset_button: 'permissions.edit.reset_button', - select_all_group: 'permissions.edit.select_all_group', - selected_summary: 'permissions.edit.selected_summary', - submit_button: 'permissions.edit.submit_button', - title: 'permissions.edit.title', - }, + keycloak: { errors: { - role_not_found: 'permissions.errors.role_not_found', - user_not_found: 'permissions.errors.user_not_found', - }, - table: { - actions: 'permissions.table.actions', - assigned: 'permissions.table.assigned', - description: 'permissions.table.description', - direct: 'permissions.table.direct', - name: 'permissions.table.name', - role: 'permissions.table.role', - user: 'permissions.table.user', + callback_failed: 'keycloak.errors.callback_failed', + invalid_state: 'keycloak.errors.invalid_state', + token_validation_failed: 'keycloak.errors.token_validation_failed', }, - toasts: { - save_failed: 'permissions.toasts.save_failed', - saved: 'permissions.toasts.saved', + login: { + redirecting: 'keycloak.login.redirecting', + title: 'keycloak.login.title', }, - user_edit: { - cancel_link: 'permissions.user_edit.cancel_link', - description: 'permissions.user_edit.description', - direct_summary: 'permissions.user_edit.direct_summary', - effective_summary: 'permissions.user_edit.effective_summary', - empty: 'permissions.user_edit.empty', - inherited_badge: 'permissions.user_edit.inherited_badge', - inherited_hint: 'permissions.user_edit.inherited_hint', - no_roles: 'permissions.user_edit.no_roles', - reset_button: 'permissions.user_edit.reset_button', - roles_label: 'permissions.user_edit.roles_label', - submit_button: 'permissions.user_edit.submit_button', - title: 'permissions.user_edit.title', + logout: { + message: 'keycloak.logout.message', + title: 'keycloak.logout.title', }, }, settings: { From 854b2e418951d5e1a1ab0f5ebf9bb0cd965e4581 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:40:05 +0200 Subject: [PATCH 24/34] style: auto-format new files with ruff --- .../simple_module_core/diagnostics/_module.py | 3 +- modules/keycloak/keycloak/endpoints/api.py | 20 +++-------- modules/keycloak/keycloak/module.py | 19 +++------- modules/keycloak/keycloak/oidc.py | 9 ++--- modules/keycloak/keycloak/provider.py | 36 +++++-------------- modules/keycloak/tests/test_jwks.py | 36 +++++-------------- .../keycloak/tests/test_keycloak_provider.py | 4 +-- modules/keycloak/tests/test_oidc.py | 11 ++---- modules/users/users/provider.py | 10 ++---- 9 files changed, 36 insertions(+), 112 deletions(-) diff --git a/framework/core/simple_module_core/diagnostics/_module.py b/framework/core/simple_module_core/diagnostics/_module.py index ecc5bbd3..b58e1a68 100644 --- a/framework/core/simple_module_core/diagnostics/_module.py +++ b/framework/core/simple_module_core/diagnostics/_module.py @@ -176,8 +176,7 @@ def _check_auth_provider_conflict(self, modules: list[ModuleBase]) -> list[Diagn message=f"Multiple auth provider modules installed: {names}", module_name=providers[0].meta.name, suggestion=( - "Install only one auth provider " - "(e.g. 'users' OR 'keycloak', not both)" + "Install only one auth provider (e.g. 'users' OR 'keycloak', not both)" ), ) ) diff --git a/modules/keycloak/keycloak/endpoints/api.py b/modules/keycloak/keycloak/endpoints/api.py index 176771dc..a7ea93cb 100644 --- a/modules/keycloak/keycloak/endpoints/api.py +++ b/modules/keycloak/keycloak/endpoints/api.py @@ -67,26 +67,18 @@ async def oidc_callback(request: Request): callback_url = str(request.url_for("oidc_callback")) try: - tokens = await client.exchange_code( - code=code, redirect_uri=callback_url - ) + tokens = await client.exchange_code(code=code, redirect_uri=callback_url) except Exception: logger.exception("Token exchange failed") - raise HTTPException( - status_code=502, detail="Token exchange failed" - ) + raise HTTPException(status_code=502, detail="Token exchange failed") id_token = tokens.get("id_token", "") access_token = tokens.get("access_token", "") jwks_cache = request.app.state.keycloak.jwks_cache - claims = ( - await jwks_cache.validate_jwt(access_token) if jwks_cache else None - ) + claims = await jwks_cache.validate_jwt(access_token) if jwks_cache else None if claims is None: - raise HTTPException( - status_code=401, detail="Token validation failed" - ) + raise HTTPException(status_code=401, detail="Token validation failed") provider = request.app.state.auth.auth_provider cache_id = await provider._upsert_user_cache(request, claims) @@ -96,7 +88,5 @@ async def oidc_callback(request: Request): request.session[_SESSION_ID_TOKEN] = id_token s = _get_settings(request) - next_url = ( - request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url - ) + next_url = request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url return RedirectResponse(next_url, status_code=303) diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index 07073bfc..e95ad8e7 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -40,9 +40,7 @@ def register_settings(self, app: FastAPI) -> None: lambda s: KeycloakState(settings=s), ) - app.state.auth.auth_provider = KeycloakAuthProvider( - app.state.keycloak.settings - ) + app.state.auth.auth_provider = KeycloakAuthProvider(app.state.keycloak.settings) def register_menu_items(self, registry: MenuRegistry) -> None: registry.add( @@ -56,9 +54,7 @@ def register_menu_items(self, registry: MenuRegistry) -> None: ) ) - def register_routes( - self, api_router: APIRouter, view_router: APIRouter - ) -> None: + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: from keycloak.endpoints.api import router as api from keycloak.endpoints.views import router as views @@ -72,10 +68,7 @@ async def on_startup(self, app: FastAPI) -> None: s = state.settings if s.server_url and s.realm: state.jwks_cache = JWKSCache( - jwks_url=( - f"{s.server_url}/realms/{s.realm}" - "/protocol/openid-connect/certs" - ), + jwks_url=(f"{s.server_url}/realms/{s.realm}/protocol/openid-connect/certs"), ttl_seconds=s.jwks_cache_ttl_seconds, issuer=f"{s.server_url}/realms/{s.realm}", audience=s.client_id, @@ -84,8 +77,4 @@ async def on_startup(self, app: FastAPI) -> None: provider.jwks_cache = state.jwks_cache def locale_dirs(self) -> dict[str, Path]: - return { - "keycloak": Path( - str(importlib.resources.files(__package__) / "locales") - ) - } + return {"keycloak": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/keycloak/keycloak/oidc.py b/modules/keycloak/keycloak/oidc.py index d7527b64..6439da67 100644 --- a/modules/keycloak/keycloak/oidc.py +++ b/modules/keycloak/keycloak/oidc.py @@ -19,10 +19,7 @@ def __init__( client_id: str, client_secret: str, ) -> None: - self._base = ( - f"{server_url.rstrip('/')}/realms/{realm}" - "/protocol/openid-connect" - ) + self._base = f"{server_url.rstrip('/')}/realms/{realm}/protocol/openid-connect" self._client_id = client_id self._client_secret = client_secret self._server_url = server_url.rstrip("/") @@ -71,9 +68,7 @@ async def exchange_code( "client_secret": self._client_secret, } async with httpx.AsyncClient() as client: - resp = await client.post( - self.token_endpoint, data=data, timeout=10 - ) + resp = await client.post(self.token_endpoint, data=data, timeout=10) resp.raise_for_status() return resp.json() diff --git a/modules/keycloak/keycloak/provider.py b/modules/keycloak/keycloak/provider.py index 56541c83..5f33e18f 100644 --- a/modules/keycloak/keycloak/provider.py +++ b/modules/keycloak/keycloak/provider.py @@ -33,13 +33,9 @@ async def resolve_user(self, request: Request) -> UserContext | None: return await self._resolve_bearer(request, auth_header[7:]) session = request.scope.get("session", {}) - return UserContext.from_session_dict( - session.get(_SESSION_USER_CTX_KEY) - ) + return UserContext.from_session_dict(session.get(_SESSION_USER_CTX_KEY)) - def get_login_url( - self, request: Request | None, next_url: str | None = None - ) -> str: + def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: return "/keycloak/login" def get_logout_url(self, request: Request | None) -> str: @@ -56,13 +52,9 @@ def is_bearer_request(self, request: Request | None) -> bool: return False return request.headers.get("authorization", "").startswith("Bearer ") - async def _resolve_bearer( - self, request: Request, token: str - ) -> UserContext | None: + async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None: if self.jwks_cache is None: - logger.warning( - "JWKS cache not initialized; rejecting bearer token" - ) + logger.warning("JWKS cache not initialized; rejecting bearer token") return None claims = await self.jwks_cache.validate_jwt(token) if claims is None: @@ -78,9 +70,7 @@ def _claims_to_user_context( cache_id: str, ) -> UserContext: roles_raw = ( - _extract_nested(claims, self._settings.roles_claim_path) - if self._settings - else None + _extract_nested(claims, self._settings.roles_claim_path) if self._settings else None ) mapped = [ self._settings.role_mapping[r] @@ -90,16 +80,12 @@ def _claims_to_user_context( return UserContext( id=cache_id, email=claims.get("email", ""), - name=( - claims.get("preferred_username") or claims.get("name", "") - ), + name=(claims.get("preferred_username") or claims.get("name", "")), roles=mapped, tenant_id=claims.get("tenant_id"), ) - async def _upsert_user_cache( - self, request: Request, claims: dict - ) -> str: + async def _upsert_user_cache(self, request: Request, claims: dict) -> str: try: from keycloak.models import KeycloakUserCache from sqlalchemy import select @@ -107,9 +93,7 @@ async def _upsert_user_cache( session_factory = request.app.state.sm.db.session_factory sub = claims["sub"] async with session_factory() as db: - stmt = select(KeycloakUserCache).where( - KeycloakUserCache.keycloak_sub == sub - ) + stmt = select(KeycloakUserCache).where(KeycloakUserCache.keycloak_sub == sub) row = (await db.execute(stmt)).scalar_one_or_none() if row is None: import uuid as uuid_mod @@ -128,9 +112,7 @@ async def _upsert_user_cache( from datetime import datetime, timezone row.email = claims.get("email", row.email) - row.full_name = claims.get( - "preferred_username", row.full_name - ) + row.full_name = claims.get("preferred_username", row.full_name) row.last_login_at = datetime.now(timezone.utc) await db.flush() return str(row.id) diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py index 0380d4d7..e180aefa 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/test_jwks.py @@ -13,9 +13,7 @@ def _generate_rsa_keypair(): - private_key = rsa.generate_private_key( - public_exponent=65537, key_size=2048 - ) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) public_key = private_key.public_key() return private_key, public_key @@ -31,9 +29,7 @@ def _make_jwks_response(public_key, kid="test-key-1"): def _sign_token(private_key, payload, kid="test-key-1"): - return jwt.encode( - payload, private_key, algorithm="RS256", headers={"kid": kid} - ) + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) @pytest.fixture @@ -59,9 +55,7 @@ def valid_payload(): async def test_validate_jwt_valid_token(rsa_keys, valid_payload, httpx_mock): private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key) - httpx_mock.add_response( - url="https://auth.example.com/jwks", json=jwks_data - ) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) cache = JWKSCache( jwks_url="https://auth.example.com/jwks", @@ -81,9 +75,7 @@ async def test_validate_jwt_expired_token(rsa_keys, valid_payload, httpx_mock): private_key, public_key = rsa_keys valid_payload["exp"] = int(time.time()) - 100 jwks_data = _make_jwks_response(public_key) - httpx_mock.add_response( - url="https://auth.example.com/jwks", json=jwks_data - ) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) cache = JWKSCache( jwks_url="https://auth.example.com/jwks", @@ -100,9 +92,7 @@ async def test_validate_jwt_expired_token(rsa_keys, valid_payload, httpx_mock): async def test_validate_jwt_wrong_issuer(rsa_keys, valid_payload, httpx_mock): private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key) - httpx_mock.add_response( - url="https://auth.example.com/jwks", json=jwks_data - ) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) cache = JWKSCache( jwks_url="https://auth.example.com/jwks", @@ -119,9 +109,7 @@ async def test_validate_jwt_wrong_issuer(rsa_keys, valid_payload, httpx_mock): async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key) - httpx_mock.add_response( - url="https://auth.example.com/jwks", json=jwks_data - ) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) cache = JWKSCache( jwks_url="https://auth.example.com/jwks", @@ -135,17 +123,11 @@ async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): assert claims is None -async def test_jwks_cache_refetches_on_unknown_kid( - rsa_keys, valid_payload, httpx_mock -): +async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload, httpx_mock): private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key, kid="rotated-key") - httpx_mock.add_response( - url="https://auth.example.com/jwks", json={"keys": []} - ) - httpx_mock.add_response( - url="https://auth.example.com/jwks", json=jwks_data - ) + httpx_mock.add_response(url="https://auth.example.com/jwks", json={"keys": []}) + httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) cache = JWKSCache( jwks_url="https://auth.example.com/jwks", diff --git a/modules/keycloak/tests/test_keycloak_provider.py b/modules/keycloak/tests/test_keycloak_provider.py index 597b71c2..7009865e 100644 --- a/modules/keycloak/tests/test_keycloak_provider.py +++ b/modules/keycloak/tests/test_keycloak_provider.py @@ -70,9 +70,7 @@ def test_claims_to_user_context(provider): "preferred_username": "testuser", "realm_access": {"roles": ["admin", "unknown_role", "user"]}, } - ctx = provider._claims_to_user_context( - claims, cache_id="aaaaaaaa-0000-0000-0000-000000000001" - ) + ctx = provider._claims_to_user_context(claims, cache_id="aaaaaaaa-0000-0000-0000-000000000001") assert isinstance(ctx, UserContext) assert ctx.id == "aaaaaaaa-0000-0000-0000-000000000001" assert ctx.email == "test@example.com" diff --git a/modules/keycloak/tests/test_oidc.py b/modules/keycloak/tests/test_oidc.py index 4f098a88..f1c943ed 100644 --- a/modules/keycloak/tests/test_oidc.py +++ b/modules/keycloak/tests/test_oidc.py @@ -22,9 +22,7 @@ def test_authorization_url(oidc_client): redirect_uri="https://app.example.com/callback", nonce="test-nonce", ) - assert ( - "auth.example.com/realms/test/protocol/openid-connect/auth" in url - ) + assert "auth.example.com/realms/test/protocol/openid-connect/auth" in url assert "client_id=my-app" in url assert "redirect_uri=" in url assert "response_type=code" in url @@ -36,8 +34,7 @@ def test_authorization_url(oidc_client): def test_token_endpoint_url(oidc_client): assert oidc_client.token_endpoint == ( - "https://auth.example.com/realms/test" - "/protocol/openid-connect/token" + "https://auth.example.com/realms/test/protocol/openid-connect/token" ) @@ -46,9 +43,7 @@ def test_logout_url(oidc_client): post_logout_redirect_uri="https://app.example.com/login", id_token_hint="token123", ) - assert ( - "auth.example.com/realms/test/protocol/openid-connect/logout" in url - ) + assert "auth.example.com/realms/test/protocol/openid-connect/logout" in url assert "post_logout_redirect_uri=" in url assert "id_token_hint=token123" in url diff --git a/modules/users/users/provider.py b/modules/users/users/provider.py index 0f90f889..e956c77c 100644 --- a/modules/users/users/provider.py +++ b/modules/users/users/provider.py @@ -98,9 +98,7 @@ async def _resolve_bearer(self, scope, token: str) -> UserContext | None: if access is None: return None stmt = ( - select(User) - .where(User.id == access.user_id) - .options(selectinload(User.roles)) + select(User).where(User.id == access.user_id).options(selectinload(User.roles)) ) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: @@ -119,11 +117,7 @@ async def _load_user(self, scope, user_id: uuid_mod.UUID) -> UserContext | None: session_factory = scope["app"].state.sm.db.session_factory async with session_factory() as db_session: - stmt = ( - select(User) - .where(User.id == user_id) - .options(selectinload(User.roles)) - ) + stmt = select(User).where(User.id == user_id).options(selectinload(User.roles)) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: return None From d3149c9d318b681e7cf72f18fe2d9686dfb1628b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:41:03 +0200 Subject: [PATCH 25/34] style: fix ruff lint errors (B904, RUF059, UP017) --- modules/keycloak/keycloak/endpoints/api.py | 2 +- modules/keycloak/keycloak/provider.py | 12 +++++++----- modules/keycloak/tests/test_jwks.py | 1 - modules/keycloak/tests/test_keycloak_provider.py | 5 ++--- modules/keycloak/tests/test_oidc.py | 1 - modules/users/tests/_middleware_support.py | 2 +- modules/users/tests/test_users_provider.py | 2 +- modules/users/users/auth_local/token_api.py | 12 ++++++------ modules/users/users/models/refresh_token.py | 4 ++-- tests/integration/test_pluggable_auth.py | 1 - 10 files changed, 20 insertions(+), 22 deletions(-) diff --git a/modules/keycloak/keycloak/endpoints/api.py b/modules/keycloak/keycloak/endpoints/api.py index a7ea93cb..fe6281a5 100644 --- a/modules/keycloak/keycloak/endpoints/api.py +++ b/modules/keycloak/keycloak/endpoints/api.py @@ -70,7 +70,7 @@ async def oidc_callback(request: Request): tokens = await client.exchange_code(code=code, redirect_uri=callback_url) except Exception: logger.exception("Token exchange failed") - raise HTTPException(status_code=502, detail="Token exchange failed") + raise HTTPException(status_code=502, detail="Token exchange failed") from None id_token = tokens.get("id_token", "") access_token = tokens.get("access_token", "") diff --git a/modules/keycloak/keycloak/provider.py b/modules/keycloak/keycloak/provider.py index 5f33e18f..bc705aad 100644 --- a/modules/keycloak/keycloak/provider.py +++ b/modules/keycloak/keycloak/provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import UTC from typing import TYPE_CHECKING, Any from auth.contracts.schemas import UserContext @@ -87,9 +88,10 @@ def _claims_to_user_context( async def _upsert_user_cache(self, request: Request, claims: dict) -> str: try: - from keycloak.models import KeycloakUserCache from sqlalchemy import select + from keycloak.models import KeycloakUserCache + session_factory = request.app.state.sm.db.session_factory sub = claims["sub"] async with session_factory() as db: @@ -97,23 +99,23 @@ async def _upsert_user_cache(self, request: Request, claims: dict) -> str: row = (await db.execute(stmt)).scalar_one_or_none() if row is None: import uuid as uuid_mod - from datetime import datetime, timezone + from datetime import datetime row = KeycloakUserCache( id=uuid_mod.uuid4(), keycloak_sub=sub, email=claims.get("email", ""), full_name=claims.get("preferred_username"), - last_login_at=datetime.now(timezone.utc), + last_login_at=datetime.now(UTC), ) db.add(row) await db.flush() else: - from datetime import datetime, timezone + from datetime import datetime row.email = claims.get("email", row.email) row.full_name = claims.get("preferred_username", row.full_name) - row.last_login_at = datetime.now(timezone.utc) + row.last_login_at = datetime.now(UTC) await db.flush() return str(row.id) except Exception: diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py index e180aefa..9c4c8e46 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/test_jwks.py @@ -8,7 +8,6 @@ import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa - from keycloak.jwks import JWKSCache diff --git a/modules/keycloak/tests/test_keycloak_provider.py b/modules/keycloak/tests/test_keycloak_provider.py index 7009865e..4b2982a4 100644 --- a/modules/keycloak/tests/test_keycloak_provider.py +++ b/modules/keycloak/tests/test_keycloak_provider.py @@ -4,13 +4,12 @@ from unittest.mock import MagicMock +import pytest from auth.contracts.provider import AuthProvider from auth.contracts.schemas import UserContext from keycloak.provider import KeycloakAuthProvider from keycloak.settings import KeycloakSettings -import pytest - @pytest.fixture def settings(): @@ -49,7 +48,7 @@ def test_logout_url(provider): def test_public_paths(provider): - prefixes, exact = provider.get_public_paths() + prefixes, _exact = provider.get_public_paths() assert "/keycloak/login" in prefixes assert "/api/keycloak/auth/" in prefixes diff --git a/modules/keycloak/tests/test_oidc.py b/modules/keycloak/tests/test_oidc.py index f1c943ed..c278afea 100644 --- a/modules/keycloak/tests/test_oidc.py +++ b/modules/keycloak/tests/test_oidc.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from keycloak.oidc import OIDCClient diff --git a/modules/users/tests/_middleware_support.py b/modules/users/tests/_middleware_support.py index 5441db86..0744746a 100644 --- a/modules/users/tests/_middleware_support.py +++ b/modules/users/tests/_middleware_support.py @@ -14,12 +14,12 @@ from typing import Any import pytest +from auth.middleware import AuthMiddleware from fastapi import FastAPI, Request from simple_module_test import forge_session_cookie from starlette.middleware.sessions import SessionMiddleware from starlette.responses import JSONResponse from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID -from auth.middleware import AuthMiddleware SECRET_KEY = "test-secret-key-for-session-middleware" diff --git a/modules/users/tests/test_users_provider.py b/modules/users/tests/test_users_provider.py index c5a3eaff..5772f974 100644 --- a/modules/users/tests/test_users_provider.py +++ b/modules/users/tests/test_users_provider.py @@ -23,7 +23,7 @@ def test_logout_url(): def test_public_paths(): provider = UsersAuthProvider() - prefixes, exact = provider.get_public_paths() + prefixes, _exact = provider.get_public_paths() assert "/users/login" in prefixes assert "/api/users/auth/" in prefixes diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index fcb0f556..3b333edf 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -8,7 +8,7 @@ from __future__ import annotations import uuid as uuid_mod -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Request from simple_module_db.deps import get_db @@ -87,9 +87,9 @@ async def token_refresh( try: token_uuid = uuid_mod.UUID(body.refresh_token) except (ValueError, TypeError): - raise HTTPException(status_code=401, detail="Invalid refresh token") + raise HTTPException(status_code=401, detail="Invalid refresh token") from None - now = datetime.now(timezone.utc) + now = datetime.now(UTC) stmt = select(RefreshToken).where( RefreshToken.token == token_uuid, RefreshToken.revoked_at.is_(None), # type: ignore[union-attr] @@ -115,12 +115,12 @@ async def token_revoke( try: token_uuid = uuid_mod.UUID(body.refresh_token) except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="Invalid token format") + raise HTTPException(status_code=400, detail="Invalid token format") from None stmt = select(RefreshToken).where(RefreshToken.token == token_uuid) rt = (await db.execute(stmt)).scalar_one_or_none() if rt and rt.revoked_at is None: - rt.revoked_at = datetime.now(timezone.utc) + rt.revoked_at = datetime.now(UTC) await db.flush() return {"status": "ok"} @@ -133,7 +133,7 @@ async def _create_token_pair( """Mint a new access token + refresh token pair and persist both.""" from users.models import UserAccessToken - now = datetime.now(timezone.utc) + now = datetime.now(UTC) access_token = UserAccessToken( token=str(uuid_mod.uuid4()), diff --git a/modules/users/users/models/refresh_token.py b/modules/users/users/models/refresh_token.py index 2cdce496..3380464d 100644 --- a/modules/users/users/models/refresh_token.py +++ b/modules/users/users/models/refresh_token.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid as uuid_mod -from datetime import datetime, timezone +from datetime import UTC, datetime from sqlmodel import Field @@ -17,6 +17,6 @@ class RefreshToken(Base, table=True): # ty: ignore[unsupported-base] token: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) user_id: uuid_mod.UUID = Field(foreign_key="users_user.id", index=True) - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) expires_at: datetime revoked_at: datetime | None = None diff --git a/tests/integration/test_pluggable_auth.py b/tests/integration/test_pluggable_auth.py index fc777c19..834ae83e 100644 --- a/tests/integration/test_pluggable_auth.py +++ b/tests/integration/test_pluggable_auth.py @@ -3,7 +3,6 @@ from __future__ import annotations from auth.contracts.provider import AuthProvider -from auth.state import AuthState def test_users_module_is_auth_provider(): From 34ef198733899723ffb653d69e75d2e527cb0f08 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:41:21 +0200 Subject: [PATCH 26/34] style: format keycloak TSX pages with biome --- modules/keycloak/keycloak/pages/LoggedOut.tsx | 2 +- modules/keycloak/keycloak/pages/Login.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/keycloak/keycloak/pages/LoggedOut.tsx b/modules/keycloak/keycloak/pages/LoggedOut.tsx index c383c2cb..8e1b961e 100644 --- a/modules/keycloak/keycloak/pages/LoggedOut.tsx +++ b/modules/keycloak/keycloak/pages/LoggedOut.tsx @@ -1,4 +1,4 @@ -import { Link } from "@inertiajs/react"; +import { Link } from '@inertiajs/react'; export default function LoggedOut() { return ( diff --git a/modules/keycloak/keycloak/pages/Login.tsx b/modules/keycloak/keycloak/pages/Login.tsx index c7b35a24..4e4641ee 100644 --- a/modules/keycloak/keycloak/pages/Login.tsx +++ b/modules/keycloak/keycloak/pages/Login.tsx @@ -1,9 +1,9 @@ -import { router } from "@inertiajs/react"; -import { useEffect } from "react"; +import { router } from '@inertiajs/react'; +import { useEffect } from 'react'; export default function Login() { useEffect(() => { - router.get("/api/keycloak/auth/login"); + router.get('/api/keycloak/auth/login'); }, []); return ( From 94fbfba950b3e7bb2417e24e518e9d1559b1f350 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:41:55 +0200 Subject: [PATCH 27/34] style: use module dependency constants in KeycloakModule --- modules/keycloak/keycloak/module.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index e95ad8e7..0576cd20 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -12,13 +12,16 @@ if TYPE_CHECKING: from fastapi import APIRouter, FastAPI +_MODULE_DEPENDENCY_AUTH = "Auth" +_MODULE_DEPENDENCY_SETTINGS = "Settings" + class KeycloakModule(ModuleBase): meta = ModuleMeta( name="Keycloak", route_prefix="/api/keycloak", view_prefix="/keycloak", - depends_on=["Auth", "Settings"], + depends_on=[_MODULE_DEPENDENCY_AUTH, _MODULE_DEPENDENCY_SETTINGS], ) _is_auth_provider = True From ba8759f38880a46edc894c3f4863eda5745f5a82 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:42:27 +0200 Subject: [PATCH 28/34] style: use page constant for Inertia render --- modules/keycloak/keycloak/endpoints/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/keycloak/keycloak/endpoints/views.py b/modules/keycloak/keycloak/endpoints/views.py index e175e8df..b40be653 100644 --- a/modules/keycloak/keycloak/endpoints/views.py +++ b/modules/keycloak/keycloak/endpoints/views.py @@ -9,11 +9,12 @@ router = APIRouter(tags=["keycloak-views"]) _SESSION_ID_TOKEN = "keycloak_id_token" +_PAGE_LOGIN = "Keycloak/Login" @router.get("/login") async def login_page(request: Request, inertia: InertiaDep): - return await inertia.render("Keycloak/Login") + return await inertia.render(_PAGE_LOGIN) @router.post("/logout") From d8c61824a62313c3f0e8f299fb3dcc6559cecc5b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 19:43:21 +0200 Subject: [PATCH 29/34] chore(keycloak): add README.md and fix pyproject metadata --- modules/keycloak/README.md | 30 ++++++++++++++++++++++++++++++ modules/keycloak/pyproject.toml | 1 + 2 files changed, 31 insertions(+) create mode 100644 modules/keycloak/README.md diff --git a/modules/keycloak/README.md b/modules/keycloak/README.md new file mode 100644 index 00000000..44f74d33 --- /dev/null +++ b/modules/keycloak/README.md @@ -0,0 +1,30 @@ +# simple_module_keycloak + +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. + +## Install + +Add to your app's `pyproject.toml` dependencies instead of `simple_module_users`: + +```toml +dependencies = [ + "simple_module_keycloak==0.0.15", +] +``` + +Run `uv sync --all-packages` to install. + +## Usage + +Set the required environment variables (or configure via the settings admin UI after first boot): + +```bash +SM_KEYCLOAK_SERVER_URL=https://keycloak.example.com +SM_KEYCLOAK_REALM=my-realm +SM_KEYCLOAK_CLIENT_ID=my-app +SM_KEYCLOAK_CLIENT_SECRET=my-secret +``` + +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. + +Keycloak realm roles are mapped to framework permissions via the `role_mapping` setting (default: `admin` and `user` map 1:1). diff --git a/modules/keycloak/pyproject.toml b/modules/keycloak/pyproject.toml index 0909c15a..27d9da86 100644 --- a/modules/keycloak/pyproject.toml +++ b/modules/keycloak/pyproject.toml @@ -2,6 +2,7 @@ name = "simple_module_keycloak" version = "0.0.15" description = "Keycloak OIDC authentication provider for simple_module — swap with simple_module_users" +readme = "README.md" license = "MIT" requires-python = ">=3.12" authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] From 098aae1bbcbfe3c523bda3aa293bd36d3fd3fdf7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 22:13:37 +0200 Subject: [PATCH 30/34] fix(ci): update lockfile, fix httpx_mock strict mode, ignore ty ConfigDict false positive - package-lock.json: add @simple-module/keycloak workspace entry - test_jwks.py: mark refetch test to allow unconsumed mock responses (pytest-httpx strict mode in CI) - pyproject.toml: ignore ty invalid-assignment rule (SQLModel ConfigDict vs SQLModelConfig false positive across all modules) --- modules/keycloak/tests/test_jwks.py | 1 + package-lock.json | 8 ++++++++ pyproject.toml | 3 +++ 3 files changed, 12 insertions(+) diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py index 9c4c8e46..09bf60e6 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/test_jwks.py @@ -122,6 +122,7 @@ async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): assert claims is None +@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload, httpx_mock): private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key, kid="rotated-key") diff --git a/package-lock.json b/package-lock.json index 8a580ea7..2d546a1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,6 +188,10 @@ "react-dom": "^19.0.0" } }, + "modules/keycloak": { + "name": "@simple-module/keycloak", + "version": "0.0.0" + }, "modules/permissions": { "name": "@simple-module-py/permissions", "version": "0.1.0", @@ -4545,6 +4549,10 @@ "resolved": "modules/users", "link": true }, + "node_modules/@simple-module/keycloak": { + "resolved": "modules/keycloak", + "link": true + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/pyproject.toml b/pyproject.toml index 7451f7d3..ceb8a21b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,6 +102,9 @@ no-matching-overload = "ignore" # The false positives massively outnumber the real bugs and the latter still # get caught at runtime by tests, so we ignore the rule globally. invalid-argument-type = "ignore" +# SQLModel's ``model_config = ConfigDict(...)`` clashes with the internal +# ``SQLModelConfig`` type in newer ty versions. Same root cause as above. +invalid-assignment = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" From 7a44c6d42f18ef587b62cb81ff19cf8e3066d5f9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 22:19:46 +0200 Subject: [PATCH 31/34] fix(ci): regenerate i18n keys with all modules, fix JWKS test mock - Regenerate i18n keys with all module locales (dashboard, bg_tasks, etc. were missing from the previous gen-pages run) - Rewrite JWKS refetch test to use unittest.mock.patch instead of pytest-httpx (the sequential mock response ordering was unreliable across pytest-httpx versions) --- modules/keycloak/tests/test_jwks.py | 23 ++- packages/i18n/src/generated-resources.ts | 138 +++++++++++++++- packages/i18n/src/keys.generated.ts | 190 +++++++++++++++++++++-- 3 files changed, 329 insertions(+), 22 deletions(-) diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py index 09bf60e6..e071fa7b 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/test_jwks.py @@ -5,6 +5,7 @@ import json import time +import httpx import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa @@ -122,12 +123,22 @@ async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): assert claims is None -@pytest.mark.httpx_mock(assert_all_responses_were_requested=False) -async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload, httpx_mock): +async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload): + """When a token has a kid not in cache, refetch JWKS once before rejecting.""" + from unittest.mock import AsyncMock, patch + private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key, kid="rotated-key") - httpx_mock.add_response(url="https://auth.example.com/jwks", json={"keys": []}) - httpx_mock.add_response(url="https://auth.example.com/jwks", json=jwks_data) + + call_count = 0 + resp_cls = type("R", (), {"raise_for_status": lambda s: None}) + + async def mock_get(self, url, **kw): + nonlocal call_count + call_count += 1 + r = resp_cls() + r.json = lambda: {"keys": []} if call_count == 1 else jwks_data + return r cache = JWKSCache( jwks_url="https://auth.example.com/jwks", @@ -137,6 +148,8 @@ async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload, http ) token = _sign_token(private_key, valid_payload, kid="rotated-key") - claims = await cache.validate_jwt(token) + with patch("httpx.AsyncClient.get", mock_get): + claims = await cache.validate_jwt(token) assert claims is not None assert claims["sub"] == "user-123" + assert call_count == 2 diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 40135719..48d2b993 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -4,6 +4,59 @@ export default { translation: { 'auth.errors.missing_permission': '', 'auth.errors.not_authenticated': '', + 'background_tasks.detail.args': '', + 'background_tasks.detail.back_button': '', + 'background_tasks.detail.kwargs': '', + 'background_tasks.detail.meta': '', + 'background_tasks.detail.no_traceback': '', + 'background_tasks.detail.result': '', + 'background_tasks.detail.retried_from': '', + 'background_tasks.detail.title': '', + 'background_tasks.detail.traceback': '', + 'background_tasks.filters.status_all': '', + 'background_tasks.filters.status_label': '', + 'background_tasks.index.count_one': '', + 'background_tasks.index.count_other': '', + 'background_tasks.index.description': '', + 'background_tasks.index.empty_description': '', + 'background_tasks.index.empty_title': '', + 'background_tasks.index.no_match': '', + 'background_tasks.index.search_placeholder': '', + 'background_tasks.index.title': '', + 'background_tasks.retry_dialog.cancel': '', + 'background_tasks.retry_dialog.confirm': '', + 'background_tasks.retry_dialog.description': '', + 'background_tasks.retry_dialog.title': '', + 'background_tasks.status.failed': '', + 'background_tasks.status.pending': '', + 'background_tasks.status.retrying': '', + 'background_tasks.status.revoked': '', + 'background_tasks.status.running': '', + 'background_tasks.status.stuck': '', + 'background_tasks.status.success': '', + 'background_tasks.table.actions': '', + 'background_tasks.table.duration': '', + 'background_tasks.table.queue': '', + 'background_tasks.table.queued_at': '', + 'background_tasks.table.retry': '', + 'background_tasks.table.status': '', + 'background_tasks.table.task': '', + 'background_tasks.table.view': '', + 'background_tasks.table.worker': '', + 'background_tasks.toasts.retried': '', + 'background_tasks.toasts.retry_failed': '', + 'dashboard.home.description': '', + 'dashboard.home.description_body': '', + 'dashboard.home.stats.active_users': '', + 'dashboard.home.stats.modules': '', + 'dashboard.home.stats.total_users': '', + 'dashboard.home.system_info.health_checks': '', + 'dashboard.home.system_info.modules': '', + 'dashboard.home.system_info.python_version': '', + 'dashboard.home.system_info_title': '', + 'dashboard.home.title': '', + 'dashboard.home.welcome_card_title': '', + 'dashboard.home.welcome_message': '', 'feature_flags.browse.back_to_system': '', 'feature_flags.browse.count_one': '', 'feature_flags.browse.count_other': '', @@ -36,6 +89,34 @@ export default { 'feature_flags.toasts.disabled': '', 'feature_flags.toasts.enabled': '', 'feature_flags.toasts.toggle_failed': '', + 'file_storage.actions.delete': '', + 'file_storage.actions.download': '', + 'file_storage.browse.count_one': '', + 'file_storage.browse.count_other': '', + 'file_storage.browse.description': '', + 'file_storage.browse.empty_description': '', + 'file_storage.browse.empty_title': '', + 'file_storage.browse.title': '', + 'file_storage.browse.upload_button': '', + 'file_storage.browse.uploading': '', + 'file_storage.delete_dialog.cancel': '', + 'file_storage.delete_dialog.confirm': '', + 'file_storage.delete_dialog.description': '', + 'file_storage.delete_dialog.title': '', + 'file_storage.errors.backend_error': '', + 'file_storage.errors.bad_type': '', + 'file_storage.errors.not_found': '', + 'file_storage.errors.too_large': '', + 'file_storage.table.actions': '', + 'file_storage.table.filename': '', + 'file_storage.table.size': '', + 'file_storage.table.type': '', + 'file_storage.table.uploaded_at': '', + 'file_storage.table.uploaded_by': '', + 'file_storage.toasts.delete_failed': '', + 'file_storage.toasts.deleted': '', + 'file_storage.toasts.upload_failed': '', + 'file_storage.toasts.uploaded': '', 'host.error.forbidden_description': '', 'host.error.forbidden_title': '', 'host.error.generic_description': '', @@ -65,13 +146,56 @@ export default { 'host.landing.hero_subtitle': '', 'host.landing.hero_title_line1': '', 'host.landing.hero_title_line2': '', - 'keycloak.errors.callback_failed': '', - 'keycloak.errors.invalid_state': '', - 'keycloak.errors.token_validation_failed': '', - 'keycloak.login.redirecting': '', - 'keycloak.login.title': '', - 'keycloak.logout.message': '', - 'keycloak.logout.title': '', + 'permissions.browse.count_label': '', + 'permissions.browse.count_label_one': '', + 'permissions.browse.count_label_other': '', + 'permissions.browse.description': '', + 'permissions.browse.direct_count_label': '', + 'permissions.browse.direct_count_label_one': '', + 'permissions.browse.direct_count_label_other': '', + 'permissions.browse.edit_link': '', + 'permissions.browse.group_description': '', + 'permissions.browse.no_permissions': '', + 'permissions.browse.no_roles': '', + 'permissions.browse.no_users': '', + 'permissions.browse.registry_heading': '', + 'permissions.browse.registry_hint': '', + 'permissions.browse.roles_heading': '', + 'permissions.browse.search_placeholder': '', + 'permissions.browse.title': '', + 'permissions.browse.users_heading': '', + 'permissions.edit.cancel_link': '', + 'permissions.edit.clear_group': '', + 'permissions.edit.description': '', + 'permissions.edit.empty': '', + 'permissions.edit.reset_button': '', + 'permissions.edit.select_all_group': '', + 'permissions.edit.selected_summary': '', + 'permissions.edit.submit_button': '', + 'permissions.edit.title': '', + 'permissions.errors.role_not_found': '', + 'permissions.errors.user_not_found': '', + 'permissions.table.actions': '', + 'permissions.table.assigned': '', + 'permissions.table.description': '', + 'permissions.table.direct': '', + 'permissions.table.name': '', + 'permissions.table.role': '', + 'permissions.table.user': '', + 'permissions.toasts.save_failed': '', + 'permissions.toasts.saved': '', + 'permissions.user_edit.cancel_link': '', + 'permissions.user_edit.description': '', + 'permissions.user_edit.direct_summary': '', + 'permissions.user_edit.effective_summary': '', + 'permissions.user_edit.empty': '', + 'permissions.user_edit.inherited_badge': '', + 'permissions.user_edit.inherited_hint': '', + 'permissions.user_edit.no_roles': '', + 'permissions.user_edit.reset_button': '', + 'permissions.user_edit.roles_label': '', + 'permissions.user_edit.submit_button': '', + 'permissions.user_edit.title': '', 'settings.browse.delete_confirm': '', 'settings.browse.delete_link': '', 'settings.browse.edit_link': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 4d4a0d9c..d050834c 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -8,6 +8,84 @@ export const keys = { not_authenticated: 'auth.errors.not_authenticated', }, }, + background_tasks: { + detail: { + args: 'background_tasks.detail.args', + back_button: 'background_tasks.detail.back_button', + kwargs: 'background_tasks.detail.kwargs', + meta: 'background_tasks.detail.meta', + no_traceback: 'background_tasks.detail.no_traceback', + result: 'background_tasks.detail.result', + retried_from: 'background_tasks.detail.retried_from', + title: 'background_tasks.detail.title', + traceback: 'background_tasks.detail.traceback', + }, + filters: { + status_all: 'background_tasks.filters.status_all', + status_label: 'background_tasks.filters.status_label', + }, + index: { + count: 'background_tasks.index.count', + count_one: 'background_tasks.index.count_one', + count_other: 'background_tasks.index.count_other', + description: 'background_tasks.index.description', + empty_description: 'background_tasks.index.empty_description', + empty_title: 'background_tasks.index.empty_title', + no_match: 'background_tasks.index.no_match', + search_placeholder: 'background_tasks.index.search_placeholder', + title: 'background_tasks.index.title', + }, + retry_dialog: { + cancel: 'background_tasks.retry_dialog.cancel', + confirm: 'background_tasks.retry_dialog.confirm', + description: 'background_tasks.retry_dialog.description', + title: 'background_tasks.retry_dialog.title', + }, + status: { + failed: 'background_tasks.status.failed', + pending: 'background_tasks.status.pending', + retrying: 'background_tasks.status.retrying', + revoked: 'background_tasks.status.revoked', + running: 'background_tasks.status.running', + stuck: 'background_tasks.status.stuck', + success: 'background_tasks.status.success', + }, + table: { + actions: 'background_tasks.table.actions', + duration: 'background_tasks.table.duration', + queue: 'background_tasks.table.queue', + queued_at: 'background_tasks.table.queued_at', + retry: 'background_tasks.table.retry', + status: 'background_tasks.table.status', + task: 'background_tasks.table.task', + view: 'background_tasks.table.view', + worker: 'background_tasks.table.worker', + }, + toasts: { + retried: 'background_tasks.toasts.retried', + retry_failed: 'background_tasks.toasts.retry_failed', + }, + }, + dashboard: { + home: { + description: 'dashboard.home.description', + description_body: 'dashboard.home.description_body', + stats: { + active_users: 'dashboard.home.stats.active_users', + modules: 'dashboard.home.stats.modules', + total_users: 'dashboard.home.stats.total_users', + }, + system_info: { + health_checks: 'dashboard.home.system_info.health_checks', + modules: 'dashboard.home.system_info.modules', + python_version: 'dashboard.home.system_info.python_version', + }, + system_info_title: 'dashboard.home.system_info_title', + title: 'dashboard.home.title', + welcome_card_title: 'dashboard.home.welcome_card_title', + welcome_message: 'dashboard.home.welcome_message', + }, + }, feature_flags: { browse: { back_to_system: 'feature_flags.browse.back_to_system', @@ -49,6 +127,49 @@ export const keys = { toggle_failed: 'feature_flags.toasts.toggle_failed', }, }, + file_storage: { + actions: { + delete: 'file_storage.actions.delete', + download: 'file_storage.actions.download', + }, + browse: { + count: 'file_storage.browse.count', + count_one: 'file_storage.browse.count_one', + count_other: 'file_storage.browse.count_other', + description: 'file_storage.browse.description', + empty_description: 'file_storage.browse.empty_description', + empty_title: 'file_storage.browse.empty_title', + title: 'file_storage.browse.title', + upload_button: 'file_storage.browse.upload_button', + uploading: 'file_storage.browse.uploading', + }, + delete_dialog: { + cancel: 'file_storage.delete_dialog.cancel', + confirm: 'file_storage.delete_dialog.confirm', + description: 'file_storage.delete_dialog.description', + title: 'file_storage.delete_dialog.title', + }, + errors: { + backend_error: 'file_storage.errors.backend_error', + bad_type: 'file_storage.errors.bad_type', + not_found: 'file_storage.errors.not_found', + too_large: 'file_storage.errors.too_large', + }, + table: { + actions: 'file_storage.table.actions', + filename: 'file_storage.table.filename', + size: 'file_storage.table.size', + type: 'file_storage.table.type', + uploaded_at: 'file_storage.table.uploaded_at', + uploaded_by: 'file_storage.table.uploaded_by', + }, + toasts: { + delete_failed: 'file_storage.toasts.delete_failed', + deleted: 'file_storage.toasts.deleted', + upload_failed: 'file_storage.toasts.upload_failed', + uploaded: 'file_storage.toasts.uploaded', + }, + }, host: { error: { forbidden_description: 'host.error.forbidden_description', @@ -86,19 +207,68 @@ export const keys = { hero_title_line2: 'host.landing.hero_title_line2', }, }, - keycloak: { + permissions: { + browse: { + count_label: 'permissions.browse.count_label', + count_label_one: 'permissions.browse.count_label_one', + count_label_other: 'permissions.browse.count_label_other', + description: 'permissions.browse.description', + direct_count_label: 'permissions.browse.direct_count_label', + direct_count_label_one: 'permissions.browse.direct_count_label_one', + direct_count_label_other: 'permissions.browse.direct_count_label_other', + edit_link: 'permissions.browse.edit_link', + group_description: 'permissions.browse.group_description', + no_permissions: 'permissions.browse.no_permissions', + no_roles: 'permissions.browse.no_roles', + no_users: 'permissions.browse.no_users', + registry_heading: 'permissions.browse.registry_heading', + registry_hint: 'permissions.browse.registry_hint', + roles_heading: 'permissions.browse.roles_heading', + search_placeholder: 'permissions.browse.search_placeholder', + title: 'permissions.browse.title', + users_heading: 'permissions.browse.users_heading', + }, + edit: { + cancel_link: 'permissions.edit.cancel_link', + clear_group: 'permissions.edit.clear_group', + description: 'permissions.edit.description', + empty: 'permissions.edit.empty', + reset_button: 'permissions.edit.reset_button', + select_all_group: 'permissions.edit.select_all_group', + selected_summary: 'permissions.edit.selected_summary', + submit_button: 'permissions.edit.submit_button', + title: 'permissions.edit.title', + }, errors: { - callback_failed: 'keycloak.errors.callback_failed', - invalid_state: 'keycloak.errors.invalid_state', - token_validation_failed: 'keycloak.errors.token_validation_failed', + role_not_found: 'permissions.errors.role_not_found', + user_not_found: 'permissions.errors.user_not_found', + }, + table: { + actions: 'permissions.table.actions', + assigned: 'permissions.table.assigned', + description: 'permissions.table.description', + direct: 'permissions.table.direct', + name: 'permissions.table.name', + role: 'permissions.table.role', + user: 'permissions.table.user', }, - login: { - redirecting: 'keycloak.login.redirecting', - title: 'keycloak.login.title', + toasts: { + save_failed: 'permissions.toasts.save_failed', + saved: 'permissions.toasts.saved', }, - logout: { - message: 'keycloak.logout.message', - title: 'keycloak.logout.title', + user_edit: { + cancel_link: 'permissions.user_edit.cancel_link', + description: 'permissions.user_edit.description', + direct_summary: 'permissions.user_edit.direct_summary', + effective_summary: 'permissions.user_edit.effective_summary', + empty: 'permissions.user_edit.empty', + inherited_badge: 'permissions.user_edit.inherited_badge', + inherited_hint: 'permissions.user_edit.inherited_hint', + no_roles: 'permissions.user_edit.no_roles', + reset_button: 'permissions.user_edit.reset_button', + roles_label: 'permissions.user_edit.roles_label', + submit_button: 'permissions.user_edit.submit_button', + title: 'permissions.user_edit.title', }, }, settings: { From 8639029e2c1282f67543d2794a76eba44cf579a9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 22:24:52 +0200 Subject: [PATCH 32/34] fix(ci): remove unused imports, exclude keycloak from E2E smoke job - test_jwks.py: remove unused httpx and AsyncMock imports - pr.yml: add SM_MODULES_ENABLED to E2E smoke job to exclude Keycloak (SM020 prevents both users and keycloak from running simultaneously; the E2E smoke tests use the users module) --- .github/workflows/pr.yml | 3 +++ modules/keycloak/tests/test_jwks.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1a3704e5..356003d8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -147,6 +147,9 @@ jobs: SM_SECRET_KEY: ci-test-key SM_USERS_BOOTSTRAP_EMAIL: admin@example.com SM_USERS_BOOTSTRAP_PASSWORD: admin + # Exclude Keycloak module — SM020 prevents both users and keycloak + # from running simultaneously. E2E tests use the users module. + SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags"]' E2E_BASE_URL: http://localhost:8000 steps: - uses: actions/checkout@v6 diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py index e071fa7b..6528142b 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/test_jwks.py @@ -5,7 +5,6 @@ import json import time -import httpx import jwt import pytest from cryptography.hazmat.primitives.asymmetric import rsa @@ -125,7 +124,7 @@ async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload): """When a token has a kid not in cache, refetch JWKS once before rejecting.""" - from unittest.mock import AsyncMock, patch + from unittest.mock import patch private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key, kid="rotated-key") From 642c6e12708739bfdefbb9cb7dbf24b41a9c8a7f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 22:29:18 +0200 Subject: [PATCH 33/34] fix(ci): rewrite JWKS refetch test to monkey-patch _fetch_keys directly --- modules/keycloak/tests/test_jwks.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/modules/keycloak/tests/test_jwks.py b/modules/keycloak/tests/test_jwks.py index 6528142b..d9b6a0df 100644 --- a/modules/keycloak/tests/test_jwks.py +++ b/modules/keycloak/tests/test_jwks.py @@ -124,20 +124,23 @@ async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): async def test_jwks_cache_refetches_on_unknown_kid(rsa_keys, valid_payload): """When a token has a kid not in cache, refetch JWKS once before rejecting.""" - from unittest.mock import patch - private_key, public_key = rsa_keys jwks_data = _make_jwks_response(public_key, kid="rotated-key") call_count = 0 - resp_cls = type("R", (), {"raise_for_status": lambda s: None}) - async def mock_get(self, url, **kw): + async def counting_fetch(self, *, force=False): nonlocal call_count call_count += 1 - r = resp_cls() - r.json = lambda: {"keys": []} if call_count == 1 else jwks_data - return r + if call_count == 1: + self._keys = {} + self._fetched_at = __import__("time").monotonic() + else: + from jwt.algorithms import RSAAlgorithm + + for kd in jwks_data["keys"]: + self._keys[kd["kid"]] = RSAAlgorithm.from_jwk(kd) + self._fetched_at = __import__("time").monotonic() cache = JWKSCache( jwks_url="https://auth.example.com/jwks", @@ -145,10 +148,10 @@ async def mock_get(self, url, **kw): issuer="https://auth.example.com/realms/test", audience="my-client", ) + cache._fetch_keys = counting_fetch.__get__(cache, JWKSCache) token = _sign_token(private_key, valid_payload, kid="rotated-key") - with patch("httpx.AsyncClient.get", mock_get): - claims = await cache.validate_jwt(token) + claims = await cache.validate_jwt(token) assert claims is not None assert claims["sub"] == "user-123" assert call_count == 2 From 806996e08e8cbb1502a12ee53bbf5e53b1a53d20 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Wed, 27 May 2026 22:32:04 +0200 Subject: [PATCH 34/34] chore: add QA verification screenshots --- .verify/01-login-page.png | Bin 0 -> 94044 bytes .verify/02-dashboard.png | Bin 0 -> 119284 bytes .verify/03-keycloak-login.png | Bin 0 -> 76715 bytes .verify/04-keycloak-authenticated.png | Bin 0 -> 26924 bytes .verify/05-session-survives-expiry.png | Bin 0 -> 39916 bytes .verify/06-after-logout.png | Bin 0 -> 9929 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 .verify/01-login-page.png create mode 100644 .verify/02-dashboard.png create mode 100644 .verify/03-keycloak-login.png create mode 100644 .verify/04-keycloak-authenticated.png create mode 100644 .verify/05-session-survives-expiry.png create mode 100644 .verify/06-after-logout.png diff --git a/.verify/01-login-page.png b/.verify/01-login-page.png new file mode 100644 index 0000000000000000000000000000000000000000..4d1a1efc8259d21db3094d7199013deb62e190f8 GIT binary patch literal 94044 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

Ri^Gd55!X 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 E0!9@qe;Yjv?qDj&syYo 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 L0Rfug5({y9ba){SnNyKBcuSk_}N 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

y2^7o%d z0rkt4PwvN<6cCQ<1w*XjRuXd&p%?eUpOTK?a`~}flphV7W3ZY}6N2cl@%IMpZrg?c zkJI(F$m~^{)%c4QB+^Yzkf{Nc?p(IGn89HX(}Q`cxr_d=;=h3LzB)F8rtV&hCqMpW ztm(*(Pr?!4QTWWD1n-h6JC9^)=V$I+*j$&$-qka2KH#gc3=J+RuNecbHd5dftK&6k z_a3mIx5>x&wjpEy73p%`Lp!buNDLi9({B3!t8C`>+71H)#?XB}ew+!+-=GTkhwHlL zMRUQ6gAXWX{{8EJZvXk;ffz}C)}RE;Oi51a+3Vl@(*La=It2sQJ3mi|s3He`T)VjN aWxNI|$G&zE-?)oKkds!HDt>P8@jn2;xl&yK literal 0 HcmV?d00001 diff --git a/.verify/03-keycloak-login.png b/.verify/03-keycloak-login.png new file mode 100644 index 0000000000000000000000000000000000000000..01425ee7a29c6a6262e35946d4589ab8e9d01511 GIT binary patch literal 76715 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

dGbIM)MxmP4 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 literal 0 HcmV?d00001 diff --git a/.verify/04-keycloak-authenticated.png b/.verify/04-keycloak-authenticated.png new file mode 100644 index 0000000000000000000000000000000000000000..7386ae5304008465ceff31eaad15addc9e6658c4 GIT binary patch literal 26924 zcmd42XH=6-)HVu_1p$Fa5D*a%r6^UpbO9-$_gAlw! zq!S3Ch7#K0dDr^R-*eXabKXCbwQ}Dxd(WPI&)&1=x}vo;l_|)ck`WORQ2
    k<*& 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 WRDQ&($9%vq1gc7!ie(D_2L2D~m7?ST literal 0 HcmV?d00001 diff --git a/.verify/05-session-survives-expiry.png b/.verify/05-session-survives-expiry.png new file mode 100644 index 0000000000000000000000000000000000000000..025c45b9ede3c43c1c93f016384b5c9c4c5ca7b8 GIT binary patch literal 39916 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 F{V&=`1+f4C literal 0 HcmV?d00001 diff --git a/.verify/06-after-logout.png b/.verify/06-after-logout.png new file mode 100644 index 0000000000000000000000000000000000000000..94ba9af6c44ec2a0959543886c94bf74bea2cf01 GIT binary patch literal 9929 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 Ang9R* literal 0 HcmV?d00001