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
+215 -19
View File
@@ -12,13 +12,15 @@ the /auth/ws/authenticate WebSocket.
import base64
import hashlib
import logging
from datetime import UTC, datetime
from uuid import UUID
from fastapi import Body, Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import db
from paskia import db, oidauth
from paskia.config import SESSION_LIFETIME
from paskia.util import oidjwt
_logger = logging.getLogger(__name__)
@@ -69,10 +71,14 @@ async def token(
client_id: str | None = Body(None, embed=False),
client_secret: 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.
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.
"""
# 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_secret = form.get("client_secret", client_secret)
code_verifier = form.get("code_verifier", code_verifier)
refresh_token = form.get("refresh_token", refresh_token)
if grant_type != "authorization_code":
return JSONResponse(
{"error": "unsupported_grant_type"},
status_code=400,
)
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing code"},
status_code=400,
)
# Get client credentials
# Get client credentials (required for all grant types)
client_id, client_secret = _parse_client_credentials(
request, client_id, client_secret
)
@@ -113,8 +108,36 @@ async def token(
if not client or not client.verify_secret(client_secret):
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)
auth_code = db.consume_oid_auth_code(code)
auth_code = oidauth.instance.consume(code)
if not auth_code:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
@@ -168,7 +191,87 @@ async def token(
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)
# Get user's permissions from role
@@ -188,7 +291,8 @@ async def token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
nonce=auth_code.nonce,
nonce=nonce,
sid=sid,
name=user.display_name,
preferred_username=user.preferred_username,
email=user.email,
@@ -200,7 +304,7 @@ async def token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
scope=auth_code.scope,
scope=scope,
)
return JSONResponse(
@@ -208,6 +312,7 @@ async def token(
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": sid,
"id_token": id_token,
}
)
@@ -273,3 +378,94 @@ async def userinfo(
response["permissions"] = permissions
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})