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