Files
paskia/oidc.md
T

233 lines
7.5 KiB
Markdown

# 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)