Hardening OIDC verification.

This commit is contained in:
Leo Vasanko
2026-02-15 20:49:13 +00:00
parent e59852b44c
commit feeea30cb6
3 changed files with 80 additions and 11 deletions
+50 -9
View File
@@ -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:
+21
View File
@@ -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:
+9 -2
View File
@@ -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