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