Skip to content

Commit 6a32215

Browse files
fix(frontend): centralize cookie credentials in fetchWrapper and route callers through it
1 parent 8d5102d commit 6a32215

21 files changed

Lines changed: 598 additions & 215 deletions

‎docs/configuration/authentication.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ PASSKEY_ORIGIN=http://192.168.1.244:5003
177177
1. Check AUTH_SECRET is persistent across restarts
178178
2. Verify cookies aren't being blocked
179179
3. Check AUTH_SECURE_COOKIES setting
180+
4. If accessing from a different site (cross-site, not just cross-origin), note that CORS alone is not sufficient: the auth config in `backend/src/auth/index.ts` sets no `sameSite` override, so better-auth's `SameSite=Lax` default prevents the session cookie from being attached to cross-site requests regardless of `credentials: 'include'` on the client. The app is designed same-origin (`VITE_API_URL` defaults to `''`); cross-site access requires changing `sameSite` to `'none'` with `secure: true`, which is an open question rather than a supported configuration.
180181

181182
### Passkey Not Working
182183

‎docs/configuration/environment.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ When configured, users can enable push notifications in Settings → Notificatio
7272
| `PORT` | Server port | `5003` |
7373
| `HOST` | Server bind address | `0.0.0.0` |
7474
| `NODE_ENV` | Environment (`development` or `production`) | `development` |
75-
| `CORS_ORIGIN` | CORS origin for frontend | `http://localhost:5173` |
75+
| `CORS_ORIGIN` | **Unused / deprecated.** Parsed by `shared/src/config` but consumed nowhere; the effective CORS control is `AUTH_TRUSTED_ORIGINS` (see [Authentication](./authentication.md)). Kept for backwards compatibility — do not rely on it. | `http://localhost:5173` |
7676
| `LOG_LEVEL` | Logging level | `info` |
7777
| `DEBUG` | Enable debug logging | `false` |
7878

@@ -128,7 +128,7 @@ When configured, users can enable push notifications in Settings → Notificatio
128128

129129
| Variable | Description | Default |
130130
|----------|-------------|---------|
131-
| `VITE_API_URL` | Backend API URL for frontend | `http://localhost:5003` |
131+
| `VITE_API_URL` | Backend API URL for frontend. Defaults to `''` (empty), making the app same-origin — the backend serves the built frontend (`backend/src/index.ts`) and dev uses the Vite `/api` proxy (`frontend/vite.config.ts`). Setting it to a different host puts the app in an **unsupported split-origin mode**; see the open question in `Decisions.md` ("is split-origin deployment supported?") before doing so. | `''` |
132132
| `VITE_SERVER_PORT` | Backend port hint for frontend | `5003` |
133133
| `VITE_OPENCODE_PORT` | OpenCode server port hint | `5551` |
134134
| `VITE_MAX_FILE_SIZE_MB` | File size limit for frontend | `50` |

‎frontend/src/api/authInfo.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { getAuthConfig, DEFAULT_AUTH_CONFIG, listUserPasskeys } from './authInfo'
3+
4+
describe('getAuthConfig', () => {
5+
const fetchMock = vi.fn()
6+
7+
beforeEach(() => {
8+
fetchMock.mockReset()
9+
vi.stubGlobal('fetch', fetchMock)
10+
})
11+
12+
afterEach(() => {
13+
vi.unstubAllGlobals()
14+
})
15+
16+
it('returns the parsed config on success', async () => {
17+
const config = {
18+
enabledProviders: ['credentials', 'github'],
19+
registrationEnabled: true,
20+
isFirstUser: false,
21+
adminConfigured: true,
22+
}
23+
fetchMock.mockResolvedValue(
24+
new Response(JSON.stringify(config), { status: 200 }),
25+
)
26+
27+
const result = await getAuthConfig()
28+
29+
expect(result).toEqual(config)
30+
31+
const callOptions = fetchMock.mock.calls[0][1]
32+
expect(callOptions.credentials).toBe('include')
33+
})
34+
35+
it('falls back to DEFAULT_AUTH_CONFIG on a non-OK response', async () => {
36+
fetchMock.mockResolvedValue(
37+
new Response(JSON.stringify({ error: 'server error' }), { status: 500 }),
38+
)
39+
40+
const result = await getAuthConfig()
41+
42+
expect(result).toEqual(DEFAULT_AUTH_CONFIG)
43+
})
44+
45+
it('falls back to DEFAULT_AUTH_CONFIG when the network rejects', async () => {
46+
fetchMock.mockRejectedValue(new Error('offline'))
47+
48+
const result = await getAuthConfig()
49+
50+
expect(result).toEqual(DEFAULT_AUTH_CONFIG)
51+
})
52+
53+
it('defaults isFirstUser to false so the setup flow is not offered when config is unknown', () => {
54+
expect(DEFAULT_AUTH_CONFIG.isFirstUser).toBe(false)
55+
})
56+
})
57+
58+
describe('listUserPasskeys', () => {
59+
const fetchMock = vi.fn()
60+
61+
beforeEach(() => {
62+
fetchMock.mockReset()
63+
vi.stubGlobal('fetch', fetchMock)
64+
})
65+
66+
afterEach(() => {
67+
vi.unstubAllGlobals()
68+
})
69+
70+
it('returns the passkey list and sends credentials', async () => {
71+
fetchMock.mockResolvedValue(
72+
new Response(JSON.stringify([{ id: 'p1', name: 'laptop' }]), { status: 200 }),
73+
)
74+
75+
const result = await listUserPasskeys()
76+
77+
expect(result).toEqual([{ id: 'p1', name: 'laptop' }])
78+
79+
const callOptions = fetchMock.mock.calls[0][1]
80+
expect(callOptions.credentials).toBe('include')
81+
})
82+
83+
it('returns an empty array when the request fails', async () => {
84+
fetchMock.mockResolvedValue(
85+
new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 }),
86+
)
87+
88+
const result = await listUserPasskeys()
89+
90+
expect(result).toEqual([])
91+
})
92+
})

‎frontend/src/api/authInfo.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { API_BASE_URL } from '@/config'
2+
import { fetchWrapper } from './fetchWrapper'
3+
4+
export interface AuthConfig {
5+
enabledProviders: string[]
6+
registrationEnabled: boolean
7+
isFirstUser: boolean
8+
adminConfigured: boolean
9+
}
10+
11+
export const DEFAULT_AUTH_CONFIG: AuthConfig = {
12+
enabledProviders: ['credentials'],
13+
registrationEnabled: true,
14+
isFirstUser: false,
15+
adminConfigured: false,
16+
}
17+
18+
export async function getAuthConfig(): Promise<AuthConfig> {
19+
try {
20+
return await fetchWrapper<AuthConfig>(`${API_BASE_URL}/api/auth-info/config`)
21+
} catch {
22+
return DEFAULT_AUTH_CONFIG
23+
}
24+
}
25+
26+
export interface Passkey {
27+
id: string
28+
name?: string
29+
credentialID: string
30+
createdAt: string
31+
deviceType: string
32+
}
33+
34+
export async function listUserPasskeys(): Promise<Passkey[]> {
35+
try {
36+
return await fetchWrapper<Passkey[]>(`${API_BASE_URL}/api/auth/passkey/list-user-passkeys`)
37+
} catch {
38+
return []
39+
}
40+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { FetchError } from '@opencode-manager/shared'
3+
import { fetchWrapper } from './fetchWrapper'
4+
5+
describe('fetchWrapper', () => {
6+
const fetchMock = vi.fn()
7+
8+
beforeEach(() => {
9+
fetchMock.mockReset()
10+
vi.stubGlobal('fetch', fetchMock)
11+
})
12+
13+
afterEach(() => {
14+
vi.unstubAllGlobals()
15+
})
16+
17+
it('sends credentials: include on every request', async () => {
18+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
19+
20+
await fetchWrapper('/api/x')
21+
22+
expect(fetchMock.mock.calls[0][1].credentials).toBe('include')
23+
})
24+
25+
it('keeps credentials: include when the caller passes its own options', async () => {
26+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
27+
28+
await fetchWrapper('/api/x', {
29+
method: 'POST',
30+
headers: { 'Content-Type': 'application/json' },
31+
body: '{}',
32+
})
33+
34+
const init = fetchMock.mock.calls[0][1]
35+
expect(init.credentials).toBe('include')
36+
expect(init.method).toBe('POST')
37+
})
38+
39+
it('aborts the request when the caller signal aborts', async () => {
40+
fetchMock.mockImplementation((_url, init) =>
41+
new Promise((_resolve, reject) => {
42+
init.signal.addEventListener('abort', () =>
43+
reject(new DOMException('Aborted', 'AbortError')),
44+
)
45+
}),
46+
)
47+
48+
const controller = new AbortController()
49+
const promise = fetchWrapper('/api/x', { signal: controller.signal })
50+
controller.abort()
51+
await expect(promise).rejects.toThrow()
52+
})
53+
54+
it('rejects with a 408 TIMEOUT FetchError when the timeout elapses', async () => {
55+
vi.useFakeTimers()
56+
fetchMock.mockImplementation((_url, init) => {
57+
const signal = init.signal as AbortSignal
58+
return new Promise((_resolve, reject) => {
59+
if (signal.aborted) {
60+
const err = new Error('The operation was aborted')
61+
err.name = 'AbortError'
62+
reject(err)
63+
return
64+
}
65+
signal.addEventListener('abort', () => {
66+
const err = new Error('The operation was aborted')
67+
err.name = 'AbortError'
68+
reject(err)
69+
}, { once: true })
70+
})
71+
})
72+
73+
const promise = fetchWrapper('/api/x', { timeout: 100 })
74+
vi.advanceTimersByTime(101)
75+
76+
await expect(promise).rejects.toBeInstanceOf(FetchError)
77+
await expect(promise).rejects.toMatchObject({
78+
statusCode: 408,
79+
code: 'TIMEOUT',
80+
})
81+
82+
vi.useRealTimers()
83+
})
84+
85+
it('rejects with 499 CANCELED when the caller aborts, not 408 TIMEOUT', async () => {
86+
fetchMock.mockImplementation((_url, init) => {
87+
const signal = init.signal as AbortSignal
88+
return new Promise((_resolve, reject) => {
89+
if (signal.aborted) {
90+
const err = new Error('The operation was aborted')
91+
err.name = 'AbortError'
92+
reject(err)
93+
return
94+
}
95+
signal.addEventListener('abort', () => {
96+
const err = new Error('The operation was aborted')
97+
err.name = 'AbortError'
98+
reject(err)
99+
}, { once: true })
100+
})
101+
})
102+
103+
const controller = new AbortController()
104+
const promise = fetchWrapper('/api/x', { signal: controller.signal })
105+
controller.abort()
106+
107+
await expect(promise).rejects.toBeInstanceOf(FetchError)
108+
await expect(promise).rejects.toMatchObject({
109+
statusCode: 499,
110+
code: 'CANCELED',
111+
})
112+
})
113+
})

‎frontend/src/api/fetchWrapper.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,19 @@ async function fetchWithTimeout(
7272
url: string,
7373
options: FetchWrapperOptions = {}
7474
): Promise<Response> {
75-
const { timeout = 30000, params, ...fetchOptions } = options
75+
const { timeout = 30000, params, signal: callerSignal, ...fetchOptions } = options
7676
const urlObj = buildUrl(url, params)
7777

7878
const controller = new AbortController()
7979
const timeoutId = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : null
8080

81+
const abortFromCaller = () => controller.abort()
82+
if (callerSignal?.aborted) {
83+
controller.abort()
84+
} else {
85+
callerSignal?.addEventListener('abort', abortFromCaller, { once: true })
86+
}
87+
8188
try {
8289
const response = await fetch(urlObj.toString(), {
8390
credentials: 'include',
@@ -86,6 +93,7 @@ async function fetchWithTimeout(
8693
})
8794

8895
if (timeoutId) clearTimeout(timeoutId)
96+
if (callerSignal) callerSignal.removeEventListener('abort', abortFromCaller)
8997

9098
if (!response.ok) {
9199
await handleResponse(response)
@@ -94,7 +102,11 @@ async function fetchWithTimeout(
94102
return response
95103
} catch (error) {
96104
if (timeoutId) clearTimeout(timeoutId)
105+
if (callerSignal) callerSignal.removeEventListener('abort', abortFromCaller)
97106
if (error instanceof Error && error.name === 'AbortError') {
107+
if (callerSignal?.aborted) {
108+
throw new FetchError('Request canceled', 499, 'CANCELED')
109+
}
98110
throw new FetchError('Request timeout', 408, 'TIMEOUT')
99111
}
100112
throw error

0 commit comments

Comments
 (0)