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/same-origin-authorized-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@seamless-auth/client': patch
---

`authorizedFetch` works again for an app that serves `/auth` on its own origin (`apiHost: ""`, as `<AuthProvider apiHost="">`). Since 0.5.0 it refused every path in that setup with `UntrustedOriginError`, because a path stayed relative and the origin check could not read an origin from it. A path now resolves on the page's own origin, which is trusted. Without a page (server rendering, React Native) an empty `apiHost` still sends nothing.
7 changes: 5 additions & 2 deletions packages/client/src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import type {
import { createFetchTransport } from '../fetchWithAuth';
import { createBrowserPasskeyPort } from '../ports/browserPasskeys';
import { isPasskeyCeremonyError, type PasskeyPort } from '../ports/passkeys';
import type { TransportOptions } from '../transport';
import { pageOrigin, type TransportOptions } from '../transport';
import { getWebAuthnErrorDetail } from './errors';
import {
NETWORK_ERROR_STATUS,
Expand Down Expand Up @@ -454,7 +454,10 @@ export const createSeamlessAuthClient = (
const fetchWithAuth = transport.fetch;
const passkeys = opts.passkeys ?? createBrowserPasskeyPort();

const host = opts.apiHost.replace(/\/+$/, '');
// An empty apiHost is the same-origin setup, so a path goes to this page's
// origin. Without a page (server rendering, React Native) it stays relative,
// and authorizedFetch refuses it rather than guess.
const host = opts.apiHost.replace(/\/+$/, '') || (pageOrigin() ?? '');

return {
authorizedFetch: (input, init) =>
Expand Down
14 changes: 13 additions & 1 deletion packages/client/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ function originOf(url: string): string | null {
}
}

/**
* The page's own origin when there is a page, for an `apiHost` of `""`, which
* means "this origin". `location.origin` is the document's real origin, which an
* injected `<base>` element cannot change, unlike the base URL a relative fetch
* resolves against.
*/
export function pageOrigin(): string | null {
const origin = (globalThis as { location?: { origin?: unknown } }).location?.origin;
return typeof origin === 'string' && origin !== 'null' && origin !== '' ? origin : null;
}

const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);

/**
Expand Down Expand Up @@ -108,7 +119,8 @@ function trustedOriginOf(entry: string): string {

function trustedOriginsFor(options: TransportOptions): Set<string> {
const trusted = new Set((options.trustedOrigins ?? []).map(trustedOriginOf));
const apiOrigin = originOf(options.apiHost);
const apiOrigin =
options.apiHost.trim() === '' ? pageOrigin() : originOf(options.apiHost);
if (apiOrigin) trusted.add(apiOrigin);
return trusted;
}
Expand Down
18 changes: 18 additions & 0 deletions packages/client/tests/createSeamlessAuthClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ describe('createSeamlessAuthClient', () => {
);
});

it('authorizedFetch resolves a path on the page origin when apiHost is empty', async () => {
const authorizedFetch = jest.fn().mockResolvedValue({ ok: true });
(createFetchTransport as jest.Mock).mockImplementation(() => ({
fetch: mockFetchWithAuth,
authorizedFetch,
mode: 'cookie',
clearTokens: jest.fn(),
}));
const client = createSeamlessAuthClient({ apiHost: '' });

await client.authorizedFetch('/api/beta-users');

expect(authorizedFetch).toHaveBeenCalledWith(
`${window.location.origin}/api/beta-users`,
undefined
);
});

it('forwards login requests through the shared auth fetch helper', async () => {
const response = { ok: true };
mockFetchWithAuth.mockResolvedValueOnce(response);
Expand Down
34 changes: 34 additions & 0 deletions packages/client/tests/transport.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,40 @@ describe('cookie transport', () => {
expect(transport.mode).toBe('cookie');
});

// 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 () => {
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/api/beta-users');
await expect(
transport.authorizedFetch('https://other.example.com/x')
).rejects.toBeInstanceOf(UntrustedOriginError);

expect(calls).toHaveLength(1);
expect(calls[0].init.credentials).toBe('include');
} finally {
(globalThis as { location?: unknown }).location = previous;
}
});

it('authorizedFetch with an empty apiHost and no page trusts nothing', async () => {
const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {}));
const transport = createTransport({ apiHost: '', fetch: fetchImpl });

await expect(
transport.authorizedFetch('https://app.example.com/api/beta-users')
).rejects.toBeInstanceOf(UntrustedOriginError);
expect(calls).toHaveLength(0);
});

it('authorizedFetch sends credentials to apiHost and trusted origins only', async () => {
const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {}));
const transport = createTransport({
Expand Down
Loading