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
41 changes: 41 additions & 0 deletions .changeset/client-ports-and-bearer-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
'@seamless-auth/client': minor
'@seamless-auth/react': minor
---

Put the platform behind ports, and add a bearer transport with token custody to the client core.

The client spoke one contract: cookies to a server adapter at `/auth`, with the browser's WebAuthn
API and `window.location` reached directly. A native binding has none of those, so the pieces that
differ by platform are now ports a binding supplies, with the browser implementations as the
defaults. A web application configures nothing and behaves exactly as before.

- `transport` on `createSeamlessAuthClient` (and `AuthProvider`): cookie transport is unchanged;
`{ mode: 'bearer', tokenStorage }` makes the client hold the auth API's own tokens. Every request
carries `x-seamless-auth-transport: bearer`; pre-auth routes carry the ephemeral token that
`/login` or `/registration/register` returned, kept in memory only; signed-in routes carry the
access token; the pair a sign-in returns is written through a `TokenStoragePort`; a 401 on a
signed-in route triggers one `POST /refresh` and one retry, with at most one refresh in flight
because the auth API revokes the chain on a replayed refresh token. Which routes take which
token is one table, mirroring the server adapter's, rather than an annotation at each of the
forty call sites.
- `PasskeyPort` (`isSupported`, `isPlatformAuthenticatorAvailable`, `create`, `get`) replaces the
direct SimpleWebAuthn calls at the four ceremony sites. `createBrowserPasskeyPort()` is the
default. A port reports an authenticator refusal with `PasskeyCeremonyError`, or any error with a
DOMException `name` and a string `code`, which the client turns into the same result a browser
failure gave. The PRF helpers no longer depend on SimpleWebAuthn at runtime.
- `OAuthRedirectPort` replaces `window.location.assign` in the built-in provider buttons.
`createBrowserOAuthRedirect()` is the default; a port that receives the callback itself resolves
with `code` and `state`, and the buttons finish the login on the spot.
- `TokenStoragePort` with `createMemoryTokenStorage()`.
- `client.authorizedFetch(input, init)` and `useAuthorizedFetch()`: a fetch for the application's
own API that carries the session the way the transport does (cookies, or the access token with
one refresh-and-retry on a 401). A path resolves on `apiHost`. This is what a native app uses to
call routes behind `requireAuth`.
- `createAuthSession` accepts the client options (or a ready-made `client`) and exposes the client
it drives as `session.client`. `useAuthClient()` returns that same instance rather than building a
second one, which bearer transport needs: the client holds the sign-in in flight.
- `AuthProvider` gains `transport` and `ports` props and exposes `client` and `ports` on the
context. `usePasskeySupport()` reads the passkey port.

Tracks fells-code/seamless-auth-react#124, #125, #127 and #128.
26 changes: 20 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,26 @@ Common usage patterns:

## Runtime Model

This package assumes a Seamless Auth-compatible backend mounted under `/auth`.

`createFetchWithAuth()` is the shared request helper:

- it always sends `credentials: "include"`
- it targets `${authHost}/auth/...`
This package assumes a Seamless Auth-compatible backend (a server adapter)
mounted under `/auth`.

`createTransport()` in `@seamless-auth/client` is the shared request layer, and
`createFetchWithAuth()` is the seam the client is built on:

- cookie transport (the default, and what every browser application uses) sends
`credentials: "include"` to `${apiHost}/auth/...` and holds no tokens
- bearer transport (`mode: 'bearer'`, for native bindings) marks every request
with `x-seamless-auth-transport: bearer`, attaches the ephemeral token on
pre-auth routes and the access token on signed-in routes, stores the pair a
session-issuing response returns through a `TokenStoragePort`, and refreshes
once through `POST /refresh` on a 401 with at most one refresh in flight
- which routes take which token is one table, `ROUTE_RULES` in
`packages/client/src/transport.ts`, mirroring the server adapter's own map

Platform differences sit behind ports the binding supplies: `PasskeyPort` (who
runs the WebAuthn ceremonies), `OAuthRedirectPort` (how the provider is opened),
`TokenStoragePort` (where a bearer session lives). The browser implementations
are the defaults, so a web application configures nothing.

Important implication:

Expand Down
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default [
// Jest only reads the `@jest-environment` pragma from the first comment in a
// file, so these suites carry it alongside the license text in one block.
// The license header is still present, it just is not a byte-for-byte match.
files: ['**/*.ssr.test.ts', '**/*.ssr.test.tsx'],
files: ['**/*.ssr.test.ts', '**/*.ssr.test.tsx', '**/*.node.test.ts'],
rules: {
'license-header/header': 'off',
},
Expand Down
40 changes: 37 additions & 3 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,43 @@ than redeclaring shapes.

## Transport

Today the client speaks the cookie contract: requests go to `${apiHost}/auth/*`
with `credentials: 'include'` and the server adapter holds the tokens. A bearer
transport for native clients is the next change to this package.
`createSeamlessAuthClient` takes a `transport` option:

- cookie transport, the default: requests go to `${apiHost}/auth/*` with
`credentials: 'include'` and the server adapter holds the tokens. The browser
contract.
- bearer transport (`{ mode: 'bearer', tokenStorage }`): the client holds the auth
API's own tokens. Every request carries `x-seamless-auth-transport: bearer`;
pre-auth routes carry the ephemeral token `/login` or `/registration/register`
returned (kept in memory only), signed-in routes carry the access token; the
pair a sign-in returns is written through the `TokenStoragePort`; a 401 on a
signed-in route triggers one `POST /refresh` and one retry, with at most one
refresh in flight. The native contract.

Which routes take which token is one table, `ROUTE_RULES` in `src/transport.ts`,
mirroring the server adapter's own map.

`client.authorizedFetch(input, init)` is a fetch for the application's own API
that carries the session the same way: cookies in cookie transport, the access
token with one refresh-and-retry on a 401 in bearer transport. It never reads
tokens out of the response, since that body is the application's.

## Ports

- `PasskeyPort`: who runs the WebAuthn ceremonies. `createBrowserPasskeyPort()`
(SimpleWebAuthn) is the default. A port throws `PasskeyCeremonyError` (or an
error with a DOMException `name` and a string `code`) when the authenticator
refuses, which the client turns into the same result a browser failure gives.
- `TokenStoragePort`: where a bearer session lives. `createMemoryTokenStorage()`
is the default and does not survive a restart; a native binding supplies one
over the platform keystore.
- `OAuthRedirectPort`: how the provider is opened. `createBrowserOAuthRedirect()`
navigates the page; a native port opens an in-app browser session and resolves
with the callback's `code` and `state`.

`createAuthSession` accepts the same client options, or a ready-made `client`,
and exposes the client it drives as `session.client` so a binding hands out one
instance rather than two.

## License

Expand Down
68 changes: 44 additions & 24 deletions packages/client/src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@
* See LICENSE file in the project root for full license information
*/

import {
startAuthentication,
startRegistration,
type AuthenticationResponseJSON,
type PublicKeyCredentialCreationOptionsJSON,
type PublicKeyCredentialRequestOptionsJSON,
type RegistrationResponseJSON,
WebAuthnError,
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser';

import type {
Expand Down Expand Up @@ -43,7 +40,10 @@ import type {
UpdateOrganizationRequest,
} from '@seamless-auth/types';

import { createFetchWithAuth } from '../fetchWithAuth';
import { createFetchTransport } from '../fetchWithAuth';
import { createBrowserPasskeyPort } from '../ports/browserPasskeys';
import { isPasskeyCeremonyError, type PasskeyPort } from '../ports/passkeys';
import type { TransportOptions } from '../transport';
import { getWebAuthnErrorDetail } from './errors';
import {
NETWORK_ERROR_STATUS,
Expand All @@ -56,7 +56,6 @@ import {
createPrfRequestBody,
extractPasskeyPrfResult,
getRegistrationPrfCapable,
isPasskeyPrfSupported,
PasskeyPrfInput,
PasskeyPrfResult,
preparePrfRequestOptions,
Expand All @@ -71,6 +70,14 @@ export interface SeamlessAuthClientOptions {
* destination as the send it repeats. Omit it to keep the deployment's.
*/
magicLinkRedirectUri?: string;
/**
* How the session travels. Defaults to cookie transport, the browser
* contract. A native binding sets `mode: 'bearer'` and supplies a
* `tokenStorage` backed by the platform keystore.
*/
transport?: Omit<TransportOptions, 'apiHost'>;
/** Who runs the passkey ceremonies. Defaults to the browser. */
passkeys?: PasskeyPort;
}

export interface LoginInput {
Expand Down Expand Up @@ -237,9 +244,17 @@ export interface LogoutOptions {
* Every request resolves to a `SeamlessAuthResult`: check `error` first, then
* read `data`. Nothing here throws for an HTTP or transport failure.
* `isPasskeyPrfSupported` is the one exception, since it is a local capability
* check rather than a request.
* check rather than a request, and `authorizedFetch` returns the raw `Response`
* because the body is the application's, not this client's.
*/
export interface SeamlessAuthClient {
/**
* A request to the application's own API, carrying the session the way the
* transport does: cookies in cookie transport, the access token (refreshed
* once on a 401) in bearer transport. `input` is a full URL or a path on
* `apiHost`.
*/
authorizedFetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
getCurrentUser: () => Promise<SeamlessAuthResult<CurrentUserResult>>;
login: (input: LoginInput) => Promise<SeamlessAuthResult<LoginStartResult>>;
loginWithPasskey: (
Expand Down Expand Up @@ -422,11 +437,22 @@ function webAuthnFailure<T>(
export const createSeamlessAuthClient = (
opts: SeamlessAuthClientOptions
): SeamlessAuthClient => {
const fetchWithAuth = createFetchWithAuth({
const transport = createFetchTransport({
...opts.transport,
authHost: opts.apiHost,
});
const fetchWithAuth = transport.fetch;
const passkeys = opts.passkeys ?? createBrowserPasskeyPort();

const host = opts.apiHost.replace(/\/+$/, '');

return {
authorizedFetch: (input, init) =>
transport.authorizedFetch(
typeof input === 'string' && input.startsWith('/') ? `${host}${input}` : input,
init
),

getCurrentUser: () =>
requestResult<CurrentUserResult>(
fetchWithAuth(`users/me`, { method: 'GET' }),
Expand Down Expand Up @@ -458,9 +484,7 @@ export const createSeamlessAuthClient = (
let assertionResponse: AuthenticationResponseJSON;

try {
const credential = (await startAuthentication({
optionsJSON: preparePrfRequestOptions(started.data),
})) as AuthenticationResponseJSON;
const credential = await passkeys.get(preparePrfRequestOptions(started.data));
prf = extractPasskeyPrfResult(credential);
assertionResponse = stripPrfResultsFromAssertion(credential);
} catch (error) {
Expand Down Expand Up @@ -685,9 +709,9 @@ export const createSeamlessAuthClient = (
let attestationResponse: RegistrationResponseJSON;

try {
attestationResponse = await startRegistration({ optionsJSON: challenge.data });
attestationResponse = await passkeys.create(challenge.data);
} catch (error) {
if (error instanceof WebAuthnError) {
if (isPasskeyCeremonyError(error)) {
// The authenticator name is the useful detail here, for example
// InvalidStateError when the passkey already exists.
return resultError(error.name, NETWORK_ERROR_STATUS, undefined, error);
Expand Down Expand Up @@ -720,7 +744,7 @@ export const createSeamlessAuthClient = (
return resultOf({ credentialId: attestationResponse.id, prfCapable });
},

isPasskeyPrfSupported,
isPasskeyPrfSupported: async () => passkeys.isSupported(),

getStepUpStatus: () =>
requestResult<StepUpStatus>(
Expand All @@ -741,9 +765,7 @@ export const createSeamlessAuthClient = (
let assertionResponse: AuthenticationResponseJSON;

try {
const credential = (await startAuthentication({
optionsJSON: preparePrfRequestOptions(started.data),
})) as AuthenticationResponseJSON;
const credential = await passkeys.get(preparePrfRequestOptions(started.data));
assertionResponse = stripPrfResultsFromAssertion(credential);
} catch (error) {
return webAuthnFailure(
Expand Down Expand Up @@ -784,9 +806,7 @@ export const createSeamlessAuthClient = (
let assertionResponse: AuthenticationResponseJSON;

try {
const credential = (await startAuthentication({
optionsJSON: preparePrfRequestOptions(started.data),
})) as AuthenticationResponseJSON;
const credential = await passkeys.get(preparePrfRequestOptions(started.data));
prf = extractPasskeyPrfResult(credential);
assertionResponse = stripPrfResultsFromAssertion(credential);
} catch (error) {
Expand Down
30 changes: 25 additions & 5 deletions packages/client/src/client/webauthnPrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,35 @@
* See LICENSE file in the project root for full license information
*/

import {
base64URLStringToBuffer,
bufferToBase64URLString,
type AuthenticationResponseJSON,
type PublicKeyCredentialRequestOptionsJSON,
import type {
AuthenticationResponseJSON,
PublicKeyCredentialRequestOptionsJSON,
} from '@simplewebauthn/browser';

import { isWebAuthnAvailable } from './webauthnSupport';

// Local rather than SimpleWebAuthn's, so the PRF logic has no runtime
// dependency on the browser package and a native binding can use it as-is.
function bufferToBase64URLString(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

function base64URLStringToBuffer(value: string): ArrayBuffer {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}

export type PasskeyPrfSalt = ArrayBuffer | ArrayBufferView | string;

export interface PasskeyPrfInput {
Expand Down
48 changes: 21 additions & 27 deletions packages/client/src/fetchWithAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,29 @@
* See LICENSE file in the project root for full license information
*/

interface FetchWithAuthOptions {
import {
createTransport,
type FetchWithAuth,
type Transport,
type TransportOptions,
} from './transport';

export interface FetchWithAuthOptions extends Omit<TransportOptions, 'apiHost'> {
authHost?: string;
}

export const createFetchWithAuth = (opts: FetchWithAuthOptions) => {
const { authHost } = opts;

return async function fetchWithAuth(
input: string,
init?: RequestInit
): Promise<Response> {
const host = authHost?.replace(/\/+$/, '') ?? '';
const path = input.startsWith('/') ? input : `/${input}`;

const url = `${host}/auth${path}`;

// Only declare a JSON content type when a body is actually sent. Some
// proxies reject a bodyless GET that advertises a request content type.
const hasBody = init?.body != null;

const requestInit: RequestInit = {
...init,
credentials: 'include',
headers: {
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
};
/**
* The fetch every client method goes through.
*
* Kept as the seam the client is built on (and tests replace) while the work
* moved into `createTransport`: cookie transport is what this always did, and
* bearer transport is the same call with `mode: 'bearer'`.
*/
export const createFetchWithAuth = (opts: FetchWithAuthOptions): FetchWithAuth => {
return createFetchTransport(opts).fetch;
};

return fetch(url, requestInit);
};
export const createFetchTransport = (opts: FetchWithAuthOptions): Transport => {
const { authHost, ...transport } = opts;
return createTransport({ ...transport, apiHost: authHost ?? '' });
};
5 changes: 5 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ export * from './client/result';
export * from './client/webauthnPrf';
export * from './client/webauthnSupport';
export * from './fetchWithAuth';
export * from './ports/browserPasskeys';
export * from './ports/oauthRedirect';
export * from './ports/passkeys';
export * from './ports/tokenStorage';
export * from './scopedRoles';
export * from './session/createAuthSession';
export * from './session/storage';
export * from './transport';
export * from './types';
Loading
Loading