diff --git a/.changeset/bearer-redirects.md b/.changeset/bearer-redirects.md new file mode 100644 index 0000000..769d68a --- /dev/null +++ b/.changeset/bearer-redirects.md @@ -0,0 +1,7 @@ +--- +'@seamless-auth/client': minor +'@seamless-auth/react': patch +'@seamless-auth/react-native': patch +--- + +In bearer transport, a request that carries a token no longer follows a redirect to an origin outside `apiHost` and `trustedOrigins`. The client asks `fetch` for `redirect: 'manual'`, follows a redirect to a trusted origin itself (up to five hops, turning a `303`, or a `301` or `302` after a POST, into a GET as `fetch` does), and rejects any other with the new `UntrustedRedirectError` without sending the token on. Where the platform follows redirects regardless, as React Native's `fetch` does, a response that ended on another origin is rejected instead. A caller that sets `redirect` itself keeps that choice, and the final-origin check still applies. Requests without a token, and cookie transport, are unchanged. diff --git a/packages/client/README.md b/packages/client/README.md index 746116a..f96402a 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -100,8 +100,13 @@ reach a third party through it. A path starting with `/` resolves on `apiHost`; any other relative URL is refused, because `fetch` would resolve it against the document's base URL. Each `trustedOrigins` entry must be a bare `https` origin (`http` only on localhost), and anything else throws when the client is -created. Use plain `fetch` for other origins, or list an API served from -another origin of your own: +created. In bearer transport a request that carries a token follows a redirect +only to one of those origins: it asks `fetch` for `redirect: 'manual'`, follows +a trusted hop itself (up to five), and rejects any other with +`UntrustedRedirectError` without sending the token on. React Native's `fetch` +follows redirects on its own, so there a response that ended on another origin +is rejected instead. Set `redirect` yourself to opt out. Use plain `fetch` for +other origins, or list an API served from another origin of your own: ```ts createSeamlessAuthClient({ diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index 2fff4c1..4a5f03a 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -54,6 +54,28 @@ export class UntrustedOriginError extends Error { } } +/** + * Rejects a bearer request that a server redirected to an origin outside + * `apiHost` and `trustedOrigins`. Where the platform honours + * `redirect: 'manual'` the redirect is not followed, so the token never leaves; + * where it does not (React Native follows redirects itself), the response is + * refused once it is known to have come from somewhere else. + */ +export class UntrustedRedirectError extends Error { + readonly url: string; + + constructor(url: string) { + super( + `A bearer request was redirected to ${url}, which is not apiHost or one of trustedOrigins, so the redirect was not followed with the session.` + ); + this.name = 'UntrustedRedirectError'; + this.url = url; + } +} + +const MAX_REDIRECTS = 5; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + /** * An absolute URL's origin, or null for a relative URL or one without an * origin. Never resolved against the page: fetch resolves a relative URL @@ -289,13 +311,14 @@ export function createTransport(options: TransportOptions): Transport { // Checked before anything is attached, so a refused request carries neither // a cookie nor a token. Only an absolute URL can pass: the client resolves a // path on apiHost before it gets here. - const refuseUntrusted = (url: string): Promise | null => { + const isTrusted = (url: string): boolean => { const origin = originOf(url); - return origin !== null && trusted.has(origin) - ? null - : Promise.reject(new UntrustedOriginError(url)); + return origin !== null && trusted.has(origin); }; + const refuseUntrusted = (url: string): Promise | null => + isTrusted(url) ? null : Promise.reject(new UntrustedOriginError(url)); + const checkedUrl = (input: unknown): string | Promise => { const url = urlOf(input); if (url === null) { @@ -446,14 +469,69 @@ export function createTransport(options: TransportOptions): Transport { markTransport: boolean ) { const authorization = await authorizationFor(identity); + const headers = { + ...(markTransport ? { [AUTH_TRANSPORT_HEADER]: 'bearer' } : {}), + ...(authorization ? { Authorization: authorization } : {}), + }; - return fetchImpl( - url, - withHeaders(init, { - ...(markTransport ? { [AUTH_TRANSPORT_HEADER]: 'bearer' } : {}), - ...(authorization ? { Authorization: authorization } : {}), - }) - ); + if (!authorization) { + return fetchImpl(url, withHeaders(init, headers)); + } + + // fetch follows redirects by default, and whether a platform drops + // Authorization on a cross-origin hop is up to its networking stack (iOS + // NSURLSession is not known to). So a request carrying a token follows a + // redirect itself, and only to an origin the session may go to. + const callerChoseRedirect = init?.redirect !== undefined; + let current = url; + let currentInit: RequestInit | undefined = callerChoseRedirect + ? init + : { ...init, redirect: 'manual' }; + + for (let hop = 0; ; hop++) { + const response: Response = await fetchImpl( + current, + withHeaders(currentInit, headers) + ); + + if (response.redirected && response.url && !isTrusted(response.url)) { + throw new UntrustedRedirectError(response.url); + } + + if (callerChoseRedirect) { + return response; + } + + if (response.type === 'opaqueredirect') { + // A browser hides where a manual redirect points, so it cannot be + // judged and is not followed. + throw new UntrustedRedirectError(current); + } + + const location = REDIRECT_STATUSES.has(response.status) + ? response.headers.get('location') + : null; + + if (!location) { + return response; + } + + const target = new URL(location, current).href; + + if (!isTrusted(target) || hop + 1 >= MAX_REDIRECTS) { + throw new UntrustedRedirectError(target); + } + + const method = (currentInit?.method ?? 'GET').toUpperCase(); + const becomesGet: boolean = + response.status === 303 || + ((response.status === 301 || response.status === 302) && method === 'POST'); + + current = target; + currentInit = becomesGet + ? { ...currentInit, method: 'GET', body: undefined } + : currentInit; + } } // An expired access token is the one 401 this layer can do something about. diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index a12c236..0dd1d27 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -17,6 +17,7 @@ import { createTransport, resolveRouteRule, UntrustedOriginError, + UntrustedRedirectError, } from '../src/transport'; const API = 'https://api.example.com'; @@ -731,3 +732,134 @@ describe('bearer transport', () => { expect(await storage.get()).toBeNull(); }); }); + +describe('bearer transport redirects', () => { + const signedIn = async (fetchImpl: typeof fetch) => { + const tokenStorage = createMemoryTokenStorage(); + await tokenStorage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' }); + return createTransport({ + apiHost: API, + mode: 'bearer', + fetch: fetchImpl, + tokenStorage, + }); + }; + + const redirect = (status: number, location: string) => + new Response(null, { status, headers: { location } }); + + const with_ = ( + response: Response, + props: Partial> + ) => { + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(response, key, { value }); + } + return response; + }; + + it('does not follow a redirect to another origin with the token', async () => { + const { calls, fetchImpl } = scriptedFetch(() => + redirect(302, 'https://elsewhere.example.com/steal') + ); + const transport = await signedIn(fetchImpl); + + await expect(transport.authorizedFetch(`${API}/api/plan`)).rejects.toBeInstanceOf( + UntrustedRedirectError + ); + + expect(calls).toHaveLength(1); + expect(calls[0].init.redirect).toBe('manual'); + expect(calls.some(call => call.url.startsWith('https://elsewhere.example.com'))).toBe( + false + ); + }); + + it('follows a redirect that stays on a trusted origin, keeping the token', async () => { + const { calls, fetchImpl } = scriptedFetch((_call, index) => + index === 0 ? redirect(307, '/api/plan/') : jsonResponse(200, { ok: true }) + ); + const transport = await signedIn(fetchImpl); + + const response = await transport.authorizedFetch(`${API}/api/plan`); + + expect(response.status).toBe(200); + expect(calls.map(call => call.url)).toEqual([`${API}/api/plan`, `${API}/api/plan/`]); + expect(headersOf(calls[1]).Authorization).toBe('Bearer access-1'); + }); + + it('turns a POST into a GET without a body on 303, as fetch would', async () => { + const { calls, fetchImpl } = scriptedFetch((_call, index) => + index === 0 ? redirect(303, `${API}/api/done`) : jsonResponse(200, {}) + ); + const transport = await signedIn(fetchImpl); + + await transport.authorizedFetch(`${API}/api/submit`, { method: 'POST', body: '{}' }); + + expect(calls[1].init.method).toBe('GET'); + expect(calls[1].init.body).toBeUndefined(); + }); + + it('stops after five redirects', async () => { + const { calls, fetchImpl } = scriptedFetch((_call, index) => + redirect(302, `${API}/loop/${index + 1}`) + ); + const transport = await signedIn(fetchImpl); + + await expect(transport.authorizedFetch(`${API}/loop/0`)).rejects.toBeInstanceOf( + UntrustedRedirectError + ); + expect(calls).toHaveLength(5); + }); + + // React Native's fetch ignores redirect: 'manual' and follows on its own. The + // token may already have gone, but the response is not handed back as if it + // came from the API. + it('refuses a response a platform followed to another origin', async () => { + const { fetchImpl } = scriptedFetch(() => + with_(jsonResponse(200, { secret: true }), { + redirected: true, + url: 'https://elsewhere.example.com/landing', + }) + ); + const transport = await signedIn(fetchImpl); + + await expect(transport.authorizedFetch(`${API}/api/plan`)).rejects.toBeInstanceOf( + UntrustedRedirectError + ); + }); + + it('does not follow a redirect a browser hides', async () => { + const { fetchImpl } = scriptedFetch(() => + with_(new Response(null, { status: 200 }), { type: 'opaqueredirect' }) + ); + const transport = await signedIn(fetchImpl); + + await expect(transport.authorizedFetch(`${API}/api/plan`)).rejects.toBeInstanceOf( + UntrustedRedirectError + ); + }); + + it("leaves the caller's own redirect choice alone, but still checks where it ended", async () => { + const { calls, fetchImpl } = scriptedFetch(() => + with_(jsonResponse(200, {}), { redirected: true, url: `${API}/api/moved` }) + ); + const transport = await signedIn(fetchImpl); + + const response = await transport.authorizedFetch(`${API}/api/plan`, { + redirect: 'follow', + }); + + expect(response.status).toBe(200); + expect(calls[0].init.redirect).toBe('follow'); + }); + + it('does not change a request that carries no token', async () => { + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {})); + const transport = createTransport({ apiHost: API, mode: 'bearer', fetch: fetchImpl }); + + await transport.fetch('/system-config/public', { method: 'GET' }); + + expect(calls[0].init.redirect).toBeUndefined(); + }); +}); diff --git a/packages/react-native/README.md b/packages/react-native/README.md index fdaff69..86b1c2a 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -99,7 +99,8 @@ token and refreshes it once on a 401; a path resolves on `apiHost`, and a path under the adapter's mount (`/auth/sessions`) is sent as an adapter call. The token goes only to `apiHost` and the origins in the provider's `trustedOrigins` prop; any other origin rejects with `UntrustedOriginError` before anything is -sent: +sent. A response that a redirect took to any other origin rejects with +`UntrustedRedirectError`, and the client never follows such a redirect itself: ```ts const authorizedFetch = useAuthorizedFetch(); diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 8329e4b..0194490 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -44,6 +44,7 @@ export { roleGrantsAccess, SeamlessAuthError, UntrustedOriginError, + UntrustedRedirectError, } from '@seamless-auth/client'; export type { Credential, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 0dd0ca0..8b5eb93 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -52,6 +52,7 @@ import { PasskeyPolicyErrorCode, SeamlessAuthError, UntrustedOriginError, + UntrustedRedirectError, WebAuthnErrorDetail, } from '@seamless-auth/client'; import type { SeamlessAuthResult } from '@seamless-auth/client'; @@ -99,6 +100,7 @@ export { roleGrantsAccess, SeamlessAuthError, UntrustedOriginError, + UntrustedRedirectError, useAuth, useAuthClient, useAuthorizedFetch,