diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3db4eb601..43db2d156 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,6 +183,7 @@ jobs: - simple_module_file_storage - simple_module_keycloak - simple_module_mail + - simple_module_oidc - simple_module_permissions - simple_module_settings - simple_module_site_lock diff --git a/.gitignore b/.gitignore index a38fbd5ee..c17e1bff7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ node_modules/ # Database *.db +*.db-wal +*.db-shm +*.db-journal *.sqlite3 # Module-managed runtime state (e.g. uploaded dataset files, diff --git a/CHANGELOG.md b/CHANGELOG.md index de6b98433..a9e143fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Added +- Generic OIDC auth provider with Keycloak discovery and single-tenant Entra + presets, configurable claims/roles, cached user directory, English/German UI, + and opt-in bearer `api_audience`, limited to `client_id` plus + `api_allowed_client_ids` (#192). Migration `c9d2e4f6a810` creates the + identity cache on the new independent `oidc` branch (`upgrade heads`). + **Behaviour:** Entra identities use issuer + `oid`; `tid` never selects an app + tenant, tenant-claim trust defaults off and memberships alone grant tenant roles. + Login uses S256 PKCE and bounded sessions with encrypted refresh tokens; + providers omitting `id_token` on refresh sign users out at expiry. Logout clears + local state and optionally redirects to the IdP without `id_token_hint`, so + remote SSO logout depends on the provider. The unused access token's `at_hash` + is not checked. Entra tenant-independent discovery is unsupported; discovery + failure refuses boot outside development. + ## [0.0.42] — 2026-10-31 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 039ceae84..1174930e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,9 +312,10 @@ development and testing (`session_cookie_secure`). - **Migrations** live in `host/migrations/versions/`, never in module packages. `host/migrations/env.py` builds metadata from every installed module and runs `ensure_db_extensions` first. The history is **not** one linear chain: it has - four roots (`77162e7b184b` initial schema, `e36ab09c4a92` tenants, - `f733c7938fd1` sharing, `d263ea727663` the framework's `sm_lease`) and six - heads (`audit_log`, `tenants`, `keycloak`, `sharing`, `leases`, and the users + five roots (`77162e7b184b` initial schema, `e36ab09c4a92` tenants, + `f733c7938fd1` sharing, `d263ea727663` the framework's `sm_lease`, and + `c9d2e4f6a810` OIDC) and seven + heads (`audit_log`, `tenants`, `keycloak`, `sharing`, `leases`, `oidc`, and the users trunk, which Alembic reports as `api_tokens`, `users`, `file_storage` and, since #536, `background_tasks`). Apply with `upgrade heads` (what `make migrate` does); plain `revision --autogenerate` refuses with "Multiple heads", so name diff --git a/README.md b/README.md index a98f44421..ac400714d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A modular-monolith framework for Python. Each feature lives in its own self-cont - **Backend:** Python 3.12, FastAPI, SQLModel (SQLAlchemy async + Pydantic), Alembic - **Frontend:** Inertia.js + React + Tailwind CSS 4, Vite HMR - **UI:** shadcn/ui primitives + emerald/teal design tokens, Sora display font, DM Sans body, JetBrains Mono code -- **Auth:** Pluggable providers — local users (email+password + OAuth/OIDC: Google, GitHub, Microsoft/Entra) via fastapi-users, or Keycloak OIDC SSO; cookie sessions or bearer tokens resolved through a principal-resolver chain +- **Auth:** Pluggable providers — local users (email+password + OAuth/OIDC: Google, GitHub, Microsoft/Entra) via fastapi-users, Keycloak OIDC SSO, or any OIDC provider (Microsoft Entra, Auth0, Okta, Keycloak) via the generic `oidc` module; cookie sessions or bearer tokens resolved through a principal-resolver chain - **Tooling:** uv workspaces, Ruff, ty, Biome, pytest ## Use in a new project @@ -183,7 +183,7 @@ Local deployments only need one env var — everything else has sensible default | `SM_ENVIRONMENT` | `development` | No — any value other than `development`, `test`, `testing` triggers strict discovery and placeholder-secret checks | | `SM_SECRET_KEY` | `change-me-in-production` | No in dev; **must** be overridden in production | | `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — Vite HMR origin | -| `SM_AUTH_PROVIDER` | `users` | No — `users` or `keycloak`. Only read when both are installed; see [Auth providers](#auth-providers-users-or-keycloak) | +| `SM_AUTH_PROVIDER` | `users` | No — `users`, `keycloak` or `oidc`. Only read when more than one is installed; see [Auth providers](#auth-providers-exactly-one-is-active) | Power users can still override the following bootstrap knobs via env if needed: `SM_DB_POOL_SIZE`, `SM_DB_MAX_OVERFLOW`, `SM_DB_POOL_PRE_PING`, `SM_DB_POOL_RECYCLE`, `SM_DEBUG`, `SM_LOG_LEVEL`, `SM_LOG_FORMAT`, `SM_MODULES_ENABLED`. These are needed before the DB connection is open. @@ -227,22 +227,23 @@ The 300-line file cap (enforced by CI) usually pushes you to factor row-level co ## User management -### Auth providers: users **or** keycloak, never both +### Auth providers: exactly one is active -This repo ships two authentication providers, and **exactly one can be active at a time**: +This repo ships three authentication providers, and **exactly one can be active at a time**: | Module | What it does | |---|---| | `users` (default) | Local accounts — password login, invites, signup, roles, an admin UI | | `keycloak` | Delegates authentication to a Keycloak realm over OIDC | +| `oidc` | Delegates authentication to any OIDC provider (Entra preset; Keycloak via the generic preset) — see [docs/modules/oidc.md](docs/modules/oidc.md) | -Both claim the same slot (`app.state.auth.auth_provider`), so running them together -is a misconfiguration, not a supported combination. Installing both and activating -neither is reported as `SM020` (error — fails boot); installing neither is `SM021`. +They all claim the same slot (`app.state.auth.auth_provider`), so running two together +is a misconfiguration, not a supported combination. Installing several and activating +none is reported as `SM020` (error — fails boot); installing neither is `SM021`. -`uv sync --all-packages` installs *every* workspace member, so a dev clone has both -packages on disk. The host therefore activates one and skips the other rather than -failing on `SM020`, and **`users` is the default** — `keycloak` is installed but inert +`uv sync --all-packages` installs *every* workspace member, so a dev clone has every +provider on disk. The host therefore activates one and skips the others rather than +failing on `SM020`, and **`users` is the default** — `keycloak` and `oidc` are installed but inert until you ask for it. To switch: ```bash @@ -266,7 +267,7 @@ Two caveats when running Keycloak: realm owns login, logout, and user administration instead. Only one provider is ever discovered, so nothing here changes if you install just one -of the two: a host that ships only `keycloak` keeps it regardless of `SM_AUTH_PROVIDER`. +of them: a host that ships only `keycloak` (or only `oidc`) keeps it regardless of `SM_AUTH_PROVIDER`. ### Creating the first admin diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 74fb48ef7..7086fd7af 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -190,6 +190,7 @@ export default defineConfig({ { text: "auth", link: "/modules/auth" }, { text: "users", link: "/modules/users" }, { text: "keycloak", link: "/modules/keycloak" }, + { text: "oidc", link: "/modules/oidc" }, { text: "api_tokens", link: "/modules/api_tokens" }, { text: "permissions", link: "/modules/permissions" }, { text: "settings", link: "/modules/settings" }, diff --git a/docs/database/migrations.md b/docs/database/migrations.md index f8555c8a5..2a84c8ce9 100644 --- a/docs/database/migrations.md +++ b/docs/database/migrations.md @@ -38,6 +38,7 @@ The history is **not** a single linear chain. It has several roots and several h | Root | Branch | |---|---| | `77162e7b184b` initial schema | the users trunk (also holds file_storage, background_tasks, api_tokens revisions) | +| `c9d2e4f6a810` OIDC identity cache (#192) | oidc (root and head) | | `e36ab09c4a92` tenants initial schema | tenants (`f1a9c3d7b2e4` head) | | `f733c7938fd1` sharing resource share | sharing (root and head) | | `d263ea727663` framework `sm_lease` table | leases (its own head; [leases](../framework/leases.md)) | @@ -50,6 +51,7 @@ The history is **not** a single linear chain. It has several roots and several h | `d263ea727663` | framework (`sm_lease`) | `leases` | | `f6a3b9d2e8c4` | keycloak | `keycloak` | | `f733c7938fd1` | sharing | `sharing` | +| `c9d2e4f6a810` | oidc | `oidc` | `audit_log` forks off the trunk at `41cf2c53660e`, and `keycloak` forks off the users revision `873ca2015033`. @@ -57,7 +59,7 @@ A module's first migration sets `branch_labels = ("",)`. Alembic ** ### Known mislabels -The declared `branch_labels` are `audit_log`, `api_tokens`, `background_tasks`, `keycloak`, `tenants`, `sharing`, `leases`, `users` (added by `a625f0b7c912`) and `file_storage` (added by `f5b1c3e8a207`). The graph carries these defects; they are documented rather than rewritten, because changing labels on revisions that installed databases already applied is not worth the risk: +The declared `branch_labels` are `audit_log`, `api_tokens`, `background_tasks`, `keycloak`, `tenants`, `sharing`, `leases`, `users` (added by `a625f0b7c912`), `oidc` (added by `c9d2e4f6a810`) and `file_storage` (added by `f5b1c3e8a207`). The graph carries these defects; they are documented rather than rewritten, because changing labels on revisions that installed databases already applied is not worth the risk: - `51b2058d8afd_api_tokens_initial` sits in the middle of the trunk, so every revision above it (file_storage, background_tasks including the outbox, and the users revisions through `c8e4f1a2b6d9`) reports the `api_tokens` label. The `api_tokens` head is therefore really a users revision. - `keycloak` branches off a users revision, so a Keycloak-only install still builds the users schema. diff --git a/docs/modules/auth.md b/docs/modules/auth.md index 1e9f047b4..865f59dc0 100644 --- a/docs/modules/auth.md +++ b/docs/modules/auth.md @@ -76,7 +76,7 @@ async def delete_order(order_id: int, user: CurrentUser) -> None: `AuthModule.register_settings` puts an empty `AuthState` on `app.state.auth` (holding `auth_provider` and `principal_resolvers`), and `register_middleware` installs `AuthMiddleware`. On each HTTP request the middleware: 1. Skips authentication for public paths — framework defaults (`/health`, `/static/`, `/api/docs`, `/api/redoc`, `/api/openapi.json` (only when served), `/i18n/`, and the exact path `/`), method-aware rules contributed via the [`register_public_routes`](/framework/lifecycle) hook (plus the `SM_AUTH_PUBLIC_PATHS` host escape hatch), and, until 0.1.0, a third-party provider's deprecated `get_public_paths()` (prefix-only). -2. Calls `provider.resolve_user(request)` (the `AuthProvider` registered by `users` or `keycloak`). +2. Calls `provider.resolve_user(request)` (the `AuthProvider` registered by `users`, `keycloak` or `oidc`). 3. On a miss, walks the **principal-resolver chain** (`app.state.auth.principal_resolvers`) in order — async callables `(Request) -> UserContext | None` for extra credential sources (PAT bearer tokens, API keys). The first non-`None` wins; each is wrapped in `try/except` so a raising resolver is treated as no-match. 4. If still unauthenticated and the path isn't public: returns `401` JSON for `/api/*` or bearer requests, otherwise stashes `next` in the session and redirects to `provider.get_login_url(request)`. diff --git a/docs/modules/index.md b/docs/modules/index.md index aa6cfaee1..820d142e7 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -1,11 +1,12 @@ # Bundled modules -simple_module_python ships with sixteen first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need. +simple_module_python ships with seventeen first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need. | Module | Depends on | What it provides | |---|---|---| -| [`auth`](/modules/auth) | — | Tiny public surface (`UserContext`, `AuthProvider`, `get_current_user`, `require_permission`) other modules import, plus the provider-agnostic `AuthMiddleware` and principal-resolver chain. The actual login/session logic lives in an auth-provider module (`users` or `keycloak`). | +| [`auth`](/modules/auth) | — | Tiny public surface (`UserContext`, `AuthProvider`, `get_current_user`, `require_permission`) other modules import, plus the provider-agnostic `AuthMiddleware` and principal-resolver chain. The actual login/session logic lives in an auth-provider module (`users`, `keycloak` or `oidc`). | | [`users`](/modules/users) | `auth` | Email + password auth, OAuth/OIDC (Google, GitHub, Microsoft/Entra ID, generic OIDC), sessions, roles, invites, password reset, email verification, admin UI, mailer backends, `smpy users create-admin`. | +| [`oidc`](/modules/oidc) | `auth` (`settings` optional) | Generic OIDC provider: Keycloak via discovery, single-tenant Entra and configurable claims. | | [`keycloak`](/modules/keycloak) | `auth`, `settings` | Alternative auth provider: Keycloak OIDC single sign-on. | | [`api_tokens`](/modules/api_tokens) | `auth`, `users`, `settings` | Personal access tokens and service API keys: hashed at rest, scope-capped on every request, revocable on the next request; self-service page and admin screen. | | [`permissions`](/modules/permissions) | `auth`, `users` | Role / direct-grant assignment store + admin UI; `RequiresPermission` dependency that honours both forms. | diff --git a/docs/modules/oidc.md b/docs/modules/oidc.md new file mode 100644 index 000000000..4d468b8a5 --- /dev/null +++ b/docs/modules/oidc.md @@ -0,0 +1,217 @@ +# OIDC + +`simple_module_oidc` is the generic OpenID Connect auth provider (#192). +It discovers authorization, token, JWKS and optional logout endpoints. It uses +hosted login with authorization code flow, S256 PKCE, state and nonce checks. +Only one auth provider may be active; select it with `SM_AUTH_PROVIDER=oidc`. + +## Install and activate + +Add `simple_module_oidc` to your host dependencies (and a workspace source +when developing in this repository), then sync your environment. Keep `Auth` +enabled. `Settings` is optional: its UI edits DB-backed policy, but framework +settings hydration works without it. Exclude modules that require `Users` when +selecting OIDC, because discovery removes competing auth providers. Use +`SM_MODULES_ENABLED` if you need an explicit module set. + +Apply host migrations with `make migrate` (`alembic upgrade heads`). Revision +`c9d2e4f6a810` creates `oidc_user_cache` on the new independent `oidc` branch. +A host consuming the package must include this migration in its own history; +module packages do not run migrations themselves. + +## Keycloak through the generic preset + +This is the primary worked example. The dedicated `keycloak` module is an +alternative; this configuration uses `oidc` throughout. + +Create an OpenID Connect client in the Keycloak realm: + +- Enable client authentication (a confidential client) and the standard flow. +- Set the valid redirect URI to `/api/oidc/auth/callback`. +- Set the valid post-logout redirect URI to `/oidc/logged-out`. +- Require PKCE with code challenge method **S256**. +- Add realm roles to the **ID token**. Keycloak 26 puts `realm_access.roles` + only in the access token by default, and this module reads roles from the ID + token. Under Client scopes -> `roles` -> Mappers -> `realm roles`, turn on + **Add to ID token** (token claim name `realm_access.roles`, multivalued), or + add a **User Realm Role** mapper with those settings to the client's + dedicated scope. Then assign the intended realm roles (for example `admin`) + to users. +- Keep the default `profile` and `email` client scopes: they supply + `preferred_username`, `name`, `email` and `email_verified` in the ID token. + +Use the realm's discovery document and the client's credentials: + +```dotenv +SM_AUTH_PROVIDER=oidc +SM_OIDC_PROVIDER=generic +SM_OIDC_DISCOVERY_URL=https:///realms//.well-known/openid-configuration +SM_OIDC_CLIENT_ID= +SM_OIDC_CLIENT_SECRET= +SM_OIDC_ROLES_CLAIM_PATH=realm_access.roles +``` + +`SM_OIDC_ROLES_CLAIM_PATH` seeds the roles path, so the first Keycloak admin is +mapped to `admin` on their first sign-in. Without it nobody holds `admin`, and +the Settings UI that edits the path needs one. A value saved in the UI later +wins over the variable. + +Here `` includes the application's scheme and host, and `` is the +Keycloak server (including any deployment prefix). Discovery is exactly +`/realms//.well-known/openid-configuration` when `` already +includes the scheme. Use HTTPS outside development. + +Set these **DB settings** under the `oidc` namespace, using Settings → OIDC when +the Settings UI is enabled: + +```json +{ + "role_mapping": {"admin": "admin", "user": "user"} +} +``` + +Choose external roles and app roles appropriate for your host. `role_mapping` +produces application roles; permission grants still come from the framework's +permission registry. No mapping grants `tenant:*` roles. Visit `/oidc/login` to +start sign-in. Cached identity is `(issuer, sub)` with a stable framework UUID. + +## Presets and Microsoft Entra ID + +`generic` requires an explicit discovery URL. Its defaults are `sub`, +`preferred_username`, `email`, `name`, no roles path, and scope +`openid email profile`. Use dotted paths such as `realm_access.roles` or +`resource_access.my-client.roles` to select a list of external roles; a claim +named exactly like the path wins, so Auth0/Okta URL-namespaced claims such as +`https://example.com/roles` work as written. Keycloak issues refresh tokens for +this scope; Auth0 and Okta only do with `offline_access`, so add it to `scope` +there or sessions end at ID-token expiry. + +`entra` supports **single-tenant** registrations. Register a Web application +for accounts in your directory, create its client secret, and add +`/api/oidc/auth/callback` as the redirect URI. Configure: + +```dotenv +SM_AUTH_PROVIDER=oidc +SM_OIDC_PROVIDER=entra +SM_OIDC_TENANT_ID= +SM_OIDC_CLIENT_ID= +SM_OIDC_CLIENT_SECRET= +``` + +The preset derives +`https://login.microsoftonline.com//v2.0/.well-known/openid-configuration`. +It requests `openid email profile offline_access`: `offline_access` allows a +refresh token so the app can renew expired browser sessions. Define app roles +under App registrations → App roles and assign them under Enterprise +applications → Users and groups. The preset reads the `roles` claim and maps +it through `role_mapping` (default `admin`/`user` map to themselves). + +Entra uses the required `oid` identity claim, namespaced by the exact issuer +(which identifies the directory). A changed pairwise `sub` does not change that +identity. `tid` is an Entra directory identifier, never an application tenant. +`common`, `organizations`, `consumers` and templated discovery issuers are +rejected. There is no multi-directory issuer substitution. Entra email claims +are display data and do not attest verified mailbox ownership. + +## Configuration reference + +All rows name fields of `OidcSettings`, stored as `oidc.`. The six +`SM_OIDC_*` variables below seed bootstrap defaults (process wiring, secrets +and the first admin's roles path); explicit DB values can +override them. **— means no environment variable**, not an undocumented `SM_OIDC_*` spelling. +Bare environment variables and old claim-mapping environment variables are +ignored. Host selection `SM_AUTH_PROVIDER=oidc` is a separate host setting. + +| Setting | Environment variable | Default | Meaning | +|---|---|---|---| +| `provider` | `SM_OIDC_PROVIDER` | `generic` | `generic` or `entra`; unknown presets fail validation. Restart required. | +| `discovery_url` | `SM_OIDC_DISCOVERY_URL` | empty | Explicit discovery URL, taking precedence over preset derivation. Restart required. | +| `tenant_id` | `SM_OIDC_TENANT_ID` | empty | Entra directory GUID used to derive discovery. Restart required. | +| `client_id` | `SM_OIDC_CLIENT_ID` | empty | Browser ID-token audience and OAuth client ID. Restart required. | +| `client_secret` | `SM_OIDC_CLIENT_SECRET` | empty | Confidential-client secret, masked by settings handling. Restart required. | +| `api_audience` | — | empty | Expected API JWT audience; empty disables bearer authentication. | +| `api_allowed_client_ids` | — | empty | Clients besides `client_id` allowed to call the API (`azp`/`appid`). | +| `trust_tenant_claim` | — | `false` | Whether a validated `tenant_id` claim may select an app tenant; see below. | +| `scope` | — | preset | Generic: `openid email profile`; Entra adds `offline_access`. | +| `uid_claim` | — | preset | Generic `sub`; Entra `oid`. Required; no fallback. Changing it changes identity mapping. | +| `username_claim` | — | `preferred_username` | Display name first choice. | +| `email_claim` | — | `email` | Display email. Generic literal `email_verified=true` alone supplies verification proof. | +| `name_claim` | — | `name` | Fallback display name. | +| `roles_claim_path` | `SM_OIDC_ROLES_CLAIM_PATH` | preset | Generic empty; Entra `roles`; dotted path to a list. | +| `login_redirect_url` | — | `/dashboard/` | Local destination after login when no safe saved deep link exists; blanks normalize to default. | +| `jwks_cache_ttl_seconds` | — | `3600` | Nonnegative signing-key cache TTL. Restart required. Last-good keys survive outages; unknown keys share a 30-second fetch cooldown. | +| `role_mapping` | — | `{"admin":"admin","user":"user"}` | External roles to application roles. Unmapped roles and mapped `tenant:*` roles are discarded. | + +Claim and role policy reads current module state after hydration/reload. Mapping +changes affect fresh tokens (callback, bearer, refresh); roles already in a +browser session remain until refresh. Tenant-claim trust is rechecked on each +restored session. Discovery, clients and JWKS initialize together, so fields +marked restart-required need a process restart rather than a partial reload. + +## Bearer APIs and tenancy + +Set the DB setting `api_audience` to the audience issued for your API. Send the +API JWT in `Authorization: Bearer ` (scheme is case insensitive). Browser +ID tokens always validate against `client_id`; they do not authorize a distinct +API audience. Bearer requests use the framework's bearer auth kind and cannot +manage API tokens. PAT/service-token handling stays with its owning resolver. +The returned OAuth access token from browser code exchange is unused. + +An API token is accepted only from a known client: its `azp` (or `appid` on +Entra v1 tokens) must be `client_id` or listed in the DB setting +`api_allowed_client_ids`. List each SPA, CLI or service that calls the API; a +token naming no client is refused. Scopes (`scp`) are not checked; what a caller +may do still comes from its mapped roles. + +Leave `trust_tenant_claim=false` unless you control and trust the IdP mapper +producing `tenant_id`. When enabled, the claim must pass framework tenant-ID +validation; a registered tenant resolver remains authoritative. Memberships, +not external claims or `role_mapping`, assign `tenant:*` roles. Entra `tid` +never selects an application tenant. + +## Sessions, logout and limits + +JWT validation requires RS256, signature, exact issuer, subject, audience, +expiry and issued-at. For the browser ID token a supplied `azp` must name this +client and is required for multiple audiences; additional audiences are rejected. +An API access token's `azp` is the calling client: it is not pinned to this +client but checked against `api_allowed_client_ids` (see above). +Discovery validates URLs and issuer, requires HTTPS outside development, and +permits legitimate endpoints on different hosts. Discovery failure refuses boot +outside development; development retries lazily at login. + +Login replaces user-bound session state and rotates CSRF. The signed cookie +stores a minimal principal, ID-token expiry, issuer/subject identity and an +encrypted refresh token if issued. It never retains the ID token. At expiry, +refresh must return a valid ID token for the same identity. **Providers that do +not return an `id_token` on refresh sign the user out at expiry.** Parallel requests +that cross the deadline may each present the same refresh token; with an IdP that +rotates refresh tokens and detects reuse (Auth0, Okta, Keycloak with *Revoke +Refresh Token*), allow a reuse grace period or the losing request signs the user +out. Definitive refresh rejection clears authentication; network/5xx/retryable failures retain +the expired session for retry while leaving that request unauthenticated; the +token endpoint is not called again for 30 seconds. A refresh token that would push +the session cookie past the 4 KB browser limit is not stored (the warning is +logged), and that session ends at ID-token expiry. + +Logout clears the local session and uses discovered `end_session_endpoint` with +`client_id` and `post_logout_redirect_uri=/oidc/logged-out`. Inertia logout +uses external navigation. Without that endpoint, logout stays local. There is +**no `id_token_hint`**, so an IdP may prompt for confirmation or retain its SSO +session. The public signed-out landing page does not auto-login. Close the +browser on a shared computer when provider logout cannot be guaranteed. + +The access token is not consumed, so **`at_hash` is not checked**. Tokens are +validated with 10 seconds of clock leeway. The client authenticates to the +token endpoint with `client_secret_post` only (supported by Keycloak, Entra, +Auth0 and Okta); IdPs that accept only `client_secret_basic` are not supported +yet. An IdP `error` reply to the callback (for example a cancelled sign-in) +answers 401 once the login state matched. A callback with a spent or unknown +state (browser Back after signing in, or a second tab whose login another tab +finished) sends a signed-in user on to `login_redirect_url` and answers anyone +else with 419, the framework's "expired, sign in again" page. `/oidc/logged-out` +likewise sends a signed-in visitor on to `login_redirect_url`. Entra support +is single-tenant only. The directory lists only users who have signed in, +reads cached names/emails and returns unknown roles (`None`), rather than +claiming no roles. Activity statistics count last login; account creation dates +are unknown. No remote IdP account listing is performed. diff --git a/docs/release.md b/docs/release.md index 84746d582..2fd845dc1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -275,6 +275,7 @@ Trusted Publishing is tied to the GitHub repo, not any personal account — so b | PyPI | `simple_module_file_storage` | [modules/file_storage/](../modules/file_storage/) | | PyPI | `simple_module_keycloak` | [modules/keycloak/](../modules/keycloak/) | | PyPI | `simple_module_mail` | [modules/mail/](../modules/mail/) | +| PyPI | `simple_module_oidc` | [modules/oidc/](../modules/oidc/) | | PyPI | `simple_module_permissions` | [modules/permissions/](../modules/permissions/) | | PyPI | `simple_module_settings` | [modules/settings/](../modules/settings/) | | PyPI | `simple_module_sharing` | [modules/sharing/](../modules/sharing/) | diff --git a/host/migrations/versions/c9d2e4f6a810_oidc_initial_schema.py b/host/migrations/versions/c9d2e4f6a810_oidc_initial_schema.py new file mode 100644 index 000000000..50b8b5fd4 --- /dev/null +++ b/host/migrations/versions/c9d2e4f6a810_oidc_initial_schema.py @@ -0,0 +1,33 @@ +"""Initial issuer-scoped OIDC identity cache (#192). + +Revision ID: c9d2e4f6a810 +Revises: None +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "c9d2e4f6a810" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = ("oidc",) +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "oidc_user_cache", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("issuer", sa.String(), nullable=False), + sa.Column("subject", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("full_name", sa.String(), nullable=True), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id", name="pk_oidc_user_cache"), + sa.UniqueConstraint("issuer", "subject", name="uq_oidc_user_cache_issuer_subject"), + ) + + +def downgrade() -> None: + op.drop_table("oidc_user_cache") diff --git a/host/tests/test_migration_graph.py b/host/tests/test_migration_graph.py index 9bac2ad1f..2acda3b37 100644 --- a/host/tests/test_migration_graph.py +++ b/host/tests/test_migration_graph.py @@ -46,6 +46,7 @@ "51b2058d8afd": "api_tokens", "168a2882f443": "keycloak", "f6a3b9d2e8c4": "keycloak", + "c9d2e4f6a810": "oidc", "e36ab09c4a92": "tenants", "f1a9c3d7b2e4": "tenants", "70786227af4c": "audit_log", @@ -54,7 +55,7 @@ "d263ea727663": "leases", # framework-owned sm_lease (#539) } -_ROOTS = {"77162e7b184b", "e36ab09c4a92", "f733c7938fd1", "d263ea727663"} +_ROOTS = {"77162e7b184b", "e36ab09c4a92", "f733c7938fd1", "d263ea727663", "c9d2e4f6a810"} # head revision -> module owning the tables it creates or alters. _HEADS = { @@ -62,6 +63,7 @@ "f5b1c3e8a207": "file_storage", # also carries ``api_tokens``, ``background_tasks``, ``users`` "f1a9c3d7b2e4": "tenants", "f6a3b9d2e8c4": "keycloak", + "c9d2e4f6a810": "oidc", "f733c7938fd1": "sharing", "d263ea727663": "leases", } @@ -77,6 +79,7 @@ "d263ea727663": "leases", "a625f0b7c912": "users", "f5b1c3e8a207": "file_storage", + "c9d2e4f6a810": "oidc", } # Known mislabels: revisions that inherit a label from an ancestor owned by a diff --git a/modules/oidc/README.md b/modules/oidc/README.md new file mode 100644 index 000000000..44a3932bc --- /dev/null +++ b/modules/oidc/README.md @@ -0,0 +1,201 @@ +# 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 when `api_audience` is configured. + +## 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", +] +``` + +Run `uv sync --all-packages` to install. + +## Usage + +Select `SM_AUTH_PROVIDER=oidc` explicitly. Keep `Auth` enabled; `Settings` UI +is optional. Exclude Users-dependent modules when selecting this provider. + +## Keycloak through the generic preset + +This is the primary worked example. The dedicated `keycloak` module is an +alternative; this configuration uses `oidc` throughout. + +Create an OpenID Connect client in the Keycloak realm: + +- Enable client authentication (a confidential client) and the standard flow. +- Set the valid redirect URI to `/api/oidc/auth/callback`. +- Set the valid post-logout redirect URI to `/oidc/logged-out`. +- Require PKCE with code challenge method **S256**. +- Add realm roles to the **ID token**. Keycloak 26 puts `realm_access.roles` + only in the access token by default, and this module reads roles from the ID + token. Under Client scopes -> `roles` -> Mappers -> `realm roles`, turn on + **Add to ID token** (token claim name `realm_access.roles`, multivalued), or + add a **User Realm Role** mapper with those settings to the client's + dedicated scope. Then assign the intended realm roles (for example `admin`) + to users. +- Keep the default `profile` and `email` client scopes: they supply + `preferred_username`, `name`, `email` and `email_verified` in the ID token. + +Use the realm's discovery document and the client's credentials: + +```dotenv +SM_AUTH_PROVIDER=oidc +SM_OIDC_PROVIDER=generic +SM_OIDC_DISCOVERY_URL=https:///realms//.well-known/openid-configuration +SM_OIDC_CLIENT_ID= +SM_OIDC_CLIENT_SECRET= +SM_OIDC_ROLES_CLAIM_PATH=realm_access.roles +``` + +`SM_OIDC_ROLES_CLAIM_PATH` seeds the roles path so the first Keycloak admin is +mapped to `admin` on first sign-in (the Settings UI that edits it needs an +admin). A value saved in the UI later wins. + +Here `` includes the application's scheme and host, and `` is the +Keycloak server (including any deployment prefix). Discovery is exactly +`/realms//.well-known/openid-configuration` when `` already +includes the scheme. Use HTTPS outside development. + +Set these **DB settings** under the `oidc` namespace, using Settings → OIDC when +the Settings UI is enabled: + +```json +{ + "role_mapping": {"admin": "admin", "user": "user"} +} +``` + +Choose external roles and app roles appropriate for your host. `role_mapping` +produces application roles; permission grants still come from the framework's +permission registry. No mapping grants `tenant:*` roles. Visit `/oidc/login` to +start sign-in. Cached identity is `(issuer, sub)` with a stable framework UUID. + +## Native Microsoft Entra ID + +Set the `entra` preset and your single-tenant registration details: + +```bash +SM_AUTH_PROVIDER=oidc +SM_OIDC_PROVIDER=entra +SM_OIDC_TENANT_ID= +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 **(issuer, `oid`)**, and +maps Entra **app roles** (the `roles` claim) to application roles 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_AUTH_PROVIDER=oidc +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_ROLES_CLAIM_PATH` | preset | Seeds `roles_claim_path`, so the first admin can sign in before the Settings UI is reachable. | + +Only the six environment variables above bootstrap process wiring, secrets and the first admin's roles path. +These variables seed defaults; explicit DB values can override them. +All other settings are DB-backed; bare environment names and the old claim +mapping environment variables are ignored. Provider, discovery, tenant, client +credentials and JWKS cache TTL changes require a restart. Claim and role mapping +and tenant-claim trust read the live settings state. + +| DB setting | Default | Behaviour | +|---|---|---| +| `api_audience` | empty | Bearer authentication disabled until configured; ID tokens always use `client_id`. | +| `api_allowed_client_ids` | empty | Clients besides `client_id` whose API tokens (`azp`, or `appid` on Entra v1) are accepted; a token naming no client is refused. | +| `trust_tenant_claim` | false | Opt in only to a trusted, validated `tenant_id` claim. Entra `tid` never selects an application tenant. Membership resolution stays authoritative. | +| `scope` | preset | Entra requests `openid email profile offline_access`. | +| `uid_claim` | `oid` for Entra, otherwise `sub` | Required identity claim; there is no fallback. Identities use unique (issuer, subject) UUID mappings. | +| `username_claim` / `name_claim` | `preferred_username` / `name` | Display name with fallback. | +| `email_claim` | `email` | Display email; only generic OIDC literal `email_verified=true` attests ownership. Entra never attests it. | +| `roles_claim_path` | `roles` for Entra, otherwise empty | Dotted claim path to role list. | +| `role_mapping` | admin/user map to themselves | External roles to application roles; `tenant:*` roles are filtered on login and restored sessions. | +| `login_redirect_url` | `/dashboard/` | Blank values normalize to the default. Saved deep links must be local paths. | +| `jwks_cache_ttl_seconds` | 3600 | Last-good signing keys retained on outage; unknown keys share a 30-second fetch cooldown. | + +## Login, sessions and logout + +Entra supports a single tenant GUID. `common`, `organizations` and `consumers` +are rejected. Unknown presets are validation errors. Discovery requires an issuer +matching the configured discovery URL and HTTPS outside development; different +endpoint hosts are supported. Discovery failure refuses boot outside development; +development retries lazily on login. + +Authorization uses code flow with S256 PKCE, state and constant-time nonce +validation. JWTs require `iss`, `sub`, `aud`, `exp` and `iat`, RS256 and the +expected audience. Additional audiences are rejected, and a supplied `azp` on a browser ID token must name this client (required with multiple +audiences); an API access token's `azp` is the calling client, checked against `api_allowed_client_ids` instead. The returned access token is unused, so `at_hash` is intentionally +not checked. + +Sign-in replaces user-bound session state and rotates CSRF. The cookie never +stores the ID token; it stores the ID-token expiry and an encrypted refresh token +when supplied. Expired or unbounded sessions renew using a fresh ID token, with +the same issuer and configured subject required. Definitive refresh rejection +clears authentication. Providers that do not return an `id_token` on refresh +sign the user out at expiry. Transport failures, 5xx and retryable 4xx preserve the +expired session for another attempt, while this request stays unauthenticated; +the token endpoint is not called again for 30 seconds, so a flapping IdP does not +stall every request. A refresh token too large for the 4 KB session cookie (Entra +can issue such tokens) is not stored, and that session ends at ID-token expiry. + +Logout always clears local state. The optional discovered logout endpoint gets +`client_id` and `post_logout_redirect_uri`, without `id_token_hint`. Register +`https:///oidc/logged-out` as the post-logout URI. Providers requiring +an ID-token hint may show a confirmation prompt or leave their SSO session active; +the local signed-out page never automatically signs in. + +The host migration `c9d2e4f6a810` (`oidc@head`, #192) creates the identity cache. +Apply host migrations with `upgrade heads` before deployment. + +## 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`. + +See [the full configuration reference](../../docs/modules/oidc.md) +for every setting, its environment support, tenancy policy and deployment limits. diff --git a/modules/oidc/oidc/__init__.py b/modules/oidc/oidc/__init__.py new file mode 100644 index 000000000..507267ca1 --- /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 000000000..0fea1aa99 --- /dev/null +++ b/modules/oidc/oidc/client.py @@ -0,0 +1,95 @@ +"""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 base64 +import hashlib +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 + + def build_authorization_url( + self, + redirect_uri: str, + nonce: str, + scope: str = "openid email profile", + *, + code_verifier: str, + ) -> 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, + "code_challenge_method": "S256", + "code_challenge": base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode("ascii")).digest() + ) + .rstrip(b"=") + .decode("ascii"), + } + url = f"{self._authorization_endpoint}?{urlencode(params)}" + return url, state + + async def exchange_code( + self, code: str, redirect_uri: str, *, code_verifier: str + ) -> dict[str, Any]: + return await self._token_request( + grant_type="authorization_code", + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + ) + + def build_logout_url( + self, + post_logout_redirect_uri: str, + ) -> 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 = { + "post_logout_redirect_uri": post_logout_redirect_uri, + "client_id": self._client_id, + } + separator = "&" if "?" in self._end_session_endpoint else "?" + return f"{self._end_session_endpoint}{separator}{urlencode(params)}" + + async def refresh_tokens(self, refresh_token: str) -> dict[str, Any]: + return await self._token_request(grant_type="refresh_token", refresh_token=refresh_token) + + async def _token_request(self, **grant: str) -> dict[str, Any]: + data = {**grant, "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() diff --git a/modules/oidc/oidc/constants.py b/modules/oidc/oidc/constants.py new file mode 100644 index 000000000..f666111f3 --- /dev/null +++ b/modules/oidc/oidc/constants.py @@ -0,0 +1,21 @@ +"""OIDC routes, page names and session keys.""" + +_MODULE_AUTH = "Auth" +_MODULE_SETTINGS = "Settings" +_PAGE_LOGIN = "Oidc/Login" +_PAGE_LOGGED_OUT = "Oidc/LoggedOut" +LOGIN_PATH = "/oidc/login" +LOGOUT_PATH = "/oidc/logout" +LOGGED_OUT_PATH = "/oidc/logged-out" +AUTH_LOGIN_PATH = "/api/oidc/auth/login" +AUTH_CALLBACK_PATH = "/api/oidc/auth/callback" +SESSION_STATE_KEY = "oidc_state" +SESSION_NONCE_KEY = "oidc_nonce" +SESSION_VERIFIER_KEY = "oidc_pkce_verifier" +SESSION_USER_KEY = "user_ctx" +SESSION_IDENTITY_KEY = "oidc_identity" +SESSION_REFRESH_TOKEN_KEY = "oidc_refresh_token" +SESSION_REFRESH_RETRY_AT_KEY = "oidc_refresh_retry_at" +DEFAULT_LOGIN_REDIRECT_URL = "/dashboard/" +# "Page expired": the framework renders it with a sign-in link (simple_module_hosting). +STALE_LOGIN_STATUS = 419 diff --git a/modules/oidc/oidc/contracts/__init__.py b/modules/oidc/oidc/contracts/__init__.py new file mode 100644 index 000000000..d14f5f01c --- /dev/null +++ b/modules/oidc/oidc/contracts/__init__.py @@ -0,0 +1 @@ +"""OIDC module contracts.""" diff --git a/modules/oidc/oidc/directory.py b/modules/oidc/oidc/directory.py new file mode 100644 index 000000000..182aae3d1 --- /dev/null +++ b/modules/oidc/oidc/directory.py @@ -0,0 +1,103 @@ +"""The oidc module's :class:`~simple_module_core.user_directory.UserDirectory`. + +The identity provider owns the accounts; this app only learns about a user when they sign +in, and caches the stable id it hands out in ``oidc_user_cache``. So the +directory knows **users who have signed in at least once**, and nothing about +their roles (those live in the IdP and arrive as token claims, never stored) +or when the IdP account was created. +""" + +from __future__ import annotations + +from collections.abc import Collection +from datetime import UTC, datetime, timedelta +from typing import Any + +from simple_module_core.user_directory import ( + DirectoryStats, + DirectoryUser, + chunked, + parse_user_ids, +) +from simple_module_db.user_directory_sql import name_or_email_contains, to_directory_user +from sqlalchemy import String, func, select +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql.expression import FunctionElement + +from oidc.models import OidcUserCache + + +class _UuidText(FunctionElement): + """A uuid column as the canonical hyphenated text every text id uses. + + ``oidc_user_cache.id`` is SQLAlchemy's ``Uuid``: native on Postgres, + where a plain cast already renders hyphenated, but 32 bare hex characters + on SQLite — which would never equal the ``str(uuid)`` an audit row stored. + """ + + type = String() + inherit_cache = True + + +@compiles(_UuidText) +def _uuid_text_default(element: Any, compiler: Any, **kw: Any) -> str: + return f"CAST({compiler.process(element.clauses, **kw)} AS VARCHAR)" + + +@compiles(_UuidText, "sqlite") +def _uuid_text_sqlite(element: Any, compiler: Any, **kw: Any) -> str: + col = compiler.process(element.clauses, **kw) + parts = [(1, 8), (9, 4), (13, 4), (17, 4), (21, 12)] + return " || '-' || ".join(f"substr({col}, {start}, {length})" for start, length in parts) + + +_COLUMNS = (OidcUserCache.id, OidcUserCache.email, OidcUserCache.full_name) + + +class OidcDirectory: + """Directory over ``oidc_user_cache``.""" + + #: Cache rows are not an audited model with a screen of their own, so actor + #: cells stay unlinked rather than pointing at a users page that is absent. + entity_type: str | None = None + + async def get_many( + self, db: Any, ids: Collection[str], *, with_roles: bool = False + ) -> dict[str, DirectoryUser]: + parsed = parse_user_ids(ids) + found: dict[str, DirectoryUser] = {} + for chunk in chunked(list(parsed)): + stmt = select(*_COLUMNS).where(OidcUserCache.id.in_(chunk)) + for row in (await db.execute(stmt)).all(): + for key in parsed[row.id]: + found[key] = to_directory_user(row, key) + return found + + async def search(self, db: Any, term: str | None, *, limit: int) -> list[DirectoryUser]: + stmt = select(*_COLUMNS).order_by(OidcUserCache.email).limit(limit) + if term and term.strip(): + stmt = stmt.where( + name_or_email_contains(OidcUserCache.full_name, OidcUserCache.email, term.strip()) + ) + return [to_directory_user(row, str(row.id)) for row in (await db.execute(stmt)).all()] + + def match_ids(self, term: str) -> Any | None: + return select(_UuidText(OidcUserCache.id)).where( + name_or_email_contains(OidcUserCache.full_name, OidcUserCache.email, term) + ) + + async def stats(self, db: Any, *, active_window: timedelta) -> DirectoryStats: + cutoff = datetime.now(UTC) - active_window + total, active = ( + await db.execute( + select( + func.count(), + func.count().filter(OidcUserCache.last_login_at >= cutoff), + ).select_from(OidcUserCache) + ) + ).one() + # The cache row is written at first sign-in, not account creation. + return DirectoryStats(total=total, active_since=active, created_this_month=None) + + +__all__ = ["OidcDirectory"] diff --git a/modules/oidc/oidc/discovery.py b/modules/oidc/oidc/discovery.py new file mode 100644 index 000000000..cc71f7473 --- /dev/null +++ b/modules/oidc/oidc/discovery.py @@ -0,0 +1,72 @@ +"""Validated OIDC discovery metadata; endpoint hosts may legitimately differ.""" + +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import urlsplit + +import httpx + + +def _validate_url(value: object, *, development: bool) -> str: + if not isinstance(value, str) or not value or any(c.isspace() for c in value): + raise ValueError("OIDC metadata URL must be a non-empty URL") + parsed = urlsplit(value) + if ( + parsed.scheme not in ({"https", "http"} if development else {"https"}) + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + ): + raise ValueError("OIDC metadata requires HTTPS URLs without credentials or fragments") + return value + + +@dataclass(frozen=True) +class OidcMetadata: + issuer: str + authorization_endpoint: str + token_endpoint: str + jwks_uri: str + end_session_endpoint: str = "" + + @classmethod + def from_document( + cls, doc: dict, *, expected_issuer: str | None = None, development: bool = False + ) -> OidcMetadata: + if not isinstance(doc, dict): + raise ValueError("OIDC discovery document must be an object") + required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri") + missing = [key for key in required if not doc.get(key)] + if missing: + raise ValueError( + f"OIDC discovery document missing required fields: {', '.join(missing)}" + ) + values = {key: _validate_url(doc[key], development=development) for key in required} + issuer = urlsplit(values["issuer"]) + if issuer.query or "{" in values["issuer"] or "}" in values["issuer"]: + raise ValueError("OIDC issuer must be concrete and have no query") + # Compared without one trailing slash: issuers such as Auth0's end in + # "/" while the discovery URL is built without it. Tokens are still + # validated against the issuer exactly as the document states it. + if expected_issuer is not None and values["issuer"].rstrip("/") != expected_issuer.rstrip( + "/" + ): + raise ValueError("OIDC discovery issuer does not match configured issuer") + logout = doc.get("end_session_endpoint", "") + if logout != "": + logout = _validate_url(logout, development=development) + return cls(**values, end_session_endpoint=logout) + + +async def fetch_metadata( + discovery_url: str, *, expected_issuer: str | None = None, development: bool = False +) -> OidcMetadata: + _validate_url(discovery_url, development=development) + async with httpx.AsyncClient() as client: + resp = await client.get(discovery_url, timeout=10) + resp.raise_for_status() + return OidcMetadata.from_document( + resp.json(), expected_issuer=expected_issuer, development=development + ) diff --git a/modules/oidc/oidc/endpoints/__init__.py b/modules/oidc/oidc/endpoints/__init__.py new file mode 100644 index 000000000..07588a58f --- /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 000000000..bfbe32f95 --- /dev/null +++ b/modules/oidc/oidc/endpoints/api.py @@ -0,0 +1,132 @@ +"""OIDC login transaction: PKCE, nonce, session rotation and bounded authority.""" + +from __future__ import annotations + +import logging +import secrets +import time + +from fastapi import APIRouter, HTTPException, Request +from simple_module_core.redirect_safety import SESSION_NEXT_KEY, safe_next_or_none +from simple_module_hosting.csrf import rotate_csrf_token +from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, sign_in_preserved_keys +from starlette.responses import RedirectResponse + +from oidc.constants import ( + SESSION_IDENTITY_KEY, + SESSION_NONCE_KEY, + SESSION_STATE_KEY, + SESSION_USER_KEY, + SESSION_VERIFIER_KEY, + STALE_LOGIN_STATUS, +) +from oidc.runtime import is_signed_in, require_client +from oidc.tokens import store_refresh_token + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["oidc-auth"]) + + +@router.get("/login", name="oidc_auth_login") +async def oidc_login(request: Request): + client = await require_client(request) + nonce = secrets.token_urlsafe(32) + verifier = secrets.token_urlsafe(64) + url, state = client.build_authorization_url( + redirect_uri=str(request.url_for("oidc_auth_callback")), + nonce=nonce, + scope=request.app.state.oidc.settings.scope, + code_verifier=verifier, + ) + request.session[SESSION_STATE_KEY] = state + request.session[SESSION_NONCE_KEY] = nonce + request.session[SESSION_VERIFIER_KEY] = verifier + return RedirectResponse(url, status_code=302) + + +@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") + # The state is compared before the login transaction is consumed: a reply + # that does not carry it (a forged link) leaves the pending sign-in alone. + expected_state = request.session.get(SESSION_STATE_KEY) + if ( + not state + or not isinstance(expected_state, str) + or not secrets.compare_digest(state.encode(), expected_state.encode()) + ): + # Browser Back after signing in, or a second tab whose login another + # tab finished, lands here with a spent state. A signed-in user just + # continues; anyone else gets the "expired, sign in again" page (419) + # rather than a raw error, and no automatic retry that could loop. + if is_signed_in(request.session): + return RedirectResponse(request.app.state.oidc.settings.login_redirect_url, 303) + raise HTTPException(status_code=STALE_LOGIN_STATUS, detail="Sign-in expired; start again") + request.session.pop(SESSION_STATE_KEY, None) + expected_nonce = request.session.pop(SESSION_NONCE_KEY, None) + verifier = request.session.pop(SESSION_VERIFIER_KEY, None) + # Past this point the reply belongs to this login, and every outcome, + # including an ``error`` reply (access_denied, login_required, ...), spends it. + if request.query_params.get("error"): + raise HTTPException(status_code=401, detail="Sign-in was refused by the identity provider") + if not code: + raise HTTPException(status_code=400, detail="Invalid OIDC state") + if not isinstance(expected_nonce, str) or not expected_nonce: + raise HTTPException(status_code=400, detail="Missing OIDC nonce") + if not isinstance(verifier, str) or not verifier: + raise HTTPException(status_code=400, detail="Missing PKCE verifier") + client = await require_client(request) + try: + tokens = await client.exchange_code( + code=code, + redirect_uri=str(request.url_for("oidc_auth_callback")), + code_verifier=verifier, + ) + except Exception: + logger.warning("OIDC token exchange failed") + raise HTTPException(status_code=502, detail="Token exchange failed") from None + if not isinstance(tokens, dict): + logger.warning("OIDC token endpoint returned a non-object response") + raise HTTPException(status_code=502, detail="Token exchange failed") + s = request.app.state.oidc.settings + cache = request.app.state.oidc.jwks_cache + claims = ( + await cache.validate_jwt(tokens.get("id_token", ""), audience=s.client_id) + if cache + else None + ) + nonce = claims.get("nonce") if claims else None + if not isinstance(nonce, str) or not secrets.compare_digest( + nonce.encode(), expected_nonce.encode() + ): + raise HTTPException(status_code=401, detail="ID token validation failed") + expires_at = claims.get("exp") + if type(expires_at) is not int or expires_at <= time.time(): + raise HTTPException(status_code=401, detail="Invalid ID token expiry") + provider = request.app.state.auth.auth_provider + try: + identity = provider._identity(claims) + except ValueError: + raise HTTPException(status_code=401, detail="Missing OIDC identity") from None + cache_id = await provider._upsert_user_cache(request, claims) + if cache_id is None: + raise HTTPException(status_code=503, detail="OIDC identity unavailable") + user_ctx = provider._claims_to_user_context(claims, cache_id=cache_id) + next_url = ( + safe_next_or_none(request.session.pop(SESSION_NEXT_KEY, None)) or s.login_redirect_url + ) + preserved = { + key: request.session[key] + for key in sign_in_preserved_keys(request.app) + if key in request.session + } + request.session.clear() + request.session.update(preserved) + rotate_csrf_token(request) + request.session[SESSION_USER_KEY] = user_ctx.to_session_dict() + request.session[SESSION_IDENTITY_KEY] = list(identity) + request.session[SESSION_EXPIRES_AT_KEY] = expires_at + if tokens.get("refresh_token"): + store_refresh_token(request.app, request.session, tokens["refresh_token"]) + 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 000000000..8bd1be6a2 --- /dev/null +++ b/modules/oidc/oidc/endpoints/views.py @@ -0,0 +1,43 @@ +"""OIDC interstitials and RP-initiated logout.""" + +import logging + +from fastapi import APIRouter, Request +from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_inertia.response import location +from starlette.responses import RedirectResponse + +from oidc.constants import _PAGE_LOGGED_OUT, _PAGE_LOGIN, LOGGED_OUT_PATH +from oidc.runtime import is_signed_in + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["oidc-views"]) + + +@router.get("/login") +async def login_page(inertia: InertiaDep): + return await inertia.render(_PAGE_LOGIN) + + +@router.get("/logged-out") +async def logged_out_page(request: Request, inertia: InertiaDep): + # Only meaningful once the session is gone; a signed-in visitor is not + # told they were signed out. + if is_signed_in(request.session): + return RedirectResponse(request.app.state.oidc.settings.login_redirect_url, 303) + return await inertia.render(_PAGE_LOGGED_OUT) + + +@router.post("/logout") +async def logout(request: Request): + request.session.clear() + destination = f"{str(request.base_url).rstrip('/')}{LOGGED_OUT_PATH}" + client = request.app.state.oidc.client + if client is not None: + try: + destination = client.build_logout_url(post_logout_redirect_uri=destination) + except Exception: + logger.warning("OIDC logout unavailable; local session cleared") + if request.headers.get("X-Inertia"): + return location(destination) + return RedirectResponse(destination, status_code=303) diff --git a/modules/oidc/oidc/jwks.py b/modules/oidc/oidc/jwks.py new file mode 100644 index 000000000..c6e5c8f2b --- /dev/null +++ b/modules/oidc/oidc/jwks.py @@ -0,0 +1,177 @@ +"""JWKS key cache and JWT validation for OIDC tokens.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any +from urllib.parse import urlsplit + +import httpx +import jwt +from jwt.algorithms import RSAAlgorithm + +logger = logging.getLogger(__name__) +_CLOCK_SKEW_SECONDS = 10 +_MIN_REFETCH_INTERVAL_SECONDS = 30 + + +class JWKSCache: + """Caches OIDC's public signing keys and validates JWTs. + + Unknown keys trigger a refresh, with a per-cache cooldown and lock to + bound unauthenticated fetches while still discovering key rotation. + """ + + 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 + self._last_fetch_attempt: float | None = None + self._fetch_lock = asyncio.Lock() + + async def validate_jwt( + self, + token: str, + *, + audience: str | None = None, + ) -> dict[str, Any] | None: + """Decode and validate a JWT. Returns claims dict or None.""" + try: + unverified = jwt.get_unverified_header(token) + except jwt.PyJWTError: + return None + + kid = unverified.get("kid") + if not isinstance(kid, str) or not kid: + return None + + key = await self._get_key(kid) + if key is None: + return None + + return self._decode(token, key, audience or self._audience) + + def _decode(self, token: str, key: Any, audience: str) -> dict[str, Any] | None: + try: + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + issuer=self._issuer, + audience=audience, + options={"require": ["iss", "sub", "aud", "exp", "iat"]}, + # An app clock a few seconds behind the IdP would otherwise + # reject fresh tokens as issued in the future. + leeway=_CLOCK_SKEW_SECONDS, + ) + if not isinstance(claims["sub"], str) or not claims["sub"].strip(): + return None + if any(type(claims[k]) is not int for k in ("exp", "iat")): + return None + audiences = claims["aud"] + audiences = [audiences] if isinstance(audiences, str) else audiences + if not isinstance(audiences, list) or any( + not isinstance(a, str) or not a for a in audiences + ): + return None + if set(audiences) - {audience}: + return None + # ``azp`` names the client a token was issued to. For this client's + # ID token that is us; for an API access token it is the *calling* + # client (Entra v2 always sets it), which is never ours to pin. + azp = claims.get("azp") + if ( + audience == self._audience + and (len(audiences) > 1 or azp is not None) + and azp != self._audience + ): + return None + return claims + except jwt.PyJWTError: + logger.debug("OIDC JWT validation failed") + return None + + async def _get_key(self, kid: str) -> Any | None: + if self._is_stale() or kid not in self._keys: + async with self._fetch_lock: + await self._fetch_keys() + return self._keys.get(kid) + + def _is_stale(self) -> bool: + return time.monotonic() - self._fetched_at > self._ttl + + async def _fetch_keys(self) -> None: + now = time.monotonic() + if ( + self._last_fetch_attempt is not None + and now - self._last_fetch_attempt < _MIN_REFETCH_INTERVAL_SECONDS + ): + return + # Failed attempts must also cool down; keep the last good keys on failure. + self._last_fetch_attempt = now + 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.warning("Failed to fetch OIDC JWKS") + return + + new_keys: dict[str, Any] = {} + if not isinstance(jwks_data, dict) or not isinstance(jwks_data.get("keys"), list): + return + for key_data in jwks_data["keys"]: + if not isinstance(key_data, dict) or not self._is_rsa_signing_key(key_data): + continue + key_issuer = key_data.get("issuer", self._issuer) + if isinstance(key_issuer, str): + # Entra may template its JWK issuer even on tenant-specific discovery. + tenant = urlsplit(self._issuer).path.strip("/").split("/")[0] + key_issuer = key_issuer.replace("{tenantid}", tenant) + if key_issuer != self._issuer: + continue + kid = key_data["kid"] # _is_rsa_signing_key guarantees a non-empty kid + try: + new_keys[kid] = RSAAlgorithm.from_jwk(key_data) + except Exception: + logger.warning("Failed to parse JWK kid=%s", kid) + if not new_keys: + return + 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 isinstance(key_data.get("kid"), str) + or not key_data["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") + if use and use != "sig": + return False + operations = key_data.get("key_ops") + return operations is None or (isinstance(operations, list) and "verify" in operations) diff --git a/modules/oidc/oidc/locales/de.json b/modules/oidc/oidc/locales/de.json new file mode 100644 index 000000000..ac9048295 --- /dev/null +++ b/modules/oidc/oidc/locales/de.json @@ -0,0 +1,14 @@ +{ + "login": { + "redirecting": "Weiterleitung zum Identitätsanbieter…", + "manual": "Keine Weiterleitung? Manuell fortfahren" + }, + "logout": { + "title": "Abgemeldet", + "message": "Sie wurden erfolgreich von dieser App abgemeldet.", + "sign_in_again": "Erneut anmelden" + }, + "nav": { + "logout": "Abmelden" + } +} diff --git a/modules/oidc/oidc/locales/en.json b/modules/oidc/oidc/locales/en.json new file mode 100644 index 000000000..70ca22534 --- /dev/null +++ b/modules/oidc/oidc/locales/en.json @@ -0,0 +1,14 @@ +{ + "login": { + "redirecting": "Redirecting to identity provider\u2026", + "manual": "Not redirected? Continue manually" + }, + "logout": { + "title": "Signed Out", + "message": "You have been signed out successfully.", + "sign_in_again": "Sign in again" + }, + "nav": { + "logout": "Logout" + } +} diff --git a/modules/oidc/oidc/models.py b/modules/oidc/oidc/models.py new file mode 100644 index 000000000..c710a4bce --- /dev/null +++ b/modules/oidc/oidc/models.py @@ -0,0 +1,28 @@ +"""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 simple_module_db.types import AwareDateTime +from sqlalchemy import UniqueConstraint +from sqlmodel import Field + +Base = create_module_base("oidc") + + +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) + __table_args__ = ( + UniqueConstraint("issuer", "subject", name="uq_oidc_user_cache_issuer_subject"), + ) + + issuer: str + subject: str + email: str = "" + full_name: str | None = None + last_login_at: datetime | None = Field(default=None, sa_type=AwareDateTime()) diff --git a/modules/oidc/oidc/module.py b/modules/oidc/oidc/module.py new file mode 100644 index 000000000..17f03c365 --- /dev/null +++ b/modules/oidc/oidc/module.py @@ -0,0 +1,87 @@ +"""Generic OIDC authentication module (Entra, Auth0, Okta, ...).""" + +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.module_settings import register_module_settings +from simple_module_core.user_directory import set_user_directory + +from oidc.constants import ( + _MODULE_AUTH, + _MODULE_SETTINGS, + AUTH_CALLBACK_PATH, + AUTH_LOGIN_PATH, + LOGGED_OUT_PATH, + LOGIN_PATH, + LOGOUT_PATH, +) + +if TYPE_CHECKING: + from fastapi import APIRouter, FastAPI + + +class OidcModule(ModuleBase): + meta = ModuleMeta( + name="Oidc", + route_prefix="/api/oidc", + view_prefix="/oidc", + depends_on=[_MODULE_AUTH], + optional_depends_on=[_MODULE_SETTINGS], + ) + _is_auth_provider = True + + def register_settings(self, app: FastAPI) -> None: + from oidc.directory import OidcDirectory + from oidc.provider import OidcAuthProvider + from oidc.settings import OidcSettings + from oidc.state import OidcState + + register_module_settings( + app, + "oidc", + OidcSettings, + lambda s: OidcState(settings=s), + ) + + set_user_directory(app, OidcDirectory()) + app.state.auth.auth_provider = OidcAuthProvider( + app.state.oidc.settings, state=app.state.oidc + ) + + def register_menu_items(self, registry: MenuRegistry) -> None: + registry.add( + MenuItem( + label="Logout", + label_key="oidc.nav.logout", + url=LOGOUT_PATH, + 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) + + def register_public_routes(self, registry) -> None: + for path in (LOGIN_PATH, LOGGED_OUT_PATH, AUTH_LOGIN_PATH, AUTH_CALLBACK_PATH): + registry.add_exact(path, methods={"GET"}) + registry.add_exact(LOGOUT_PATH, methods={"POST"}) + + async def on_startup(self, app: FastAPI) -> None: + from oidc.runtime import initialize + + await initialize(app) + + 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 000000000..c2c3ad1f6 --- /dev/null +++ b/modules/oidc/oidc/pages/LoggedOut.tsx @@ -0,0 +1,15 @@ +import { Link } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; + +export default function LoggedOut() { + const { t } = useT(); + return ( +
+

{t(keys.oidc.logout.title)}

+

{t(keys.oidc.logout.message)}

+ + {t(keys.oidc.logout.sign_in_again)} + +
+ ); +} diff --git a/modules/oidc/oidc/pages/Login.tsx b/modules/oidc/oidc/pages/Login.tsx new file mode 100644 index 000000000..98b88dfb1 --- /dev/null +++ b/modules/oidc/oidc/pages/Login.tsx @@ -0,0 +1,20 @@ +import { keys, useT } from '@simple-module-py/i18n'; +import { useEffect } from 'react'; + +const START_LOGIN_URL = '/api/oidc/auth/login'; + +export default function Login() { + const { t } = useT(); + useEffect(() => { + window.location.assign(START_LOGIN_URL); + }, []); + + return ( +
+

{t(keys.oidc.login.redirecting)}

+ + {t(keys.oidc.login.manual)} + +
+ ); +} diff --git a/modules/oidc/oidc/persistence.py b/modules/oidc/oidc/persistence.py new file mode 100644 index 000000000..f26301992 --- /dev/null +++ b/modules/oidc/oidc/persistence.py @@ -0,0 +1,75 @@ +"""Durable issuer-scoped UUIDs, with race recovery and throttled activity writes.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta + +from simple_module_db import independent_session +from sqlmodel import col +from starlette.requests import Request + +logger = logging.getLogger(__name__) +_LAST_LOGIN_WRITE_INTERVAL = timedelta(minutes=5) + + +class CachePersistence: + async def _upsert_user_cache( + self, request: Request, claims: dict, *, throttle_login: bool = False + ) -> str | None: + try: + from sqlalchemy import or_, select, update + from sqlalchemy.exc import IntegrityError + + from oidc.models import OidcUserCache + + issuer, subject = self._identity(claims) + now = datetime.now(UTC) + email = self._email(claims) + async with independent_session(request.app) as db: + stmt = select(OidcUserCache).where( + col(OidcUserCache.issuer) == issuer, col(OidcUserCache.subject) == subject + ) + row = (await db.execute(stmt)).scalar_one_or_none() + if row is None: + try: + async with db.begin_nested(): + row = OidcUserCache( + issuer=issuer, + subject=subject, + email=email or "", + full_name=self._display_name(claims), + last_login_at=now, + ) + db.add(row) + await db.flush() + except IntegrityError: + # Another request inserted this sub after our SELECT. + row = (await db.execute(stmt)).scalar_one() + + if email is not None: + row.email = email # an absent claim keeps the last known address + name = self._display_name(claims) + if name or row.full_name is None: + row.full_name = name # an absent claim keeps the last known name + if not throttle_login: + row.last_login_at = now + else: + cutoff = now - _LAST_LOGIN_WRITE_INTERVAL + if row.last_login_at is None or row.last_login_at <= cutoff: + # Recheck in SQL so concurrent bearer requests share one touch. + await db.execute( + update(OidcUserCache) + .where( + col(OidcUserCache.id) == row.id, + or_( + col(OidcUserCache.last_login_at).is_(None), + col(OidcUserCache.last_login_at) <= cutoff, + ), + ) + .values(last_login_at=now) + ) + return str(row.id) + except Exception: + logger.warning("Failed to persist OIDC identity; refusing authentication") + return None diff --git a/modules/oidc/oidc/presets.py b/modules/oidc/oidc/presets.py new file mode 100644 index 000000000..97b7f205e --- /dev/null +++ b/modules/oidc/oidc/presets.py @@ -0,0 +1,59 @@ +"""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 goes in ``tenant_id`` (a domain name would not match the GUID issuer + # the discovery document reports). 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", + scope="openid email profile offline_access", + 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 a supported preset; typos must not silently change security policy.""" + if name not in PRESETS: + raise ValueError(f"Unknown OIDC preset: {name}") + return PRESETS[name] diff --git a/modules/oidc/oidc/provider.py b/modules/oidc/oidc/provider.py new file mode 100644 index 000000000..a9c1ef734 --- /dev/null +++ b/modules/oidc/oidc/provider.py @@ -0,0 +1,211 @@ +"""OidcAuthProvider — resolves users from Oidc JWTs or session.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from auth.contracts.schemas import UserContext +from auth.contracts.tokens import AUTH_KIND_BEARER, is_api_token, parse_bearer +from simple_module_core.tenancy import is_tenant_role +from simple_module_db import is_valid_tenant_id +from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY, session_has_expired +from starlette.requests import Request + +from oidc.constants import LOGIN_PATH, LOGOUT_PATH, SESSION_USER_KEY +from oidc.persistence import CachePersistence +from oidc.renewal import SessionRenewal + +if TYPE_CHECKING: + from oidc.jwks import JWKSCache + from oidc.settings import OidcSettings + from oidc.state import OidcState + +logger = logging.getLogger(__name__) + + +class OidcAuthProvider(CachePersistence, SessionRenewal): + """Generic OIDC authentication with issuer-scoped identities.""" + + name = "oidc" + _is_auth_provider = True + + def __init__( + self, settings: OidcSettings | None = None, *, state: OidcState | None = None + ) -> None: + self._initial_settings = settings + # The module's state object: a settings save or the boot-time DB + # hydration swaps ``state.settings``, so reading through it keeps + # ``trust_tenant_claim`` / ``role_mapping`` edits live. + self._state = state + self.jwks_cache: JWKSCache | None = None + self._warned_tenant_roles: set[str] = set() + + @property + def _settings(self) -> OidcSettings | None: + live = getattr(self._state, "settings", None) + return live if live is not None else self._initial_settings + + async def resolve_user(self, request: Request) -> UserContext | None: + credential = parse_bearer(request.headers.get("authorization")) + if credential is not None: + # An ``api_tokens`` credential is not a JWT: skip the parse and + # leave it to the principal resolver that owns it. + if is_api_token(credential): + return None + return await self._resolve_bearer(request, credential) + + session = request.scope.get("session", {}) + # Renew authority from the IdP at expiry, without interrupting the request. + if type(session.get(SESSION_EXPIRES_AT_KEY)) is not int or session_has_expired(session): + return await self._refresh_session(request) + ctx = UserContext.from_session_dict(session.get(SESSION_USER_KEY)) + if ctx is not None: + # The cookie froze what was decided at login; re-decide with the + # current settings, so turning trust_tenant_claim off takes effect + # on sessions that already exist. + ctx.tenant_id = self._trusted_tenant(ctx.tenant_id) + ctx.roles = self._without_tenant_roles(ctx.roles) + return ctx + + def get_login_url(self, request: Request | None, next_url: str | None = None) -> str: + return LOGIN_PATH + + def get_logout_url(self, request: Request | None) -> str: + return LOGOUT_PATH + + def is_bearer_request(self, request: Request | None) -> bool: + if request is None: + return False + return parse_bearer(request.headers.get("authorization")) is not None + + async def _resolve_bearer(self, request: Request, token: str) -> UserContext | None: + if self.jwks_cache is None or not self._settings or not self._settings.api_audience: + logger.debug("OIDC bearer authentication unavailable") + return None + claims = await self.jwks_cache.validate_jwt(token, audience=self._settings.api_audience) + if claims is None or not self._allowed_api_client(claims): + return None + + cache_id = await self._upsert_user_cache(request, claims, throttle_login=True) + if cache_id is None: + return None + ctx = self._claims_to_user_context(claims, cache_id=cache_id) + # Full rights, but a bearer credential: it may not manage API tokens + # (``auth.contracts.tokens.AUTH_KIND_BEARER``). The browser sign-in + # builds the same context from the callback and keeps ``"session"``. + ctx.auth_kind = AUTH_KIND_BEARER + return ctx + + def _allowed_api_client(self, claims: dict[str, Any]) -> bool: + """Whether the client that obtained this API token may call us. + + Fails closed: a token naming no client (no ``azp``/``appid``) is refused. + """ + s = self._settings + client = claims.get("azp") or claims.get("appid") + if not isinstance(client, str) or not client or not s: + return False + if client == s.client_id or client in s.api_allowed_client_ids: + return True + logger.info("OIDC bearer token from a client not in api_allowed_client_ids") + return False + + 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 else None + mapping = s.role_mapping if s else {} + mapped = [mapping[r] for r in (roles_raw or []) if isinstance(r, str) and r in mapping] + return UserContext( + id=cache_id, + email=self._email(claims) or "", + email_verified=bool( + (not s or s.provider != "entra") and claims.get("email_verified") is True + ), + name=self._display_name(claims), + roles=self._without_tenant_roles(mapped), + tenant_id=self._trusted_tenant(claims.get("tenant_id")), + ) + + def _email(self, claims: dict[str, Any]) -> str | None: + """The configured email claim, or ``None`` when absent or not a string.""" + value = claims.get(self._settings.email_claim if self._settings else "email") + return value if isinstance(value, str) else None + + def _display_name(self, claims: dict[str, Any]) -> str: + s = self._settings + for name in ( + s.username_claim if s else "preferred_username", + s.name_claim if s else "name", + ): + value = claims.get(name) + if isinstance(value, str) and value: + return value + return "" + + def _subject(self, claims: dict[str, Any]) -> str: + key = self._settings.uid_claim if self._settings else "sub" + subject = claims.get(key) + if not isinstance(subject, str) or not subject.strip(): + raise ValueError("Missing configured OIDC identity claim") + return subject + + def _identity(self, claims: dict[str, Any]) -> tuple[str, str]: + issuer = claims.get("iss") + if not isinstance(issuer, str) or not issuer: + raise ValueError("Missing OIDC issuer") + return issuer, self._subject(claims) + + def _trusted_tenant(self, value: Any) -> str | None: + """The ``tenant_id`` claim, if the operator trusts it and it is well-formed. + + The claim is the IdP's word, not ours: it is ignored unless the operator + vouches for the realm mapper (``trust_tenant_claim``), and even then a + registered tenant resolver overrides it every request. It must also pass + the same id check as any other tenant id taken from outside. + """ + if value is None or not (self._settings and self._settings.trust_tenant_claim): + return None + if not is_valid_tenant_id(value): + logger.warning("Ignoring malformed tenant_id claim %r", value) + return None + return value + + def _without_tenant_roles(self, roles: list[str]) -> list[str]: + """Drop ``tenant:*`` roles: only the tenants module may grant them, for + the active tenant. A ``role_mapping`` entry producing one would + otherwise hand every user that tenant role in every tenant.""" + kept = [] + for role in roles: + if not is_tenant_role(role): + kept.append(role) + elif role not in self._warned_tenant_roles: + self._warned_tenant_roles.add(role) + logger.warning( + "Oidc role_mapping produced tenant role %r; ignored " + "(tenant roles come from memberships only)", + role, + ) + return kept + + +def _extract_nested(data: dict, path: str) -> list[str] | None: + # A claim named exactly ``path`` wins: Auth0/Okta namespace custom claims + # as URLs (``https://example.com/roles``), which contain dots. + if path in data: + value = data[path] + return value if isinstance(value, list) else 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 000000000..e69de29bb diff --git a/modules/oidc/oidc/renewal.py b/modules/oidc/oidc/renewal.py new file mode 100644 index 000000000..4870130ef --- /dev/null +++ b/modules/oidc/oidc/renewal.py @@ -0,0 +1,102 @@ +"""Renew browser authority at ID-token expiry without retaining the ID token.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +import httpx +from auth.contracts.schemas import UserContext +from simple_module_hosting.session import SESSION_EXPIRES_AT_KEY +from starlette.requests import Request + +from oidc.constants import ( + SESSION_IDENTITY_KEY, + SESSION_REFRESH_RETRY_AT_KEY, + SESSION_REFRESH_TOKEN_KEY, + SESSION_USER_KEY, +) +from oidc.runtime import require_client +from oidc.tokens import open_refresh_token, store_refresh_token + +logger = logging.getLogger(__name__) +_RETRYABLE_STATUSES = frozenset({408, 425, 429}) +# After a transient failure, don't call the token endpoint again for this long: +# every request on the session would otherwise wait up to its 10 s timeout. +_RETRY_AFTER_SECONDS = 30 + + +class SessionRenewal: + @staticmethod + def _forget_session(session: Any) -> None: + for key in ( + SESSION_USER_KEY, + SESSION_REFRESH_TOKEN_KEY, + SESSION_EXPIRES_AT_KEY, + SESSION_IDENTITY_KEY, + SESSION_REFRESH_RETRY_AT_KEY, + ): + session.pop(key, None) + + async def _refresh_session(self, request: Request) -> UserContext | None: + """Renew an expired session with a refresh grant; sign it out if that is refused. + + A transient failure (network error, 5xx) signs this request out but keeps + the session, so a request after ``_RETRY_AFTER_SECONDS`` retries instead + of losing the sign-in; requests in between are signed out without a call. + """ + session = request.scope.get("session", {}) + sealed = session.get(SESSION_REFRESH_TOKEN_KEY) + refresh_token = open_refresh_token(request.app, sealed) + old_ctx = UserContext.from_session_dict(session.get(SESSION_USER_KEY)) + if not refresh_token or old_ctx is None or self.jwks_cache is None: + self._forget_session(session) + return None + if getattr(request.state, "oidc_refresh_attempted", False): + return None + retry_at = session.get(SESSION_REFRESH_RETRY_AT_KEY) + if type(retry_at) is int and retry_at > time.time(): + return None + request.state.oidc_refresh_attempted = True + try: + tokens = await (await require_client(request)).refresh_tokens(refresh_token) + except (httpx.TransportError, httpx.HTTPStatusError) as exc: + # Never log the exception text or body: token endpoints can echo credentials. + status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None + logger.warning("OIDC session refresh failed (status=%s)", status) + if status is None or status >= 500 or status in _RETRYABLE_STATUSES: + session[SESSION_REFRESH_RETRY_AT_KEY] = int(time.time()) + _RETRY_AFTER_SECONDS + return None + self._forget_session(session) + return None + except Exception: + logger.warning("OIDC session refresh failed") + self._forget_session(session) + return None + claims = await self.jwks_cache.validate_jwt( + tokens.get("id_token", ""), audience=self._settings.client_id + ) + expires_at = claims.get("exp") if claims else None + if ( + not claims + or not claims.get("sub") + or type(expires_at) is not int + or expires_at <= time.time() + ): + self._forget_session(session) + return None + try: + identity = list(self._identity(claims)) + except ValueError: + self._forget_session(session) + return None + if identity != session.get(SESSION_IDENTITY_KEY): + self._forget_session(session) + return None + ctx = self._claims_to_user_context(claims, cache_id=old_ctx.id) + session[SESSION_USER_KEY] = ctx.to_session_dict() + session.pop(SESSION_REFRESH_RETRY_AT_KEY, None) + session[SESSION_EXPIRES_AT_KEY] = expires_at + store_refresh_token(request.app, session, tokens.get("refresh_token") or refresh_token) + return ctx diff --git a/modules/oidc/oidc/runtime.py b/modules/oidc/oidc/runtime.py new file mode 100644 index 000000000..e4532f9c8 --- /dev/null +++ b/modules/oidc/oidc/runtime.py @@ -0,0 +1,69 @@ +"""Initialize one coherent discovery/client/JWKS configuration per process.""" + +import logging + +from fastapi import HTTPException, Request + +from oidc.client import OIDCClient +from oidc.constants import SESSION_USER_KEY +from oidc.discovery import fetch_metadata +from oidc.jwks import JWKSCache + +logger = logging.getLogger(__name__) +_DISCOVERY_SUFFIX = "/.well-known/openid-configuration" + + +async def initialize(app) -> bool: + state = app.state.oidc + development = app.state.sm.settings.environment == "development" + async with state.initialization_lock: + if state.client is not None: + return True + s = state.settings + try: + if not s.discovery_url or not s.client_id: + raise ValueError("OIDC discovery URL and client ID are required") + if _DISCOVERY_SUFFIX not in s.discovery_url: + raise ValueError("OIDC discovery URL must identify its issuer") + # OIDC discovery uses either issuer + suffix or suffix + issuer path. + prefix, suffix = s.discovery_url.split(_DISCOVERY_SUFFIX, 1) + expected_issuer = prefix + suffix + metadata = await fetch_metadata( + s.discovery_url, expected_issuer=expected_issuer, development=development + ) + cache = JWKSCache( + metadata.jwks_uri, + ttl_seconds=s.jwks_cache_ttl_seconds, + issuer=metadata.issuer, + audience=s.client_id, + ) + 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, + ) + except Exception as exc: + if not development: + raise RuntimeError("OIDC discovery failed; authentication unavailable") from exc + logger.warning("OIDC discovery unavailable; login will retry") + return False + state.metadata, state.jwks_cache, state.client = metadata, cache, client + app.state.auth.auth_provider.jwks_cache = cache + return True + + +async def require_client(request: Request) -> OIDCClient: + """The configured client, initialising lazily; 503 when the provider is unavailable.""" + if request.app.state.oidc.client is None: + await initialize(request.app) + client = request.app.state.oidc.client + if client is None: + raise HTTPException(status_code=503, detail="OIDC provider not configured") + return client + + +def is_signed_in(session) -> bool: + """Whether the browser session already holds an OIDC sign-in.""" + return isinstance(session.get(SESSION_USER_KEY), dict) diff --git a/modules/oidc/oidc/settings.py b/modules/oidc/oidc/settings.py new file mode 100644 index 000000000..e736113bd --- /dev/null +++ b/modules/oidc/oidc/settings.py @@ -0,0 +1,131 @@ +"""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, field_validator, model_validator +from pydantic_settings import SettingsConfigDict +from simple_module_core.dotenv import env_str +from simple_module_core.environments import NON_PROD_ENVIRONMENTS +from simple_module_core.redirect_safety import non_empty_redirect +from simple_module_core.settings_base import DbBackedSettings +from simple_module_core.stored_settings import field_error + +from oidc.constants import DEFAULT_LOGIN_REDIRECT_URL +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(DbBackedSettings): + """Generic OIDC configuration with per-provider presets.""" + + model_config = SettingsConfigDict(extra="ignore") + + provider: str = Field( + default=env_str("SM_OIDC_PROVIDER", "generic"), json_schema_extra={"requires_restart": True} + ) + + # 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 = Field( + default=env_str("SM_OIDC_DISCOVERY_URL", ""), json_schema_extra={"requires_restart": True} + ) + tenant_id: str = Field( + default=env_str("SM_OIDC_TENANT_ID", ""), json_schema_extra={"requires_restart": True} + ) + + client_id: str = Field( + default=env_str("SM_OIDC_CLIENT_ID", ""), json_schema_extra={"requires_restart": True} + ) + client_secret: str = Field( + default=env_str("SM_OIDC_CLIENT_SECRET", ""), json_schema_extra={"requires_restart": True} + ) + + # Bearer authentication is opt-in; browser ID tokens always use client_id. + api_audience: str = "" + # Clients (``azp``, or ``appid`` on Entra v1 tokens) allowed to call the API + # with a bearer token, besides ``client_id``. An API token is minted for our + # audience but by any client the IdP lets ask for it, so name each one. + api_allowed_client_ids: list[str] = Field( + default_factory=list, + description=( + "Client IDs (token azp, or appid on Entra v1) besides client_id allowed to call " + "the API with a bearer token. Empty: only this app's own client." + ), + ) + trust_tenant_claim: bool = False + + # Claim mapping. ``_UNSET`` defaults are replaced by the preset's value. + scope: str = _UNSET + uid_claim: str = _UNSET + username_claim: str = _UNSET + email_claim: str = _UNSET + name_claim: str = _UNSET + # Env-seedable: the first admin can only sign in once roles are mapped, and + # the Settings UI that edits this needs an admin. A stored value still wins. + roles_claim_path: str = env_str("SM_OIDC_ROLES_CLAIM_PATH", _UNSET) + + login_redirect_url: str = DEFAULT_LOGIN_REDIRECT_URL + jwks_cache_ttl_seconds: int = Field( + default=3600, ge=0, json_schema_extra={"requires_restart": True} + ) + + role_mapping: dict[str, str] = Field( + default_factory=lambda: {"admin": "admin", "user": "user"}, + ) + + @field_validator("login_redirect_url") + @classmethod + def _non_empty_redirect(cls, value: str) -> str: + return non_empty_redirect(value, default=DEFAULT_LOGIN_REDIRECT_URL) + + @model_validator(mode="after") + def _apply_preset(self) -> OidcSettings: + preset = get_preset(self.provider) + if self.provider == "entra": + from urllib.parse import urlsplit + + forbidden = {"common", "organizations", "consumers"} + segments = urlsplit(self.discovery_url).path.lower().split("/") + if self.tenant_id.strip().lower() in forbidden or forbidden.intersection(segments): + raise ValueError("Entra supports single-tenant configuration only") + for name in ( + "uid_claim", + "username_claim", + "email_claim", + "name_claim", + "roles_claim_path", + "scope", + ): + if getattr(self, name) == _UNSET: + setattr(self, name, getattr(preset, name)) + 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 + required = ("discovery_url", "client_id", "client_secret") + missing = [field for field in required if not getattr(self, field)] + if missing: + raise field_error( + type(self), + missing[0], + "oidc_settings_required", + f"OIDC settings required in production: {', '.join(missing)}", + value="", + related=list(required), + ) + return self diff --git a/modules/oidc/oidc/state.py b/modules/oidc/oidc/state.py new file mode 100644 index 000000000..53089b597 --- /dev/null +++ b/modules/oidc/oidc/state.py @@ -0,0 +1,25 @@ +"""Module-scoped state container for the oidc module.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +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 + + initialization_lock: asyncio.Lock = field(default_factory=asyncio.Lock) diff --git a/modules/oidc/oidc/tokens.py b/modules/oidc/oidc/tokens.py new file mode 100644 index 000000000..0263ab0cf --- /dev/null +++ b/modules/oidc/oidc/tokens.py @@ -0,0 +1,66 @@ +"""Sealing for the refresh token kept in the browser session. + +Starlette's session cookie is signed, not encrypted, so anything stored in it +is readable by whoever holds the cookie. The refresh token is a long-lived +credential, so it is Fernet-encrypted under a key derived (HKDF) from the app's +secret key before it goes into the session. +""" + +from __future__ import annotations + +import base64 +import json +import logging +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from oidc.constants import SESSION_REFRESH_TOKEN_KEY + +logger = logging.getLogger(__name__) +_HKDF_INFO = b"oidc-refresh-token" +# Browsers drop a cookie over 4096 bytes, and a dropped session cookie means a +# sign-in loop. Starlette's cookie is base64(JSON) plus a signature; keep a +# margin for the signature, the cookie name and its attributes. +_COOKIE_LIMIT_BYTES = 4096 +_COOKIE_OVERHEAD_BYTES = 200 + + +def _fernet(app: Any) -> Fernet: + secret = app.state.sm.settings.secret_key + key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=_HKDF_INFO).derive( + secret.encode() + ) + return Fernet(base64.urlsafe_b64encode(key)) + + +def seal_refresh_token(app: Any, token: str) -> str: + return _fernet(app).encrypt(token.encode()).decode() + + +def open_refresh_token(app: Any, sealed: Any) -> str | None: + """The plaintext token, or ``None`` if absent, tampered with or sealed under another key.""" + if not isinstance(sealed, str) or not sealed: + return None + try: + return _fernet(app).decrypt(sealed.encode()).decode() + except (InvalidToken, UnicodeError): + return None + + +def store_refresh_token(app: Any, session: Any, token: str) -> bool: + """Seal ``token`` into ``session`` if the cookie still fits, else leave it out. + + Without it the session simply ends at the ID token's expiry and the user + signs in again. Some IdPs (Entra) issue refresh tokens large enough to + overflow the cookie together with the principal. + """ + session[SESSION_REFRESH_TOKEN_KEY] = seal_refresh_token(app, token) + encoded = base64.b64encode(json.dumps(dict(session)).encode()) + if len(encoded) + _COOKIE_OVERHEAD_BYTES <= _COOKIE_LIMIT_BYTES: + return True + session.pop(SESSION_REFRESH_TOKEN_KEY, None) + logger.warning("OIDC refresh token too large for the session cookie; session ends at expiry") + return False diff --git a/modules/oidc/package.json b/modules/oidc/package.json new file mode 100644 index 000000000..dd32c885a --- /dev/null +++ b/modules/oidc/package.json @@ -0,0 +1,7 @@ +{ + "name": "@simple-module/oidc", + "private": true, + "version": "0.0.42", + "type": "module", + "dependencies": {} +} diff --git a/modules/oidc/pyproject.toml b/modules/oidc/pyproject.toml new file mode 100644 index 000000000..307c90b4e --- /dev/null +++ b/modules/oidc/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "simple_module_oidc" +version = "0.0.42" +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.42", + "simple_module_db==0.0.42", + "simple_module_hosting==0.0.42", + "simple_module_auth==0.0.42", + "PyJWT[crypto]>=2.8,<3", + "cryptography>=42", + "httpx>=0.27,<0.29", +] + +[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_auth = { workspace = true } diff --git a/modules/oidc/tests-js/oidc-login.test.tsx b/modules/oidc/tests-js/oidc-login.test.tsx new file mode 100644 index 000000000..749d1804e --- /dev/null +++ b/modules/oidc/tests-js/oidc-login.test.tsx @@ -0,0 +1,25 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n } from '@simple-module-py/i18n'; +import { render, screen } from '@testing-library/react'; +import { expect, test, vi } from 'vitest'; + +configureI18n({ + locale: 'en', + messages: { + 'oidc.login.redirecting': 'Redirecting to identity provider…', + 'oidc.login.manual': 'Continue manually', + }, +}); +const assign = vi.fn(); +vi.stubGlobal('location', { assign }); + +import Login from '../oidc/pages/Login'; + +test('login leaves the Inertia transport and provides a manual navigation link', () => { + render(); + expect(assign).toHaveBeenCalledWith('/api/oidc/auth/login'); + expect(screen.getByRole('link', { name: 'Continue manually' })).toHaveAttribute( + 'href', + '/api/oidc/auth/login', + ); +}); diff --git a/modules/oidc/tests-js/oidc-translations.test.tsx b/modules/oidc/tests-js/oidc-translations.test.tsx new file mode 100644 index 000000000..6980263fa --- /dev/null +++ b/modules/oidc/tests-js/oidc-translations.test.tsx @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n } from '@simple-module-py/i18n'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import de from '../oidc/locales/de.json'; +import en from '../oidc/locales/en.json'; +import LoggedOut from '../oidc/pages/LoggedOut'; +import Login from '../oidc/pages/Login'; + +vi.mock('@inertiajs/react', () => ({ + Link: (props: React.AnchorHTMLAttributes) => , +})); +vi.stubGlobal('location', { assign: vi.fn() }); +afterEach(cleanup); + +test('German login and signed-out pages use the supplied catalogue', () => { + configureI18n({ + locale: 'de', + messages: { + 'oidc.login.redirecting': de.login.redirecting, + 'oidc.login.manual': de.login.manual, + 'oidc.logout.title': de.logout.title, + 'oidc.logout.message': de.logout.message, + 'oidc.logout.sign_in_again': de.logout.sign_in_again, + }, + }); + render(); + expect(screen.getByRole('link', { name: de.login.manual })).toHaveAttribute( + 'href', + '/api/oidc/auth/login', + ); + render(); + expect(screen.getByRole('heading', { name: de.logout.title })).toBeInTheDocument(); + expect(screen.getByText(de.logout.message)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: de.logout.sign_in_again })).toHaveAttribute( + 'href', + '/oidc/login', + ); +}); + +test('English and German catalogues have matching keys', () => { + for (const section of Object.keys(en) as (keyof typeof en)[]) { + expect(Object.keys(de[section]).sort()).toEqual(Object.keys(en[section]).sort()); + } +}); diff --git a/modules/oidc/tests/conftest.py b/modules/oidc/tests/conftest.py new file mode 100644 index 000000000..9dc9408ab --- /dev/null +++ b/modules/oidc/tests/conftest.py @@ -0,0 +1,120 @@ +"""Build an OIDC-selected app; mock only the external identity provider.""" + +import json + +import httpx +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm +from oidc.settings import OidcSettings +from oidc_flow_helpers import _AUDIENCE, _KID, _PRIVATE_KEY +from simple_module_core.discovery import discover_modules +from simple_module_db.base import all_module_bases +from simple_module_hosting.app_builder import create_app +from simple_module_hosting.migrations import resolve_head_revisions +from simple_module_hosting.settings import Settings +from sqlalchemy import text + + +@pytest.fixture +async def oidc_app(httpx_mock, request): + # Explicitly boot without Settings UI or Users-dependent modules. + modules = [ + m.meta.name + for m in discover_modules() + if m.meta.name not in {"Users", "Settings", "Keycloak"} + and "Users" not in m.meta.depends_on + and "Settings" not in m.meta.depends_on + ] + app = create_app( + Settings( + database_url="sqlite+aiosqlite:///:memory:", + environment="testing", + secret_key="test-secret-key", + multi_tenant=False, + auth_provider="oidc", + modules_enabled=modules, + ) + ) + async with app.state.sm.db.engine.begin() as conn: + + def create(sync): + for base in all_module_bases: + if "oidc_user_cache" not in base.metadata.tables: + base.metadata.create_all(sync) + + await conn.run_sync(create) + from alembic.config import Config + from alembic.migration import MigrationContext + from alembic.operations import Operations + from alembic.script import ScriptDirectory + + revision = ScriptDirectory.from_config(Config("host/alembic.ini")).get_revision("oidc@head") + + def migrate(sync): + with Operations.context(MigrationContext.configure(sync)): + revision.module.upgrade() + + await conn.run_sync(migrate) + await conn.execute( + text("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL PRIMARY KEY)") + ) + for head in resolve_head_revisions(): + await conn.execute(text("INSERT INTO alembic_version VALUES (:v)"), {"v": head}) + + # Settings hydration restores defaults; supply DB-backed values before startup. + config = { + "provider": "entra", + "tenant_id": "test-tenant", + "client_id": _AUDIENCE, + "client_secret": "secret", + "api_audience": "api", + } + config.update(getattr(request, "param", {})) + oidc_settings = OidcSettings(**config) + issuer = oidc_settings.discovery_url.split("/.well-known/", 1)[0] + if oidc_settings.provider == "generic": + endpoint_base = f"{issuer}/protocol/openid-connect" + authorization_url = f"{endpoint_base}/auth" + keys_url = f"{endpoint_base}/certs" + else: + endpoint_base = "https://idp" + authorization_url = f"{endpoint_base}/authorize" + keys_url = "https://idp/keys" + from simple_module_db.stored_settings import Setting + + async with app.state.sm.db.session_factory() as db: + for name, value in oidc_settings.model_dump().items(): + db.add(Setting(key=f"oidc.{name}", value=json.dumps(value), value_type="json")) + await db.commit() + + httpx_mock.add_response( + url=oidc_settings.discovery_url, + json={ + "issuer": issuer, + "authorization_endpoint": authorization_url, + "token_endpoint": f"{endpoint_base}/token", + "jwks_uri": keys_url, + "end_session_endpoint": f"{endpoint_base}/logout", + }, + is_reusable=True, + ) + jwk = json.loads(RSAAlgorithm.to_jwk(_PRIVATE_KEY.public_key())) + jwk.update(kid=_KID, use="sig") + httpx_mock.add_response(url=keys_url, json={"keys": [jwk]}, is_reusable=True, is_optional=True) + async with app.router.lifespan_context(app): + yield app + + +@pytest.fixture +async def flow_client(oidc_app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=oidc_app), base_url="http://testserver" + ) as client: + yield client + + +@pytest.fixture +def rsa_keys(): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return private, private.public_key() diff --git a/modules/oidc/tests/oidc_flow_helpers.py b/modules/oidc/tests/oidc_flow_helpers.py new file mode 100644 index 000000000..3be87f3aa --- /dev/null +++ b/modules/oidc/tests/oidc_flow_helpers.py @@ -0,0 +1,44 @@ +"""Real signing material for OIDC integration tests.""" + +import json +import time + +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.algorithms import RSAAlgorithm + +_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", + "sub": "pairwise-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}) + + +def make_jwks_response(public_key, kid=_KID, *, include_alg=True): + jwk = json.loads(RSAAlgorithm.to_jwk(public_key)) + jwk.update(kid=kid, use="sig") + if include_alg: + jwk["alg"] = "RS256" + return {"keys": [jwk]} + + +def sign_token(private_key, payload, kid=_KID): + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) diff --git a/modules/oidc/tests/test_oidc_authorization.py b/modules/oidc/tests/test_oidc_authorization.py new file mode 100644 index 000000000..b17c5687c --- /dev/null +++ b/modules/oidc/tests/test_oidc_authorization.py @@ -0,0 +1,111 @@ +"""Bearer purpose, tenant isolation and explicit mailbox proof.""" + +import time +from unittest.mock import AsyncMock + +import pytest +from auth.contracts.schemas import UserContext +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings +from oidc.state import OidcState +from starlette.requests import Request + + +@pytest.mark.parametrize( + "preset,proof,expected", + [ + ("generic", True, True), + ("generic", "true", False), + ("generic", 1, False), + ("generic", False, False), + ("entra", True, False), + ], +) +def test_email_proof(preset, proof, expected): + provider = OidcAuthProvider(OidcSettings(provider=preset, tenant_id="tenant")) + ctx = provider._claims_to_user_context({"email_verified": proof}, cache_id="id") + assert ctx.email_verified is expected + + +@pytest.mark.parametrize( + "trusted,value,expected", + [ + (False, "acme", None), + (True, "acme", "acme"), + (True, "has space", None), + (True, 1, None), + ], +) +def test_tenant_claim_and_roles(trusted, value, expected): + settings = OidcSettings( + trust_tenant_claim=trusted, + roles_claim_path="roles", + role_mapping={"a": "admin", "t": "tenant:owner"}, + ) + ctx = OidcAuthProvider(settings)._claims_to_user_context( + {"tenant_id": value, "tid": "entra-directory", "roles": ["a", "t", {}]}, cache_id="id" + ) + assert ctx.tenant_id == expected + assert ctx.roles == ["admin"] + + +async def test_restored_session_rechecks_live_trust_and_filters_roles(): + state = OidcState(settings=OidcSettings(trust_tenant_claim=True)) + provider = OidcAuthProvider(state=state) + ctx = UserContext(id="id", email="", name="", tenant_id="acme", roles=["admin", "tenant:owner"]) + request = Request( + { + "type": "http", + "headers": [], + "session": {"user_ctx": ctx.to_session_dict(), "expires_at": int(time.time()) + 300}, + } + ) + assert (await provider.resolve_user(request)).tenant_id == "acme" + state.settings = OidcSettings(trust_tenant_claim=False) + restored = await provider.resolve_user(request) + assert restored.tenant_id is None + assert restored.roles == ["admin"] + + +async def test_mapping_reads_live_state(): + state = OidcState(settings=OidcSettings(roles_claim_path="roles")) + provider = OidcAuthProvider(state=state) + state.settings = OidcSettings(roles_claim_path="nested.roles", role_mapping={"x": "reader"}) + assert provider._claims_to_user_context({"nested": {"roles": ["x"]}}, cache_id="id").roles == [ + "reader" + ] + + +@pytest.mark.parametrize("prefix", ["smpat_", "smsk_"]) +async def test_api_tokens_skip_jwt(prefix): + provider = OidcAuthProvider(OidcSettings(api_audience="api")) + provider.jwks_cache = AsyncMock() + request = Request( + {"type": "http", "headers": [(b"authorization", f"bearer {prefix}secret".encode())]} + ) + assert provider.is_bearer_request(request) + assert await provider.resolve_user(request) is None + provider.jwks_cache.validate_jwt.assert_not_awaited() + + +async def test_bearer_disabled_without_api_audience(): + provider = OidcAuthProvider(OidcSettings(client_id="app")) + provider.jwks_cache = AsyncMock() + request = Request({"type": "http", "headers": [(b"authorization", b"bearer token")]}) + assert await provider.resolve_user(request) is None + provider.jwks_cache.validate_jwt.assert_not_awaited() + + +async def test_lowercase_bearer_has_bearer_kind(monkeypatch): + provider = OidcAuthProvider(OidcSettings(client_id="app", api_audience="api")) + provider.jwks_cache = AsyncMock() + provider.jwks_cache.validate_jwt.return_value = { + "iss": "https://idp", + "sub": "subject", + "azp": "app", + } + monkeypatch.setattr(provider, "_upsert_user_cache", AsyncMock(return_value="uuid")) + request = Request({"type": "http", "headers": [(b"authorization", b"bearer token")]}) + principal = await provider.resolve_user(request) + assert principal.auth_kind == "bearer" + provider.jwks_cache.validate_jwt.assert_awaited_once_with("token", audience="api") diff --git a/modules/oidc/tests/test_oidc_bearer_policy.py b/modules/oidc/tests/test_oidc_bearer_policy.py new file mode 100644 index 000000000..04c2ff550 --- /dev/null +++ b/modules/oidc/tests/test_oidc_bearer_policy.py @@ -0,0 +1,49 @@ +"""A browser ID token cannot authenticate the configured resource-server API.""" + +import pytest +from oidc_flow_helpers import _AUDIENCE, _sign_id_token +from starlette.requests import Request + + +@pytest.mark.parametrize("audience,accepted", [("my-app", False), ("api", True), ("graph", False)]) +async def test_signed_bearer_purpose(oidc_app, audience, accepted): + provider = oidc_app.state.auth.auth_provider + token = _sign_id_token(nonce="unused", aud=audience, azp=_AUDIENCE) + request = Request( + { + "type": "http", + "app": oidc_app, + "headers": [(b"authorization", f"bearer {token}".encode())], + } + ) + principal = await provider.resolve_user(request) + assert (principal is not None) is accepted + if principal: + assert principal.auth_kind == "bearer" + assert principal.tenant_id is None + assert principal.roles == ["admin"] + + +def _bearer_request(app, token): + return Request( + {"type": "http", "app": app, "headers": [(b"authorization", f"Bearer {token}".encode())]} + ) + + +@pytest.mark.parametrize( + "client_claims,allowed,accepted", + [ + ({"azp": _AUDIENCE}, [], True), # our own client + ({"azp": "other-spa"}, [], False), # any other client of the IdP + ({"azp": "other-spa"}, ["other-spa"], True), # explicitly allowed + ({"appid": "other-spa"}, ["other-spa"], True), # Entra v1 names it appid + ({}, [_AUDIENCE], False), # no client named: fail closed + ], +) +async def test_api_tokens_only_from_allowed_clients(oidc_app, client_claims, allowed, accepted): + oidc_app.state.oidc.settings.api_allowed_client_ids = allowed + token = _sign_id_token(nonce="unused", aud="api", **client_claims) + principal = await oidc_app.state.auth.auth_provider.resolve_user( + _bearer_request(oidc_app, token) + ) + assert (principal is not None) is accepted diff --git a/modules/oidc/tests/test_oidc_boot.py b/modules/oidc/tests/test_oidc_boot.py new file mode 100644 index 000000000..3e568a7b7 --- /dev/null +++ b/modules/oidc/tests/test_oidc_boot.py @@ -0,0 +1,68 @@ +"""Discovery is a strict boot dependency outside development; dev login retries.""" + +import asyncio +from types import SimpleNamespace + +import httpx +import pytest +from oidc.module import OidcModule +from oidc.provider import OidcAuthProvider +from oidc.runtime import require_client +from oidc.settings import OidcSettings +from oidc.state import OidcState +from starlette.requests import Request + + +def _app(environment): + state = OidcState( + settings=OidcSettings( + discovery_url="https://idp/.well-known/openid-configuration", client_id="app" + ) + ) + provider = OidcAuthProvider(state=state) + return SimpleNamespace( + state=SimpleNamespace( + oidc=state, + auth=SimpleNamespace(auth_provider=provider), + sm=SimpleNamespace(settings=SimpleNamespace(environment=environment)), + ) + ) + + +@pytest.mark.parametrize("environment", ["testing", "staging", "production"]) +async def test_discovery_failure_refuses_non_development_boot(httpx_mock, environment): + httpx_mock.add_response(status_code=503) + with pytest.raises(RuntimeError, match="OIDC discovery failed"): + await OidcModule().on_startup(_app(environment)) + + +async def test_development_retries_lazily_and_single_flight(httpx_mock): + app = _app("development") + httpx_mock.add_exception(httpx.ConnectError("offline")) + await OidcModule().on_startup(app) + assert app.state.oidc.client is None + httpx_mock.add_response( + json={ + "issuer": "https://idp", + "authorization_endpoint": "https://login/authorize", + "token_endpoint": "https://tokens/token", + "jwks_uri": "https://keys/jwks", + } + ) + request = Request({"type": "http", "app": app}) + clients = await asyncio.gather(*[require_client(request) for _ in range(5)]) + assert all(client is clients[0] for client in clients) + assert len(httpx_mock.get_requests()) == 2 + + +async def test_mismatched_discovery_issuer_refuses_boot(httpx_mock): + httpx_mock.add_response( + json={ + "issuer": "https://other", + "authorization_endpoint": "https://idp/authorize", + "token_endpoint": "https://idp/token", + "jwks_uri": "https://idp/jwks", + } + ) + with pytest.raises(RuntimeError): + await OidcModule().on_startup(_app("testing")) diff --git a/modules/oidc/tests/test_oidc_cache_persistence.py b/modules/oidc/tests/test_oidc_cache_persistence.py new file mode 100644 index 000000000..84518fb29 --- /dev/null +++ b/modules/oidc/tests/test_oidc_cache_persistence.py @@ -0,0 +1,168 @@ +"""Real cache transactions keep Oidc identities stable across requests (#618).""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from oidc.models import OidcUserCache +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings +from simple_module_db import independent_session +from sqlalchemy import event, select +from starlette.requests import Request + +CLAIMS = { + "iss": "https://idp", + "sub": "kc-stable", + "email": "old@example.com", + "preferred_username": "Old", +} + + +@pytest.fixture +def cache_request(db_state, db_session): + app = SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(db=db_state))) + return Request({"type": "http", "app": app, "headers": []}) + + +async def test_oidc_cache_commits_and_keeps_id_across_logins(cache_request, db_state): + provider = OidcAuthProvider(OidcSettings(api_audience="api")) + first = await provider._upsert_user_cache(cache_request, CLAIMS) + async with db_state.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert str(row.id) == first + assert row.email == "old@example.com" + + second = await provider._upsert_user_cache(cache_request, CLAIMS) + assert second == first + changed = {**CLAIMS, "email": "new@example.com", "preferred_username": "New"} + assert await provider._upsert_user_cache(cache_request, changed) == first + async with db_state.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert str(row.id) == first + assert (row.email, row.full_name) == ("new@example.com", "New") + assert row.last_login_at is not None + + +@pytest.mark.parametrize("age", [None, 30, 600]) +async def test_oidc_bearer_throttles_timestamp_writes(cache_request, db_state, age): + now = datetime.now(UTC) + previous = None if age is None else now - timedelta(seconds=age) + async with independent_session(cache_request.app) as db: + row = OidcUserCache( + issuer=CLAIMS["iss"], + subject=CLAIMS["sub"], + email=CLAIMS["email"], + full_name=CLAIMS["preferred_username"], + last_login_at=previous, + ) + db.add(row) + user_id = str(row.id) + + updates = [] + + def record_update(conn, cursor, statement, parameters, context, executemany): + if statement.lstrip().upper().startswith("UPDATE OIDC_USER_CACHE"): + updates.append(statement) + + event.listen(db_state.engine.sync_engine, "before_cursor_execute", record_update) + try: + provider = OidcAuthProvider(OidcSettings(api_audience="api", client_id="app")) + + class Jwks: + async def validate_jwt(self, token, **kwargs): + return {**CLAIMS, "azp": "app"} + + provider.jwks_cache = Jwks() + for _ in range(2): + ctx = await provider._resolve_bearer(cache_request, "jwt") + assert ctx.id == user_id + assert len(updates) == (0 if age == 30 else 1) + + # A browser login always records the login, even inside the interval. + await provider._upsert_user_cache(cache_request, CLAIMS) + assert len(updates) == (1 if age == 30 else 2) + finally: + event.remove(db_state.engine.sync_engine, "before_cursor_execute", record_update) + + async with db_state.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert row.last_login_at >= now + + +async def test_oidc_bearer_saves_profile_inside_touch_interval(cache_request, db_state): + provider = OidcAuthProvider(OidcSettings(api_audience="api")) + user_id = await provider._upsert_user_cache(cache_request, CLAIMS) + async with db_state.session_factory() as db: + previous = (await db.execute(select(OidcUserCache.last_login_at))).scalar_one() + + changed = {**CLAIMS, "email": "new@example.com", "preferred_username": "New"} + assert await provider._upsert_user_cache(cache_request, changed, throttle_login=True) == user_id + async with db_state.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert (row.email, row.full_name) == ("new@example.com", "New") + assert row.last_login_at == previous + + +async def test_oidc_cache_recovers_unique_insert_race(cache_request, db_state, monkeypatch): + async with independent_session(cache_request.app) as db: + winner = OidcUserCache( + issuer=CLAIMS["iss"], subject=CLAIMS["sub"], email="winner@example.com" + ) + db.add(winner) + winner_id = str(winner.id) + + session_class = db_state.session_factory.class_ + execute = session_class.execute + hide_winner = True + + async def stale_first_read(self, statement, *args, **kwargs): + nonlocal hide_winner + if hide_winner: + hide_winner = False + # Simulate the initial SELECT finishing before the competing insert + # commits. The subsequent INSERT hits the real unique constraint. + return await execute(self, select(OidcUserCache).where(False)) + return await execute(self, statement, *args, **kwargs) + + monkeypatch.setattr(session_class, "execute", stale_first_read) + result = await OidcAuthProvider()._upsert_user_cache(cache_request, CLAIMS) + assert result == winner_id + async with db_state.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert str(row.id) == winner_id + assert (row.email, row.full_name) == ("old@example.com", "Old") + + +async def test_issuer_namespaces_same_subject(cache_request, db_state): + provider = OidcAuthProvider() + first = await provider._upsert_user_cache(cache_request, CLAIMS) + second = await provider._upsert_user_cache(cache_request, {**CLAIMS, "iss": "https://other"}) + assert first and second and first != second + + +async def test_failed_cache_never_falls_back_to_external_subject(cache_request, monkeypatch): + def broken(*args): + raise RuntimeError("database unavailable") + + monkeypatch.setattr("oidc.persistence.independent_session", broken) + assert await OidcAuthProvider()._upsert_user_cache(cache_request, CLAIMS) is None + + +@pytest.mark.parametrize( + "claims", [{"sub": "s"}, {"iss": "https://idp"}, {"iss": "https://idp", "sub": 3}] +) +async def test_missing_identity_is_refused(cache_request, claims): + assert await OidcAuthProvider()._upsert_user_cache(cache_request, claims) is None + + +async def test_oidc_token_without_profile_claims_keeps_cached_name(cache_request, db_state): + # A bearer token minted without the profile scope carries no name claims; + # it must not wipe the name an earlier browser sign-in stored. + provider = OidcAuthProvider(OidcSettings(api_audience="api")) + await provider._upsert_user_cache(cache_request, CLAIMS) + bare = {k: v for k, v in CLAIMS.items() if k not in ("preferred_username", "name", "email")} + await provider._upsert_user_cache(cache_request, bare, throttle_login=True) + async with db_state.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert (row.email, row.full_name) == (CLAIMS["email"], CLAIMS["preferred_username"]) diff --git a/modules/oidc/tests/test_oidc_client.py b/modules/oidc/tests/test_oidc_client.py new file mode 100644 index 000000000..0e3efa969 --- /dev/null +++ b/modules/oidc/tests/test_oidc_client.py @@ -0,0 +1,119 @@ +"""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", + code_verifier="a" * 43, + ) + 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", + ) + assert url.startswith("https://idp.example.com/logout?") + assert "post_logout_redirect_uri=" in url + assert "client_id=my-app" in url + assert "id_token_hint" not 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", + code_verifier="a" * 43, + redirect_uri="https://app.example.com/callback", + ) + assert tokens["id_token"] == "id-123" + from urllib.parse import parse_qs + + assert parse_qs(httpx_mock.get_request().content.decode())["code_verifier"] == ["a" * 43] + + +def test_pkce_challenge(client): + from urllib.parse import parse_qs, urlsplit + + url, _ = client.build_authorization_url( + redirect_uri="https://app/cb", + nonce="nonce", + code_verifier="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + ) + query = parse_qs(urlsplit(url).query) + assert query["code_challenge_method"] == ["S256"] + assert query["code_challenge"] == ["E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"] + + +async def test_refresh_grant(client, httpx_mock): + from urllib.parse import parse_qs + + httpx_mock.add_response(json={"id_token": "new", "refresh_token": "rotated"}) + assert (await client.refresh_tokens("old"))["refresh_token"] == "rotated" + sent = httpx_mock.get_request() + assert parse_qs(sent.content.decode()) == { + "grant_type": ["refresh_token"], + "refresh_token": ["old"], + "client_id": ["my-app"], + "client_secret": ["secret123"], + } + assert sent.extensions["timeout"]["read"] <= 10 + + +def test_logout_url_keeps_an_existing_query(): + client = OIDCClient( + authorization_endpoint="https://idp.example.com/authorize", + token_endpoint="https://idp.example.com/token", + end_session_endpoint="https://idp.example.com/logout?tenant=x", + client_id="my-app", + client_secret="secret123", + ) + url = client.build_logout_url( + post_logout_redirect_uri="https://app.example.com/oidc/logged-out" + ) + assert url.startswith("https://idp.example.com/logout?tenant=x&") + assert url.count("?") == 1 diff --git a/modules/oidc/tests/test_oidc_directory.py b/modules/oidc/tests/test_oidc_directory.py new file mode 100644 index 000000000..7203728ba --- /dev/null +++ b/modules/oidc/tests/test_oidc_directory.py @@ -0,0 +1,76 @@ +"""The OIDC cache supplies provider-neutral account lookup (#192).""" + +import uuid +from datetime import UTC, datetime, timedelta + +from oidc.models import OidcUserCache +from simple_module_core.user_directory import get_user_directory + + +async def _cached(db, email, **kw): + row = OidcUserCache(issuer="https://idp", subject=uuid.uuid4().hex, email=email, **kw) + db.add(row) + await db.flush() + return row + + +async def test_registered_directory_looks_up_uuid_spellings_and_unknown_roles(oidc_app): + directory = get_user_directory(oidc_app) + assert directory.entity_type is None + async with oidc_app.state.sm.db.session_factory() as db: + row = await _cached(db, "alice@example.com", full_name="Alice") + spellings = [str(row.id), str(row.id).upper(), row.id.hex] + found = await directory.get_many( + db, [*spellings, "svc:1", str(uuid.uuid4())], with_roles=True + ) + assert set(found) == set(spellings) + for key, user in found.items(): + assert user.id == key + assert user.label == "Alice" + assert user.email == "alice@example.com" + assert user.is_active + assert user.roles is None + assert user.email_verified is None + assert await directory.get_many(db, []) == {} + + +async def test_directory_search_and_text_ids(oidc_app): + directory = get_user_directory(oidc_app) + async with oidc_app.state.sm.db.session_factory() as db: + first = await _cached(db, "a@example.com", full_name="Okafor A") + second = await _cached(db, "b-okafor@example.com") + await _cached(db, "nobody@example.com") + found = await directory.search(db, " OKAFOR ", limit=10) + assert [user.id for user in found] == [str(first.id), str(second.id)] + assert len(await directory.search(db, None, limit=1)) == 1 + assert len(await directory.search(db, " ", limit=10)) == 3 + assert await directory.search(db, "%", limit=10) == [] + assert await directory.search(db, "_", limit=10) == [] + ids = (await db.execute(directory.match_ids("okafor"))).scalars().all() + assert set(ids) == {str(first.id), str(second.id)} + assert (await db.execute(directory.match_ids("%"))).scalars().all() == [] + + +async def test_directory_stats_measure_login_not_account_creation(oidc_app): + directory = get_user_directory(oidc_app) + async with oidc_app.state.sm.db.session_factory() as db: + await _cached(db, "now@example.com", last_login_at=datetime.now(UTC)) + await _cached(db, "old@example.com", last_login_at=datetime.now(UTC) - timedelta(days=60)) + await _cached(db, "unknown@example.com") + stats = await directory.stats(db, active_window=timedelta(days=7)) + assert stats.total == 3 + assert stats.active_since == 1 + assert stats.created_this_month is None + + +def test_module_installs_directory_during_registration(): + from types import SimpleNamespace + + from fastapi import FastAPI + from oidc.module import OidcModule + from simple_module_core.user_directory import NullUserDirectory + + app = FastAPI() + app.state.auth = SimpleNamespace() + OidcModule().register_settings(app) + assert not isinstance(get_user_directory(app), NullUserDirectory) diff --git a/modules/oidc/tests/test_oidc_discovery.py b/modules/oidc/tests/test_oidc_discovery.py new file mode 100644 index 000000000..a1f7a9564 --- /dev/null +++ b/modules/oidc/tests/test_oidc_discovery.py @@ -0,0 +1,77 @@ +"""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"] + + +@pytest.mark.parametrize( + "value", + [ + 42, + [], + "http://idp/keys", + "javascript:alert(1)", + "https://u:p@idp/keys", + "https://idp/keys#frag", + ], +) +def test_metadata_rejects_invalid_endpoint(value): + with pytest.raises(ValueError): + OidcMetadata.from_document({**_ENTRA_DOC, "jwks_uri": value}) + + +def test_metadata_checks_expected_issuer(): + with pytest.raises(ValueError, match="issuer"): + OidcMetadata.from_document(_ENTRA_DOC, expected_issuer="https://other") + + +def test_metadata_issuer_tolerates_one_trailing_slash(): + # Auth0 publishes "https://tenant.auth0.com/"; the discovery URL has no slash. + doc = {**_ENTRA_DOC, "issuer": "https://tenant.example.com/"} + metadata = OidcMetadata.from_document(doc, expected_issuer="https://tenant.example.com") + assert metadata.issuer == "https://tenant.example.com/" + + +def test_metadata_allows_local_http_only_in_development(): + doc = {k: "http://localhost/" + k for k in _ENTRA_DOC} + assert OidcMetadata.from_document(doc, development=True).jwks_uri.startswith("http:") + with pytest.raises(ValueError): + OidcMetadata.from_document(doc) diff --git a/modules/oidc/tests/test_oidc_flow.py b/modules/oidc/tests/test_oidc_flow.py new file mode 100644 index 000000000..b93f2293e --- /dev/null +++ b/modules/oidc/tests/test_oidc_flow.py @@ -0,0 +1,133 @@ +"""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 + +from urllib.parse import parse_qs, urlparse + +from oidc_flow_helpers import _AUDIENCE, _sign_id_token + + +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, httpx_mock): + state, nonce, _ = await _begin_login(flow_client) + + token = _sign_id_token(nonce=nonce) + httpx_mock.add_response(url="https://idp/token", json={"id_token": 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, httpx_mock): + """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) + httpx_mock.add_response( + url="https://idp/token", json={"id_token": _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, httpx_mock): + """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") + httpx_mock.add_response(url="https://idp/token", json={"id_token": 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 == 419 + + +async def test_callback_rejects_token_with_wrong_audience(oidc_app, flow_client, httpx_mock): + state, nonce, _ = await _begin_login(flow_client) + + token = _sign_id_token(nonce=nonce, aud="some-other-app") + httpx_mock.add_response(url="https://idp/token", json={"id_token": token}) + + resp = await flow_client.get(f"/api/oidc/auth/callback?code=abc&state={state}") + assert resp.status_code == 401 + + +async def test_http_pkce_verifier_matches_authorization_challenge(flow_client, httpx_mock): + import base64 + import hashlib + + response = await flow_client.get("/api/oidc/auth/login") + query = parse_qs(urlparse(response.headers["location"]).query) + httpx_mock.add_response( + url="https://idp/token", json={"id_token": _sign_id_token(nonce=query["nonce"][0])} + ) + response = await flow_client.get( + "/api/oidc/auth/callback", params={"code": "abc", "state": query["state"][0]} + ) + assert response.status_code == 303, response.text + sent = parse_qs(httpx_mock.get_request(url="https://idp/token").content.decode()) + verifier = sent["code_verifier"][0] + assert 43 <= len(verifier) <= 128 + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + ) + assert query["code_challenge"] == [challenge] + assert query["code_challenge_method"] == ["S256"] diff --git a/modules/oidc/tests/test_oidc_jwks.py b/modules/oidc/tests/test_oidc_jwks.py new file mode 100644 index 000000000..a43d0160d --- /dev/null +++ b/modules/oidc/tests/test_oidc_jwks.py @@ -0,0 +1,116 @@ +"""Tests for JWKS key cache and JWT validation.""" + +from __future__ import annotations + +import time + +import pytest +from oidc.jwks import JWKSCache +from oidc_flow_helpers import make_jwks_response, sign_token + + +@pytest.fixture +def valid_payload(): + now = int(time.time()) + return { + "oid": "user-123", + "sub": "pairwise-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_jwks_resilience.py b/modules/oidc/tests/test_oidc_jwks_resilience.py new file mode 100644 index 000000000..659d6f7b9 --- /dev/null +++ b/modules/oidc/tests/test_oidc_jwks_resilience.py @@ -0,0 +1,189 @@ +"""OIDC key rotation and bounded single-flight fetches.""" + +import asyncio +import time +from types import SimpleNamespace + +import httpx +import pytest +from oidc.jwks import JWKSCache +from oidc_flow_helpers import make_jwks_response, sign_token + + +@pytest.fixture +def valid_payload(): + return { + "sub": "user-123", + "iss": "https://auth.example.com/realms/test", + "aud": "my-client", + "iat": int(time.time()), + "exp": int(time.time()) + 300, + } + + +async def test_jwks_cache_refetches_on_unknown_kid( + rsa_keys, valid_payload, httpx_mock, monkeypatch +): + """Rotation is discovered after the short refetch cooldown, before the TTL.""" + private_key, public_key = rsa_keys + now = 10_000.0 + monkeypatch.setattr("oidc.jwks.time", SimpleNamespace(monotonic=lambda: now)) + httpx_mock.add_response(json=make_jwks_response(public_key)) + httpx_mock.add_response(json=make_jwks_response(public_key, kid="rotated-key")) + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + assert await cache.validate_jwt(sign_token(private_key, valid_payload)) is not None + + now += 31 + 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" + assert len(httpx_mock.get_requests()) == 2 + + +@pytest.mark.parametrize("outage", [False, True]) +@pytest.mark.parametrize("ttl", [0, 3600]) +async def test_jwks_random_kids_throttle_fetch_attempts( + rsa_keys, valid_payload, httpx_mock, monkeypatch, outage, ttl +): + """Random kids cannot bypass the cooldown, even with stale keys or an outage.""" + private_key, public_key = rsa_keys + now = 10_000.0 + monkeypatch.setattr("oidc.jwks.time", SimpleNamespace(monotonic=lambda: now)) + if outage: + httpx_mock.add_exception(httpx.ConnectError("IdP unavailable"), is_reusable=True) + else: + httpx_mock.add_response(json=make_jwks_response(public_key), is_reusable=True) + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + ttl_seconds=ttl, + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + + for index in range(5): + now += 1 + token = sign_token(private_key, valid_payload, kid=f"unknown-{index}") + assert await cache.validate_jwt(token) is None + assert len(httpx_mock.get_requests()) == 1 + + now += 31 + assert await cache.validate_jwt(token) is None + assert len(httpx_mock.get_requests()) == 2 + if not outage: + assert await cache.validate_jwt(sign_token(private_key, valid_payload)) is not None + + +@pytest.mark.parametrize("outage", [False, True]) +async def test_jwks_concurrent_unknown_kids_share_fetch( + rsa_keys, valid_payload, httpx_mock, outage +): + """Overlapping validations perform one fetch, including when that fetch fails.""" + private_key, public_key = rsa_keys + started = asyncio.Event() + release = asyncio.Event() + + async def respond(request): + started.set() + await release.wait() + if outage: + raise httpx.ConnectError("IdP unavailable") + return httpx.Response(200, json=make_jwks_response(public_key)) + + httpx_mock.add_callback(respond, is_reusable=True) + cache = JWKSCache( + jwks_url="https://auth.example.com/jwks", + issuer="https://auth.example.com/realms/test", + audience="my-client", + ) + tokens = [sign_token(private_key, valid_payload, kid=f"unknown-{i}") for i in range(5)] + tasks = [asyncio.create_task(cache.validate_jwt(token)) for token in tokens] + try: + await asyncio.wait_for(started.wait(), timeout=5) + # Let the remaining validations reach the in-flight fetch. + await asyncio.sleep(0) + finally: + release.set() + results = await asyncio.wait_for(asyncio.gather(*tasks), timeout=5) + assert results == [None] * 5 + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("bad", [[], {"keys": 3}, {"keys": [None, "invalid"]}, {"keys": []}]) +async def test_last_good_keys_survive_malformed_metadata( + rsa_keys, valid_payload, httpx_mock, monkeypatch, bad +): + private, public = rsa_keys + now = 10000 + monkeypatch.setattr("oidc.jwks.time", SimpleNamespace(monotonic=lambda: now)) + httpx_mock.add_response(json=make_jwks_response(public)) + httpx_mock.add_response(json=bad) + cache = JWKSCache( + "https://idp/keys", ttl_seconds=0, issuer=valid_payload["iss"], audience="my-client" + ) + token = sign_token(private, valid_payload) + assert await cache.validate_jwt(token) + now += 31 + assert await cache.validate_jwt(token) + assert len(httpx_mock.get_requests()) == 2 + + +@pytest.mark.parametrize( + "changes", + [ + {"kty": "EC"}, + {"alg": "HS256"}, + {"use": "enc"}, + {"kid": []}, + {"key_ops": ["encrypt"]}, + {"issuer": "https://wrong"}, + ], +) +async def test_incompatible_keys_rejected(rsa_keys, valid_payload, httpx_mock, changes): + private, public = rsa_keys + doc = make_jwks_response(public) + doc["keys"][0].update(changes) + httpx_mock.add_response(json=doc) + cache = JWKSCache("https://idp/keys", issuer=valid_payload["iss"], audience="my-client") + assert await cache.validate_jwt(sign_token(private, valid_payload)) is None + + +async def test_entra_template_key_issuer_is_bound_to_concrete_tenant(rsa_keys, httpx_mock): + private, public = rsa_keys + doc = make_jwks_response(public) + doc["keys"][0]["issuer"] = "https://login.microsoftonline.com/{tenantid}/v2.0" + httpx_mock.add_response(json=doc) + cache = JWKSCache( + "https://idp/keys", issuer="https://login.microsoftonline.com/tenant/v2.0", audience="app" + ) + claims = { + "iss": "https://login.microsoftonline.com/tenant/v2.0", + "sub": "user", + "aud": "app", + "exp": int(time.time()) + 300, + "iat": int(time.time()), + } + assert await cache.validate_jwt(sign_token(private, claims)) is not None + + +async def test_last_good_keys_survive_network_failure( + rsa_keys, valid_payload, httpx_mock, monkeypatch +): + private, public = rsa_keys + now = 10000 + monkeypatch.setattr("oidc.jwks.time", SimpleNamespace(monotonic=lambda: now)) + httpx_mock.add_response(json=make_jwks_response(public)) + httpx_mock.add_response(status_code=503) + cache = JWKSCache( + "https://idp/keys", ttl_seconds=0, issuer=valid_payload["iss"], audience="my-client" + ) + token = sign_token(private, valid_payload) + assert await cache.validate_jwt(token) + now += 31 + assert await cache.validate_jwt(token) + assert await cache.validate_jwt(token) + assert len(httpx_mock.get_requests()) == 2 diff --git a/modules/oidc/tests/test_oidc_jwt_policy.py b/modules/oidc/tests/test_oidc_jwt_policy.py new file mode 100644 index 000000000..45ba9d1c1 --- /dev/null +++ b/modules/oidc/tests/test_oidc_jwt_policy.py @@ -0,0 +1,124 @@ +"""Reject ambiguous token purpose and incomplete authenticated claims.""" + +import time + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from oidc.jwks import JWKSCache + + +@pytest.fixture +def signed(): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + cache = JWKSCache("https://idp/keys", issuer="https://idp", audience="app") + cache._keys = {"key": key.public_key()} + cache._fetched_at = time.monotonic() + payload = { + "iss": "https://idp", + "sub": "user", + "aud": "app", + "exp": int(time.time()) + 300, + "iat": int(time.time()), + } + + def sign(changes=None, missing=None): + claims = {**payload, **(changes or {})} + if missing: + claims.pop(missing) + return jwt.encode(claims, key, algorithm="RS256", headers={"kid": "key"}) + + return cache, sign + + +@pytest.mark.parametrize("claim", ["iss", "sub", "aud", "exp", "iat"]) +async def test_required_claim(signed, claim): + cache, sign = signed + assert await cache.validate_jwt(sign(missing=claim)) is None + + +@pytest.mark.parametrize( + "changes", + [ + {"sub": ""}, + {"sub": 3}, + {"exp": True}, + {"exp": "2000000000"}, + {"exp": 1e100}, + {"exp": float("inf")}, + {"iat": float("inf")}, + {"iat": "100"}, + {"iat": True}, + {"iat": 1e100}, + {"aud": ["app", "untrusted"]}, + {"aud": ["app", "api"], "azp": "other"}, + {"aud": ["app", "api"]}, + {"azp": "other"}, + ], +) +async def test_rejects_invalid_claims(signed, changes): + cache, sign = signed + assert await cache.validate_jwt(sign(changes)) is None + + +async def test_audience_is_per_call(signed): + cache, sign = signed + assert await cache.validate_jwt(sign(), audience="api") is None + assert await cache.validate_jwt(sign({"aud": "api"}), audience="api") is not None + + +async def test_api_token_azp_is_the_calling_client(signed): + cache, sign = signed + token = sign({"aud": "api", "azp": "some-spa"}) + assert await cache.validate_jwt(token, audience="api") is not None + + +async def test_wrong_signature_rejected(signed): + cache, _ = signed + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + token = jwt.encode( + { + "iss": "https://idp", + "sub": "u", + "aud": "app", + "exp": int(time.time()) + 300, + "iat": int(time.time()), + }, + other_key, + algorithm="RS256", + headers={"kid": "key"}, + ) + assert await cache.validate_jwt(token) is None + + +@pytest.mark.parametrize("token", ["", "not-a-jwt", "a.b.c"]) +async def test_malformed_tokens_rejected(signed, token): + cache, _ = signed + assert await cache.validate_jwt(token) is None + + +@pytest.mark.parametrize( + "algorithm,key", [("HS256", "attacker-secret-that-is-over-32-bytes"), ("none", None)] +) +async def test_algorithm_allowlist(signed, algorithm, key): + cache, _ = signed + token = jwt.encode( + { + "iss": "https://idp", + "sub": "user", + "aud": "app", + "iat": int(time.time()), + "exp": int(time.time()) + 300, + }, + key, + algorithm=algorithm, + headers={"kid": "key"}, + ) + assert await cache.validate_jwt(token) is None + + +async def test_small_clock_skew_is_tolerated(signed): + cache, sign = signed + # Issued "in the future" by an IdP clock a few seconds ahead of ours. + assert await cache.validate_jwt(sign({"iat": int(time.time()) + 5})) is not None + assert await cache.validate_jwt(sign({"iat": int(time.time()) + 60})) is None diff --git a/modules/oidc/tests/test_oidc_keycloak_flow.py b/modules/oidc/tests/test_oidc_keycloak_flow.py new file mode 100644 index 000000000..f2bf32308 --- /dev/null +++ b/modules/oidc/tests/test_oidc_keycloak_flow.py @@ -0,0 +1,116 @@ +"""Keycloak through the generic preset: discovery to renewed session to logout.""" + +import base64 +import json +import time +from urllib.parse import parse_qs, urlsplit + +import pytest +from itsdangerous import TimestampSigner +from oidc.models import OidcUserCache +from oidc_flow_helpers import _sign_id_token +from simple_module_test.session_cookie import forge_session_cookie +from sqlalchemy import select + +_ISSUER = "https://keycloak.test/realms/example" +_ENDPOINT = f"{_ISSUER}/protocol/openid-connect" + + +def _session(client): + value = client.cookies.get("session") + if not value or value == "null": + return {} + return json.loads(base64.b64decode(TimestampSigner("test-secret-key").unsign(value))) + + +@pytest.mark.parametrize( + "oidc_app", + [ + { + "provider": "generic", + "tenant_id": "", + "discovery_url": f"{_ISSUER}/.well-known/openid-configuration", + "roles_claim_path": "realm_access.roles", + } + ], + indirect=True, +) +@pytest.mark.parametrize("refresh_id_token", [True, False]) +async def test_keycloak_generic_login_refresh_logout( + oidc_app, flow_client, httpx_mock, refresh_id_token +): + assert oidc_app.state.oidc.metadata.issuer == _ISSUER + response = await flow_client.get("/api/oidc/auth/login") + query = parse_qs(urlsplit(response.headers["location"]).query) + assert response.headers["location"].startswith(f"{_ENDPOINT}/auth?") + assert query["code_challenge_method"] == ["S256"] + claims = { + "iss": _ISSUER, + "azp": "my-app", + "preferred_username": "alice", + "realm_access": {"roles": ["admin"]}, + "roles": [], + } + httpx_mock.add_response( + url=f"{_ENDPOINT}/token", + json={ + "id_token": _sign_id_token(nonce=query["nonce"][0], **claims), + "refresh_token": "keycloak-refresh", + }, + ) + response = await flow_client.get( + "/api/oidc/auth/callback", + params={ + "code": "keycloak-code", + "state": query["state"][0], + }, + ) + assert response.status_code == 303 + session = _session(flow_client) + assert session["user_ctx"]["roles"] == ["admin"] + assert session["user_ctx"]["name"] == "alice" + assert session["oidc_identity"] == [_ISSUER, "pairwise-123"] + async with oidc_app.state.sm.db.session_factory() as db: + row = (await db.execute(select(OidcUserCache))).scalar_one() + assert str(row.id) == session["user_ctx"]["id"] + assert row.subject == "pairwise-123" + + # Expire only the signed test cookie; the refreshed JWT uses the real clock. + session["expires_at"] = int(time.time()) - 1 + flow_client.cookies.clear() + flow_client.cookies.set( + "session", + forge_session_cookie("test-secret-key", session), + domain="testserver.local", + path="/", + ) + claims["realm_access"] = {"roles": ["user"]} + refreshed = {"refresh_token": "keycloak-rotated"} + if refresh_id_token: + refreshed["id_token"] = _sign_id_token(nonce="", **claims) + httpx_mock.add_response(url=f"{_ENDPOINT}/token", json=refreshed) + # Any request on the expired session triggers the refresh. Renewed, the + # visitor is still signed in and sent on to the app; a refresh without an + # ID token signs out, and the signed-out page renders. + response = await flow_client.get("/oidc/logged-out") + assert response.status_code == (303 if refresh_id_token else 200) + renewed = _session(flow_client) + if refresh_id_token: + assert renewed["user_ctx"]["id"] == session["user_ctx"]["id"] + assert renewed["user_ctx"]["roles"] == ["user"] + assert renewed["expires_at"] > time.time() + else: + assert "user_ctx" not in renewed + assert "oidc_refresh_token" not in renewed + grants = httpx_mock.get_requests(url=f"{_ENDPOINT}/token") + assert parse_qs(grants[-1].content.decode())["refresh_token"] == ["keycloak-refresh"] + + response = await flow_client.post("/oidc/logout") + assert response.status_code == 303 + assert response.headers["location"].startswith(f"{_ENDPOINT}/logout?") + logout = parse_qs(urlsplit(response.headers["location"]).query) + assert logout["post_logout_redirect_uri"] == ["http://testserver/oidc/logged-out"] + assert "id_token_hint" not in logout + response = await flow_client.get("/oidc/logged-out") + assert response.status_code == 200 + assert "user_ctx" not in _session(flow_client) diff --git a/modules/oidc/tests/test_oidc_logout.py b/modules/oidc/tests/test_oidc_logout.py new file mode 100644 index 000000000..11911561f --- /dev/null +++ b/modules/oidc/tests/test_oidc_logout.py @@ -0,0 +1,66 @@ +"""Signed-out landing, Inertia transport and local clearing during IdP failure.""" + +import base64 +import json +from urllib.parse import parse_qs, urlsplit + +import pytest +from itsdangerous import TimestampSigner +from simple_module_test.session_cookie import forge_session_cookie + +_INERTIA = {"X-Inertia": "true", "X-Inertia-Version": "1.0"} + + +async def test_public_logged_out_page(flow_client): + response = await flow_client.get("/oidc/logged-out", headers=_INERTIA) + assert response.status_code == 200, response.text + assert response.json()["component"] == "Oidc/LoggedOut" + + +async def test_logged_out_page_sends_a_signed_in_visitor_on(flow_client): + signed_in = { + "user_ctx": {"id": "u", "email": "a@example.com", "name": "A", "roles": []}, + "expires_at": 4_102_444_800, # 2100-01-01: no renewal attempt + } + flow_client.cookies.set("session", forge_session_cookie("test-secret-key", signed_in)) + response = await flow_client.get("/oidc/logged-out") + assert response.status_code == 303 + assert response.headers["location"] == "/dashboard/" + + +@pytest.mark.parametrize("inertia", [True, False]) +async def test_logout_redirect_and_session_clear(oidc_app, flow_client, inertia): + flow_client.cookies.set( + "session", + forge_session_cookie( + "test-secret-key", {"oidc_id_token": "old", "oidc_refresh_token": "old"} + ), + ) + response = await flow_client.post("/oidc/logout", headers=_INERTIA if inertia else {}) + assert response.status_code == (409 if inertia else 303) + destination = response.headers["x-inertia-location" if inertia else "location"] + query = parse_qs(urlsplit(destination).query) + assert query["post_logout_redirect_uri"] == ["http://testserver/oidc/logged-out"] + assert query["client_id"] == ["my-app"] + assert "id_token_hint" not in query + cookie = response.cookies.get("session") + if cookie and cookie != "null": + data = TimestampSigner("test-secret-key").unsign(cookie) + assert "oidc_refresh_token" not in json.loads(base64.b64decode(data)) + + +async def test_logout_falls_back_when_client_unavailable(oidc_app, flow_client, monkeypatch): + def unavailable(**kwargs): + raise RuntimeError("IdP offline") + + monkeypatch.setattr(oidc_app.state.oidc.client, "build_logout_url", unavailable) + response = await flow_client.post("/oidc/logout") + assert response.status_code == 303 + assert response.headers["location"] == "http://testserver/oidc/logged-out" + + +async def test_logout_without_end_session_endpoint(oidc_app, flow_client): + oidc_app.state.oidc.client._end_session_endpoint = "" + response = await flow_client.post("/oidc/logout", headers=_INERTIA) + assert response.status_code == 409 + assert response.headers["x-inertia-location"] == "http://testserver/oidc/logged-out" diff --git a/modules/oidc/tests/test_oidc_migration.py b/modules/oidc/tests/test_oidc_migration.py new file mode 100644 index 000000000..972703fa4 --- /dev/null +++ b/modules/oidc/tests/test_oidc_migration.py @@ -0,0 +1,66 @@ +"""The host's initial OIDC migration creates the actual identity schema.""" + +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy.dialects import postgresql +from sqlalchemy.exc import IntegrityError + + +def _migration(): + path = Path("host/migrations/versions/c9d2e4f6a810_oidc_initial_schema.py") + spec = spec_from_file_location("oidc_migration", path) + module = module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_oidc_migration_roundtrip(): + revision = _migration() + engine = sa.create_engine("sqlite://") + with engine.begin() as conn, Operations.context(MigrationContext.configure(conn)): + revision.upgrade() + columns = {c["name"] for c in sa.inspect(conn).get_columns("oidc_user_cache")} + assert columns == {"id", "issuer", "subject", "email", "full_name", "last_login_at"} + conn.execute( + sa.text( + "INSERT INTO oidc_user_cache (id, issuer, subject, email) " + "VALUES ('1', 'https://one', 'user', '')" + ) + ) + conn.execute( + sa.text( + "INSERT INTO oidc_user_cache (id, issuer, subject, email) " + "VALUES ('2', 'https://two', 'user', '')" + ) + ) + with pytest.raises(IntegrityError): + conn.execute( + sa.text( + "INSERT INTO oidc_user_cache (id, issuer, subject, email) " + "VALUES ('3', 'https://one', 'user', '')" + ) + ) + revision.downgrade() + assert not sa.inspect(conn).has_table("oidc_user_cache") + revision.upgrade() + + +def test_oidc_postgres_ddl(): + from io import StringIO + + output = StringIO() + with Operations.context( + MigrationContext.configure( + dialect=postgresql.dialect(), opts={"as_sql": True, "output_buffer": output} + ) + ): + _migration().upgrade() + sql = output.getvalue() + assert "UUID" in sql + assert "TIMESTAMP WITH TIME ZONE" in sql + assert "UNIQUE (issuer, subject)" in sql diff --git a/modules/oidc/tests/test_oidc_module.py b/modules/oidc/tests/test_oidc_module.py new file mode 100644 index 000000000..c27d1baa7 --- /dev/null +++ b/modules/oidc/tests/test_oidc_module.py @@ -0,0 +1,39 @@ +"""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"] + assert mod.meta.optional_depends_on == ["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" + + +def test_public_routes(): + from oidc.module import OidcModule + from simple_module_core.public_routes import PublicRouteRegistry + + registry = PublicRouteRegistry() + OidcModule().register_public_routes(registry) + for path in ( + "/oidc/login", + "/oidc/logged-out", + "/api/oidc/auth/login", + "/api/oidc/auth/callback", + ): + assert registry.matches("GET", path) + assert not registry.matches("GET", "/api/oidc/private") diff --git a/modules/oidc/tests/test_oidc_provider.py b/modules/oidc/tests/test_oidc_provider.py new file mode 100644 index 000000000..6af110c5e --- /dev/null +++ b/modules/oidc/tests/test_oidc_provider.py @@ -0,0 +1,131 @@ +"""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_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_requires_configured_uid(provider): + claims = {"sub": "fallback-sub"} + with pytest.raises(ValueError): + provider._subject(claims) + + +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 is None + 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 == [] + + +@pytest.mark.parametrize("email", [None, 7, ["a@b"]]) +def test_non_string_claims_do_not_break_the_context(provider, email): + ctx = provider._claims_to_user_context( + {"email": email, "preferred_username": 5, "name": "Alice"}, cache_id="id-1" + ) + assert (ctx.email, ctx.name) == ("", "Alice") + + +@pytest.mark.parametrize( + "path,claims", + [ + # Auth0/Okta namespace custom claims as URLs, dots included. + ("https://example.com/roles", {"https://example.com/roles": ["admin"]}), + ("realm_access.roles", {"realm_access": {"roles": ["admin"]}}), + ], +) +def test_roles_claim_path_exact_key_then_dotted_path(path, claims): + settings = OidcSettings( + provider="generic", + discovery_url="https://issuer/.well-known/openid-configuration", + client_id="my-app", + client_secret="secret", + roles_claim_path=path, + ) + ctx = OidcAuthProvider(settings)._claims_to_user_context( + {"sub": "s-1", **claims}, cache_id="cache" + ) + assert ctx.roles == ["admin"] diff --git a/modules/oidc/tests/test_oidc_refresh.py b/modules/oidc/tests/test_oidc_refresh.py new file mode 100644 index 000000000..7cebc7c51 --- /dev/null +++ b/modules/oidc/tests/test_oidc_refresh.py @@ -0,0 +1,248 @@ +"""Browser sessions renew authority at access-token expiry.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock +from urllib.parse import parse_qs + +import httpx +import pytest +from auth.contracts.schemas import UserContext +from oidc.client import OIDCClient +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings +from oidc.tokens import open_refresh_token, seal_refresh_token +from starlette.requests import Request + + +@pytest.fixture +def refresh_session(monkeypatch): + monkeypatch.setattr("time.time", lambda: 1000) + settings = OidcSettings( + discovery_url="https://idp.test/.well-known/openid-configuration", + client_id="app", + client_secret="secret", + roles_claim_path="roles", + role_mapping={"reader": "reader", "admin": "admin"}, + ) + provider = OidcAuthProvider(settings) + provider.jwks_cache = SimpleNamespace( + validate_jwt=AsyncMock( + return_value={ + "sub": "subject", + "iss": "https://idp.test", + "email": "new@example.com", + "preferred_username": "New", + "exp": 1300, + "roles": ["reader"], + } + ) + ) + app = SimpleNamespace( + state=SimpleNamespace( + oidc=SimpleNamespace( + settings=settings, + client=OIDCClient( + authorization_endpoint="https://idp.test/authorize", + token_endpoint="https://idp.test/token", + end_session_endpoint="", + client_id="app", + client_secret="secret", + ), + ), + sm=SimpleNamespace(settings=SimpleNamespace(secret_key="test-secret-key")), + ) + ) + session = { + "user_ctx": UserContext( + id="user", email="old@example.com", name="Old", roles=["admin"] + ).to_session_dict(), + "oidc_identity": ["https://idp.test", "subject"], + "oidc_refresh_token": seal_refresh_token(app, "refresh-old"), + "expires_at": 999, + "gate": "unlocked", + } + request = Request({"type": "http", "headers": [], "session": session, "app": app}) + return provider, request + + +def _plain(request): + return open_refresh_token(request.app, request.session["oidc_refresh_token"]) + + +async def test_expired_session_refreshes_roles_and_deadline(refresh_session, httpx_mock): + provider, request = refresh_session + httpx_mock.add_response( + url="https://idp.test/token", + method="POST", + json={"id_token": "access-new", "refresh_token": "refresh-new"}, + ) + principal = await provider.resolve_user(request) + assert principal is not None + assert principal.roles == ["reader"] + assert principal.email == "new@example.com" + assert principal.auth_kind == "session" + assert request.session["user_ctx"] == principal.to_session_dict() + assert request.session["expires_at"] == 1300 + assert _plain(request) == "refresh-new" + sent = httpx_mock.get_request() + assert parse_qs(sent.content.decode()) == { + "grant_type": ["refresh_token"], + "refresh_token": ["refresh-old"], + "client_id": ["app"], + "client_secret": ["secret"], + } + assert sent.extensions["timeout"]["read"] <= 10 + provider.jwks_cache.validate_jwt.assert_awaited_once_with("access-new", audience="app") + # A second resolution in the same request must not issue another grant. + assert (await provider.resolve_user(request)).roles == ["reader"] + request.session["expires_at"] = 999 + assert await provider.resolve_user(request) is None + assert len(httpx_mock.get_requests()) == 1 + + +@pytest.mark.parametrize("failure", ["invalid_grant", "invalid_token", "expired_token"]) +async def test_failed_refresh_clears_auth(refresh_session, httpx_mock, failure): + provider, request = refresh_session + if failure == "invalid_grant": + httpx_mock.add_response(status_code=400, json={"error": "invalid_grant"}) + else: + httpx_mock.add_response(json={"id_token": "access-new", "refresh_token": "refresh-new"}) + provider.jwks_cache.validate_jwt.return_value = ( + None if failure == "invalid_token" else {"sub": "subject", "exp": 999} + ) + assert await provider.resolve_user(request) is None + assert request.session == {"gate": "unlocked"} + assert await provider.resolve_user(request) is None + assert len(httpx_mock.get_requests()) == 1 + + +async def test_expired_session_without_refresh_token(refresh_session): + provider, request = refresh_session + del request.session["oidc_refresh_token"] + assert await provider.resolve_user(request) is None + assert request.session == {"gate": "unlocked"} + + +async def test_refresh_keeps_token_when_not_rotated(refresh_session, httpx_mock): + provider, request = refresh_session + httpx_mock.add_response(json={"id_token": "access-new"}) + assert await provider.resolve_user(request) is not None + assert _plain(request) == "refresh-old" + + +async def test_bearer_does_not_refresh_browser_session(refresh_session, monkeypatch): + provider, request = refresh_session + request.scope["headers"] = [(b"authorization", b"Bearer access-bearer")] + monkeypatch.setattr(provider, "_resolve_bearer", AsyncMock(return_value=None)) + assert await provider.resolve_user(request) is None + assert _plain(request) == "refresh-old" + assert request.session["expires_at"] == 999 + + +@pytest.mark.parametrize("failure", ["network", "server_error", "rate_limited"]) +async def test_transient_refresh_failure_keeps_session_for_retry( + refresh_session, httpx_mock, failure +): + provider, request = refresh_session + if failure == "network": + httpx_mock.add_exception(httpx.ConnectError("offline")) + elif failure == "rate_limited": + httpx_mock.add_response(status_code=429) + else: + httpx_mock.add_response(status_code=503) + sealed = request.session["oidc_refresh_token"] + assert await provider.resolve_user(request) is None + assert request.session["oidc_refresh_token"] == sealed + assert "user_ctx" in request.session + + +async def test_refresh_token_is_not_stored_in_plaintext(refresh_session): + _, request = refresh_session + assert "refresh-old" not in request.session["oidc_refresh_token"] + + +@pytest.mark.parametrize("tamper", ["garbage", "other-key"]) +async def test_undecryptable_refresh_token_requires_sign_in(refresh_session, tamper): + provider, request = refresh_session + if tamper == "garbage": + request.session["oidc_refresh_token"] = "not-a-fernet-token" + else: + request.app.state.sm.settings.secret_key = "rotated-secret" + assert await provider.resolve_user(request) is None + assert request.session == {"gate": "unlocked"} + + +@pytest.mark.parametrize("changes", [{"iss": "https://other"}, {"sub": "other"}, {"sub": ""}]) +async def test_refresh_refuses_identity_switch(refresh_session, httpx_mock, changes): + provider, request = refresh_session + provider.jwks_cache.validate_jwt.return_value.update(changes) + httpx_mock.add_response(json={"id_token": "access-new"}) + assert await provider.resolve_user(request) is None + assert request.session == {"gate": "unlocked"} + + +@pytest.mark.parametrize("deadline", [None, True, "invalid", 999, 1000, float("inf"), float("nan")]) +async def test_unbounded_or_expired_session_without_refresh_requires_login( + refresh_session, deadline +): + provider, request = refresh_session + request.session.pop("oidc_refresh_token") + request.session.pop("expires_at") + if deadline is not None: + request.session["expires_at"] = deadline + assert await provider.resolve_user(request) is None + assert request.session == {"gate": "unlocked"} + + +async def test_session_deadline_never_slides(refresh_session, monkeypatch): + provider, request = refresh_session + request.session["expires_at"] = 1001 + assert await provider.resolve_user(request) is not None + assert request.session["expires_at"] == 1001 + + +async def test_refresh_sealing_is_provider_scoped(refresh_session): + from keycloak.tokens import open_refresh_token as open_keycloak_token + + _, request = refresh_session + sealed = request.session["oidc_refresh_token"] + assert open_refresh_token(request.app, sealed) == "refresh-old" + assert open_keycloak_token(request.app, sealed) is None + + +async def test_transient_failure_backs_off_before_calling_the_idp_again( + refresh_session, httpx_mock, monkeypatch +): + provider, request = refresh_session + httpx_mock.add_response(status_code=503) + assert await provider.resolve_user(request) is None + assert request.session["oidc_refresh_retry_at"] == 1030 + # A later request inside the back-off window makes no token call at all + # (httpx_mock would fail on an unexpected request). + request.state.oidc_refresh_attempted = False + monkeypatch.setattr("time.time", lambda: 1029) + assert await provider.resolve_user(request) is None + assert len(httpx_mock.get_requests()) == 1 + # After it, the retry goes out and success clears the marker. + monkeypatch.setattr("time.time", lambda: 1031) + httpx_mock.add_response( + url="https://idp.test/token", method="POST", json={"id_token": "id-new"} + ) + request.state.oidc_refresh_attempted = False + assert await provider.resolve_user(request) is not None + assert "oidc_refresh_retry_at" not in request.session + + +async def test_refresh_token_too_large_for_the_cookie_is_not_stored(refresh_session, httpx_mock): + provider, request = refresh_session + httpx_mock.add_response( + url="https://idp.test/token", + method="POST", + json={"id_token": "id-new", "refresh_token": "r" * 3000}, + ) + principal = await provider.resolve_user(request) + # Renewed for this ID token's lifetime; without a refresh token the session + # ends at that expiry instead of overflowing the browser's cookie limit. + assert principal is not None + assert request.session["expires_at"] == 1300 + assert "oidc_refresh_token" not in request.session diff --git a/modules/oidc/tests/test_oidc_session_transaction.py b/modules/oidc/tests/test_oidc_session_transaction.py new file mode 100644 index 000000000..30386d4f7 --- /dev/null +++ b/modules/oidc/tests/test_oidc_session_transaction.py @@ -0,0 +1,221 @@ +"""Login replaces user-bound session state, consumes the transaction and seals tokens.""" + +import base64 +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import HTTPException +from oidc.endpoints import api +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings +from oidc.tokens import open_refresh_token +from simple_module_hosting.csrf import get_csrf_token +from simple_module_hosting.session import SessionMiddleware, preserve_across_sign_in +from starlette.requests import Request +from starlette.responses import Response + + +@pytest.fixture +def callback(monkeypatch): + settings = OidcSettings(client_id="app") + provider = OidcAuthProvider(settings) + monkeypatch.setattr(provider, "_upsert_user_cache", AsyncMock(return_value="uuid")) + claims = {"iss": "https://idp", "sub": "user", "nonce": "nonce", "exp": 2000} + app = SimpleNamespace( + state=SimpleNamespace( + oidc=SimpleNamespace( + settings=settings, + client=SimpleNamespace( + exchange_code=AsyncMock( + return_value={"id_token": "id", "refresh_token": "refresh"} + ) + ), + jwks_cache=SimpleNamespace(validate_jwt=AsyncMock(return_value=claims)), + ), + auth=SimpleNamespace(auth_provider=provider), + sm=SimpleNamespace(settings=SimpleNamespace(secret_key="test-secret-key")), + ) + ) + preserve_across_sign_in(app, "gate") + request = Request( + { + "type": "http", + "app": app, + "query_string": b"code=code&state=state", + "session": { + "oidc_state": "state", + "oidc_nonce": "nonce", + "oidc_pkce_verifier": "verifier", + "next": "/deep-link", + "active_tenant_id": "old", + "remember": True, + "gate": "unlocked", + "old-state": "old", + }, + } + ) + request.url_for = lambda name: "https://app/callback" + monkeypatch.setattr("time.time", lambda: 1000) + return request, claims + + +async def test_sign_in_rotates_and_preserves_only_registered_keys(callback): + request, _ = callback + old_csrf = get_csrf_token(request) + response = await api.oidc_callback(request) + assert response.headers["location"] == "/deep-link" + assert request.session["user_ctx"]["id"] == "uuid" + assert request.session["expires_at"] == 2000 + assert request.session["oidc_identity"] == ["https://idp", "user"] + assert request.session["gate"] == "unlocked" + assert get_csrf_token(request) != old_csrf + assert ( + not { + "active_tenant_id", + "remember", + "old-state", + "next", + "oidc_id_token", + "oidc_state", + "oidc_nonce", + "oidc_pkce_verifier", + } + & request.session.keys() + ) + assert open_refresh_token(request.app, request.session["oidc_refresh_token"]) == "refresh" + sent = request.app.state.oidc.client.exchange_code.call_args.kwargs + assert sent == { + "code": "code", + "redirect_uri": "https://app/callback", + "code_verifier": "verifier", + } + + +@pytest.mark.parametrize("nonce", [None, 42, "wrong", "é"]) +async def test_callback_checks_nonce_and_consumes_transaction(callback, nonce): + request, claims = callback + claims["nonce"] = nonce + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 401 + assert "user_ctx" not in request.session + # The transaction is spent: a replay is a stale sign-in, not a second try. + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 419 + + +@pytest.mark.parametrize("missing", ["oidc_nonce", "oidc_pkce_verifier"]) +async def test_missing_saved_login_material(callback, missing): + request, _ = callback + request.session.pop(missing) + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize("unsafe", ["https://evil.test/", "//evil.test/", " /\\evil.test"]) +async def test_saved_next_sanitized(callback, unsafe): + request, _ = callback + request.session["next"] = unsafe + response = await api.oidc_callback(request) + assert response.headers["location"] == "/dashboard/" + + +@pytest.mark.parametrize("expiry", [None, True, "2000", 999, float("inf")]) +async def test_callback_defends_expiry_boundary(callback, expiry): + request, claims = callback + claims["exp"] = expiry + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 401 + assert "user_ctx" not in request.session + + +async def test_token_exchange_failure_consumes_login_transaction(callback): + request, _ = callback + request.app.state.oidc.client.exchange_code.side_effect = RuntimeError("sensitive body") + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 502 + assert "oidc_pkce_verifier" not in request.session + + +async def test_cache_failure_refuses_session(callback): + request, _ = callback + request.app.state.auth.auth_provider._upsert_user_cache.return_value = None + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 503 + assert "user_ctx" not in request.session + + +async def test_realistic_cookie_fits(callback): + request, claims = callback + claims["roles"] = [f"department-{n:02d}-administrator" for n in range(20)] + settings = request.app.state.oidc.settings + settings.roles_claim_path = "roles" + settings.role_mapping = {role: role for role in claims["roles"]} + request.app.state.oidc.client.exchange_code.return_value = { + "id_token": "i" * 1229, + "refresh_token": "r" * 922, + } + await api.oidc_callback(request) + + async def app(scope, receive, send): + scope["session"].update(request.session) + await Response()(scope, receive, send) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=SessionMiddleware(app, secret_key="test")), + base_url="https://app.test", + ) as client: + response = await client.get("/") + cookie = response.headers["set-cookie"] + assert len(cookie.encode()) < 4096 + payload = json.loads(base64.b64decode(cookie.split("=", 1)[1].split(".", 1)[0])) + assert "oidc_id_token" not in payload + assert "r" * 922 not in payload["oidc_refresh_token"] + + +async def test_idp_error_is_reported_and_consumes_login_transaction(callback): + request, _ = callback + request.scope["query_string"] = b"error=access_denied&state=state" + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 401 + assert "refused" in exc.value.detail + assert "oidc_state" not in request.session + request.app.state.oidc.client.exchange_code.assert_not_awaited() + + +async def test_non_object_token_response_is_a_bad_gateway(callback): + request, _ = callback + request.app.state.oidc.client.exchange_code.return_value = ["not", "an", "object"] + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 502 + + +async def test_forged_error_reply_cannot_spend_a_victims_login(callback): + # An error reply without the victim's state is a state mismatch, not a + # refusal, and it does not consume the pending login transaction. + request, _ = callback + request.scope["query_string"] = b"error=access_denied&state=forged" + with pytest.raises(HTTPException) as exc: + await api.oidc_callback(request) + assert exc.value.status_code == 419 + # The victim's pending login is still there to complete. + assert {"oidc_state", "oidc_nonce", "oidc_pkce_verifier"} <= request.session.keys() + + +async def test_replayed_callback_of_a_signed_in_user_continues_to_the_app(callback): + # Browser Back after signing in replays the spent callback URL. + request, _ = callback + await api.oidc_callback(request) + response = await api.oidc_callback(request) + assert response.status_code == 303 + assert response.headers["location"] == "/dashboard/" 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 000000000..2314059c2 --- /dev/null +++ b/modules/oidc/tests/test_oidc_settings_presets.py @@ -0,0 +1,119 @@ +"""Tests for preset-driven OidcSettings resolution.""" + +from __future__ import annotations + +import pytest +from oidc.settings import OidcSettings +from pydantic import ValidationError + + +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 offline_access" + + +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_rejected(): + with pytest.raises(ValidationError, match="Unknown OIDC preset"): + OidcSettings(provider="does-not-exist") + + +@pytest.mark.parametrize("tenant", ["common", "organizations", "consumers", "COMMON"]) +def test_entra_single_tenant_only(tenant): + with pytest.raises(ValidationError, match="single-tenant"): + OidcSettings(provider="entra", tenant_id=tenant) + + +def test_bare_environment_cannot_override_db_policy(monkeypatch): + monkeypatch.setenv("UID_CLAIM", "attacker") + monkeypatch.setenv("SM_OIDC_UID_CLAIM", "attacker") + assert OidcSettings().uid_claim == "sub" + + +def test_redirect_and_bearer_defaults(): + settings = OidcSettings(client_id="app", login_redirect_url=" ") + assert settings.login_redirect_url == "/dashboard/" + assert settings.api_audience == "" + assert settings.trust_tenant_claim is False + + +def test_production_missing_fields_have_field_location(monkeypatch): + monkeypatch.setenv("SM_ENVIRONMENT", "production") + with pytest.raises(ValidationError) as exc: + OidcSettings(discovery_url="", client_id="", client_secret="") + assert exc.value.errors()[0]["loc"] == ("discovery_url",) + + +def test_restart_fields_marked(): + fields = OidcSettings.model_fields + for name in ( + "provider", + "discovery_url", + "tenant_id", + "client_id", + "client_secret", + "jwks_cache_ttl_seconds", + ): + extra = fields[name].json_schema_extra + assert isinstance(extra, dict) + assert extra["requires_restart"] is True + + +def test_roles_claim_path_is_seeded_from_env_for_the_first_admin(): + # Read at import time like the other bootstrap fields, so check it in a + # fresh interpreter rather than reloading the module under test. + import os + import subprocess + import sys + + code = "from oidc.settings import OidcSettings; print(OidcSettings().roles_claim_path)" + env = {**os.environ, "SM_OIDC_ROLES_CLAIM_PATH": "realm_access.roles"} + out = subprocess.run( + [sys.executable, "-c", code], env=env, capture_output=True, text=True, check=True + ) + assert out.stdout.strip() == "realm_access.roles" diff --git a/modules/oidc/tests/test_oidc_settings_runtime.py b/modules/oidc/tests/test_oidc_settings_runtime.py new file mode 100644 index 000000000..13568c783 --- /dev/null +++ b/modules/oidc/tests/test_oidc_settings_runtime.py @@ -0,0 +1,34 @@ +"""OIDC policy uses hydrated settings even without the Settings UI (#192).""" + +from oidc.settings import OidcSettings +from settings.invalidation import reload_package +from settings.service import SettingService +from settings.store import SettingsStore +from simple_module_core.module_settings import module_settings_registry + + +async def test_boot_hydrates_db_settings_without_settings_ui(oidc_app): + assert not hasattr(oidc_app.state, "settings") + assert module_settings_registry(oidc_app).get("oidc") is OidcSettings + assert oidc_app.state.oidc.settings.client_id == "my-app" + provider = oidc_app.state.auth.auth_provider + ctx = provider._claims_to_user_context( + {"oid": "user", "roles": ["admin"], "preferred_username": "Alice"}, cache_id="uuid" + ) + assert ctx.roles == ["admin"] + assert ctx.name == "Alice" + assert provider._subject({"oid": "object-id", "sub": "pairwise"}) == "object-id" + + +async def test_mapping_reload_changes_provider_policy(oidc_app): + provider = oidc_app.state.auth.auth_provider + claims = {"roles": ["admin"], "realm_access": {"roles": ["editor"]}} + assert provider._claims_to_user_context(claims, cache_id="uuid").roles == ["admin"] + async with oidc_app.state.sm.db.session_factory() as db: + store = SettingsStore(SettingService(db)) + await store.set_override("oidc", "roles_claim_path", "realm_access.roles", "string") + await store.set_override("oidc", "role_mapping", '{"editor": "writer"}', "json") + await db.commit() + await reload_package(oidc_app, oidc_app.state.sm.event_bus, store, "oidc") + assert oidc_app.state.auth.auth_provider is provider + assert provider._claims_to_user_context(claims, cache_id="uuid").roles == ["writer"] diff --git a/modules/oidc/tests/test_oidc_tenant_resolver.py b/modules/oidc/tests/test_oidc_tenant_resolver.py new file mode 100644 index 000000000..f84d83a93 --- /dev/null +++ b/modules/oidc/tests/test_oidc_tenant_resolver.py @@ -0,0 +1,51 @@ +"""A forged ``tenant_id`` JWT claim never wins over the ``tenants`` resolver (#376). + +Lives apart from ``test_oidc_provider.py`` because that file overrides the +``settings`` fixture, which the app-level ``app`` fixture depends on. +""" + +from __future__ import annotations + +from oidc.provider import OidcAuthProvider +from oidc.settings import OidcSettings +from starlette.requests import Request + +_USER_ID = "11111111-1111-1111-1111-111111111111" + + +def _request(app, user) -> Request: + request = Request( + { + "type": "http", + "app": app, + "method": "GET", + "path": "/", + "query_string": b"", + "headers": [(b"host", b"testserver")], + } + ) + request.state.user = user + return request + + +def _ctx(trust: bool, tenant: str): + provider = OidcAuthProvider(OidcSettings(trust_tenant_claim=trust)) + claims = {"sub": "kc-t", "email": "t@example.com", "tenant_id": tenant} + return provider._claims_to_user_context(claims, cache_id=_USER_ID) + + +async def test_forged_claim_is_not_the_tenant_when_a_resolver_is_registered(app): + assert app.state.tenant_resolver is not None + # Trusting the claim is the worst case: it is on the UserContext, yet the + # resolver answers from memberships (this user has none). + ctx = _ctx(True, "forged-tenant") + assert (await app.state.tenant_resolver(_request(app, ctx))).tenant_id is None + + +async def test_forged_claim_never_overrides_a_real_membership(app, tenant_client): + async with tenant_client() as member: + ctx = _ctx(True, "forged-tenant") + ctx.id = member.user_id + request = _request(app, ctx) + assert (await app.state.tenant_resolver(request)).tenant_id == member.tenant_id + assert request.state.user.tenant_id == member.tenant_id diff --git a/modules/oidc/tsconfig.json b/modules/oidc/tsconfig.json new file mode 100644 index 000000000..aead18015 --- /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 472768107..14996bf41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -174,6 +174,10 @@ "name": "@simple-module/keycloak", "version": "0.0.42" }, + "modules/oidc": { + "name": "@simple-module/oidc", + "version": "0.0.42" + }, "modules/permissions": { "name": "@simple-module-py/permissions", "version": "0.0.42", @@ -2827,6 +2831,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/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index c3c41f26d..7ad86b1b1 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -666,6 +666,12 @@ export default { 'keycloak.logout.sign_in_again': '', 'keycloak.logout.title': '', 'keycloak.nav.logout': '', + 'oidc.login.manual': '', + 'oidc.login.redirecting': '', + 'oidc.logout.message': '', + 'oidc.logout.sign_in_again': '', + 'oidc.logout.title': '', + 'oidc.nav.logout': '', 'permissions.audit.role_permission': '', 'permissions.browse.count_label': '', 'permissions.browse.count_label_one': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 318f1f8b5..e283cc061 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -862,6 +862,20 @@ export const keys = { logout: 'keycloak.nav.logout', }, }, + oidc: { + login: { + manual: 'oidc.login.manual', + redirecting: 'oidc.login.redirecting', + }, + logout: { + message: 'oidc.logout.message', + sign_in_again: 'oidc.logout.sign_in_again', + title: 'oidc.logout.title', + }, + nav: { + logout: 'oidc.nav.logout', + }, + }, permissions: { audit: { role_permission: 'permissions.audit.role_permission', diff --git a/pyproject.toml b/pyproject.toml index e9f60b69e..712f04288 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ extra-paths = [ "modules/settings", "modules/feature_flags", "modules/keycloak", + "modules/oidc", "modules/audit_log", "modules/site_lock", "modules/branding", @@ -133,7 +134,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/inertia/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", "modules/branding/tests", "modules/site_lock/tests", "modules/tenants/tests", "modules/api_tokens/tests", "modules/mail/tests", "modules/sharing/tests", "scripts/tests", "tests"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/inertia/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", "modules/branding/tests", "modules/site_lock/tests", "modules/tenants/tests", "modules/api_tokens/tests", "modules/mail/tests", "modules/sharing/tests", "scripts/tests", "tests"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", diff --git a/uv.lock b/uv.lock index 943d1de4f..3f2c0c62a 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "simple-module-inertia", "simple-module-keycloak", "simple-module-mail", + "simple-module-oidc", "simple-module-permissions", "simple-module-python", "simple-module-settings", @@ -3465,6 +3466,31 @@ requires-dist = [ { name = "simple-module-settings", editable = "modules/settings" }, ] +[[package]] +name = "simple-module-oidc" +version = "0.0.42" +source = { editable = "modules/oidc" } +dependencies = [ + { name = "cryptography" }, + { name = "httpx" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "simple-module-auth" }, + { name = "simple-module-core" }, + { name = "simple-module-db" }, + { name = "simple-module-hosting" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=42" }, + { name = "httpx", specifier = ">=0.27,<0.29" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8,<3" }, + { name = "simple-module-auth", editable = "modules/auth" }, + { name = "simple-module-core", editable = "framework/core" }, + { name = "simple-module-db", editable = "framework/db" }, + { name = "simple-module-hosting", editable = "framework/hosting" }, +] + [[package]] name = "simple-module-permissions" version = "0.0.42"