Files
paskia/oidc.md
T
LeoVasanko 10af29f92d MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
2026-09-07 22:02:06 +00:00

4.5 KiB

OIDC Provider Implementation

OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.

Domains (multi rp-id)

The OIDC provider is instance-global: one signing key (oidc.key in the transaction log) and one client set for the whole instance, usable through every configured domain. Discovery, keys, token and userinfo endpoints resolve the issuer from the request host (domain dispatch), so every configured host is an issuer alias sharing the one key. Session.issuer records the issuing origin (scheme included, stamped from the WS Origin) so refresh and back-channel logout produce the right iss; Session.rp_id records the owning domain for display. CookieCode is stamped with the session's rp-id and verified at redemption; OIDCCode is not, since the provider is instance-global.

Data Models

User — Added: email, preferred_username

Session — Added: client_uuid (None = native, set = OIDC), issuer (origin that issued the session), rp_id (owning domain, display only)

  • key: bytes — hashed DB key, never stored raw
  • secrethash_secret("session", secret) → DB lookup
  • OIDC sidbase64url.encode(hash_secret("oidc", session.key))

OIDClientuuid, client_secret_hash, name, redirect_uris

Auth Codes (In-Memory Only)

60-second lifetime, auto-cleaned. Two separate stores keep the OIDC and cookie flows isolated:

from paskia.authcode import CookieCode, OIDCCode, store_cookie, store_oidc

class OIDCCode(msgspec.Struct):
    session_key: str       # Session DB key
    created: datetime
    redirect_uri, scope: str
    nonce, code_challenge: str | None   # PKCE S256 when provided

class CookieCode(msgspec.Struct):
    session_key: str
    created: datetime
    rp_id: str             # domain the code was issued in; checked at redemption

Usage: code = store_oidc(OIDCCode(...)) → later popped from oidc_codes / cookie_codes.

Authorization Flows

OIDC (Authorization Code)

  1. GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...
  2. Frontend → WebSocket: /auth/ws/authenticate?client_id=...&redirect_uri=...&...
  3. Validate client/redirect_uri, authenticate via passkey
  4. db.oidc_login()(secret, session_key)
  5. Create AuthCode(session_key, oidc=OIDC(...)) → code
  6. Return: {"redirect_url": "{redirect_uri}?code={code}&state={state}"}
  7. Client exchanges code at /auth/oidc/token with code_verifier (PKCE S256)

Token: access_token, id_token, refresh_token={secret}, expires_in=3600

ID token: sub, sid (base64url), name, preferred_username, email, groups

  1. WebSocket: /auth/ws/authenticate (no OIDC params)
  2. Authenticate via passkey
  3. db.login() → secret
  4. Create AuthCode(session_key=secret, oidc=None) → exchange_code
  5. Return: {"user": "UUID", "exchange_code": "..."}
  6. POST /auth/api/exchange with code → sets cookie

Refresh & Logout

Refresh: POST /auth/oidc/token with grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...

  • Looks up session, validates client match
  • Extends expiry +24h (sliding window)
  • Returns new tokens with same sid

Back-channel logout: POST /auth/oidc/backchannel-logout with logout_token={jwt}

  • Verify signature, extract sid or sub
  • Delete matched sessions
  • Return 200 OK

Discovery: backchannel_logout_supported: true

Endpoints

  • GET /.well-known/openid-configuration — Discovery
  • GET /auth/oidc/keys — Keys (EdDSA)
  • POST /auth/oidc/token — Exchange/refresh
  • GET /auth/oidc/userinfo — User (bearer token, includes picture when profile scope is granted and avatar exists)
  • POST /auth/oidc/backchannel-logout — Logout
  • POST /auth/api/exchange — Native auth code → cookie

Claims

  • profile scope may include name, preferred_username, and picture
  • email scope may include email
  • groups is emitted from client-scoped permissions

Files

Created: paskia/authcode.py, paskia/util/crypto.py, paskia/fastapi/oid.py

Modified: paskia/db/structs.py, paskia/db/operations.py, paskia/fastapi/ws.py, paskia/fastapi/api.py, paskia/domains.py, paskia/fastapi/mainapp.py