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
7 changes: 7 additions & 0 deletions .changeset/bearer-redirects.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 7 additions & 2 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
100 changes: 89 additions & 11 deletions packages/client/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<never> | 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<never> | null =>
isTrusted(url) ? null : Promise.reject(new UntrustedOriginError(url));

const checkedUrl = (input: unknown): string | Promise<never> => {
const url = urlOf(input);
if (url === null) {
Expand Down Expand Up @@ -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.
Expand Down
132 changes: 132 additions & 0 deletions packages/client/tests/transport.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createTransport,
resolveRouteRule,
UntrustedOriginError,
UntrustedRedirectError,
} from '../src/transport';

const API = 'https://api.example.com';
Expand Down Expand Up @@ -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<Record<'redirected' | 'url' | 'type', unknown>>
) => {
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();
});
});
3 changes: 2 additions & 1 deletion packages/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export {
roleGrantsAccess,
SeamlessAuthError,
UntrustedOriginError,
UntrustedRedirectError,
} from '@seamless-auth/client';
export type {
Credential,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
PasskeyPolicyErrorCode,
SeamlessAuthError,
UntrustedOriginError,
UntrustedRedirectError,
WebAuthnErrorDetail,
} from '@seamless-auth/client';
import type { SeamlessAuthResult } from '@seamless-auth/client';
Expand Down Expand Up @@ -99,6 +100,7 @@ export {
roleGrantsAccess,
SeamlessAuthError,
UntrustedOriginError,
UntrustedRedirectError,
useAuth,
useAuthClient,
useAuthorizedFetch,
Expand Down
Loading