Implement stateful OIDC as Session objects. Add refresh tokens and backchannel logout.
This commit is contained in:
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user