Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cookie-transport-header.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
48 changes: 36 additions & 12 deletions packages/client/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
const method = (init?.method ?? 'GET').toUpperCase();
return SAFE_METHODS.has(method) ? {} : { [AUTH_TRANSPORT_HEADER]: 'cookie' };
}

interface SessionBody {
token?: unknown;
refreshToken?: unknown;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
111 changes: 110 additions & 1 deletion packages/client/tests/transport.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
Loading