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
17 changes: 17 additions & 0 deletions .changeset/react-native-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@seamless-auth/react-native': minor
---

First release of `@seamless-auth/react-native`, the headless React Native binding.

An `AuthProvider` over the shared session store, always on bearer transport; `useAuth`,
`useAuthClient`, `useLoginMethods` and `usePasskeySupport`; and the native ports:
`createSecureStoreTokenStorage` (expo-secure-store), `createNativePasskeyPort`
(react-native-passkeys, mapping native failures onto the DOMException names the client's error
readers understand), `createWebBrowserOAuthRedirect` (expo-web-browser auth session, resolving the
callback's `code` and `state`), and `describeDevice` for passkey metadata. Each port takes its
native module as a parameter, so an app installs only what it uses and Metro never resolves a
module it did not.

No screens: the app brings its own, the same way a web app that skips `AuthRoutes` does. The flows,
session state, and token custody come from `@seamless-auth/client`.
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
This repository is an npm workspace that publishes the client-side packages for
[Seamless Auth](https://github.com/fells-code/seamless-auth-api):

| Package | What it is |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| [`@seamless-auth/client`](packages/client/README.md) | Framework-agnostic core: the headless auth client, session store, result and error types. |
| [`@seamless-auth/react`](packages/react/README.md) | React binding: `AuthProvider`, hooks, and optional prebuilt auth screens. Depends on the client package. |
| Package | What it is |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| [`@seamless-auth/client`](packages/client/README.md) | Framework-agnostic core: the headless auth client, session store, result and error types. |
| [`@seamless-auth/react`](packages/react/README.md) | React binding: `AuthProvider`, hooks, and optional prebuilt auth screens. Depends on the client package. |
| [`@seamless-auth/react-native`](packages/react-native/README.md) | Headless React Native binding: provider, hooks, and the native ports for passkeys, keystore token storage, and in-app browser OAuth. |

Most React applications only install `@seamless-auth/react`; it brings the client
core with it. The client package exists so that other bindings (React Native
next) share one implementation of the auth flows and session state instead of
re-implementing them.
core with it. The client package exists so that the bindings share one
implementation of the auth flows and session state instead of re-implementing
them; `@seamless-auth/react-native` is the second binding over it.

## Working in this repository

Expand Down
6 changes: 5 additions & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export default {
projects: ['<rootDir>/packages/client', '<rootDir>/packages/react'],
projects: [
'<rootDir>/packages/client',
'<rootDir>/packages/react',
'<rootDir>/packages/react-native',
],
collectCoverage: true,
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
coverageThreshold: {
Expand Down
23 changes: 22 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@
"type": "module",
"workspaces": [
"packages/client",
"packages/react"
"packages/react",
"packages/react-native"
],
"engines": {
"node": ">=24.0.0 <25.0.0",
"npm": ">=9.0.0 <13.0.0"
},
"scripts": {
"build": "npm run build -w @seamless-auth/client && npm run build -w @seamless-auth/react",
"build": "npm run build -w @seamless-auth/client && npm run build -w @seamless-auth/react && npm run build -w @seamless-auth/react-native",
"test": "jest",
"coverage": "npm test -- --coverage",
"typecheck": "npm run typecheck -w @seamless-auth/client && npm run typecheck -w @seamless-auth/react",
"typecheck": "npm run typecheck -w @seamless-auth/client && npm run typecheck -w @seamless-auth/react && npm run typecheck -w @seamless-auth/react-native",
"lint": "eslint ./packages",
"format": "prettier --write .",
"format:check": "prettier --check .",
Expand Down
5 changes: 4 additions & 1 deletion packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ 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.
tokens out of the response, since that body is the application's. A URL under
the adapter's own mount (`/auth/sessions`, a passthrough the adapter adds) is
sent the way the client's own calls are, transport header included, so an
application can reach every adapter route through the one fetch.

## Ports

Expand Down
9 changes: 9 additions & 0 deletions packages/client/src/ports/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import type {
RegistrationResponseJSON,
} from '@simplewebauthn/browser';

// Re-exported so a binding can type its port without depending on the
// browser package itself.
export type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
};

/**
* The passkey ceremonies, as the platform runs them.
*
Expand Down
97 changes: 70 additions & 27 deletions packages/client/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,31 @@ function buildUrl(apiHost: string, basePath: string, path: string): string {
return `${host}${mount}${path}`;
}

/**
* The caller's headers as a plain object, whatever shape they came in.
*
* A `Headers` instance or an entries array cannot be spread: spreading a
* `Headers` copies its internals (React Native's polyfill keeps a `map`
* field), and the resulting nested object makes the native fetch on Expo
* refuse the whole request.
*/
function plainHeaders(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (Array.isArray(headers)) {
return Object.fromEntries(headers);
}
// Duck-typed rather than `instanceof Headers`, so a polyfilled instance
// from another realm is flattened too.
if (typeof (headers as Headers).forEach === 'function') {
const out: Record<string, string> = {};
(headers as Headers).forEach((value, key) => {
out[key] = value;
});
return out;
}
return { ...(headers as Record<string, string>) };
}

function withHeaders(
init: RequestInit | undefined,
extra: Record<string, string>
Expand All @@ -119,14 +144,21 @@ function withHeaders(
// proxies reject a bodyless GET that advertises a request content type.
const hasBody = init?.body != null;

return {
...init,
headers: {
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...extra,
...init?.headers,
},
const headers: Record<string, string> = {
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
...extra,
};
// Header names are case-insensitive, and a `Headers` instance lower-cases
// them, so the caller's `content-type` replaces the default `Content-Type`
// rather than travelling beside it.
for (const [name, value] of Object.entries(plainHeaders(init?.headers))) {
for (const existing of Object.keys(headers)) {
if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
}
headers[name] = value;
}

return { ...init, headers };
}

interface SessionBody {
Expand Down Expand Up @@ -315,29 +347,40 @@ export function createTransport(options: TransportOptions): Transport {
return response;
}

const mount = `${buildUrl(options.apiHost, basePath, '')}/`;

const fetchUnderMount: FetchWithAuth = async (input, init) => {
const path = normalizePath(input);
const rule = resolveRouteRule(path);

const response = await sendWithRefresh(
buildUrl(options.apiHost, basePath, path),
init,
rule.identity,
true
);

if (response.ok && rule.effect) {
await applyEffect(rule.effect, response);
}

return response;
};

return {
mode,
clearTokens,
fetch: async (input, init) => {
const path = normalizePath(input);
const rule = resolveRouteRule(path);

const response = await sendWithRefresh(
buildUrl(options.apiHost, basePath, path),
init,
rule.identity,
true
);

if (response.ok && rule.effect) {
await applyEffect(rule.effect, response);
}

return response;
},
fetch: fetchUnderMount,
// The transport header is the adapter's; an application's own API only
// needs the bearer token, which requireAuth reads.
authorizedFetch: (input, init) =>
sendWithRefresh(String(input), init, 'access', false),
// needs the bearer token, which requireAuth reads. A URL under the
// adapter's own mount (its session list, a passthrough it adds) is the
// adapter's, though, and without the header it would answer in cookie
// mode, so it takes the same road as the client's own calls.
authorizedFetch: (input, init) => {
const url = String(input);
return url.startsWith(mount)
? fetchUnderMount(url.slice(mount.length - 1), init)
: sendWithRefresh(url, init, 'access', false);
},
};
}
46 changes: 46 additions & 0 deletions packages/client/tests/transport.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,52 @@ describe('bearer transport', () => {
expect(headersOf(calls[0])).toEqual({ Authorization: 'Bearer access-1' });
});

it('authorizedFetch flattens Headers instances and entry arrays into plain headers', async () => {
const storage = createMemoryTokenStorage();
await storage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' });
const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, {}));
const transport = bearer(fetchImpl, storage);

const asInstance = new Headers();
asInstance.set('Content-Type', 'application/json');
await transport.authorizedFetch(`${API}/api/plan`, {
method: 'POST',
body: '{}',
headers: asInstance,
});
await transport.authorizedFetch(`${API}/api/plan`, {
headers: [['X-Trace', 'abc']],
});

// Spreading a Headers instance would have produced an object with no
// usable keys (or a nested `map` on React Native) and lost the header.
expect(headersOf(calls[0])).toEqual({
Authorization: 'Bearer access-1',
'content-type': 'application/json',
});
expect(headersOf(calls[1])).toEqual({
Authorization: 'Bearer access-1',
'X-Trace': 'abc',
});
});

it('authorizedFetch treats a URL under the adapter mount as an adapter call', async () => {
const storage = createMemoryTokenStorage();
await storage.set({ accessToken: 'access-1', refreshToken: 'refresh-1' });
const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(200, { sessions: [] }));

const response = await bearer(fetchImpl, storage).authorizedFetch(`${API}/auth/sessions`, {
method: 'GET',
});

expect(response.status).toBe(200);
expect(calls[0].url).toBe(`${API}/auth/sessions`);
expect(headersOf(calls[0])).toEqual({
Authorization: 'Bearer access-1',
[AUTH_TRANSPORT_HEADER]: 'bearer',
});
});

it('authorizedFetch refreshes once on a 401 and retries', async () => {
const storage = createMemoryTokenStorage();
await storage.set({ accessToken: 'access-old', refreshToken: 'refresh-old' });
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @seamless-auth/react-native
Loading
Loading