fix(oidc): Add state parameter and validate nonce against login CSRF

The OIDC implicit-flow authorize request carried no state parameter and
the nonce was generated with weak Math.random() and never validated
client- or server-side, enabling login CSRF / forced authentication /
replay (AUTHZ-VULN-05).

Generate state and nonce with the Web Crypto CSPRNG, send state in the
authorize request, and validate both state and the id_token nonce claim
in the callback before storing tokens. Forward the nonce to /validate-
token so the backend also binds the id_token to the login flow.

Fixes the forged-callback fragment attack where a planted id_token with
a mismatched nonce was accepted, stored, and used by the SPA.
This commit is contained in:
2026-07-08 13:34:31 +02:00
parent c960cee268
commit baef35115a
4 changed files with 203 additions and 10 deletions
+17 -3
View File
@@ -38,6 +38,7 @@ AUTHORIZED_USERS = {
class TokenValidationRequest(BaseModel):
id_token: str
access_token: Optional[str] = None
nonce: Optional[str] = None
class TokenRefreshRequest(BaseModel):
refresh_token: str
@@ -204,8 +205,13 @@ def refresh_token(refresh_token: str):
except Exception as e:
raise ValueError(f"Token refresh failed: {str(e)}")
def verify_token(id_token: str):
"""Verify JWT token and return payload if valid"""
def verify_token(id_token: str, expected_nonce: Optional[str] = None):
"""Verify JWT token and return payload if valid
When expected_nonce is provided, the id_token's nonce claim must match it.
This binds the token to the OIDC login flow that minted it (anti-replay /
anti-forced-authentication), complementing the client-side state/nonce checks.
"""
try:
# Validate input
if not id_token or not isinstance(id_token, str):
@@ -243,6 +249,14 @@ def verify_token(id_token: str):
issuer=ISSUER
)
# Validate nonce binding when an expected nonce is supplied
if expected_nonce is not None:
token_nonce = payload.get("nonce")
if not token_nonce:
raise ValueError("Token missing nonce claim")
if token_nonce != expected_nonce:
raise ValueError("Nonce mismatch: token is not bound to this login")
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
@@ -261,7 +275,7 @@ async def validate_token(request: TokenValidationRequest):
try:
# Verify token
payload = verify_token(request.id_token)
payload = verify_token(request.id_token, request.nonce)
# Extract user email
user_email = payload.get("email")