Files
paskia/oidc.md
T

7.5 KiB

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:

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

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:

@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

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:

{
  "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:

{
  "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:

{
  "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)