# OIDC Provider Implementation ## Overview OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys. Features stateful sessions with refresh tokens and back-channel logout support. ## Database Changes ### User model additions (in `db/structs.py`) - `email: str | None` — omit if None - `preferred_username: str` — initially derived from full name, require unique and non-empty ### Session model additions (in `db/structs.py`) Sessions now support both native (cookie-based) and OIDC authentication: ```python class Session(msgspec.Struct, dict=True, omit_defaults=True): user_uuid: UUID credential_uuid: UUID # Always captured (passkey used for auth) host: str ip: str user_agent: str expiry: datetime client_uuid: UUID | None = None # If set, this is an OIDC session ``` - Native sessions: `client_uuid` is None, validated via `session_ctx()` - OIDC sessions: `client_uuid` set, looked up via `oidc_session_by_sid()` - Session key serves as both cookie token (native) and `sid` claim (OIDC) ### New OIDC-specific models ```python class OIDClient(msgspec.Struct): uuid: UUID client_secret_hash: bytes name: str redirect_uris: list[str] created_at: datetime def verify_secret(self, secret: str) -> bool: ... ``` ### In-memory auth codes (`paskia/oidauth.py`) Authorization codes are stored in-memory only (not persisted), with 60-second lifetime: ```python @dataclass class AuthCode: code: str client_uuid: UUID user_uuid: UUID redirect_uri: str scope: str sid: str # Session ID for backchannel logout nonce: str | None code_challenge: str | None code_challenge_method: str | None created_at: datetime expires_at: datetime # 60 seconds ``` ### DB additions ```python oid_clients: dict[UUID, OIDClient] = {} ``` ## Endpoints ### Well-known (root level) - `GET /.well-known/openid-configuration` — Discovery document - `GET /.well-known/jwks.json` — Public keys for token verification ### OIDC routes (`/auth/oidc/`) - `POST /auth/oidc/token` — Token endpoint (code exchange & refresh) - `GET /auth/oidc/userinfo` — UserInfo endpoint (bearer token) - `POST /auth/oidc/backchannel-logout` — Back-channel logout endpoint ### Authorization (via existing restricted app) - `GET /auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid...` The restricted app detects OIDC params from URL and handles authentication via WebSocket. ## JWT & Signing - Ed25519 keypair generated on first boot (stored in data directory) - ID tokens signed with EdDSA - `kid` in JWKS for key rotation support - Access tokens are signed JWTs (not opaque) ## Scopes & Claims **Standard OIDC scopes** — implicit for authenticated users: - `openid` — required, enables ID token - `profile` — includes `name`, `preferred_username` - `email` — includes `email` (if set) **Paskia permissions as claims** — user's effective permission scopes included automatically: ```json { "sub": "user-uuid", "sid": "session-id", "name": "Alice", "preferred_username": "alice", "email": "alice@example.com", "permissions": ["admin", "reports:view"] } ``` ## Authorization Flow The `/auth/restricted/oidc` page handles OIDC authorization: 1. Client redirects to `/auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...` 2. Frontend detects OIDC params from `window.location.search` 3. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}` 4. User authenticates via passkey 5. WebSocket validates client/redirect_uri, authenticates user 6. **Creates OIDC session** (persisted, with credential/IP/user_agent) 7. Creates auth code with session's `sid` 8. WebSocket returns `{"redirect_url": "redirect_uri?code=...&state=..."}` 9. Frontend redirects to the URL 10. Client exchanges code at `/auth/oidc/token` → receives tokens **Token Response:** ```json { "access_token": "...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "{sid}", "id_token": "..." } ``` **Key design points:** - OIDC sessions are persisted (credential, IP, user_agent recorded) - Session key becomes `sid` claim and `refresh_token` - Raw query string preserved throughout (no parsing/reconstruction of redirect_uri) - Redirect URI validated against client's registered URIs via exact string match - PKCE required (S256 only) ## Refresh Tokens The `refresh_token` returned is the OIDC session's `sid`. On refresh: **Request:** ``` POST /auth/oidc/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token&refresh_token={sid}&client_id=...&client_secret=... ``` **Behavior:** 1. Validates session exists and belongs to client 2. Checks session not expired 3. Extends session expiry to +24h (sliding window, matching native sessions) 4. Records current IP and user_agent 5. Issues new `access_token` and `id_token` (with same `sid`) 6. Returns same `refresh_token` (the sid is stable) **Session lifetime:** 24 hours with sliding window via refresh — same as native sessions. ## Back-Channel Logout Supports OIDC Back-Channel Logout 1.0. **Discovery claims:** ```json { "backchannel_logout_supported": true, "backchannel_logout_session_supported": true } ``` **Endpoint:** `POST /auth/oidc/backchannel-logout` **Request:** ``` POST /auth/oidc/backchannel-logout Content-Type: application/x-www-form-urlencoded logout_token={jwt} ``` The `logout_token` JWT must contain: - `sid` — specific session to terminate, OR - `sub` — terminate all sessions for user (optionally filtered by `aud` client) **Behavior:** 1. Decode and verify logout token signature 2. Look up session(s) by `sid` or `sub` 3. Verify client (`aud`) matches session's `client_uuid` 4. Delete session(s) 5. Return 200 OK (even if no sessions found, per spec) ## WebSocket OIDC Mode The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params: - `client_id` — OIDC client UUID - `redirect_uri` — exact registered redirect URI - `scope` — must include `openid` - `state`, `nonce` — passed through - `code_challenge`, `code_challenge_method` — for PKCE When OIDC params present: - Validates client and redirect_uri before authentication - Performs passkey authentication - Creates OIDC session via `db.oidc_login()` (persists credential, IP, user_agent) - Creates auth code with session's sid - Returns `{"redirect_url": "..."}` instead of session token When OIDC params absent: - Normal authentication flow with native session creation ## Files ### Created - `paskia/oidauth.py` — In-memory auth code storage (60-second lifetime, no persistence) - `paskia/db/structs.py` — Added `OIDClient` model; Session `client_uuid` field; User fields `email`, `preferred_username` - `paskia/db/operations.py` — CRUD for OIDC clients; `oidc_login()` function - `paskia/fastapi/oid.py` — Token, userinfo, and backchannel-logout endpoints - `paskia/util/oidjwt.py` — Ed25519 key management, JWT creation, JWKS ### Modified - `paskia/globals.py` — Initialize oidauth on startup - `paskia/fastapi/mainapp.py` — Mount OIDC app, well-known endpoints - `paskia/fastapi/ws.py` — OIDC params support in `/authenticate` - `frontend/src/utils/passkey.js` — Pass query string to authenticate - `frontend/auth/restricted/RestrictedApi.vue` — Detect OIDC from URL, handle redirect - `frontend/src/components/RestrictedAuth.vue` — Pass OIDC query string prop ## TODO - Master admin UI for client management - User UI for editing preferred_username, email, profile picture - Token revocation endpoint (optional)