From feeea30cb6e50b05fe7451da27944ae5a8606b75 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 20:49:13 +0000 Subject: [PATCH] Hardening OIDC verification. --- paskia/fastapi/oid.py | 59 ++++++++++++++++++++++++++++++++++++------- paskia/fastapi/ws.py | 21 +++++++++++++++ paskia/util/oidjwt.py | 11 ++++++-- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 7ae0289..08bf3fe 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -237,6 +237,7 @@ async def _handle_authorization_code( sid, auth_code.oidc.nonce if auth_code.oidc else None, auth_code.oidc.scope if auth_code.oidc else None, + credential_uuid=session.credential_uuid, ) @@ -313,6 +314,7 @@ async def _handle_refresh_token( sid_str, nonce=None, scope="openid", + credential_uuid=session.credential_uuid, ) @@ -324,6 +326,7 @@ def _build_token_response( sid: str, nonce: str | None, scope: str, + credential_uuid: UUID | None = None, ): """Build the token response with access_token, id_token, and refresh_token.""" issuer = _get_issuer(request) @@ -340,6 +343,16 @@ def _build_token_response( if p: permissions.append(p.scope) + # Get credential's last_used as auth_time + auth_time = None + if credential_uuid: + try: + credential = db.data().credentials[credential_uuid] + if credential.last_used: + auth_time = credential.last_used + except KeyError: + pass + # Create ID token id_token = oidjwt.create_id_token( issuer=issuer, @@ -351,6 +364,7 @@ def _build_token_response( preferred_username=user.preferred_username, email=user.email, permissions=permissions if permissions else None, + auth_time=auth_time, ) # Create access token @@ -393,6 +407,19 @@ async def userinfo( if not payload: raise HTTPException(401, "Invalid or expired token") + # Verify audience is a valid client + aud = payload.get("aud") + if not aud: + raise HTTPException(401, "Invalid token (missing aud claim)") + + try: + client_uuid = UUID(aud) + except ValueError: + raise HTTPException(401, "Invalid token (invalid aud format)") + + if not db.data().oid_clients.get(client_uuid): + raise HTTPException(401, "Invalid token (unknown client)") + # Get user try: user_uuid = UUID(payload["sub"]) @@ -469,6 +496,29 @@ async def backchannel_logout( sid = payload.get("sid") sub = payload.get("sub") + # Verify audience is a valid client (if present) + aud = payload.get("aud") + client_uuid = None + if aud: + try: + client_uuid = UUID(aud) + if not db.data().oid_clients.get(client_uuid): + return JSONResponse( + { + "error": "invalid_request", + "error_description": "Unknown client in logout_token", + }, + status_code=400, + ) + except ValueError: + return JSONResponse( + { + "error": "invalid_request", + "error_description": "Invalid client format in logout_token", + }, + status_code=400, + ) + if not sid and not sub: return JSONResponse( { @@ -478,15 +528,6 @@ async def backchannel_logout( 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: diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 18da687..3e62e10 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -154,6 +154,27 @@ async def websocket_authenticate( await ws.send_json({"status": 400, "detail": "nonce is required for OIDC"}) return + # Validate state parameter if provided (defensive against injection) + if state: + # State should be short-lived and contain only safe characters + # Per OAuth 2.0 spec: unreserved characters - alphanumerics and -._~ + if len(state) > 500: + await ws.send_json( + { + "status": 400, + "detail": "state parameter is too long (max 500 chars)", + } + ) + return + if not all(c.isalnum() or c in "-._~" for c in state): + await ws.send_json( + { + "status": 400, + "detail": "state parameter contains invalid characters", + } + ) + return + # If there's an existing session, restrict to that user's credentials (reauth) session_user_uuid = None if auth: diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index f016e22..b89228b 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -91,6 +91,7 @@ def create_id_token( preferred_username: str | None = None, email: str | None = None, permissions: list[str] | None = None, + auth_time: datetime | None = None, expires_in: int = 3600, ) -> str: """Create a signed ID token (JWT). @@ -105,6 +106,7 @@ def create_id_token( preferred_username: User's preferred username email: User's email address permissions: List of permission scopes + auth_time: When the user authenticated (last credential use time) expires_in: Token lifetime in seconds Returns: @@ -131,6 +133,8 @@ def create_id_token( payload["email"] = email if permissions: payload["permissions"] = permissions + if auth_time: + payload["auth_time"] = int(auth_time.timestamp()) return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) @@ -167,12 +171,15 @@ def create_access_token( return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) -def decode_access_token(token: str, issuer: str) -> dict | None: +def decode_access_token( + token: str, issuer: str, audience: str | None = None +) -> dict | None: """Decode and verify an access token. Args: token: JWT string issuer: Expected issuer + audience: Optional expected audience (client_id). If provided, aud claim must match. Returns: Decoded payload or None if invalid @@ -184,7 +191,7 @@ def decode_access_token(token: str, issuer: str) -> dict | None: _public_key, algorithms=["EdDSA"], issuer=issuer, - options={"verify_aud": False}, # We'll verify audience separately if needed + audience=audience, # PyJWT handles None by skipping verification ) except jwt.PyJWTError: return None