From 0413867fcfa4ffb2aceda3c9e2a9707e77fcd3c9 Mon Sep 17 00:00:00 2001 From: monarcode Date: Sat, 3 Oct 2026 18:39:16 +0100 Subject: [PATCH 1/4] refactor(core)!: replace TanStack Store with Nano Stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Require function listeners for state subscriptions. - Report subscriber errors asynchronously without interrupting auth. - Use React’s selector adapter and update alpha.3 documentation. --- .editorconfig | 3 + .oxfmtrc.json | 6 + CHANGELOG.md | 17 ++ README.md | 495 ++++++++++++++++++++++--------------------- package.json | 5 +- pnpm-lock.yaml | 26 ++- src/core/client.ts | 29 ++- src/core/types.ts | 14 +- src/react/hooks.ts | 22 +- tests/auth.test.mjs | 25 +++ tests/react.test.mjs | 14 ++ 11 files changed, 388 insertions(+), 268 deletions(-) diff --git a/.editorconfig b/.editorconfig index 63254bc..a7f1275 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,6 @@ max_line_length = 80 [*.md] trim_trailing_whitespace = false +# GitHub and npm render tabs 8 columns wide, so code examples use spaces. +indent_style = space +indent_size = 2 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index f9355b8..b6634ca 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,12 @@ "endOfLine": "lf", "bracketSpacing": true, "insertFinalNewline": true, + "overrides": [ + { + "files": ["*.md"], + "options": { "useTabs": false, "tabWidth": 2 } + } + ], "ignorePatterns": [ "node_modules", "dist", diff --git a/CHANGELOG.md b/CHANGELOG.md index 706a370..53234f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.1.0-alpha.3 + +### Changed + +- Reactive state uses Nano Stores instead of TanStack Store. Apps on Router + versions that depend on an older TanStack Store no longer bundle two copies. +- **Breaking:** `AuthClient.state` is typed by this package rather than + `@tanstack/store`. `state.subscribe` takes a listener function; observer + objects (`{ next, error, complete }`) are no longer accepted. + +### Fixed + +- A `state.subscribe` listener that throws no longer makes `signIn` (or another + operation) reject after it succeeded, or stops later listeners, including + `useAuth` and `connectAuth`, from seeing the change. The error is rethrown + asynchronously instead. + ## 0.1.0-alpha.2 ### Changed diff --git a/README.md b/README.md index 2bc30dc..227fa08 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ Schema-driven browser authentication for React and TanStack Router. -**Version: 0.1.0-alpha.0. Early development; not ready for production.** +**Version: 0.1.0-alpha.3. Early development; not ready for production.** -One auth client lives in Router context. TanStack Store makes its state reactive, +One auth client lives in Router context. Nano Stores makes its state reactive, with no separate AuthProvider. Tokens and the validated user profile are stored together in one localStorage entry. ## Installation -Install the alpha using the `next` distribution tag: +Alpha releases are published to the `latest` distribution tag: ```sh -pnpm add @monarcode/session-kit@next +pnpm add @monarcode/session-kit ``` -Pin `@monarcode/session-kit@0.1.0-alpha.0` to use this exact version. Alpha +Pin `@monarcode/session-kit@0.1.0-alpha.3` 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.0.0`, and TanStack React @@ -29,7 +29,9 @@ 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, sets no Node.js engine requirement, and is checked with +users. The package is ESM-only and sets no Node.js engine requirement, though its +Nano Stores dependency declares Node.js 20 or 22 and newer for server-side +rendering and test runners. It 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`. @@ -55,10 +57,10 @@ import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; export default defineConfig({ - plugins: [ - tanstackRouter({ target: "react", autoCodeSplitting: true }), - react(), - ], + plugins: [ + tanstackRouter({ target: "react", autoCodeSplitting: true }), + react(), + ], }); ``` @@ -89,36 +91,36 @@ import { z } from "zod"; import { createAuth, createRefreshFn } from "@monarcode/session-kit"; export const userSchema = z.object({ - id: z.string(), - email: z.string(), + id: z.string(), + email: z.string(), }); const tokensSchema = z.object({ - accessToken: z.string().min(1), - refreshToken: z.string().min(1).optional(), - expiresIn: z.number().positive().optional(), + accessToken: z.string().min(1), + refreshToken: z.string().min(1).optional(), + expiresIn: z.number().positive().optional(), }); export const loginResponseSchema = tokensSchema.extend({ user: userSchema }); const refreshResponseSchema = tokensSchema.extend({ - user: userSchema.optional(), + user: userSchema.optional(), }); export const auth = createAuth({ - name: "my-app", - userSchema, - refresh: createRefreshFn(async ({ refreshToken, signal }) => { - // Use plain fetch here to avoid recursive auth refresh. - const response = await fetch("/api/auth/refresh", { - method: "POST", - signal, - redirect: "error", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), - }); - if (response.status === 401) return null; - if (!response.ok) throw new Error(`Refresh failed (${response.status})`); - return refreshResponseSchema.parse(await response.json()); - }), + name: "my-app", + userSchema, + refresh: createRefreshFn(async ({ refreshToken, signal }) => { + // Use plain fetch here to avoid recursive auth refresh. + const response = await fetch("/api/auth/refresh", { + method: "POST", + signal, + redirect: "error", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + if (response.status === 401) return null; + if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + return refreshResponseSchema.parse(await response.json()); + }), }); export type AppAuth = typeof auth; @@ -138,46 +140,46 @@ frozen; use `updateUser` to replace it. ```tsx import { - createRootRouteWithContext, - Outlet, - useRouter, + createRootRouteWithContext, + Outlet, + useRouter, } from "@tanstack/react-router"; import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; import type { AppAuth } from "../auth.js"; export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({ - component: Root, + component: Root, }); function Root() { - const error = useAuth((state) => state.error); - const auth = useAuthClient(); - const router = useRouter(); - - return ( - <> - {error && ( -
-

{error.message}

- {error.code === "PERSISTENCE_FAILED" && ( - - )} -
- )} - - - ); + const error = useAuth((state) => state.error); + const auth = useAuthClient(); + const router = useRouter(); + + return ( + <> + {error && ( +
+

{error.message}

+ {error.code === "PERSISTENCE_FAILED" && ( + + )} +
+ )} + + + ); } ``` @@ -198,75 +200,72 @@ import { safeReturnTo, useAuthClient } from "@monarcode/session-kit/react"; import { loginResponseSchema } from "../auth.js"; export const Route = createFileRoute("/login")({ - validateSearch: (search: Record) => ({ - redirectTo: safeReturnTo(search.redirectTo), - }), - beforeLoad: async ({ context, search }) => { - if (await context.auth.getSession()) { - throw redirect({ href: safeReturnTo(search.redirectTo) }); - } - }, - component: Login, + validateSearch: (search: Record) => ({ + redirectTo: safeReturnTo(search.redirectTo), + }), + beforeLoad: async ({ context, search }) => { + if (await context.auth.getSession()) { + throw redirect({ href: safeReturnTo(search.redirectTo) }); + } + }, + component: Login, }); function Login() { - const auth = useAuthClient(); - const router = useRouter(); - const { redirectTo } = Route.useSearch(); - const [error, setError] = useState(""); - const [pending, setPending] = useState(false); - - async function submit(event: FormEvent) { - event.preventDefault(); - const form = new FormData(event.currentTarget); - setPending(true); - setError(""); - try { - const response = await fetch("/api/auth/login", { - method: "POST", - redirect: "error", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: form.get("email"), - password: form.get("password"), - }), - }); - if (!response.ok) - throw new Error(`Sign-in failed (${response.status})`); - await auth.signIn(loginResponseSchema.parse(await response.json())); - await router.navigate({ - href: safeReturnTo(redirectTo), - replace: true, - }); - } catch (cause) { - setError(cause instanceof Error ? cause.message : "Could not sign in"); - } finally { - setPending(false); - } - } - - return ( -
-

Sign in

- - - {error &&

{error}

} - -
- ); + const auth = useAuthClient(); + const router = useRouter(); + const { redirectTo } = Route.useSearch(); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setPending(true); + setError(""); + try { + const response = await fetch("/api/auth/login", { + method: "POST", + redirect: "error", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: form.get("email"), + password: form.get("password"), + }), + }); + if (!response.ok) throw new Error(`Sign-in failed (${response.status})`); + await auth.signIn(loginResponseSchema.parse(await response.json())); + await router.navigate({ + href: safeReturnTo(redirectTo), + replace: true, + }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not sign in"); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); } ``` @@ -286,69 +285,69 @@ routes inherit the guard without adding `authenticated` to the URL. ```tsx import { - createFileRoute, - Outlet, - redirect, - type ErrorComponentProps, + createFileRoute, + Outlet, + redirect, + type ErrorComponentProps, } from "@tanstack/react-router"; import { AuthError } from "@monarcode/session-kit"; import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; export const Route = createFileRoute("/_authenticated")({ - beforeLoad: async ({ context, location }) => { - const session = await context.auth.getSession(); - if (!session) - throw redirect({ - to: "/login", - search: { redirectTo: location.href }, - }); - if (!context.auth.isCurrent(session)) { - throw new AuthError( - "SESSION_CHANGED", - "Session changed; retry navigation", - ); - } - // Return public context only. Never put the token-bearing session here. - return { - sessionId: session.id, - authVersion: context.auth.state.get().version, - }; - }, - errorComponent: SessionError, - component: ProtectedLayout, + beforeLoad: async ({ context, location }) => { + const session = await context.auth.getSession(); + if (!session) + throw redirect({ + to: "/login", + search: { redirectTo: location.href }, + }); + if (!context.auth.isCurrent(session)) { + throw new AuthError( + "SESSION_CHANGED", + "Session changed; retry navigation", + ); + } + // Return public context only. Never put the token-bearing session here. + return { + sessionId: session.id, + 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.

- -
- ); + 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); - const usable = status === "authenticated" || status === "refreshing"; - if (!usable || version !== authVersion) { - return

Checking session…

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

Checking session…

; + } + return ; } ``` @@ -367,27 +366,27 @@ import { createFileRoute } from "@tanstack/react-router"; import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; export const Route = createFileRoute("/_authenticated/")({ - component: Home, + component: Home, }); function Home() { - const user = useAuth((state) => state.user); - const auth = useAuthClient(); - - return ( -
-

Welcome, {user?.email}

- -
- ); + const user = useAuth((state) => state.user); + const auth = useAuthClient(); + + return ( +
+

Welcome, {user?.email}

+ +
+ ); } ``` @@ -404,37 +403,35 @@ import { auth } from "./auth.js"; import { routeTree } from "./routeTree.gen.js"; export const router = createRouter({ - routeTree, - context: { auth }, - defaultPreload: "intent", - defaultPreloadStaleTime: 0, - defaultPendingComponent: () =>

Loading…

, - defaultErrorComponent: ({ error }) => , + routeTree, + context: { auth }, + defaultPreload: "intent", + defaultPreloadStaleTime: 0, + defaultPendingComponent: () =>

Loading…

, + defaultErrorComponent: ({ error }) => , }); function RouteError({ error }: { error: unknown }) { - const router = useRouter(); - return ( -
-

- {error instanceof Error - ? error.message - : "Could not load this page"} -

- -
- ); + const router = useRouter(); + return ( +
+

+ {error instanceof Error ? error.message : "Could not load this page"} +

+ +
+ ); } // Call once beside the stable Router, outside component rendering. export const disconnectAuth = connectAuth(router); declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } + interface Register { + router: typeof router; + } } ``` @@ -443,6 +440,11 @@ Keep it in the consuming app. `useAuth()` reads the full reactive snapshot; `useAuth(selector)` subscribes to a selected value. `useAuthClient()` returns the stable client for actions. +Outside React, `auth.state.get()` returns the current snapshot and +`auth.state.subscribe(listener)` reports later changes, not the current value; it +returns `{ unsubscribe }`. A listener that throws does not interrupt auth: its +error is rethrown asynchronously, where error reporting can capture it. + `connectAuth` initializes auth, starts expiry timers, clears inactive Router cache entries on guard-relevant changes, and queues Router revalidation. Call `disconnectAuth` during app teardown or your bundler's hot-module disposal. @@ -462,14 +464,14 @@ import { router, disconnectAuth } from "./router.js"; const root = createRoot(document.getElementById("root")!); root.render( - - - , + + + , ); export function dispose() { - root.unmount(); - disconnectAuth(); + root.unmount(); + disconnectAuth(); } ``` @@ -486,17 +488,17 @@ import { createAuthFetch } from "@monarcode/session-kit/http"; import { auth, userSchema } from "./auth.js"; export async function updateEmail(email: string) { - await auth.updateUser(async () => { - const request = createAuthFetch(auth, `${location.origin}/api/`); - const response = await request("profile", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - if (!response.ok) - throw new Error(`Profile update failed (${response.status})`); - return userSchema.parse(await response.json()); - }); + await auth.updateUser(async () => { + const request = createAuthFetch(auth, `${location.origin}/api/`); + const response = await request("profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (!response.ok) + throw new Error(`Profile update failed (${response.status})`); + return userSchema.parse(await response.json()); + }); } ``` @@ -536,11 +538,11 @@ refresh callback rather than throwing: ```ts refresh: createRefreshFn(async ({ refreshToken, signal }) => { - try { - return await requestRefresh(refreshToken, signal); - } catch { - return null; - } + try { + return await requestRefresh(refreshToken, signal); + } catch { + return null; + } }), ``` @@ -647,8 +649,8 @@ pnpm install pnpm run check ``` -`check` runs Oxlint and Oxfmt checks, builds the package, runs 45 auth behavior tests, five React hook tests, -four package smoke tests, and five release validation tests, then checks consumer +`check` runs Oxlint and Oxfmt checks, builds the package, runs the auth behavior, +React hook, package smoke, and release validation tests, then checks consumer declarations and every marked TypeScript example in this README under NodeNext and Bundler resolution. The docs checker supplies the generated route tree that a consumer's Router plugin owns. @@ -678,8 +680,9 @@ The separate manual alpha release workflow is documented in ### Try a local build -Run `pnpm pack` in this repository, then install the resulting tarball in your app: +Run `pnpm pack` in this repository, then install the resulting tarball in your +app. Replace `` with the version in `package.json`: ```sh -pnpm add /absolute/path/to/monarcode-session-kit-0.1.0-alpha.0.tgz +pnpm add /absolute/path/to/monarcode-session-kit-.tgz ``` diff --git a/package.json b/package.json index 7631dcd..a2d87f5 100644 --- a/package.json +++ b/package.json @@ -65,14 +65,15 @@ }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "@tanstack/react-store": "^0.11.1", - "@tanstack/store": "^0.11.1" + "nanostores": "^1.5.4", + "use-sync-external-store": "^1.6.0" }, "devDependencies": { "@tanstack/react-router": "1.170.38", "@types/node": "22.20.4", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "@types/use-sync-external-store": "1.7.0", "husky": "9.1.7", "jsdom": "26.1.0", "oxfmt": "0.71.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdf10cd..260de1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,12 @@ importers: '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 - '@tanstack/react-store': - specifier: ^0.11.1 - version: 0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/store': - specifier: ^0.11.1 - version: 0.11.1 + nanostores: + specifier: ^1.5.4 + version: 1.5.4 + use-sync-external-store: + specifier: ^1.6.0 + version: 1.7.0(react@19.3.0) devDependencies: '@tanstack/react-router': specifier: 1.170.38 @@ -30,6 +30,9 @@ importers: '@types/react-dom': specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) + '@types/use-sync-external-store': + specifier: 1.7.0 + version: 1.7.0 husky: specifier: 9.1.7 version: 9.1.7 @@ -354,6 +357,9 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/use-sync-external-store@1.7.0': + resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -431,6 +437,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanostores@1.5.4: + resolution: {integrity: sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==} + engines: {node: ^20.0.0 || >=22.0.0} + nwsapi@2.2.28: resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} @@ -762,6 +772,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/use-sync-external-store@1.7.0': {} + agent-base@7.1.4: {} cookie-es@3.1.1: {} @@ -845,6 +857,8 @@ snapshots: ms@2.1.3: {} + nanostores@1.5.4: {} + nwsapi@2.2.28: {} oxfmt@0.71.0: diff --git a/src/core/client.ts b/src/core/client.ts index 59413a9..d087f9e 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,5 +1,5 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { Store } from "@tanstack/store"; +import { atom } from "nanostores"; import { AuthError, authError, sessionChanged } from "./errors.js"; import { createPersistence, type StoredSession } from "./persistence.js"; @@ -67,7 +67,7 @@ export function createAuth( type U = User; type I = UserInput; const persistence = createPersistence(options.name, options.maxAge); - const store = new Store>({ + const store = atom>({ status: "initializing", user: null, sessionId: null, @@ -150,7 +150,7 @@ export function createAuth( guardStatus(previous.status) !== guardStatus(status) || previous.user !== user || previous.sessionId !== sessionId; - store.setState(() => + store.set( Object.freeze({ status, user, @@ -383,12 +383,13 @@ export function createAuth( } return; } - store.setState((previous) => ({ + const previous = store.get(); + store.set({ ...previous, status: "unavailable", error, version: previous.version + 1, - })); + }); throw error; } })(); @@ -670,7 +671,23 @@ export function createAuth( } return { - state: { get: store.get, subscribe: store.subscribe }, + state: { + get: store.get, + // `listen`, not `subscribe`: nanostores' `subscribe` also calls the + // listener immediately with the current value. + subscribe: (listener) => ({ + unsubscribe: store.listen((value) => { + try { + listener(value); + } catch (error) { + // A failing listener must not interrupt the auth operation that notified it. + queueMicrotask(() => { + throw error; + }); + } + }), + }), + }, signIn, signOut, updateUser, diff --git a/src/core/types.ts b/src/core/types.ts index 7144578..061e55a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,5 +1,4 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; -import type { Store } from "@tanstack/store"; import type { AuthError } from "./errors.js"; @@ -123,8 +122,17 @@ export type AuthState = Readonly<{ }>; export type AuthClient = { - /** Reactive `AuthState`. Read it in React with `useAuth`. */ - state: Pick>, "get" | "subscribe">; + /** + * Reactive `AuthState`. Read it in React with `useAuth`. `subscribe` reports + * later changes, not the current value. A listener that throws does not + * interrupt auth; its error is rethrown asynchronously. + */ + state: { + get: () => AuthState; + subscribe: (listener: (state: AuthState) => void) => { + unsubscribe: () => void; + }; + }; /** * Validates and saves a new session, replacing any current one. The latest * call wins. Rejects expired tokens; invalid input keeps the previous session. diff --git a/src/react/hooks.ts b/src/react/hooks.ts index 177c4ce..dc01fa5 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1,5 +1,7 @@ +import { useCallback } from "react"; + import { useRouter, type Register } from "@tanstack/react-router"; -import { useSelector } from "@tanstack/react-store"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector"; export type RegisteredAuth = Register extends { router: { options: { context: { auth: infer Client } } }; @@ -55,8 +57,18 @@ export function useAuth(): RegisteredAuthState; export function useAuth(selector: (state: RegisteredAuthState) => T): T; export function useAuth(selector?: (state: RegisteredAuthState) => T) { const auth = useContextAuth(); - return useSelector(auth.state, (value) => { - const state = value as RegisteredAuthState; - return selector ? selector(state) : state; - }); + const subscribe = useCallback( + (onChange: () => void) => auth.state.subscribe(onChange).unsubscribe, + [auth], + ); + const getSnapshot = useCallback(() => auth.state.get(), [auth]); + return useSyncExternalStoreWithSelector( + subscribe, + getSnapshot, + getSnapshot, + (value) => { + const state = value as RegisteredAuthState; + return selector ? selector(state) : state; + }, + ); } diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index dadeb60..4558f19 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -811,6 +811,31 @@ const recordStates = (auth) => { return states; }; +test("a throwing subscriber is reported without interrupting auth or other subscribers", async (t) => { + const auth = client(); + await auth.getSession(); + const reported = []; + t.mock.method(globalThis, "queueMicrotask", (callback) => { + try { + callback(); + } catch (error) { + reported.push(error.message); + } + }); + const failing = auth.state.subscribe((state) => { + if (state.status === "authenticated") throw new Error("consumer bug"); + }); + cleanups.push(() => failing.unsubscribe()); + const states = recordStates(auth); + await auth.signIn(input()); + assert.equal(auth.state.get().status, "authenticated"); + assert.deepEqual( + states.map((state) => state.status), + ["authenticated"], + ); + assert.deepEqual(reported, ["consumer bug"]); +}); + test("401-triggered token-only refresh keeps the user and does not invalidate", async () => { const gate = deferred(); const auth = client({ refresh: async () => gate.promise }); diff --git a/tests/react.test.mjs b/tests/react.test.mjs index 3009327..6558e8f 100644 --- a/tests/react.test.mjs +++ b/tests/react.test.mjs @@ -148,6 +148,20 @@ test("useAuth selector skips unrelated updates and renders changed selections", assert.equal(container.textContent, "none"); }); +test("useAuth accepts inline selectors that return new objects", async () => { + const { auth, router } = await fixture(); + function Status() { + const { status } = useAuth((state) => ({ status: state.status })); + return h("output", null, status); + } + const { container } = await mount(router, Status, { strict: true }); + assert.equal(container.textContent, "unauthenticated"); + await act(async () => signIn(auth)); + assert.equal(container.textContent, "authenticated"); + await act(async () => auth.signOut()); + assert.equal(container.textContent, "unauthenticated"); +}); + test("useAuthClient retains the Router client across reactive renders", async () => { const { auth, router } = await fixture(); const clients = []; From 3cfdb6d5738746e4963d6c8d8bc9a1e013686564 Mon Sep 17 00:00:00 2001 From: monarcode Date: Sun, 4 Oct 2026 07:17:18 +0100 Subject: [PATCH 2/4] style(scripts): format and lint release scripts - Stop ignoring `scripts/` in the oxfmt and oxlint configs - Reformat `check-readme`, `clean` and `prepare-release` with double quotes, tabs and wrapped lines --- .oxfmtrc.json | 3 +- .oxlintrc.json | 3 +- scripts/check-readme.mjs | 57 ++++++++++++------- scripts/clean.mjs | 8 +-- scripts/prepare-release.mjs | 110 +++++++++++++++++++++++------------- 5 files changed, 115 insertions(+), 66 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b6634ca..bb60ff6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -25,8 +25,7 @@ "routeTree.gen.ts", "*.gen.ts", "coverage", - "templates/", - "scripts/" + "templates/" ], "sortImports": { "customGroups": [ diff --git a/.oxlintrc.json b/.oxlintrc.json index a49ab01..b7d9d2a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -21,8 +21,7 @@ "**/routeTree.gen.ts", "**/*.gen.ts", "**/coverage/**", - "**/templates/**", - "**/scripts/**" + "**/templates/**" ], "rules": { "no-unused-vars": "off", diff --git a/scripts/check-readme.mjs b/scripts/check-readme.mjs index 22c8380..ec4f5c3 100644 --- a/scripts/check-readme.mjs +++ b/scripts/check-readme.mjs @@ -1,14 +1,16 @@ +import { spawnSync } from "node:child_process"; import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; -import { spawnSync } from "node:child_process"; const root = fileURLToPath(new URL("../", import.meta.url)); const readme = await readFile(join(root, "README.md"), "utf8"); -const examples = [...readme.matchAll( - /\s*```tsx?\n([\s\S]*?)\n```/g, -)]; +const examples = [ + ...readme.matchAll( + /\s*```tsx?\n([\s\S]*?)\n```/g, + ), +]; if (!examples.length) throw new Error("No README TypeScript examples found"); const directory = await mkdtemp(join(root, "tests", ".readme-")); const require = createRequire(import.meta.url); @@ -29,7 +31,9 @@ try { "src/routes/_authenticated/index.tsx", ]; if (fileRoutePaths.every((path) => paths.has(path))) { - await writeFile(join(directory, "src/routeTree.gen.ts"), ` + await writeFile( + join(directory, "src/routeTree.gen.ts"), + ` import { Route as rootRoute } from "./routes/__root.js"; import { Route as loginRouteImport } from "./routes/login.js"; import { Route as authenticatedRouteImport } from "./routes/_authenticated.js"; @@ -80,26 +84,41 @@ declare module "@tanstack/react-router" { }; } } -`); +`, + ); } for (const [module, moduleResolution] of [ - ["NodeNext", "NodeNext"], ["ESNext", "Bundler"], + ["NodeNext", "NodeNext"], + ["ESNext", "Bundler"], ]) { const config = join(directory, "tsconfig.json"); - await writeFile(config, JSON.stringify({ - compilerOptions: { - target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], - module, moduleResolution, jsx: "react-jsx", strict: true, - noEmit: true, skipLibCheck: true, types: [], - }, - include: ["src/**/*.ts", "src/**/*.tsx"], - })); + await writeFile( + config, + JSON.stringify({ + compilerOptions: { + target: "ES2022", + lib: ["ES2022", "DOM", "DOM.Iterable"], + module, + moduleResolution, + jsx: "react-jsx", + strict: true, + noEmit: true, + skipLibCheck: true, + types: [], + }, + include: ["src/**/*.ts", "src/**/*.tsx"], + }), + ); const result = spawnSync(process.execPath, [compiler, "-p", config], { - cwd: root, stdio: "inherit", + cwd: root, + stdio: "inherit", }); if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`README ${moduleResolution} check failed`); - console.log(`README: ${examples.length} examples passed (${moduleResolution})`); + if (result.status !== 0) + throw new Error(`README ${moduleResolution} check failed`); + console.log( + `README: ${examples.length} examples passed (${moduleResolution})`, + ); } } finally { await rm(directory, { recursive: true, force: true }); diff --git a/scripts/clean.mjs b/scripts/clean.mjs index 3d0f3e2..8f99dba 100644 --- a/scripts/clean.mjs +++ b/scripts/clean.mjs @@ -1,6 +1,6 @@ -import { rm } from 'node:fs/promises'; +import { rm } from "node:fs/promises"; -await rm(new URL('../dist/', import.meta.url), { - recursive: true, - force: true, +await rm(new URL("../dist/", import.meta.url), { + recursive: true, + force: true, }); diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index f412be6..5dc2271 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -1,47 +1,79 @@ -import { readFile, mkdir, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { readFile, mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; export function prepareRelease(manifest, changelog, version) { - const number = '(?:0|[1-9][0-9]*)'; - const alpha = new RegExp(`^${number}\\.${number}\\.${number}-alpha\\.${number}$`); - if (typeof version !== 'string' || version.trim() !== version || !alpha.test(version)) { - throw new Error('Use an alpha version such as 0.1.0-alpha.1, without a v prefix.'); - } - if (manifest.version !== version) throw new Error('Version must match package.json.'); - if (manifest.name !== '@monarcode/session-kit' - || manifest.repository?.url !== 'git+https://github.com/monarcode/session-kit.git' - || manifest.publishConfig?.access !== 'public' - || manifest.publishConfig?.registry !== 'https://registry.npmjs.org/') { - throw new Error('Package identity or publishing configuration does not match session-kit.'); - } - const sections = [...changelog.matchAll(/^## (.+)\r?$/gm)]; - const matching = sections.filter((section) => section[1].trim() === version); - if (matching.length !== 1) throw new Error('CHANGELOG.md must contain exactly one heading for this version.'); - const section = matching[0]; - const next = sections[sections.indexOf(section) + 1]; - const notes = changelog.slice(section.index + section[0].length, next?.index).trim(); - if (!notes) throw new Error('Release notes must not be empty.'); - return { version, tag: `v${version}`, notes }; + const number = "(?:0|[1-9][0-9]*)"; + const alpha = new RegExp( + `^${number}\\.${number}\\.${number}-alpha\\.${number}$`, + ); + if ( + typeof version !== "string" || + version.trim() !== version || + !alpha.test(version) + ) { + throw new Error( + "Use an alpha version such as 0.1.0-alpha.1, without a v prefix.", + ); + } + if (manifest.version !== version) + throw new Error("Version must match package.json."); + if ( + manifest.name !== "@monarcode/session-kit" || + manifest.repository?.url !== + "git+https://github.com/monarcode/session-kit.git" || + manifest.publishConfig?.access !== "public" || + manifest.publishConfig?.registry !== "https://registry.npmjs.org/" + ) { + throw new Error( + "Package identity or publishing configuration does not match session-kit.", + ); + } + const sections = [...changelog.matchAll(/^## (.+)\r?$/gm)]; + const matching = sections.filter((section) => section[1].trim() === version); + if (matching.length !== 1) + throw new Error( + "CHANGELOG.md must contain exactly one heading for this version.", + ); + const section = matching[0]; + const next = sections[sections.indexOf(section) + 1]; + const notes = changelog + .slice(section.index + section[0].length, next?.index) + .trim(); + if (!notes) throw new Error("Release notes must not be empty."); + return { version, tag: `v${version}`, notes }; } export async function checkRegistry(version, request = fetch) { - const response = await request(`https://registry.npmjs.org/@monarcode%2Fsession-kit/${version}`); - if (response.status === 404) return; - if (response.ok) throw new Error(`Version ${version} is already published.`); - throw new Error(`npm registry check failed with HTTP ${response.status}.`); + const response = await request( + `https://registry.npmjs.org/@monarcode%2Fsession-kit/${version}`, + ); + if (response.status === 404) return; + if (response.ok) throw new Error(`Version ${version} is already published.`); + throw new Error(`npm registry check failed with HTTP ${response.status}.`); } -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - const [version, directory, registryFlag] = process.argv.slice(2); - if (!directory || (registryFlag && registryFlag !== '--check-registry')) { - throw new Error('Usage: node scripts/prepare-release.mjs VERSION OUTPUT_DIRECTORY [--check-registry]'); - } - const manifest = JSON.parse(await readFile('package.json', 'utf8')); - const changelog = await readFile('CHANGELOG.md', 'utf8'); - const release = prepareRelease(manifest, changelog, version); - if (registryFlag) await checkRegistry(release.version); - await mkdir(directory, { recursive: true }); - await writeFile(resolve(directory, 'release-notes.md'), `${release.notes}\n`); - await writeFile(resolve(directory, 'release.json'), `${JSON.stringify(release, null, 2)}\n`); +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + const [version, directory, registryFlag] = process.argv.slice(2); + if (!directory || (registryFlag && registryFlag !== "--check-registry")) { + throw new Error( + "Usage: node scripts/prepare-release.mjs VERSION OUTPUT_DIRECTORY [--check-registry]", + ); + } + const manifest = JSON.parse(await readFile("package.json", "utf8")); + const changelog = await readFile("CHANGELOG.md", "utf8"); + const release = prepareRelease(manifest, changelog, version); + if (registryFlag) await checkRegistry(release.version); + await mkdir(directory, { recursive: true }); + await writeFile( + resolve(directory, "release-notes.md"), + `${release.notes}\n`, + ); + await writeFile( + resolve(directory, "release.json"), + `${JSON.stringify(release, null, 2)}\n`, + ); } From d282deb7e369d13178df73296cf8f6d4b5ea9083 Mon Sep 17 00:00:00 2001 From: monarcode Date: Sun, 4 Oct 2026 07:48:12 +0100 Subject: [PATCH 3/4] refactor(core)!: split client into focused modules and move AuthError issues into options - Extract tokens, refresh, user, task and timer helpers from `validation.ts` and `client.ts`, and move `safeReturnTo` to `react/return-to.ts` - Add the `AuthStatus` type and `AuthErrorOptions`, and sign every tab out with `USER_VALIDATION_FAILED` when another tab saves a user the schema rejects - Breaking: `new AuthError(code, message, { cause, issues })` replaces the fourth `issues` argument --- CHANGELOG.md | 12 ++ README.md | 25 +-- src/core/client.ts | 338 +++++++++++++++++++++------------------- src/core/errors.ts | 17 +- src/core/json.ts | 3 + src/core/persistence.ts | 17 +- src/core/refresh.ts | 86 ++++++++++ src/core/task.ts | 36 +++++ src/core/timers.ts | 32 ++++ src/core/tokens.ts | 108 +++++++++++++ src/core/types.ts | 36 ++--- src/core/user.ts | 94 +++++++++++ src/core/validation.ts | 222 -------------------------- src/index.ts | 5 +- src/react/connect.ts | 45 ------ src/react/index.ts | 5 +- src/react/return-to.ts | 46 ++++++ 17 files changed, 660 insertions(+), 467 deletions(-) create mode 100644 src/core/json.ts create mode 100644 src/core/refresh.ts create mode 100644 src/core/task.ts create mode 100644 src/core/timers.ts create mode 100644 src/core/tokens.ts create mode 100644 src/core/user.ts delete mode 100644 src/core/validation.ts create mode 100644 src/react/return-to.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 53234f5..751a63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.1.0-alpha.3 +### Added + +- `AuthStatus` names the union of `AuthState.status` values, for helpers and + exhaustive `switch` statements. + ### Changed - Reactive state uses Nano Stores instead of TanStack Store. Apps on Router @@ -9,6 +14,9 @@ - **Breaking:** `AuthClient.state` is typed by this package rather than `@tanstack/store`. `state.subscribe` takes a listener function; observer objects (`{ next, error, complete }`) are no longer accepted. +- **Breaking:** the `AuthError` constructor takes schema `issues` in its options + object, `new AuthError(code, message, { cause, issues })`, instead of as a + fourth argument. The new `AuthErrorOptions` type describes those options. ### Fixed @@ -16,6 +24,10 @@ operation) reject after it succeeded, or stops later listeners, including `useAuth` and `connectAuth`, from seeing the change. The error is rethrown asynchronously instead. +- When another tab saves a user that this tab's schema rejects, for example + while two deploys are open, this tab no longer keeps the old profile with no + error. It signs every tab out with `USER_VALIDATION_FAILED`, as restoring + that session would. ## 0.1.0-alpha.2 diff --git a/README.md b/README.md index 227fa08..37a3a3a 100644 --- a/README.md +++ b/README.md @@ -341,10 +341,10 @@ function SessionError({ error }: ErrorComponentProps) { function ProtectedLayout() { const { authVersion } = Route.useRouteContext(); - const status = useAuth((state) => state.status); + // `user` is set exactly while the session is authenticated or refreshing. + const signedIn = useAuth((state) => state.user !== null); const version = useAuth((state) => state.version); - const usable = status === "authenticated" || status === "refreshing"; - if (!usable || version !== authVersion) { + if (!signedIn || version !== authVersion) { return

Checking session…

; } return ; @@ -617,7 +617,9 @@ the remaining data after storage becomes available. 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 + this tab's pending auth work with `SESSION_CHANGED`. A user another tab saved + that this tab's schema rejects signs every tab out with + `USER_VALIDATION_FAILED`, as restoring it would. 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 @@ -650,9 +652,9 @@ pnpm run check ``` `check` runs Oxlint and Oxfmt checks, builds the package, runs the auth behavior, -React hook, package smoke, and release validation tests, then checks consumer -declarations and every marked TypeScript example in this README under NodeNext -and Bundler resolution. The docs +React hook, package smoke, and release validation tests, then type-checks the +tests, consumer declarations, and every marked TypeScript example in this README +under NodeNext and Bundler resolution. The docs checker supplies the generated route tree that a consumer's Router plugin owns. After building, `pnpm test` runs runtime tests and `pnpm run test:docs` checks README examples alone. The documentation checker extracts the examples into a @@ -663,12 +665,15 @@ temporary project; it does not execute them or contact the example backend. do not modify files; lint errors or formatting differences block the commit, while lint warnings remain non-blocking. Run `pnpm run lint:fix` to apply safe lint fixes and `pnpm run format` to format eligible files. Both tools exclude -generated files, build output, and `scripts/` using their committed configs. +generated files and build output using their committed configs. CI and release workflows disable Husky and enforce these checks through `pnpm run check`. -Behavior tests use mocked storage, HTTP, and timers, plus a real Router with -memory history. The `development` condition enables its client redirect test. +Tests are TypeScript files that Node runs directly, without a build step. Behavior +tests are split by area (sessions, refresh, expiry, Router, HTTP, return URLs, +and tabs) and share the browser mocks in `tests/helpers.ts`. They use mocked +storage, HTTP, and timers, plus a real Router with memory history. The +`development` condition enables its client redirect test. Hook tests mount React DOM in jsdom, including StrictMode subscription cleanup. These tests do not cover a live backend or a real browser. `pnpm pack` runs the package checks before creating a local tarball; it does not publish to npm. diff --git a/src/core/client.ts b/src/core/client.ts index d087f9e..05e5f4b 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,38 +1,69 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { atom } from "nanostores"; -import { AuthError, authError, sessionChanged } from "./errors.js"; +import { AuthError, toAuthError, sessionChanged } from "./errors.js"; import { createPersistence, type StoredSession } from "./persistence.js"; +import { obtainTokens } from "./refresh.js"; +import { runTask } from "./task.js"; +import { setLongTimeout, proactiveRefreshDelay } from "./timers.js"; +import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js"; import type { AuthClient, AuthOptions, AuthState, + AuthStatus, Session, SignInInput, User, UserInput, } from "./types.js"; -import { - isRecord, - receiveTokens, - runTask, - validateUser, - type SessionTokens, -} from "./validation.js"; +import { sameUser, validateUser } from "./user.js"; type InternalSession = SessionTokens & { id: string; user: U }; -/** Starts work nobody awaits. Failures are already published to `state.error`. */ +/** How a refresh ended, inside the cross-tab lock. */ +type RefreshOutcome = + /** Storage no longer holds this tab's session; follow what it holds. */ + | { kind: "stale" } + /** The backend rejected the refresh token, so the session ended. */ + | { kind: "signedOut" } + | { kind: "installed"; session: Session }; + +/** + * Starts work nobody awaits. Its failures are already published to + * `state.error`, or are `SESSION_CHANGED` from work a newer session superseded. + */ function inBackground(work: Promise) { work.catch(() => {}); } -function tokensOf(saved: SessionTokens): SessionTokens { - return { - accessToken: saved.accessToken, - refreshToken: saved.refreshToken, - expiresAt: saved.expiresAt, - }; +/** Statuses that expose `user`. Adding a status will not compile until it is listed. */ +const SIGNED_IN = { + initializing: false, + authenticated: true, + refreshing: true, + unauthenticated: false, + unavailable: false, +} satisfies Record; + +/** + * Statuses that guards treat alike, so moving between them leaves `version` + * unchanged. A refresh keeps the user signed in for guards. + */ +const GUARD_STATUS = { + initializing: "initializing", + authenticated: "authenticated", + refreshing: "authenticated", + unauthenticated: "unauthenticated", + unavailable: "unavailable", +} satisfies Record; + +/** Whether `error` means the saved session itself is unusable, not storage. */ +function rejectsSaved(error: AuthError) { + return ( + error.code === "INVALID_SESSION" || + error.code === "USER_VALIDATION_FAILED" + ); } /** @@ -74,33 +105,43 @@ export function createAuth( version: 0, error: null, }); - let session: InternalSession | null = null; + // Lifecycle. `epoch` increments whenever the session is replaced or pending + // work is cancelled; async work captures it and stops once it changes, and + // `controller` aborts that work. Timers and the storage watch run only + // while `mounts`, the number of Router connections, is above zero. let epoch = 0; - let profileVersion = 0; - let profileIntent = 0; let controller = new AbortController(); - let initialization: Promise | undefined; + let mounts = 0; + let unwatch: (() => void) | undefined; + let cancelExpiry: (() => void) | undefined; + let cancelProactive: (() => void) | undefined; + + // Restoration. `session` is meaningful only once `initialized` is true; + // `initialization` is the restore in progress, if any. + let session: InternalSession | null = null; let initialized = false; - let rejectedToken: string | undefined; + let initialization: Promise | undefined; + + // Refresh. `flight` is the single refresh in progress. `failure` is its + // error, kept only while access is unusable, so `getSession` rethrows it + // until `retry()`. `refreshAttempted` allows one proactive attempt per + // installed token. `rejectedToken` is an access token the backend refused. 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; + let rejectedToken: string | undefined; + + // Profile. `profileVersion` changes whenever the user is replaced, so a + // refresh that began earlier cannot overwrite it. `profileIntent` orders + // overlapping `updateUser` calls; only the latest commits. + let profileVersion = 0; + let profileIntent = 0; 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. @@ -128,26 +169,20 @@ export function createAuth( }); } + function deriveStatus(): AuthStatus { + if (!initialized) return "initializing"; + if (!session) return "unauthenticated"; + if (valid()) return "authenticated"; + return flight ? "refreshing" : "unavailable"; + } + function publish(error: AuthError | null = null) { - const status = !initialized - ? "initializing" - : !session - ? "unauthenticated" - : valid() - ? "authenticated" - : flight - ? "refreshing" - : "unavailable"; - const user = - status === "authenticated" || status === "refreshing" - ? session!.user - : null; + const status = deriveStatus(); + const user = SIGNED_IN[status] ? session!.user : null; const sessionId = session?.id ?? null; const previous = store.get(); - const guardStatus = (value: AuthState["status"]) => - value === "refreshing" ? "authenticated" : value; const changed = - guardStatus(previous.status) !== guardStatus(status) || + GUARD_STATUS[previous.status] !== GUARD_STATUS[status] || previous.user !== user || previous.sessionId !== sessionId; store.set( @@ -162,8 +197,9 @@ export function createAuth( } function stopTimers() { - clearTimeout(proactiveTimer); - clearTimeout(expiryTimer); + cancelExpiry?.(); + cancelProactive?.(); + cancelExpiry = cancelProactive = undefined; } function schedule() { @@ -171,27 +207,15 @@ export function createAuth( if (!mounts || !session || session.expiresAt === undefined) return; const remaining = session.expiresAt - Date.now(); if (remaining <= 0) return; - expiryTimer = setTimeout( - () => { - if (valid()) { - schedule(); - return; - } - inBackground(getSession()); - }, - Math.min(remaining, 2_147_483_647), - ); + cancelExpiry = setLongTimeout(() => { + // The browser clock may have moved while the timer waited. + if (valid()) schedule(); + else inBackground(getSession()); + }, remaining); if (options.refresh && session.refreshToken && !refreshAttempted) { - const delay = remaining - Math.min(60_000, remaining / 2); - proactiveTimer = setTimeout( - () => { - if (delay > 2_147_483_647) { - schedule(); - return; - } - inBackground(refreshSession(snapshot(), false)); - }, - Math.min(delay, 2_147_483_647), + cancelProactive = setLongTimeout( + () => inBackground(refreshSession(snapshot(), false)), + proactiveRefreshDelay(remaining), ); } } @@ -220,11 +244,27 @@ export function createAuth( publish(error); } + /** Signs out every tab because the saved session cannot be used, then clears it. */ + function discardSaved(error: AuthError) { + forget(error); + try { + persistence.clear(); + } catch (cleanup) { + const failure = new AuthError( + "PERSISTENCE_FAILED", + "Could not clear the invalid saved session", + { cause: new AggregateError([error, cleanup]) }, + ); + publish(failure); + throw failure; + } + } + function commit(next: InternalSession) { try { persistence.write(next); } catch (cause) { - const error = authError( + const error = toAuthError( "PERSISTENCE_FAILED", "Could not save the session", cause, @@ -284,7 +324,10 @@ export function createAuth( /** * 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. + * late or repeated event cannot apply stale data. Unreadable or malformed + * storage is ignored, keeping this tab's session; a well-formed session + * whose user this tab's schema rejects signs every tab out, as restoring + * it would. */ async function sync() { if (!initialized) { @@ -302,30 +345,49 @@ export function createAuth( return; } if (stored.id !== session?.id) return reload(); - const sameUser = - JSON.stringify(stored.user) === JSON.stringify(session.user); - const sameTokens = + const userUnchanged = sameUser(stored.user, session.user); + const tokensUnchanged = stored.accessToken === session.accessToken && stored.refreshToken === session.refreshToken && stored.expiresAt === session.expiresAt; - if (sameUser && sameTokens) return; + if (userUnchanged && tokensUnchanged) return; const expected = epoch; - const user = sameUser - ? session.user - : await runTask( - () => validateUser(options.userSchema, stored.user), - controller.signal, - ); + let user: U; + try { + user = userUnchanged + ? session.user + : await runTask( + () => validateUser(options.userSchema, stored.user), + controller.signal, + ); + } catch (cause) { + if (epoch !== expected) return; + const error = toAuthError( + "PERSISTENCE_FAILED", + "Could not apply another tab's session", + cause, + ); + if (error.code === "SESSION_CHANGED") return; + if (!rejectsSaved(error)) { + publish(error); + return; + } + // A newer save arrived during validation; its own event applies it. + const current = latest(stored.id); + if (!current || !sameUser(current.user, stored.user)) return; + discardSaved(error); + return; + } const now = latest(stored.id); if ( epoch !== expected || !now || now.accessToken !== stored.accessToken || - JSON.stringify(now.user) !== JSON.stringify(stored.user) + !sameUser(now.user, stored.user) ) return; - if (!sameTokens) refreshAttempted = false; - if (!sameUser) profileVersion++; + if (!tokensUnchanged) refreshAttempted = false; + if (!userUnchanged) profileVersion++; install({ ...tokensOf(stored), id: stored.id, user }); } @@ -360,27 +422,13 @@ export function createAuth( schedule(); } catch (cause) { assertEpoch(expected); - const error = authError( + const error = toAuthError( "PERSISTENCE_FAILED", "Could not restore auth", cause, ); - if ( - error.code === "INVALID_SESSION" || - error.code === "USER_VALIDATION_FAILED" - ) { - forget(error); - try { - persistence.clear(); - } catch (cleanup) { - const failure = new AuthError( - "PERSISTENCE_FAILED", - "Could not clear the invalid saved session", - { cause: new AggregateError([error, cleanup]) }, - ); - publish(failure); - throw failure; - } + if (rejectsSaved(error)) { + discardSaved(error); return; } const previous = store.get(); @@ -429,7 +477,7 @@ export function createAuth( profileVersion++; commit({ ...tokens, id: crypto.randomUUID(), user }); } catch (cause) { - throw authError( + throw toAuthError( "USER_VALIDATION_FAILED", "Could not accept the user", cause, @@ -450,7 +498,7 @@ export function createAuth( try { persistence.clear(); } catch (cause) { - const error = authError( + const error = toAuthError( "PERSISTENCE_FAILED", "Could not clear the saved session", cause, @@ -515,58 +563,22 @@ export function createAuth( failure = undefined; const promise = (async () => { try { - const outcome = await persistence.exclusive(lifecycle, async () => { + const run = async (): Promise> => { 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); + if (stored === null) return { kind: "stale" }; + const result = await runTask( + (signal) => + obtainTokens({ + current, + stored, + refresh: refreshHandler, + userSchema: options.userSchema, + rejectedToken, + signal, + }), + lifecycle, + ); assertEpoch(expected); if (result === null) { const after = latest(current.id); @@ -575,9 +587,9 @@ export function createAuth( (after && after.accessToken !== (stored ?? current).accessToken) ) - return "stale" as const; + return { kind: "stale" }; await signOut(); - return null; + return { kind: "signedOut" }; } if ( result.tokens.expiresAt !== undefined && @@ -600,13 +612,21 @@ export function createAuth( : session!.user; rejectedToken = undefined; refreshAttempted = false; - if (latest(current.id) === null) return "stale" as const; + if (latest(current.id) === null) return { kind: "stale" }; const next = { ...result.tokens, id: current.id, user }; if (result.adopted && profile === profileVersion) install(next); else commit(next); - return snapshot(); - }); - return outcome === "stale" ? followStorage(current.id) : outcome; + return { kind: "installed", session: snapshot() }; + }; + const outcome = await persistence.exclusive(lifecycle, run); + switch (outcome.kind) { + case "stale": + return followStorage(current.id); + case "signedOut": + return null; + case "installed": + return outcome.session; + } } catch (cause) { if (epoch !== expected) { if ( @@ -617,7 +637,7 @@ export function createAuth( throw sessionChanged(); } flight = undefined; - const error = authError( + const error = toAuthError( "REFRESH_FAILED", "Could not refresh the session", cause, diff --git a/src/core/errors.ts b/src/core/errors.ts index c5bb66b..caa4af1 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -8,19 +8,30 @@ export type AuthErrorCode = | "SESSION_CHANGED" | "UNAUTHENTICATED"; +export type AuthErrorOptions = ErrorOptions & { + /** Schema issues behind a `USER_VALIDATION_FAILED` error. */ + issues?: readonly StandardSchemaV1.Issue[]; +}; + export class AuthError extends Error { + readonly issues?: readonly StandardSchemaV1.Issue[]; + constructor( readonly code: AuthErrorCode, message: string, - options?: ErrorOptions, - readonly issues?: readonly StandardSchemaV1.Issue[], + options?: AuthErrorOptions, ) { super(message, options); this.name = "AuthError"; + this.issues = options?.issues; } } -export function authError( +/** + * Returns `cause` unchanged when it is already an `AuthError`; otherwise wraps + * it with `code`. The code is a fallback, not a guarantee. + */ +export function toAuthError( code: AuthErrorCode, message: string, cause: unknown, diff --git a/src/core/json.ts b/src/core/json.ts new file mode 100644 index 0000000..0ac88f3 --- /dev/null +++ b/src/core/json.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 2eee88c..e9a13bd 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -1,9 +1,18 @@ import { AuthError } from "./errors.js"; -import { isRecord, validateTokens, type SessionTokens } from "./validation.js"; +import { isRecord } from "./json.js"; +import { validateTokens, type SessionTokens } from "./tokens.js"; export type StoredSession = SessionTokens & { id: string; user: unknown }; -export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { +/** Format of the saved entry; entries in any other format are not restored. */ +const STORAGE_VERSION = 3; +/** Thirty days. */ +const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; + +export function createPersistence( + name: string, + maxAge = DEFAULT_MAX_AGE_SECONDS, +) { if ( !/^[a-zA-Z0-9_-]{1,64}$/.test(name) || !Number.isSafeInteger(maxAge) || @@ -58,7 +67,7 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { const saved: unknown = JSON.parse(text); if ( !isRecord(saved) || - saved.v !== 3 || + saved.v !== STORAGE_VERSION || typeof saved.id !== "string" || !saved.id || !("user" in saved) || @@ -88,7 +97,7 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { localStorage.setItem( key, JSON.stringify({ - v: 3, + v: STORAGE_VERSION, ...session, persistUntil: Date.now() + maxAge * 1000, }), diff --git a/src/core/refresh.ts b/src/core/refresh.ts new file mode 100644 index 0000000..f24961d --- /dev/null +++ b/src/core/refresh.ts @@ -0,0 +1,86 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +import { AuthError } from "./errors.js"; +import { isRecord } from "./json.js"; +import type { StoredSession } from "./persistence.js"; +import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js"; +import type { RefreshFn, User, UserInput } from "./types.js"; +import { sameUser, validateUser } from "./user.js"; + +/** The shortest remaining lifetime worth installing or adopting a token for. */ +const MIN_TOKEN_LIFETIME_MS = 5_000; + +/** Types a refresh callback against a user schema before passing it to `createAuth`. */ +export function createRefreshFn( + handler: RefreshFn, +): RefreshFn { + return handler; +} + +export type ObtainedTokens = { + tokens: SessionTokens; + /** The replacement user, or `undefined` to keep the current one. */ + user: U | undefined; + /** Whether the tokens came from another tab's save, not the callback. */ + adopted: boolean; +}; + +/** + * Gets tokens to replace `current`'s: another tab's newer saved tokens while + * they stay usable, otherwise the refresh callback's. Resolves `null` when the + * backend rejected the refresh token. + */ +export async function obtainTokens(input: { + current: SessionTokens & { user: User }; + /** What storage holds for this session; `undefined` when unreadable. */ + stored: StoredSession | undefined; + refresh: RefreshFn>; + userSchema: S; + /** An access token the backend refused, which must not be adopted. */ + rejectedToken: string | undefined; + signal: AbortSignal; +}): Promise> | null> { + const { current, stored, refresh, userSchema, rejectedToken, signal } = + input; + const savedUser = (saved: StoredSession) => + sameUser(saved.user, current.user) + ? current.user + : validateUser(userSchema, saved.user); + if ( + stored && + stored.accessToken !== current.accessToken && + stored.accessToken !== rejectedToken && + (stored.expiresAt === undefined || + stored.expiresAt >= Date.now() + MIN_TOKEN_LIFETIME_MS) + ) { + return { + tokens: tokensOf(stored), + user: await savedUser(stored), + adopted: true, + }; + } + const refreshToken = stored?.refreshToken ?? current.refreshToken!; + const value = await refresh({ refreshToken, signal }); + if (value === null) return null; + const tokens = receiveTokens(value); + if ( + tokens.expiresAt !== undefined && + tokens.expiresAt < Date.now() + MIN_TOKEN_LIFETIME_MS + ) { + throw new AuthError( + "INVALID_SESSION", + "Refreshed access token must last at least five seconds", + ); + } + const user = + isRecord(value) && value.user !== undefined + ? await validateUser(userSchema, value.user) + : stored + ? await savedUser(stored) + : undefined; + return { + tokens: { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken }, + user, + adopted: false, + }; +} diff --git a/src/core/task.ts b/src/core/task.ts new file mode 100644 index 0000000..2820dc3 --- /dev/null +++ b/src/core/task.ts @@ -0,0 +1,36 @@ +/** Deadline for validation and refresh work. */ +const TASK_TIMEOUT_MS = 15_000; + +/** + * Runs `work` with a signal tied to `parent` and a timeout. Rejects on abort or + * timeout even when `work` ignores the signal, so late results never commit. + */ +export function runTask( + work: (signal: AbortSignal) => Promise, + parent: AbortSignal, + timeoutMs = TASK_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const abort = () => controller.abort(parent.reason); + parent.addEventListener("abort", abort, { once: true }); + if (parent.aborted) abort(); + const timer = setTimeout( + () => controller.abort(new Error("Auth operation timed out")), + timeoutMs, + ); + return new Promise((resolve, reject) => { + const fail = () => reject(controller.signal.reason); + controller.signal.addEventListener("abort", fail, { once: true }); + if (controller.signal.aborted) fail(); + else + Promise.resolve() + .then(() => { + controller.signal.throwIfAborted(); + return work(controller.signal); + }) + .then(resolve, reject); + }).finally(() => { + clearTimeout(timer); + parent.removeEventListener("abort", abort); + }); +} diff --git a/src/core/timers.ts b/src/core/timers.ts new file mode 100644 index 0000000..7c28ce7 --- /dev/null +++ b/src/core/timers.ts @@ -0,0 +1,32 @@ +/** The longest delay `setTimeout` supports, about 24.8 days. */ +const MAX_TIMER_DELAY_MS = 2_147_483_647; +/** Proactive refresh starts this long before expiry, or halfway, if sooner. */ +const REFRESH_LEAD_MS = 60_000; + +/** + * Like `setTimeout`, but a delay beyond the platform limit re-arms instead of + * firing early. Each re-arm measures from the clock, not elapsed timer time, + * so a wait that outlasts a suspended device stays anchored to its deadline. + * Returns a function that cancels the timer. + */ +export function setLongTimeout( + callback: () => void, + delay: number, +): () => void { + const deadline = Date.now() + delay; + let timer: ReturnType; + const arm = () => { + const remaining = deadline - Date.now(); + timer = + remaining > MAX_TIMER_DELAY_MS + ? setTimeout(arm, MAX_TIMER_DELAY_MS) + : setTimeout(callback, Math.max(remaining, 0)); + }; + arm(); + return () => clearTimeout(timer); +} + +/** How long to wait before refreshing a token that expires in `remaining` ms. */ +export function proactiveRefreshDelay(remaining: number): number { + return remaining - Math.min(REFRESH_LEAD_MS, remaining / 2); +} diff --git a/src/core/tokens.ts b/src/core/tokens.ts new file mode 100644 index 0000000..acab375 --- /dev/null +++ b/src/core/tokens.ts @@ -0,0 +1,108 @@ +import { AuthError } from "./errors.js"; +import { isRecord } from "./json.js"; +import type { Tokens } from "./types.js"; + +/** Session tokens with `expiresAt` measured on this browser's clock. */ +export type SessionTokens = Omit; + +/** Copies only the token fields, dropping a stored session's other data. */ +export function tokensOf(saved: SessionTokens): SessionTokens { + return { + accessToken: saved.accessToken, + refreshToken: saved.refreshToken, + expiresAt: saved.expiresAt, + }; +} + +function jwtClaims(token: string): { exp?: number; iat?: number } { + try { + const parts = token.split("."); + 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)) 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 {}; + } +} + +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" || + !value.accessToken.trim() + ) { + throw new AuthError( + "INVALID_SESSION", + "A nonempty access token is required", + ); + } + if ( + value.refreshToken !== undefined && + (typeof value.refreshToken !== "string" || !value.refreshToken.trim()) + ) { + throw new AuthError( + "INVALID_SESSION", + "Refresh token must be a nonempty string", + ); + } + 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", + "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 { + ...tokens, + expiresAt: + iat !== undefined && exp > iat + ? Date.now() + (exp - iat) * 1000 + : exp * 1000, + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index 061e55a..b53c41a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -75,13 +75,6 @@ export type RefreshFn = (context: { 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. */ @@ -96,21 +89,24 @@ export type Session = Readonly<{ user: U; }>; +/** + * - `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. + */ +export type AuthStatus = + | "initializing" + | "authenticated" + | "refreshing" + | "unauthenticated" + | "unavailable"; + /** Reactive auth snapshot. Tokens are intentionally excluded. */ export type AuthState = Readonly<{ - /** - * - `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"; + /** See `AuthStatus`. */ + status: AuthStatus; /** The validated user while `authenticated` or `refreshing`; otherwise `null`. */ user: U | null; /** The current `Session.id`, or `null` when signed out. */ diff --git a/src/core/user.ts b/src/core/user.ts new file mode 100644 index 0000000..556d6cd --- /dev/null +++ b/src/core/user.ts @@ -0,0 +1,94 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +import { AuthError } from "./errors.js"; +import { isRecord } from "./json.js"; +import type { User } from "./types.js"; + +/** + * Rejects values that JSON would silently drop or change, then deep-freezes the + * value so callers cannot mutate auth state without notifying subscribers. + */ +function freezeJson(value: unknown, ancestors = new Set()): void { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) + return; + if (typeof value === "number" && Number.isFinite(value)) return; + if (typeof value !== "object" || value === null || ancestors.has(value)) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "User must contain only JSON values", + ); + } + const proto = Object.getPrototypeOf(value); + if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "User must contain plain objects", + ); + } + ancestors.add(value); + if (Array.isArray(value)) { + if (Object.keys(value).length !== value.length) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "User arrays must be dense", + ); + } + for (const item of value) freezeJson(item, ancestors); + } else { + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key)!; + if ( + typeof key !== "string" || + !descriptor.enumerable || + !("value" in descriptor) + ) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "User properties must be plain JSON data", + ); + } + freezeJson(descriptor.value, ancestors); + } + } + ancestors.delete(value); + Object.freeze(value); +} + +export async function validateUser( + schema: S, + input: unknown, +): Promise> { + try { + const result = await schema["~standard"].validate(structuredClone(input)); + if (result.issues) { + throw new AuthError("USER_VALIDATION_FAILED", "Invalid user", { + issues: result.issues, + }); + } + if (!isRecord(result.value)) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "User must be an object", + ); + } + freezeJson(result.value); + return result.value; + } catch (cause) { + if (cause instanceof AuthError) throw cause; + throw new AuthError("USER_VALIDATION_FAILED", "User validation failed", { + cause, + }); + } +} + +/** + * Whether two validated users hold the same data. Both are JSON produced by + * the same schema, so serialization compares them reliably. + */ +export function sameUser(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/src/core/validation.ts b/src/core/validation.ts deleted file mode 100644 index e9067b5..0000000 --- a/src/core/validation.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; - -import { AuthError } from "./errors.js"; -import type { Tokens, User } from "./types.js"; - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** - * Rejects values that JSON would silently drop or change, then deep-freezes the - * value so callers cannot mutate auth state without notifying subscribers. - */ -function freezeJson(value: unknown, ancestors = new Set()): void { - if ( - value === null || - typeof value === "string" || - typeof value === "boolean" - ) - return; - if (typeof value === "number" && Number.isFinite(value)) return; - if (typeof value !== "object" || value === null || ancestors.has(value)) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "User must contain only JSON values", - ); - } - const proto = Object.getPrototypeOf(value); - if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "User must contain plain objects", - ); - } - ancestors.add(value); - if (Array.isArray(value)) { - if (Object.keys(value).length !== value.length) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "User arrays must be dense", - ); - } - for (const item of value) freezeJson(item, ancestors); - } else { - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key)!; - if ( - typeof key !== "string" || - !descriptor.enumerable || - !("value" in descriptor) - ) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "User properties must be plain JSON data", - ); - } - freezeJson(descriptor.value, ancestors); - } - } - ancestors.delete(value); - Object.freeze(value); -} - -export async function validateUser( - schema: S, - input: unknown, -): Promise> { - try { - const result = await schema["~standard"].validate(structuredClone(input)); - if (result.issues) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "Invalid user", - undefined, - result.issues, - ); - } - if (!isRecord(result.value)) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "User must be an object", - ); - } - freezeJson(result.value); - return result.value; - } catch (cause) { - if (cause instanceof AuthError) throw cause; - throw new AuthError("USER_VALIDATION_FAILED", "User validation failed", { - cause, - }); - } -} - -/** 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 {}; - 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)) 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 {}; - } -} - -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" || - !value.accessToken.trim() - ) { - throw new AuthError( - "INVALID_SESSION", - "A nonempty access token is required", - ); - } - if ( - value.refreshToken !== undefined && - (typeof value.refreshToken !== "string" || !value.refreshToken.trim()) - ) { - throw new AuthError( - "INVALID_SESSION", - "Refresh token must be a nonempty string", - ); - } - 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", - "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 { - ...tokens, - expiresAt: - iat !== undefined && exp > iat - ? Date.now() + (exp - iat) * 1000 - : exp * 1000, - }; -} - -/** - * Runs `work` with a signal tied to `parent` and a timeout. Rejects on abort or - * timeout even when `work` ignores the signal, so late results never commit. - */ -export function runTask( - work: (signal: AbortSignal) => Promise, - parent: AbortSignal, - timeoutMs = 15_000, -): Promise { - const controller = new AbortController(); - const abort = () => controller.abort(parent.reason); - parent.addEventListener("abort", abort, { once: true }); - if (parent.aborted) abort(); - const timer = setTimeout( - () => controller.abort(new Error("Auth operation timed out")), - timeoutMs, - ); - return new Promise((resolve, reject) => { - const fail = () => reject(controller.signal.reason); - controller.signal.addEventListener("abort", fail, { once: true }); - if (controller.signal.aborted) fail(); - else - Promise.resolve() - .then(() => { - controller.signal.throwIfAborted(); - return work(controller.signal); - }) - .then(resolve, reject); - }).finally(() => { - clearTimeout(timer); - parent.removeEventListener("abort", abort); - }); -} diff --git a/src/index.ts b/src/index.ts index 2df8bf3..6650b69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,14 @@ export { createAuth } from "./core/client.js"; -export { createRefreshFn } from "./core/types.js"; +export { createRefreshFn } from "./core/refresh.js"; export { AuthError } from "./core/errors.js"; -export type { AuthErrorCode } from "./core/errors.js"; +export type { AuthErrorCode, AuthErrorOptions } from "./core/errors.js"; export type { AuthClient, AuthOptions, AuthState, + AuthStatus, Tokens, SignInInput, Session, diff --git a/src/react/connect.ts b/src/react/connect.ts index 736c48b..99deb89 100644 --- a/src/react/connect.ts +++ b/src/react/connect.ts @@ -39,48 +39,3 @@ export function connectAuth(router: AuthRouter): () => void { connections.set(router, disconnect); return disconnect; } - -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- \u007f]/.test(value) - ) - return "/"; - const base = "https://auth.invalid"; - try { - const url = new URL(value, base); - 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 50d438d..930e3aa 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,5 +1,6 @@ -export { connectAuth, safeReturnTo } from "./connect.js"; +export { connectAuth } from "./connect.js"; export { useAuth, useAuthClient } from "./hooks.js"; +export { safeReturnTo } from "./return-to.js"; -export type { SafeReturnToOptions } from "./connect.js"; +export type { SafeReturnToOptions } from "./return-to.js"; export type { RegisteredAuth, RegisteredAuthState } from "./hooks.js"; diff --git a/src/react/return-to.ts b/src/react/return-to.ts new file mode 100644 index 0000000..2431805 --- /dev/null +++ b/src/react/return-to.ts @@ -0,0 +1,46 @@ +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("//") || + // Rejecting backslashes, spaces, and control characters is the point. + // oxlint-disable-next-line no-control-regex + /[\\\u0000- \u007f]/.test(value) + ) + return "/"; + const base = "https://auth.invalid"; + try { + const url = new URL(value, base); + 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 "/"; + } +} From 439a1d1b6e83eb690e9f57b7e16a68de3ef2f48e Mon Sep 17 00:00:00 2001 From: monarcode Date: Sun, 4 Oct 2026 07:54:05 +0100 Subject: [PATCH 4/4] test: split auth tests by area and convert the suite to TypeScript - Replace the monolithic `auth.test.mjs` with typed session, refresh, expiry, Router, HTTP, return-URL and tab tests that share `tests/helpers.ts` - Run `*.test.ts` directly with Node and type-check them via `tests/tsconfig.tests.json` in `test:types` - Bump the version to `0.1.0-alpha.3` and add `@types/jsdom` --- package.json | 7 +- pnpm-lock.yaml | 17 + tests/auth.test.mjs | 1475 ------------------- tests/expiry.test.ts | 196 +++ tests/helpers.ts | 249 ++++ tests/http.test.ts | 105 ++ tests/{package.test.mjs => package.test.ts} | 6 +- tests/{react.test.mjs => react.test.ts} | 62 +- tests/refresh.test.ts | 213 +++ tests/{release.test.mjs => release.test.ts} | 19 +- tests/return-to.test.ts | 46 + tests/router.test.ts | 247 ++++ tests/session.test.ts | 400 +++++ tests/tabs.test.ts | 283 ++++ tests/tsconfig.tests.json | 18 + 15 files changed, 1837 insertions(+), 1506 deletions(-) delete mode 100644 tests/auth.test.mjs create mode 100644 tests/expiry.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/http.test.ts rename tests/{package.test.mjs => package.test.ts} (90%) rename tests/{react.test.mjs => react.test.ts} (78%) create mode 100644 tests/refresh.test.ts rename tests/{release.test.mjs => release.test.ts} (87%) create mode 100644 tests/return-to.test.ts create mode 100644 tests/router.test.ts create mode 100644 tests/session.test.ts create mode 100644 tests/tabs.test.ts create mode 100644 tests/tsconfig.tests.json diff --git a/package.json b/package.json index a2d87f5..9f70960 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/session-kit", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "description": "Schema-driven browser sessions for React and TanStack Router", "keywords": [ "auth", @@ -57,8 +57,8 @@ "build": "pnpm run clean && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --conditions=development --test tests/*.test.mjs", - "test:types": "tsc -p tests/tsconfig.json && tsc -p tests/tsconfig.bundler.json && pnpm run test:docs", + "test": "node --conditions=development --test tests/*.test.ts", + "test:types": "tsc -p tests/tsconfig.tests.json && tsc -p tests/tsconfig.json && tsc -p tests/tsconfig.bundler.json && pnpm run test:docs", "test:docs": "node scripts/check-readme.mjs", "check": "pnpm run lint && pnpm run format:check && pnpm run build && pnpm test && pnpm run test:types", "prepack": "pnpm run check" @@ -70,6 +70,7 @@ }, "devDependencies": { "@tanstack/react-router": "1.170.38", + "@types/jsdom": "21.1.7", "@types/node": "22.20.4", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 260de1a..f5fd154 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@tanstack/react-router': specifier: 1.170.38 version: 1.170.38(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/jsdom': + specifier: 21.1.7 + version: 21.1.7 '@types/node': specifier: 22.20.4 version: 22.20.4 @@ -346,6 +349,9 @@ packages: '@tanstack/store@0.11.1': resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + '@types/node@22.20.4': resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} @@ -357,6 +363,9 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/use-sync-external-store@1.7.0': resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} @@ -760,6 +769,12 @@ snapshots: '@tanstack/store@0.11.1': {} + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 22.20.4 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/node@22.20.4': dependencies: undici-types: 6.21.0 @@ -772,6 +787,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/tough-cookie@4.0.5': {} + '@types/use-sync-external-store@1.7.0': {} agent-base@7.1.4: {} diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs deleted file mode 100644 index 4558f19..0000000 --- a/tests/auth.test.mjs +++ /dev/null @@ -1,1475 +0,0 @@ -import assert from "node:assert/strict"; -import { test, beforeEach, afterEach } from "node:test"; - -import { createAuth } from "@monarcode/session-kit"; -import { createAuthFetch } from "@monarcode/session-kit/http"; -import { connectAuth, safeReturnTo } from "@monarcode/session-kit/react"; -import { z } from "zod"; - -const userSchema = z.object({ - id: z.string(), - email: z.string(), - role: z.enum(["admin", "member"]).default("member"), -}); -const user = { id: "alice", email: "alice@example.com", role: "member" }; -let cookies, values, blockedStorage, blockedWrites, blockedDeletes, cleanups; -const SESSION = "test:auth:session"; -let originalGlobals; -const browserGlobals = [ - "location", - "document", - "localStorage", - "fetch", - "self", - "scrollTo", - "window", - "navigator", - "addEventListener", - "removeEventListener", -]; -const setGlobal = (name, value) => - Object.defineProperty(globalThis, name, { - configurable: true, - writable: true, - value, - }); -const deferred = () => { - let resolve, reject; - const promise = new Promise((a, b) => { - resolve = a; - reject = b; - }); - return { promise, resolve, reject }; -}; -const flush = async () => { - for (let i = 0; i < 40; i++) await Promise.resolve(); -}; -const input = (overrides = {}) => ({ - accessToken: "access-1", - refreshToken: "refresh-1", - user, - ...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(); - values = new Map(); - blockedStorage = false; - blockedWrites = new Set(); - blockedDeletes = new Set(); - cleanups = []; - locks = createLocks(); - storageEvents = new EventTarget(); - originalGlobals = new Map( - browserGlobals.map((name) => [ - name, - Object.getOwnPropertyDescriptor(globalThis, name), - ]), - ); - 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("; "); - }, - set cookie(text) { - const pair = text.split(";")[0]; - const index = pair.indexOf("="); - const key = pair.slice(0, index), - value = pair.slice(index + 1); - if (text.includes("Max-Age=0")) cookies.delete(key); - else cookies.set(key, value); - }, - }); - setGlobal("localStorage", { - getItem(key) { - if (blockedStorage) throw new Error("Blocked"); - return values.get(key) ?? null; - }, - setItem(key, value) { - if (blockedStorage || blockedWrites.has(key)) throw new Error("Quota"); - values.set(key, value); - }, - removeItem(key) { - if (blockedStorage || blockedDeletes.has(key)) - throw new Error("Blocked"); - values.delete(key); - }, - }); -}); -afterEach(() => { - try { - for (const cleanup of cleanups.toReversed()) cleanup(); - } finally { - for (const [name, descriptor] of originalGlobals) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else delete globalThis[name]; - } - } -}); - -test("schema defaults and custom fields survive restoration; tokens stay out of the profile/state", async () => { - const auth = client(); - await auth.signIn(input({ user: { id: "alice", email: user.email } })); - assert.equal(auth.state.get().user.role, "member"); - 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(); - assert.equal(restored.user.email, user.email); - assert.equal(restored.accessToken, "access-1"); -}); - -test("user output is frozen without freezing caller input", async () => { - const auth = client(); - const data = { ...user }; - await auth.signIn(input({ user: data })); - assert.equal(Object.isFrozen(auth.state.get().user), true); - assert.equal(Object.isFrozen(data), false); -}); - -test("invalid sign-in leaves the current session usable", async () => { - const auth = client(); - await auth.signIn(input()); - const before = await auth.getSession(); - await assert.rejects( - auth.signIn(input({ user: { id: 4 } })), - code("USER_VALIDATION_FAILED"), - ); - assert.equal((await auth.getSession()).id, before.id); -}); - -for (const timeout of [false, true]) { - for (const canRefresh of [false, true]) { - test(`expiry during ${timeout ? "timed-out" : "failed"} sign-in ${canRefresh ? "refreshes" : "clears"} the previous session`, async (t) => { - t.mock.timers.enable({ - apis: ["Date", "setTimeout"], - now: 1_000_000, - }); - const validation = deferred(); - const refresh = deferred(); - let refreshCalls = 0; - const auth = client({ - userSchema: userSchema.refine(async (value) => - value.id === "bob" ? validation.promise : true, - ), - refresh: canRefresh - ? async () => { - refreshCalls++; - return refresh.promise; - } - : undefined, - }); - await auth.signIn(input({ expiresAt: 1_001_000 })); - const previousId = auth.state.get().sessionId; - cleanups.push(auth.mount()); - await flush(); - const pending = auth.signIn( - input({ - accessToken: "bob-token", - user: { ...user, id: "bob" }, - }), - ); - const checked = assert.rejects( - pending, - code("USER_VALIDATION_FAILED"), - ); - await flush(); - t.mock.timers.tick(1_000); - if (timeout) t.mock.timers.tick(14_000); - else validation.resolve(false); - await checked; - await flush(); - if (canRefresh) { - assert.equal(refreshCalls, 1); - 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, - }); - await flush(); - assert.equal(auth.state.get().status, "authenticated"); - assert.equal(auth.state.get().sessionId, previousId); - assert.equal(auth.state.get().user.id, "alice"); - } else { - assert.equal(auth.state.get().status, "unauthenticated"); - assert.equal(auth.state.get().user, null); - assert.equal(auth.state.get().sessionId, null); - assert.equal(values.size, 0); - } - if (timeout) { - validation.resolve(true); - await flush(); - assert.notEqual(auth.state.get().user?.id, "bob"); - } - }); - } -} - -test("expiry during validation does not cancel a successful new sign-in", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const validation = deferred(); - const auth = client({ - userSchema: userSchema.refine(async (value) => - value.id === "bob" ? validation.promise : true, - ), - }); - await auth.signIn(input({ expiresAt: 1_001_000 })); - const previousId = auth.state.get().sessionId; - cleanups.push(auth.mount()); - await flush(); - const pending = auth.signIn( - input({ - accessToken: "bob-token", - user: { ...user, id: "bob" }, - }), - ); - await flush(); - t.mock.timers.tick(1_000); - await flush(); - validation.resolve(true); - await pending; - assert.equal(auth.state.get().status, "authenticated"); - assert.equal(auth.state.get().user.id, "bob"); - assert.notEqual(auth.state.get().sessionId, previousId); -}); - -test("failed sign-in recovery retains expired credentials without a refresh retry loop", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const validation = deferred(); - let refreshCalls = 0; - const auth = client({ - userSchema: userSchema.refine(async (value) => - value.id === "bob" ? validation.promise : true, - ), - refresh: async () => { - refreshCalls++; - throw new Error("offline"); - }, - }); - await auth.signIn(input({ expiresAt: 1_001_000 })); - cleanups.push(auth.mount()); - await flush(); - const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); - const checked = assert.rejects(pending, code("USER_VALIDATION_FAILED")); - await flush(); - t.mock.timers.tick(1_000); - validation.resolve(false); - await checked; - await flush(); - assert.equal(refreshCalls, 1); - assert.equal(auth.state.get().status, "unavailable"); - assert.equal(auth.state.get().error.code, "REFRESH_FAILED"); - assert.ok(values.has(SESSION)); - t.mock.timers.tick(60_000); - await flush(); - assert.equal(refreshCalls, 1); -}); - -test("failed sign-in hides an expired unmounted session until demand retries refresh", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const validation = deferred(); - let refreshCalls = 0; - const auth = client({ - userSchema: userSchema.refine(async (value) => - value.id === "bob" ? validation.promise : true, - ), - refresh: async () => { - refreshCalls++; - return { accessToken: "access-2", expiresAt: Date.now() + 60_000 }; - }, - }); - await auth.signIn(input({ expiresAt: 1_001_000 })); - const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); - const checked = assert.rejects(pending, code("USER_VALIDATION_FAILED")); - await flush(); - t.mock.timers.tick(1_000); - validation.resolve(false); - await checked; - await flush(); - assert.equal(auth.state.get().status, "unavailable"); - assert.equal(auth.state.get().user, null); - assert.equal(refreshCalls, 0); - assert.ok(values.has(SESSION)); - assert.equal((await auth.getSession()).accessToken, "access-2"); - assert.equal(refreshCalls, 1); -}); - -test("a superseded sign-in cannot recover or clear the newer account", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const validation = deferred(); - const auth = client({ - userSchema: userSchema.refine(async (value) => - value.id === "bob" ? validation.promise : true, - ), - }); - await auth.signIn(input({ expiresAt: 1_001_000 })); - cleanups.push(auth.mount()); - await flush(); - const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - await auth.signIn( - input({ accessToken: "carol-token", user: { ...user, id: "carol" } }), - ); - t.mock.timers.tick(2_000); - validation.resolve(false); - await checked; - await flush(); - assert.equal(auth.state.get().status, "authenticated"); - assert.equal(auth.state.get().user.id, "carol"); - assert.equal((await auth.getSession()).accessToken, "carol-token"); -}); - -test("malformed saved session fails closed and removes saved data", async () => { - await client().signIn(input()); - 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(SESSION)); - values.set(SESSION, JSON.stringify({ ...saved, user: { id: 5 } })); - assert.equal(await client().getSession(), null); -}); - -for (const invalidUser of [false, true]) { - 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 () => { - await client().signIn(input()); - const auth = client(); - blockedStorage = true; - await assert.rejects(auth.getSession(), code("PERSISTENCE_FAILED")); - assert.equal(auth.state.get().status, "unavailable"); - blockedStorage = false; - assert.equal((await auth.getSession()).user.id, "alice"); -}); - -test("token write failure clears runtime state and fails visibly", async () => { - const auth = client(); - blockedWrites.add(SESSION); - await assert.rejects(auth.signIn(input()), code("PERSISTENCE_FAILED")); - assert.equal(auth.state.get().status, "unauthenticated"); - assert.equal(values.size, 0); -}); - -test("signout stays signed out when token deletion fails", async () => { - const auth = client(); - await auth.signIn(input()); - blockedDeletes.add(SESSION); - await assert.rejects(auth.signOut(), code("PERSISTENCE_FAILED")); - assert.equal(await auth.getSession(), null); - assert.equal(auth.state.get().user, null); -}); - -test("concurrent refresh requests share one backend operation", async () => { - const gate = deferred(); - let calls = 0; - const auth = client({ - refresh: async () => { - calls++; - return gate.promise; - }, - }); - await auth.signIn(input()); - const session = await auth.getSession(); - const a = auth.refresh(session), - b = auth.refresh(session); - await flush(); - assert.equal(calls, 1); - 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(SESSION)).refreshToken, "refresh-1"); -}); - -test("late refresh success cannot restore a signed-out session", async () => { - const gate = deferred(); - const auth = client({ refresh: async () => gate.promise }); - await auth.signIn(input()); - const pending = auth.refresh(await auth.getSession()); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - await auth.signOut(); - gate.resolve({ accessToken: "late" }); - await checked; - await flush(); - assert.equal(await auth.getSession(), null); - assert.equal(values.size, 0); -}); - -test("late refresh failure cannot clear a new account", async () => { - const gate = deferred(); - const auth = client({ refresh: async () => gate.promise }); - await auth.signIn(input()); - const pending = auth.refresh(await auth.getSession()); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - await auth.signIn( - input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), - ); - gate.reject(new Error("old failure")); - await checked; - assert.equal((await auth.getSession()).user.id, "bob"); -}); - -test("async validation cannot commit after logout", async () => { - const gate = deferred(); - const schema = z.object({ id: z.string() }).refine(async () => gate.promise); - const auth = client({ userSchema: schema }); - const pending = auth.signIn(input({ user: { id: "a" } })); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - await auth.signOut(); - gate.resolve(true); - await checked; - assert.equal(await auth.getSession(), null); -}); - -test("a refresh begun while sign-in validates cannot overwrite the new account", async () => { - const validation = deferred(), - refresh = deferred(); - const schema = userSchema.refine(async (u) => - u.id === "bob" ? validation.promise : true, - ); - const auth = client({ - userSchema: schema, - refresh: async () => refresh.promise, - }); - await auth.signIn(input()); - const old = await auth.getSession(); - const login = auth.signIn( - input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), - ); - const pending = auth.refresh(old); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - validation.resolve(true); - await login; - refresh.resolve({ accessToken: "old-rotated" }); - await checked; - assert.equal((await auth.getSession()).accessToken, "bob-token"); -}); - -test("refresh null ends the session; thrown network errors retain credentials", async () => { - const auth = client({ refresh: async () => null }); - await auth.signIn(input()); - assert.equal(await auth.refresh(await auth.getSession()), null); - const retry = client({ - refresh: async () => { - throw new Error("offline"); - }, - }); - await retry.signIn(input()); - await assert.rejects( - retry.refresh(await retry.getSession()), - code("REFRESH_FAILED"), - ); - assert.equal(retry.state.get().status, "unavailable"); - assert.ok(values.has(SESSION)); -}); - -test("a rejected access token cannot be returned as fresh", async () => { - const auth = client({ refresh: async () => ({ accessToken: "access-1" }) }); - await auth.signIn(input()); - await assert.rejects( - auth.refresh(await auth.getSession()), - code("INVALID_SESSION"), - ); - assert.equal(auth.state.get().user, null); -}); - -test("late 401 for a rotated token reuses the newer token", async () => { - let calls = 0; - const auth = client({ - refresh: async () => { - calls++; - return { accessToken: "access-2" }; - }, - }); - await auth.signIn(input()); - const old = await auth.getSession(); - await auth.refresh(old); - assert.equal((await auth.refresh(old)).accessToken, "access-2"); - assert.equal(calls, 1); -}); - -test("an old account rejection cannot sign out the new account", async () => { - const auth = client(); - await auth.signIn(input()); - const old = await auth.getSession(); - await auth.signIn(input({ user: { ...user, id: "bob" } })); - await assert.rejects(auth.refresh(old), code("SESSION_CHANGED")); - await auth.rejectSession(old); - assert.equal((await auth.getSession()).user.id, "bob"); -}); - -test("explicit profile updates win over a profile returned by in-flight proactive refresh", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const gate = deferred(); - const auth = client({ refresh: async () => gate.promise }); - await auth.signIn(input({ expiresAt: 1_020_000 })); - cleanups.push(auth.mount()); - await flush(); - t.mock.timers.tick(10_000); - await flush(); - await auth.updateUser({ ...user, email: "new@example.com" }); - gate.resolve({ accessToken: "access-2", expiresAt: 1_040_000, user }); - await flush(); - assert.equal(auth.state.get().user.email, "new@example.com"); - cleanups.pop()(); - t.mock.timers.reset(); -}); - -test("async updateUser captures the session before running the callback", async () => { - const gate = deferred(); - const auth = client(); - await auth.signIn(input()); - const pending = auth.updateUser(() => gate.promise); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - await auth.signIn(input({ user: { ...user, id: "bob" } })); - gate.resolve({ ...user, email: "old" }); - await checked; - assert.equal(auth.state.get().user.id, "bob"); -}); - -test("proactive failure keeps unexpired access but expiry hides the user", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const auth = client({ - refresh: async () => { - throw new Error("offline"); - }, - }); - await auth.signIn(input({ expiresAt: 1_020_000 })); - cleanups.push(auth.mount()); - await flush(); - t.mock.timers.tick(10_000); - await flush(); - assert.equal(auth.state.get().status, "authenticated"); - t.mock.timers.tick(10_000); - await flush(); - assert.equal(auth.state.get().status, "unavailable"); - cleanups.pop()(); - 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(() => {}) }); - await auth.signIn(input()); - const pending = auth.refresh(await auth.getSession()); - const checked = assert.rejects(pending, code("REFRESH_FAILED")); - await flush(); - t.mock.timers.tick(15_000); - await checked; - t.mock.timers.reset(); -}); - -test("expiry without a refresh handler signs out", async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - const auth = client(); - await auth.signIn(input({ expiresAt: 1_010_000 })); - t.mock.timers.tick(10_001); - assert.equal(await auth.getSession(), null); - t.mock.timers.reset(); -}); - -test("opaque tokens work and explicit expiry wins over JWT exp", async () => { - const auth = client(); - await auth.signIn(input()); - assert.equal((await auth.getSession()).expiresAt, undefined); - const jwt = `e30.${Buffer.from(JSON.stringify({ exp: 1 })).toString("base64url")}.x`; - await auth.signIn( - input({ accessToken: jwt, expiresAt: Date.now() + 60_000 }), - ); - 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({ - refresh: async () => ({ - accessToken: "access-2", - expiresAt: 1_050_000, - }), - }); - let invalidations = 0; - const router = { - options: { context: { auth } }, - clearCache() {}, - async invalidate() { - invalidations++; - }, - }; - const off = connectAuth(router); - cleanups.push(off); - assert.equal(connectAuth(router), off); - await flush(); - await auth.signIn(input({ expiresAt: 1_020_000 })); - await flush(); - const before = invalidations; - t.mock.timers.tick(10_000); - await flush(); - assert.equal(invalidations, before); - await auth.updateUser({ ...user, email: "updated" }); - await flush(); - assert.equal(invalidations, before + 1); - off(); - cleanups.pop(); - 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("a throwing subscriber is reported without interrupting auth or other subscribers", async (t) => { - const auth = client(); - await auth.getSession(); - const reported = []; - t.mock.method(globalThis, "queueMicrotask", (callback) => { - try { - callback(); - } catch (error) { - reported.push(error.message); - } - }); - const failing = auth.state.subscribe((state) => { - if (state.status === "authenticated") throw new Error("consumer bug"); - }); - cleanups.push(() => failing.unsubscribe()); - const states = recordStates(auth); - await auth.signIn(input()); - assert.equal(auth.state.get().status, "authenticated"); - assert.deepEqual( - states.map((state) => state.status), - ["authenticated"], - ); - assert.deepEqual(reported, ["consumer bug"]); -}); - -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()); - const headers = []; - global.fetch = async (request) => { - headers.push(request.headers.get("Authorization")); - return new Response(null, { status: headers.length === 1 ? 401 : 200 }); - }; - const request = createAuthFetch(auth, "https://api.example.com/"); - assert.equal((await request("/me")).status, 200); - assert.deepEqual(headers, ["Bearer access-1", "Bearer access-2"]); - await assert.rejects(request("https://evil.example/me")); - assert.equal(headers.length, 2); -}); - -test("mutations are not replayed automatically", async () => { - const auth = client({ refresh: async () => ({ accessToken: "access-2" }) }); - await auth.signIn(input()); - let calls = 0; - global.fetch = async () => { - calls++; - return new Response(null, { status: 401 }); - }; - const request = createAuthFetch(auth, "https://api.example.com/"); - assert.equal( - (await request("/change", { method: "POST", body: "data" })).status, - 401, - ); - assert.equal(calls, 1); -}); - -test("second 401 ends only the matching session", 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/"); - assert.equal((await request("/me")).status, 401); - 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", - "/\\evil.com", - "/\nevil.com", - "https://evil.com", - "/login", - null, - ]) - assert.equal(safeReturnTo(value), "/"); - assert.equal( - safeReturnTo("/dashboard?tab=one#title"), - "/dashboard?tab=one#title", - ); -}); - -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 }); - await assert.rejects( - auth.signIn(input({ user: { created: new Date() } })), - code("USER_VALIDATION_FAILED"), - ); -}); - -test("invalid optional refresh user cannot partially replace tokens", async () => { - const auth = client({ - refresh: async () => ({ accessToken: "access-2", user: { id: 3 } }), - }); - await auth.signIn(input()); - await assert.rejects( - auth.refresh(await auth.getSession()), - code("USER_VALIDATION_FAILED"), - ); - const saved = JSON.parse(values.get(SESSION)); - assert.equal(saved.accessToken, "access-1"); -}); - -test("overlapping profile updates commit only the most recent request", async () => { - const auth = client(); - await auth.signIn(input()); - const gate = deferred(); - const pending = auth.updateUser(() => gate.promise); - const checked = assert.rejects(pending, code("SESSION_CHANGED")); - await flush(); - await auth.updateUser({ ...user, email: "newest" }); - gate.resolve({ ...user, email: "older" }); - await checked; - assert.equal(auth.state.get().user.email, "newest"); -}); - -test("remount while initial validation is pending starts a fresh restoration", async () => { - await client().signIn(input()); - const gate = deferred(); - let calls = 0; - const schema = userSchema.refine(async () => { - calls++; - return gate.promise; - }); - const auth = client({ userSchema: schema }); - const off = auth.mount(); - await flush(); - off(); - cleanups.push(auth.mount()); - await flush(); - gate.resolve(true); - await flush(); - assert.equal(auth.state.get().status, "authenticated"); - assert.ok(calls >= 2); -}); - -const realRouter = async (auth) => { - global.self = global; - global.scrollTo = () => {}; - global.window = { - location: new URL("https://example.com/private"), - addEventListener() {}, - removeEventListener() {}, - }; - const { - createRouter, - createRootRouteWithContext, - createRoute, - createMemoryHistory, - redirect, - } = await import("@tanstack/react-router"); - const root = createRootRouteWithContext()({}); - const login = createRoute({ getParentRoute: () => root, path: "/login" }); - const privateRoute = createRoute({ - getParentRoute: () => root, - path: "/private", - beforeLoad: async ({ context }) => { - router.guardCalls++; - if (!(await context.auth.getSession())) - throw redirect({ to: "/login" }); - }, - }); - const router = createRouter({ - routeTree: root.addChildren([login, privateRoute]), - context: { auth }, - history: createMemoryHistory({ initialEntries: ["/private"] }), - isServer: false, - }); - router.guardCalls = 0; - router.invalidations = 0; - router.startTransition = async (fn) => { - fn(); - return true; - }; - 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(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/expiry.test.ts b/tests/expiry.test.ts new file mode 100644 index 0000000..06fb89f --- /dev/null +++ b/tests/expiry.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + cleanups, + deferred, + flush, + input, + client, + code, + recordStates, + sessionOf, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +test("timers longer than setTimeout's limit neither fire early nor get lost", async (t) => { + const day = 86_400_000; + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + let refreshes = 0; + const auth = client({ + maxAge: 90 * 86_400, + refresh: async () => { + refreshes++; + return { accessToken: "access-2", expiresIn: 3_600 }; + }, + }); + await auth.signIn(input({ expiresIn: 40 * 86_400 })); + cleanups.push(auth.mount()); + await flush(); + t.mock.timers.tick(30 * day); + await flush(); + assert.equal(refreshes, 0); + assert.equal(auth.state.get().status, "authenticated"); + t.mock.timers.tick(10 * day - 60_000); + await flush(); + assert.equal(refreshes, 1); + assert.equal((await sessionOf(auth)).accessToken, "access-2"); + cleanups.pop()!(); + t.mock.timers.reset(); +}); + +test("proactive failure keeps unexpired access but expiry hides the user", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const auth = client({ + refresh: async () => { + throw new Error("offline"); + }, + }); + await auth.signIn(input({ expiresAt: 1_020_000 })); + cleanups.push(auth.mount()); + await flush(); + t.mock.timers.tick(10_000); + await flush(); + assert.equal(auth.state.get().status, "authenticated"); + t.mock.timers.tick(10_000); + await flush(); + assert.equal(auth.state.get().status, "unavailable"); + cleanups.pop()!(); + 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("expiry without a refresh handler signs out", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const auth = client(); + await auth.signIn(input({ expiresAt: 1_010_000 })); + t.mock.timers.tick(10_001); + assert.equal(await auth.getSession(), null); + t.mock.timers.reset(); +}); + +test("opaque tokens work and explicit expiry wins over JWT exp", async () => { + const auth = client(); + await auth.signIn(input()); + assert.equal((await sessionOf(auth)).expiresAt, undefined); + const jwt = `e30.${Buffer.from(JSON.stringify({ exp: 1 })).toString("base64url")}.x`; + await auth.signIn( + input({ accessToken: jwt, expiresAt: Date.now() + 60_000 }), + ); + assert.ok(((await sessionOf(auth)).expiresAt ?? 0) > Date.now()); +}); + +const jwt = (claims: object) => + `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 sessionOf(auth)).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 sessionOf(auth)).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 sessionOf(auth)).expiresAt, 1_060_000); + t.mock.timers.tick(10_000); + const refreshed = await auth.refresh(await sessionOf(auth)); + 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( + // @ts-expect-error -- deliberately invalid lifetimes, including a string + 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 sessionOf(client()); + assert.equal(restored.expiresAt, 1_000_300_000); + t.mock.timers.reset(); +}); + +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(); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..4b3f2e2 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach } from "node:test"; + +import { + createAuth, + type AuthClient, + type AuthErrorCode, + type AuthOptions, + type AuthState, + type SignInInput, +} from "@monarcode/session-kit"; +import { connectAuth } from "@monarcode/session-kit/react"; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { z } from "zod"; + +export const userSchema = z.object({ + id: z.string(), + email: z.string(), + role: z.enum(["admin", "member"]).default("member"), +}); +type UserInput = z.input; +export const user: UserInput = { + id: "alice", + email: "alice@example.com", + role: "member", +}; +export const SESSION = "test:auth:session"; + +/** The mocked localStorage contents, cleared before each test. */ +export const values = new Map(); +export const cookies = new Map(); +/** Storage keys whose writes or deletes throw. */ +export const blockedWrites = new Set(); +export const blockedDeletes = new Set(); +/** While `blocked`, every storage call throws. */ +export const storage = { blocked: false }; +/** Run after each test, newest first. */ +export const cleanups: Array<() => void> = []; + +let locks: ReturnType; +let storageEvents: EventTarget; +let originalGlobals: Map; +const browserGlobals = [ + "location", + "document", + "localStorage", + "fetch", + "self", + "scrollTo", + "window", + "navigator", + "addEventListener", + "removeEventListener", +]; + +export const setGlobal = (name: string, value: unknown) => + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + +/** + * A promise with its settle functions exposed. The value type defaults to + * `any` so a deferred can stand in for any callback's result. + */ +export const deferred = () => { + let resolve!: (value?: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((a, b) => { + resolve = a as typeof resolve; + reject = b; + }); + return { promise, resolve, reject }; +}; + +export const flush = async () => { + for (let i = 0; i < 40; i++) await Promise.resolve(); +}; + +/** Sign-in input for `user`; override `user` when a test uses another schema. */ +export const input = ( + overrides: Partial> = {}, +): SignInInput => ({ + accessToken: "access-1", + refreshToken: "refresh-1", + user: user as I, + ...overrides, +}); + +export type TestClient = ReturnType>; + +/** A client named `test`, using `userSchema` unless another schema is given. */ +export function client( + options?: Partial>, +): TestClient; +export function client( + options: Partial> & { userSchema: S }, +): ReturnType>; +export function client(options: object = {}) { + return createAuth({ + name: "test", + userSchema, + ...(options as Partial>), + }); +} + +/** The saved session entry, parsed. */ +export const saved = () => JSON.parse(values.get(SESSION) ?? "null"); + +/** The current session, failing the test when there is none. */ +export const sessionOf = async (auth: AuthClient) => { + const session = await auth.getSession(); + assert.ok(session, "Expected a current session"); + return session; +}; + +/** Matches an `AuthError` with `expected` for `assert.rejects`. */ +export const code = (expected: AuthErrorCode) => (error: unknown) => + typeof error === "object" && + error !== null && + "code" in error && + error.code === expected; + +const createLocks = () => { + const queues = new Map>(); + const aborted = (signal?: AbortSignal) => + new Promise((_, reject) => { + if (signal?.aborted) reject(signal.reason); + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + }); + return { + request( + name: string, + { signal }: { signal?: AbortSignal } = {}, + callback: (lock: { name: string }) => Promise, + ) { + 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; + }, + }; +}; + +/** Installs mocked browser globals before each test and restores them after. */ +export function useBrowserMocks() { + beforeEach(() => { + values.clear(); + cookies.clear(); + blockedWrites.clear(); + blockedDeletes.clear(); + storage.blocked = false; + cleanups.length = 0; + locks = createLocks(); + storageEvents = new EventTarget(); + originalGlobals = new Map( + browserGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), + ); + setGlobal("location", { protocol: "https:" }); + setGlobal("navigator", { locks }); + setGlobal( + "addEventListener", + (...args: Parameters) => + storageEvents.addEventListener(...args), + ); + setGlobal( + "removeEventListener", + (...args: Parameters) => + storageEvents.removeEventListener(...args), + ); + setGlobal("document", { + get cookie() { + return [...cookies].map(([k, v]) => `${k}=${v}`).join("; "); + }, + set cookie(text: string) { + const pair = text.split(";")[0]; + const index = pair.indexOf("="); + const key = pair.slice(0, index), + value = pair.slice(index + 1); + if (text.includes("Max-Age=0")) cookies.delete(key); + else cookies.set(key, value); + }, + }); + setGlobal("localStorage", { + getItem(key: string) { + if (storage.blocked) throw new Error("Blocked"); + return values.get(key) ?? null; + }, + setItem(key: string, value: string) { + if (storage.blocked || blockedWrites.has(key)) + throw new Error("Quota"); + values.set(key, value); + }, + removeItem(key: string) { + if (storage.blocked || blockedDeletes.has(key)) + throw new Error("Blocked"); + values.delete(key); + }, + }); + }); + afterEach(() => { + try { + for (const cleanup of cleanups.toReversed()) cleanup(); + } finally { + for (const [name, descriptor] of originalGlobals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } + } + }); +} + +/** Sends a `storage` event, as another tab's write would, then settles. */ +export const broadcast = async (key = SESSION) => { + const event = Object.assign(new Event("storage"), { key }); + storageEvents.dispatchEvent(event); + await flush(); +}; + +/** A Router stand-in that counts invalidations. */ +export const connectCounting = (auth: AuthClient) => { + const router = { + invalidations: 0, + options: { context: { auth } }, + clearCache() {}, + async invalidate() { + router.invalidations++; + }, + }; + cleanups.push(connectAuth(router)); + return router; +}; + +/** Records every state the client publishes from now on. */ +export const recordStates = (auth: AuthClient) => { + const states: AuthState[] = []; + const subscription = auth.state.subscribe((state) => states.push(state)); + cleanups.push(() => subscription.unsubscribe()); + return states; +}; diff --git a/tests/http.test.ts b/tests/http.test.ts new file mode 100644 index 0000000..e9059e1 --- /dev/null +++ b/tests/http.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createAuthFetch } from "@monarcode/session-kit/http"; + +import { + input, + client, + code, + sessionOf, + setGlobal, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +test("GET retries once; bearer tokens stay on the configured origin", async () => { + const auth = client({ refresh: async () => ({ accessToken: "access-2" }) }); + await auth.signIn(input()); + const headers: (string | null)[] = []; + setGlobal("fetch", async (request: Request) => { + headers.push(request.headers.get("Authorization")); + return new Response(null, { status: headers.length === 1 ? 401 : 200 }); + }); + const request = createAuthFetch(auth, "https://api.example.com/"); + assert.equal((await request("/me")).status, 200); + assert.deepEqual(headers, ["Bearer access-1", "Bearer access-2"]); + await assert.rejects(request("https://evil.example/me")); + assert.equal(headers.length, 2); +}); + +test("mutations are not replayed automatically", async () => { + const auth = client({ refresh: async () => ({ accessToken: "access-2" }) }); + await auth.signIn(input()); + let calls = 0; + setGlobal("fetch", async () => { + calls++; + return new Response(null, { status: 401 }); + }); + const request = createAuthFetch(auth, "https://api.example.com/"); + assert.equal( + (await request("/change", { method: "POST", body: "data" })).status, + 401, + ); + assert.equal(calls, 1); +}); + +test("second 401 ends only the matching session", async () => { + const auth = client({ refresh: async () => ({ accessToken: "access-2" }) }); + await auth.signIn(input()); + setGlobal("fetch", async () => new Response(null, { status: 401 })); + const request = createAuthFetch(auth, "https://api.example.com/"); + assert.equal((await request("/me")).status, 401); + assert.equal(await auth.getSession(), null); +}); + +const trackedBody = () => { + const body: { cancelled: boolean; stream: ReadableStream } = { + cancelled: false, + 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()); + setGlobal("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 sessionOf(auth)).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(); + setGlobal("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(); + setGlobal("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); +}); diff --git a/tests/package.test.mjs b/tests/package.test.ts similarity index 90% rename from tests/package.test.mjs rename to tests/package.test.ts index 03f2fde..ea7acff 100644 --- a/tests/package.test.mjs +++ b/tests/package.test.ts @@ -21,9 +21,9 @@ test("all public entry points import without browser globals", () => { }); test("each entry exports readable JavaScript and declarations", async () => { - const manifest = JSON.parse( - await readFile(new URL("package.json", packageRoot), "utf8"), - ); + const manifest: { + exports: Record; + } = JSON.parse(await readFile(new URL("package.json", packageRoot), "utf8")); for (const [name, entry] of Object.entries(manifest.exports)) { if (name === "./package.json") continue; assert.equal(Object.keys(entry)[0], "types"); diff --git a/tests/react.test.mjs b/tests/react.test.ts similarity index 78% rename from tests/react.test.mjs rename to tests/react.test.ts index 6558e8f..731c078 100644 --- a/tests/react.test.mjs +++ b/tests/react.test.ts @@ -1,15 +1,46 @@ import assert from "node:assert/strict"; import { test, beforeEach, afterEach } from "node:test"; -import { act, createElement as h, StrictMode } from "react"; +import { + act, + createElement as h, + StrictMode, + type FunctionComponent, +} from "react"; -import { createAuth } from "@monarcode/session-kit"; +import { + createAuth, + type AuthClient, + type AuthState, +} from "@monarcode/session-kit"; +import type { AnyRouter } from "@tanstack/react-router"; import { JSDOM } from "jsdom"; +import type { Root } from "react-dom/client"; import { z } from "zod"; -let dom, roots, originalGlobals; -let createRoot, RouterContextProvider, createRouter, createRootRoute; -let createMemoryHistory, useAuth, useAuthClient; +const userSchema = z.object({ id: z.string(), email: z.string() }); +type TestUser = z.infer; +type Client = AuthClient; +/** The hooks as typed for this test's user; it registers no Router types. */ +type Hooks = { + useAuth: { + (): AuthState; + (selector: (state: AuthState) => T): T; + }; + useAuthClient: () => Client; +}; +type RouterModule = typeof import("@tanstack/react-router"); + +let dom: JSDOM; +let roots: Set; +let originalGlobals: Map; +let createRoot: typeof import("react-dom/client").createRoot; +let RouterContextProvider: RouterModule["RouterContextProvider"]; +let createRouter: RouterModule["createRouter"]; +let createRootRoute: RouterModule["createRootRoute"]; +let createMemoryHistory: RouterModule["createMemoryHistory"]; +let useAuth: Hooks["useAuth"]; +let useAuthClient: Hooks["useAuthClient"]; beforeEach(async () => { dom = new JSDOM("", { @@ -44,7 +75,8 @@ beforeEach(async () => { createRootRoute, createMemoryHistory, } = await import("@tanstack/react-router")); - ({ useAuth, useAuthClient } = await import("@monarcode/session-kit/react")); + ({ useAuth, useAuthClient } = + (await import("@monarcode/session-kit/react")) as unknown as Hooks); }); afterEach(async () => { @@ -56,18 +88,18 @@ afterEach(async () => { dom.window.close(); for (const [name, descriptor] of originalGlobals) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else delete globalThis[name]; + else delete (globalThis as Record)[name]; } } }); const user = { id: "alice", email: "alice@example.com" }; -const signIn = (auth) => auth.signIn({ accessToken: "access-1", user }); +const signIn = (auth: Client) => auth.signIn({ accessToken: "access-1", user }); async function fixture() { const auth = createAuth({ name: "react-test", - userSchema: z.object({ id: z.string(), email: z.string() }), + userSchema, }); await auth.getSession(); const router = createRouter({ @@ -78,12 +110,16 @@ async function fixture() { return { auth, router }; } -async function mount(router, Component, { strict = false } = {}) { +async function mount( + router: AnyRouter, + Component: FunctionComponent, + { strict = false } = {}, +) { const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); roots.add(root); - const tree = h(RouterContextProvider, { router }, h(Component)); + const tree = h(RouterContextProvider, { router, children: h(Component) }); await act(async () => root.render(strict ? h(StrictMode, null, tree) : tree), ); @@ -133,7 +169,7 @@ test("useAuth selector skips unrelated updates and renders changed selections", await act(async () => auth.updateUser({ ...user, email: "updated@example.com" }), ); - assert.equal(auth.state.get().user.email, "updated@example.com"); + assert.equal(auth.state.get().user?.email, "updated@example.com"); assert.equal(renders, before); assert.equal(container.textContent, "alice"); await act(async () => @@ -164,7 +200,7 @@ test("useAuth accepts inline selectors that return new objects", async () => { test("useAuthClient retains the Router client across reactive renders", async () => { const { auth, router } = await fixture(); - const clients = []; + const clients: Client[] = []; function Consumer() { const state = useAuth(); clients.push(useAuthClient()); diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts new file mode 100644 index 0000000..3a8cc40 --- /dev/null +++ b/tests/refresh.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + userSchema, + user, + SESSION, + values, + cleanups, + deferred, + flush, + input, + client, + code, + sessionOf, + saved, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +test("concurrent refresh requests share one backend operation", async () => { + const gate = deferred(); + let calls = 0; + const auth = client({ + refresh: async () => { + calls++; + return gate.promise; + }, + }); + await auth.signIn(input()); + const session = await sessionOf(auth); + const a = auth.refresh(session), + b = auth.refresh(session); + await flush(); + assert.equal(calls, 1); + gate.resolve({ accessToken: "access-2" }); + assert.equal((await a)?.accessToken, "access-2"); + assert.equal((await b)?.accessToken, "access-2"); + assert.equal(saved().refreshToken, "refresh-1"); +}); + +test("late refresh success cannot restore a signed-out session", async () => { + const gate = deferred(); + const auth = client({ refresh: async () => gate.promise }); + await auth.signIn(input()); + const pending = auth.refresh(await sessionOf(auth)); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await auth.signOut(); + gate.resolve({ accessToken: "late" }); + await checked; + await flush(); + assert.equal(await auth.getSession(), null); + assert.equal(values.size, 0); +}); + +test("late refresh failure cannot clear a new account", async () => { + const gate = deferred(); + const auth = client({ refresh: async () => gate.promise }); + await auth.signIn(input()); + const pending = auth.refresh(await sessionOf(auth)); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await auth.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + gate.reject(new Error("old failure")); + await checked; + assert.equal((await sessionOf(auth)).user.id, "bob"); +}); + +test("a refresh begun while sign-in validates cannot overwrite the new account", async () => { + const validation = deferred(), + refresh = deferred(); + const schema = userSchema.refine(async (u) => + u.id === "bob" ? validation.promise : true, + ); + const auth = client({ + userSchema: schema, + refresh: async () => refresh.promise, + }); + await auth.signIn(input()); + const old = await sessionOf(auth); + const login = auth.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + const pending = auth.refresh(old); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + validation.resolve(true); + await login; + refresh.resolve({ accessToken: "old-rotated" }); + await checked; + assert.equal((await sessionOf(auth)).accessToken, "bob-token"); +}); + +test("refresh null ends the session; thrown network errors retain credentials", async () => { + const auth = client({ refresh: async () => null }); + await auth.signIn(input()); + assert.equal(await auth.refresh(await sessionOf(auth)), null); + const retry = client({ + refresh: async () => { + throw new Error("offline"); + }, + }); + await retry.signIn(input()); + await assert.rejects( + retry.refresh(await sessionOf(retry)), + code("REFRESH_FAILED"), + ); + assert.equal(retry.state.get().status, "unavailable"); + assert.ok(values.has(SESSION)); +}); + +test("a rejected access token cannot be returned as fresh", async () => { + const auth = client({ refresh: async () => ({ accessToken: "access-1" }) }); + await auth.signIn(input()); + await assert.rejects( + auth.refresh(await sessionOf(auth)), + code("INVALID_SESSION"), + ); + assert.equal(auth.state.get().user, null); +}); + +test("late 401 for a rotated token reuses the newer token", async () => { + let calls = 0; + const auth = client({ + refresh: async () => { + calls++; + return { accessToken: "access-2" }; + }, + }); + await auth.signIn(input()); + const old = await sessionOf(auth); + await auth.refresh(old); + assert.equal((await auth.refresh(old))?.accessToken, "access-2"); + assert.equal(calls, 1); +}); + +test("an old account rejection cannot sign out the new account", async () => { + const auth = client(); + await auth.signIn(input()); + const old = await sessionOf(auth); + await auth.signIn(input({ user: { ...user, id: "bob" } })); + await assert.rejects(auth.refresh(old), code("SESSION_CHANGED")); + await auth.rejectSession(old); + assert.equal((await sessionOf(auth)).user.id, "bob"); +}); + +test("explicit profile updates win over a profile returned by in-flight proactive refresh", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const gate = deferred(); + const auth = client({ refresh: async () => gate.promise }); + await auth.signIn(input({ expiresAt: 1_020_000 })); + cleanups.push(auth.mount()); + await flush(); + t.mock.timers.tick(10_000); + await flush(); + await auth.updateUser({ ...user, email: "new@example.com" }); + gate.resolve({ accessToken: "access-2", expiresAt: 1_040_000, user }); + await flush(); + assert.equal(auth.state.get().user?.email, "new@example.com"); + 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(() => {}) }); + await auth.signIn(input()); + const pending = auth.refresh(await sessionOf(auth)); + const checked = assert.rejects(pending, code("REFRESH_FAILED")); + await flush(); + t.mock.timers.tick(15_000); + await checked; + t.mock.timers.reset(); +}); + +test("invalid optional refresh user cannot partially replace tokens", async () => { + // @ts-expect-error -- deliberately invalid: the refreshed user's id is a number + const auth = client({ + refresh: async () => ({ accessToken: "access-2", user: { id: 3 } }), + }); + await auth.signIn(input()); + await assert.rejects( + auth.refresh(await sessionOf(auth)), + code("USER_VALIDATION_FAILED"), + ); + const entry = saved(); + assert.equal(entry.accessToken, "access-1"); +}); + +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 sessionOf(auth)), + 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(); +}); diff --git a/tests/release.test.mjs b/tests/release.test.ts similarity index 87% rename from tests/release.test.mjs rename to tests/release.test.ts index ea067d5..ae20ac5 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.ts @@ -84,23 +84,18 @@ test("release requires unique, nonempty notes for the version", () => { } }); +/** A fetch stand-in answering with only what `checkRegistry` reads. */ +const registry = (status: number, ok: boolean) => + (async () => ({ status, ok })) as unknown as typeof fetch; + test("registry preflight allows only an unpublished version and propagates failures", async () => { - await checkRegistry(manifest.version, async () => ({ - status: 404, - ok: false, - })); + await checkRegistry(manifest.version, registry(404, false)); await assert.rejects( - checkRegistry(manifest.version, async () => ({ - status: 200, - ok: true, - })), + checkRegistry(manifest.version, registry(200, true)), /already published/, ); await assert.rejects( - checkRegistry(manifest.version, async () => ({ - status: 503, - ok: false, - })), + checkRegistry(manifest.version, registry(503, false)), /HTTP 503/, ); await assert.rejects( diff --git a/tests/return-to.test.ts b/tests/return-to.test.ts new file mode 100644 index 0000000..8d9db01 --- /dev/null +++ b/tests/return-to.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { safeReturnTo } from "@monarcode/session-kit/react"; + +import { useBrowserMocks } from "./helpers.ts"; + +useBrowserMocks(); + +test("return URL rejects external origins, backslashes, controls, and login loops", () => { + for (const value of [ + "//evil.com", + "/\\evil.com", + "/\nevil.com", + "https://evil.com", + "/login", + null, + ]) + assert.equal(safeReturnTo(value), "/"); + assert.equal( + safeReturnTo("/dashboard?tab=one#title"), + "/dashboard?tab=one#title", + ); +}); + +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" })); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts new file mode 100644 index 0000000..6580d7b --- /dev/null +++ b/tests/router.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AuthError } from "@monarcode/session-kit"; +import { connectAuth } from "@monarcode/session-kit/react"; + +import { + user, + cleanups, + deferred, + flush, + input, + client, + code, + connectCounting, + recordStates, + sessionOf, + setGlobal, + type TestClient, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +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({ + refresh: async () => ({ + accessToken: "access-2", + expiresAt: 1_050_000, + }), + }); + let invalidations = 0; + const router = { + options: { context: { auth } }, + clearCache() {}, + async invalidate() { + invalidations++; + }, + }; + const off = connectAuth(router); + cleanups.push(off); + assert.equal(connectAuth(router), off); + await flush(); + await auth.signIn(input({ expiresAt: 1_020_000 })); + await flush(); + const before = invalidations; + t.mock.timers.tick(10_000); + await flush(); + assert.equal(invalidations, before); + await auth.updateUser({ ...user, email: "updated" }); + await flush(); + assert.equal(invalidations, before + 1); + off(); + cleanups.pop(); + t.mock.timers.reset(); +}); + +test("a throwing subscriber is reported without interrupting auth or other subscribers", async (t) => { + const auth = client(); + await auth.getSession(); + const reported: string[] = []; + t.mock.method(globalThis, "queueMicrotask", (callback: () => void) => { + try { + callback(); + } catch (error) { + reported.push((error as Error).message); + } + }); + const failing = auth.state.subscribe((state) => { + if (state.status === "authenticated") throw new Error("consumer bug"); + }); + cleanups.push(() => failing.unsubscribe()); + const states = recordStates(auth); + await auth.signIn(input()); + assert.equal(auth.state.get().status, "authenticated"); + assert.deepEqual( + states.map((state) => state.status), + ["authenticated"], + ); + assert.deepEqual(reported, ["consumer bug"]); +}); + +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 sessionOf(auth)); + 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 sessionOf(auth)); + 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 sessionOf(auth)), null); + await flush(); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(router.invalidations, before + 1); +}); + +/** A real TanStack Router guarding `/private`, counting guard runs and invalidations. */ +const realRouter = async (auth: TestClient) => { + setGlobal("self", globalThis); + setGlobal("scrollTo", () => {}); + setGlobal("window", { + location: new URL("https://example.com/private"), + addEventListener() {}, + removeEventListener() {}, + }); + const { + createRouter, + createRootRouteWithContext, + createRoute, + createMemoryHistory, + redirect, + } = await import("@tanstack/react-router"); + const counts = { guardCalls: 0, invalidations: 0 }; + const root = createRootRouteWithContext<{ auth: TestClient }>()({}); + const login = createRoute({ getParentRoute: () => root, path: "/login" }); + const privateRoute = createRoute({ + getParentRoute: () => root, + path: "/private", + beforeLoad: async ({ context }) => { + counts.guardCalls++; + if (!(await context.auth.getSession())) + throw redirect({ to: "/login" }); + }, + }); + const router = createRouter({ + routeTree: root.addChildren([login, privateRoute]), + context: { auth }, + history: createMemoryHistory({ initialEntries: ["/private"] }), + isServer: false, + }); + // Run transitions synchronously; there is no React renderer here. + router.startTransition = (async (fn: () => void) => { + fn(); + return true; + }) as unknown as typeof router.startTransition; + const invalidate = router.invalidate.bind(router); + router.invalidate = (...args: Parameters) => { + counts.invalidations++; + return invalidate(...args); + }; + cleanups.push(connectAuth(router)); + await router.load(); + return Object.assign(router, { counts }); +}; + +const settle = async () => { + for (let i = 0; i < 20; i++) await new Promise(setImmediate); +}; + +const privateMatch = (router: Awaited>) => + 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(router.counts.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.counts.guardCalls; + await assert.rejects( + auth.refresh(await sessionOf(auth)), + code("REFRESH_FAILED"), + ); + await settle(); + assert.equal(refreshes, 1); + assert.equal(router.counts.invalidations, 1); + assert.equal(router.counts.guardCalls, guardCalls + 1); + assert.equal(auth.state.get().status, "unavailable"); + assert.equal( + (privateMatch(router)?.error as AuthError | undefined)?.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"); +}); diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..1c04785 --- /dev/null +++ b/tests/session.test.ts @@ -0,0 +1,400 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AuthError } from "@monarcode/session-kit"; +import { z } from "zod"; + +import { + userSchema, + user, + SESSION, + values, + cookies, + blockedWrites, + blockedDeletes, + storage, + cleanups, + deferred, + flush, + input, + client, + code, + sessionOf, + saved, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +test("schema defaults and custom fields survive restoration; tokens stay out of the profile/state", async () => { + const auth = client(); + await auth.signIn(input({ user: { id: "alice", email: user.email } })); + assert.equal(auth.state.get().user?.role, "member"); + assert.equal(saved().refreshToken, "refresh-1"); + assert.equal(cookies.size, 0); + assert.equal(JSON.stringify(auth.state.get()).includes("refresh-1"), false); + const restored = await sessionOf(client()); + assert.equal(restored.user.email, user.email); + assert.equal(restored.accessToken, "access-1"); +}); + +test("user output is frozen without freezing caller input", async () => { + const auth = client(); + const data = { ...user }; + await auth.signIn(input({ user: data })); + assert.equal(Object.isFrozen(auth.state.get().user), true); + assert.equal(Object.isFrozen(data), false); +}); + +test("invalid sign-in leaves the current session usable", async () => { + const auth = client(); + await auth.signIn(input()); + const before = await sessionOf(auth); + await assert.rejects( + // @ts-expect-error -- deliberately invalid: the schema requires string fields + auth.signIn(input({ user: { id: 4 } })), + code("USER_VALIDATION_FAILED"), + ); + assert.equal((await sessionOf(auth)).id, before.id); +}); + +for (const timeout of [false, true]) { + for (const canRefresh of [false, true]) { + test(`expiry during ${timeout ? "timed-out" : "failed"} sign-in ${canRefresh ? "refreshes" : "clears"} the previous session`, async (t) => { + t.mock.timers.enable({ + apis: ["Date", "setTimeout"], + now: 1_000_000, + }); + const validation = deferred(); + const refresh = deferred(); + let refreshCalls = 0; + const auth = client({ + userSchema: userSchema.refine(async (value) => + value.id === "bob" ? validation.promise : true, + ), + refresh: canRefresh + ? async () => { + refreshCalls++; + return refresh.promise; + } + : undefined, + }); + await auth.signIn(input({ expiresAt: 1_001_000 })); + const previousId = auth.state.get().sessionId; + cleanups.push(auth.mount()); + await flush(); + const pending = auth.signIn( + input({ + accessToken: "bob-token", + user: { ...user, id: "bob" }, + }), + ); + const checked = assert.rejects( + pending, + code("USER_VALIDATION_FAILED"), + ); + await flush(); + t.mock.timers.tick(1_000); + if (timeout) t.mock.timers.tick(14_000); + else validation.resolve(false); + await checked; + await flush(); + if (canRefresh) { + assert.equal(refreshCalls, 1); + 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, + }); + await flush(); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().sessionId, previousId); + assert.equal(auth.state.get().user?.id, "alice"); + } else { + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(auth.state.get().user, null); + assert.equal(auth.state.get().sessionId, null); + assert.equal(values.size, 0); + } + if (timeout) { + validation.resolve(true); + await flush(); + assert.notEqual(auth.state.get().user?.id, "bob"); + } + }); + } +} + +test("expiry during validation does not cancel a successful new sign-in", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const validation = deferred(); + const auth = client({ + userSchema: userSchema.refine(async (value) => + value.id === "bob" ? validation.promise : true, + ), + }); + await auth.signIn(input({ expiresAt: 1_001_000 })); + const previousId = auth.state.get().sessionId; + cleanups.push(auth.mount()); + await flush(); + const pending = auth.signIn( + input({ + accessToken: "bob-token", + user: { ...user, id: "bob" }, + }), + ); + await flush(); + t.mock.timers.tick(1_000); + await flush(); + validation.resolve(true); + await pending; + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().user?.id, "bob"); + assert.notEqual(auth.state.get().sessionId, previousId); +}); + +test("failed sign-in recovery retains expired credentials without a refresh retry loop", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const validation = deferred(); + let refreshCalls = 0; + const auth = client({ + userSchema: userSchema.refine(async (value) => + value.id === "bob" ? validation.promise : true, + ), + refresh: async () => { + refreshCalls++; + throw new Error("offline"); + }, + }); + await auth.signIn(input({ expiresAt: 1_001_000 })); + cleanups.push(auth.mount()); + await flush(); + const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); + const checked = assert.rejects(pending, code("USER_VALIDATION_FAILED")); + await flush(); + t.mock.timers.tick(1_000); + validation.resolve(false); + await checked; + await flush(); + assert.equal(refreshCalls, 1); + assert.equal(auth.state.get().status, "unavailable"); + assert.equal(auth.state.get().error?.code, "REFRESH_FAILED"); + assert.ok(values.has(SESSION)); + t.mock.timers.tick(60_000); + await flush(); + assert.equal(refreshCalls, 1); +}); + +test("failed sign-in hides an expired unmounted session until demand retries refresh", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const validation = deferred(); + let refreshCalls = 0; + const auth = client({ + userSchema: userSchema.refine(async (value) => + value.id === "bob" ? validation.promise : true, + ), + refresh: async () => { + refreshCalls++; + return { accessToken: "access-2", expiresAt: Date.now() + 60_000 }; + }, + }); + await auth.signIn(input({ expiresAt: 1_001_000 })); + const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); + const checked = assert.rejects(pending, code("USER_VALIDATION_FAILED")); + await flush(); + t.mock.timers.tick(1_000); + validation.resolve(false); + await checked; + await flush(); + assert.equal(auth.state.get().status, "unavailable"); + assert.equal(auth.state.get().user, null); + assert.equal(refreshCalls, 0); + assert.ok(values.has(SESSION)); + assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal(refreshCalls, 1); +}); + +test("a superseded sign-in cannot recover or clear the newer account", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const validation = deferred(); + const auth = client({ + userSchema: userSchema.refine(async (value) => + value.id === "bob" ? validation.promise : true, + ), + }); + await auth.signIn(input({ expiresAt: 1_001_000 })); + cleanups.push(auth.mount()); + await flush(); + const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await auth.signIn( + input({ accessToken: "carol-token", user: { ...user, id: "carol" } }), + ); + t.mock.timers.tick(2_000); + validation.resolve(false); + await checked; + await flush(); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().user?.id, "carol"); + assert.equal((await sessionOf(auth)).accessToken, "carol-token"); +}); + +test("malformed saved session fails closed and removes saved data", async () => { + await client().signIn(input()); + const entry = saved(); + values.set(SESSION, JSON.stringify({ ...entry, 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 entry = saved(); + values.set(SESSION, JSON.stringify({ ...entry, user: { id: 5 } })); + assert.equal(await client().getSession(), null); +}); + +for (const invalidUser of [false, true]) { + test(`restoring ${invalidUser ? "an invalid user" : "a malformed session"} exposes blocked cleanup and permits retry`, async () => { + await client().signIn(input()); + if (invalidUser) { + const entry = saved(); + values.set(SESSION, JSON.stringify({ ...entry, user: { id: 5 } })); + } else { + values.set(SESSION, "invalid-json"); + } + blockedDeletes.add(SESSION); + const auth = client(); + let failure: AuthError | undefined; + await assert.rejects(auth.getSession(), (error: AuthError) => { + 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); + const cause = failure?.cause; + assert.ok(cause instanceof AggregateError); + const [restoration, cleanup] = 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 () => { + await client().signIn(input()); + const auth = client(); + storage.blocked = true; + await assert.rejects(auth.getSession(), code("PERSISTENCE_FAILED")); + assert.equal(auth.state.get().status, "unavailable"); + storage.blocked = false; + assert.equal((await sessionOf(auth)).user.id, "alice"); +}); + +test("token write failure clears runtime state and fails visibly", async () => { + const auth = client(); + blockedWrites.add(SESSION); + await assert.rejects(auth.signIn(input()), code("PERSISTENCE_FAILED")); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(values.size, 0); +}); + +test("signout stays signed out when token deletion fails", async () => { + const auth = client(); + await auth.signIn(input()); + blockedDeletes.add(SESSION); + await assert.rejects(auth.signOut(), code("PERSISTENCE_FAILED")); + assert.equal(await auth.getSession(), null); + assert.equal(auth.state.get().user, null); +}); + +test("async validation cannot commit after logout", async () => { + const gate = deferred(); + const schema = z.object({ id: z.string() }).refine(async () => gate.promise); + const auth = client({ userSchema: schema }); + const pending = auth.signIn(input({ user: { id: "a" } })); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await auth.signOut(); + gate.resolve(true); + await checked; + assert.equal(await auth.getSession(), null); +}); + +test("async updateUser captures the session before running the callback", async () => { + const gate = deferred(); + const auth = client(); + await auth.signIn(input()); + const pending = auth.updateUser(() => gate.promise); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await auth.signIn(input({ user: { ...user, id: "bob" } })); + gate.resolve({ ...user, email: "old" }); + await checked; + assert.equal(auth.state.get().user?.id, "bob"); +}); + +test("schemas that return non-JSON output are rejected", async () => { + const schema = z.object({ created: z.date() }); + const auth = client({ userSchema: schema }); + await assert.rejects( + auth.signIn(input({ user: { created: new Date() } })), + code("USER_VALIDATION_FAILED"), + ); +}); + +test("overlapping profile updates commit only the most recent request", async () => { + const auth = client(); + await auth.signIn(input()); + const gate = deferred(); + const pending = auth.updateUser(() => gate.promise); + const checked = assert.rejects(pending, code("SESSION_CHANGED")); + await flush(); + await auth.updateUser({ ...user, email: "newest" }); + gate.resolve({ ...user, email: "older" }); + await checked; + assert.equal(auth.state.get().user?.email, "newest"); +}); + +test("remount while initial validation is pending starts a fresh restoration", async () => { + await client().signIn(input()); + const gate = deferred(); + let calls = 0; + const schema = userSchema.refine(async () => { + calls++; + return gate.promise; + }); + const auth = client({ userSchema: schema }); + const off = auth.mount(); + await flush(); + off(); + cleanups.push(auth.mount()); + await flush(); + gate.resolve(true); + await flush(); + assert.equal(auth.state.get().status, "authenticated"); + assert.ok(calls >= 2); +}); diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts new file mode 100644 index 0000000..fccf08c --- /dev/null +++ b/tests/tabs.test.ts @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { AuthOptions } from "@monarcode/session-kit"; +import { z } from "zod"; + +import { + userSchema, + user, + SESSION, + values, + cleanups, + setGlobal, + deferred, + flush, + input, + client, + code, + broadcast, + connectCounting, + sessionOf, + saved, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +/** A backend that rotates refresh tokens and rejects any token already spent. */ +const rotatingServer = ({ gates = [] as Promise[] } = {}) => { + let issued = 1; + const live = new Set(["refresh-1"]); + const server = { + calls: 0, + async refresh({ refreshToken }: { refreshToken: string }) { + await gates[server.calls++]; + if (!live.delete(refreshToken)) return null; + issued++; + live.add(`refresh-${issued}`); + return { + accessToken: `access-${issued}`, + refreshToken: `refresh-${issued}`, + }; + }, + }; + return server; +}; + +type Backend = { refresh: AuthOptions["refresh"] }; + +const twoTabs = async (server: Backend) => { + const a = client({ refresh: server.refresh }); + await a.signIn(input()); + const b = client({ refresh: server.refresh }); + assert.equal((await sessionOf(b)).accessToken, "access-1"); + return { a, b }; +}; + +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 sessionOf(b); + assert.equal((await a.refresh(await sessionOf(a)))?.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 sessionOf(b)))?.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 sessionOf(a)), + b.refresh(await sessionOf(b)), + ]); + 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 sessionOf(a)); + const fromB = b.refresh(await sessionOf(b)); + 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 sessionOf(a)); + 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 sessionOf(b)).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 sessionOf(b)), 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 sessionOf(b)), 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 sessionOf(b); + 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 mountedTabs = async (server: Backend = 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 sessionOf(b)); + 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 sessionOf(b)).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 sessionOf(a)); + await broadcast(); + assert.equal(b.state.get().version, before.version); + assert.equal(b.state.get().user, before.user); + assert.equal((await sessionOf(b)).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 sessionOf(b)).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 sessionOf(b)), code("REFRESH_FAILED")); + await assert.rejects(b.getSession(), code("REFRESH_FAILED")); + await a.refresh(await sessionOf(a)); + await broadcast(); + assert.equal(b.state.get().status, "authenticated"); + assert.equal(b.state.get().error, null); + assert.equal((await sessionOf(b)).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 sessionOf(b)); + 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"); +}); + +test("a user another tab saved that this tab's schema rejects signs every tab out", async () => { + const a = client(); + await a.signIn(input()); + const b = client({ userSchema: userSchema.extend({ email: z.email() }) }); + cleanups.push(a.mount(), b.mount()); + await flush(); + assert.equal(b.state.get().user?.email, "alice@example.com"); + await a.updateUser({ ...user, email: "not-an-email" }); + await broadcast(); + assert.equal(b.state.get().status, "unauthenticated"); + assert.equal(b.state.get().error?.code, "USER_VALIDATION_FAILED"); + assert.equal(values.size, 0); + await broadcast(); + assert.equal(a.state.get().status, "unauthenticated"); +}); diff --git a/tests/tsconfig.tests.json b/tests/tsconfig.tests.json new file mode 100644 index 0000000..109d4d6 --- /dev/null +++ b/tests/tsconfig.tests.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "allowJs": true, + "types": ["node"] + }, + "include": ["*.test.ts", "helpers.ts"] +}