From d5b3f8a36247afc8c8e7613cbbf941277cbf1885 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 07:26:39 +0000 Subject: [PATCH 1/3] Add generic OIDC auth provider with native Microsoft Entra support Introduce a new optional `oidc` auth-provider module that authenticates against any OpenID Connect provider via its discovery document, with a first-class `entra` preset for Microsoft Entra ID (Azure AD). Configuration is preset-driven: pick SM_OIDC_PROVIDER plus the few required secrets and the preset fills claim defaults and derives the discovery URL. The well-known document supplies the authorize/token/JWKS/end-session endpoints and issuer, so no per-provider URLs are hardcoded. Entra specifics baked into the preset: validate the id_token in the browser callback (Entra's Graph access tokens aren't app-validatable), key the user cache on the stable `oid` claim, and default the roles claim to `roles`. The JWKS validator also accepts RSA keys that omit `alg`, as Entra's keys do. Supports both browser-interactive login and bearer-token validation. Shipped as an optional swap-in (not active in host/ by default) like the keycloak module; keycloak is left untouched. Generalize test_app_state_has_sm_services to keep a single auth provider now that three are installed in the workspace. https://claude.ai/code/session_01SbQuMY1b1tEoKkYTs1eb1C --- framework/hosting/tests/test_app.py | 12 +- modules/oidc/README.md | 96 +++++++++++ modules/oidc/oidc/__init__.py | 1 + modules/oidc/oidc/client.py | 81 +++++++++ modules/oidc/oidc/contracts/__init__.py | 1 + modules/oidc/oidc/discovery.py | 49 ++++++ modules/oidc/oidc/endpoints/__init__.py | 1 + modules/oidc/oidc/endpoints/api.py | 82 ++++++++++ modules/oidc/oidc/endpoints/views.py | 36 ++++ modules/oidc/oidc/jwks.py | 125 ++++++++++++++ modules/oidc/oidc/locales/en.json | 15 ++ modules/oidc/oidc/models.py | 21 +++ modules/oidc/oidc/module.py | 104 ++++++++++++ modules/oidc/oidc/pages/LoggedOut.tsx | 13 ++ modules/oidc/oidc/pages/Login.tsx | 14 ++ modules/oidc/oidc/presets.py | 55 +++++++ modules/oidc/oidc/provider.py | 154 ++++++++++++++++++ modules/oidc/oidc/py.typed | 0 modules/oidc/oidc/settings.py | 96 +++++++++++ modules/oidc/oidc/state.py | 22 +++ modules/oidc/package.json | 7 + modules/oidc/pyproject.toml | 54 ++++++ modules/oidc/tests/conftest.py | 1 + modules/oidc/tests/test_oidc_client.py | 71 ++++++++ modules/oidc/tests/test_oidc_discovery.py | 42 +++++ modules/oidc/tests/test_oidc_jwks.py | 143 ++++++++++++++++ modules/oidc/tests/test_oidc_module.py | 22 +++ modules/oidc/tests/test_oidc_provider.py | 106 ++++++++++++ .../oidc/tests/test_oidc_settings_presets.py | 78 +++++++++ modules/oidc/tsconfig.json | 4 + package-lock.json | 8 + pyproject.toml | 3 +- 32 files changed, 1512 insertions(+), 5 deletions(-) create mode 100644 modules/oidc/README.md create mode 100644 modules/oidc/oidc/__init__.py create mode 100644 modules/oidc/oidc/client.py create mode 100644 modules/oidc/oidc/contracts/__init__.py create mode 100644 modules/oidc/oidc/discovery.py create mode 100644 modules/oidc/oidc/endpoints/__init__.py create mode 100644 modules/oidc/oidc/endpoints/api.py create mode 100644 modules/oidc/oidc/endpoints/views.py create mode 100644 modules/oidc/oidc/jwks.py create mode 100644 modules/oidc/oidc/locales/en.json create mode 100644 modules/oidc/oidc/models.py create mode 100644 modules/oidc/oidc/module.py create mode 100644 modules/oidc/oidc/pages/LoggedOut.tsx create mode 100644 modules/oidc/oidc/pages/Login.tsx create mode 100644 modules/oidc/oidc/presets.py create mode 100644 modules/oidc/oidc/provider.py create mode 100644 modules/oidc/oidc/py.typed create mode 100644 modules/oidc/oidc/settings.py create mode 100644 modules/oidc/oidc/state.py create mode 100644 modules/oidc/package.json create mode 100644 modules/oidc/pyproject.toml create mode 100644 modules/oidc/tests/conftest.py create mode 100644 modules/oidc/tests/test_oidc_client.py create mode 100644 modules/oidc/tests/test_oidc_discovery.py create mode 100644 modules/oidc/tests/test_oidc_jwks.py create mode 100644 modules/oidc/tests/test_oidc_module.py create mode 100644 modules/oidc/tests/test_oidc_provider.py create mode 100644 modules/oidc/tests/test_oidc_settings_presets.py create mode 100644 modules/oidc/tsconfig.json diff --git a/framework/hosting/tests/test_app.py b/framework/hosting/tests/test_app.py index e434f510..95e887fd 100644 --- a/framework/hosting/tests/test_app.py +++ b/framework/hosting/tests/test_app.py @@ -77,12 +77,16 @@ async def test_app_state_has_sm_services( (tmp_path / "host" / "templates").mkdir(parents=True) (tmp_path / "host" / "templates" / "index.html").write_text("") - # 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. + # Keep a single auth provider — ``users``, ``keycloak``, and ``oidc`` are + # all installed as entry points in the dev workspace, and SM020 fires if + # more than one is active. The app is only meant to run with one provider. from simple_module_core.discovery import discover_modules - all_names = [m.meta.name for m in discover_modules() if m.meta.name != "Keycloak"] + all_names = [ + m.meta.name + for m in discover_modules() + if not (getattr(m, "_is_auth_provider", False) and m.meta.name != "Users") + ] app = create_app(Settings(modules_enabled=all_names)) sm = app.state.sm diff --git a/modules/oidc/README.md b/modules/oidc/README.md new file mode 100644 index 00000000..3a7a757b --- /dev/null +++ b/modules/oidc/README.md @@ -0,0 +1,96 @@ +# simple_module_oidc + +Generic **OpenID Connect** authentication provider for simple_module — with +**native Microsoft Entra ID (Azure AD)** support, plus Auth0, Okta, Zitadel, +Authentik, Keycloak, and any OIDC-compliant identity provider. + +The module is configured from a provider's OIDC **discovery document** +(`.well-known/openid-configuration`), so endpoints (authorize / token / JWKS / +logout) and the issuer are resolved automatically — no per-provider URL +templating. Provider differences (claim names, scopes) are config; **presets** +fill those in for you. + +It auto-registers as the application's auth provider. Browser users are +redirected to the IdP's hosted login; mobile/API clients send a bearer token +which is validated against the provider's JWKS. + +## Install + +Add to your app's `pyproject.toml` dependencies instead of another auth provider +(e.g. `simple_module_users` / `simple_module_keycloak` — only one auth provider +can be active): + +```toml +dependencies = [ + "simple_module_oidc==0.0.17", +] +``` + +Run `uv sync --all-packages` to install. + +## Usage + +Pick a provider preset and supply its credentials via environment variables (or +configure them through the settings admin UI after first boot). The module then +auto-registers as the application's auth provider — see the preset sections below. + +## Native Microsoft Entra ID + +Set the `entra` preset and your single-tenant registration details: + +```bash +SM_OIDC_PROVIDER=entra +SM_OIDC_TENANT_ID= # or a verified domain +SM_OIDC_CLIENT_ID= +SM_OIDC_CLIENT_SECRET= +``` + +In the Entra app registration, add the redirect URI: + +``` +https:///api/oidc/auth/callback +``` + +The `entra` preset derives the discovery URL +(`https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration`), +validates the **id_token**, keys the user cache on the stable **`oid`** claim, and +maps Entra **app roles** (the `roles` claim) to framework permissions via +`role_mapping` (default: `admin` and `user` map 1:1). Assign app roles in +*Entra → App registrations → App roles* and *Enterprise applications → Users and groups*. + +## Any other OIDC provider + +Use the `generic` preset with an explicit discovery URL: + +```bash +SM_OIDC_PROVIDER=generic +SM_OIDC_DISCOVERY_URL=https:///.well-known/openid-configuration +SM_OIDC_CLIENT_ID= +SM_OIDC_CLIENT_SECRET= +``` + +## Configuration reference + +| Env var | Default | Notes | +|---|---|---| +| `SM_OIDC_PROVIDER` | `generic` | Preset name (`entra`, `generic`). | +| `SM_OIDC_DISCOVERY_URL` | — | Explicit discovery URL; wins over the preset. | +| `SM_OIDC_TENANT_ID` | — | Used by templated presets (Entra) to derive the discovery URL. | +| `SM_OIDC_CLIENT_ID` | — | OAuth client id. | +| `SM_OIDC_CLIENT_SECRET` | — | OAuth client secret. | +| `SM_OIDC_AUDIENCE` | `client_id` | JWT audience to validate against. | +| `SM_OIDC_SCOPE` | preset | OAuth scope string. | +| `SM_OIDC_UID_CLAIM` | preset (`oid` for Entra, else `sub`) | Stable subject claim. | +| `SM_OIDC_USERNAME_CLAIM` | `preferred_username` | Display-name source. | +| `SM_OIDC_EMAIL_CLAIM` | `email` | Email claim. | +| `SM_OIDC_NAME_CLAIM` | `name` | Fallback display name. | +| `SM_OIDC_ROLES_CLAIM_PATH` | preset (`roles` for Entra) | Dotted path to a roles list (e.g. `realm_access.roles`). | + +`login_redirect_url`, `jwks_cache_ttl_seconds`, and `role_mapping` are configurable +via the settings admin UI after first boot. + +## Activation + +Add `simple_module_oidc` to the host's `pyproject.toml` dependencies and +`[tool.uv.sources]`, remove the competing provider, then `uv sync --all-packages`. +Alternatively scope active modules with `SM_MODULES_ENABLED`. diff --git a/modules/oidc/oidc/__init__.py b/modules/oidc/oidc/__init__.py new file mode 100644 index 00000000..507267ca --- /dev/null +++ b/modules/oidc/oidc/__init__.py @@ -0,0 +1 @@ +"""Generic OIDC authentication provider for simple_module (Entra, Auth0, Okta, ...).""" diff --git a/modules/oidc/oidc/client.py b/modules/oidc/oidc/client.py new file mode 100644 index 00000000..03844759 --- /dev/null +++ b/modules/oidc/oidc/client.py @@ -0,0 +1,81 @@ +"""OIDC client -- authorization URL, code exchange, and logout. + +Endpoint-driven: the authorize/token/end-session URLs come from the discovery +document, so this client is provider-agnostic. +""" + +from __future__ import annotations + +import secrets +from typing import Any +from urllib.parse import urlencode + +import httpx + + +class OIDCClient: + """Thin wrapper around a provider's discovered OIDC endpoints.""" + + def __init__( + self, + *, + authorization_endpoint: str, + token_endpoint: str, + end_session_endpoint: str, + client_id: str, + client_secret: str, + ) -> None: + self._authorization_endpoint = authorization_endpoint + self._token_endpoint = token_endpoint + self._end_session_endpoint = end_session_endpoint + self._client_id = client_id + self._client_secret = client_secret + + @property + def token_endpoint(self) -> str: + return self._token_endpoint + + 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._authorization_endpoint}?{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: + """End-session URL. Falls back to the local redirect if the provider + does not advertise an ``end_session_endpoint``.""" + if not self._end_session_endpoint: + return post_logout_redirect_uri + 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._end_session_endpoint}?{urlencode(params)}" diff --git a/modules/oidc/oidc/contracts/__init__.py b/modules/oidc/oidc/contracts/__init__.py new file mode 100644 index 00000000..d14f5f01 --- /dev/null +++ b/modules/oidc/oidc/contracts/__init__.py @@ -0,0 +1 @@ +"""OIDC module contracts.""" diff --git a/modules/oidc/oidc/discovery.py b/modules/oidc/oidc/discovery.py new file mode 100644 index 00000000..1e99fd53 --- /dev/null +++ b/modules/oidc/oidc/discovery.py @@ -0,0 +1,49 @@ +"""OIDC discovery -- fetch and parse ``.well-known/openid-configuration``. + +Resolved once at startup; the returned endpoints drive the OIDC client and the +JWKS validator, so per-provider URL templates are never hardcoded. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + + +@dataclass(frozen=True) +class OidcMetadata: + """The subset of OIDC provider metadata this module relies on.""" + + issuer: str + authorization_endpoint: str + token_endpoint: str + jwks_uri: str + end_session_endpoint: str = "" + + @classmethod + def from_document(cls, doc: dict) -> OidcMetadata: + """Build metadata from a parsed discovery document. + + Raises ``ValueError`` if a required endpoint is absent. + """ + required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri") + missing = [k for k in required if not doc.get(k)] + if missing: + msg = f"OIDC discovery document missing required fields: {', '.join(missing)}" + raise ValueError(msg) + return cls( + issuer=doc["issuer"], + authorization_endpoint=doc["authorization_endpoint"], + token_endpoint=doc["token_endpoint"], + jwks_uri=doc["jwks_uri"], + end_session_endpoint=doc.get("end_session_endpoint", ""), + ) + + +async def fetch_metadata(discovery_url: str) -> OidcMetadata: + """Fetch and parse the provider's OIDC discovery document.""" + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url, timeout=10) + resp.raise_for_status() + return OidcMetadata.from_document(resp.json()) diff --git a/modules/oidc/oidc/endpoints/__init__.py b/modules/oidc/oidc/endpoints/__init__.py new file mode 100644 index 00000000..07588a58 --- /dev/null +++ b/modules/oidc/oidc/endpoints/__init__.py @@ -0,0 +1 @@ +"""OIDC endpoint routers.""" diff --git a/modules/oidc/oidc/endpoints/api.py b/modules/oidc/oidc/endpoints/api.py new file mode 100644 index 00000000..4d95a012 --- /dev/null +++ b/modules/oidc/oidc/endpoints/api.py @@ -0,0 +1,82 @@ +"""OIDC API endpoints — login redirect, callback.""" + +from __future__ import annotations + +import logging +import secrets + +from fastapi import APIRouter, HTTPException, Request +from starlette.responses import RedirectResponse + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["oidc-auth"]) + +_SESSION_OIDC_STATE = "oidc_state" +_SESSION_OIDC_NONCE = "oidc_nonce" +_SESSION_USER_CTX = "user_ctx" +_SESSION_ID_TOKEN = "oidc_id_token" +_SESSION_NEXT = "next" + + +def _require_client(request: Request): + client = request.app.state.oidc.client + if client is None: + raise HTTPException(status_code=503, detail="OIDC provider not configured") + return client + + +@router.get("/login") +async def oidc_login(request: Request): + client = _require_client(request) + s = request.app.state.oidc.settings + 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, + scope=s.scope, + ) + 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 = _require_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") from None + + id_token = tokens.get("id_token", "") + + # Validate the id_token: aud == client_id across all OIDC providers, and the + # reliable identity source for Entra (its access tokens aren't app-validatable). + jwks_cache = request.app.state.oidc.jwks_cache + claims = await jwks_cache.validate_jwt(id_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 = request.app.state.oidc.settings + next_url = request.session.pop(_SESSION_NEXT, None) or s.login_redirect_url + return RedirectResponse(next_url, status_code=303) diff --git a/modules/oidc/oidc/endpoints/views.py b/modules/oidc/oidc/endpoints/views.py new file mode 100644 index 00000000..6cf59575 --- /dev/null +++ b/modules/oidc/oidc/endpoints/views.py @@ -0,0 +1,36 @@ +"""OIDC 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=["oidc-views"]) + +_SESSION_ID_TOKEN = "oidc_id_token" +_PAGE_LOGIN = "Oidc/Login" + + +@router.get("/login") +async def login_page(request: Request, inertia: InertiaDep): + return await inertia.render(_PAGE_LOGIN) + + +@router.post("/logout") +async def logout(request: Request): + client = request.app.state.oidc.client + id_token = request.session.get(_SESSION_ID_TOKEN) + + request.session.clear() + + base_url = str(request.base_url).rstrip("/") + post_logout = f"{base_url}/oidc/login" + if client is None: + return RedirectResponse(post_logout, status_code=303) + + logout_url = client.build_logout_url( + post_logout_redirect_uri=post_logout, + id_token_hint=id_token, + ) + return RedirectResponse(logout_url, status_code=303) diff --git a/modules/oidc/oidc/jwks.py b/modules/oidc/oidc/jwks.py new file mode 100644 index 00000000..f75e779f --- /dev/null +++ b/modules/oidc/oidc/jwks.py @@ -0,0 +1,125 @@ +"""JWKS key cache and JWT validation for OIDC 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 a provider's public signing keys and validates JWTs. + + On validation failure with cached keys, refetches JWKS once before + rejecting -- this handles signing-key rotation gracefully. + """ + + def __init__( + self, + jwks_url: str, + ttl_seconds: int = 3600, + *, + 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 + 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, + audience=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", []): + if not self._is_rsa_signing_key(key_data): + continue + kid = key_data["kid"] + try: + new_keys[kid] = RSAAlgorithm.from_jwk(key_data) + except Exception: + logger.warning("Failed to parse JWK kid=%s", kid) + self._keys = new_keys + self._fetched_at = time.monotonic() + + @staticmethod + def _is_rsa_signing_key(key_data: dict[str, Any]) -> bool: + """Accept RSA signing keys. ``alg``/``use`` are optional in JWKS — Entra + omits ``alg`` — so only reject when a present value is incompatible.""" + if not key_data.get("kid") or key_data.get("kty") != "RSA": + return False + alg = key_data.get("alg") + if alg and alg != "RS256": + return False + use = key_data.get("use") + return not (use and use != "sig") diff --git a/modules/oidc/oidc/locales/en.json b/modules/oidc/oidc/locales/en.json new file mode 100644 index 00000000..a5d10d40 --- /dev/null +++ b/modules/oidc/oidc/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/oidc/oidc/models.py b/modules/oidc/oidc/models.py new file mode 100644 index 00000000..b064f68f --- /dev/null +++ b/modules/oidc/oidc/models.py @@ -0,0 +1,21 @@ +"""OIDC user cache -- maps a provider subject 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("oidc") + + +class OidcUserCache(Base, table=True): + __tablename__ = "oidc_user_cache" + + id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) + subject: str = Field(unique=True, index=True) + email: str = "" + full_name: str | None = None + last_login_at: datetime | None = None diff --git a/modules/oidc/oidc/module.py b/modules/oidc/oidc/module.py new file mode 100644 index 00000000..5e352ef9 --- /dev/null +++ b/modules/oidc/oidc/module.py @@ -0,0 +1,104 @@ +"""Generic OIDC authentication module (Entra, Auth0, Okta, ...).""" + +from __future__ import annotations + +import importlib.resources +import logging +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 + +logger = logging.getLogger(__name__) + +_MODULE_DEPENDENCY_AUTH = "Auth" +_MODULE_DEPENDENCY_SETTINGS = "Settings" + + +class OidcModule(ModuleBase): + meta = ModuleMeta( + name="Oidc", + route_prefix="/api/oidc", + view_prefix="/oidc", + depends_on=[_MODULE_DEPENDENCY_AUTH, _MODULE_DEPENDENCY_SETTINGS], + ) + _is_auth_provider = True + + def register_settings(self, app: FastAPI) -> None: + import importlib + + from oidc.provider import OidcAuthProvider + from oidc.settings import OidcSettings + from oidc.state import OidcState + + register_module_settings = importlib.import_module( + "settings.registration" + ).register_module_settings + + register_module_settings( + app, + "oidc", + OidcSettings, + lambda s: OidcState(settings=s), + ) + + app.state.auth.auth_provider = OidcAuthProvider(app.state.oidc.settings) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Logout", + url="/oidc/logout", + icon="log-out", + order=999, + section=MenuSection.USER_DROPDOWN, + method="post", + ) + ) + + def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: + from oidc.endpoints.api import router as api + from oidc.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 oidc.client import OIDCClient + from oidc.discovery import fetch_metadata + from oidc.jwks import JWKSCache + + state = app.state.oidc + s = state.settings + if not s.discovery_url: + logger.warning("OIDC discovery_url not configured; provider inactive") + return + try: + metadata = await fetch_metadata(s.discovery_url) + except Exception: + logger.exception("Failed to fetch OIDC discovery from %s", s.discovery_url) + return + + state.metadata = metadata + state.jwks_cache = JWKSCache( + jwks_url=metadata.jwks_uri, + ttl_seconds=s.jwks_cache_ttl_seconds, + issuer=metadata.issuer, + audience=s.jwt_audience, + ) + state.client = OIDCClient( + authorization_endpoint=metadata.authorization_endpoint, + token_endpoint=metadata.token_endpoint, + end_session_endpoint=metadata.end_session_endpoint, + client_id=s.client_id, + client_secret=s.client_secret, + ) + provider = app.state.auth.auth_provider + provider.jwks_cache = state.jwks_cache + + def locale_dirs(self) -> dict[str, Path]: + return {"oidc": Path(str(importlib.resources.files(__package__) / "locales"))} diff --git a/modules/oidc/oidc/pages/LoggedOut.tsx b/modules/oidc/oidc/pages/LoggedOut.tsx new file mode 100644 index 00000000..05aff37f --- /dev/null +++ b/modules/oidc/oidc/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/oidc/oidc/pages/Login.tsx b/modules/oidc/oidc/pages/Login.tsx new file mode 100644 index 00000000..44d719a6 --- /dev/null +++ b/modules/oidc/oidc/pages/Login.tsx @@ -0,0 +1,14 @@ +import { router } from '@inertiajs/react'; +import { useEffect } from 'react'; + +export default function Login() { + useEffect(() => { + router.get('/api/oidc/auth/login'); + }, []); + + return ( +
+

Redirecting to identity provider...

+
+ ); +} diff --git a/modules/oidc/oidc/presets.py b/modules/oidc/oidc/presets.py new file mode 100644 index 00000000..eee1efca --- /dev/null +++ b/modules/oidc/oidc/presets.py @@ -0,0 +1,55 @@ +"""Provider presets — per-IdP defaults layered onto OidcSettings. + +A preset fills in claim names and (optionally) derives the discovery URL so a +well-known provider needs only a handful of env vars. ``generic`` is the +fallback for any OIDC-compliant IdP given an explicit discovery URL. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Preset: + """Defaults applied to unset OidcSettings fields for a named provider.""" + + name: str + # Template filled with ``tenant_id`` to derive the discovery URL when one is + # not supplied explicitly. Empty ⇒ discovery URL must be configured directly. + discovery_url_template: str = "" + uid_claim: str = "sub" + username_claim: str = "preferred_username" + email_claim: str = "email" + name_claim: str = "name" + roles_claim_path: str = "" + scope: str = "openid email profile" + + def discovery_url(self, tenant_id: str) -> str: + """Render the discovery URL from ``tenant_id`` (empty if not templated).""" + if not self.discovery_url_template: + return "" + return self.discovery_url_template.format(tenant_id=tenant_id) + + +PRESETS: dict[str, Preset] = { + # Microsoft Entra ID (Azure AD), v2.0 endpoints. Single-tenant: the tenant + # GUID (or a verified domain) goes in ``tenant_id``. App roles arrive in the + # ``roles`` claim; ``oid`` is the stable per-tenant user object id. + "entra": Preset( + name="entra", + discovery_url_template=( + "https://login.microsoftonline.com/{tenant_id}/v2.0/.well-known/openid-configuration" + ), + uid_claim="oid", + roles_claim_path="roles", + ), + # Any OIDC-compliant provider (Auth0, Okta, Zitadel, Authentik, Keycloak, ...) + # configured with an explicit ``discovery_url``. + "generic": Preset(name="generic"), +} + + +def get_preset(name: str) -> Preset: + """Return the named preset, falling back to ``generic`` for unknown names.""" + return PRESETS.get(name, PRESETS["generic"]) diff --git a/modules/oidc/oidc/provider.py b/modules/oidc/oidc/provider.py new file mode 100644 index 00000000..f0c972fa --- /dev/null +++ b/modules/oidc/oidc/provider.py @@ -0,0 +1,154 @@ +"""OidcAuthProvider — resolves users from OIDC tokens or session.""" + +from __future__ import annotations + +import logging +from datetime import UTC +from typing import TYPE_CHECKING, Any + +from auth.contracts.schemas import UserContext +from starlette.requests import Request + +if TYPE_CHECKING: + from oidc.jwks import JWKSCache + from oidc.settings import OidcSettings + +logger = logging.getLogger(__name__) + +_SESSION_USER_CTX_KEY = "user_ctx" + + +class OidcAuthProvider: + """Generic OIDC auth provider (Entra, Auth0, Okta, ...).""" + + name = "oidc" + _is_auth_provider = True + + def __init__(self, settings: OidcSettings | None = None) -> 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 "/oidc/login" + + def get_logout_url(self, request: Request | None) -> str: + return "/oidc/logout" + + def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: + return ( + ("/oidc/login", "/oidc/logout", "/api/oidc/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 _subject(self, claims: dict[str, Any]) -> str: + """Stable subject id: the configured uid claim, falling back to ``sub``.""" + uid_claim = self._settings.uid_claim if self._settings else "sub" + return str(claims.get(uid_claim) or claims.get("sub") or "") + + def _claims_to_user_context( + self, + claims: dict[str, Any], + *, + cache_id: str, + ) -> UserContext: + s = self._settings + roles_raw = ( + _extract_nested(claims, s.roles_claim_path) if s and s.roles_claim_path else None + ) + mapping = s.role_mapping if s else {} + mapped = [mapping[r] for r in (roles_raw or []) if r in mapping] + username = claims.get(s.username_claim) if s else None + return UserContext( + id=cache_id, + email=claims.get(s.email_claim, "") if s else claims.get("email", ""), + name=(username or (claims.get(s.name_claim, "") if s else claims.get("name", ""))), + roles=mapped, + tenant_id=claims.get("tid") or claims.get("tenant_id"), + ) + + async def _upsert_user_cache(self, request: Request, claims: dict) -> str: + subject = self._subject(claims) + try: + from sqlalchemy import select + + from oidc.models import OidcUserCache + + session_factory = request.app.state.sm.db.session_factory + async with session_factory() as db: + stmt = select(OidcUserCache).where(OidcUserCache.subject == subject) + row = (await db.execute(stmt)).scalar_one_or_none() + if row is None: + row = self._new_cache_row(subject, claims) + db.add(row) + else: + self._touch_cache_row(row, claims) + await db.flush() + return str(row.id) + except Exception: + logger.exception("Failed to upsert OidcUserCache for subject=%s", subject) + return subject or "unknown" + + def _full_name(self, claims: dict) -> str | None: + s = self._settings + if s: + return claims.get(s.username_claim) or claims.get(s.name_claim) + return claims.get("name") + + def _new_cache_row(self, subject: str, claims: dict): + import uuid as uuid_mod + from datetime import datetime + + from oidc.models import OidcUserCache + + email_claim = self._settings.email_claim if self._settings else "email" + return OidcUserCache( + id=uuid_mod.uuid4(), + subject=subject, + email=claims.get(email_claim, ""), + full_name=self._full_name(claims), + last_login_at=datetime.now(UTC), + ) + + def _touch_cache_row(self, row, claims: dict) -> None: + from datetime import datetime + + email_claim = self._settings.email_claim if self._settings else "email" + row.email = claims.get(email_claim, row.email) + row.full_name = self._full_name(claims) or row.full_name + row.last_login_at = datetime.now(UTC) + + +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/oidc/oidc/py.typed b/modules/oidc/oidc/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/modules/oidc/oidc/settings.py b/modules/oidc/oidc/settings.py new file mode 100644 index 00000000..e7c18877 --- /dev/null +++ b/modules/oidc/oidc/settings.py @@ -0,0 +1,96 @@ +"""OIDC module settings -- DB-backed via ``register_module_settings``. + +Configuration is preset-driven: pick a ``provider`` preset (e.g. ``entra``) and +supply the few required secrets; the preset fills claim defaults and derives the +discovery URL. ``generic`` works with any OIDC provider given a ``discovery_url``. +""" + +from __future__ import annotations + +import os + +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 + +from oidc.presets import get_preset + +# Sentinel for "unset by the user" so the preset can supply a default while still +# letting an explicit empty/other value win. +_UNSET = "__unset__" + + +class OidcSettings(BaseSettings): + """Generic OIDC configuration with per-provider presets.""" + + model_config = SettingsConfigDict(extra="ignore") + + provider: str = env_str("SM_OIDC_PROVIDER", "generic") + + # Endpoint discovery. ``discovery_url`` wins; otherwise the preset derives it + # from ``tenant_id`` (Entra). The well-known doc supplies all endpoints + issuer. + discovery_url: str = env_str("SM_OIDC_DISCOVERY_URL", "") + tenant_id: str = env_str("SM_OIDC_TENANT_ID", "") + + client_id: str = env_str("SM_OIDC_CLIENT_ID", "") + client_secret: str = env_str("SM_OIDC_CLIENT_SECRET", "") + + # Audience for JWT validation. Empty ⇒ falls back to ``client_id``. + audience: str = env_str("SM_OIDC_AUDIENCE", "") + + # Claim mapping. ``_UNSET`` defaults are replaced by the preset's value. + scope: str = env_str("SM_OIDC_SCOPE", _UNSET) + uid_claim: str = env_str("SM_OIDC_UID_CLAIM", _UNSET) + username_claim: str = env_str("SM_OIDC_USERNAME_CLAIM", _UNSET) + email_claim: str = env_str("SM_OIDC_EMAIL_CLAIM", _UNSET) + name_claim: str = env_str("SM_OIDC_NAME_CLAIM", _UNSET) + roles_claim_path: str = env_str("SM_OIDC_ROLES_CLAIM_PATH", _UNSET) + + login_redirect_url: str = "/dashboard/" + jwks_cache_ttl_seconds: int = 3600 + + role_mapping: dict[str, str] = Field( + default_factory=lambda: {"admin": "admin", "user": "user"}, + ) + + @property + def jwt_audience(self) -> str: + """Audience to validate tokens against (explicit override or client_id).""" + return self.audience or self.client_id + + @model_validator(mode="after") + def _apply_preset(self) -> OidcSettings: + preset = get_preset(self.provider) + if self.uid_claim == _UNSET: + self.uid_claim = preset.uid_claim + if self.username_claim == _UNSET: + self.username_claim = preset.username_claim + if self.email_claim == _UNSET: + self.email_claim = preset.email_claim + if self.name_claim == _UNSET: + self.name_claim = preset.name_claim + if self.roles_claim_path == _UNSET: + self.roles_claim_path = preset.roles_claim_path + if self.scope == _UNSET: + self.scope = preset.scope + if not self.discovery_url and self.tenant_id: + self.discovery_url = preset.discovery_url(self.tenant_id) + return self + + @model_validator(mode="after") + def _check_required_in_production(self) -> OidcSettings: + env = os.environ.get("SM_ENVIRONMENT", "development") + if env in NON_PROD_ENVIRONMENTS: + return self + missing = [] + if not self.discovery_url: + missing.append("SM_OIDC_DISCOVERY_URL (or SM_OIDC_TENANT_ID for a templated preset)") + if not self.client_id: + missing.append("SM_OIDC_CLIENT_ID") + if not self.client_secret: + missing.append("SM_OIDC_CLIENT_SECRET") + if missing: + msg = f"OIDC settings required in production: {', '.join(missing)}" + raise ValueError(msg) + return self diff --git a/modules/oidc/oidc/state.py b/modules/oidc/oidc/state.py new file mode 100644 index 00000000..a5141693 --- /dev/null +++ b/modules/oidc/oidc/state.py @@ -0,0 +1,22 @@ +"""Module-scoped state container for the oidc module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from oidc.client import OIDCClient + from oidc.discovery import OidcMetadata + from oidc.jwks import JWKSCache + from oidc.settings import OidcSettings + + +@dataclass +class OidcState: + """OIDC-module singletons. Single slot at ``app.state.oidc``.""" + + settings: OidcSettings + metadata: OidcMetadata | None = None + jwks_cache: JWKSCache | None = None + client: OIDCClient | None = None diff --git a/modules/oidc/package.json b/modules/oidc/package.json new file mode 100644 index 00000000..21de631d --- /dev/null +++ b/modules/oidc/package.json @@ -0,0 +1,7 @@ +{ + "name": "@simple-module/oidc", + "private": true, + "version": "0.0.0", + "type": "module", + "dependencies": {} +} diff --git a/modules/oidc/pyproject.toml b/modules/oidc/pyproject.toml new file mode 100644 index 00000000..7ecae9ae --- /dev/null +++ b/modules/oidc/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "simple_module_oidc" +version = "0.0.17" +description = "Generic OIDC authentication provider for simple_module — native Microsoft Entra ID, Auth0, Okta, Keycloak, and any OpenID Connect identity provider" +readme = "README.md" +license = "MIT" +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "oidc", "entra", "azure-ad", "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.17", + "simple_module_db==0.0.17", + "simple_module_hosting==0.0.17", + "simple_module_settings==0.0.17", + "simple_module_auth==0.0.17", + "PyJWT[crypto]>=2.8", + "httpx>=0.27", +] + +[project.entry-points.simple_module] +oidc = "oidc.module:OidcModule" + +[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 = ["oidc"] + +[tool.hatch.build.targets.wheel.force-include] +"package.json" = "oidc/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/oidc/tests/conftest.py b/modules/oidc/tests/conftest.py new file mode 100644 index 00000000..7668af4f --- /dev/null +++ b/modules/oidc/tests/conftest.py @@ -0,0 +1 @@ +"""OIDC module test fixtures.""" diff --git a/modules/oidc/tests/test_oidc_client.py b/modules/oidc/tests/test_oidc_client.py new file mode 100644 index 00000000..9ba7962b --- /dev/null +++ b/modules/oidc/tests/test_oidc_client.py @@ -0,0 +1,71 @@ +"""Tests for the endpoint-driven OIDC client.""" + +from __future__ import annotations + +import pytest +from oidc.client import OIDCClient + + +@pytest.fixture +def client(): + return OIDCClient( + authorization_endpoint="https://idp.example.com/authorize", + token_endpoint="https://idp.example.com/token", + end_session_endpoint="https://idp.example.com/logout", + client_id="my-app", + client_secret="secret123", + ) + + +def test_authorization_url(client): + url, state = client.build_authorization_url( + redirect_uri="https://app.example.com/callback", + nonce="test-nonce", + scope="openid email profile", + ) + assert url.startswith("https://idp.example.com/authorize?") + 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 and len(state) > 0 + + +def test_logout_url(client): + url = client.build_logout_url( + post_logout_redirect_uri="https://app.example.com/oidc/login", + id_token_hint="token123", + ) + assert url.startswith("https://idp.example.com/logout?") + assert "post_logout_redirect_uri=" in url + assert "id_token_hint=token123" in url + + +def test_logout_url_without_end_session_endpoint(): + client = OIDCClient( + authorization_endpoint="https://idp.example.com/authorize", + token_endpoint="https://idp.example.com/token", + end_session_endpoint="", + client_id="my-app", + client_secret="secret123", + ) + url = client.build_logout_url(post_logout_redirect_uri="https://app.example.com/oidc/login") + assert url == "https://app.example.com/oidc/login" + + +async def test_exchange_code(client, httpx_mock): + httpx_mock.add_response( + url="https://idp.example.com/token", + json={ + "access_token": "at-123", + "id_token": "id-123", + "token_type": "Bearer", + "expires_in": 300, + }, + ) + tokens = await client.exchange_code( + code="auth-code-xyz", + redirect_uri="https://app.example.com/callback", + ) + assert tokens["id_token"] == "id-123" diff --git a/modules/oidc/tests/test_oidc_discovery.py b/modules/oidc/tests/test_oidc_discovery.py new file mode 100644 index 00000000..e9900c7b --- /dev/null +++ b/modules/oidc/tests/test_oidc_discovery.py @@ -0,0 +1,42 @@ +"""Tests for OIDC discovery document parsing and fetching.""" + +from __future__ import annotations + +import pytest +from oidc.discovery import OidcMetadata, fetch_metadata + +_ENTRA_DOC = { + "issuer": "https://login.microsoftonline.com/tid/v2.0", + "authorization_endpoint": "https://login.microsoftonline.com/tid/oauth2/v2.0/authorize", + "token_endpoint": "https://login.microsoftonline.com/tid/oauth2/v2.0/token", + "jwks_uri": "https://login.microsoftonline.com/tid/discovery/v2.0/keys", + "end_session_endpoint": "https://login.microsoftonline.com/tid/oauth2/v2.0/logout", +} + + +def test_from_document_parses_endpoints(): + meta = OidcMetadata.from_document(_ENTRA_DOC) + assert meta.issuer == "https://login.microsoftonline.com/tid/v2.0" + assert meta.authorization_endpoint.endswith("/authorize") + assert meta.token_endpoint.endswith("/token") + assert meta.jwks_uri.endswith("/keys") + assert meta.end_session_endpoint.endswith("/logout") + + +def test_from_document_end_session_optional(): + doc = {k: v for k, v in _ENTRA_DOC.items() if k != "end_session_endpoint"} + meta = OidcMetadata.from_document(doc) + assert meta.end_session_endpoint == "" + + +def test_from_document_requires_core_fields(): + doc = {k: v for k, v in _ENTRA_DOC.items() if k != "jwks_uri"} + with pytest.raises(ValueError, match="jwks_uri"): + OidcMetadata.from_document(doc) + + +async def test_fetch_metadata(httpx_mock): + url = "https://issuer/.well-known/openid-configuration" + httpx_mock.add_response(url=url, json=_ENTRA_DOC) + meta = await fetch_metadata(url) + assert meta.token_endpoint == _ENTRA_DOC["token_endpoint"] diff --git a/modules/oidc/tests/test_oidc_jwks.py b/modules/oidc/tests/test_oidc_jwks.py new file mode 100644 index 00000000..705bef41 --- /dev/null +++ b/modules/oidc/tests/test_oidc_jwks.py @@ -0,0 +1,143 @@ +"""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 oidc.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", *, include_alg=True): + from jwt.algorithms import RSAAlgorithm + + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk["kid"] = kid + jwk["use"] = "sig" + if include_alg: + 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 { + "oid": "user-123", + "email": "test@example.com", + "preferred_username": "testuser", + "iss": "https://login.microsoftonline.com/tid/v2.0", + "aud": "my-client", + "exp": now + 3600, + "iat": now, + "roles": ["admin", "user"], + } + + +async def test_validate_jwt_valid_token(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + httpx_mock.add_response(url="https://idp/jwks", json=_make_jwks_response(public_key)) + + cache = JWKSCache( + jwks_url="https://idp/jwks", + ttl_seconds=3600, + issuer="https://login.microsoftonline.com/tid/v2.0", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is not None + assert claims["oid"] == "user-123" + + +async def test_validate_jwt_accepts_keys_without_alg(rsa_keys, valid_payload, httpx_mock): + """Entra JWKS keys omit ``alg`` — they must still be accepted.""" + private_key, public_key = rsa_keys + httpx_mock.add_response( + url="https://idp/jwks", + json=_make_jwks_response(public_key, include_alg=False), + ) + + cache = JWKSCache( + jwks_url="https://idp/jwks", + ttl_seconds=3600, + issuer="https://login.microsoftonline.com/tid/v2.0", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + claims = await cache.validate_jwt(token) + assert claims is not None + assert claims["oid"] == "user-123" + + +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 + httpx_mock.add_response(url="https://idp/jwks", json=_make_jwks_response(public_key)) + + cache = JWKSCache( + jwks_url="https://idp/jwks", + ttl_seconds=3600, + issuer="https://login.microsoftonline.com/tid/v2.0", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + assert await cache.validate_jwt(token) is None + + +async def test_validate_jwt_wrong_issuer(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + httpx_mock.add_response(url="https://idp/jwks", json=_make_jwks_response(public_key)) + + cache = JWKSCache( + jwks_url="https://idp/jwks", + ttl_seconds=3600, + issuer="https://wrong-issuer.example.com/v2.0", + audience="my-client", + ) + + token = _sign_token(private_key, valid_payload) + assert await cache.validate_jwt(token) is None + + +async def test_validate_jwt_wrong_audience(rsa_keys, valid_payload, httpx_mock): + private_key, public_key = rsa_keys + httpx_mock.add_response(url="https://idp/jwks", json=_make_jwks_response(public_key)) + + cache = JWKSCache( + jwks_url="https://idp/jwks", + ttl_seconds=3600, + issuer="https://login.microsoftonline.com/tid/v2.0", + audience="wrong-client", + ) + + token = _sign_token(private_key, valid_payload) + assert await cache.validate_jwt(token) is None + + +def test_jwks_requires_issuer_and_audience(): + with pytest.raises(ValueError, match="issuer"): + JWKSCache(jwks_url="https://idp/jwks", issuer="", audience="my-client") + with pytest.raises(ValueError, match="audience"): + JWKSCache(jwks_url="https://idp/jwks", issuer="https://idp", audience="") diff --git a/modules/oidc/tests/test_oidc_module.py b/modules/oidc/tests/test_oidc_module.py new file mode 100644 index 00000000..c9dbcf4a --- /dev/null +++ b/modules/oidc/tests/test_oidc_module.py @@ -0,0 +1,22 @@ +"""Tests for OidcModule lifecycle.""" + +from __future__ import annotations + +from auth.contracts.provider import AuthProvider + + +def test_oidc_module_meta(): + from oidc.module import OidcModule + + mod = OidcModule() + assert mod.meta.name == "Oidc" + assert mod.meta.depends_on == ["Auth", "Settings"] + assert mod._is_auth_provider is True + + +def test_oidc_provider_satisfies_protocol(): + from oidc.provider import OidcAuthProvider + + provider = OidcAuthProvider() + assert isinstance(provider, AuthProvider) + assert provider.name == "oidc" diff --git a/modules/oidc/tests/test_oidc_provider.py b/modules/oidc/tests/test_oidc_provider.py new file mode 100644 index 00000000..8fc9baca --- /dev/null +++ b/modules/oidc/tests/test_oidc_provider.py @@ -0,0 +1,106 @@ +"""Tests for OidcAuthProvider.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from auth.contracts.provider import AuthProvider +from auth.contracts.schemas import UserContext +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings + + +@pytest.fixture +def settings(): + return OidcSettings( + provider="entra", + tenant_id="11111111-2222-3333-4444-555555555555", + client_id="my-app", + client_secret="secret", + role_mapping={"admin": "admin", "user": "user", "editor": "editor"}, + ) + + +@pytest.fixture +def provider(settings): + return OidcAuthProvider(settings) + + +def test_satisfies_protocol(provider): + assert isinstance(provider, AuthProvider) + + +def test_name(provider): + assert provider.name == "oidc" + + +def test_login_url(provider): + assert provider.get_login_url(None) == "/oidc/login" + + +def test_logout_url(provider): + assert provider.get_logout_url(None) == "/oidc/logout" + + +def test_public_paths(provider): + prefixes, _exact = provider.get_public_paths() + assert "/oidc/login" in prefixes + assert "/api/oidc/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_subject_uses_oid_for_entra(provider): + claims = {"oid": "entra-oid-1", "sub": "pairwise-sub"} + assert provider._subject(claims) == "entra-oid-1" + + +def test_subject_falls_back_to_sub(provider): + claims = {"sub": "fallback-sub"} + assert provider._subject(claims) == "fallback-sub" + + +def test_claims_to_user_context_entra_roles(provider): + claims = { + "oid": "entra-oid-1", + "email": "test@example.com", + "preferred_username": "testuser", + "tid": "tenant-abc", + "roles": ["admin", "unknown_role", "user"], + } + ctx = provider._claims_to_user_context(claims, cache_id="cache-1") + assert isinstance(ctx, UserContext) + assert ctx.id == "cache-1" + assert ctx.email == "test@example.com" + assert ctx.name == "testuser" + assert ctx.tenant_id == "tenant-abc" + assert sorted(ctx.roles) == ["admin", "user"] + + +def test_claims_to_user_context_no_roles(provider): + claims = {"oid": "entra-oid-2", "email": "noroles@example.com"} + ctx = provider._claims_to_user_context(claims, cache_id="cache-2") + assert ctx.roles == [] + + +def test_claims_to_user_context_generic_sub_and_name(): + settings = OidcSettings( + provider="generic", + discovery_url="https://issuer/.well-known/openid-configuration", + client_id="my-app", + client_secret="secret", + ) + provider = OidcAuthProvider(settings) + # No preferred_username -> falls back to the name claim. + claims = {"sub": "s-1", "email": "g@example.com", "name": "Generic User"} + ctx = provider._claims_to_user_context(claims, cache_id="cache-3") + assert ctx.name == "Generic User" + assert ctx.roles == [] diff --git a/modules/oidc/tests/test_oidc_settings_presets.py b/modules/oidc/tests/test_oidc_settings_presets.py new file mode 100644 index 00000000..b2ee1e3c --- /dev/null +++ b/modules/oidc/tests/test_oidc_settings_presets.py @@ -0,0 +1,78 @@ +"""Tests for preset-driven OidcSettings resolution.""" + +from __future__ import annotations + +from oidc.settings import OidcSettings + + +def test_entra_preset_derives_discovery_url_and_claims(): + s = OidcSettings( + provider="entra", + tenant_id="11111111-2222-3333-4444-555555555555", + client_id="my-app", + client_secret="secret", + ) + assert s.discovery_url == ( + "https://login.microsoftonline.com/" + "11111111-2222-3333-4444-555555555555/v2.0/.well-known/openid-configuration" + ) + assert s.uid_claim == "oid" + assert s.roles_claim_path == "roles" + assert s.scope == "openid email profile" + + +def test_explicit_discovery_url_wins_over_tenant(): + s = OidcSettings( + provider="entra", + tenant_id="tid", + discovery_url="https://custom/.well-known/openid-configuration", + client_id="my-app", + client_secret="secret", + ) + assert s.discovery_url == "https://custom/.well-known/openid-configuration" + + +def test_generic_preset_defaults_to_sub(): + s = OidcSettings( + provider="generic", + discovery_url="https://issuer/.well-known/openid-configuration", + client_id="my-app", + client_secret="secret", + ) + assert s.uid_claim == "sub" + assert s.roles_claim_path == "" + + +def test_explicit_claim_overrides_preset(): + s = OidcSettings( + provider="entra", + tenant_id="tid", + client_id="my-app", + client_secret="secret", + uid_claim="sub", + roles_claim_path="resource_access.my-app.roles", + ) + assert s.uid_claim == "sub" + assert s.roles_claim_path == "resource_access.my-app.roles" + + +def test_unknown_provider_falls_back_to_generic(): + s = OidcSettings( + provider="does-not-exist", + discovery_url="https://issuer/.well-known/openid-configuration", + client_id="my-app", + client_secret="secret", + ) + assert s.uid_claim == "sub" + + +def test_jwt_audience_falls_back_to_client_id(): + s = OidcSettings(provider="generic", client_id="my-app", client_secret="secret") + assert s.jwt_audience == "my-app" + s2 = OidcSettings( + provider="generic", + client_id="my-app", + client_secret="secret", + audience="api://my-app", + ) + assert s2.jwt_audience == "api://my-app" diff --git a/modules/oidc/tsconfig.json b/modules/oidc/tsconfig.json new file mode 100644 index 00000000..aead1801 --- /dev/null +++ b/modules/oidc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../host/client_app/tsconfig.json", + "include": ["oidc/**/*.ts", "oidc/**/*.tsx"] +} diff --git a/package-lock.json b/package-lock.json index fa04883e..607eca27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -205,6 +205,10 @@ "name": "@simple-module/keycloak", "version": "0.0.0" }, + "modules/oidc": { + "name": "@simple-module/oidc", + "version": "0.0.0" + }, "modules/permissions": { "name": "@simple-module-py/permissions", "version": "0.1.0", @@ -4570,6 +4574,10 @@ "resolved": "modules/keycloak", "link": true }, + "node_modules/@simple-module/oidc": { + "resolved": "modules/oidc", + "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 edfede6e..8dded069 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ extra-paths = [ "modules/settings", "modules/feature_flags", "modules/keycloak", + "modules/oidc", "modules/audit_log", "host", "scripts", @@ -109,7 +110,7 @@ invalid-assignment = "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", "modules/keycloak/tests", "modules/audit_log/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", "modules/oidc/tests", "modules/audit_log/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 c5699f18f4a1d3235152e5699c89c834ddba4d38 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 23 Jun 2026 13:35:01 +0200 Subject: [PATCH 2/3] fix(oidc): validate nonce, persist user cache, disambiguate callback route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues found while testing the OIDC provider end-to-end: - Security: the login nonce was stored in the session then discarded at callback without ever being compared to the id_token `nonce` claim, so a separately-obtained id_token could be replayed/injected. Now validated (OIDC Core 3.1.3.7 §11). - Correctness: `_upsert_user_cache` opened its own session and only flushed, so the subject->UUID row was rolled back on close and never persisted — every login minted a fresh framework id. Now commits explicitly. - Routing: the `oidc_login`/`oidc_callback` route function names collide with keycloak's, so `url_for("oidc_callback")` could resolve to keycloak's endpoint and send the IdP the wrong redirect_uri. Routes now carry unique names (`oidc_auth_login` / `oidc_auth_callback`). Adds an end-to-end login->callback flow test (real RS256 id_token validated against an injected JWKS key) covering the happy path, stable-id reuse across logins, and rejection of nonce/state/audience mismatches. Also adds the project's standard inline `unsupported-base` suppression to the new model. Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY --- modules/oidc/oidc/endpoints/api.py | 16 ++- modules/oidc/oidc/models.py | 2 +- modules/oidc/oidc/provider.py | 8 +- modules/oidc/tests/test_oidc_flow.py | 187 +++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 7 deletions(-) create mode 100644 modules/oidc/tests/test_oidc_flow.py diff --git a/modules/oidc/oidc/endpoints/api.py b/modules/oidc/oidc/endpoints/api.py index 4d95a012..8932eb8e 100644 --- a/modules/oidc/oidc/endpoints/api.py +++ b/modules/oidc/oidc/endpoints/api.py @@ -25,11 +25,11 @@ def _require_client(request: Request): return client -@router.get("/login") +@router.get("/login", name="oidc_auth_login") async def oidc_login(request: Request): client = _require_client(request) s = request.app.state.oidc.settings - callback_url = str(request.url_for("oidc_callback")) + callback_url = str(request.url_for("oidc_auth_callback")) nonce = secrets.token_urlsafe(32) url, state = client.build_authorization_url( redirect_uri=callback_url, @@ -41,19 +41,19 @@ async def oidc_login(request: Request): return RedirectResponse(url, status_code=302) -@router.get("/callback") +@router.get("/callback", name="oidc_auth_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) + expected_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 = _require_client(request) - callback_url = str(request.url_for("oidc_callback")) + callback_url = str(request.url_for("oidc_auth_callback")) try: tokens = await client.exchange_code(code=code, redirect_uri=callback_url) @@ -70,6 +70,12 @@ async def oidc_callback(request: Request): if claims is None: raise HTTPException(status_code=401, detail="Token validation failed") + # Bind the id_token to this login: the nonce we sent in the authorize + # request must come back in the validated token (OIDC Core 3.1.3.7 §11). + # Without this an attacker could replay/inject a separately-obtained token. + if not expected_nonce or claims.get("nonce") != expected_nonce: + raise HTTPException(status_code=401, detail="OIDC nonce mismatch") + 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) diff --git a/modules/oidc/oidc/models.py b/modules/oidc/oidc/models.py index b064f68f..bf1a4e9d 100644 --- a/modules/oidc/oidc/models.py +++ b/modules/oidc/oidc/models.py @@ -11,7 +11,7 @@ Base = create_module_base("oidc") -class OidcUserCache(Base, table=True): +class OidcUserCache(Base, table=True): # ty: ignore[unsupported-base] __tablename__ = "oidc_user_cache" id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) diff --git a/modules/oidc/oidc/provider.py b/modules/oidc/oidc/provider.py index f0c972fa..5b1c49c4 100644 --- a/modules/oidc/oidc/provider.py +++ b/modules/oidc/oidc/provider.py @@ -107,7 +107,13 @@ async def _upsert_user_cache(self, request: Request, claims: dict) -> str: else: self._touch_cache_row(row, claims) await db.flush() - return str(row.id) + row_id = str(row.id) + # This is a self-managed session (not the request-scoped + # ``get_db``), so it must commit explicitly — otherwise the row + # is rolled back on close and the subject->UUID mapping never + # persists, minting a fresh id on every login. + await db.commit() + return row_id except Exception: logger.exception("Failed to upsert OidcUserCache for subject=%s", subject) return subject or "unknown" diff --git a/modules/oidc/tests/test_oidc_flow.py b/modules/oidc/tests/test_oidc_flow.py new file mode 100644 index 00000000..3396b426 --- /dev/null +++ b/modules/oidc/tests/test_oidc_flow.py @@ -0,0 +1,187 @@ +"""End-to-end login -> callback flow for the OIDC module. + +Drives the real HTTP endpoints (`/api/oidc/auth/login` and `/callback`) through +the full middleware stack with the OIDC provider active. Token exchange is +stubbed (no live IdP) but the id_token is a genuine RS256-signed JWT validated +against an injected JWKS key, so state/nonce handling, signature/issuer/audience +checks, claim->UserContext mapping, session creation, and the redirect contract +are all exercised for real. +""" + +from __future__ import annotations + +import time +from urllib.parse import parse_qs, urlparse + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from oidc.client import OIDCClient +from oidc.jwks import JWKSCache +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings + +_KID = "test-key-1" +_ISSUER = "https://login.microsoftonline.com/test-tenant/v2.0" +_AUDIENCE = "my-app" +_PRIVATE_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _sign_id_token(*, nonce: str, **overrides) -> str: + now = int(time.time()) + claims = { + "iss": _ISSUER, + "aud": _AUDIENCE, + "exp": now + 3600, + "iat": now, + "oid": "entra-oid-123", + "email": "alice@example.com", + "preferred_username": "alice", + "tid": "test-tenant", + "roles": ["admin"], + "nonce": nonce, + } + claims.update(overrides) + return jwt.encode(claims, _PRIVATE_KEY, algorithm="RS256", headers={"kid": _KID}) + + +@pytest.fixture +async def oidc_app(app): + """Reuse the shared app but make OIDC the active, fully-wired provider.""" + settings = OidcSettings( + provider="entra", + tenant_id="test-tenant", + client_id=_AUDIENCE, + client_secret="secret", + ) + provider = OidcAuthProvider(settings) + + jwks = JWKSCache(jwks_url="https://idp/jwks", issuer=_ISSUER, audience=_AUDIENCE) + # Inject the signing key directly and mark the cache fresh so validate_jwt + # never hits the network. + jwks._keys = {_KID: _PRIVATE_KEY.public_key()} + jwks._ttl = 10_000 + jwks._fetched_at = time.monotonic() + provider.jwks_cache = jwks + + client = OIDCClient( + authorization_endpoint="https://idp/authorize", + token_endpoint="https://idp/token", + end_session_endpoint="https://idp/logout", + client_id=_AUDIENCE, + client_secret="secret", + ) + + app.state.auth.auth_provider = provider + app.state.oidc.settings = settings + app.state.oidc.client = client + app.state.oidc.jwks_cache = jwks + return app + + +@pytest.fixture +async def flow_client(oidc_app): + transport = httpx.ASGITransport(app=oidc_app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as c: + yield c + + +async def _begin_login(flow_client) -> tuple[str, str, str]: + """Hit /login and return (state, nonce, redirect_uri) from the authorize URL.""" + resp = await flow_client.get("/api/oidc/auth/login") + assert resp.status_code == 302 + location = resp.headers["location"] + q = parse_qs(urlparse(location).query) + return q["state"][0], q["nonce"][0], q["redirect_uri"][0] + + +async def test_login_redirects_to_authorize_with_state_and_nonce(flow_client): + resp = await flow_client.get("/api/oidc/auth/login") + assert resp.status_code == 302 + location = resp.headers["location"] + assert location.startswith("https://idp/authorize?") + q = parse_qs(urlparse(location).query) + assert q["client_id"] == [_AUDIENCE] + assert q["response_type"] == ["code"] + assert q["state"] and q["nonce"] + + +async def test_login_redirect_uri_targets_oidc_callback(flow_client): + """Regression: with keycloak + oidc both installed, the callback URL sent to + the IdP must point at THIS module, not keycloak's identically-named route.""" + _state, _nonce, redirect_uri = await _begin_login(flow_client) + assert urlparse(redirect_uri).path == "/api/oidc/auth/callback" + + +async def test_happy_path_creates_session_and_user_cache(oidc_app, flow_client): + state, nonce, _ = await _begin_login(flow_client) + + token = _sign_id_token(nonce=nonce) + oidc_app.state.oidc.client.exchange_code = _stub_exchange(token) + + resp = await flow_client.get(f"/api/oidc/auth/callback?code=abc&state={state}") + assert resp.status_code == 303 + assert resp.headers["location"] == "/dashboard/" + + # The provider subject (Entra `oid`) was cached. + from oidc.models import OidcUserCache + from sqlalchemy import select + + async with oidc_app.state.sm.db.session_factory() as db: + rows = (await db.execute(select(OidcUserCache))).scalars().all() + assert len(rows) == 1 + assert rows[0].subject == "entra-oid-123" + assert rows[0].email == "alice@example.com" + + +async def test_repeat_login_reuses_stable_id(oidc_app, flow_client): + """Two logins for the same subject must map to ONE persisted row / stable id.""" + from oidc.models import OidcUserCache + from sqlalchemy import select + + ids = [] + for _ in range(2): + state, nonce, _ = await _begin_login(flow_client) + oidc_app.state.oidc.client.exchange_code = _stub_exchange(_sign_id_token(nonce=nonce)) + resp = await flow_client.get(f"/api/oidc/auth/callback?code=abc&state={state}") + assert resp.status_code == 303 + async with oidc_app.state.sm.db.session_factory() as db: + rows = (await db.execute(select(OidcUserCache))).scalars().all() + assert len(rows) == 1 + ids.append(str(rows[0].id)) + assert ids[0] == ids[1] + + +async def test_callback_rejects_nonce_mismatch(oidc_app, flow_client): + """A replayed id_token whose nonce does not match the session must be rejected.""" + state, _nonce, _ = await _begin_login(flow_client) + + token = _sign_id_token(nonce="attacker-controlled-different-nonce") + oidc_app.state.oidc.client.exchange_code = _stub_exchange(token) + + resp = await flow_client.get(f"/api/oidc/auth/callback?code=abc&state={state}") + assert resp.status_code == 401 + + +async def test_callback_rejects_state_mismatch(flow_client): + await _begin_login(flow_client) + resp = await flow_client.get("/api/oidc/auth/callback?code=abc&state=forged-state") + assert resp.status_code == 400 + + +async def test_callback_rejects_token_with_wrong_audience(oidc_app, flow_client): + state, nonce, _ = await _begin_login(flow_client) + + token = _sign_id_token(nonce=nonce, aud="some-other-app") + oidc_app.state.oidc.client.exchange_code = _stub_exchange(token) + + resp = await flow_client.get(f"/api/oidc/auth/callback?code=abc&state={state}") + assert resp.status_code == 401 + + +def _stub_exchange(id_token: str): + async def _exchange(*, code: str, redirect_uri: str) -> dict: + return {"id_token": id_token, "access_token": "stub", "token_type": "Bearer"} + + return _exchange From d9fbf043f6cb6add94aa598fd8542f73f3ed1803 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 23 Jun 2026 13:35:09 +0200 Subject: [PATCH 3/3] chore(typecheck): unbreak ty 0.0.52 across the repo ty 0.0.52 (released 2026-06-23) tightened its diagnostics, turning the lint gate red on main independently of any single PR: - It now requires the project's inline `# ty: ignore[unsupported-base]` SQLModel suppression (carried by 24 model classes) on keycloak's `KeycloakUserCache`, which was missing it. - It flags four `# ty: ignore[invalid-assignment]` directives as unused, because `invalid-assignment` is already globally ignored in pyproject. Add the missing keycloak suppression and drop the four redundant inline directives. No behaviour change. Claude-Session: https://claude.ai/code/session_012kKthZeQPYEUWRL4jygquY --- framework/core/tests/test_module_base.py | 2 +- framework/core/tests/test_services.py | 4 ++-- modules/keycloak/keycloak/models.py | 2 +- modules/users/users/backend.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/framework/core/tests/test_module_base.py b/framework/core/tests/test_module_base.py index a5f0b672..33164a13 100644 --- a/framework/core/tests/test_module_base.py +++ b/framework/core/tests/test_module_base.py @@ -35,7 +35,7 @@ async def test_custom_fields(self): async def test_frozen(self): meta = ModuleMeta(name="Frozen") with pytest.raises(AttributeError): - meta.name = "Changed" # type: ignore[misc] # ty: ignore[invalid-assignment] + meta.name = "Changed" # type: ignore[misc] class DummyModule(ModuleBase): diff --git a/framework/core/tests/test_services.py b/framework/core/tests/test_services.py index 86cd6228..b5fa8ffc 100644 --- a/framework/core/tests/test_services.py +++ b/framework/core/tests/test_services.py @@ -11,13 +11,13 @@ async def test_services_is_frozen(self) -> None: """Mutation after construction must raise — singletons don't change at runtime.""" s = _make_services() with pytest.raises((AttributeError, TypeError)): - s.settings = None # type: ignore[misc,assignment] # ty: ignore[invalid-assignment] + s.settings = None # type: ignore[misc,assignment] async def test_services_has_slots(self) -> None: """Slotted dataclass prevents silent attribute additions (the original bloat pattern).""" s = _make_services() with pytest.raises((AttributeError, TypeError)): - s.rogue_new_attribute = 42 # type: ignore[attr-defined] # ty: ignore[invalid-assignment] + s.rogue_new_attribute = 42 # type: ignore[attr-defined] async def test_services_round_trip_field_access(self) -> None: """Every declared field must be readable after construction.""" diff --git a/modules/keycloak/keycloak/models.py b/modules/keycloak/keycloak/models.py index 0183b42e..e11efc63 100644 --- a/modules/keycloak/keycloak/models.py +++ b/modules/keycloak/keycloak/models.py @@ -11,7 +11,7 @@ Base = create_module_base("keycloak") -class KeycloakUserCache(Base, table=True): +class KeycloakUserCache(Base, table=True): # ty: ignore[unsupported-base] __tablename__ = "keycloak_user_cache" id: uuid_mod.UUID = Field(default_factory=uuid_mod.uuid4, primary_key=True) diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py index 66059cf5..76f7be29 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/backend.py @@ -82,4 +82,4 @@ def reconfigure_cookie_transport( transport.cookie_name = settings.cookie_name transport.cookie_max_age = settings.cookie_max_age_seconds transport.cookie_secure = settings.cookie_secure - transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment] # ty: ignore[invalid-assignment] + transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment]