Draft OpenID Connect support.

This commit is contained in:
Leo Vasanko
2026-02-14 23:01:13 +00:00
parent f195818f07
commit b5a5f2707a
12 changed files with 941 additions and 19 deletions
@@ -2,6 +2,7 @@
<RestrictedAuth
:mode="authMode"
:remote-auth-token="remoteAuthToken"
:oidc-query-string="oidcQueryString"
@authenticated="handleAuthenticated"
@back="handleBack"
/>
@@ -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,
+11 -1
View File
@@ -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'
+7 -2
View File
@@ -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}`)
+140
View File
@@ -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)
+15
View File
@@ -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",
]
+58
View File
@@ -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)
+113 -2
View File
@@ -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."""
+42 -1
View File
@@ -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/")
+272
View File
@@ -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
+80 -12
View File
@@ -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,
}
)
+193
View File
@@ -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
+1 -1
View File
@@ -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",