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..bb60ff6 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", @@ -19,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/CHANGELOG.md b/CHANGELOG.md index 706a370..751a63d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 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 + 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. +- **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 + +- 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. +- 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 ### Changed diff --git a/README.md b/README.md index 2bc30dc..37a3a3a 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(); + // `user` is set exactly while the session is authenticated or refreshing. + const signedIn = useAuth((state) => state.user !== null); + const version = useAuth((state) => state.version); + if (!signedIn || 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; + } }), ``` @@ -615,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 @@ -647,10 +651,10 @@ 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 -declarations and every marked TypeScript example in this README under NodeNext -and Bundler resolution. The docs +`check` runs Oxlint and Oxfmt checks, builds the package, runs the auth behavior, +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 @@ -661,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. @@ -678,8 +685,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..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,22 +57,24 @@ "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" }, "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/jsdom": "21.1.7", "@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..f5fd154 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,16 +11,19 @@ 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 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 @@ -30,6 +33,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 @@ -343,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==} @@ -354,6 +363,12 @@ 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==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -431,6 +446,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==} @@ -750,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 @@ -762,6 +787,10 @@ 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: {} cookie-es@3.1.1: {} @@ -845,6 +874,8 @@ snapshots: ms@2.1.3: {} + nanostores@1.5.4: {} + nwsapi@2.2.28: {} oxfmt@0.71.0: 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`, + ); } diff --git a/src/core/client.ts b/src/core/client.ts index 59413a9..05e5f4b 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,38 +1,69 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; -import { Store } from "@tanstack/store"; +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" + ); } /** @@ -67,40 +98,50 @@ 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, 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,29 +169,23 @@ 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.setState(() => + store.set( Object.freeze({ status, user, @@ -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,35 +422,22 @@ 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; } - store.setState((previous) => ({ + const previous = store.get(); + store.set({ ...previous, status: "unavailable", error, version: previous.version + 1, - })); + }); throw error; } })(); @@ -428,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, @@ -449,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, @@ -514,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); @@ -574,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 && @@ -599,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 ( @@ -616,7 +637,7 @@ export function createAuth( throw sessionChanged(); } flight = undefined; - const error = authError( + const error = toAuthError( "REFRESH_FAILED", "Could not refresh the session", cause, @@ -670,7 +691,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/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 7144578..b53c41a 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"; @@ -76,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. */ @@ -97,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. */ @@ -123,8 +118,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/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/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/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 "/"; + } +} diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs deleted file mode 100644 index dadeb60..0000000 --- a/tests/auth.test.mjs +++ /dev/null @@ -1,1450 +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("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 71% rename from tests/react.test.mjs rename to tests/react.test.ts index 3009327..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 () => @@ -148,9 +184,23 @@ 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 = []; + 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"] +}