From 6d3ce10c9f825413d59dcdb25e40f60224c2cba4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 18 Oct 2026 19:10:10 -0400 Subject: [PATCH] fix(client): resolve and trust the page origin when apiHost is empty authorizedFetch refused every path for a same-origin app (apiHost "") because the path stayed relative and the origin check could not read an origin from it. Closes #188 --- .changeset/same-origin-authorized-fetch.md | 5 +++ .../src/client/createSeamlessAuthClient.ts | 7 ++-- packages/client/src/transport.ts | 14 +++++++- .../tests/createSeamlessAuthClient.test.ts | 18 ++++++++++ packages/client/tests/transport.node.test.ts | 34 +++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 .changeset/same-origin-authorized-fetch.md diff --git a/.changeset/same-origin-authorized-fetch.md b/.changeset/same-origin-authorized-fetch.md new file mode 100644 index 0000000..8280ea3 --- /dev/null +++ b/.changeset/same-origin-authorized-fetch.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/client': patch +--- + +`authorizedFetch` works again for an app that serves `/auth` on its own origin (`apiHost: ""`, as ``). 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. diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index fee8e42..7be2f4d 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -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, @@ -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) => diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index 5344990..2fff4c1 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -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 `` 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]']); /** @@ -108,7 +119,8 @@ function trustedOriginOf(entry: string): string { function trustedOriginsFor(options: TransportOptions): Set { 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; } diff --git a/packages/client/tests/createSeamlessAuthClient.test.ts b/packages/client/tests/createSeamlessAuthClient.test.ts index b65c3e2..56335de 100644 --- a/packages/client/tests/createSeamlessAuthClient.test.ts +++ b/packages/client/tests/createSeamlessAuthClient.test.ts @@ -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); diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index d81cd4e..a12c236 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -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({