fix: improve error handling in authentication flow; validate access token and format, and ensure proper state verification in callback
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -47,26 +47,20 @@ function AuthCallbackContent() {
|
|||||||
// * 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user