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:
@@ -58,6 +58,99 @@ def test_verify_token_no_matching_key(mock_get_header, mock_get_jwks):
|
||||
with pytest.raises(ValueError, match="Unable to find matching key in JWKS"):
|
||||
verify_token("some.token.string")
|
||||
|
||||
|
||||
def _mock_jwks_and_key():
|
||||
"""Helper: return (jwks, header) fixtures for reaching the jwt.decode step."""
|
||||
jwks = {
|
||||
"keys": [
|
||||
{"kid": "test-key-id", "kty": "RSA", "n": "modulus", "e": "AQAB"}
|
||||
]
|
||||
}
|
||||
header = {"kid": "test-key-id", "alg": "RS256"}
|
||||
return jwks, header
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_match(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token returns payload when expected_nonce matches the token nonce."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
"nonce": "abc123",
|
||||
}
|
||||
|
||||
payload = verify_token("some.token.string", expected_nonce="abc123")
|
||||
assert payload["nonce"] == "abc123"
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_mismatch(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token rejects an id_token whose nonce does not match the expected one."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "attacker@evil.com",
|
||||
"nonce": "VERYWRONG_NONCE",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Nonce mismatch"):
|
||||
verify_token("some.token.string", expected_nonce="abc123")
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_missing_in_token(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token rejects a token lacking a nonce claim when an expected nonce is supplied."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Token missing nonce claim"):
|
||||
verify_token("some.token.string", expected_nonce="abc123")
|
||||
|
||||
|
||||
@patch('main.jwt.decode')
|
||||
@patch('main.jwt.algorithms.RSAAlgorithm.from_jwk')
|
||||
@patch('main.jwt.get_unverified_header')
|
||||
@patch('main.get_jwks')
|
||||
def test_verify_token_nonce_not_checked_when_none(mock_get_jwks, mock_get_header, mock_from_jwk, mock_decode):
|
||||
"""verify_token skips the nonce check when no expected_nonce is provided (returning visits / refresh)."""
|
||||
jwks, header = _mock_jwks_and_key()
|
||||
mock_get_jwks.return_value = jwks
|
||||
mock_get_header.return_value = header
|
||||
mock_from_jwk.return_value = "mock-key"
|
||||
mock_decode.return_value = {
|
||||
"iss": "https://login.infomaniak.com",
|
||||
"aud": "test-client-id",
|
||||
"email": "rene.luria@infomaniak.com",
|
||||
}
|
||||
|
||||
payload = verify_token("some.token.string")
|
||||
assert payload["email"] == "rene.luria@infomaniak.com"
|
||||
|
||||
@patch('main.requests.get')
|
||||
def test_get_well_known_config_success(mock_get):
|
||||
"""Test successful retrieval of well-known configuration."""
|
||||
|
||||
Reference in New Issue
Block a user