Session keys hardened (namespaced hashes of tokens). Various cleanup.

This commit is contained in:
Leo Vasanko
2026-02-15 20:14:48 +00:00
parent 18722f0e01
commit e59852b44c
21 changed files with 495 additions and 565 deletions
+82 -20
View File
@@ -15,19 +15,47 @@ import logging
from datetime import UTC, datetime
from uuid import UUID
import base64url
from fastapi import Body, Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import db, oidauth
from paskia import authcode, db
from paskia.config import SESSION_LIFETIME
from paskia.db.structs import Session
from paskia.util import oidjwt
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
def _oidc_session_by_token(
token: str, client_uuid: UUID | None = None
) -> Session | None:
"""Look up an OIDC session by token (refresh token value)."""
key = hash_secret("oidc", token)
s = db.data().sessions.get(key)
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
def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session | None:
"""Look up an OIDC session by sid (for backchannel logout)."""
for s in db.data().sessions.values():
if s.client_uuid is None:
continue
if client_uuid is not None and s.client_uuid != client_uuid:
continue
if hash_secret("oidc", s.key) == sid:
return s
return None
def _get_issuer(request: Request) -> str:
"""Build issuer URL from request."""
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
@@ -137,26 +165,33 @@ async def _handle_authorization_code(
)
# Consume auth code (atomic delete + return)
auth_code = oidauth.instance.consume(code)
auth_code = authcode.codes.pop(code, None)
if not auth_code:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
status_code=400,
)
# Verify client matches
if auth_code.client_uuid != client.uuid:
return JSONResponse({"error": "invalid_grant"}, status_code=400)
# Look up the OIDC session by token
session = _oidc_session_by_token(auth_code.session_key, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Session not found or not OIDC",
},
status_code=400,
)
# Verify redirect_uri matches
if redirect_uri and redirect_uri != auth_code.redirect_uri:
# Verify redirect_uri matches (OIDC only)
if auth_code.oidc and redirect_uri and redirect_uri != auth_code.oidc.redirect_uri:
return JSONResponse(
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
status_code=400,
)
# Verify PKCE if code_challenge was provided
if auth_code.code_challenge:
# Verify PKCE (OIDC only)
if auth_code.oidc:
if not code_verifier:
return JSONResponse(
{
@@ -165,7 +200,7 @@ async def _handle_authorization_code(
},
status_code=400,
)
method = auth_code.code_challenge_method or "S256"
method = auth_code.oidc.code_challenge_method
if method != "S256":
return JSONResponse(
{
@@ -174,7 +209,7 @@ async def _handle_authorization_code(
},
status_code=400,
)
if not _verify_pkce(code_verifier, auth_code.code_challenge):
if not _verify_pkce(code_verifier, auth_code.oidc.code_challenge):
return JSONResponse(
{
"error": "invalid_grant",
@@ -183,16 +218,25 @@ async def _handle_authorization_code(
status_code=400,
)
# Get user
user = db.data().users.get(auth_code.user_uuid)
# Get user from session
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Derive sid from session key
sid = base64url.enc(hash_secret("oidc", session.key))
return _build_token_response(
request, user, client_id, auth_code.sid, auth_code.nonce, auth_code.scope
request,
user,
client_id,
auth_code.session_key,
sid,
auth_code.oidc.nonce if auth_code.oidc else None,
auth_code.oidc.scope if auth_code.oidc else None,
)
@@ -204,7 +248,7 @@ async def _handle_refresh_token(
):
"""Handle grant_type=refresh_token.
The refresh_token is the OIDC session sid. On refresh:
The refresh_token is the session secret. On refresh:
- Validates session exists and belongs to client
- Extends session expiry (24h sliding window)
- Records current IP and user_agent
@@ -216,8 +260,8 @@ async def _handle_refresh_token(
status_code=400,
)
# Look up session by sid
session = db.data().oidc_session_by_sid(refresh_token_value, client.uuid)
# Look up session by refresh token
session = _oidc_session_by_token(refresh_token_value, client.uuid)
if not session:
return JSONResponse(
{
@@ -258,8 +302,17 @@ async def _handle_refresh_token(
_logger.info("OIDC session refreshed: %s", session.key)
# Base64url encode session's derived sid for JWT claim
sid_str = base64url.enc(hash_secret("oidc", session.key))
return _build_token_response(
request, user, client_id, session.key, nonce=None, scope="openid"
request,
user,
client_id,
refresh_token_value,
sid_str,
nonce=None,
scope="openid",
)
@@ -267,6 +320,7 @@ def _build_token_response(
request: Request,
user,
client_id: str,
secret: str,
sid: str,
nonce: str | None,
scope: str,
@@ -312,7 +366,7 @@ def _build_token_response(
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": sid,
"refresh_token": secret,
"id_token": id_token,
}
)
@@ -436,8 +490,16 @@ async def backchannel_logout(
# Delete session(s)
deleted = 0
if sid:
# Decode sid from base64url to bytes
try:
sid_bytes = base64url.dec(sid)
except Exception:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sid format"},
status_code=400,
)
# Delete specific session by sid
session = db.data().oidc_session_by_sid(sid, client_uuid)
session = _oidc_session_by_sid(sid_bytes, client_uuid)
if session:
db.delete_session(session.key)
deleted = 1