OAuth2 OpenID Connect provider support etc. #3
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user