Remove some confusion between exchange and set-session endpoints, all using set-session now with a bearer code. Using codes in remote auth as well. Full separation of cookie and OIDC codes.

This commit is contained in:
Leo Vasanko
2026-02-16 14:26:20 +00:00
parent ebf5f6db2c
commit eece6d4a21
11 changed files with 257 additions and 134 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ function handleAuthenticated(result) {
postToParent({
type: 'auth-success',
authenticated: true,
sessionToken: result.session_token
exchangeCode: result.exchange_code
})
}
+5 -7
View File
@@ -144,7 +144,7 @@ async function registerPasskey() {
}
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
@@ -156,15 +156,13 @@ async function registerPasskey() {
setTimeout(() => { loading.value = false; goHome() }, 800)
}
async function setSessionCookie(result) {
if (!result?.session_token) {
throw new Error('Registration response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
throw new Error('Registration response missing exchange_code')
}
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {
Authorization: `Bearer ${result.session_token}`
}
headers: { 'Authorization': `Bearer ${result.exchange_code}` }
})
}
@@ -196,7 +196,7 @@ async function startRemoteAuth() {
} else if (msg.status === 'authenticated') {
// Success
completed.value = true
emit('authenticated', { session_token: msg.session_token })
emit('authenticated', { exchange_code: msg.exchange_code })
break
} else if (msg.status === 'denied') {
// Explicitly denied by the authenticating device
+7 -7
View File
@@ -181,7 +181,7 @@ async function authenticateUser() {
emit('authenticated', result)
return
}
try { await setSessionCookie(result) } catch (error) {
try { await exchangeCode(result) } catch (error) {
loading.value = false
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
@@ -212,13 +212,13 @@ function openProfile() {
if (profileWindow) profileWindow.focus()
}
async function setSessionCookie(result) {
if (!result?.session_token) {
console.error('setSessionCookie called with missing session_token:', result)
throw new Error('Authentication response missing session_token')
async function exchangeCode(result) {
if (!result?.exchange_code) {
console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing exchange_code')
}
return await fetchJson('/auth/api/set-session', {
method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` }
method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` }
})
}
@@ -233,7 +233,7 @@ function switchToLocal() {
async function handleRemoteAuthenticated(result) {
showMessage('Authenticated from another device!', 'success', 2000)
try {
await setSessionCookie(result)
await exchangeCode(result)
} catch (error) {
const message = error?.message || 'Failed to establish session'
showMessage(message, 'error', 4000)
+7 -7
View File
@@ -41,21 +41,21 @@ export const useAuthStore = defineStore('auth', {
}, effectiveDuration)
}
},
async setSessionCookie(result) {
if (!result?.session_token) {
console.error('setSessionCookie called with missing session_token:', result)
throw new Error('Authentication response missing session_token')
async exchangeCode(result) {
if (!result?.exchange_code) {
console.error('exchangeCode called with missing exchange_code:', result)
throw new Error('Authentication response missing exchange_code')
}
return await apiJson('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${result.session_token}`},
headers: { 'Authorization': `Bearer ${result.exchange_code}` },
})
},
async register() {
this.isLoading = true
try {
const result = await register()
await this.setSessionCookie(result)
await this.exchangeCode(result)
await this.loadUserInfo()
this.selectView()
return result
@@ -68,7 +68,7 @@ export const useAuthStore = defineStore('auth', {
try {
const result = await authenticate()
await this.setSessionCookie(result)
await this.exchangeCode(result)
await this.loadUserInfo()
this.selectView()
+44 -23
View File
@@ -1,8 +1,8 @@
"""
OIDC authorization code management.
Authorization code management for OIDC and cookie exchange flows.
Authorization codes are short-lived (60 seconds) and stored in-memory only.
Similar to remote auth, these are not persisted to the database.
Codes are short-lived (60 seconds) and stored in-memory only.
Two separate stores maintain full isolation between OIDC and cookie flows.
"""
from __future__ import annotations
@@ -20,26 +20,30 @@ _logger = logging.getLogger(__name__)
AUTH_CODE_LIFETIME = timedelta(seconds=60)
class AuthCode(msgspec.Struct):
"""A pending authorization code for OIDC or native auth."""
class OIDCCode(msgspec.Struct):
"""An OIDC authorization code pending token exchange.
PKCE uses S256 only (verified at auth time).
"""
session_key: str
created: datetime
oidc: OIDC | None = None # OIDC-specific fields (None for native auth)
class OIDC(msgspec.Struct):
"""OIDC verification data carried authenticate->token that is not stored in Session."""
redirect_uri: str
scope: str
nonce: str
code_challenge: str
code_challenge_method: str
# Public interface - auth codes in-memory store
codes: dict[str, AuthCode] = {}
class CookieCode(msgspec.Struct):
"""A cookie exchange code for setting session cookie after WebSocket auth."""
session_key: str
created: datetime
# Separate stores for each code type
oidc_codes: dict[str, OIDCCode] = {}
cookie_codes: dict[str, CookieCode] = {}
# Background cleanup task
_cleanup_task: asyncio.Task | None = None
@@ -77,16 +81,33 @@ async def _cleanup_loop():
def _cleanup_expired():
oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME
for code, auth_code in list(codes.items()):
for code, auth_code in list(oidc_codes.items()):
if auth_code.created < oldest:
del codes[code]
del oidc_codes[code]
for code, auth_code in list(cookie_codes.items()):
if auth_code.created < oldest:
del cookie_codes[code]
def store(auth_code: AuthCode) -> str:
"""Store an authorization code and return the code string.
def store_oidc(code: OIDCCode) -> str:
"""Store an OIDC authorization code and return the code string."""
token = secrets.token_urlsafe(12)
oidc_codes[token] = code
return token
Caller must construct AuthCode with their own timestamp.
"""
code = secrets.token_urlsafe(12)
codes[code] = auth_code
return code
def consume_oidc(token: str) -> OIDCCode | None:
"""Consume an OIDC code, returning it if valid. Atomic removal."""
return oidc_codes.pop(token, None)
def store_cookie(code: CookieCode) -> str:
"""Store a cookie exchange code and return the code string."""
token = secrets.token_urlsafe(12)
cookie_codes[token] = code
return token
def consume_cookie(token: str) -> CookieCode | None:
"""Consume a cookie exchange code, returning it if valid. Atomic removal."""
return cookie_codes.pop(token, None)
+120
View File
@@ -1,4 +1,5 @@
import logging
import secrets
from uuid import UUID
import base64url
@@ -12,6 +13,7 @@ from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC
from paskia.db import User as UserDC
from paskia.db.structs import OIDClient
from paskia.fastapi import authz
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE
@@ -919,3 +921,121 @@ async def admin_delete_permission(
db.delete_permission(permission_uuid, ctx=ctx)
return {"status": "ok"}
# -------------------- OIDC Clients --------------------
@app.get("/oidc-clients")
async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE):
"""List all OIDC clients (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
clients = db.data().oid_clients.values()
return MsgspecResponse(
[
{
"uuid": str(client.uuid),
"name": client.name,
"redirect_uris": client.redirect_uris,
"created_at": format_datetime(client.created_at),
}
for client in clients
]
)
@app.post("/oidc-clients")
async def admin_create_oidc_client(
request: Request,
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Create a new OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
name = payload.get("name", "").strip()
redirect_uris = payload.get("redirect_uris", [])
if not name:
raise ValueError("Client name is required")
if not redirect_uris:
raise ValueError("At least one redirect URI is required")
if not isinstance(redirect_uris, list):
raise ValueError("redirect_uris must be a list")
# Validate redirect URIs
for uri in redirect_uris:
if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}")
# Generate a secure client secret
client_secret = secrets.token_urlsafe(32)
# Create the client
client, _ = OIDClient.create(
name=name,
redirect_uris=redirect_uris,
client_secret=client_secret,
)
db.create_oid_client(client, ctx=ctx)
return {
"status": "ok",
"client_id": str(client.uuid),
"client_secret": client_secret,
"message": "Save the client_secret now - it cannot be retrieved later",
}
@app.delete("/oidc-clients/{client_uuid}")
async def admin_delete_oidc_client(
client_uuid: UUID,
request: Request,
auth=AUTH_COOKIE,
):
"""Delete an OIDC client (master admin only)."""
ctx = await authz.verify(
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
if not master_admin(ctx):
raise authz.AuthException(
status_code=403,
detail="Only master admin can manage OIDC clients",
mode="forbidden",
)
try:
db.delete_oid_client(client_uuid, ctx=ctx)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"status": "ok"}
+24 -37
View File
@@ -3,7 +3,6 @@ from contextlib import suppress
from datetime import UTC, datetime, timedelta
from fastapi import (
Body,
Depends,
FastAPI,
HTTPException,
@@ -68,34 +67,6 @@ async def general_exception_handler(
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.post("/exchange")
async def exchange_code(
request: Request,
response: Response,
code: str = Body(..., embed=True),
):
"""Exchange a session code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
auth_code = authcode.codes.pop(code, None)
if not auth_code:
raise HTTPException(status_code=400, detail="Invalid or expired code")
secret = auth_code.session_key
# Verify the session exists
host = hostutil.normalize_host(request.headers.get("host", ""))
ctx = db.data().session_ctx(secret, host)
if not ctx:
raise HTTPException(status_code=400, detail="Session not found")
# Set the session cookie
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}
@app.post("/validate")
async def validate_token(
request: Request,
@@ -294,13 +265,29 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth)
):
"""Exchange an auth code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
if not auth or not auth.credentials:
raise HTTPException(401, "Bearer token required")
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
raise HTTPException(400, "Bearer token required")
# Verify host is provided
host = hostutil.normalize_host(request.headers.get("host", ""))
if not host:
raise HTTPException(400, "Host header required")
a = authcode.consume_cookie(auth.credentials)
if not a:
raise HTTPException(401, "Code expired or already used")
secret = a.session_key
# Verify the session exists
ctx = db.data().session_ctx(secret, host)
if not ctx:
raise HTTPException(401, "Session expired")
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
"user": str(ctx.user.uuid),
}
raise HTTPException(401, f"Session not found on {host}")
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}
+25 -35
View File
@@ -165,15 +165,15 @@ async def _handle_authorization_code(
)
# Consume auth code (atomic delete + return)
auth_code = authcode.codes.pop(code, None)
if not auth_code:
oidc_code = authcode.consume_oidc(code)
if not oidc_code:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
status_code=400,
)
# Look up the OIDC session by token
session = _oidc_session_by_token(auth_code.session_key, client.uuid)
session = _oidc_session_by_token(oidc_code.session_key, client.uuid)
if not session:
return JSONResponse(
{
@@ -183,40 +183,30 @@ async def _handle_authorization_code(
status_code=400,
)
# Verify redirect_uri matches (OIDC only)
if auth_code.oidc and redirect_uri and redirect_uri != auth_code.oidc.redirect_uri:
# Verify redirect_uri matches
if redirect_uri and redirect_uri != oidc_code.redirect_uri:
return JSONResponse(
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
status_code=400,
)
# Verify PKCE (OIDC only)
if auth_code.oidc:
if not code_verifier:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Missing code_verifier",
},
status_code=400,
)
method = auth_code.oidc.code_challenge_method
if method != "S256":
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Only S256 code_challenge_method is supported",
},
status_code=400,
)
if not _verify_pkce(code_verifier, auth_code.oidc.code_challenge):
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid code_verifier",
},
status_code=400,
)
# Verify PKCE (S256 only, enforced at auth time)
if not code_verifier:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Missing code_verifier",
},
status_code=400,
)
if not _verify_pkce(code_verifier, oidc_code.code_challenge):
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid code_verifier",
},
status_code=400,
)
# Get user from session
user = db.data().users.get(session.user_uuid)
@@ -233,10 +223,10 @@ async def _handle_authorization_code(
request,
user,
client_id,
auth_code.session_key,
oidc_code.session_key,
sid,
auth_code.oidc.nonce if auth_code.oidc else None,
auth_code.oidc.scope if auth_code.oidc else None,
oidc_code.nonce,
oidc_code.scope,
credential_uuid=session.credential_uuid,
)
+14 -4
View File
@@ -10,12 +10,14 @@ Endpoints:
"""
import asyncio
from datetime import UTC, datetime
from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from paskia import db, remoteauth
from paskia import authcode, db, remoteauth
from paskia.authcode import CookieCode
from paskia.authsession import expires
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login
@@ -183,7 +185,7 @@ async def websocket_remote_auth_request(ws: WebSocket):
"user": str(result_data["user_uuid"]),
}
if result_data.get("session_token"):
response["session_token"] = result_data["session_token"]
response["exchange_code"] = result_data["session_token"]
if result_data.get("reset_token"):
response["reset_token"] = result_data["reset_token"]
await ws.send_json(response)
@@ -310,7 +312,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
# Handle authenticate request (no PoW needed - already validated during lookup)
if msg.get("authenticate") and request is not None:
ctx, session_token = await authenticate_and_login(ws, auth)
ctx, secret = await authenticate_and_login(ws, auth)
reset_token = None
@@ -324,11 +326,19 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
user=str(ctx.user.uuid),
)
# Create exchange code for the session (don't expose raw secret)
exchange_code = authcode.store_cookie(
CookieCode(
session_key=secret,
created=datetime.now(UTC),
)
)
# Complete the remote auth request (notifies the waiting device)
cred = db.data().credentials[ctx.session.credential_uuid]
completed = await remoteauth.instance.complete_request(
token=request.key,
session_token=session_token,
session_token=exchange_code,
user_uuid=ctx.user.uuid,
credential_uuid=cred.uuid,
reset_token=reset_token,
+9 -12
View File
@@ -6,7 +6,7 @@ from uuid import UUID
from fastapi import FastAPI, WebSocket
from paskia import authcode, db
from paskia.authcode import OIDC, AuthCode
from paskia.authcode import CookieCode, OIDCCode
from paskia.authsession import get_reset
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import Session
@@ -213,18 +213,15 @@ async def websocket_authenticate(
sign_count=new_sign_count,
)
# Create auth code (in-memory only)
auth_code = AuthCode(
oidc_code = OIDCCode(
session_key=token,
created=now,
oidc=OIDC(
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method or "S256",
),
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
)
code = authcode.store(auth_code)
code = authcode.store_oidc(oidc_code)
# Build redirect URL
params = {"code": code}
@@ -243,11 +240,11 @@ async def websocket_authenticate(
# Create exchange code (ephemeral, 60s TTL)
now = datetime.now(UTC)
auth_code = AuthCode(
cookie_code = CookieCode(
session_key=secret,
created=now,
)
exchange_code = authcode.store(auth_code)
exchange_code = authcode.store_cookie(cookie_code)
await ws.send_json(
{