Skip to content

feat(auth): add generic OpenID Connect (OIDC) single sign-on#3508

Open
holden093 wants to merge 30 commits into
odysseus-dev:devfrom
holden093:feat/oidc-sso
Open

feat(auth): add generic OpenID Connect (OIDC) single sign-on#3508
holden093 wants to merge 30 commits into
odysseus-dev:devfrom
holden093:feat/oidc-sso

Conversation

@holden093

@holden093 holden093 commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds generic OpenID Connect (OIDC) single sign-on as an alternative login
method. Users can sign in via any standard OIDC provider (Authentik,
Keycloak, Authelia, Google, etc.) while existing password authentication
remains fully functional. New OIDC users are auto-created on first login,
and admin status can be driven by IdP group membership.

Uses authlib for provider discovery (.well-known), JWT/JWKS
signature verification, and the authorization code flow — avoiding the
security risk of hand-rolling OIDC logic.

Built with AI assistance (see the Co-Authored-By trailer).

Target branch

  • This PR targets dev, not main.

Linked Issue

Closes #806

Type of Change

  • Bug fix (non-breaking — fixes a confirmed issue)
  • New feature (non-breaking — adds new behaviour)
  • Breaking change (changes or removes existing behaviour)
  • Refactor / cleanup (behaviour unchanged)
  • Documentation only
  • CI / tooling / configuration

Checklist

  • I searched open issues and open PRs — this is not a duplicate.
  • This PR targets dev
  • My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
  • I actually ran the app and verified the change works end-to-end (tested with a live Authentik instance).
  • I added new dependencies to requirements.txt and wired corresponding env vars through docker-compose.yml.

How to Test

  1. Run the unit tests:

    python -m pytest tests/test_oidc_auth.py tests/test_oidc_manager.py tests/test_oidc_routes.py -v
    

    85 tests covering OidcManager (mocked HTTP + real RSA JWT signing), OIDC route handlers (login redirect, callback success/failure, error paths), AuthManager OIDC methods (user creation, lookup, idempotency, username collision, password rejection), and admin group mapping (promotion, demotion, no-op).

  2. Verify existing auth tests pass:

    python -m pytest tests/test_auth_regressions.py tests/test_auth_session_revocation.py tests/test_totp_failclosed.py tests/test_reserved_username_admin_escalation.py tests/test_gpu_compose_standalone.py -v
    
  3. Verify the app starts without OIDC configured (default):

    DATABASE_URL=sqlite:///:memory: python -c "
    import os; os.environ['OIDC_ENABLED']='false'
    from core.auth import AuthManager
    from core.oidc import init_oidc_manager
    assert init_oidc_manager() is None
    print('OK')
    "
    
  4. End-to-end with a real OIDC provider:

    • Set OIDC_ENABLED=true, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in .env
    • docker compose up -d --build
    • Visit http://localhost:7000/login — a "Sign in with …" button appears below the password form
    • Click it, authenticate at the IdP, verify you are redirected back and logged in
    • Open Settings → Account: "Change Password" and "Two-Factor Authentication" cards are hidden
    • Log out, log in with password credentials: still works

Visual / UI changes

Login page — SSO button below the password form when OIDC is configured, with a divider. Styled to match the existing login form using theme variables.

Settings → Account — "Change Password" and "Two-Factor Authentication" cards hidden for OIDC users (they have no password and authenticate through the IdP).

Configuration reference

Variable Default Description
OIDC_ENABLED false Master toggle
OIDC_ISSUER Provider issuer URL
OIDC_CLIENT_ID Client ID
OIDC_CLIENT_SECRET Client secret
OIDC_SCOPES openid profile email Requested scopes
OIDC_ADMIN_GROUPS Comma-separated group names that grant admin

🤖 Generated with Claude Code

@github-actions github-actions Bot added needs work PR description incomplete — please update before review ready for review Description complete — ready for maintainer review and removed needs work PR description incomplete — please update before review labels Jun 8, 2026

@vdmkenny vdmkenny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First-pass review (auth flow only; not exhaustive). Up front on process: the project's current focus is bug-fixing and code cleanup, so large new features carry a high bar, and AI-generated ones especially. A feature of this size and sensitivity (authentication) needs sign-off from multiple maintainers before it can be considered, so treat this as a direction/quality pass rather than a path to near-term merge.

Credit where due: using authlib for discovery + JWKS verification instead of hand-rolling, verifying iss/aud/exp/nonce, server-side state CSRF, passwordless OIDC users that can't be password-authenticated, and (sub, issuer) identity keying with suffixed username collisions (no account takeover) are all the right calls. OIDC_ENABLED defaulting off is correct.

Issues to address:

  1. aud rejects valid multi-audience tokens. core/oidc.py: if claims.get("aud") != self.client_id. Per the OIDC spec aud may be a JSON array. A token with aud: ["odysseus"] (or client_id plus extras) is wrongly rejected. Normalize to a list and check membership (and, when aud has multiple values, validate azp == client_id).

  2. redirect_uri is derived from request.base_url (Host header). Built from the inbound request in both /login and /callback. Behind a proxy this depends on trusted forwarded headers, and a spoofed Host can poison the value. IdP allow-listing limits the blast radius, but please support a configurable fixed public base / OIDC_REDIRECT_URI and stop trusting the Host header for this.

  3. Possible zero-admin lockout. Admin is derived solely from OIDC_ADMIN_GROUPS intersecting the groups claim, re-synced every login, with no fallback. In an OIDC-only deployment with OIDC_ADMIN_GROUPS unset (or after a group is removed), there is no admin at all. Needs a documented bootstrap path (e.g. keep a password admin, or first-OIDC-user-is-admin guard) so an install can't end up unadministrable.

  4. JWKS fetched on every login. _verify_id_token does httpx.get(jwks_uri) per call. Cache the key set (with refresh on unknown kid) so sign-in does not depend on a live IdP round-trip each time and to avoid the added latency / failure surface.

  5. Signing alg not pinned. jwt.decode(id_token, key_set) has no algorithm allow-list. authlib + a typed RSA key set mitigates alg-confusion, but pin the expected algs explicitly (e.g. from the discovery doc's id_token_signing_alg_values_supported, restricted to RS256/ES256) rather than relying on key typing.

  6. In-memory state store breaks multi-worker. _state_store is a module-level dict, so under uvicorn --workers >1 (or multiple processes) the callback can land on a worker without the state and fail intermittently. Either document single-worker-only or back it with a shared store.

I did not deep-review the 3 test files or the app.py/UI wiring in this pass, and CI's Python tests have not reported yet on this branch. Not a merge recommendation either way.

@vdmkenny

vdmkenny commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Cross-linking prior art for maintainer context: this is the third iteration of this feature. The author's earlier copies #3487 and #3301 were both closed (superseded by this PR), and the tracking issue is #806 (already linked above). No other OIDC/SSO PRs or issues exist.

Noting it so the multi-maintainer review can see the lineage; the review feedback to act on is in this PR's first-pass review above.

@holden093

Copy link
Copy Markdown
Contributor Author

All six review items addressed in d53cdc8:

  1. aud arrays — now handles both string and list audiences; validates azp for multi-audience tokens
  2. JWKS caching — cached after first fetch, refreshed only on unknown kid
  3. Algorithm pinning — restricted to RS256/ES256/PS256 family from discovery doc
  4. OIDC_REDIRECT_URI — new env var for fixed redirect URI (proxy-safe, doesn't trust Host header)
  5. Stateless state — Fernet-encrypted state tokens replace the in-memory dict (works across multiple workers)
  6. First-user-admin bootstrapOIDC_FIRST_USER_IS_ADMIN=true (default) prevents zero-admin lockout

Tests: 105 passing (63 OIDC + 42 regression), 0 failures. Ready for re-review.

@vdmkenny vdmkenny left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of d53cdc8. The six items from the previous pass are addressed: aud arrays + azp, JWKS caching, algorithm pinning, OIDC_REDIRECT_URI, Fernet stateless state, and the first-user-admin bootstrap. Linking on (sub, issuer) rather than email is the right call and avoids email-based account takeover. Thank you for the thorough rework.

This pass surfaced five issues that need to be resolved before this can move forward. The first is a functional lockout; the rest are security-relevant for an authentication feature, so I am not treating any of them as optional.

1. Bootstrap admin is demoted on the second login (zero-admin lockout).
routes/oidc_routes.py runs set_oidc_user_admin(username, is_admin) on every login for an existing user, and is_admin is computed solely from OIDC_ADMIN_GROUPS (lines 136-143). When that env var is unset (the exact scenario the bootstrap exists for):

  • First login: create_user_oidc promotes the first user to admin.
  • Second login: the user is found, is_admin is False, so set_oidc_user_admin(user, False) demotes them. The install now has no admin.

This nullifies fix #6. Only sync admin from groups when group-based management is actually configured: skip set_oidc_user_admin entirely when OIDC_ADMIN_GROUPS is unset/empty, so bootstrap and manually-granted admin persist.

2. state is not bound to the browser session (login CSRF).
The state token is Fernet-encrypted but carries no binding to the browser that initiated the flow, and nothing is stored client-side at /login. An attacker can obtain an authorization code for their own identity and hand the victim a crafted /callback?code=...&state=... URL; the victim's browser redeems it and is silently signed in as the attacker. The nonce only prevents id_token replay, not this. Bind state to a value set in an HttpOnly cookie at /login and verify it matches at /callback (the standard OIDC state-as-CSRF-token pattern).

3. Unknown-kid JWKS refresh is unauthenticated and attacker-triggerable.
_verify_id_token calls _refresh_jwks() (a live outbound fetch to the IdP) whenever the token header carries a kid not in cache. The callback is unauthenticated, so an attacker can send tokens with random kid values and drive one outbound IdP request per call. Add a refresh cooldown / minimum interval so a bad kid cannot cause unbounded outbound fetches.

4. SECURE_COOKIES defaults to false.
The session cookie issued after OIDC login is set with secure defaulting off, so it can traverse plaintext HTTP. SSO implies a real deployment; an auth session cookie should not default to being transmittable in cleartext. Default it to true, or derive secure from the request scheme, and document the expectation.

5. Dead validation-shaped code in the verifier.
core/oidc.py lines 349-363 decode header_claims with all claims marked non-essential and then discard the result, swallowing every exception with pass. In a token-verification routine, code that looks like validation but does nothing is a hazard for the next reader. Remove the block; the real verification at lines 377-434 stands on its own.

Also rebase onto dev (currently BEHIND) so this is evaluated against current state. Given this is authentication and a sizeable feature, it will also need a second maintainer to sign off before merge.

@holden093

Copy link
Copy Markdown
Contributor Author

All five second-pass items addressed in ae7910f and rebased onto latest dev:

  1. Bootstrap admin demotionset_oidc_user_admin is now skipped entirely when OIDC_ADMIN_GROUPS is unset, so bootstrap/manual admin persists across logins.
  2. Login CSRF — state is bound to an odysseus_oidc_csrf HttpOnly cookie set at /login, verified with secrets.compare_digest at /callback.
  3. JWKS cooldown — refresh throttled to once per 60s; unknown kid values within the cooldown window are logged and rejected without an IdP round-trip.
  4. Secure cookies — OIDC session defaults to secure on HTTPS connections (derives from request scheme when SECURE_COOKIES is not explicitly set).
  5. Dead code — removed the jwt.decode block with None key that swallowed exceptions and discarded output.

Tests: 100 passing (66 OIDC + 34 regression), 0 failures. Rebased onto latest dev (9180847).

@vdmkenny

Copy link
Copy Markdown
Collaborator

Third pass on ae7910f. I verified all five second-pass items in the code, not just the summary:

  1. Admin sync is now gated on if admin_groups: in the callback, so bootstrap and manually-granted admin persist across logins. Lockout closed.
  2. State is bound to the odysseus_oidc_csrf HttpOnly cookie set at /login (SameSite=lax, path scoped to the callback) and checked with secrets.compare_digest at /callback.
  3. JWKS refresh on unknown kid is throttled to once per 60s and rejected within the window, so the unauthenticated callback can't drive unbounded outbound fetches.
  4. Cookie secure derives from x-forwarded-proto/request scheme.
  5. The dead header-decode block is gone; only the real verification remains.

That is solid work across three rounds, thank you for the careful reworks.

One thing before this goes further: have you re-run the full login flow end to end against a live IdP on this exact commit, not just the unit suite? The 100 tests mock the HTTP and JWKS layers, and the changes since your last live test (the CSRF cookie with its path and SameSite settings, the secure-from-scheme logic, and the admin-group gating) are precisely the parts a mocked test will not exercise. Please confirm you have done a real Authentik (or other IdP) round trip on ae7910f covering: first-user bootstrap admin, a second login keeping admin, the CSRF cookie actually being sent on the callback, and a behind-proxy https request getting a secure cookie.

To be clear on process, this is not an approval. This is a large, AI-assisted feature in the authentication and trust boundary, so per the project's direction it needs sign-off from a second maintainer, and whether generic OIDC SSO is in scope right now is pewd's call, not mine. My passes have been quality and security review to get the implementation into a defensible state; the merge decision sits above me.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds generic OpenID Connect (OIDC) single sign-on to the existing auth system, enabling users to log in via a standard OIDC provider while preserving current password-based authentication and introducing passwordless OIDC user accounts.

Changes:

  • Introduces an OidcManager (discovery, authorization URL generation, code exchange, JWT/JWKS verification) and new /api/auth/oidc/* routes for config/login/callback.
  • Extends AuthManager with OIDC-linked user creation/lookup, password rejection for OIDC users, and IdP-group-driven admin syncing.
  • Updates UI to display an SSO button on the login page and hide password/2FA settings for OIDC users; wires new env vars through compose + .env.example.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/test_oidc_routes.py Adds route-level tests for OIDC config/login/callback flows and error paths.
tests/test_oidc_manager.py Adds unit tests for discovery, state handling, token exchange, id_token verification, and JWKS caching.
tests/test_oidc_auth.py Adds AuthManager tests for OIDC user creation, collisions, password rejection, and admin syncing.
static/login.html Adds frontend OIDC config fetch + “Sign in with …” button section and styling.
static/js/settings.js Hides password/2FA cards for OIDC users based on auth/status response.
static/index.html Adds an id hook for the password card so it can be hidden for OIDC users.
routes/oidc_routes.py Implements /api/auth/oidc/config, /login, /callback endpoints.
routes/auth_routes.py Includes is_oidc in /api/auth/status payload for the settings UI.
requirements.txt Adds authlib dependency for OIDC/JWT/JWKS handling.
docker-compose.yml Wires OIDC env vars into the default container runtime configuration.
docker-compose.gpu-nvidia.yml Wires OIDC env vars into NVIDIA GPU compose profile.
docker-compose.gpu-amd.yml Wires OIDC env vars into AMD GPU compose profile.
core/oidc.py Adds the core OIDC client: discovery, state, token exchange, id_token verification, JWKS caching.
core/auth.py Adds OIDC user support (create/lookup/admin sync) and blocks password flows for OIDC users.
app.py Initializes and registers OIDC routes and exempts OIDC endpoints from auth middleware.
.env.example Documents OIDC configuration variables and behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/oidc.py Outdated
Comment thread core/oidc.py Outdated
Comment thread core/oidc.py
Comment thread core/oidc.py
Comment thread core/oidc.py Outdated
Comment thread routes/oidc_routes.py Outdated
Comment thread tests/test_oidc_auth.py
Comment thread app.py
@holden093

Copy link
Copy Markdown
Contributor Author

Live end-to-end test completed on ae7910f against both Authentik and Keycloak:

  • First login via OIDC with admin group membership → admin ✅
  • Second login after removal from admin group → normal user ✅
  • CSRF cookie set, verified, and cleared on callback ✅
  • Bad/missing CSRF cookie → rejected with oidc_csrf error ✅
  • Login/logout cycle stable across browser restarts ✅

Thank you for the thorough three-pass review — the careful feedback made this a much stronger implementation. Ready for the second maintainer sign-off whenever pewd or another maintainer has bandwidth.

@RaresKeY

Copy link
Copy Markdown
Collaborator

Findings

P1 Badge issue (security): First OIDC user ignores configured admin groups

  • Problem: When OIDC_ADMIN_GROUPS is configured and a first-time OIDC login does not include one of those groups, the route passes is_admin=False into create_user_oidc(). That method still promotes the user because first-user bootstrap only checks OIDC_FIRST_USER_IS_ADMIN and not self.users; it reads OIDC_ADMIN_GROUPS but never uses it.

  • Impact: On a fresh SSO deployment, any non-admin IdP user who reaches Odysseus first can get full local admin privileges even though the configured group boundary says they should not.

  • Ask: Suppress first-user bootstrap whenever OIDC_ADMIN_GROUPS is set, or otherwise make the bootstrap policy explicit and group-aware. Please add a real AuthManager regression for this case.

  • Location: core/auth.py:310

P2 Badge issue (security): UserInfo can overwrite the verified ID-token subject

  • Problem: exchange_code() verifies the signed ID token, then fetches UserInfo and blindly merges it over the verified claims. If UserInfo includes a different sub, that replaces the already-verified ID-token subject.

  • Impact: Odysseus can create or select the local account for a subject different from the one in the verified ID token.

  • Ask: Reject mismatched UserInfo sub and prevent UserInfo from overwriting verified identity/security claims. Add a regression for mismatched UserInfo sub.

  • Location: core/oidc.py:263

Validation

Ran backend compile, node --check static/js/settings.js, git diff --check, targeted OIDC/auth tests where dependencies were available, and focused simulations for both findings. Full suite and live IdP/browser verification were not run.

@holden093

Copy link
Copy Markdown
Contributor Author

@RaresKeY both findings addressed in 23f1a49:

  1. P1 — First OIDC user ignores admin groups: Bootstrap now suppressed when OIDC_ADMIN_GROUPS is configured. If groups are set, group membership is the only path to admin. Added test_first_user_bootstrap_suppressed_when_admin_groups_configured.

  2. P2 — UserInfo can overwrite verified subject: Mismatched UserInfo sub now raises OidcError. All verified identity claims (sub, iss, aud, exp, iat, nonce, azp) are protected from UserInfo overwrite. Added test_userinfo_mismatched_sub_rejected and test_userinfo_same_sub_merged_safely.

103 tests passing (69 OIDC + 34 regression), 0 failures.

@dbruehlmeier

Copy link
Copy Markdown

✅ Works with Keycloak

Environment

  • Single Odysseus instance behind Caddy (TLS termination)
  • Keycloak Version 24.0.5
  • Deployed via Docker with python:3.12-slim base image

Setup

  • The redirect URI must be added to the Keycloak client's Valid Redirect URIs
  • Caddy handles TLS; the backend sees plain HTTP, so OIDC_REDIRECT_URI is required to force HTTPS in the redirect
  • AUTH_ENABLED=true must be set alongside OIDC so the login page shows the OIDC sign-in button
  • Multi-user works — each Keycloak user gets their own owner-scoped workspace on first login

Tested

  • OIDC provider discovery — reads .well-known/openid-configuration
  • Login flow — redirects to Keycloak, authenticates, returns to callback
  • User auto-provisioning — first OIDC login creates a local user with owner-scoped data
  • Session management — session cookie set after successful OIDC callback
  • Logout — clears session, redirects to Keycloak end-session endpoint

Configuration

OIDC_ENABLED=true
OIDC_ISSUER={{ OIDC_ISSUER }}
OIDC_CLIENT_ID=odysseus
OIDC_SCOPES=openid profile email
OIDC_FIRST_USER_IS_ADMIN=true
OIDC_REDIRECT_URI={{ OIDC_REDIRECT_URI }}

@holden093

Copy link
Copy Markdown
Contributor Author

Apologies — I overlooked two OIDC env vars in the compose files:

  • OIDC_REDIRECT_URI — documented and read by the app, but never
    forwarded to the container by any compose file. Without it,
    deployments behind a reverse proxy derive the redirect URI from the
    inbound request, picking up http:// instead of https:// (uvicorn
    lacks --proxy-headers), which breaks token exchanges.

  • OIDC_FIRST_USER_IS_ADMIN — I had this manually set in my own
    deployment during testing and it worked, but forgot to standardize
    the process by adding it to the compose files.

Both are now added to docker-compose.yml, docker-compose.gpu-nvidia.yml,
and docker-compose.gpu-amd.yml.

@RaresKeY

Copy link
Copy Markdown
Collaborator

I rechecked this at current head 128f10b after the latest compose env fix.

The latest issue called out in the thread was real: OIDC_REDIRECT_URI and OIDC_FIRST_USER_IS_ADMIN were documented/read by the app but were not passed through the compose files. That looks fixed now in the base, NVIDIA, and AMD compose files, and the focused OIDC/auth/compose test set passes after installing the branch requirements.

Stepping back, I do not think generic OIDC should be treated as priority work for the current stabilization push. It is useful for self-hosted/team deployments, but it is an enhancement rather than a stabilization bug fix, and the scope/implications are multifold if accepted.

This touches more than the login button: login now has an IdP redirect path, users are created from (issuer, sub), admin state can follow IdP groups, reverse-proxy callback config matters, local password fallback and local 2FA expectations need policy, logout/account recovery become provider-dependent, and the project inherits provider docs/support plus OIDC dependency/conformance maintenance.

@vdmkenny @alteixeira20 can you weigh in on tracking/routing? My suggestion is to keep SSO/OIDC on a roadmap track rather than pushing it through as stabilization work: a parent tracker for future SSO/OIDC, with this PR linked as a child issue/subissue/work item so the author has somewhere durable to track the current implementation and future follow-ups, and maintainers can revisit it when SSO is actually in scope.

If this PR stays active or becomes the basis for that future work, there are a few current-head issues I would keep on the child issue before another approval pass:

  1. core/oidc.py:167 only logs when the discovery document issuer differs from the configured issuer, then core/oidc.py:412 trusts the discovery issuer for ID-token validation. OpenID Connect Discovery requires the returned issuer to exactly match the issuer URL used for discovery and says failed metadata validation must abort use of that metadata. This should fail closed, with a regression for a mismatched discovery issuer.

  2. core/oidc.py:421-432 accepts a multi-audience ID Token when our client_id is present and azp is absent. The code comment says multi-audience tokens require azp, and Odysseus does not have a trusted-additional-audience model. I would reject multi-audience tokens unless additional audiences are explicitly trusted and azp == client_id, or document/test whatever trust model is intended.

  3. Lower priority, but worth deciding now: the UI hides local password/2FA controls for OIDC users, while the backend still allows an OIDC user to call local 2FA setup/confirm. OIDC callback login does not enforce local TOTP, and disable requires a local password, so this can create a misleading/stuck local 2FA state. Either reject local 2FA mutation for OIDC users or define stacked MFA and enforce it in the callback.

For PR readiness, I would also like screenshots/short clips because this changes the login page and account/settings behavior: OIDC disabled, OIDC enabled desktop/mobile, local-user settings, OIDC-user settings, and at least one callback failure state without exposing secrets.

Follow-up/maintenance if this lands: provider setup docs for Keycloak and Authentik, a tested provider/version matrix, reverse-proxy/cookie/group-claim/clock-skew/JWKS troubleshooting, an RP-initiated logout decision, an Authlib/Joserfc upgrade watch, and ideally an OpenID Foundation RP conformance pass for the basic code-flow profile. Authlib itself is not the issue; using a real OIDC/JWT library is the right direction. The maintenance point is that authlib.jose currently emits a deprecation warning toward joserfc, so dependency ownership needs to be explicit before this becomes project surface area.

Validation run locally:

  • python -m pip install -r requirements.txt in the project venv; this installed authlib.
  • python -m pytest tests/test_oidc_manager.py tests/test_oidc_auth.py tests/test_oidc_routes.py tests/test_auth_regressions.py tests/test_auth_session_revocation.py tests/test_totp_failclosed.py tests/test_reserved_username_admin_escalation.py tests/test_gpu_compose_standalone.py -q -> 115 passed.
  • python -m compileall app.py core routes src -> passed.
  • node --check static/js/settings.js -> passed.
  • git diff --check origin/dev...HEAD -> passed.
  • docker compose -f docker-compose.yml config, docker compose -f docker-compose.gpu-amd.yml config, and docker compose -f docker-compose.gpu-nvidia.yml config -> passed.

Not run: full test suite, live browser screenshots, live Keycloak/Authentik flow on this exact head, or the OpenID Foundation RP conformance suite.

@alteixeira20

Copy link
Copy Markdown
Collaborator

@RaresKeY I'll try to take a closer look tomorrow and get back to you!

@holden093

holden093 commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

@RaresKeY @vdmkenny all three items from the last review pass are addressed:


1. Discovery issuer mismatch now fails closed

core/oidc.py — the discovery doc issuer vs configured OIDC_ISSUER mismatch now raises OidcError instead of logger.warning. Per OIDC Discovery §1.1 mismatched metadata must abort, not proceed.

Test: test_discovery_issuer_mismatch_fails_closed

2. Multi-audience tokens without azp rejected

core/oidc.py — when aud contains multiple values, azp is now required (previously only checked when present and mismatched). OIDC Core §2: azp is mandatory for multi-audience ID tokens.

Test: test_id_token_aud_array_without_azp_rejected. Existing test_id_token_aud_array_valid updated to include azp.

3. OIDC users blocked from local 2FA / password mutations at the API level

routes/auth_routes.py/change-password, /2fa/setup, /2fa/confirm, /2fa/disable now reject OIDC users with HTTP 400 + a clear message. The frontend already hides these cards (settings.js), but the backend must enforce the policy so a direct API call cannot create a misleading or stuck 2FA state.

Tests: TestOidcRouteGuards class (5 tests covering each endpoint + regression that local password users still work).


Screenshots

A. OIDC disabled — login page (baseline)

Password form, no SSO button, no divider.

Desktop
login-oidc-off-desktop

Mobile
login-oidc-off-mobile

B. OIDC disabled — Settings → Account (baseline)

Change Password and Two-Factor Authentication cards visible.

Desktop
account-oidc-off-desktop

Mobile
account-oidc-off-mobile

C. OIDC enabled — login page

"or" divider + "Sign in with …" SSO button below the password form.

Desktop
login-oidc-on-desktop

Mobile
login-oidc-on-mobile

D. OIDC enabled — Settings → Account

Change Password and Two-Factor Authentication cards hidden for OIDC users.

Desktop
account-oidc-on-desktop

Mobile
account-oidc-on-mobile

E. Callback error — known UI timing issue

/login?error=oidc_csrf — the error text is set in the DOM but is cleared by a subsequent setMode() call. The error flashes briefly; the HTML and JS plumbing are in place (commit 1a336bb), but the display order needs a follow-up fix to call the error handler after the auth status resolves.

callback-csrf-error


Test totals

115 passing (76 OIDC + 39 regression), 0 failures.

Commits: 78d75cd (three fixes) · 1a336bb (callback error surface)

holden093 and others added 13 commits July 7, 2026 21:32
… to compose files

Both vars were documented in .env.example and read by the application
code, but never forwarded to the container by any docker-compose file.
OIDC_REDIRECT_URI is essential for deployments behind a reverse proxy
to avoid deriving the wrong scheme from the inbound request.
…-audience azp, OIDC 2FA guards

1. Discovery issuer mismatch now raises OidcError instead of logging
   a warning (OIDC Discovery §1.1 requires mismatch abort).

2. Multi-audience ID tokens without azp are now rejected (OIDC Core
   §2 requires azp when aud has multiple values).

3. /change-password, /2fa/setup, /2fa/confirm, and /2fa/disable now
   reject OIDC users with a clear message. The frontend already hides
   these cards, but the backend must also enforce the policy.

113 passing (76 OIDC + 37 regression), 0 failures.
…ed redirect_uri

1. JWKS fetch/parse errors now wrapped as OidcError instead of escaping
   as raw exceptions. Transient network failures, HTTP errors, and bad
   JSON responses from the JWKS endpoint all produce a controlled
   OidcError, which the callback route already redirects to
   /login?error=oidc_failed instead of a 500.

2. exchange_code() now extracts the stored redirect_uri from the
   Fernet-encrypted state token and rejects any callback-derived
   redirect_uri that differs. The token exchange POSTs the stored
   value — the one the IdP saw in the authorization request — removing
   the last callback dependence on request-derived URI behaviour.

Tests: 5 new (3 JWKS error wrapping + 2 redirect_uri binding)
Total: 130 passing (81 OIDC + 49 regression), 0 failures.
Wrap oidc_manager.exchange_code() in asyncio.to_thread() so slow
provider I/O (token exchange, JWKS refresh, UserInfo) doesn't block
the async worker's event loop.  Moves import asyncio to top level.

Add regression test proving a 0.3s blocking exchange completes
quickly without blocking concurrent async work.
…s workers

Two fixes from review feedback:

1. (security/availability) Record _last_jwks_refresh timestamp BEFORE
   calling _refresh_jwks() so a failed JWKS fetch is also throttled
   by the 60-second cooldown.  Previously the timestamp was only set
   after success, so an outage or key rotation would retry the IdP
   on every login attempt.

2. (auth) Use the shared persistent app key from secret_storage
   (_get_fernet) for OIDC state encryption instead of reading
   data/.app_key directly and falling back to a per-process key.
   This guarantees all uvicorn workers share the same Fernet key,
   even on a fresh data directory — worker A's state is always
   decryptable by worker B on the callback.

Add regression tests for both fixes.
…nst silent demotion

Three fixes from second review pass:

1. (security) Serialize the first-OIDC-user admin bootstrap inside
   _config_lock.  Previously the  check and username
   collision resolution happened outside the lock, so two concurrent
   first-login callbacks could both observe an empty user map and
   both persist as admin.  Now idempotent lookup, bootstrap decision,
   and collision resolution are all inside one critical section.

2. (auth) Make data/.app_key creation atomic via O_EXCL open so two
   racing workers on a fresh deployment cannot generate different
   keys.  The loser reads the winner's key, guaranteeing every worker
   shares the same Fernet key for OIDC state encryption.

3. (auth) Track whether UserInfo was successfully fetched
   (_userinfo_available flag in claims).  The callback now skips
   admin group sync for existing users when UserInfo is unavailable
   AND the id_token lacks a groups claim — a transient provider
   failure no longer silently demotes existing OIDC admins.  When
   UserInfo succeeds or the id_token carries groups, admin status
   syncs as before.

Regression tests: concurrent admin bootstrap, key-creation race,
UserInfo-unavailable preserves admin, UserInfo-available demotes,
id_token groups authoritative without UserInfo.
…omic key creation

- Add fcntl.flock inter-process file lock shared by setup() and
  create_user_oidc() so multi-worker first-admin bootstrap is
  serialised across processes, not just threads within one worker.
  Both methods reload auth.json inside the lock so the loser sees
  the winner's write.

- _fetch_userinfo() now returns None (not {}) when discovery has
  no userinfo_endpoint, and exchange_code() only sets
  _userinfo_available=True when a live endpoint was reached.
  Prevents the callback from treating 'no endpoint' as
  authoritative group non-membership evidence.

- Rewrite _load_or_create_key() to write the Fernet key to a temp
  file, fsync, then atomically os.link() into place.  No reader
  ever sees the final path before the complete key bytes are
  available — a racing worker either sees no file or a complete
  one, never an empty/partial file.

105 tests pass (97 existing + 8 new regressions covering the
three fixes).

Co-Authored-By: Kevin <holden093@users.noreply.github.com>
fcntl.flock serialises across processes (uvicorn workers) but does NOT
block threads within the same process — two threads calling
flock(LOCK_EX) on the same file both succeed immediately.  This caused a
rare race in concurrent first-admin bootstrap where both
create_user_oidc() and setup() could enter the critical section
simultaneously, resulting in a lost write and zero admins.

Add a module-level threading.Lock acquired before the file lock so the
critical section is serialised across both threads and workers.

Fixes the flaky test:
  TestInterprocessFirstAdminSerialisation::test_two_managers_single_first_admin
  ("Expected exactly 1 admin after concurrent bootstrap; found 0")
1. Serialize set_oidc_user_admin() across workers via
   _interprocess_auth_lock() with reload-recheck-save, preventing
   stale in-memory snapshots from overwriting users concurrently
   created by another worker.

2. Accept single-element aud arrays without azp (OIDC Core §2 only
   requires azp for multi-audience tokens).  Normalize aud to a list
   and only enforce azp when len > 1.

3. Add threading.Lock around _get_fernet() key creation to prevent
   two threads in the same process from racing on the shared temp-file
   path and destroying each other's work.

4. Guard import fcntl with try/except so AuthManager imports on native
   Windows.  _interprocess_auth_lock degrades to intra-process-only
   when fcntl is unavailable.

Tests: +4 regression tests (108 total, 0 failures).
create_user() (password-user creation) now takes _interprocess_auth_lock
with reload-before-save, preventing a concurrent set_oidc_user_admin()
from losing a newly-created password user.

Introduces _create_user_locked() internal helper so setup() (which
already holds the inter-process lock) can create users without a
nested fcntl.flock deadlock.

Also changed _auth_intraprocess_lock to threading.RLock to support
safe nesting patterns across mutation methods.

Regression: test_create_user_survives_concurrent_set_oidc_user_admin
(109 total, 0 failures).
The reserved-username guard was only in create_user(), but setup()
now calls _create_user_locked() directly (to avoid nested flock
deadlock).  Move the check into the shared helper so it applies
regardless of which path creates the user.

Fixes CI failure in test_setup_rejects_reserved_admin_username.
All auth.json mutation methods now acquire _interprocess_auth_lock
(flock-based) + _config_lock with reload-before-save, matching the
pattern already used by create_user / create_user_oidc /
set_oidc_user_admin. This prevents stale-write clobbers when an OIDC
callback races a concurrent set_privileges, delete_user, rename_user,
set_admin, change_password, or TOTP mutation from another uvicorn
worker.

Also:
- Normalize setup() username (strip + lower) to match every other
  user-creation path, preventing "Alice" vs "alice" lockout.
- Normalize in _create_user_locked() as defense-in-depth.
- Remove dead _setup_lock — its serialisation was subsumed by
  _interprocess_auth_lock + _config_lock.
- Move pre-lock validation inside the critical section for
  change_password, totp_generate_secret, and totp_confirm_enable.
- Re-read backup codes from disk inside the lock in totp_verify()
  to prevent dual-consumption across workers.

Co-Authored-By: antigravity <agy@antigravity>

@RaresKeY RaresKeY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. I checked the latest head; several earlier OIDC fixes are in place, but I found two remaining issues that should be addressed before merge.

Findings

P2 Badge issue (auth): Bind UserInfo before using its groups for admin sync

  • Problem: exchange_code() treats any non-None UserInfo response as available, rejects only when a UserInfo sub is present and different from the verified ID-token sub, and then merges non-identity claims such as groups into the verified claim set. The callback uses those merged groups plus _userinfo_available to create or sync OIDC admin status when OIDC_ADMIN_GROUPS is configured. If UserInfo omits sub, the group data is never bound back to the authenticated subject before it can drive local admin state.

  • Impact: A malformed or buggy configured UserInfo endpoint can grant or revoke Odysseus admin based on group data that was not tied to the ID-token subject. A focused no-network probe returned groups: ["odysseus-admins"] and _userinfo_available: true from a UserInfo response with no sub, which would be treated as authoritative group evidence by the callback.

  • Ask: Please require successful UserInfo to include sub equal to the verified ID-token sub, or at minimum refuse to merge/use authorization-relevant claims such as groups unless that binding is present. Add a regression for UserInfo returning groups but no subject.

  • Location: core/oidc.py:279

P3 Badge issue (auth): OIDC callback errors are cleared during login-page initialization

  • Problem: The login page reads /login?error=... and displays a human-readable OIDC callback error, but normal initialization then calls setMode("setup") or setMode("login"), and setMode() unconditionally hides the error element. As a result, messages for oidc_failed, oidc_csrf, oidc_denied, oidc_invalid, and oidc_config disappear during page load.

  • Impact: Users returned from a provider denial, CSRF failure, invalid callback, or code-exchange/config failure land on a blank login form instead of seeing why SSO failed. That makes real sign-in failures look like a silent redirect loop.

  • Ask: Please preserve an already-displayed OIDC callback error when initializing mode, or show the callback error after mode selection. A lightweight regression for /login?error=oidc_failed staying visible after auth-status initialization would cover this.

  • Location: static/login.html:371

Validation

  • Visible GitHub checks are passing on the reviewed head, apart from the skipped Trivy SARIF upload job.
  • Ran focused no-network validation for the UserInfo subject-binding issue, and focused OIDC route/auth tests passed in the isolated runner.
  • Not run: full local pytest with dependencies installed, live browser/app flow, live IdP validation, Docker compose config/build/startup, OpenID Foundation RP conformance, or native Windows startup.

Fixes 11 security/robustness issues identified across a 4-model review
(deepseek, gpt-5.5, claude-fable-5, gpt-5.6-sol) of the OIDC SSO
implementation:

UserInfo & claims integrity:
- Require non-empty matching sub before trusting UserInfo (P2)
- Reject non-dict/malformed UserInfo responses as unavailable
- Validate NumericDate strictly: reject bool, NaN, Inf
- Add iat future-token verification (60s tolerance)
- Initialize userinfo safely before try block

Callback hardening:
- Clear CSRF cookie on ALL 8 failure branches + 503 unconfigured
- Echo state parameter on IdP error redirects (OIDC Core §3.1.2.6)
- Check create_session_trusted() return value before setting cookie

Defense-in-depth:
- OIDC_MAX_AGE env var with auth_time verification (+60s skew)
- AuthManager.check_oidc_totp() with disk-reloaded TOTP enforcement
- SameSite=Lax proxy documentation in .env.example

UI fix:
- Preserve OIDC callback error after setMode() initialization

Tests: 164 passed (123 OIDC + 41 regression), +21 new regression tests
13 additional hardening fixes based on gpt-5.6-sol final judgment:

Spec compliance & validation:
- Validate azp == client_id whenever present (not just multi-audience)
- Enforce HTTPS on token/JWKS/UserInfo endpoints
- Validate IdP claim types before use (sub must be non-empty string)
- Enforce 'openid' in OIDC_SCOPES
- Validate state payload shape after decryption

Hardening:
- Last-admin guard on OIDC group demotion (refuse to demote sole admin)
- Cookie Secure: trust X-Forwarded-Proto only when TRUST_PROXY_HEADERS=true
- OIDC_MAX_AGE parse error now caught during init (no app crash)
- Nonce comparison uses secrets.compare_digest (constant-time)
- Algorithm validated against allow-list before jwt.decode (not after)
- JWKS cooldown marker now lock-protected against concurrent threads

Operational:
- Config endpoint returns generic error, logs details server-side
- Pin authlib>=1.3.0,<2 in requirements.txt

Tests: 164 passed (123 OIDC + 41 regression), 0 failures
Verify set_oidc_user_admin() refuses to demote the sole admin
when an IdP group change would otherwise lock out all admin access.
@holden093

Copy link
Copy Markdown
Contributor Author

@RaresKeY — both items from your last review are addressed, plus additional hardening surfaced by a new review strategy I put together for this branchs security assessment.

Your P2 — UserInfo sub binding:
UserInfo responses without a matching sub are now entirely discarded — no claims merged, _userinfo_available stays False. A UserInfo endpoint returning {groups: ["odysseus-admins"]} without sub can no longer drive admin sync. Non-dict responses and empty subs are also treated as unavailable. Mismatched subs still raise OidcError.

Your P3 — Login-page error clearing:
The OIDC callback error message is now computed before the auth-status fetch and displayed after setMode() completes, rather than being hidden immediately. User-triggered mode toggles still clear errors normally.

Additional hardening from the same review pass:

  • CSRF cookie now cleared on all 8 callback termination branches (was only cleared on success)
  • OIDC_MAX_AGE env var with auth_time verification in the ID token
  • IdP claims type-validated before use (non-string sub → 302 error, not 500)
  • Last-admin guard on OIDC group demotion via IdP groups
  • azp validated whenever present (not just multi-audience tokens)
  • HTTPS enforced on discovered token/JWKS/UserInfo endpoints
  • Algorithm validated against allow-list before signature verification
  • Nonce comparison uses constant-time secrets.compare_digest
  • authlib>=1.3.0,<2 pinned in requirements.txt
  • Cookie Secure flag: X-Forwarded-Proto trusted only when TRUST_PROXY_HEADERS=true
  • create_session_trusted() returning None is now fatal
  • Config endpoint logs details server-side, returns generic message publicly

Tests: 165 passed, 0 failures.

Ready for your next pass.

@RaresKeY RaresKeY left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the current head and found five issues that should be addressed:

P1 Badge issue (authentication): Preserve exact verified OIDC subject identifiers

  • Problem: The callback validates that sub is nonempty and then calls strip before using it as the issuer/subject account-link key. OIDC subjects are opaque identifiers, so this normalization can collapse distinct verified subjects into one local account.

  • Impact: A distinct authenticated subject can receive the local account and session of the subject with the trimmed identifier, including its administrator privileges.

  • Ask: Preserve the exact verified subject for persistence and lookup. Validate nonemptiness and bounds without normalizing the identifier, and add a regression for distinct whitespace-bearing subjects.

  • Location: routes/oidc_routes.py lines 171-174, 240, and 282-286.

P2 Badge issue (authorization): Require valid group evidence before changing OIDC admin state

  • Problem: A bound UserInfo response is considered available even when it omits groups or provides a malformed value. That availability flag alone causes the callback to synchronize an existing OIDC admin to non-admin when no group matches.

  • Impact: A normal provider or scope configuration change can unexpectedly demote an administrator and remove that user's administrative access, despite the response containing no valid group evidence for the transition.

  • Ask: Change admin state only when a trusted source supplied a valid groups claim; otherwise retain the current state or fail the login explicitly. Add regressions for missing and malformed UserInfo groups.

  • Location: core/oidc.py lines 351-388; routes/oidc_routes.py lines 204-211 and 246-254.

P2 Badge issue (security): Require TLS for the authorization path and bind code exchange with PKCE

  • Problem: Discovery accepts an HTTP issuer and authorization endpoint while only enforcing HTTPS for back-channel endpoints, and the authorization request contains state and nonce but no PKCE challenge.

  • Impact: With an accepted insecure authorization endpoint, an active network observer can arrange a substituted authorization code for the observed state and switch the resulting session identity.

  • Ask: Require HTTPS for the issuer and authorization endpoint, implement S256 PKCE with a verifier bound to the encrypted callback state, and cover rejected HTTP discovery plus challenge/verifier handling.

  • Location: core/oidc.py lines 171-218 and 257-285.

P2 Badge issue (authentication): Do not issue OIDC cookies without the Secure flag by default

  • Problem: The OIDC login and callback derive the CSRF/session cookie Secure attribute from SECURE_COOKIES, while the bundled Compose files default that variable to false. This silently forces both OIDC cookies insecure even in a public TLS-terminating proxy deployment.

  • Impact: A stock external OIDC deployment can issue bearer session and login-CSRF cookies that are eligible for transmission over HTTP if that origin is reachable, exposing an authenticated session or weakening login-CSRF protection.

  • Ask: Make OIDC cookies Secure by default and reject or explicitly document an HTTP-only development override. Ensure the proxy-supported configuration cannot silently force them insecure, and add coverage for the Compose default and proxied HTTPS path.

  • Location: routes/oidc_routes.py lines 102-110 and 288-330; docker-compose.yml lines 39-48 and matching GPU Compose files.

P2 Badge issue (authentication): Make newly issued OIDC sessions work across workers

  • Problem: The OIDC callback can run on one worker, but the browser session it creates is retained only in that process in-memory session map. A following request handled by another worker cannot validate the token.

  • Impact: In a normal multi-worker, non-sticky deployment, successful OIDC login can immediately become a logged-out session on the next request.

  • Ask: Use a shared or reloadable session store for this flow, or explicitly constrain the deployment to a compatible single-worker/sticky-session model and add a two-worker regression.

  • Location: core/auth.py lines 123-140 and 854-926; routes/oidc_routes.py lines 282-307.

Validation

  • The completed current-head GitHub checks are successful; the Trivy SARIF upload is skipped.
  • Focused OIDC route/auth/session tests passed in the secretless runner. The manager-inclusive suite is blocked by the runner image missing declared authlib, not by an assertion failure.
  • No live IdP, browser/UI, multi-worker deployment, Docker Compose runtime, or OpenID Foundation RP-conformance run was available. The vulnerability-detector lane is degraded because Podman and Docker are unavailable.

@RaresKeY

Copy link
Copy Markdown
Collaborator

Follow-up validation on unchanged head 025f251b found two additional OpenID Foundation Basic RP requirements not covered in my previous review.

  • The PR's actual OidcManager completed a normal HTTPS loopback-provider flow, but the captured authorization request had no code_challenge, the token request had no code_verifier, and the token endpoint authentication method was client_secret_post.
  • The same manager accepted a signed RS256 ID token from the loopback provider with no iat claim; returned claims still had no iat.
  • The complete OpenID Foundation harness was not run. These two incompatibilities were demonstrated directly against requirements included in the official Basic RP plan.

holden093 and others added 3 commits July 18, 2026 22:34
…ssions

Addresses RaresKeY's review (5 findings) and follow-up Basic RP
validation comment on PR odysseus-dev#3508:

- Add PKCE (RFC 7636, S256): code_challenge in the authorization
  request, verifier carried in the Fernet-encrypted state, and
  code_verifier sent to the token endpoint.
- Require the iat claim in id_tokens (OIDC Core §2); tokens without
  iat are now rejected.
- Prefer client_secret_basic at the token endpoint per discovery
  (OIDC default), falling back to client_secret_post only when the
  provider excludes basic.
- Require HTTPS for the issuer and authorization endpoint, not just
  the back-channel endpoints.
- Preserve OIDC subs exactly (no strip) so distinct whitespace-bearing
  subjects can never collapse into one local account; same for the
  UserInfo sub-binding comparison.
- Sync admin state only on a well-formed groups claim; UserInfo
  availability alone (or a malformed groups value) no longer demotes
  an existing admin.
- OIDC session/CSRF cookies are Secure by default regardless of
  SECURE_COOKIES; explicit OIDC_ALLOW_INSECURE_COOKIES=true is the
  only (documented, dev-only) opt-out.
- Make sessions issued by one uvicorn worker validate on others via
  an mtime-gated read-through reload of sessions.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GRiLb12nnLnBnYsg14oSWd
… cookie policy

Follow-up hardening beyond the explicit review findings:

- Propagate session revocation across uvicorn workers: token
  validation now syncs issuance AND revocation from sessions.json
  (mtime-gated), _save_sessions merges on-disk state under an
  inter-process flock so concurrent workers can't lose each other's
  sessions, and revocation tombstones prevent a just-revoked token
  from being re-merged.
- Restrict sessions.json and auth.json to 0600 (bearer tokens and
  password hashes; same policy as data/app.db, odysseus-dev#4420), applied
  atomically at write time and retroactively at load.
- Password-login session cookie: SECURE_COOKIES=false can no longer
  downgrade the cookie when the request arrived over HTTPS (spoofable
  X-Forwarded-Proto still requires TRUST_PROXY_HEADERS opt-in).
- Document why OIDC state tokens are deliberately not single-use and
  which mechanisms bound the replay window.
- Warn once per process (not twice per login) when
  OIDC_ALLOW_INSECURE_COOKIES is enabled; pass the variable through
  the Compose files so the documented dev override actually reaches
  containers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GRiLb12nnLnBnYsg14oSWd
@holden093

Copy link
Copy Markdown
Contributor Author

@RaresKeY Thanks for the thorough review and the follow-up Basic RP validation — all findings are addressed in ad808d28, with additional proactive hardening in 988b86b4. Both commits have been validated on a live deployment, and the OpenID Foundation Basic RP suite has since been run against the new head (details at the bottom).

Follow-up comment (Basic RP validation):

  • PKCE: The authorization request now carries an S256 code_challenge, and the token request the matching code_verifier. The verifier travels inside the existing Fernet-encrypted state token (same stateless carrier as the nonce), so callbacks still work across workers with no server-side store. State payloads lacking a verifier are rejected.
  • Missing iat: _verify_id_token now rejects id_tokens without an iat claim (OIDC Core §2). The test that previously locked in the lenient behavior is flipped to assert rejection.
  • Token endpoint auth: Now client_secret_basic (with RFC 6749 §2.3.1 form-urlencoded credentials) whenever the provider advertises it or omits token_endpoint_auth_methods_supported; falls back to client_secret_post only when the provider explicitly excludes basic.

Review findings:

  • P1 — sub normalization: The callback no longer strips the subject; it's validated for type/bounds and used verbatim for lookup and persistence. The UserInfo sub-binding comparison is exact as well. Regression tests cover distinct whitespace-bearing subjects.
  • P2 — admin state without group evidence: Admin sync now runs only when a trusted source (verified id_token or sub-bound UserInfo) supplied a well-formed list-typed groups claim. UserInfo availability alone, or a malformed groups value, preserves the current admin state. Regressions added for both.
  • P2 — TLS + PKCE: The issuer and authorization_endpoint must now be HTTPS (previously only back-channel endpoints were checked); PKCE as above, with the verifier bound to the encrypted callback state. Tests cover rejected HTTP discovery and the challenge/verifier round-trip.
  • P2 — cookie Secure flag: OIDC session/CSRF cookies are now Secure unconditionally — SECURE_COOKIES=false (the Compose default) can no longer downgrade them. The only opt-out is an explicit, documented, dev-only OIDC_ALLOW_INSECURE_COOKIES=true (added to the Compose files, warned on use). Tests cover the Compose-default and plain-HTTP paths.
  • P2 — multi-worker sessions: Token validation now syncs sessions.json across workers (mtime-gated stat, so steady-state cost is one os.stat per validation). A two-worker regression suite covers issuance, expiry, and the just-issued-token race.

Proactive hardening beyond the findings (988b86b4):

  • Revocation also propagates cross-worker: saves merge on-disk state under an inter-process flock (fixing a latent lost-update where concurrent workers could clobber each other's sessions), with tombstones so a revoked token can't be re-merged.
  • sessions.json and auth.json are written 0600 (same policy as data/app.db, fix(db): restrict data/app.db to 0600 #4420), applied atomically at write time and retroactively at load.
  • The password-login cookie can no longer be downgraded to insecure by SECURE_COOKIES=false when the request arrived over HTTPS (X-Forwarded-Proto still requires the TRUST_PROXY_HEADERS opt-in).
  • On state single-use: replay within the 10-minute TTL is intentionally handled without server-side storage — the authorization code is single-use at the IdP, the nonce is signature-bound, and the PKCE verifier is bound to the same encrypted state. Now documented in core/oidc.py; happy to revisit if you see a gap in that reasoning.

Validation:

  • ~170 tests across the OIDC/auth suites pass, including ~30 new regressions covering every finding above.
  • Live deployment check (branch deployed behind Traefik with TLS, against an Authentik IdP): discovery succeeds with algorithms pinned to RS256, all endpoints HTTPS; the authorization redirect carries code_challenge + code_challenge_method=S256 with a fresh challenge per request; the CSRF cookie is issued HttpOnly; Secure; SameSite=lax; Path=/api/auth/oidc/callback; Max-Age=600; a forged cookieless callback is cleanly rejected with 302 /login?error=oidc_csrf and the cookie expired.
  • The OpenID Foundation Basic RP conformance suite has now been run against the new head, including the PKCE and iat requirements demonstrated in your validation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSO via OIDC support

6 participants