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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user