fix(cors): allow credentials on options requests and update frontend auth flow

This commit is contained in:
Usman Baig
2026-01-18 21:27:22 +01:00
parent d4486f952f
commit 2fda4667ed
4 changed files with 535 additions and 531 deletions

View File

@@ -1,146 +1,147 @@
/**
* HTTP client wrapper for API calls
*/
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000'
export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net'
export function getLoginUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
}
export function getSignupUrl(redirectPath = '/auth/callback') {
const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
}
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
// * Mutex for token refresh
let isRefreshing = false
let refreshSubscribers: (() => void)[] = []
function subscribeToTokenRefresh(cb: () => void) {
refreshSubscribers.push(cb)
}
function onRefreshed() {
refreshSubscribers.map((cb) => cb())
refreshSubscribers = []
}
/**
* Base API client with error handling
*/
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// * Determine base URL
const isAuthRequest = endpoint.startsWith('/auth')
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
const url = `${baseUrl}/api/v1${endpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
const response = await fetch(url, {
...options,
headers,
credentials: 'include', // * IMPORTANT: Send cookies
})
if (!response.ok) {
if (response.status === 401) {
// * Attempt Token Refresh if 401
if (typeof window !== 'undefined') {
// * 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 (isRefreshing) {
// * If refresh is already in progress, wait for it to complete
return new Promise((resolve, reject) => {
subscribeToTokenRefresh(async () => {
// Retry original request (browser uses new cookie)
try {
const retryResponse = await fetch(url, {
...options,
headers,
credentials: 'include',
})
if (retryResponse.ok) {
resolve(retryResponse.json())
} else {
reject(new ApiError('Retry failed', retryResponse.status))
}
} catch (e) {
reject(e)
}
})
})
}
isRefreshing = true
try {
// * Call our internal Next.js API route to handle refresh securely
const refreshRes = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (refreshRes.ok) {
// * Refresh successful, cookies updated
onRefreshed()
// * Retry original request
const retryResponse = await fetch(url, {
...options,
headers,
credentials: 'include',
})
if (retryResponse.ok) {
return retryResponse.json()
}
} else {
// * Refresh failed, logout
localStorage.removeItem('user')
// * Redirect to login if needed, or let the app handle 401
// window.location.href = '/'
}
} catch (e) {
// * Network error during refresh
throw e
} finally {
isRefreshing = false
}
}
}
}
const errorBody = await response.json().catch(() => ({
error: 'Unknown error',
message: `HTTP ${response.status}: ${response.statusText}`,
}))
throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status)
}
return response.json()
}
export const authFetch = apiRequest
export default apiRequest
1|/**
2| * HTTP client wrapper for API calls
3| */
4|
5|export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8082'
6|export const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:3000'
7|export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3003'
8|export const AUTH_API_URL = process.env.NEXT_PUBLIC_AUTH_API_URL || 'https://auth-api.ciphera.net'
9|
10|export function getLoginUrl(redirectPath = '/auth/callback') {
11| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
12| return `${AUTH_URL}/login?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
13|}
14|
15|export function getSignupUrl(redirectPath = '/auth/callback') {
16| const redirectUri = encodeURIComponent(`${APP_URL}${redirectPath}`)
17| return `${AUTH_URL}/signup?client_id=analytics-app&redirect_uri=${redirectUri}&response_type=code`
18|}
19|
20|export class ApiError extends Error {
21| status: number
22| constructor(message: string, status: number) {
23| super(message)
24| this.status = status
25| }
26|}
27|
28|// * Mutex for token refresh
29|let isRefreshing = false
30|let refreshSubscribers: (() => void)[] = []
31|
32|function subscribeToTokenRefresh(cb: () => void) {
33| refreshSubscribers.push(cb)
34|}
35|
36|function onRefreshed() {
37| refreshSubscribers.map((cb) => cb())
38| refreshSubscribers = []
39|}
40|
41|/**
42| * Base API client with error handling
43| */
44|async function apiRequest<T>(
45| endpoint: string,
46| options: RequestInit = {}
47|): Promise<T> {
48| // * Determine base URL
49| const isAuthRequest = endpoint.startsWith('/auth')
50| const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
51| const url = `${baseUrl}/api/v1${endpoint}`
52|
53| const headers: HeadersInit = {
54| 'Content-Type': 'application/json',
55| ...options.headers,
56| }
57|
58| // * We rely on HttpOnly cookies, so no manual Authorization header injection.
59| // * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
60|
61| const response = await fetch(url, {
62| ...options,
63| headers,
64| credentials: 'include', // * IMPORTANT: Send cookies
65| })
66|
67| if (!response.ok) {
68| if (response.status === 401) {
69| // * Attempt Token Refresh if 401
70| if (typeof window !== 'undefined') {
71| // * Prevent infinite loop: Don't refresh if the failed request WAS a refresh request (unlikely via apiRequest but safe to check)
72| if (!endpoint.includes('/auth/refresh')) {
73| if (isRefreshing) {
74| // * If refresh is already in progress, wait for it to complete
75| return new Promise((resolve, reject) => {
76| subscribeToTokenRefresh(async () => {
77| // Retry original request (browser uses new cookie)
78| try {
79| const retryResponse = await fetch(url, {
80| ...options,
81| headers,
82| credentials: 'include',
83| })
84| if (retryResponse.ok) {
85| resolve(retryResponse.json())
86| } else {
87| reject(new ApiError('Retry failed', retryResponse.status))
88| }
89| } catch (e) {
90| reject(e)
91| }
92| })
93| })
94| }
95|
96| isRefreshing = true
97|
98| try {
99| // * Call our internal Next.js API route to handle refresh securely
100| const refreshRes = await fetch('/api/auth/refresh', {
101| method: 'POST',
102| headers: { 'Content-Type': 'application/json' },
103| })
104|
105| if (refreshRes.ok) {
106| // * Refresh successful, cookies updated
107| onRefreshed()
108|
109| // * Retry original request
110| const retryResponse = await fetch(url, {
111| ...options,
112| headers,
113| credentials: 'include',
114| })
115|
116| if (retryResponse.ok) {
117| return retryResponse.json()
118| }
119| } else {
120| // * Refresh failed, logout
121| localStorage.removeItem('user')
122| // * Redirect to login if needed, or let the app handle 401
123| // window.location.href = '/'
124| }
125| } catch (e) {
126| // * Network error during refresh
127| throw e
128| } finally {
129| isRefreshing = false
130| }
131| }
132| }
133| }
134|
135| const errorBody = await response.json().catch(() => ({
136| error: 'Unknown error',
137| message: `HTTP ${response.status}: ${response.statusText}`,
138| }))
139| throw new ApiError(errorBody.message || errorBody.error || 'Request failed', response.status)
140| }
141|
142| return response.json()
143|}
144|
145|export const authFetch = apiRequest
146|export default apiRequest
147|