Implement stateful OIDC as Session objects. Add refresh tokens and backchannel logout.

This commit is contained in:
Leo Vasanko
2026-02-15 03:48:10 +00:00
parent 8132189a04
commit 18722f0e01
10 changed files with 609 additions and 154 deletions
+117 -25
View File
@@ -1,7 +1,7 @@
# OIDC Provider Implementation # OIDC Provider Implementation
## Overview ## Overview
Minimal OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys. OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys. Features stateful sessions with refresh tokens and back-channel logout support.
## Database Changes ## Database Changes
@@ -9,6 +9,23 @@ Minimal OpenID Connect 1.0 provider implementation for Paskia, enabling third-pa
- `email: str | None` — omit if None - `email: str | None` — omit if None
- `preferred_username: str` — initially derived from full name, require unique and non-empty - `preferred_username: str` — initially derived from full name, require unique and non-empty
### Session model additions (in `db/structs.py`)
Sessions now support both native (cookie-based) and OIDC authentication:
```python
class Session(msgspec.Struct, dict=True, omit_defaults=True):
user_uuid: UUID
credential_uuid: UUID # Always captured (passkey used for auth)
host: str
ip: str
user_agent: str
expiry: datetime
client_uuid: UUID | None = None # If set, this is an OIDC session
```
- Native sessions: `client_uuid` is None, validated via `session_ctx()`
- OIDC sessions: `client_uuid` set, looked up via `oidc_session_by_sid()`
- Session key serves as both cookie token (native) and `sid` claim (OIDC)
### New OIDC-specific models ### New OIDC-specific models
```python ```python
class OIDClient(msgspec.Struct): class OIDClient(msgspec.Struct):
@@ -19,27 +36,29 @@ class OIDClient(msgspec.Struct):
created_at: datetime created_at: datetime
def verify_secret(self, secret: str) -> bool: ... def verify_secret(self, secret: str) -> bool: ...
```
class OIDAuthCode(msgspec.Struct): ### In-memory auth codes (`paskia/oidauth.py`)
code: str # dict key, secure random Authorization codes are stored in-memory only (not persisted), with 60-second lifetime:
client: UUID ```python
user: UUID @dataclass
class AuthCode:
code: str
client_uuid: UUID
user_uuid: UUID
redirect_uri: str redirect_uri: str
scope: str scope: str
sid: str # Session ID for backchannel logout
nonce: str | None nonce: str | None
code_challenge: str | None # PKCE code_challenge: str | None
code_challenge_method: str | None code_challenge_method: str | None
created_at: datetime created_at: datetime
expires_at: datetime # 10 minutes expires_at: datetime # 60 seconds
@classmethod
def create(cls, ...) -> OIDAuthCode: ...
``` ```
### DB additions ### DB additions
```python ```python
oid_clients: dict[UUID, OIDClient] = {} oid_clients: dict[UUID, OIDClient] = {}
oid_auth_codes: dict[str, OIDAuthCode] = {}
``` ```
## Endpoints ## Endpoints
@@ -49,8 +68,9 @@ oid_auth_codes: dict[str, OIDAuthCode] = {}
- `GET /.well-known/jwks.json` — Public keys for token verification - `GET /.well-known/jwks.json` — Public keys for token verification
### OIDC routes (`/auth/oidc/`) ### OIDC routes (`/auth/oidc/`)
- `POST /auth/oidc/token` — Token endpoint (code exchange) - `POST /auth/oidc/token` — Token endpoint (code exchange & refresh)
- `GET /auth/oidc/userinfo` — UserInfo endpoint (bearer token) - `GET /auth/oidc/userinfo` — UserInfo endpoint (bearer token)
- `POST /auth/oidc/backchannel-logout` — Back-channel logout endpoint
### Authorization (via existing restricted app) ### Authorization (via existing restricted app)
- `GET /auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid...` - `GET /auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid...`
@@ -74,6 +94,7 @@ The restricted app detects OIDC params from URL and handles authentication via W
```json ```json
{ {
"sub": "user-uuid", "sub": "user-uuid",
"sid": "session-id",
"name": "Alice", "name": "Alice",
"preferred_username": "alice", "preferred_username": "alice",
"email": "alice@example.com", "email": "alice@example.com",
@@ -83,24 +104,92 @@ The restricted app detects OIDC params from URL and handles authentication via W
## Authorization Flow ## Authorization Flow
The `/auth/restricted/oidc` page handles OIDC authorization (same code as `/auth/restricted/iframe` for API auth): The `/auth/restricted/oidc` page handles OIDC authorization:
1. Client redirects to `/auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...` 1. Client redirects to `/auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...`
2. Frontend detects OIDC params from `window.location.search` 2. Frontend detects OIDC params from `window.location.search`
3. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}` 3. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}`
4. User authenticates via passkey 4. User authenticates via passkey
5. WebSocket validates client/redirect_uri, authenticates user, creates auth code 5. WebSocket validates client/redirect_uri, authenticates user
6. WebSocket returns `{"redirect_url": "redirect_uri?code=...&state=..."}` 6. **Creates OIDC session** (persisted, with credential/IP/user_agent)
7. Frontend redirects to the URL 7. Creates auth code with session's `sid`
8. Client exchanges code at `/auth/oidc/token` → receives `id_token` + `access_token` 8. WebSocket returns `{"redirect_url": "redirect_uri?code=...&state=..."}`
9. Optionally calls `/auth/oidc/userinfo` with bearer token 9. Frontend redirects to the URL
10. Client exchanges code at `/auth/oidc/token` → receives tokens
**Token Response:**
```json
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "{sid}",
"id_token": "..."
}
```
**Key design points:** **Key design points:**
- No session created during OIDC auth (stateless for the OIDC client) - OIDC sessions are persisted (credential, IP, user_agent recorded)
- Session key becomes `sid` claim and `refresh_token`
- Raw query string preserved throughout (no parsing/reconstruction of redirect_uri) - Raw query string preserved throughout (no parsing/reconstruction of redirect_uri)
- Redirect URI validated against client's registered URIs via exact string match - Redirect URI validated against client's registered URIs via exact string match
- PKCE required (S256 only) - PKCE required (S256 only)
## Refresh Tokens
The `refresh_token` returned is the OIDC session's `sid`. On refresh:
**Request:**
```
POST /auth/oidc/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token={sid}&client_id=...&client_secret=...
```
**Behavior:**
1. Validates session exists and belongs to client
2. Checks session not expired
3. Extends session expiry to +24h (sliding window, matching native sessions)
4. Records current IP and user_agent
5. Issues new `access_token` and `id_token` (with same `sid`)
6. Returns same `refresh_token` (the sid is stable)
**Session lifetime:** 24 hours with sliding window via refresh — same as native sessions.
## Back-Channel Logout
Supports OIDC Back-Channel Logout 1.0.
**Discovery claims:**
```json
{
"backchannel_logout_supported": true,
"backchannel_logout_session_supported": true
}
```
**Endpoint:** `POST /auth/oidc/backchannel-logout`
**Request:**
```
POST /auth/oidc/backchannel-logout
Content-Type: application/x-www-form-urlencoded
logout_token={jwt}
```
The `logout_token` JWT must contain:
- `sid` — specific session to terminate, OR
- `sub` — terminate all sessions for user (optionally filtered by `aud` client)
**Behavior:**
1. Decode and verify logout token signature
2. Look up session(s) by `sid` or `sub`
3. Verify client (`aud`) matches session's `client_uuid`
4. Delete session(s)
5. Return 200 OK (even if no sessions found, per spec)
## WebSocket OIDC Mode ## WebSocket OIDC Mode
The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params: The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params:
@@ -112,21 +201,25 @@ The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params:
When OIDC params present: When OIDC params present:
- Validates client and redirect_uri before authentication - Validates client and redirect_uri before authentication
- Creates auth code after successful passkey auth - Performs passkey authentication
- Creates OIDC session via `db.oidc_login()` (persists credential, IP, user_agent)
- Creates auth code with session's sid
- Returns `{"redirect_url": "..."}` instead of session token - Returns `{"redirect_url": "..."}` instead of session token
When OIDC params absent: When OIDC params absent:
- Normal authentication flow with session creation - Normal authentication flow with native session creation
## Files ## Files
### Created ### Created
- `paskia/db/structs.py` — Added `OIDClient`, `OIDAuthCode` models; User fields `email`, `preferred_username` - `paskia/oidauth.py` — In-memory auth code storage (60-second lifetime, no persistence)
- `paskia/db/operations.py` — CRUD for OIDC entities - `paskia/db/structs.py` — Added `OIDClient` model; Session `client_uuid` field; User fields `email`, `preferred_username`
- `paskia/fastapi/oid.py` — Token and userinfo endpoints - `paskia/db/operations.py` — CRUD for OIDC clients; `oidc_login()` function
- `paskia/fastapi/oid.py` — Token, userinfo, and backchannel-logout endpoints
- `paskia/util/oidjwt.py` — Ed25519 key management, JWT creation, JWKS - `paskia/util/oidjwt.py` — Ed25519 key management, JWT creation, JWKS
### Modified ### Modified
- `paskia/globals.py` — Initialize oidauth on startup
- `paskia/fastapi/mainapp.py` — Mount OIDC app, well-known endpoints - `paskia/fastapi/mainapp.py` — Mount OIDC app, well-known endpoints
- `paskia/fastapi/ws.py` — OIDC params support in `/authenticate` - `paskia/fastapi/ws.py` — OIDC params support in `/authenticate`
- `frontend/src/utils/passkey.js` — Pass query string to authenticate - `frontend/src/utils/passkey.js` — Pass query string to authenticate
@@ -137,4 +230,3 @@ When OIDC params absent:
- Master admin UI for client management - Master admin UI for client management
- User UI for editing preferred_username, email, profile picture - User UI for editing preferred_username, email, profile picture
- Token revocation endpoint (optional) - Token revocation endpoint (optional)
- Logout/session management spec (optional)
+2 -8
View File
@@ -31,11 +31,8 @@ from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import ( from paskia.db.operations import (
add_permission_to_org, add_permission_to_org,
add_permission_to_role, add_permission_to_role,
cleanup_expired_oid_auth_codes,
consume_oid_auth_code,
create_credential, create_credential,
create_credential_session, create_credential_session,
create_oid_auth_code,
create_oid_client, create_oid_client,
create_org, create_org,
create_permission, create_permission,
@@ -53,6 +50,7 @@ from paskia.db.operations import (
delete_sessions_for_user, delete_sessions_for_user,
delete_user, delete_user,
login, login,
oidc_login,
remove_permission_from_org, remove_permission_from_org,
remove_permission_from_role, remove_permission_from_role,
set_session_host, set_session_host,
@@ -70,7 +68,6 @@ from paskia.db.structs import (
DB, DB,
Config, Config,
Credential, Credential,
OIDAuthCode,
OIDClient, OIDClient,
Org, Org,
Permission, Permission,
@@ -92,7 +89,6 @@ __all__ = [
"Config", "Config",
"Credential", "Credential",
"DB", "DB",
"OIDAuthCode",
"OIDClient", "OIDClient",
"Org", "Org",
"Permission", "Permission",
@@ -139,6 +135,7 @@ __all__ = [
"delete_sessions_for_user", "delete_sessions_for_user",
"delete_user", "delete_user",
"login", "login",
"oidc_login",
"remove_permission_from_org", "remove_permission_from_org",
"remove_permission_from_role", "remove_permission_from_role",
"set_session_host", "set_session_host",
@@ -152,9 +149,6 @@ __all__ = [
"update_user_role", "update_user_role",
"update_user_theme", "update_user_theme",
# OIDC # OIDC
"cleanup_expired_oid_auth_codes",
"consume_oid_auth_code",
"create_oid_auth_code",
"create_oid_client", "create_oid_client",
"delete_oid_client", "delete_oid_client",
] ]
+50 -36
View File
@@ -20,7 +20,6 @@ from paskia.db.structs import (
DB, DB,
Config, Config,
Credential, Credential,
OIDAuthCode,
OIDClient, OIDClient,
Org, Org,
Permission, Permission,
@@ -532,6 +531,56 @@ def login(
return session.key return session.key
def oidc_login(
user_uuid: UUID,
credential_uuid: UUID,
sign_count: int,
client_uuid: UUID,
host: str,
ip: str,
user_agent: str,
) -> str:
"""Create an OIDC session after passkey authentication.
Similar to login() but creates an OIDC session (with client_uuid set).
The returned session key becomes the 'sid' claim in the id_token.
Session uses standard SESSION_LIFETIME (24h) with refresh token support.
Updates:
- credential.sign_count, credential.last_used
Creates:
- new OIDC session
Returns the session key (sid for OIDC tokens).
"""
if isinstance(user_uuid, str):
user_uuid = UUID(user_uuid)
now = datetime.now(UTC)
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
if client_uuid not in _db.oid_clients:
raise ValueError(f"OIDC client {client_uuid} not found")
session = Session.create(
user=user_uuid,
credential=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
client=client_uuid,
)
user_str = str(user_uuid)
with _db.transaction("oidc_login", user=user_str):
session.store(now)
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
return session.key
def create_credential_session( def create_credential_session(
user_uuid: UUID, user_uuid: UUID,
credential: Credential, credential: Credential,
@@ -609,38 +658,3 @@ def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -
raise ValueError(f"OIDC client {client_uuid} not found") raise ValueError(f"OIDC client {client_uuid} not found")
with _db.transaction("admin:delete_oid_client", ctx): with _db.transaction("admin:delete_oid_client", ctx):
del _db.oid_clients[client_uuid] 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)
+35 -57
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import hashlib import hashlib
import secrets import secrets
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
import msgspec import msgspec
@@ -355,12 +355,14 @@ class Credential(msgspec.Struct, dict=True):
return cred return cred
class Session(msgspec.Struct, dict=True): class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure. """Session data structure.
Mutable fields: expiry (updated on session refresh) Mutable fields: expiry (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is stored in the dict key, not in the struct. key is stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session (key is the sid claim).
""" """
user_uuid: UUID = msgspec.field(name="user") user_uuid: UUID = msgspec.field(name="user")
@@ -369,6 +371,7 @@ class Session(msgspec.Struct, dict=True):
ip: str ip: str
user_agent: str user_agent: str
expiry: datetime expiry: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self): def __post_init__(self):
if not hasattr(self, "key"): if not hasattr(self, "key"):
@@ -416,8 +419,12 @@ class Session(msgspec.Struct, dict=True):
ip: str, ip: str,
user_agent: str, user_agent: str,
expiry: datetime, expiry: datetime,
client: UUID | None = None,
) -> Session: ) -> Session:
"""Create a new Session with auto-generated key.""" """Create a new Session with auto-generated key.
If client is provided, creates an OIDC session (key becomes sid claim).
"""
user_uuid = user if isinstance(user, UUID) else user.uuid user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = ( credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid credential if isinstance(credential, UUID) else credential.uuid
@@ -429,6 +436,7 @@ class Session(msgspec.Struct, dict=True):
ip=ip, ip=ip,
user_agent=user_agent, user_agent=user_agent,
expiry=expiry, expiry=expiry,
client_uuid=client,
) )
session.key = secrets.token_urlsafe(12) session.key = secrets.token_urlsafe(12)
return session return session
@@ -559,56 +567,6 @@ class OIDClient(msgspec.Struct, dict=True):
) )
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): class SessionContext(msgspec.Struct):
session: Session session: Session
user: User user: User
@@ -646,7 +604,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
reset_tokens: dict[bytes, ResetToken] = {} reset_tokens: dict[bytes, ResetToken] = {}
# OIDC provider data # OIDC provider data
oid_clients: dict[UUID, OIDClient] = {} oid_clients: dict[UUID, OIDClient] = {}
oid_auth_codes: dict[str, OIDAuthCode] = {}
def __post_init__(self): def __post_init__(self):
# Store reference for persistence (not serialized) # Store reference for persistence (not serialized)
@@ -669,8 +626,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
# OIDC # OIDC
for uuid, client in self.oid_clients.items(): for uuid, client in self.oid_clients.items():
client.uuid = uuid 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): def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore.""" """Wrap writes in transaction. Delegates to JsonlStore."""
@@ -693,6 +648,10 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
except KeyError: except KeyError:
return None return None
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
# Normalize host for comparison (stored hosts are already normalized) # Normalize host for comparison (stored hosts are already normalized)
normalized_input = hostutil.normalize_host(host) normalized_input = hostutil.normalize_host(host)
@@ -734,3 +693,22 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credential=credential, credential=credential,
permissions=effective_perms, permissions=effective_perms,
) )
def oidc_session_by_sid(
self, sid: str, client_uuid: UUID | None = None
) -> Session | None:
"""Look up an OIDC session by sid (session key).
Args:
sid: The session ID (same as session key)
client_uuid: If provided, verify the session belongs to this client
Returns:
Session if found and valid OIDC session, None otherwise
"""
s = self.sessions.get(sid)
if not s or s.client_uuid is None:
return None
if client_uuid is not None and s.client_uuid != client_uuid:
return None
return s
+5
View File
@@ -106,7 +106,11 @@ async def openid_configuration(request: Request):
"token_endpoint": f"{issuer}/auth/oidc/token", "token_endpoint": f"{issuer}/auth/oidc/token",
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo", "userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
"jwks_uri": f"{issuer}/.well-known/jwks.json", "jwks_uri": f"{issuer}/.well-known/jwks.json",
"backchannel_logout_supported": True,
"backchannel_logout_session_supported": True,
"backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout",
"response_types_supported": ["code"], "response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"], "subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["EdDSA"], "id_token_signing_alg_values_supported": ["EdDSA"],
"scopes_supported": ["openid", "profile", "email"], "scopes_supported": ["openid", "profile", "email"],
@@ -121,6 +125,7 @@ async def openid_configuration(request: Request):
"preferred_username", "preferred_username",
"email", "email",
"permissions", "permissions",
"sid",
], ],
} }
+215 -19
View File
@@ -12,13 +12,15 @@ the /auth/ws/authenticate WebSocket.
import base64 import base64
import hashlib import hashlib
import logging import logging
from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from fastapi import Body, Depends, FastAPI, HTTPException, Request from fastapi import Body, Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer from fastapi.security import HTTPBearer
from paskia import db from paskia import db, oidauth
from paskia.config import SESSION_LIFETIME
from paskia.util import oidjwt from paskia.util import oidjwt
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -69,10 +71,14 @@ async def token(
client_id: str | None = Body(None, embed=False), client_id: str | None = Body(None, embed=False),
client_secret: str | None = Body(None, embed=False), client_secret: str | None = Body(None, embed=False),
code_verifier: str | None = Body(None, embed=False), code_verifier: str | None = Body(None, embed=False),
refresh_token: str | None = Body(None, embed=False),
): ):
"""OIDC Token endpoint. """OIDC Token endpoint.
Exchanges authorization code for tokens. Supports:
- grant_type=authorization_code: Exchange code for tokens
- grant_type=refresh_token: Refresh access token using sid
Supports client_secret_post and client_secret_basic authentication. Supports client_secret_post and client_secret_basic authentication.
""" """
# Parse form data (OAuth uses application/x-www-form-urlencoded) # Parse form data (OAuth uses application/x-www-form-urlencoded)
@@ -85,20 +91,9 @@ async def token(
client_id = form.get("client_id", client_id) client_id = form.get("client_id", client_id)
client_secret = form.get("client_secret", client_secret) client_secret = form.get("client_secret", client_secret)
code_verifier = form.get("code_verifier", code_verifier) code_verifier = form.get("code_verifier", code_verifier)
refresh_token = form.get("refresh_token", refresh_token)
if grant_type != "authorization_code": # Get client credentials (required for all grant types)
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( client_id, client_secret = _parse_client_credentials(
request, client_id, client_secret request, client_id, client_secret
) )
@@ -113,8 +108,36 @@ async def token(
if not client or not client.verify_secret(client_secret): if not client or not client.verify_secret(client_secret):
return JSONResponse({"error": "invalid_client"}, status_code=401) return JSONResponse({"error": "invalid_client"}, status_code=401)
if grant_type == "authorization_code":
return await _handle_authorization_code(
request, client, client_id, code, redirect_uri, code_verifier
)
elif grant_type == "refresh_token":
return await _handle_refresh_token(request, client, client_id, refresh_token)
else:
return JSONResponse(
{"error": "unsupported_grant_type"},
status_code=400,
)
async def _handle_authorization_code(
request: Request,
client,
client_id: str,
code: str | None,
redirect_uri: str | None,
code_verifier: str | None,
):
"""Handle grant_type=authorization_code."""
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing code"},
status_code=400,
)
# Consume auth code (atomic delete + return) # Consume auth code (atomic delete + return)
auth_code = db.consume_oid_auth_code(code) auth_code = oidauth.instance.consume(code)
if not auth_code: if not auth_code:
return JSONResponse( return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"}, {"error": "invalid_grant", "error_description": "Code expired or invalid"},
@@ -168,7 +191,87 @@ async def token(
status_code=400, status_code=400,
) )
# Build issuer return _build_token_response(
request, user, client_id, auth_code.sid, auth_code.nonce, auth_code.scope
)
async def _handle_refresh_token(
request: Request,
client,
client_id: str,
refresh_token_value: str | None,
):
"""Handle grant_type=refresh_token.
The refresh_token is the OIDC session sid. On refresh:
- Validates session exists and belongs to client
- Extends session expiry (24h sliding window)
- Records current IP and user_agent
- Issues new access_token and id_token
"""
if not refresh_token_value:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing refresh_token"},
status_code=400,
)
# Look up session by sid
session = db.data().oidc_session_by_sid(refresh_token_value, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid or expired refresh_token",
},
status_code=400,
)
# Check session not expired
now = datetime.now(UTC)
if session.expiry < now:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Refresh token expired"},
status_code=400,
)
# Get user
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Refresh the session - extend expiry and record IP/user_agent
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
db.update_session(
session.key,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
)
_logger.info("OIDC session refreshed: %s", session.key)
return _build_token_response(
request, user, client_id, session.key, nonce=None, scope="openid"
)
def _build_token_response(
request: Request,
user,
client_id: str,
sid: str,
nonce: str | None,
scope: str,
):
"""Build the token response with access_token, id_token, and refresh_token."""
issuer = _get_issuer(request) issuer = _get_issuer(request)
# Get user's permissions from role # Get user's permissions from role
@@ -188,7 +291,8 @@ async def token(
issuer=issuer, issuer=issuer,
subject=user.uuid, subject=user.uuid,
audience=client_id, audience=client_id,
nonce=auth_code.nonce, nonce=nonce,
sid=sid,
name=user.display_name, name=user.display_name,
preferred_username=user.preferred_username, preferred_username=user.preferred_username,
email=user.email, email=user.email,
@@ -200,7 +304,7 @@ async def token(
issuer=issuer, issuer=issuer,
subject=user.uuid, subject=user.uuid,
audience=client_id, audience=client_id,
scope=auth_code.scope, scope=scope,
) )
return JSONResponse( return JSONResponse(
@@ -208,6 +312,7 @@ async def token(
"access_token": access_token, "access_token": access_token,
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 3600, "expires_in": 3600,
"refresh_token": sid,
"id_token": id_token, "id_token": id_token,
} }
) )
@@ -273,3 +378,94 @@ async def userinfo(
response["permissions"] = permissions response["permissions"] = permissions
return response return response
@app.post("/backchannel-logout")
async def backchannel_logout(
request: Request,
logout_token: str | None = Body(None, embed=False),
):
"""OIDC Back-Channel Logout endpoint.
Receives a logout_token JWT from the RP and invalidates the session.
The logout_token must contain either 'sid' (session ID) or 'sub' (user ID).
"""
# Parse form data
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" in content_type:
form = await request.form()
logout_token = form.get("logout_token", logout_token)
if not logout_token:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing logout_token"},
status_code=400,
)
# Decode and verify the logout token
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(logout_token, issuer)
if not payload:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid logout_token"},
status_code=400,
)
# Validate required claims
sid = payload.get("sid")
sub = payload.get("sub")
if not sid and not sub:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "logout_token must contain sid or sub",
},
status_code=400,
)
# Get client from audience
aud = payload.get("aud")
client_uuid = None
if aud:
try:
client_uuid = UUID(aud)
except ValueError:
pass
# Delete session(s)
deleted = 0
if sid:
# Delete specific session by sid
session = db.data().oidc_session_by_sid(sid, client_uuid)
if session:
db.delete_session(session.key)
deleted = 1
_logger.info("Back-channel logout: deleted session %s", sid)
elif sub:
# Delete all OIDC sessions for this user/client
try:
user_uuid = UUID(sub)
except ValueError:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sub claim"},
status_code=400,
)
# Find and delete matching sessions
sessions_to_delete = [
s
for s in db.data().sessions.values()
if s.user_uuid == user_uuid
and s.client_uuid is not None
and (client_uuid is None or s.client_uuid == client_uuid)
]
for session in sessions_to_delete:
db.delete_session(session.key)
deleted += 1
if deleted:
_logger.info(
"Back-channel logout: deleted %d sessions for user %s", deleted, sub
)
# Return 200 OK even if no sessions were found (per spec)
return JSONResponse({"deleted": deleted})
+24 -9
View File
@@ -3,9 +3,8 @@ from uuid import UUID
from fastapi import FastAPI, WebSocket from fastapi import FastAPI, WebSocket
from paskia import db from paskia import db, oidauth
from paskia.authsession import get_reset from paskia.authsession import get_reset
from paskia.db import OIDAuthCode
from paskia.fastapi import authz, remote from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import ( from paskia.fastapi.wschat import (
@@ -152,21 +151,37 @@ async def websocket_authenticate(
session_user_uuid = existing_ctx.user.uuid session_user_uuid = existing_ctx.user.uuid
if oidc_client: if oidc_client:
# OIDC mode: authenticate only, no session # OIDC mode: authenticate and create OIDC session
cred, new_sign_count = await authenticate_chat(ws) cred, new_sign_count = await authenticate_chat(ws)
db.update_credential_sign_count(cred.uuid, new_sign_count)
# Create auth code # Get metadata for session
auth_code = OIDAuthCode.create( origin = validate_origin(ws)
client=oidc_client.uuid, host = origin.split("://", 1)[1]
user=cred.user_uuid, normalized_host = hostutil.normalize_host(host)
metadata = infodict(ws, "oidc_auth")
# Create OIDC session (returns sid)
sid = db.oidc_login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
client_uuid=oidc_client.uuid,
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
)
# Create auth code (in-memory only)
auth_code = oidauth.instance.create(
client_uuid=oidc_client.uuid,
user_uuid=cred.user_uuid,
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
scope=scope, scope=scope,
sid=sid,
nonce=nonce, nonce=nonce,
code_challenge=code_challenge, code_challenge=code_challenge,
code_challenge_method=code_challenge_method, code_challenge_method=code_challenge_method,
) )
db.create_oid_auth_code(auth_code)
# Build redirect URL # Build redirect URL
params = {"code": auth_code.code} params = {"code": auth_code.code}
+5
View File
@@ -58,6 +58,11 @@ async def init(
# Initialize remote auth manager # Initialize remote auth manager
await remoteauth.init() await remoteauth.init()
# Initialize OIDC auth code manager
from paskia import oidauth
await oidauth.init()
if bootstrap: if bootstrap:
# Bootstrap system if needed # Bootstrap system if needed
+152
View File
@@ -0,0 +1,152 @@
"""
OIDC authorization code management.
Authorization codes are short-lived (60 seconds) and stored in-memory only.
Similar to remote auth, these are not persisted to the database.
"""
import asyncio
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID
_logger = logging.getLogger(__name__)
# Auth codes expire after this duration
AUTH_CODE_LIFETIME = timedelta(seconds=60)
@dataclass
class AuthCode:
"""A pending OIDC authorization code."""
code: str
client_uuid: UUID
user_uuid: UUID
redirect_uri: str
scope: str
sid: str # Session ID for backchannel logout
nonce: str | None
code_challenge: str | None
code_challenge_method: str | None
created_at: datetime
expires_at: datetime
class OIDAuthCodeManager:
"""Manages pending OIDC authorization codes in-memory."""
def __init__(self):
self._codes: dict[str, AuthCode] = {}
self._cleanup_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
async def start(self):
"""Start the cleanup background task."""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
async def stop(self):
"""Stop the cleanup background task."""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _cleanup_loop(self):
"""Periodically clean up expired codes."""
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
_logger.exception("Error in OIDC auth code cleanup loop")
async def _cleanup_expired(self):
"""Remove expired authorization codes."""
now = datetime.now(UTC)
expired_codes = []
async with self._lock:
for code, auth_code in self._codes.items():
if now > auth_code.expires_at:
expired_codes.append(code)
for code in expired_codes:
del self._codes[code]
if expired_codes:
_logger.debug("Cleaned up %d expired OIDC auth codes", len(expired_codes))
def create(
self,
client_uuid: UUID,
user_uuid: UUID,
redirect_uri: str,
scope: str,
sid: str,
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> AuthCode:
"""Create a new authorization code.
Returns the AuthCode with a unique code string.
"""
now = datetime.now(UTC)
code = secrets.token_urlsafe(32)
auth_code = AuthCode(
code=code,
client_uuid=client_uuid,
user_uuid=user_uuid,
redirect_uri=redirect_uri,
scope=scope,
sid=sid,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
created_at=now,
expires_at=now + AUTH_CODE_LIFETIME,
)
self._codes[code] = auth_code
return auth_code
def consume(self, code: str) -> AuthCode | None:
"""Consume (delete and return) an authorization code.
Returns None if code not found or expired.
"""
auth_code = self._codes.get(code)
if not auth_code:
return None
if auth_code.expires_at < datetime.now(UTC):
# Expired - delete it
del self._codes[code]
return None
del self._codes[code]
return auth_code
# Global instance (initialized on startup)
instance: OIDAuthCodeManager | None = None
async def init():
"""Initialize the global OIDC auth code manager."""
global instance
instance = OIDAuthCodeManager()
await instance.start()
async def shutdown():
"""Shutdown the global OIDC auth code manager."""
global instance
if instance:
await instance.stop()
instance = None
+4
View File
@@ -86,6 +86,7 @@ def create_id_token(
subject: UUID, subject: UUID,
audience: str, # client_id audience: str, # client_id
nonce: str | None = None, nonce: str | None = None,
sid: str | None = None,
name: str | None = None, name: str | None = None,
preferred_username: str | None = None, preferred_username: str | None = None,
email: str | None = None, email: str | None = None,
@@ -99,6 +100,7 @@ def create_id_token(
subject: User UUID (sub claim) subject: User UUID (sub claim)
audience: Client ID (aud claim) audience: Client ID (aud claim)
nonce: Nonce from authorization request nonce: Nonce from authorization request
sid: Session ID for backchannel logout
name: User's display name name: User's display name
preferred_username: User's preferred username preferred_username: User's preferred username
email: User's email address email: User's email address
@@ -119,6 +121,8 @@ def create_id_token(
} }
if nonce: if nonce:
payload["nonce"] = nonce payload["nonce"] = nonce
if sid:
payload["sid"] = sid
if name: if name:
payload["name"] = name payload["name"] = name
if preferred_username: if preferred_username: