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/web-binding-cookie-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@seamless-auth/react': minor
---

`AuthProvider` is cookie transport only. Its `transport` prop no longer accepts `mode` or `tokenStorage`, and passing `mode: 'bearer'` throws, so in a browser the tokens stay with the server adapter in `httpOnly` cookies and a script on the page can never read them. `basePath`, `fetch` and `trustedOrigins` are unchanged. Native apps that need bearer transport use `@seamless-auth/react-native`, which is unaffected. No first-party template, the admin console or RoxTarget used bearer mode from `@seamless-auth/react`.
19 changes: 9 additions & 10 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,25 +318,24 @@ Custom UIs get the same default through `useAuthClient()`, and can still overrid

Everything that differs between a browser and another platform sits behind a port on
`AuthProvider`. A web application configures nothing; the browser implementations are the
defaults. A binding for another platform (React Native is the first) supplies its own:
defaults. A port can be swapped, for example to drive the passkey ceremonies yourself:

```tsx
<AuthProvider
apiHost={apiHost}
transport={{ mode: 'bearer', tokenStorage: keystoreTokenStorage }}
ports={{ passkeys: nativePasskeys, oauthRedirect: inAppBrowserRedirect }}
transport={{ basePath: '/identity', trustedOrigins: ['https://data.example.com'] }}
ports={{ passkeys: customPasskeys }}
>
<App />
</AuthProvider>
```

- `transport` selects how the session travels. Cookie transport, the default, is the browser
contract: the server adapter holds the tokens in `httpOnly` cookies and the client never sees
them. Bearer transport is the native contract: the client holds the auth API's own tokens,
presents the one a route needs in `Authorization`, stores the pair a sign-in returns through
`tokenStorage`, and refreshes once through `POST /auth/refresh` when a request answers 401. At
most one refresh is in flight at a time, because the auth API treats a replayed refresh token as
theft and revokes the chain.
- `transport` sets where the adapter is mounted (`basePath`, default `/auth`), the `fetch` to use,
and the extra origins `authorizedFetch` may reach (`trustedOrigins`). It is always cookie
transport: the server adapter holds the tokens in `httpOnly` cookies and the browser never sees
them, so a script on the page can ride the session but not read it. Passing `mode: 'bearer'`
throws. Native apps that need bearer transport, where the client holds the tokens in a keystore,
use `@seamless-auth/react-native`.
- `ports.passkeys` runs the WebAuthn ceremonies (`create`, `get`, and the two support checks).
`usePasskeySupport()` and every passkey flow go through it.
- `ports.oauthRedirect` opens the provider. The browser navigates away and the callback route
Expand Down
31 changes: 18 additions & 13 deletions packages/react/src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,13 @@ interface AuthProviderProps {
*/
magicLinkRedirectUri?: string;
/**
* How the session travels to the server adapter. Defaults to cookie
* transport, which is what a browser application wants.
* Where the adapter is mounted, the fetch to use, and the extra origins
* `authorizedFetch` may reach. Always cookie transport: the server adapter
* holds the tokens and the browser only HttpOnly cookies, so a script on the
* page can ride the session but never read it. Bearer transport is for
* native apps, through `@seamless-auth/react-native`.
*/
transport?: Omit<TransportOptions, 'apiHost'>;
transport?: Omit<TransportOptions, 'apiHost' | 'mode' | 'tokenStorage'>;
/** Platform ports. Each one left out falls back to the browser's. */
ports?: Partial<AuthPorts>;
/**
Expand Down Expand Up @@ -166,30 +169,32 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
[providedPorts?.passkeys, providedPorts?.oauthRedirect]
);

const {
mode,
basePath,
tokenStorage,
fetch: fetchImpl,
trustedOrigins,
} = transport ?? {};
// The type no longer offers bearer, but a JavaScript caller can still pass it,
// and quietly switching it to cookies would hide that their tokens were never
// where they thought.
if ((transport as { mode?: unknown } | undefined)?.mode === 'bearer') {
throw new TypeError(
'@seamless-auth/react is cookie transport only, so the browser never holds the tokens. Use @seamless-auth/react-native for bearer transport in a native app.'
);
}

const { basePath, fetch: fetchImpl, trustedOrigins } = transport ?? {};
// An inline array is a new value on every render, so it is compared by content.
const trustedOriginsKey = trustedOrigins ? JSON.stringify(trustedOrigins) : undefined;
const stableTransport = useMemo(
() =>
transport
? {
mode,
mode: 'cookie' as const,
basePath,
tokenStorage,
fetch: fetchImpl,
trustedOrigins: trustedOriginsKey
? (JSON.parse(trustedOriginsKey) as string[])
: undefined,
}
: undefined,
// eslint-disable-next-line react-hooks/exhaustive-deps -- `transport` itself is deliberately not a dependency
[mode, basePath, tokenStorage, fetchImpl, trustedOriginsKey]
[basePath, fetchImpl, trustedOriginsKey]
);

// Seeds the store like a `useState` initialiser. A server component hands over
Expand Down
44 changes: 29 additions & 15 deletions packages/react/tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,13 +511,8 @@ describe('AuthProvider', () => {
expect(typeof (seen[0] as { login: unknown }).login).toBe('function');
});

it('passes transport and passkey ports through to the client', async () => {
it('passes transport options and passkey ports through to the client, as cookie transport', async () => {
mockFetchWithAuthImpl.mockResolvedValue(signedIn());
const tokenStorage = {
get: jest.fn(async () => null),
set: jest.fn(async () => undefined),
remove: jest.fn(async () => undefined),
};
const passkeys = {
isSupported: () => true,
isPlatformAuthenticatorAvailable: async () => true,
Expand All @@ -535,7 +530,7 @@ describe('AuthProvider', () => {
render(
<AuthProvider
apiHost={apiHost}
transport={{ mode: 'bearer', tokenStorage }}
transport={{ basePath: '/identity' }}
ports={{ passkeys }}
>
<Probe />
Expand All @@ -544,18 +539,39 @@ describe('AuthProvider', () => {
});

expect(createFetchTransport).toHaveBeenCalledWith(
expect.objectContaining({ authHost: apiHost, mode: 'bearer', tokenStorage })
expect.objectContaining({
authHost: apiHost,
mode: 'cookie',
basePath: '/identity',
})
);
expect(createFetchTransport).not.toHaveBeenCalledWith(
expect.objectContaining({ tokenStorage: expect.anything() })
);
expect((ports as { passkeys: unknown }).passkeys).toBe(passkeys);
});

// The browser binding keeps tokens out of page script. A JavaScript caller
// can still pass bearer, and quietly using cookies instead would hide that.
it('refuses bearer transport', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => undefined);

expect(() =>
render(
<AuthProvider
apiHost={apiHost}
transport={{ mode: 'bearer' } as unknown as { basePath?: string }}
>
<Consumer />
</AuthProvider>
)
).toThrow(/cookie transport only/);

spy.mockRestore();
});

it('keeps one session across re-renders that pass fresh but equal prop objects', async () => {
mockFetchWithAuthImpl.mockResolvedValue(signedIn());
const tokenStorage = {
get: jest.fn(async () => null),
set: jest.fn(async () => undefined),
remove: jest.fn(async () => undefined),
};
const passkeys = {
isSupported: () => true,
isPlatformAuthenticatorAvailable: async () => true,
Expand All @@ -567,8 +583,6 @@ describe('AuthProvider', () => {
<AuthProvider
apiHost={apiHost}
transport={{
mode: 'bearer',
tokenStorage,
trustedOrigins: ['https://data.example.com'],
}}
ports={{ passkeys }}
Expand Down
7 changes: 7 additions & 0 deletions scripts/check-react-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ for (const file of await emittedModules(dist)) {
}
}

for (const file of await emittedModules(dist)) {
// The browser binding is cookie transport only; bearer belongs to react-native.
if (/mode:\s*["']bearer["']/.test(await readFile(file, 'utf8'))) {
failures.push(`${path.relative(dist, file)} configures bearer transport`);
}
}

const reachableFromIndex = await staticImports(path.join(dist, 'index.js'));
if (reachableFromIndex.has('react-router-dom')) {
failures.push(
Expand Down
Loading