diff --git a/.changeset/cookie-transport-header.md b/.changeset/cookie-transport-header.md new file mode 100644 index 0000000..e205169 --- /dev/null +++ b/.changeset/cookie-transport-header.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/client': patch +--- + +Cookie transport now sends `x-seamless-auth-transport: cookie` on every unsafe request (`POST`, `PUT`, `PATCH`, `DELETE`) to the server adapter, including `POST /refresh` and requests `authorizedFetch` sends under the adapter's mount. Server adapters from v0.23 refuse a body-less unsafe auth request that carries neither a JSON content type nor this header with `415 unsupported_media_type`, so body-less calls such as recovery code generation and sign-out keep working. Safe methods and requests to the application's own routes are unchanged. A web app whose API is on another origin must allow the header in its CORS configuration; the starters already mirror requested headers. diff --git a/packages/client/README.md b/packages/client/README.md index 76e6af7..da045ac 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -111,8 +111,11 @@ and `startOAuthSignIn` reports it as a provider that would not start. A custom `createSeamlessAuthClient` takes a `transport` option: - cookie transport, the default: requests go to `${apiHost}/auth/*` with - `credentials: 'include'` and the server adapter holds the tokens. The browser - contract. + `credentials: 'include'` and the server adapter holds the tokens. Every unsafe + request (`POST`, `PUT`, `PATCH`, `DELETE`) to the adapter carries + `x-seamless-auth-transport: cookie`, which the adapter requires on a body-less + unsafe request. A web app on another origin than its API must allow that header + in its CORS configuration. The browser contract. - bearer transport (`{ mode: 'bearer', tokenStorage }`): the client holds the auth API's own tokens. Every request carries `x-seamless-auth-transport: bearer`; pre-auth routes carry the ephemeral token `/login` or `/registration/register` diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index 637e460..689b9ea 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -325,6 +325,20 @@ function withHeaders( return { ...init, headers }; } +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * The adapter refuses a body-less unsafe request that carries neither a JSON + * content type nor the transport header, since that is what a cross-site form + * can send. Cookie transport names itself on those requests so a body-less + * POST or DELETE passes. Safe methods go without it, so a cross-origin GET + * stays a simple request with no preflight. + */ +function cookieTransportMark(init: RequestInit | undefined): Record { + const method = (init?.method ?? 'GET').toUpperCase(); + return SAFE_METHODS.has(method) ? {} : { [AUTH_TRANSPORT_HEADER]: 'cookie' }; +} + interface SessionBody { token?: unknown; refreshToken?: unknown; @@ -377,25 +391,37 @@ export function createTransport(options: TransportOptions): Transport { return refuseUntrusted(url) ?? url; }; + // An empty apiHost is this page's origin, and authorizedFetch only takes an + // absolute URL, so the mount is matched against that origin. + const mount = `${buildUrl( + options.apiHost.trim() === '' ? (pageOrigin() ?? '') : options.apiHost, + basePath, + '' + )}/`; + if (mode === 'cookie') { + const fetchUnderMount: FetchWithAuth = (input, init) => + fetchImpl( + buildUrl(options.apiHost, basePath, normalizePath(input)), + withHeaders({ ...init, credentials: 'include' }, cookieTransportMark(init)) + ); + return { mode, - fetch: (input, init) => - fetchImpl( - buildUrl(options.apiHost, basePath, normalizePath(input)), - withHeaders({ ...init, credentials: 'include' }, {}) - ), + fetch: fetchUnderMount, + // The application's own routes are left as the caller built them: the + // transport header is the adapter's, and a custom header there would only + // add a CORS preflight the application's API may not allow. authorizedFetch: (input, init) => { const url = checkedUrl(input); if (typeof url !== 'string') return url; - return fetchImpl(url, withHeaders({ ...init, credentials: 'include' }, {})); + return url.startsWith(mount) + ? fetchUnderMount(url.slice(mount.length - 1), init) + : fetchImpl(url, withHeaders({ ...init, credentials: 'include' }, {})); }, refresh: async () => { - const response = await fetchImpl( - buildUrl(options.apiHost, basePath, '/refresh'), - withHeaders({ method: 'POST', credentials: 'include' }, {}) - ); + const response = await fetchUnderMount('/refresh', { method: 'POST' }); return response.ok; }, clearTokens: async () => undefined, @@ -633,8 +659,6 @@ export function createTransport(options: TransportOptions): Transport { return response; } - const mount = `${buildUrl(options.apiHost, basePath, '')}/`; - const fetchUnderMount: FetchWithAuth = async (input, init) => { const path = normalizePath(input); const rule = resolveRouteRule(path); diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index 3ba2ad4..6f145a7 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -94,10 +94,119 @@ describe('cookie transport', () => { expect(headersOf(calls[0])).toEqual({}); expect(calls[1].url).toBe(`${API}/auth/login`); - expect(headersOf(calls[1])).toEqual({ 'Content-Type': 'application/json' }); + expect(headersOf(calls[1])).toEqual({ + 'Content-Type': 'application/json', + [AUTH_TRANSPORT_HEADER]: 'cookie', + }); expect(transport.mode).toBe('cookie'); }); + // The adapter refuses a body-less unsafe request that names neither a JSON + // body nor a transport, so every unsafe request names cookie transport. + it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'delete'])( + 'marks a body-less %s with the cookie transport header', + async method => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await transport.fetch('/recovery-codes', { method }); + + expect(calls[0].init.credentials).toBe('include'); + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'cookie' }); + } + ); + + // A header on a cross-origin GET would make it preflighted for nothing. + it.each(['GET', 'HEAD', 'OPTIONS', undefined])( + 'leaves a %s request unmarked', + async method => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await transport.fetch('/users/me', method ? { method } : undefined); + + expect(headersOf(calls[0])).toEqual({}); + } + ); + + it('lets the caller override the transport header', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await transport.fetch('/logout', { + method: 'DELETE', + headers: new Headers({ [AUTH_TRANSPORT_HEADER]: 'custom' }), + }); + + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'custom' }); + }); + + it('refreshes through POST /refresh with the cookie transport header', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, fetch: fetchImpl }); + + await expect(transport.refresh()).resolves.toBe(true); + + expect(calls[0].url).toBe(`${API}/auth/refresh`); + expect(calls[0].init.method).toBe('POST'); + expect(calls[0].init.credentials).toBe('include'); + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'cookie' }); + }); + + it('authorizedFetch marks an unsafe request under the adapter mount only', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ + apiHost: API, + fetch: fetchImpl, + trustedOrigins: ['https://data.example.com'], + }); + + await transport.authorizedFetch(`${API}/auth/sessions/s-1`, { method: 'DELETE' }); + await transport.authorizedFetch(`${API}/auth/sessions`); + await transport.authorizedFetch(`${API}/api/plan`, { method: 'DELETE' }); + await transport.authorizedFetch('https://data.example.com/auth/x', { + method: 'POST', + }); + + expect(calls.map(call => call.url)).toEqual([ + `${API}/auth/sessions/s-1`, + `${API}/auth/sessions`, + `${API}/api/plan`, + 'https://data.example.com/auth/x', + ]); + expect(calls.every(call => call.init.credentials === 'include')).toBe(true); + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'cookie' }); + expect(headersOf(calls[1])).toEqual({}); + expect(headersOf(calls[2])).toEqual({}); + expect(headersOf(calls[3])).toEqual({}); + }); + + it('authorizedFetch finds the adapter mount on the page origin when apiHost is empty', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const previous = (globalThis as { location?: unknown }).location; + (globalThis as { location?: unknown }).location = { + origin: 'https://app.example.com', + }; + + try { + const transport = createTransport({ apiHost: '', fetch: fetchImpl }); + + await transport.authorizedFetch('https://app.example.com/auth/sessions/s-1', { + method: 'DELETE', + }); + await transport.authorizedFetch('https://app.example.com/api/thing', { + method: 'DELETE', + }); + + expect(calls[0].url).toBe('/auth/sessions/s-1'); + expect(headersOf(calls[0])).toEqual({ [AUTH_TRANSPORT_HEADER]: 'cookie' }); + expect(calls[1].url).toBe('https://app.example.com/api/thing'); + expect(headersOf(calls[1])).toEqual({}); + } finally { + (globalThis as { location?: unknown }).location = previous; + } + }); + // An empty apiHost is the same-origin setup (an app serving /auth itself), so // the page's own origin is apiHost's origin. it('authorizedFetch trusts the page origin when apiHost is empty', async () => { diff --git a/packages/react/README.md b/packages/react/README.md index 9ae77b6..eee30e7 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1376,8 +1376,10 @@ This package assumes a Seamless Auth-compatible backend with the auth adapter mo - Requests target `${apiHost}/auth/...` - `apiHost` may be provided with or without a trailing slash -- In cookie transport (the default) requests are sent with `credentials: 'include'`; in bearer - transport they carry `x-seamless-auth-transport: bearer` and an `Authorization` header instead +- In cookie transport (the default) requests are sent with `credentials: 'include'`, and unsafe + requests (`POST`, `PUT`, `PATCH`, `DELETE`) carry `x-seamless-auth-transport: cookie`, so a + cross-origin backend's CORS configuration must allow that header; in bearer transport they carry + `x-seamless-auth-transport: bearer` and an `Authorization` header instead - `AuthProvider` validates the current session by calling `/users/me` on load - Bearer transport additionally uses `POST /refresh`