fix: improve error handling in authentication flow; validate access token and format, and ensure proper state verification in callback

This commit is contained in:
Usman Baig
2026-02-01 21:07:17 +01:00
parent 0dee4a699a
commit af5d9631e5
3 changed files with 54 additions and 42 deletions

View File

@@ -51,9 +51,14 @@ export async function exchangeAuthCode(code: string, codeVerifier: string, redir
} }
const data: AuthResponse = await res.json() const data: AuthResponse = await res.json()
if (!data?.access_token || typeof data.access_token !== 'string') {
throw new Error('Invalid token response')
}
// * Decode payload (without verification, we trust the direct channel to Auth Server) // * Decode payload (without verification, we trust the direct channel to Auth Server)
const payloadPart = data.access_token.split('.')[1] const payloadPart = data.access_token.split('.')[1]
if (!payloadPart) {
throw new Error('Invalid token format')
}
const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString()) const payload: UserPayload = JSON.parse(Buffer.from(payloadPart, 'base64').toString())
// * Set Cookies // * Set Cookies

View File

@@ -43,30 +43,24 @@ function AuthCallbackContent() {
const code = searchParams.get('code') const code = searchParams.get('code')
const state = searchParams.get('state') const state = searchParams.get('state')
// * Skip if params are missing (might be initial render before params are ready) // * Skip if params are missing (might be initial render before params are ready)
if (!code || !state) return if (!code || !state) return
processedRef.current = true
const storedState = localStorage.getItem('oauth_state') const storedState = localStorage.getItem('oauth_state')
const codeVerifier = localStorage.getItem('oauth_code_verifier') const codeVerifier = localStorage.getItem('oauth_code_verifier')
if (!code || !state) { if (!codeVerifier) {
setError('Missing code or state') setError('Missing code verifier')
return
}
if (state !== storedState) {
console.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state')
return return
} }
if (state !== storedState) { processedRef.current = true
console.error('State mismatch', { received: state, stored: storedState })
setError('Invalid state')
return
}
if (!codeVerifier) {
setError('Missing code verifier')
return
}
const exchangeCode = async () => { const exchangeCode = async () => {
try { try {

View File

@@ -30,14 +30,26 @@ export class ApiError extends Error {
// * Mutex for token refresh // * Mutex for token refresh
let isRefreshing = false let isRefreshing = false
let refreshSubscribers: (() => void)[] = [] type RefreshSubscriber = { onSuccess: () => void; onFailure: (err: unknown) => void }
let refreshSubscribers: RefreshSubscriber[] = []
function subscribeToTokenRefresh(cb: () => void) { function subscribeToTokenRefresh(onSuccess: () => void, onFailure: (err: unknown) => void) {
refreshSubscribers.push(cb) refreshSubscribers.push({ onSuccess, onFailure })
} }
function onRefreshed() { function onRefreshed() {
refreshSubscribers.map((cb) => cb()) refreshSubscribers.forEach((s) => s.onSuccess())
refreshSubscribers = []
}
function onRefreshFailed(err: unknown) {
refreshSubscribers.forEach((s) => {
try {
s.onFailure(err)
} catch {
// ignore
}
})
refreshSubscribers = [] refreshSubscribers = []
} }
@@ -74,25 +86,27 @@ async function apiRequest<T>(
// * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check) // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check)
if (!endpoint.includes('/auth/refresh')) { if (!endpoint.includes('/auth/refresh')) {
if (isRefreshing) { if (isRefreshing) {
// * If refresh is already in progress, wait for it to complete // * If refresh is already in progress, wait for it to complete (or fail)
return new Promise((resolve, reject) => { return new Promise<T>((resolve, reject) => {
subscribeToTokenRefresh(async () => { subscribeToTokenRefresh(
// Retry original request (browser uses new cookie) async () => {
try { try {
const retryResponse = await fetch(url, { const retryResponse = await fetch(url, {
...options, ...options,
headers, headers,
credentials: 'include', credentials: 'include',
}) })
if (retryResponse.ok) { if (retryResponse.ok) {
resolve(retryResponse.json()) resolve(await retryResponse.json())
} else { } else {
reject(new ApiError('Retry failed', retryResponse.status)) reject(new ApiError('Retry failed', retryResponse.status))
}
} catch (e) {
reject(e)
} }
} catch (e) { },
reject(e) (err) => reject(err)
} )
})
}) })
} }
@@ -103,6 +117,7 @@ async function apiRequest<T>(
const refreshRes = await fetch('/api/auth/refresh', { const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include',
}) })
if (refreshRes.ok) { if (refreshRes.ok) {
@@ -120,13 +135,11 @@ async function apiRequest<T>(
return retryResponse.json() return retryResponse.json()
} }
} else { } else {
// * Refresh failed, logout onRefreshFailed(new ApiError('Refresh failed', 401))
localStorage.removeItem('user') localStorage.removeItem('user')
// * Redirect to login if needed, or let the app handle 401
// window.location.href = '/'
} }
} catch (e) { } catch (e) {
// * Network error during refresh onRefreshFailed(e)
throw e throw e
} finally { } finally {
isRefreshing = false isRefreshing = false