diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7d445be --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +tests/** linguist-detectable=false +scripts/** linguist-detectable=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45ff6d0..83d95de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,36 @@ jobs: - name: Verify package tarball run: pnpm --config.ignore-scripts=true pack --pack-destination "$RUNNER_TEMP/session-kit-pack" + + minimum-peers: + name: check (oldest supported peers) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.12.1 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Keep these at the lower bounds of peerDependencies in package.json. + - name: Install oldest supported peers + run: >- + pnpm add --save-dev --save-exact + react@19.0.0 react-dom@19.0.0 + @types/react@19.0.0 @types/react-dom@19.0.0 + @tanstack/react-router@1.127.0 + + - name: Build and test + run: pnpm run build && pnpm test && pnpm run test:types diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f439dc..706a370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## 0.1.0-alpha.2 + +### Changed + +- **Breaking:** `AuthState.status` adds `"refreshing"`. Exhaustive `switch` + statements over `status` need a case for it. +- While a refresh replaces expired or rejected access (after a 401, at expiry, + or when restoring an expired saved session), `status` is `"refreshing"` and + `user` stays visible. Previously the state passed through `"unavailable"` with + `user: null`, so the UI briefly looked signed out. +- A refresh that only replaces the token no longer changes `version`, so + `connectAuth` no longer invalidates the router twice per 401. A failed refresh, + a terminal rejection, or a changed user still changes `version` once. +- **Breaking:** after a failed refresh, `getSession()` rethrows that + `REFRESH_FAILED` error instead of starting another refresh. It keeps doing so + until `auth.retry()`, a sign-in, or a sign-out. This prevents a loop where each + failure re-ran Router guards and each guard started a new refresh. + +- **Breaking:** a JWT with both `iat` and `exp`, and no explicit expiry, now + expires `exp - iat` seconds after auth receives it, instead of at absolute + `exp`. A browser clock that runs ahead of the server no longer makes fresh + tokens look expired and block sign-in. A JWT without `iat` still uses `exp`. + +- **Breaking:** tokens and profile are saved together in one + `:auth:session` localStorage entry, so another tab can never read half a + save. Entries written by 0.1.0-alpha.1 are removed, not restored, + so users sign in once after upgrading. +- Tabs no longer sign each other out when the backend rotates refresh tokens. + Refresh runs under a `:auth:refresh` Web Lock, so one tab at a time + spends the refresh token, and a tab first uses tokens another tab already + saved. Previously a tab refreshing with a token another tab had already spent + got `null`, signed out, and cleared storage for every tab. +- A profile update keeps tokens another tab refreshed instead of overwriting + them with this tab's older ones. +- A refresh, a terminal rejection, or a second 401 never overwrites or clears an + account another tab signed in, or signs it out. This tab switches to what + storage holds; the pending call rejects with `SESSION_CHANGED`. +- While connected, tabs follow each other live through `storage` events. Signing + out in one tab signs out every tab, so guards redirect to sign-in. Signing in + or switching accounts in one tab switches the others, with a new `sessionId`. + Profile updates appear in every tab. Each of these changes `version` once. A + refresh elsewhere quietly replaces tokens and clears this tab's + `REFRESH_FAILED` state, without changing `version`. Previously other tabs kept + working with the old session until reload. + +- Peer ranges are wider: React and React DOM `^19.0.0` (was `^19.3.0`), and + TanStack React Router `^1.127.0` (was `^1.170.38`). CI tests the oldest + versions in each range. +- The published package no longer declares `engines.node`. Installing it with + `engine-strict` on Node.js 22 or older no longer fails. Node.js 24 is still + required to develop the package, through `devEngines`. + +### Added + +- Tokens accept `expiresIn` (seconds, as in OAuth `expires_in`), counted from + receipt. Passing both `expiresIn` and `expiresAt` is rejected. +- `auth.retry()` tries a failed refresh again. The README's protected layout + example uses it in a Retry button. +- The README shows how to treat every refresh failure as a sign-out by returning + `null` from the refresh callback. +- `createAuthFetch` accepts `{ signOutOnRepeated401: false }`, so an endpoint + that answers 401 for reasons other than a rejected token cannot sign users out. +- `safeReturnTo` accepts `{ loginPath }`, one path or several, for apps whose + sign-in page is not `/login`. + +### Fixed + +- `safeReturnTo` treats `/login/`, `/LOGIN`, and percent-encoded spellings of + the login path as redirect loops. Previously only the exact `/login` was + caught. +- `createAuthFetch` releases the first 401 response body when the refresh + throws, and releases a response that arrives after the session changed. +- A proactive refresh cancelled by unmounting, or by a new sign-in, no longer + counts as that token's one proactive attempt. Previously, after a remount, the + token waited until expiry to refresh. + ## 0.1.0-alpha.1 ### Changed diff --git a/README.md b/README.md index 4b24364..2bc30dc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Schema-driven browser authentication for React and TanStack Router. One auth client lives in Router context. TanStack Store makes its state reactive, with no separate AuthProvider. Tokens and the validated user profile are stored -in localStorage as separate entries. +together in one localStorage entry. ## Installation @@ -19,16 +19,17 @@ pnpm add @monarcode/session-kit@next Pin `@monarcode/session-kit@0.1.0-alpha.0` to use this exact version. Alpha releases may change the public API. See [release notes](./CHANGELOG.md). -The declared peer ranges are React and React DOM `^19.3.0`, and TanStack React -Router `^1.170.38`. Use matching React and React DOM versions. This example uses -Zod for its Standard Schema implementation: +The declared peer ranges are React and React DOM `^19.0.0`, and TanStack React +Router `^1.127.0`. CI checks both the oldest and the newest versions in those +ranges. Use matching React and React DOM versions. This example uses Zod for its +Standard Schema implementation: ```sh pnpm add react@19.3.0 react-dom@19.3.0 @tanstack/react-router@1.170.38 zod@4.4.3 ``` Zod is optional: any compatible Standard Schema V1 implementation can describe -users. The package is ESM-only, declares Node.js `>=24`, and is checked with +users. The package is ESM-only, sets no Node.js engine requirement, and is checked with TypeScript 6.0.3 under NodeNext and Bundler resolution. Older TypeScript versions have not been verified. Browser use requires modern APIs including `structuredClone`, `AbortController`, and `crypto.randomUUID`. @@ -69,13 +70,13 @@ application; the package does not implement a login server: | Endpoint | Request | Successful response | | ------------------------ | -------------------------- | --------------------------------------------------- | -| `POST /api/auth/login` | JSON `{ email, password }` | `{ accessToken, refreshToken?, expiresAt?, user }` | -| `POST /api/auth/refresh` | JSON `{ refreshToken }` | `{ accessToken, refreshToken?, expiresAt?, user? }` | +| `POST /api/auth/login` | JSON `{ email, password }` | `{ accessToken, refreshToken?, expiresIn?, user }` | +| `POST /api/auth/refresh` | JSON `{ refreshToken }` | `{ accessToken, refreshToken?, expiresIn?, user? }` | -`expiresAt` is positive Unix time in **milliseconds**. In this example, a refresh -401 means terminal rejection; other failures are operational. Adapt that mapping -to your backend's contract. If it returns `expiresIn` in seconds, convert it to -`Date.now() + expiresIn * 1000` before passing tokens to auth. +`expiresIn` is the access token lifetime in **seconds**, as in OAuth +`expires_in`. In this example, a refresh 401 means terminal rejection; other +failures are operational. Adapt that mapping to your backend's contract. See +[Refresh and errors](#refresh-and-errors) for other ways to supply expiry. ### 1. Define the user and client @@ -95,7 +96,7 @@ export const userSchema = z.object({ const tokensSchema = z.object({ accessToken: z.string().min(1), refreshToken: z.string().min(1).optional(), - expiresAt: z.number().positive().optional(), + expiresIn: z.number().positive().optional(), }); export const loginResponseSchema = tokensSchema.extend({ user: userSchema }); const refreshResponseSchema = tokensSchema.extend({ @@ -269,6 +270,11 @@ function Login() { } ``` +`safeReturnTo` accepts only same-origin paths and returns `/` for anything else, +including the sign-in page itself, so a stale `redirectTo` cannot loop. It +treats `/login`, `/login/`, and `/LOGIN` alike. If sign-in lives elsewhere, pass +its path, or several: `safeReturnTo(value, { loginPath: ["/sign-in", "/signup"] })`. + ### 4. Add a pathless protected layout The leading underscore makes `_authenticated.tsx` a pathless layout. Its child @@ -279,9 +285,14 @@ routes inherit the guard without adding `authenticated` to the URL. ```tsx -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { + createFileRoute, + Outlet, + redirect, + type ErrorComponentProps, +} from "@tanstack/react-router"; import { AuthError } from "@monarcode/session-kit"; -import { useAuth } from "@monarcode/session-kit/react"; +import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; export const Route = createFileRoute("/_authenticated")({ beforeLoad: async ({ context, location }) => { @@ -303,14 +314,38 @@ export const Route = createFileRoute("/_authenticated")({ authVersion: context.auth.state.get().version, }; }, + errorComponent: SessionError, component: ProtectedLayout, }); +function SessionError({ error }: ErrorComponentProps) { + const auth = useAuthClient(); + const retrying = useAuth((state) => state.status === "refreshing"); + if (!(error instanceof AuthError) || error.code !== "REFRESH_FAILED") { + return

Something went wrong.

; + } + return ( +
+

We couldn't reach the sign-in service.

+ +
+ ); +} + function ProtectedLayout() { const { authVersion } = Route.useRouteContext(); const status = useAuth((state) => state.status); const version = useAuth((state) => state.version); - if (status !== "authenticated" || version !== authVersion) { + const usable = status === "authenticated" || status === "refreshing"; + if (!usable || version !== authVersion) { return

Checking session…

; } return ; @@ -471,7 +506,10 @@ creates a new session ID. A profile update keeps the current session ID. `createAuthFetch` attaches a Bearer token, restricts requests to the configured origin, and rejects redirects. On a 401 it attempts refresh. GET and HEAD are replayed at most once; mutations are never automatically replayed. A second 401 -ends only the matching session. Network or refresh errors reject the request. +ends only the matching session. If some endpoints answer 401 for reasons other +than a rejected token, pass `{ signOutOnRepeated401: false }` as the third +argument so they return the 401 without signing out. Network or refresh errors +reject the request. ## Refresh and errors @@ -482,26 +520,58 @@ ends only the matching session. Network or refresh errors reject the request. | Throw | Operational failure: retain recoverable credentials and expose the error. | A failed proactive refresh leaves access usable until its expiry. Expired or -explicitly rejected access is unavailable; a later `getSession()` can retry with -retained refresh credentials. Guards should let operational errors reach an -error component instead of turning every failure into a login redirect. +explicitly rejected access is `refreshing` while a refresh runs: `user` stays +visible and guards wait for the outcome without re-running. A token-only refresh +does not change `version`. + +If the refresh fails, access becomes `unavailable` and guards run once more. +Until `auth.retry()`, a sign-in, or a sign-out, `getSession()` rethrows that +`REFRESH_FAILED` error without contacting the backend, so the guard re-run cannot +start another refresh. Guards should let the error reach an error component +instead of turning it into a login redirect; the protected layout example offers +a Retry button that calls `auth.retry()`. + +To treat every refresh failure as a sign-out instead, return `null` from the +refresh callback rather than throwing: + +```ts +refresh: createRefreshFn(async ({ refreshToken, signal }) => { + try { + return await requestRefresh(refreshToken, signal); + } catch { + return null; + } +}), +``` + +Expiry comes from the first of these that the tokens provide: + +1. `expiresAt`: Unix time in milliseconds, compared with the browser clock. +2. `expiresIn`: lifetime in seconds, counted from when auth receives the tokens. +3. A JWT's `exp - iat` lifetime, counted from receipt. +4. A JWT's `exp` alone, compared with the browser clock. -Explicit `expiresAt` takes precedence over JWT `exp`. JWT decoding supplies only +Browser clocks can be minutes off. Options 2 and 3 are unaffected; with 1 and 4, +a fast clock can make fresh tokens look expired and block sign-in. Options 2 and +3 assume newly issued tokens. `Session.expiresAt` is always on the browser +clock, and restored sessions keep their saved expiry. JWT decoding supplies only a scheduling hint, without verifying signatures. Opaque tokens without expiry cannot be proactively refreshed. While connected, refresh is scheduled at the earlier of 60 seconds before expiry or halfway through the remaining lifetime. There is one proactive attempt per installed token, with no recurring retry loop. +An attempt cancelled by unmounting or by a new sign-in does not count. Validation and refresh work have a 15-second deadline. -| State status | Meaning | -| ----------------- | ---------------------------------------------------------------------- | -| `initializing` | Restoration has not completed. | -| `authenticated` | Access is locally usable; `user` is available. | -| `unauthenticated` | No session; sign-in is needed. | -| `unavailable` | Restoration failed or current credentials cannot supply usable access. | +| State status | Meaning | +| ----------------- | ----------------------------------------------------------------------- | +| `initializing` | Restoration has not completed. | +| `authenticated` | Access is locally usable; `user` is available. | +| `refreshing` | A refresh is replacing expired or rejected access; `user` is available. | +| `unauthenticated` | No session; sign-in is needed. | +| `unavailable` | Restoration failed or current credentials cannot supply usable access. | `state.error` is an `AuthError` or `null`; user data is hidden outside the -authenticated state. Error codes are `USER_VALIDATION_FAILED`, `INVALID_SESSION`, +`authenticated` and `refreshing` states. Error codes are `USER_VALIDATION_FAILED`, `INVALID_SESSION`, `PERSISTENCE_FAILED`, `REFRESH_FAILED`, `SESSION_CHANGED`, and `UNAUTHENTICATED`. Validation errors can include `issues`; underlying failures can appear in `cause`. Application callbacks can also throw ordinary errors. @@ -517,9 +587,10 @@ the remaining data after storage becomes available. ## Storage and limits -- Tokens use the `:auth:tokens` localStorage entry; the profile uses - `:auth:user`. Matching session/write IDs detect incomplete writes on - restoration. Tokens are never placed in cookies, so the browser does not send +- Tokens and the profile share the `:auth:session` localStorage entry, so + every save is a single write that other tabs see whole. Entries from + 0.1.0-alpha.1 (`:auth:tokens`, `:auth:user`) are + removed, not restored; users sign in once after upgrading. Tokens are never placed in cookies, so the browser does not send them automatically; `createAuthFetch` attaches the access token explicitly. - `maxAge` is in seconds, defaults to 30 days, and renews on successful writes. Expired entries are removed on the next restoration. It does not extend backend @@ -528,8 +599,25 @@ the remaining data after storage becomes available. independently authenticate requests and authorize private operations. Local profile fields and route guards do not establish server authorization. - Sign-out clears local credentials; backend revocation belongs to your app. -- Refresh coordination is limited to one client in one tab. Shared browser storage - does not synchronize live state across tabs or coordinate rotating refresh tokens. +- Tabs coordinate refresh through the `:auth:refresh` Web Lock: one tab at + a time spends the refresh token, and a tab that needs a refresh first uses + tokens another tab already saved. Rotating refresh tokens therefore work with + several tabs open. Profile updates keep tokens another tab refreshed, and a + refresh or rejection never overwrites or clears an account another tab signed + in; this tab switches to whatever storage holds instead. +- Browsers without Web Locks still check storage before refreshing and before + signing out, but two tabs refreshing at the same instant can both spend the + token. A short refresh-token reuse window on the backend covers that case and + a refresh response lost to a timeout. +- While connected through `connectAuth`, tabs update each other live through + `storage` events. A sign-out in one tab signs out the others, a sign-in or + account switch switches them (with a new `sessionId`), and profile updates + appear everywhere. Each of these changes `version` once, so guards rerun. + A refresh elsewhere replaces this tab's tokens without changing `version`, + and clears a refresh failure this tab was showing. An account change cancels + this tab's pending auth work with `SESSION_CHANGED`. Scope private Query data + to `sessionId`, so another tab's account switch cannot show the old account's + data. - Query cache management belongs to your app. Scope private data to the sign-in session ID, and reject late results from an old session. Router revalidation does not clear a separate TanStack Query cache. @@ -551,7 +639,8 @@ files. Do not import internal `dist` paths. ## Development -Use Node.js 24 and pnpm: +Use Node.js 24 or newer and pnpm. `devEngines` enforces the Node.js version for +contributors only; it does not affect installing the package: ```sh pnpm install diff --git a/package.json b/package.json index 5ff05b3..7631dcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/session-kit", - "version": "0.1.0-alpha.1", + "version": "0.1.0-alpha.2", "description": "Schema-driven browser sessions for React and TanStack Router", "keywords": [ "auth", @@ -83,12 +83,16 @@ "zod": "4.4.3" }, "peerDependencies": { - "@tanstack/react-router": "^1.170.38", - "react": "^19.3.0", - "react-dom": "^19.3.0" + "@tanstack/react-router": "^1.127.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" }, - "engines": { - "node": ">=24" + "devEngines": { + "runtime": { + "name": "node", + "version": ">=24", + "onFail": "error" + } }, "packageManager": "pnpm@10.12.1" } diff --git a/scripts/.gitkeep b/scripts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/.gitkeep b/src/core/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/core/client.ts b/src/core/client.ts index bc35b93..59413a9 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -2,30 +2,65 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { Store } from "@tanstack/store"; import { AuthError, authError, sessionChanged } from "./errors.js"; -import { createPersistence } from "./persistence.js"; +import { createPersistence, type StoredSession } from "./persistence.js"; import type { AuthClient, AuthOptions, AuthState, Session, - Tokens, + SignInInput, User, UserInput, } from "./types.js"; import { isRecord, + receiveTokens, runTask, - validateTokens, validateUser, + type SessionTokens, } from "./validation.js"; -type InternalSession = Tokens & { id: string; user: U }; +type InternalSession = SessionTokens & { id: string; user: U }; /** Starts work nobody awaits. Failures are already published to `state.error`. */ function inBackground(work: Promise) { work.catch(() => {}); } +function tokensOf(saved: SessionTokens): SessionTokens { + return { + accessToken: saved.accessToken, + refreshToken: saved.refreshToken, + expiresAt: saved.expiresAt, + }; +} + +/** + * Creates the auth client. Put one in Router context per auth `name` per tab. + * + * Token expiry comes from `expiresAt`, `expiresIn`, or JWT claims; see `Tokens`. + * Prefer `expiresIn` so a wrong browser clock cannot expire tokens early. + * + * @example + * const auth = createAuth({ + * name: "my-app", + * userSchema: z.object({ id: z.string(), email: z.string() }), + * refresh: createRefreshFn(async ({ refreshToken, signal }) => { + * const response = await fetch("/api/auth/refresh", { + * method: "POST", + * signal, + * headers: { "Content-Type": "application/json" }, + * body: JSON.stringify({ refreshToken }), + * }); + * if (response.status === 401) return null; // rejected: sign out + * if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + * const body = await response.json(); + * return { accessToken: body.access_token, expiresIn: body.expires_in }; + * }), + * }); + * + * await auth.signIn({ accessToken, refreshToken, expiresIn: 900, user }); + */ export function createAuth( options: AuthOptions, ): AuthClient, User> { @@ -48,16 +83,40 @@ export function createAuth( let initialized = false; let rejectedToken: string | undefined; let flight: Promise | null> | undefined; + let failure: AuthError | undefined; let mounts = 0; let proactiveTimer: ReturnType | undefined; let expiryTimer: ReturnType | undefined; let refreshAttempted = false; + let unwatch: (() => void) | undefined; const valid = () => session !== null && session.accessToken !== rejectedToken && (session.expiresAt === undefined || session.expiresAt > Date.now()); + /** Whether another tab's saved tokens can be used instead of refreshing. */ + const usable = (tokens: SessionTokens) => + tokens.accessToken !== rejectedToken && + (tokens.expiresAt === undefined || + tokens.expiresAt >= Date.now() + 5_000); + + /** + * Reads what storage holds for session `id`. Every tab shares it, so another + * tab may have refreshed this session, signed out, or signed in again. + * Returns the saved session (possibly newer than memory), `null` when this + * tab's session is no longer the saved one, or `undefined` when storage is + * unreadable and memory is the best remaining source. + */ + function latest(id: string): StoredSession | null | undefined { + try { + const stored = persistence.read(); + return stored?.id === id ? stored : null; + } catch { + return undefined; + } + } + function snapshot(): Session { if (!session) throw new AuthError("UNAUTHENTICATED", "Sign in is required"); @@ -76,12 +135,19 @@ export function createAuth( ? "unauthenticated" : valid() ? "authenticated" - : "unavailable"; - const user = status === "authenticated" ? session!.user : null; + : flight + ? "refreshing" + : "unavailable"; + const user = + status === "authenticated" || status === "refreshing" + ? session!.user + : null; const sessionId = session?.id ?? null; const previous = store.get(); + const guardStatus = (value: AuthState["status"]) => + value === "refreshing" ? "authenticated" : value; const changed = - previous.status !== status || + guardStatus(previous.status) !== guardStatus(status) || previous.user !== user || previous.sessionId !== sessionId; store.setState(() => @@ -111,7 +177,6 @@ export function createAuth( schedule(); return; } - publish(store.get().error); inBackground(getSession()); }, Math.min(remaining, 2_147_483_647), @@ -136,6 +201,8 @@ export function createAuth( } function advanceEpoch() { + // A cancelled refresh never finished, so it does not use up the token's attempt. + if (flight) refreshAttempted = false; epoch++; controller.abort(sessionChanged()); controller = new AbortController(); @@ -149,6 +216,7 @@ export function createAuth( session = null; initialized = true; rejectedToken = undefined; + failure = undefined; publish(error); } @@ -175,12 +243,102 @@ export function createAuth( } throw error; } + install(next); + } + + /** Uses `next` without saving it, because storage already holds it. */ + function install(next: InternalSession) { session = next; initialized = true; + failure = undefined; publish(); schedule(); } + /** + * Another tab signed out or signed in again, so this tab's session is + * obsolete. Restores whatever storage now holds, without writing to it. + * Resolves `null` when storage is empty and rejects with `SESSION_CHANGED` + * when it holds another session. + */ + async function followStorage(id: string): Promise | null> { + await reload(); + const restored = session as InternalSession | null; + if (!restored) return null; + if (restored.id !== id) throw sessionChanged(); + return getSession(); + } + + /** Drops this tab's session and restores what storage holds, without writing. */ + async function reload() { + advanceEpoch(); + session = null; + initialized = false; + rejectedToken = undefined; + failure = undefined; + refreshAttempted = false; + publish(); + await initialize(); + } + + /** + * Applies what another tab saved: a sign-out, a sign-in or account switch, a + * refresh, or a profile update. Reads storage rather than the event, so a + * late or repeated event cannot apply stale data. + */ + async function sync() { + if (!initialized) { + if (initialization) await initialization.then(sync, () => {}); + return; + } + let stored: StoredSession | null; + try { + stored = persistence.read(); + } catch { + return; + } + if (!stored) { + if (session) forget(); + return; + } + if (stored.id !== session?.id) return reload(); + const sameUser = + JSON.stringify(stored.user) === JSON.stringify(session.user); + const sameTokens = + stored.accessToken === session.accessToken && + stored.refreshToken === session.refreshToken && + stored.expiresAt === session.expiresAt; + if (sameUser && sameTokens) return; + const expected = epoch; + const user = sameUser + ? session.user + : await runTask( + () => validateUser(options.userSchema, stored.user), + controller.signal, + ); + const now = latest(stored.id); + if ( + epoch !== expected || + !now || + now.accessToken !== stored.accessToken || + JSON.stringify(now.user) !== JSON.stringify(stored.user) + ) + return; + if (!sameTokens) refreshAttempted = false; + if (!sameUser) profileVersion++; + install({ ...tokensOf(stored), id: stored.id, user }); + } + + /** + * Signs out after the backend rejected session `id`. If another tab has + * already replaced that session in storage, keeps the replacement. + */ + async function endSession(id: string): Promise | null> { + if (latest(id) === null) return followStorage(id); + await signOut(); + return null; + } + async function initialize() { if (initialized) return; if (initialization) return initialization; @@ -198,7 +356,7 @@ export function createAuth( } assertEpoch(expected); initialized = true; - publish(); + if (!session || valid()) publish(); schedule(); } catch (cause) { assertEpoch(expected); @@ -242,11 +400,11 @@ export function createAuth( return promise; } - async function signIn(input: Tokens & { user: I }) { + async function signIn(input: SignInInput) { advanceEpoch(); const expected = epoch; try { - const tokens = validateTokens(input); + const tokens = receiveTokens(input); if (tokens.expiresAt !== undefined && tokens.expiresAt <= Date.now()) { throw new AuthError( "INVALID_SESSION", @@ -279,8 +437,8 @@ export function createAuth( if (epoch === expected) { schedule(); if (session && !valid()) { - publish(store.get().error); if (mounts) inBackground(getSession()); + else publish(store.get().error); } } } @@ -322,8 +480,15 @@ export function createAuth( if (intent !== profileIntent) throw sessionChanged(); if (!session || session.id !== current.id || !valid()) throw sessionChanged(); + const stored = latest(session.id); + if (stored === null) { + await followStorage(session.id); + throw sessionChanged(); + } + if (stored && stored.accessToken !== session.accessToken) + refreshAttempted = false; profileVersion++; - commit({ ...session, user }); + commit({ ...session, ...(stored && tokensOf(stored)), user }); } async function refreshSession( @@ -333,78 +498,114 @@ export function createAuth( if (!session) return null; if (session.id !== captured.id) throw sessionChanged(); if (session.accessToken !== captured.accessToken) return getSession(); - if (rejected) { - rejectedToken = captured.accessToken; - publish(); - } - if (flight) return flight; - if (!options.refresh || !session.refreshToken) { - await signOut(); - return null; + if (rejected) rejectedToken = captured.accessToken; + if (flight) { + if (!valid()) publish(store.get().error); + return flight; } + if (!options.refresh || !session.refreshToken) + return endSession(session.id); const expected = epoch; const profile = profileVersion; const current = session; const refreshHandler = options.refresh; - const refreshToken = current.refreshToken!; + const lifecycle = controller.signal; refreshAttempted = true; + failure = undefined; const promise = (async () => { try { - const result = await runTask(async (signal) => { - const value = await refreshHandler({ - refreshToken, - signal, - }); - if (value === null) return null; - const tokens = validateTokens(value); + const outcome = await persistence.exclusive(lifecycle, async () => { + assertEpoch(expected); + const stored = latest(current.id); + if (stored === null) return "stale" as const; + const savedUser = (saved: StoredSession) => + JSON.stringify(saved.user) === JSON.stringify(current.user) + ? current.user + : validateUser(options.userSchema, saved.user); + const result = await runTask(async (signal) => { + if ( + stored && + stored.accessToken !== current.accessToken && + usable(stored) + ) { + return { + tokens: tokensOf(stored), + user: await savedUser(stored), + adopted: true, + }; + } + const refreshToken = + stored?.refreshToken ?? current.refreshToken!; + const value = await refreshHandler({ + refreshToken, + signal, + }); + if (value === null) return null; + const tokens = receiveTokens(value); + if ( + tokens.expiresAt !== undefined && + tokens.expiresAt < Date.now() + 5_000 + ) { + throw new AuthError( + "INVALID_SESSION", + "Refreshed access token must last at least five seconds", + ); + } + const user = + isRecord(value) && value.user !== undefined + ? await validateUser(options.userSchema, value.user) + : stored + ? await savedUser(stored) + : undefined; + return { + tokens: { + ...tokens, + refreshToken: tokens.refreshToken ?? refreshToken, + }, + user, + adopted: false, + }; + }, lifecycle); + assertEpoch(expected); + if (result === null) { + const after = latest(current.id); + if ( + after === null || + (after && + after.accessToken !== (stored ?? current).accessToken) + ) + return "stale" as const; + await signOut(); + return null; + } if ( - tokens.expiresAt !== undefined && - tokens.expiresAt < Date.now() + 5_000 + result.tokens.expiresAt !== undefined && + result.tokens.expiresAt <= Date.now() ) { throw new AuthError( "INVALID_SESSION", - "Refreshed access token must last at least five seconds", + "Refreshed token expired during validation", + ); + } + if (result.tokens.accessToken === rejectedToken) { + throw new AuthError( + "INVALID_SESSION", + "Refresh returned the rejected access token", ); } const user = - isRecord(value) && value.user !== undefined - ? await validateUser(options.userSchema, value.user) - : undefined; - return { tokens, user }; - }, controller.signal); - assertEpoch(expected); - if (result === null) { - await signOut(); - return null; - } - if ( - result.tokens.expiresAt !== undefined && - result.tokens.expiresAt <= Date.now() - ) { - throw new AuthError( - "INVALID_SESSION", - "Refreshed token expired during validation", - ); - } - if (result.tokens.accessToken === rejectedToken) { - throw new AuthError( - "INVALID_SESSION", - "Refresh returned the rejected access token", - ); - } - const user = - profile === profileVersion - ? (result.user ?? session!.user) - : session!.user; - rejectedToken = undefined; - refreshAttempted = false; - commit({ - ...result.tokens, - id: current.id, - user, - refreshToken: result.tokens.refreshToken ?? current.refreshToken, + profile === profileVersion + ? (result.user ?? session!.user) + : session!.user; + rejectedToken = undefined; + refreshAttempted = false; + if (latest(current.id) === null) return "stale" as const; + const next = { ...result.tokens, id: current.id, user }; + if (result.adopted && profile === profileVersion) install(next); + else commit(next); + return snapshot(); }); - return snapshot(); + return outcome === "stale" ? followStorage(current.id) : outcome; } catch (cause) { if (epoch !== expected) { if ( @@ -414,11 +615,13 @@ export function createAuth( throw cause; throw sessionChanged(); } + flight = undefined; const error = authError( "REFRESH_FAILED", "Could not refresh the session", cause, ); + if (!valid()) failure = error; publish(error); schedule(); throw error; @@ -429,21 +632,27 @@ export function createAuth( if (flight === promise) flight = undefined; }; void promise.then(clearFlight, clearFlight); + if (!valid()) publish(store.get().error); return promise; } - async function getSession(): Promise | null> { + async function resume(retry: boolean): Promise | null> { const expected = epoch; await initialize(); if (epoch !== expected && session) throw sessionChanged(); if (!session) return null; if (valid()) return snapshot(); - publish(store.get().error); + if (failure && !retry) throw failure; return refreshSession(snapshot(), false); } + function getSession() { + return resume(false); + } + function mount() { - mounts++; + if (mounts++ === 0) + unwatch = persistence.watch(() => inBackground(sync())); inBackground(getSession()); schedule(); let active = true; @@ -451,8 +660,11 @@ export function createAuth( if (!active) return; active = false; if (--mounts === 0) { + unwatch?.(); + unwatch = undefined; stopTimers(); advanceEpoch(); + if (session && !valid()) publish(store.get().error); } }; } @@ -463,6 +675,7 @@ export function createAuth( signOut, updateUser, getSession, + retry: () => resume(true), mount, refresh: (captured) => refreshSession(captured, true), isCurrent: (captured) => session?.id === captured.id, @@ -471,7 +684,7 @@ export function createAuth( session?.id === captured.id && session.accessToken === captured.accessToken ) - await signOut(); + await endSession(captured.id); }, }; } diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 590142e..2eee88c 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -1,8 +1,7 @@ import { AuthError } from "./errors.js"; -import type { Tokens } from "./types.js"; -import { isRecord, validateTokens } from "./validation.js"; +import { isRecord, validateTokens, type SessionTokens } from "./validation.js"; -export type StoredSession = Tokens & { id: string; user: unknown }; +export type StoredSession = SessionTokens & { id: string; user: unknown }; export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { if ( @@ -14,8 +13,9 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { "Use a simple auth name and a positive maxAge in seconds", ); } - const tokensKey = `${name}:auth:tokens`; - const userKey = `${name}:auth:user`; + const key = `${name}:auth:session`; + const legacyKeys = [`${name}:auth:tokens`, `${name}:auth:user`]; + const lockName = `${name}:auth:refresh`; function storageOperation(work: () => T): T { try { @@ -35,9 +35,9 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { function clear() { storageOperation(() => { const failures: unknown[] = []; - for (const key of [tokensKey, userKey]) { + for (const entry of [key, ...legacyKeys]) { try { - localStorage.removeItem(key); + localStorage.removeItem(entry); } catch (error) { failures.push(error); } @@ -49,37 +49,29 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { function read(): StoredSession | null { return storageOperation(() => { - const tokensText = localStorage.getItem(tokensKey); - if (!tokensText) { - localStorage.removeItem(userKey); + const text = localStorage.getItem(key); + if (!text) { + for (const entry of legacyKeys) localStorage.removeItem(entry); return null; } - const userText = localStorage.getItem(userKey); try { - const tokens: unknown = JSON.parse(tokensText); - const profile: unknown = userText ? JSON.parse(userText) : null; + const saved: unknown = JSON.parse(text); if ( - !isRecord(tokens) || - !isRecord(profile) || - tokens.v !== 2 || - profile.v !== 2 || - typeof tokens.id !== "string" || - !tokens.id || - typeof tokens.writeId !== "string" || - tokens.id !== profile.id || - tokens.writeId !== profile.writeId || - typeof tokens.persistUntil !== "number" || - !Number.isFinite(tokens.persistUntil) || - tokens.persistUntil <= Date.now() + !isRecord(saved) || + saved.v !== 3 || + typeof saved.id !== "string" || + !saved.id || + !("user" in saved) || + typeof saved.persistUntil !== "number" || + !Number.isFinite(saved.persistUntil) || + saved.persistUntil <= Date.now() ) { - throw new Error("Missing, expired, or mismatched auth data"); + throw new Error("Missing, expired, or malformed auth data"); } - if (localStorage.getItem(tokensKey) !== tokensText) - throw new Error("Auth storage changed while reading"); return { - ...validateTokens(tokens), - id: tokens.id, - user: profile.user, + ...validateTokens(saved), + id: saved.id, + user: saved.user, }; } catch (cause) { throw new AuthError( @@ -93,22 +85,46 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { function write(session: StoredSession) { storageOperation(() => { - const { user, ...tokens } = session; - const writeId = crypto.randomUUID(); localStorage.setItem( - userKey, - JSON.stringify({ v: 2, id: session.id, writeId, user }), - ); - localStorage.setItem( - tokensKey, + key, JSON.stringify({ - v: 2, - ...tokens, - writeId, + v: 3, + ...session, persistUntil: Date.now() + maxAge * 1000, }), ); }); } - return { read, write, clear }; + + /** + * Runs `work` while holding this auth name's lock, shared by every tab, so + * only one tab at a time spends a refresh token. Without Web Locks it runs + * directly. Aborting `signal` stops waiting for the lock. Once granted, the + * lock is held until `work` settles, so `work` must bound its own duration. + */ + function exclusive( + signal: AbortSignal, + work: () => Promise, + ): Promise { + const locks = globalThis.navigator?.locks; + if (!locks) return work(); + return locks.request(lockName, { signal }, work); + } + + /** + * Calls `listener` when another tab saves or clears the session. Browsers + * fire `storage` events only in other tabs, never in the tab that wrote. + * Returns a function that stops listening. + */ + function watch(listener: () => void): () => void { + if (typeof globalThis.addEventListener !== "function") return () => {}; + const onStorage = (event: Event) => { + const changed = (event as StorageEvent).key; + if (changed === key || changed === null) listener(); + }; + globalThis.addEventListener("storage", onStorage); + return () => globalThis.removeEventListener("storage", onStorage); + } + + return { read, write, clear, exclusive, watch }; } diff --git a/src/core/types.ts b/src/core/types.ts index bb14f73..7144578 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,68 +3,181 @@ import type { Store } from "@tanstack/store"; import type { AuthError } from "./errors.js"; +/** What the user schema accepts: the shape passed to `signIn` and `updateUser`. */ export type UserInput = StandardSchemaV1.InferInput; +/** What the user schema produces: the shape exposed in `state.user` and `Session.user`. */ export type User = StandardSchemaV1.InferOutput; +/** + * Tokens from your backend, passed to `signIn` or returned by the refresh callback. + * + * Expiry comes from the first of these that is present: + * 1. `expiresAt`: an absolute deadline, compared with the browser clock. + * 2. `expiresIn`: a lifetime, counted from when auth receives the tokens. + * 3. A JWT access token's `exp - iat` lifetime, counted from receipt. + * 4. A JWT access token's `exp` alone, compared with the browser clock. + * + * Prefer `expiresIn` (or a JWT with `iat`): browser clocks can be minutes off, and + * a fast clock makes an absolute deadline look already passed, rejecting sign-in. + * With none of these, the token never expires locally and is only replaced + * after a 401. Passing both `expiresIn` and `expiresAt` is rejected. + */ export type Tokens = { + /** Sent as `Authorization: Bearer ` by `createAuthFetch`. */ accessToken: string; + /** + * Passed to the refresh callback. Without it, an expired or rejected access + * token signs the user out. When refresh omits it, the current one is kept. + */ refreshToken?: string; - /** Unix time in milliseconds. Falls back to JWT exp when omitted. */ + /** + * Access token lifetime in **seconds**, as in OAuth `expires_in`. Counted from + * receipt, so a wrong browser clock cannot expire the token early or late. + * @example 900 // 15 minutes + */ + expiresIn?: number; + /** + * Absolute expiry as Unix time in **milliseconds**, compared with the browser + * clock. Use only when the backend gives no lifetime; prefer `expiresIn`. + * @example Date.parse("2026-10-02T12:15:00Z") + */ expiresAt?: number; }; -export type SignInInput = Tokens & { user: I }; +/** Input to `signIn`: tokens plus the user, validated by `userSchema`. */ +export type SignInInput = Tokens & { + /** Validated, cloned, and frozen. Must be a plain JSON object. */ + user: I; +}; -export type RefreshResult = (Tokens & { user?: I }) | null; +/** + * What the refresh callback returns: + * - Tokens: install them. They must last at least five seconds. An omitted + * `refreshToken` or `user` keeps the current value. + * - `null`: the backend rejected the refresh token. Sign out and clear storage. + * + * Throw instead for operational failures (network errors, 5xx). Credentials are + * kept and the error is exposed in `state.error`. + */ +export type RefreshResult = + | (Tokens & { + /** Replaces the profile unless `updateUser` ran during the refresh. */ + user?: I; + }) + | null; +/** Exchanges a refresh token for new tokens. See `RefreshResult`. */ export type RefreshFn = (context: { + /** The current session's refresh token. */ refreshToken: string; + /** Aborted when the session changes or after the 15-second deadline. */ signal: AbortSignal; }) => Promise>; +/** Types a refresh callback against a user schema before passing it to `createAuth`. */ export function createRefreshFn( handler: RefreshFn, ): RefreshFn { return handler; } +/** A frozen snapshot of the current credentials, from `getSession()`. */ export type Session = Readonly<{ + /** Stable across refreshes and profile updates; new on every sign-in. */ id: string; accessToken: string; + /** + * Unix time in milliseconds on **this browser's clock**, normalized from + * `expiresIn`, `expiresAt`, or JWT claims. Safe to compare with `Date.now()`. + */ expiresAt?: number; + /** The validated user, as produced by `userSchema`. */ user: U; }>; /** Reactive auth snapshot. Tokens are intentionally excluded. */ export type AuthState = Readonly<{ - status: "initializing" | "authenticated" | "unauthenticated" | "unavailable"; + /** + * - `initializing`: restoring the saved session has not finished. + * - `authenticated`: access is locally usable; `user` is set. + * - `refreshing`: a refresh is replacing expired or rejected access; `user` stays visible. + * - `unauthenticated`: no session; sign-in is needed. + * - `unavailable`: restoration failed, or the credentials cannot supply usable access. + */ + status: + | "initializing" + | "authenticated" + | "refreshing" + | "unauthenticated" + | "unavailable"; + /** The validated user while `authenticated` or `refreshing`; otherwise `null`. */ user: U | null; + /** The current `Session.id`, or `null` when signed out. */ sessionId: string | null; /** Changes when guards must run again. */ version: number; + /** The latest failure, cleared by the next successful change. */ error: AuthError | null; }>; export type AuthClient = { + /** Reactive `AuthState`. Read it in React with `useAuth`. */ state: Pick>, "get" | "subscribe">; + /** + * Validates and saves a new session, replacing any current one. The latest + * call wins. Rejects expired tokens; invalid input keeps the previous session. + */ signIn: (input: SignInInput) => Promise; + /** + * Ends the session in this tab and clears storage. Rejects with + * `PERSISTENCE_FAILED` if storage cannot be cleared; call it again later. + */ signOut: () => Promise; + /** + * Replaces the user and keeps the session ID. Accepts the user or an async + * function returning it. If calls overlap, only the latest commits. + */ updateUser: (input: I | (() => Promise)) => Promise; + /** + * Resolves the current session, restoring or refreshing it if needed, or + * `null` when signed out. After a failed refresh, rethrows that failure until + * `retry()`, sign-in, or sign-out. + */ getSession: () => Promise | null>; - /** Recover from rejection of this particular access token. */ + /** Like `getSession()`, but tries a failed refresh again. */ + retry: () => Promise | null>; + /** + * Recovers after the server rejected this session's access token, e.g. a 401. + * Concurrent calls share one refresh. Without a refresh callback or refresh + * token, signs out and resolves `null`. + */ refresh: (session: Session) => Promise | null>; - /** End only the session that produced this rejected token. */ + /** Signs out only if this session and access token are still current. */ rejectSession: (session: Session) => Promise; + /** Whether this snapshot belongs to the current session (same `id`). */ isCurrent: (session: Session) => boolean; /** Called by the Router connector. Cleanup stops background timers. */ mount: () => () => void; }; export type AuthOptions = { + /** + * Storage namespace: letters, digits, `_` or `-`, up to 64 characters. Uses + * the localStorage key `:auth:session` and the Web Lock + * `:auth:refresh`. + */ name: string; + /** + * Standard Schema V1 schema for the user. Its output must be a plain JSON + * object and must validate again when restored from storage. + */ userSchema: S; + /** + * Exchanges a refresh token for new tokens, at expiry or after a 401. Without + * it, expired or rejected access signs the user out. See `RefreshFn`. + */ refresh?: RefreshFn>>; /** Saved-session lifetime in seconds, renewed on successful writes. Default: 30 days. */ maxAge?: number; diff --git a/src/core/validation.ts b/src/core/validation.ts index b21f8ab..e9067b5 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -91,30 +91,42 @@ export async function validateUser( } } -function jwtExpiry(token: string): number | undefined { +/** Session tokens with `expiresAt` measured on this browser's clock. */ +export type SessionTokens = Omit; + +function jwtClaims(token: string): { exp?: number; iat?: number } { try { const parts = token.split("."); - if (parts.length !== 3) return undefined; + if (parts.length !== 3) return {}; const encoded = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const bytes = Uint8Array.from( atob(encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=")), (c) => c.charCodeAt(0), ); const payload: unknown = JSON.parse(new TextDecoder().decode(bytes)); - if ( - isRecord(payload) && - typeof payload.exp === "number" && - Number.isFinite(payload.exp * 1000) - ) { - return payload.exp * 1000; - } - return undefined; + if (!isRecord(payload)) return {}; + const seconds = (value: unknown) => + typeof value === "number" && Number.isFinite(value * 1000) + ? value + : undefined; + return { exp: seconds(payload.exp), iat: seconds(payload.iat) }; } catch { - return undefined; + return {}; } } -export function validateTokens(value: unknown): Tokens { +function positive(value: unknown, message: string): number | undefined { + if ( + value !== undefined && + (typeof value !== "number" || !Number.isFinite(value) || value <= 0) + ) { + throw new AuthError("INVALID_SESSION", message); + } + return value; +} + +/** Checks token shape and an explicit `expiresAt`. Restores saved sessions as-is. */ +export function validateTokens(value: unknown): SessionTokens { if ( !isRecord(value) || typeof value.accessToken !== "string" || @@ -134,21 +146,44 @@ export function validateTokens(value: unknown): Tokens { "Refresh token must be a nonempty string", ); } - if ( - value.expiresAt !== undefined && - (typeof value.expiresAt !== "number" || - !Number.isFinite(value.expiresAt) || - value.expiresAt <= 0) - ) { + return { + accessToken: value.accessToken, + refreshToken: value.refreshToken, + expiresAt: positive( + value.expiresAt, + "expiresAt must be positive Unix milliseconds", + ), + }; +} + +/** + * Accepts newly issued tokens. Lifetimes are measured from receipt so a skewed + * browser clock cannot expire them early: `expiresIn`, then JWT `exp - iat`. + * Only explicit `expiresAt` and JWT `exp` without `iat` use absolute time. + */ +export function receiveTokens(value: unknown): SessionTokens { + const tokens = validateTokens(value); + const expiresIn = positive( + isRecord(value) ? value.expiresIn : undefined, + "expiresIn must be positive seconds", + ); + if (expiresIn !== undefined && tokens.expiresAt !== undefined) { throw new AuthError( "INVALID_SESSION", - "expiresAt must be positive Unix milliseconds", + "Provide expiresAt or expiresIn, not both", ); } + if (tokens.expiresAt !== undefined) return tokens; + if (expiresIn !== undefined) + return { ...tokens, expiresAt: Date.now() + expiresIn * 1000 }; + const { exp, iat } = jwtClaims(tokens.accessToken); + if (exp === undefined) return tokens; return { - accessToken: value.accessToken, - refreshToken: value.refreshToken, - expiresAt: value.expiresAt ?? jwtExpiry(value.accessToken), + ...tokens, + expiresAt: + iat !== undefined && exp > iat + ? Date.now() + (exp - iat) * 1000 + : exp * 1000, }; } diff --git a/src/http/.gitkeep b/src/http/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/http/index.ts b/src/http/index.ts index 732c7f1..31939c3 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,8 +1,27 @@ import { AuthError, sessionChanged } from "../core/errors.js"; import type { AuthClient, Session } from "../core/types.js"; -export function createAuthFetch(auth: AuthClient, baseUrl: string) { +export type AuthFetchOptions = { + /** + * Whether a 401 on the replayed request, after a successful refresh, ends the + * session. Default: `true`. Set `false` when some endpoints answer 401 for + * reasons other than a rejected token, so one of them cannot sign users out. + */ + signOutOnRepeated401?: boolean; +}; + +/** Releases a response the caller will never see. */ +async function discard(response: Response) { + await response.body?.cancel().catch(() => {}); +} + +export function createAuthFetch( + auth: AuthClient, + baseUrl: string, + options: AuthFetchOptions = {}, +) { const base = new URL(baseUrl); + const signOutOnRepeated401 = options.signOutOnRepeated401 ?? true; return async function authFetch( path: string, init: RequestInit = {}, @@ -27,7 +46,10 @@ export function createAuthFetch(auth: AuthClient, baseUrl: string) { const response = await fetch( new Request(request, { headers, redirect: "error" }), ); - if (!auth.isCurrent(session)) throw sessionChanged(); + if (!auth.isCurrent(session)) { + await discard(response); + throw sessionChanged(); + } return response; }; @@ -35,15 +57,22 @@ export function createAuthFetch(auth: AuthClient, baseUrl: string) { if (response.status !== 401) return response; - const fresh = await auth.refresh(captured); + let fresh: Session | null; + try { + fresh = await auth.refresh(captured); + } catch (error) { + await discard(response); + throw error; + } if (!fresh) return response; if (!canReplay) return response; - await response.body?.cancel(); + await discard(response); const retried = await send(fresh); - if (retried.status === 401) await auth.rejectSession(fresh); + if (retried.status === 401 && signOutOnRepeated401) + await auth.rejectSession(fresh); return retried; }; diff --git a/src/react/.gitkeep b/src/react/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/react/connect.ts b/src/react/connect.ts index 9fd675f..736c48b 100644 --- a/src/react/connect.ts +++ b/src/react/connect.ts @@ -40,18 +40,45 @@ export function connectAuth(router: AuthRouter): () => void { return disconnect; } -export function safeReturnTo(value: unknown): string { +export type SafeReturnToOptions = { + /** + * Sign-in paths that must not be returned to, to avoid redirect loops. + * Matched ignoring case, percent-encoding, and trailing slashes. + * Default: `"/login"`. + */ + loginPath?: string | readonly string[]; +}; + +/** Normalizes a pathname the way Router matching treats it by default. */ +function routeKey(pathname: string): string { + return decodeURIComponent(pathname).toLowerCase().replace(/\/+$/, "") || "/"; +} + +export function safeReturnTo( + value: unknown, + options: SafeReturnToOptions = {}, +): string { + const loginPaths = [options.loginPath ?? "/login"].flat(); + for (const path of loginPaths) { + if (!path.startsWith("/")) + throw new Error(`loginPath must start with "/": ${path}`); + } if ( typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || - /[\\\u0000-\u0020\u007f]/.test(value) + /[\\\u0000- \u007f]/.test(value) ) return "/"; const base = "https://auth.invalid"; try { const url = new URL(value, base); - if (url.origin !== base || url.pathname === "/login") return "/"; + const key = routeKey(url.pathname); + if ( + url.origin !== base || + loginPaths.some((path) => routeKey(path) === key) + ) + return "/"; return url.pathname + url.search + url.hash; } catch { return "/"; diff --git a/src/react/index.ts b/src/react/index.ts index a884286..50d438d 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,4 +1,5 @@ export { connectAuth, safeReturnTo } from "./connect.js"; export { useAuth, useAuthClient } from "./hooks.js"; +export type { SafeReturnToOptions } from "./connect.js"; export type { RegisteredAuth, RegisteredAuthState } from "./hooks.js"; diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 149cdb5..dadeb60 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -13,8 +13,7 @@ const userSchema = z.object({ }); const user = { id: "alice", email: "alice@example.com", role: "member" }; let cookies, values, blockedStorage, blockedWrites, blockedDeletes, cleanups; -const TOKENS = "test:auth:tokens", - PROFILE = "test:auth:user"; +const SESSION = "test:auth:session"; let originalGlobals; const browserGlobals = [ "location", @@ -24,6 +23,9 @@ const browserGlobals = [ "self", "scrollTo", "window", + "navigator", + "addEventListener", + "removeEventListener", ]; const setGlobal = (name, value) => Object.defineProperty(globalThis, name, { @@ -51,6 +53,28 @@ const input = (overrides = {}) => ({ const client = (options = {}) => createAuth({ name: "test", userSchema, ...options }); const code = (expected) => (error) => error.code === expected; +const createLocks = () => { + const queues = new Map(); + const aborted = (signal) => + new Promise((_, reject) => { + if (signal?.aborted) reject(signal.reason); + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + return { + request(name, { signal } = {}, callback) { + const previous = queues.get(name) ?? Promise.resolve(); + const run = (async () => { + await Promise.race([previous, aborted(signal)]); + return callback({ name }); + })(); + queues.set(name, Promise.allSettled([previous, run])); + return run; + }, + }; +}; +let locks, storageEvents; beforeEach(() => { cookies = new Map(); @@ -59,6 +83,8 @@ beforeEach(() => { blockedWrites = new Set(); blockedDeletes = new Set(); cleanups = []; + locks = createLocks(); + storageEvents = new EventTarget(); originalGlobals = new Map( browserGlobals.map((name) => [ name, @@ -66,6 +92,13 @@ beforeEach(() => { ]), ); setGlobal("location", { protocol: "https:" }); + setGlobal("navigator", { locks }); + setGlobal("addEventListener", (...args) => + storageEvents.addEventListener(...args), + ); + setGlobal("removeEventListener", (...args) => + storageEvents.removeEventListener(...args), + ); setGlobal("document", { get cookie() { return [...cookies].map(([k, v]) => `${k}=${v}`).join("; "); @@ -110,8 +143,7 @@ test("schema defaults and custom fields survive restoration; tokens stay out of const auth = client(); await auth.signIn(input({ user: { id: "alice", email: user.email } })); assert.equal(auth.state.get().user.role, "member"); - assert.equal(values.get(PROFILE).includes("access-1"), false); - assert.equal(JSON.parse(values.get(TOKENS)).refreshToken, "refresh-1"); + assert.equal(JSON.parse(values.get(SESSION)).refreshToken, "refresh-1"); assert.equal(cookies.size, 0); assert.equal(JSON.stringify(auth.state.get()).includes("refresh-1"), false); const restored = await client().getSession(); @@ -181,8 +213,8 @@ for (const timeout of [false, true]) { await flush(); if (canRefresh) { assert.equal(refreshCalls, 1); - assert.equal(auth.state.get().status, "unavailable"); - assert.equal(auth.state.get().user, null); + assert.equal(auth.state.get().status, "refreshing"); + assert.equal(auth.state.get().user.id, "alice"); refresh.resolve({ accessToken: "access-2", expiresAt: Date.now() + 60_000, @@ -260,7 +292,7 @@ test("failed sign-in recovery retains expired credentials without a refresh retr assert.equal(refreshCalls, 1); assert.equal(auth.state.get().status, "unavailable"); assert.equal(auth.state.get().error.code, "REFRESH_FAILED"); - assert.ok(values.has(TOKENS)); + assert.ok(values.has(SESSION)); t.mock.timers.tick(60_000); await flush(); assert.equal(refreshCalls, 1); @@ -290,7 +322,7 @@ test("failed sign-in hides an expired unmounted session until demand retries ref assert.equal(auth.state.get().status, "unavailable"); assert.equal(auth.state.get().user, null); assert.equal(refreshCalls, 0); - assert.ok(values.has(TOKENS)); + assert.ok(values.has(SESSION)); assert.equal((await auth.getSession()).accessToken, "access-2"); assert.equal(refreshCalls, 1); }); @@ -321,62 +353,66 @@ test("a superseded sign-in cannot recover or clear the newer account", async (t) assert.equal((await auth.getSession()).accessToken, "carol-token"); }); -test("token/profile mismatch fails closed and removes saved data", async () => { +test("malformed saved session fails closed and removes saved data", async () => { await client().signIn(input()); - const saved = JSON.parse(values.get(PROFILE)); - values.set(PROFILE, JSON.stringify({ ...saved, writeId: "wrong" })); + const saved = JSON.parse(values.get(SESSION)); + values.set(SESSION, JSON.stringify({ ...saved, id: "" })); + assert.equal(await client().getSession(), null); + assert.equal(values.size, 0); +}); + +test("entries from the two-key format are removed, not restored", async () => { + values.set("test:auth:tokens", JSON.stringify({ v: 2, accessToken: "old" })); + values.set("test:auth:user", JSON.stringify({ v: 2, user })); assert.equal(await client().getSession(), null); assert.equal(values.size, 0); }); test("malformed stored user fails schema validation", async () => { await client().signIn(input()); - const saved = JSON.parse(values.get(PROFILE)); - values.set(PROFILE, JSON.stringify({ ...saved, user: { id: 5 } })); + const saved = JSON.parse(values.get(SESSION)); + values.set(SESSION, JSON.stringify({ ...saved, user: { id: 5 } })); assert.equal(await client().getSession(), null); }); for (const invalidUser of [false, true]) { - for (const blockedTarget of ["tokens", "profile"]) { - test(`restoring ${invalidUser ? "an invalid user" : "a malformed session"} exposes blocked ${blockedTarget} cleanup and permits retry`, async () => { - await client().signIn(input()); - if (invalidUser) { - const saved = JSON.parse(values.get(PROFILE)); - values.set(PROFILE, JSON.stringify({ ...saved, user: { id: 5 } })); - } else { - values.set(TOKENS, "invalid-json"); - } - blockedDeletes.add(blockedTarget === "tokens" ? TOKENS : PROFILE); - const auth = client(); - let failure; - await assert.rejects(auth.getSession(), (error) => { - failure = error; - return error.code === "PERSISTENCE_FAILED"; - }); - assert.equal(auth.state.get().error, failure); - assert.equal(auth.state.get().status, "unauthenticated"); - assert.equal(auth.state.get().user, null); - assert.equal(auth.state.get().sessionId, null); - assert.ok(failure.cause instanceof AggregateError); - const [restoration, cleanup] = failure.cause.errors; - assert.equal( - restoration.code, - invalidUser ? "USER_VALIDATION_FAILED" : "INVALID_SESSION", - ); - if (invalidUser) assert.ok(restoration.issues.length > 0); - else assert.ok(restoration.cause instanceof Error); - assert.equal(cleanup.code, "PERSISTENCE_FAILED"); - assert.ok(cleanup.cause instanceof AggregateError); - assert.equal(values.has(TOKENS), blockedTarget === "tokens"); - assert.equal(values.has(PROFILE), blockedTarget === "profile"); - assert.equal(await auth.getSession(), null); - assert.equal(auth.state.get().error, failure); - blockedDeletes.clear(); - await auth.signOut(); - assert.equal(auth.state.get().error, null); - assert.equal(values.size, 0); + test(`restoring ${invalidUser ? "an invalid user" : "a malformed session"} exposes blocked cleanup and permits retry`, async () => { + await client().signIn(input()); + if (invalidUser) { + const saved = JSON.parse(values.get(SESSION)); + values.set(SESSION, JSON.stringify({ ...saved, user: { id: 5 } })); + } else { + values.set(SESSION, "invalid-json"); + } + blockedDeletes.add(SESSION); + const auth = client(); + let failure; + await assert.rejects(auth.getSession(), (error) => { + failure = error; + return error.code === "PERSISTENCE_FAILED"; }); - } + assert.equal(auth.state.get().error, failure); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(auth.state.get().user, null); + assert.equal(auth.state.get().sessionId, null); + assert.ok(failure.cause instanceof AggregateError); + const [restoration, cleanup] = failure.cause.errors; + assert.equal( + restoration.code, + invalidUser ? "USER_VALIDATION_FAILED" : "INVALID_SESSION", + ); + if (invalidUser) assert.ok(restoration.issues.length > 0); + else assert.ok(restoration.cause instanceof Error); + assert.equal(cleanup.code, "PERSISTENCE_FAILED"); + assert.ok(cleanup.cause instanceof AggregateError); + assert.ok(values.has(SESSION)); + assert.equal(await auth.getSession(), null); + assert.equal(auth.state.get().error, failure); + blockedDeletes.clear(); + await auth.signOut(); + assert.equal(auth.state.get().error, null); + assert.equal(values.size, 0); + }); } test("storage read failure is operational and can be retried", async () => { @@ -391,7 +427,7 @@ test("storage read failure is operational and can be retried", async () => { test("token write failure clears runtime state and fails visibly", async () => { const auth = client(); - blockedWrites.add(TOKENS); + blockedWrites.add(SESSION); await assert.rejects(auth.signIn(input()), code("PERSISTENCE_FAILED")); assert.equal(auth.state.get().status, "unauthenticated"); assert.equal(values.size, 0); @@ -400,7 +436,7 @@ test("token write failure clears runtime state and fails visibly", async () => { test("signout stays signed out when token deletion fails", async () => { const auth = client(); await auth.signIn(input()); - blockedDeletes.add(TOKENS); + blockedDeletes.add(SESSION); await assert.rejects(auth.signOut(), code("PERSISTENCE_FAILED")); assert.equal(await auth.getSession(), null); assert.equal(auth.state.get().user, null); @@ -424,7 +460,7 @@ test("concurrent refresh requests share one backend operation", async () => { gate.resolve({ accessToken: "access-2" }); assert.equal((await a).accessToken, "access-2"); assert.equal((await b).accessToken, "access-2"); - assert.equal(JSON.parse(values.get(TOKENS)).refreshToken, "refresh-1"); + assert.equal(JSON.parse(values.get(SESSION)).refreshToken, "refresh-1"); }); test("late refresh success cannot restore a signed-out session", async () => { @@ -510,7 +546,7 @@ test("refresh null ends the session; thrown network errors retain credentials", code("REFRESH_FAILED"), ); assert.equal(retry.state.get().status, "unavailable"); - assert.ok(values.has(TOKENS)); + assert.ok(values.has(SESSION)); }); test("a rejected access token cannot be returned as fresh", async () => { @@ -598,6 +634,33 @@ test("proactive failure keeps unexpired access but expiry hides the user", async t.mock.timers.reset(); }); +test("a proactive refresh cancelled by unmount is attempted again after remount", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + let calls = 0; + const auth = client({ + refresh: async () => { + calls++; + return new Promise(() => {}); + }, + }); + await auth.signIn(input({ expiresAt: 1_020_000 })); + const unmount = auth.mount(); + await flush(); + t.mock.timers.tick(10_000); + await flush(); + assert.equal(calls, 1); + unmount(); + await flush(); + cleanups.push(auth.mount()); + await flush(); + t.mock.timers.tick(5_000); + await flush(); + assert.equal(calls, 2); + assert.equal(auth.state.get().status, "authenticated"); + cleanups.pop()(); + t.mock.timers.reset(); +}); + test("a handler that ignores abort still times out", async (t) => { t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); const auth = client({ refresh: async () => new Promise(() => {}) }); @@ -630,6 +693,71 @@ test("opaque tokens work and explicit expiry wins over JWT exp", async () => { assert.ok((await auth.getSession()).expiresAt > Date.now()); }); +const jwt = (claims) => + `e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.x`; + +test("JWT lifetime is measured from receipt when the browser clock runs fast", async (t) => { + const serverNow = 1_000_000; + t.mock.timers.enable({ + apis: ["Date", "setTimeout"], + now: (serverNow + 600) * 1000, + }); + const auth = client(); + await auth.signIn( + input({ accessToken: jwt({ iat: serverNow, exp: serverNow + 300 }) }), + ); + assert.equal((await auth.getSession()).expiresAt, Date.now() + 300_000); + t.mock.timers.reset(); +}); + +test("JWT exp without iat stays absolute", async () => { + const auth = client(); + const exp = Math.floor(Date.now() / 1000) + 300; + await auth.signIn(input({ accessToken: jwt({ exp }) })); + assert.equal((await auth.getSession()).expiresAt, exp * 1000); + await assert.rejects( + auth.signIn(input({ accessToken: jwt({ exp: 1 }) })), + code("INVALID_SESSION"), + ); +}); + +test("expiresIn seconds are measured from receipt for sign-in and refresh", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const auth = client({ + refresh: async () => ({ accessToken: "access-2", expiresIn: 120 }), + }); + await auth.signIn(input({ expiresIn: 60 })); + assert.equal((await auth.getSession()).expiresAt, 1_060_000); + t.mock.timers.tick(10_000); + const refreshed = await auth.refresh(await auth.getSession()); + assert.equal(refreshed.expiresAt, 1_130_000); + t.mock.timers.reset(); +}); + +test("expiresIn rejects invalid values and conflicts with expiresAt", async () => { + const auth = client(); + for (const overrides of [ + { expiresIn: 0 }, + { expiresIn: "60" }, + { expiresIn: 60, expiresAt: Date.now() + 60_000 }, + ]) { + await assert.rejects( + auth.signIn(input(overrides)), + code("INVALID_SESSION"), + ); + } +}); + +test("restoration keeps the saved expiry instead of re-deriving JWT lifetime", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000_000 }); + const iat = 1_000_000; + await client().signIn(input({ accessToken: jwt({ iat, exp: iat + 300 }) })); + t.mock.timers.tick(200_000); + const restored = await client().getSession(); + assert.equal(restored.expiresAt, 1_000_300_000); + t.mock.timers.reset(); +}); + test("Router connection is idempotent and token-only proactive refresh does not invalidate", async (t) => { t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); const auth = client({ @@ -664,6 +792,106 @@ test("Router connection is idempotent and token-only proactive refresh does not t.mock.timers.reset(); }); +const connectCounting = (auth) => { + const router = { + invalidations: 0, + options: { context: { auth } }, + clearCache() {}, + async invalidate() { + router.invalidations++; + }, + }; + cleanups.push(connectAuth(router)); + return router; +}; +const recordStates = (auth) => { + const states = []; + const subscription = auth.state.subscribe((state) => states.push(state)); + cleanups.push(() => subscription.unsubscribe()); + return states; +}; + +test("401-triggered token-only refresh keeps the user and does not invalidate", async () => { + const gate = deferred(); + const auth = client({ refresh: async () => gate.promise }); + const router = connectCounting(auth); + await flush(); + await auth.signIn(input()); + await flush(); + const before = { ...auth.state.get(), invalidations: router.invalidations }; + const states = recordStates(auth); + const pending = auth.refresh(await auth.getSession()); + await flush(); + assert.equal(auth.state.get().status, "refreshing"); + assert.equal(auth.state.get().user.id, "alice"); + gate.resolve({ accessToken: "access-2" }); + assert.equal((await pending).accessToken, "access-2"); + await flush(); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().version, before.version); + assert.equal(router.invalidations, before.invalidations); + assert.ok(states.every((state) => state.user?.id === "alice")); +}); + +test("refresh failure after rejection becomes unavailable and invalidates once", async () => { + const gate = deferred(); + const auth = client({ refresh: async () => gate.promise }); + const router = connectCounting(auth); + await flush(); + await auth.signIn(input()); + await flush(); + const before = router.invalidations; + const states = recordStates(auth); + const pending = auth.refresh(await auth.getSession()); + const checked = assert.rejects(pending, code("REFRESH_FAILED")); + await flush(); + assert.equal(auth.state.get().status, "refreshing"); + gate.reject(new Error("offline")); + await checked; + await flush(); + assert.deepEqual( + states.map((state) => state.status), + ["refreshing", "unavailable"], + ); + assert.equal(auth.state.get().user, null); + assert.equal(auth.state.get().error.code, "REFRESH_FAILED"); + assert.equal(router.invalidations, before + 1); +}); + +test("terminal refresh rejection signs out with one invalidation", async () => { + const auth = client({ refresh: async () => null }); + const router = connectCounting(auth); + await flush(); + await auth.signIn(input()); + await flush(); + const before = router.invalidations; + assert.equal(await auth.refresh(await auth.getSession()), null); + await flush(); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(router.invalidations, before + 1); +}); + +test("expired saved session restores through refreshing", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + await client().signIn(input({ expiresAt: 1_010_000 })); + t.mock.timers.tick(20_000); + const gate = deferred(); + const auth = client({ refresh: async () => gate.promise }); + const states = recordStates(auth); + cleanups.push(auth.mount()); + await flush(); + assert.equal(auth.state.get().status, "refreshing"); + assert.equal(auth.state.get().user.id, "alice"); + gate.resolve({ accessToken: "access-2", expiresAt: 1_100_000 }); + await flush(); + assert.deepEqual( + states.map((state) => state.status), + ["refreshing", "authenticated"], + ); + cleanups.pop()(); + t.mock.timers.reset(); +}); + test("GET retries once; bearer tokens stay on the configured origin", async () => { const auth = client({ refresh: async () => ({ accessToken: "access-2" }) }); await auth.signIn(input()); @@ -704,6 +932,54 @@ test("second 401 ends only the matching session", async () => { assert.equal(await auth.getSession(), null); }); +const trackedBody = () => { + const body = { cancelled: false }; + body.stream = new ReadableStream({ + cancel() { + body.cancelled = true; + }, + }); + return body; +}; + +test("a repeated 401 can keep the session when configured", async () => { + const auth = client({ refresh: async () => ({ accessToken: "access-2" }) }); + await auth.signIn(input()); + global.fetch = async () => new Response(null, { status: 401 }); + const request = createAuthFetch(auth, "https://api.example.com/", { + signOutOnRepeated401: false, + }); + assert.equal((await request("/me")).status, 401); + assert.equal((await auth.getSession()).accessToken, "access-2"); +}); + +test("a failed refresh after a 401 releases the 401 response body", async () => { + const auth = client({ + refresh: async () => { + throw new Error("offline"); + }, + }); + await auth.signIn(input()); + const body = trackedBody(); + global.fetch = async () => new Response(body.stream, { status: 401 }); + const request = createAuthFetch(auth, "https://api.example.com/"); + await assert.rejects(request("/me"), code("REFRESH_FAILED")); + assert.equal(body.cancelled, true); +}); + +test("a session change during a request releases its response body", async () => { + const auth = client(); + await auth.signIn(input()); + const body = trackedBody(); + global.fetch = async () => { + await auth.signOut(); + return new Response(body.stream); + }; + const request = createAuthFetch(auth, "https://api.example.com/"); + await assert.rejects(request("/me"), code("SESSION_CHANGED")); + assert.equal(body.cancelled, true); +}); + test("return URL rejects external origins, backslashes, controls, and login loops", () => { for (const value of [ "//evil.com", @@ -720,6 +996,28 @@ test("return URL rejects external origins, backslashes, controls, and login loop ); }); +test("return URL treats login path variants and custom login paths as loops", () => { + for (const value of [ + "/login/", + "/LOGIN", + "/Login?next=1", + "/log%69n", + "/%E0", + ]) + assert.equal(safeReturnTo(value), "/"); + assert.equal(safeReturnTo("/login/callback"), "/login/callback"); + const options = { loginPath: ["/sign-in", "/auth/Register/"] }; + for (const value of [ + "/sign-in", + "/Sign-In/", + "/auth/register", + "/auth/register/", + ]) + assert.equal(safeReturnTo(value, options), "/"); + assert.equal(safeReturnTo("/login", options), "/login"); + assert.throws(() => safeReturnTo("/", { loginPath: "sign-in" })); +}); + test("schemas that return non-JSON output are rejected", async () => { const schema = z.object({ created: z.date() }); const auth = client({ userSchema: schema }); @@ -738,7 +1036,7 @@ test("invalid optional refresh user cannot partially replace tokens", async () = auth.refresh(await auth.getSession()), code("USER_VALIDATION_FAILED"), ); - const saved = JSON.parse(values.get(TOKENS)); + const saved = JSON.parse(values.get(SESSION)); assert.equal(saved.accessToken, "access-1"); }); @@ -775,7 +1073,7 @@ test("remount while initial validation is pending starts a fresh restoration", a assert.ok(calls >= 2); }); -test("real Router guards rerun after logout and produce a login redirect", async () => { +const realRouter = async (auth) => { global.self = global; global.scrollTo = () => {}; global.window = { @@ -790,16 +1088,13 @@ test("real Router guards rerun after logout and produce a login redirect", async createMemoryHistory, redirect, } = await import("@tanstack/react-router"); - const auth = client(); - await auth.signIn(input()); const root = createRootRouteWithContext()({}); const login = createRoute({ getParentRoute: () => root, path: "/login" }); - let guardCalls = 0; const privateRoute = createRoute({ getParentRoute: () => root, path: "/private", beforeLoad: async ({ context }) => { - guardCalls++; + router.guardCalls++; if (!(await context.auth.getSession())) throw redirect({ to: "/login" }); }, @@ -810,16 +1105,346 @@ test("real Router guards rerun after logout and produce a login redirect", async history: createMemoryHistory({ initialEntries: ["/private"] }), isServer: false, }); + router.guardCalls = 0; + router.invalidations = 0; router.startTransition = async (fn) => { fn(); return true; }; - const off = connectAuth(router); - cleanups.push(off); + const invalidate = router.invalidate.bind(router); + router.invalidate = (...args) => { + router.invalidations++; + return invalidate(...args); + }; + cleanups.push(connectAuth(router)); await router.load(); + return router; +}; +const settle = async () => { + for (let i = 0; i < 20; i++) await new Promise(setImmediate); +}; +const privateMatch = (router) => + router.state.matches.find((match) => match.routeId === "/private"); + +test("real Router guards rerun after logout and produce a login redirect", async () => { + const auth = client(); + await auth.signIn(input()); + const router = await realRouter(auth); assert.equal(router.state.location.pathname, "/private"); await auth.signOut(); await new Promise(setImmediate); - assert.ok(guardCalls >= 2); + assert.ok(router.guardCalls >= 2); assert.equal(router.state.location.pathname, "/login"); }); + +test("a failed refresh reruns real Router guards once without retrying until retry()", async () => { + let refreshes = 0; + let offline = true; + const auth = client({ + refresh: async () => { + refreshes++; + await new Promise(setImmediate); + if (offline) throw new Error("offline"); + return { accessToken: "access-2" }; + }, + }); + await auth.signIn(input()); + const router = await realRouter(auth); + const guardCalls = router.guardCalls; + await assert.rejects( + auth.refresh(await auth.getSession()), + code("REFRESH_FAILED"), + ); + await settle(); + assert.equal(refreshes, 1); + assert.equal(router.invalidations, 1); + assert.equal(router.guardCalls, guardCalls + 1); + assert.equal(auth.state.get().status, "unavailable"); + assert.equal(privateMatch(router).error?.code, "REFRESH_FAILED"); + await assert.rejects(auth.getSession(), code("REFRESH_FAILED")); + assert.equal(refreshes, 1); + + offline = false; + assert.equal((await auth.retry()).accessToken, "access-2"); + await settle(); + assert.equal(refreshes, 2); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(privateMatch(router).status, "success"); +}); + +test("sign-in clears a stored refresh failure", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + let refreshes = 0; + const auth = client({ + refresh: async () => { + refreshes++; + throw new Error("offline"); + }, + }); + await auth.signIn(input()); + await assert.rejects( + auth.refresh(await auth.getSession()), + code("REFRESH_FAILED"), + ); + await auth.signIn(input({ accessToken: "access-2", expiresAt: 1_010_000 })); + t.mock.timers.tick(20_000); + await assert.rejects(auth.getSession(), code("REFRESH_FAILED")); + assert.equal(refreshes, 2); + t.mock.timers.reset(); +}); + +const rotatingServer = ({ gates = [] } = {}) => { + let issued = 1; + const live = new Set(["refresh-1"]); + const server = { + calls: 0, + async refresh({ refreshToken }) { + await gates[server.calls++]; + if (!live.delete(refreshToken)) return null; + issued++; + live.add(`refresh-${issued}`); + return { + accessToken: `access-${issued}`, + refreshToken: `refresh-${issued}`, + }; + }, + }; + return server; +}; +const twoTabs = async (server) => { + const a = client({ refresh: server.refresh }); + await a.signIn(input()); + const b = client({ refresh: server.refresh }); + assert.equal((await b.getSession()).accessToken, "access-1"); + return { a, b }; +}; +const saved = () => JSON.parse(values.get(SESSION)); + +test("a tab refreshing after another tab rotated the token adopts the saved tokens", async () => { + const server = rotatingServer(); + const { a, b } = await twoTabs(server); + const stale = await b.getSession(); + assert.equal( + (await a.refresh(await a.getSession())).accessToken, + "access-2", + ); + assert.equal((await b.refresh(stale)).accessToken, "access-2"); + assert.equal(server.calls, 1); + assert.equal(b.state.get().status, "authenticated"); + assert.equal(saved().refreshToken, "refresh-2"); + assert.equal( + (await b.refresh(await b.getSession())).accessToken, + "access-3", + ); + assert.equal(saved().refreshToken, "refresh-3"); +}); + +test("tabs refreshing at once spend the refresh token once", async () => { + const server = rotatingServer(); + const { a, b } = await twoTabs(server); + const [fromA, fromB] = await Promise.all([ + a.refresh(await a.getSession()), + b.refresh(await b.getSession()), + ]); + assert.equal(server.calls, 1); + assert.equal(fromA.accessToken, "access-2"); + assert.equal(fromB.accessToken, "access-2"); + assert.equal(saved().refreshToken, "refresh-2"); +}); + +test("without Web Locks, a rejected refresh adopts newer saved tokens instead of signing out", async () => { + setGlobal("navigator", undefined); + const gates = [deferred(), deferred()]; + const server = rotatingServer({ gates: gates.map((gate) => gate.promise) }); + const { a, b } = await twoTabs(server); + const fromA = a.refresh(await a.getSession()); + const fromB = b.refresh(await b.getSession()); + await flush(); + assert.equal(server.calls, 2); + gates[0].resolve(); + assert.equal((await fromA).accessToken, "access-2"); + gates[1].resolve(); + assert.equal((await fromB).accessToken, "access-2"); + assert.equal(b.state.get().status, "authenticated"); + assert.equal(saved().refreshToken, "refresh-2"); +}); + +test("a stale tab's profile update keeps tokens another tab refreshed", async () => { + const server = rotatingServer(); + const { a, b } = await twoTabs(server); + await a.refresh(await a.getSession()); + await b.updateUser({ ...user, email: "b@example.com" }); + assert.equal(saved().refreshToken, "refresh-2"); + assert.equal(saved().user.email, "b@example.com"); + assert.equal((await b.getSession()).accessToken, "access-2"); +}); + +test("a refresh cannot overwrite an account another tab signed in", async () => { + const server = rotatingServer(); + const { a, b } = await twoTabs(server); + await a.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + await assert.rejects( + b.refresh(await b.getSession()), + code("SESSION_CHANGED"), + ); + assert.equal(server.calls, 0); + assert.equal(saved().accessToken, "bob-token"); + assert.equal(b.state.get().user.id, "bob"); +}); + +test("a refresh after another tab signed out ends this tab's session too", async () => { + const server = rotatingServer(); + const { a, b } = await twoTabs(server); + await a.signOut(); + assert.equal(await b.refresh(await b.getSession()), null); + assert.equal(server.calls, 0); + assert.equal(b.state.get().status, "unauthenticated"); + assert.equal(values.size, 0); +}); + +test("a second 401 does not clear an account another tab signed in", async () => { + const { a, b } = await twoTabs(rotatingServer()); + const stale = await b.getSession(); + await a.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + await assert.rejects(b.rejectSession(stale), code("SESSION_CHANGED")); + assert.equal(saved().accessToken, "bob-token"); +}); + +const broadcast = async (key = SESSION) => { + const event = new Event("storage"); + event.key = key; + storageEvents.dispatchEvent(event); + await flush(); +}; +const mountedTabs = async (server = rotatingServer()) => { + const tabs = await twoTabs(server); + cleanups.push(tabs.a.mount(), tabs.b.mount()); + await flush(); + return tabs; +}; + +test("signing out in one tab signs out the others", async () => { + const { a, b } = await mountedTabs(); + const router = connectCounting(b); + await flush(); + const before = router.invalidations; + await a.signOut(); + await broadcast(); + assert.equal(b.state.get().status, "unauthenticated"); + assert.equal(await b.getSession(), null); + assert.equal(router.invalidations, before + 1); +}); + +test("signing in in one tab signs in a signed-out tab", async () => { + const b = client(); + cleanups.push(b.mount()); + await flush(); + assert.equal(b.state.get().status, "unauthenticated"); + await client().signIn(input()); + await broadcast(); + assert.equal(b.state.get().status, "authenticated"); + assert.equal(b.state.get().user.id, "alice"); +}); + +test("switching accounts in one tab switches the others and cancels their work", async () => { + const gate = deferred(); + const { a, b } = await mountedTabs({ refresh: async () => gate.promise }); + const aliceId = b.state.get().sessionId; + const pending = b.refresh(await b.getSession()); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await a.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + await broadcast(); + await checked; + assert.equal(b.state.get().user.id, "bob"); + assert.notEqual(b.state.get().sessionId, aliceId); + assert.equal((await b.getSession()).accessToken, "bob-token"); + gate.resolve({ accessToken: "late" }); + await flush(); + assert.equal(saved().accessToken, "bob-token"); +}); + +test("a refresh in one tab reaches the others without a version change", async () => { + const server = rotatingServer(); + const { a, b } = await mountedTabs(server); + const before = b.state.get(); + await a.refresh(await a.getSession()); + await broadcast(); + assert.equal(b.state.get().version, before.version); + assert.equal(b.state.get().user, before.user); + assert.equal((await b.getSession()).accessToken, "access-2"); + assert.equal(server.calls, 1); +}); + +test("a profile update in one tab reaches the others", async () => { + const { a, b } = await mountedTabs(); + const before = b.state.get().version; + await a.updateUser({ ...user, email: "new@example.com" }); + await broadcast(); + assert.equal(b.state.get().user.email, "new@example.com"); + assert.equal(b.state.get().version, before + 1); + assert.equal((await b.getSession()).accessToken, "access-1"); +}); + +test("another tab's successful refresh clears this tab's refresh failure", async () => { + const server = rotatingServer(); + const a = client({ refresh: server.refresh }); + await a.signIn(input()); + const b = client({ + refresh: async () => { + throw new Error("offline"); + }, + }); + cleanups.push(b.mount()); + await flush(); + await assert.rejects( + b.refresh(await b.getSession()), + code("REFRESH_FAILED"), + ); + await assert.rejects(b.getSession(), code("REFRESH_FAILED")); + await a.refresh(await a.getSession()); + await broadcast(); + assert.equal(b.state.get().status, "authenticated"); + assert.equal(b.state.get().error, null); + assert.equal((await b.getSession()).accessToken, "access-2"); +}); + +test("a refresh in flight cannot overwrite an account signed in meanwhile", async () => { + const gate = deferred(); + const { a, b } = await twoTabs({ refresh: async () => gate.promise }); + const pending = b.refresh(await b.getSession()); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await a.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + gate.resolve({ accessToken: "alice-refreshed" }); + await checked; + assert.equal(saved().accessToken, "bob-token"); + assert.equal(b.state.get().user.id, "bob"); +}); + +test("unmounted tabs and unrelated keys ignore storage events", async () => { + const { a, b } = await twoTabs(rotatingServer()); + const off = b.mount(); + await flush(); + await a.signOut(); + await broadcast("unrelated"); + assert.equal(b.state.get().status, "authenticated"); + off(); + await broadcast(); + assert.equal(b.state.get().status, "authenticated"); +}); + +test("invalid saved data from another tab leaves this tab's session alone", async () => { + const { b } = await mountedTabs(); + values.set(SESSION, "invalid-json"); + await broadcast(); + assert.equal(b.state.get().status, "authenticated"); +}); diff --git a/tests/consumer.ts b/tests/consumer.ts index c17049f..a0502cd 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -11,7 +11,10 @@ import { connectAuth, useAuth, useAuthClient } from "../dist/react/index.js"; const auth = createAuth({ name: "type-test", userSchema: z.object({ id: z.string(), email: z.string() }), - refresh: createRefreshFn(async () => ({ accessToken: "new-token" })), + refresh: createRefreshFn(async () => ({ + accessToken: "new-token", + expiresIn: 3600, + })), }); const root = createRootRouteWithContext<{ auth: typeof auth }>()({}); const router = createRouter({ routeTree: root, context: { auth } });