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
+58 -7
View File
@@ -131,11 +131,26 @@
},
/**
* Generate a nonce for security
* Generate a cryptographically secure nonce for OIDC anti-replay
* @returns {string} Nonce string
*/
generateNonce() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
return utils.generateRandomString(32);
},
/**
* Decode a JWT payload without verification (client-side nonce check only)
* @param {string} token - JWT token
* @returns {Object|null} Decoded payload or null if malformed
*/
decodeJwtPayload(token) {
try {
const part = token.split('.')[1];
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json);
} catch (e) {
return null;
}
},
/**
@@ -197,9 +212,11 @@
*/
async login() {
const nonce = utils.generateNonce();
const state = utils.generateRandomString(32);
// Store nonce for later use
// Store nonce and state for later validation (anti-replay / anti-CSRF)
localStorage.setItem('nonce', nonce);
localStorage.setItem('oidc_state', state);
// Build authorization URL for implicit flow
const authUrl = new URL(CONFIG.issuer + '/authorize');
@@ -208,6 +225,7 @@
authUrl.searchParams.append('response_type', 'id_token token');
authUrl.searchParams.append('scope', CONFIG.scopes);
authUrl.searchParams.append('nonce', nonce);
authUrl.searchParams.append('state', state);
// Redirect to authorization server
window.location.href = authUrl.toString();
@@ -224,6 +242,7 @@
const accessToken = urlParams.get('access_token');
const idToken = urlParams.get('id_token');
const refreshToken = urlParams.get('refresh_token');
const state = urlParams.get('state');
const error = urlParams.get('error');
if (error) {
@@ -232,7 +251,29 @@
return;
}
// Anti-CSRF: validate state binds this callback to a user-initiated login
const storedState = localStorage.getItem('oidc_state');
if (!state || !storedState || state !== storedState) {
alert('Security error: invalid or missing state parameter. Login aborted.');
localStorage.removeItem('oidc_state');
localStorage.removeItem('nonce');
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (accessToken && idToken) {
// Anti-replay / anti-forced-auth: validate the id_token nonce matches the one we sent
const storedNonce = localStorage.getItem('nonce');
const payload = utils.decodeJwtPayload(idToken);
const tokenNonce = payload ? payload.nonce : null;
if (!storedNonce || !tokenNonce || tokenNonce !== storedNonce) {
alert('Security error: nonce mismatch. Login aborted to prevent session fixation.');
localStorage.removeItem('oidc_state');
localStorage.removeItem('nonce');
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
// Clear URL fragment
window.history.replaceState({}, document.title, window.location.pathname);
@@ -244,8 +285,9 @@
localStorage.setItem('refresh_token', refreshToken);
}
// Clean up
localStorage.removeItem('nonce');
// Keep nonce in storage so it can be sent to /validate-token
// (cleared after validation completes in handleValidationResponse)
localStorage.removeItem('oidc_state');
// Get user info from backend after a short delay
// Only call getSecretPhrase if we don't already have a recent cached secret
@@ -351,6 +393,7 @@
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_data');
localStorage.removeItem('nonce');
localStorage.removeItem('oidc_state');
localStorage.removeItem('secret_phrase');
localStorage.removeItem('secret_last_fetch');
@@ -448,6 +491,7 @@
const idToken = localStorage.getItem('id_token');
const accessToken = localStorage.getItem('access_token');
const nonce = localStorage.getItem('nonce');
if (!idToken) {
utils.showAlert(elements.secretResult, 'No ID token found. Please log in first.', 'danger');
@@ -464,7 +508,8 @@
},
body: JSON.stringify({
id_token: idToken,
access_token: accessToken
access_token: accessToken,
nonce: nonce || undefined
})
});
@@ -476,6 +521,7 @@
// Retry the request with refreshed tokens
const refreshedIdToken = localStorage.getItem('id_token');
const refreshedAccessToken = localStorage.getItem('access_token');
const refreshedNonce = localStorage.getItem('nonce');
const retryResponse = await fetch('/validate-token', {
method: 'POST',
@@ -484,7 +530,8 @@
},
body: JSON.stringify({
id_token: refreshedIdToken,
access_token: refreshedAccessToken
access_token: refreshedAccessToken,
nonce: refreshedNonce || undefined
})
});
@@ -518,6 +565,10 @@
* @param {Object} data - Response data from validation endpoint
*/
async handleValidationResponse(data) {
// Nonce is single-use for the login flow; clear it now that validation is done
localStorage.removeItem('nonce');
localStorage.removeItem('oidc_state');
if (data.valid) {
// Store user info
if (data.user) {