diff --git a/frontend/auth/restricted/RestrictedApi.vue b/frontend/auth/restricted/RestrictedApi.vue index 791b445..5eae0a4 100644 --- a/frontend/auth/restricted/RestrictedApi.vue +++ b/frontend/auth/restricted/RestrictedApi.vue @@ -2,6 +2,7 @@ @@ -15,6 +16,9 @@ import RestrictedAuth from '@/components/RestrictedAuth.vue' // The token is a 5-word passphrase like "word1.word2.word3.word4.word5" const remoteAuthToken = ref(null) +// For OIDC flow, pass the raw query string to preserve exact param values +const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null + function extractRemoteToken() { const path = window.location.pathname // Match /auth/{token} where token is a passphrase with dots @@ -41,6 +45,11 @@ function postToParent(message) { } function handleAuthenticated(result) { + if (result.redirect_url) { + // OIDC flow: redirect to client with auth code + window.location.href = result.redirect_url + return + } postToParent({ type: 'auth-success', authenticated: true, diff --git a/frontend/src/components/RestrictedAuth.vue b/frontend/src/components/RestrictedAuth.vue index 4ee1360..ed554ff 100644 --- a/frontend/src/components/RestrictedAuth.vue +++ b/frontend/src/components/RestrictedAuth.vue @@ -67,6 +67,10 @@ const props = defineProps({ type: String, default: 'login', validator: (value) => ['login', 'reauth', 'forbidden'].includes(value) + }, + oidcQueryString: { + type: String, + default: null } }) @@ -163,7 +167,7 @@ async function authenticateUser() { loading.value = true showMessage('Starting authentication…', 'info') let result - try { result = await passkey.authenticate() } catch (error) { + try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) { loading.value = false const message = error?.message || 'Passkey authentication cancelled' const cancelled = message === 'Passkey authentication cancelled' @@ -171,6 +175,12 @@ async function authenticateUser() { emit('auth-error', { message, cancelled }) return } + // OIDC flow: no session cookie, just emit the redirect_url + if (result.redirect_url) { + loading.value = false + emit('authenticated', result) + return + } try { await setSessionCookie(result) } catch (error) { loading.value = false const message = error?.message || 'Failed to establish session' diff --git a/frontend/src/utils/passkey.js b/frontend/src/utils/passkey.js index 89c79f7..ca26267 100644 --- a/frontend/src/utils/passkey.js +++ b/frontend/src/utils/passkey.js @@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg } } -export async function authenticate() { - const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) +export async function authenticate(queryString = null) { + // Build URL, optionally appending raw query string (e.g. for OIDC params) + let url = await makeUrl('/auth/ws/authenticate') + if (queryString) { + url += queryString.startsWith('?') ? queryString : `?${queryString}` + } + const ws = await aWebSocket(url) try { let res = await ws.receive_json() if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`) diff --git a/oidc.md b/oidc.md new file mode 100644 index 0000000..ceb1a9e --- /dev/null +++ b/oidc.md @@ -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) diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 09637b2..1bdb479 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -31,8 +31,12 @@ from paskia.db.lifecycle import cleanup_expired, init from paskia.db.operations import ( add_permission_to_org, add_permission_to_role, + cleanup_expired_oid_auth_codes, + consume_oid_auth_code, create_credential, create_credential_session, + create_oid_auth_code, + create_oid_client, create_org, create_permission, create_reset_token, @@ -40,6 +44,7 @@ from paskia.db.operations import ( create_session, create_user, delete_credential, + delete_oid_client, delete_org, delete_permission, delete_reset_token, @@ -65,6 +70,8 @@ from paskia.db.structs import ( DB, Config, Credential, + OIDAuthCode, + OIDClient, Org, Permission, ResetToken, @@ -85,6 +92,8 @@ __all__ = [ "Config", "Credential", "DB", + "OIDAuthCode", + "OIDClient", "Org", "Permission", "ResetToken", @@ -142,4 +151,10 @@ __all__ = [ "update_user_display_name", "update_user_role", "update_user_theme", + # OIDC + "cleanup_expired_oid_auth_codes", + "consume_oid_auth_code", + "create_oid_auth_code", + "create_oid_client", + "delete_oid_client", ] diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 3a0f75b..4eb94eb 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -20,6 +20,8 @@ from paskia.db.structs import ( DB, Config, Credential, + OIDAuthCode, + OIDClient, Org, Permission, ResetToken, @@ -586,3 +588,59 @@ def create_credential_session( if token: token.delete() return session.key + + +# ------------------------------------------------------------------------- +# OIDC Provider operations +# ------------------------------------------------------------------------- + + +def create_oid_client(client: OIDClient, *, ctx: SessionContext | None = None) -> None: + """Create a new OIDC client.""" + if client.uuid in _db.oid_clients: + raise ValueError(f"OIDC client {client.uuid} already exists") + with _db.transaction("admin:create_oid_client", ctx): + _db.oid_clients[client.uuid] = client + + +def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None: + """Delete an OIDC client.""" + if client_uuid not in _db.oid_clients: + raise ValueError(f"OIDC client {client_uuid} not found") + with _db.transaction("admin:delete_oid_client", ctx): + del _db.oid_clients[client_uuid] + + +def create_oid_auth_code(auth_code: OIDAuthCode) -> None: + """Create a new OIDC authorization code.""" + with _db.transaction("oid:create_auth_code"): + _db.oid_auth_codes[auth_code.code] = auth_code + + +def consume_oid_auth_code(code: str) -> OIDAuthCode | None: + """Consume (delete and return) an OIDC authorization code. + + Returns None if code not found or expired. + """ + auth_code = _db.oid_auth_codes.get(code) + if not auth_code: + return None + if auth_code.expires_at < datetime.now(UTC): + # Expired - delete it + with _db.transaction("oid:expire_auth_code"): + del _db.oid_auth_codes[code] + return None + with _db.transaction("oid:consume_auth_code"): + del _db.oid_auth_codes[code] + return auth_code + + +def cleanup_expired_oid_auth_codes() -> int: + """Delete expired OIDC authorization codes. Returns count deleted.""" + now = datetime.now(UTC) + expired = [k for k, v in _db.oid_auth_codes.items() if v.expires_at < now] + if expired: + with _db.transaction("oid:cleanup_auth_codes"): + for code in expired: + del _db.oid_auth_codes[code] + return len(expired) diff --git a/paskia/db/structs.py b/paskia/db/structs.py index c1e981b..579cd22 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -2,7 +2,7 @@ from __future__ import annotations import hashlib import secrets -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from uuid import UUID import msgspec @@ -197,7 +197,7 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True): class User(msgspec.Struct, dict=True, omit_defaults=True): """User data structure. - Mutable fields: display_name, role_uuid, last_seen, visits, theme + Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username Immutable fields: created_at (set at creation, never modified) uuid is derived from created_at using uuid7. """ @@ -208,6 +208,8 @@ class User(msgspec.Struct, dict=True, omit_defaults=True): last_seen: datetime | None = None visits: int = 0 theme: str = "" # "" or "auto" = OS default, "light", "dark" + email: str | None = None # OIDC email claim + preferred_username: str | None = None # OIDC preferred_username claim def __post_init__(self): if not hasattr(self, "uuid"): @@ -506,6 +508,107 @@ class ResetToken(msgspec.Struct, dict=True): return token, passphrase +# ------------------------------------------------------------------------- +# OIDC Provider structures +# ------------------------------------------------------------------------- + + +class OIDClient(msgspec.Struct, dict=True): + """OIDC client (relying party) registration. + + client_id is the dict key (UUID). + """ + + client_secret_hash: bytes + name: str + redirect_uris: list[str] + created_at: datetime + + def __post_init__(self): + if not hasattr(self, "uuid"): + self.uuid: UUID = _UUID_UNSET + + @classmethod + def create( + cls, + name: str, + redirect_uris: list[str], + client_secret: str, + created_at: datetime | None = None, + ) -> tuple[OIDClient, str]: + """Create a new OIDClient with hashed secret. + + Returns (client, client_secret) tuple. + """ + now = created_at or datetime.now(UTC) + secret_hash = hashlib.sha256(client_secret.encode()).digest() + client = cls( + client_secret_hash=secret_hash, + name=name, + redirect_uris=redirect_uris, + created_at=now, + ) + client.uuid = uuid7.create(now) + return client, client_secret + + def verify_secret(self, client_secret: str) -> bool: + """Verify a client secret against stored hash.""" + return secrets.compare_digest( + self.client_secret_hash, + hashlib.sha256(client_secret.encode()).digest(), + ) + + +class OIDAuthCode(msgspec.Struct, dict=True): + """OIDC authorization code (short-lived, single-use). + + code is the dict key (random string). + """ + + client_uuid: UUID = msgspec.field(name="client") + user_uuid: UUID = msgspec.field(name="user") + redirect_uri: str + scope: str + nonce: str | None = None + code_challenge: str | None = None + code_challenge_method: str | None = None + created_at: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC)) + expires_at: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC)) + + def __post_init__(self): + if not hasattr(self, "code"): + self.code: str = "" + + @classmethod + def create( + cls, + client: UUID | OIDClient, + user: UUID, + redirect_uri: str, + scope: str, + nonce: str | None = None, + code_challenge: str | None = None, + code_challenge_method: str | None = None, + lifetime_seconds: int = 600, + ) -> OIDAuthCode: + """Create a new auth code with 10-minute default lifetime.""" + now = datetime.now(UTC) + client_uuid = client if isinstance(client, UUID) else client.uuid + auth_code = cls( + client_uuid=client_uuid, + user_uuid=user, + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + created_at=now, + expires_at=now + timedelta(seconds=lifetime_seconds), + ) + auth_code.code = secrets.token_urlsafe(32) + return auth_code + + class SessionContext(msgspec.Struct): session: Session user: User @@ -541,6 +644,9 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): credentials: dict[UUID, Credential] = {} sessions: dict[str, Session] = {} reset_tokens: dict[bytes, ResetToken] = {} + # OIDC provider data + oid_clients: dict[UUID, OIDClient] = {} + oid_auth_codes: dict[str, OIDAuthCode] = {} def __post_init__(self): # Store reference for persistence (not serialized) @@ -560,6 +666,11 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): session.key = key for key, token in self.reset_tokens.items(): token.key = key + # OIDC + for uuid, client in self.oid_clients.items(): + client.uuid = uuid + for code, auth_code in self.oid_auth_codes.items(): + auth_code.code = code def transaction(self, action, ctx=None, *, user=None): """Wrap writes in transaction. Delegates to JsonlStore.""" diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 22eaa76..0a9db95 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -11,11 +11,12 @@ from fastapi_vue import Frontend from paskia import globals from paskia.db import start_background, stop_background from paskia.db.logging import configure_db_logging -from paskia.fastapi import admin, api, auth_host, ws +from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi.__main__ import DEVMODE from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging from paskia.fastapi.session import AUTH_COOKIE from paskia.util import hostutil, passphrase, vitedev +from paskia.util.oidjwt import get_jwks # Configure custom logging configure_access_logging() @@ -87,6 +88,46 @@ app.middleware("http")(auth_host.redirect_middleware) app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) +app.mount("/auth/oid/", oid.app) + + +# OIDC Well-Known endpoints (must be at site root) +@app.get("/.well-known/openid-configuration") +async def openid_configuration(request: Request): + """OpenID Connect Discovery document.""" + # Build issuer URL from request + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("host", request.url.netloc) + issuer = f"{scheme}://{host}" + + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/auth/restricted/", + "token_endpoint": f"{issuer}/auth/oid/token", + "userinfo_endpoint": f"{issuer}/auth/oid/userinfo", + "jwks_uri": f"{issuer}/.well-known/jwks.json", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "profile", "email"], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + ], + "claims_supported": [ + "sub", + "name", + "preferred_username", + "email", + "permissions", + ], + } + + +@app.get("/.well-known/jwks.json") +async def jwks(): + """JSON Web Key Set for token verification.""" + return get_jwks() @app.get("/auth/restricted/") diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py new file mode 100644 index 0000000..fb1ea16 --- /dev/null +++ b/paskia/fastapi/oid.py @@ -0,0 +1,272 @@ +""" +OIDC Provider endpoints. + +Implements OpenID Connect 1.0 Authorization Code flow: +- POST /token - Token endpoint (code exchange) +- GET /userinfo - UserInfo endpoint (bearer token) + +Authorization is handled by /auth/restricted/ which passes OIDC params to +the /auth/ws/authenticate WebSocket. +""" + +import base64 +import hashlib +import logging +from uuid import UUID + +from fastapi import Body, Depends, FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse +from fastapi.security import HTTPBearer + +from paskia import db +from paskia.util import oidjwt + +_logger = logging.getLogger(__name__) + +app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + +def _get_issuer(request: Request) -> str: + """Build issuer URL from request.""" + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("host", request.url.netloc) + return f"{scheme}://{host}" + + +def _verify_pkce(code_verifier: str, code_challenge: str, method: str) -> bool: + """Verify PKCE code_verifier against stored code_challenge.""" + if method == "plain": + return code_verifier == code_challenge + elif method == "S256": + # SHA256 hash, base64url encode + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return computed == code_challenge + return False + + +def _parse_client_credentials( + request: Request, + client_id: str | None, + client_secret: str | None, +) -> tuple[str, str]: + """Extract client credentials from request (Basic auth or body params).""" + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("basic "): + try: + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") + client_id, client_secret = decoded.split(":", 1) + except Exception: + raise HTTPException(401, "Invalid Authorization header") + + if not client_id or not client_secret: + raise HTTPException(401, "Missing client credentials") + + return client_id, client_secret + + +@app.post("/token") +async def token( + request: Request, + grant_type: str = Body(..., embed=False), + code: str | None = Body(None, embed=False), + redirect_uri: str | None = Body(None, embed=False), + client_id: str | None = Body(None, embed=False), + client_secret: str | None = Body(None, embed=False), + code_verifier: str | None = Body(None, embed=False), +): + """OIDC Token endpoint. + + Exchanges authorization code for tokens. + Supports client_secret_post and client_secret_basic authentication. + """ + # Parse form data (OAuth uses application/x-www-form-urlencoded) + content_type = request.headers.get("content-type", "") + if "application/x-www-form-urlencoded" in content_type: + form = await request.form() + grant_type = form.get("grant_type", grant_type) + code = form.get("code", code) + redirect_uri = form.get("redirect_uri", redirect_uri) + client_id = form.get("client_id", client_id) + client_secret = form.get("client_secret", client_secret) + code_verifier = form.get("code_verifier", code_verifier) + + if grant_type != "authorization_code": + return JSONResponse( + {"error": "unsupported_grant_type"}, + status_code=400, + ) + + if not code: + return JSONResponse( + {"error": "invalid_request", "error_description": "Missing code"}, + status_code=400, + ) + + # Get client credentials + client_id, client_secret = _parse_client_credentials( + request, client_id, client_secret + ) + + # Validate client + try: + client_uuid = UUID(client_id) + except ValueError: + return JSONResponse({"error": "invalid_client"}, status_code=401) + + client = db.data().oid_clients.get(client_uuid) + if not client or not client.verify_secret(client_secret): + return JSONResponse({"error": "invalid_client"}, status_code=401) + + # Consume auth code (atomic delete + return) + auth_code = db.consume_oid_auth_code(code) + if not auth_code: + return JSONResponse( + {"error": "invalid_grant", "error_description": "Code expired or invalid"}, + status_code=400, + ) + + # Verify client matches + if auth_code.client_uuid != client.uuid: + return JSONResponse({"error": "invalid_grant"}, status_code=400) + + # Verify redirect_uri matches + if redirect_uri and redirect_uri != auth_code.redirect_uri: + return JSONResponse( + {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, + status_code=400, + ) + + # Verify PKCE if code_challenge was provided + if auth_code.code_challenge: + if not code_verifier: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Missing code_verifier", + }, + status_code=400, + ) + method = auth_code.code_challenge_method or "plain" + if not _verify_pkce(code_verifier, auth_code.code_challenge, method): + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Invalid code_verifier", + }, + status_code=400, + ) + + # Get user + user = db.data().users.get(auth_code.user_uuid) + if not user: + return JSONResponse( + {"error": "invalid_grant", "error_description": "User not found"}, + status_code=400, + ) + + # Build issuer + issuer = _get_issuer(request) + + # Get user's permissions from role + role = user.role + org = role.org + org_perm_uuids = {p.uuid for p in org.permissions} + permissions = [] + for perm_uuid in role.permission_set: + if perm_uuid not in org_perm_uuids: + continue + p = db.data().permissions.get(perm_uuid) + if p: + permissions.append(p.scope) + + # Create ID token + id_token = oidjwt.create_id_token( + issuer=issuer, + subject=user.uuid, + audience=client_id, + nonce=auth_code.nonce, + name=user.display_name, + preferred_username=user.preferred_username, + email=user.email, + permissions=permissions if permissions else None, + ) + + # Create access token + access_token = oidjwt.create_access_token( + issuer=issuer, + subject=user.uuid, + audience=client_id, + scope=auth_code.scope, + ) + + return JSONResponse( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 3600, + "id_token": id_token, + } + ) + + +bearer_auth = HTTPBearer(auto_error=False) + + +@app.get("/userinfo") +async def userinfo( + request: Request, + credentials=Depends(bearer_auth), +): + """OIDC UserInfo endpoint. + + Returns claims about the authenticated user. + Requires Bearer token from /token endpoint. + """ + if not credentials: + raise HTTPException(401, "Bearer token required") + + issuer = _get_issuer(request) + payload = oidjwt.decode_access_token(credentials.credentials, issuer) + if not payload: + raise HTTPException(401, "Invalid or expired token") + + # Get user + try: + user_uuid = UUID(payload["sub"]) + except (KeyError, ValueError): + raise HTTPException(401, "Invalid token") + + user = db.data().users.get(user_uuid) + if not user: + raise HTTPException(401, "User not found") + + # Get user's permissions + role = user.role + org = role.org + org_perm_uuids = {p.uuid for p in org.permissions} + permissions = [] + for perm_uuid in role.permission_set: + if perm_uuid not in org_perm_uuids: + continue + p = db.data().permissions.get(perm_uuid) + if p: + permissions.append(p.scope) + + # Build userinfo response based on scope + scope = payload.get("scope", "openid").split() + response = {"sub": str(user.uuid)} + + if "profile" in scope: + response["name"] = user.display_name + if user.preferred_username: + response["preferred_username"] = user.preferred_username + + if "email" in scope and user.email: + response["email"] = user.email + + # Always include permissions + if permissions: + response["permissions"] = permissions + + return response diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 30649d7..ac5fa59 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -1,10 +1,18 @@ +from urllib.parse import urlencode +from uuid import UUID + from fastapi import FastAPI, WebSocket from paskia import db from paskia.authsession import get_reset +from paskia.db import OIDAuthCode from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict -from paskia.fastapi.wschat import authenticate_and_login, register_chat +from paskia.fastapi.wschat import ( + authenticate_and_login, + authenticate_chat, + register_chat, +) from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import passkey from paskia.util import hostutil, passphrase @@ -83,10 +91,44 @@ async def websocket_register_add( @app.websocket("/authenticate") @websocket_error_handler -async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): +async def websocket_authenticate( + ws: WebSocket, + auth=AUTH_COOKIE, + # OIDC params (when present, creates auth code instead of session) + client_id: str | None = None, + redirect_uri: str | None = None, + scope: str = "openid", + state: str | None = None, + nonce: str | None = None, + code_challenge: str | None = None, + code_challenge_method: str | None = None, +): origin = validate_origin(ws) host = origin.split("://", 1)[1] + # OIDC mode: validate client before auth + oidc_client = None + if client_id and redirect_uri: + try: + client_uuid = UUID(client_id) + except ValueError: + await ws.send_json({"status": 400, "detail": "Invalid client_id"}) + return + + oidc_client = db.data().oid_clients.get(client_uuid) + if not oidc_client: + await ws.send_json({"status": 400, "detail": "Unknown client_id"}) + return + + if redirect_uri not in oidc_client.redirect_uris: + await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"}) + return + + scopes = scope.split() + if "openid" not in scopes: + await ws.send_json({"status": 400, "detail": "Scope must include openid"}) + return + # If there's an existing session, restrict to that user's credentials (reauth) session_user_uuid = None if auth: @@ -94,15 +136,41 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): if existing_ctx: session_user_uuid = existing_ctx.user.uuid - ctx = await authenticate_and_login(ws, auth) + if oidc_client: + # OIDC mode: authenticate only, no session + cred, new_sign_count = await authenticate_chat(ws) + db.update_credential_sign_count(cred.uuid, new_sign_count) - # If reauth mode, verify the credential belongs to the session's user - if session_user_uuid and ctx.user.uuid != session_user_uuid: - raise ValueError("This passkey belongs to a different account") + # Create auth code + auth_code = OIDAuthCode.create( + client=oidc_client.uuid, + user=cred.user_uuid, + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + db.create_oid_auth_code(auth_code) - await ws.send_json( - { - "user": str(ctx.user.uuid), - "session_token": ctx.session.key, - } - ) + # Build redirect URL + params = {"code": auth_code.code} + if state: + params["state"] = state + redirect_url = f"{redirect_uri}?{urlencode(params)}" + + await ws.send_json({"redirect_url": redirect_url}) + else: + # Normal mode: authenticate and create session + ctx = await authenticate_and_login(ws, auth) + + # If reauth mode, verify the credential belongs to the session's user + if session_user_uuid and ctx.user.uuid != session_user_uuid: + raise ValueError("This passkey belongs to a different account") + + await ws.send_json( + { + "user": str(ctx.user.uuid), + "session_token": ctx.session.key, + } + ) diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py new file mode 100644 index 0000000..4f79b4f --- /dev/null +++ b/paskia/util/oidjwt.py @@ -0,0 +1,193 @@ +""" +OIDC JWT utilities for signing ID tokens and serving JWKS. +""" + +import hashlib +import logging +from base64 import urlsafe_b64encode +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +_logger = logging.getLogger(__name__) + +# JWT signing key (loaded on first use) +_private_key = None +_public_key = None +_kid: str | None = None + +# Key file location (same directory as database) +_KEY_FILE = Path("oidc_key.pem") + + +def _load_or_generate_key() -> None: + """Load existing RSA key or generate a new one.""" + global _private_key, _public_key, _kid + + if _KEY_FILE.exists(): + _logger.info("Loading OIDC signing key from %s", _KEY_FILE) + pem_data = _KEY_FILE.read_bytes() + _private_key = serialization.load_pem_private_key(pem_data, password=None) + else: + _logger.info("Generating new OIDC signing key") + _private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + pem_data = _private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + _KEY_FILE.write_bytes(pem_data) + _logger.info("Saved OIDC signing key to %s", _KEY_FILE) + + _public_key = _private_key.public_key() + # Generate kid from public key fingerprint + pub_der = _public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + _kid = hashlib.sha256(pub_der).hexdigest()[:16] + + +def _ensure_key() -> None: + """Ensure key is loaded.""" + if _private_key is None: + _load_or_generate_key() + + +def _b64url_uint(value: int) -> str: + """Encode an integer as base64url without padding (for JWK).""" + # Calculate minimum bytes needed + byte_length = (value.bit_length() + 7) // 8 + value_bytes = value.to_bytes(byte_length, byteorder="big") + return urlsafe_b64encode(value_bytes).rstrip(b"=").decode("ascii") + + +def get_jwks() -> dict: + """Get JWKS (JSON Web Key Set) for public key verification.""" + _ensure_key() + assert _public_key is not None + pub_numbers = _public_key.public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": _kid, + "n": _b64url_uint(pub_numbers.n), + "e": _b64url_uint(pub_numbers.e), + } + ] + } + + +def create_id_token( + issuer: str, + subject: UUID, + audience: str, # client_id + nonce: str | None = None, + name: str | None = None, + preferred_username: str | None = None, + email: str | None = None, + permissions: list[str] | None = None, + expires_in: int = 3600, +) -> str: + """Create a signed ID token (JWT). + + Args: + issuer: Token issuer (site URL) + subject: User UUID (sub claim) + audience: Client ID (aud claim) + nonce: Nonce from authorization request + name: User's display name + preferred_username: User's preferred username + email: User's email address + permissions: List of permission scopes + expires_in: Token lifetime in seconds + + Returns: + Signed JWT string + """ + _ensure_key() + now = datetime.now(UTC) + payload = { + "iss": issuer, + "sub": str(subject), + "aud": audience, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=expires_in)).timestamp()), + } + if nonce: + payload["nonce"] = nonce + if name: + payload["name"] = name + if preferred_username: + payload["preferred_username"] = preferred_username + if email: + payload["email"] = email + if permissions: + payload["permissions"] = permissions + + return jwt.encode(payload, _private_key, algorithm="RS256", headers={"kid": _kid}) + + +def create_access_token( + issuer: str, + subject: UUID, + audience: str, + scope: str, + expires_in: int = 3600, +) -> str: + """Create a signed access token (JWT) for userinfo endpoint. + + Args: + issuer: Token issuer (site URL) + subject: User UUID + audience: Client ID + scope: Granted scopes + expires_in: Token lifetime in seconds + + Returns: + Signed JWT string + """ + _ensure_key() + now = datetime.now(UTC) + payload = { + "iss": issuer, + "sub": str(subject), + "aud": audience, + "scope": scope, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=expires_in)).timestamp()), + } + return jwt.encode(payload, _private_key, algorithm="RS256", headers={"kid": _kid}) + + +def decode_access_token(token: str, issuer: str) -> dict | None: + """Decode and verify an access token. + + Args: + token: JWT string + issuer: Expected issuer + + Returns: + Decoded payload or None if invalid + """ + _ensure_key() + try: + return jwt.decode( + token, + _public_key, + algorithms=["RS256"], + issuer=issuer, + options={"verify_aud": False}, # We'll verify audience separately if needed + ) + except jwt.PyJWTError: + return None diff --git a/pyproject.toml b/pyproject.toml index 3bb2d61..4e7193a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "webauthn>=1.11.1", "base64url>=1.0.0", "uuid7-standard>=1.0.0", - "pyjwt>=2.8.0", + "pyjwt[crypto]>=2.8.0", "jsondiff>=2.2.1", "msgspec>=0.20.0", "aiofiles>=25.1.0",