Draft OpenID Connect support.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
# OIDC Provider Implementation
|
||||
|
||||
## Overview
|
||||
Minimal OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys.
|
||||
|
||||
## 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
|
||||
|
||||
### 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: ...
|
||||
|
||||
class OIDAuthCode(msgspec.Struct):
|
||||
code: str # dict key, secure random
|
||||
client: UUID
|
||||
user: UUID
|
||||
redirect_uri: str
|
||||
scope: str
|
||||
nonce: str | None
|
||||
code_challenge: str | None # PKCE
|
||||
code_challenge_method: str | None
|
||||
created_at: datetime
|
||||
expires_at: datetime # 10 minutes
|
||||
|
||||
@classmethod
|
||||
def create(cls, ...) -> OIDAuthCode: ...
|
||||
```
|
||||
|
||||
### DB additions
|
||||
```python
|
||||
oid_clients: dict[UUID, OIDClient] = {}
|
||||
oid_auth_codes: dict[str, OIDAuthCode] = {}
|
||||
```
|
||||
|
||||
## 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/oid/`)
|
||||
- `POST /auth/oid/token` — Token endpoint (code exchange)
|
||||
- `GET /auth/oid/userinfo` — UserInfo endpoint (bearer token)
|
||||
|
||||
### Authorization (via existing restricted app)
|
||||
- `GET /auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid...`
|
||||
|
||||
The restricted app detects OIDC params from URL and handles authentication via WebSocket.
|
||||
|
||||
## JWT & Signing
|
||||
- RSA keypair generated on first boot (stored in data directory)
|
||||
- ID tokens signed with RS256
|
||||
- `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",
|
||||
"name": "Alice",
|
||||
"preferred_username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"permissions": ["admin", "reports:view"]
|
||||
}
|
||||
```
|
||||
|
||||
## Authorization Flow
|
||||
|
||||
The `/auth/restricted/` page handles OIDC authorization alongside normal iframe auth:
|
||||
|
||||
1. Client redirects to `/auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...`
|
||||
2. Frontend detects OIDC params from `window.location.search`
|
||||
3. User authenticates via passkey
|
||||
4. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}`
|
||||
5. WebSocket validates client/redirect_uri, authenticates user, creates auth code
|
||||
6. WebSocket returns `{"redirect_url": "redirect_uri?code=...&state=..."}`
|
||||
7. Frontend redirects to the URL
|
||||
8. Client exchanges code at `/auth/oid/token` → receives `id_token` + `access_token`
|
||||
9. Optionally calls `/auth/oid/userinfo` with bearer token
|
||||
|
||||
**Key design points:**
|
||||
- No session created during OIDC auth (stateless for the OIDC client)
|
||||
- Raw query string preserved throughout (no parsing/reconstruction of redirect_uri)
|
||||
- Redirect URI validated against client's registered URIs via exact string match
|
||||
- PKCE supported (S256 and plain methods)
|
||||
|
||||
## 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
|
||||
- Creates auth code after successful passkey auth
|
||||
- Returns `{"redirect_url": "..."}` instead of session token
|
||||
|
||||
When OIDC params absent:
|
||||
- Normal authentication flow with session creation
|
||||
|
||||
## Files
|
||||
|
||||
### Created
|
||||
- `paskia/db/structs.py` — Added `OIDClient`, `OIDAuthCode` models; User fields `email`, `preferred_username`
|
||||
- `paskia/db/operations.py` — CRUD for OIDC entities
|
||||
- `paskia/fastapi/oid.py` — Token and userinfo endpoints
|
||||
- `paskia/util/oidjwt.py` — RSA key management, JWT creation, JWKS
|
||||
|
||||
### Modified
|
||||
- `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)
|
||||
- Logout/session management spec (optional)
|
||||
Reference in New Issue
Block a user