Enhance OAuth security and state validation

- Implemented stricter OAuth security measures, including CSRF protection via state parameter validation and redirect_uri checks.
- Updated OAuth models to support timezone-aware datetime comparisons, replacing deprecated `utcnow`.
- Enhanced logging for malformed Basic auth headers during token, introspect, and revoke requests.
- Added allowlist validation for OAuth provider domains to prevent open redirect attacks.
- Improved nonce validation for OpenID Connect tokens, ensuring token integrity during Google provider flows.
- Updated E2E and unit tests to cover new security features and expanded OAuth state handling scenarios.
This commit is contained in:
Felipe Cardoso
2025-11-25 23:50:43 +01:00
parent 7716468d72
commit 400d6f6f75
14 changed files with 246 additions and 57 deletions

View File

@@ -153,6 +153,7 @@
"authFailed": "Authentication Failed",
"providerError": "The authentication provider returned an error",
"missingParams": "Missing authentication parameters",
"stateMismatch": "Invalid OAuth state. Please try again.",
"unexpectedError": "An unexpected error occurred during authentication",
"backToLogin": "Back to Login"
}

View File

@@ -153,6 +153,7 @@
"authFailed": "Autenticazione Fallita",
"providerError": "Il provider di autenticazione ha restituito un errore",
"missingParams": "Parametri di autenticazione mancanti",
"stateMismatch": "Stato OAuth non valido. Riprova.",
"unexpectedError": "Si è verificato un errore durante l'autenticazione",
"backToLogin": "Torna al Login"
}

View File

@@ -53,6 +53,18 @@ export default function OAuthCallbackPage() {
return;
}
// SECURITY: Validate state parameter against stored value (CSRF protection)
// This prevents cross-site request forgery attacks
const storedState = sessionStorage.getItem('oauth_state');
if (!storedState || storedState !== state) {
// Clean up stored state on mismatch
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_mode');
sessionStorage.removeItem('oauth_provider');
setError(t('stateMismatch') || 'Invalid OAuth state. Please try again.');
return;
}
hasProcessed.current = true;
// Process the OAuth callback

View File

@@ -56,6 +56,44 @@ export function useOAuthProviders() {
// OAuth Flow Mutations
// ============================================================================
// Allowed OAuth provider domains for security validation
const ALLOWED_OAUTH_DOMAINS = [
'accounts.google.com',
'github.com',
'www.facebook.com', // For future Facebook support
'login.microsoftonline.com', // For future Microsoft support
];
/**
* Validate OAuth authorization URL
* SECURITY: Prevents open redirect attacks by only allowing known OAuth provider domains
*/
function isValidOAuthUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Only allow HTTPS for OAuth (security requirement)
if (parsed.protocol !== 'https:') {
return false;
}
// Check if domain is in allowlist
return ALLOWED_OAUTH_DOMAINS.includes(parsed.hostname);
} catch {
return false;
}
}
/**
* Extract state parameter from OAuth authorization URL
*/
function extractStateFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
return parsed.searchParams.get('state');
} catch {
return null;
}
}
/**
* Start OAuth login/registration flow
* Redirects user to the OAuth provider
@@ -77,12 +115,27 @@ export function useOAuthStart() {
});
if (response.data) {
// Store mode in sessionStorage for callback handling
sessionStorage.setItem('oauth_mode', mode);
sessionStorage.setItem('oauth_provider', provider);
// Response is { [key: string]: unknown }, so cast authorization_url
const authUrl = (response.data as { authorization_url: string }).authorization_url;
// SECURITY: Validate the authorization URL before redirecting
// This prevents open redirect attacks if the backend is compromised
if (!isValidOAuthUrl(authUrl)) {
throw new Error('Invalid OAuth authorization URL');
}
// SECURITY: Extract and store the state parameter for CSRF validation
// The callback page will verify this matches the state in the response
const state = extractStateFromUrl(authUrl);
if (!state) {
throw new Error('Missing state parameter in authorization URL');
}
// Store mode, provider, and state in sessionStorage for callback handling
sessionStorage.setItem('oauth_mode', mode);
sessionStorage.setItem('oauth_provider', provider);
sessionStorage.setItem('oauth_state', state);
// Redirect to OAuth provider
window.location.href = authUrl;
}
@@ -151,14 +204,16 @@ export function useOAuthCallback() {
queryClient.invalidateQueries({ queryKey: ['user'] });
}
// Clean up session storage
// Clean up session storage (including state for security)
sessionStorage.removeItem('oauth_mode');
sessionStorage.removeItem('oauth_provider');
sessionStorage.removeItem('oauth_state');
},
onError: () => {
// Clean up session storage on error too
sessionStorage.removeItem('oauth_mode');
sessionStorage.removeItem('oauth_provider');
sessionStorage.removeItem('oauth_state');
},
});
}
@@ -199,12 +254,25 @@ export function useOAuthLink() {
});
if (response.data) {
// Store mode in sessionStorage for callback handling
sessionStorage.setItem('oauth_mode', 'link');
sessionStorage.setItem('oauth_provider', provider);
// Response is { [key: string]: unknown }, so cast authorization_url
const authUrl = (response.data as { authorization_url: string }).authorization_url;
// SECURITY: Validate the authorization URL before redirecting
if (!isValidOAuthUrl(authUrl)) {
throw new Error('Invalid OAuth authorization URL');
}
// SECURITY: Extract and store the state parameter for CSRF validation
const state = extractStateFromUrl(authUrl);
if (!state) {
throw new Error('Missing state parameter in authorization URL');
}
// Store mode, provider, and state in sessionStorage for callback handling
sessionStorage.setItem('oauth_mode', 'link');
sessionStorage.setItem('oauth_provider', provider);
sessionStorage.setItem('oauth_state', state);
// Redirect to OAuth provider
window.location.href = authUrl;
}