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 Nonepreferred_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_uuidis None, validated viasession_ctx() - OIDC sessions:
client_uuidset, looked up viaoidc_session_by_sid() - Session key serves as both cookie token (native) and
sidclaim (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 documentGET /.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
kidin 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 tokenprofile— includesname,preferred_usernameemail— includesemail(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:
- Client redirects to
/auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=... - Frontend detects OIDC params from
window.location.search - Frontend passes raw query string to
/auth/ws/authenticate?{query_string} - User authenticates via passkey
- WebSocket validates client/redirect_uri, authenticates user
- Creates OIDC session (persisted, with credential/IP/user_agent)
- Creates auth code with session's
sid - WebSocket returns
{"redirect_url": "redirect_uri?code=...&state=..."} - Frontend redirects to the URL
- 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
sidclaim andrefresh_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:
- Validates session exists and belongs to client
- Checks session not expired
- Extends session expiry to +24h (sliding window, matching native sessions)
- Records current IP and user_agent
- Issues new
access_tokenandid_token(with samesid) - 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, ORsub— terminate all sessions for user (optionally filtered byaudclient)
Behavior:
- Decode and verify logout token signature
- Look up session(s) by
sidorsub - Verify client (
aud) matches session'sclient_uuid - Delete session(s)
- 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 UUIDredirect_uri— exact registered redirect URIscope— must includeopenidstate,nonce— passed throughcode_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— AddedOIDClientmodel; Sessionclient_uuidfield; User fieldsemail,preferred_usernamepaskia/db/operations.py— CRUD for OIDC clients;oidc_login()functionpaskia/fastapi/oid.py— Token, userinfo, and backchannel-logout endpointspaskia/util/oidjwt.py— Ed25519 key management, JWT creation, JWKS
Modified
paskia/globals.py— Initialize oidauth on startuppaskia/fastapi/mainapp.py— Mount OIDC app, well-known endpointspaskia/fastapi/ws.py— OIDC params support in/authenticatefrontend/src/utils/passkey.js— Pass query string to authenticatefrontend/auth/restricted/RestrictedApi.vue— Detect OIDC from URL, handle redirectfrontend/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)