From caac315844e84494ef827c31108aa3d6ab1de989 Mon Sep 17 00:00:00 2001 From: mjnong Date: Mon, 7 Sep 2026 17:28:15 +0200 Subject: [PATCH 1/9] feat: add guarded session resolution --- README.md | 40 +- __tests__/auth-api.test.ts | 91 +++- __tests__/auth-client.test.ts | 271 +++++++---- __tests__/auth-store.test.ts | 5 +- __tests__/session-resolution.test.ts | 342 ++++++++++++++ src/auth-api.ts | 49 +- src/auth-client.ts | 83 +++- src/auth-store.ts | 670 ++++++++++++++++----------- src/auth-user.ts | 70 +++ src/index.ts | 10 + src/react/login-form.tsx | 2 +- src/session-continuity.ts | 36 ++ src/types.ts | 45 +- 13 files changed, 1343 insertions(+), 371 deletions(-) create mode 100644 __tests__/session-resolution.test.ts create mode 100644 src/auth-user.ts create mode 100644 src/session-continuity.ts diff --git a/README.md b/README.md index 3dbbe48..a57cbcc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,13 @@ npm install @cellarnode/auth ### Core (framework-agnostic) ```ts -import { createAuthStore, createAuthClient, createAuthApi } from "@cellarnode/auth"; +import { + canReplaySession, + captureSessionContinuity, + createAuthApi, + createAuthClient, + createAuthStore, +} from "@cellarnode/auth"; const authStore = createAuthStore({ baseUrl: "http://localhost:4000", @@ -28,6 +34,36 @@ const authClient = createAuthClient({ const authApi = createAuthApi({ client: authClient, store: authStore }); ``` +Resolve token and authoritative identity together before enabling protected +work. `unavailable` preserves credentials while authority getters fail closed. +Caller abort returns `superseded` without cancelling shared adoption. + +```ts +const session = await authStore.resolveSession({ refresh: true, signal }); +if (session.status === "ready") { + // session.token and validated session.user are from one guarded generation +} +``` + +`onSessionStateChange` immediately reports current state. A synchronous +`resolving` notification precedes credential or authority changes, allowing +consumers to suspend writes and clear captured tenant queries first. + +Authenticated transports that retry after 401 must capture continuity before +their first request and replay only for same validated user and organisation: + +```ts +const before = captureSessionContinuity(authStore); +if (!before) throw new Error("Session authority unavailable"); +const response = await fetch(url, init); +if (response.status === 401) { + const after = await authStore.resolveSession({ refresh: true, signal: init.signal }); + if (canReplaySession(before, after, authStore)) { + // retry once with after.token + } +} +``` + ### React Components ```tsx @@ -84,7 +120,7 @@ Add this to your CSS file so Tailwind picks up utility classes from the package: ## Exports -- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, `AuthError`, types (incl. `DevLoginResult`) +- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `captureSessionContinuity`, `canReplaySession`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, `AuthError`, session-resolution types, and `DevLoginResult` - `@cellarnode/auth/react` — React: `LoginForm`, `RegisterForm`, `UnauthorizedPage`, `SquircleShift`, `InputOTP` (+ `Group` / `Slot` / `Separator`) `DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`, `readDevLoginEmail` and diff --git a/__tests__/auth-api.test.ts b/__tests__/auth-api.test.ts index 9abf6d0..c2032fc 100644 --- a/__tests__/auth-api.test.ts +++ b/__tests__/auth-api.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { createAuthApi } from "../src/auth-api.js"; +import { createAuthStore } from "../src/auth-store.js"; import { AuthError } from "../src/types.js"; import type { AuthClient, AuthStore } from "../src/types.js"; @@ -77,6 +78,28 @@ describe("createAuthApi", () => { expect(store.setAccessToken).toHaveBeenCalledWith("tok_new", 900); }); + it("does not apply full /auth/me validation to sparse verify-otp user", async () => { + const client = mockClient(); + const store = mockStore(); + (client.fetch as ReturnType).mockResolvedValue({ + accessToken: "tok_sparse", + expiresIn: 900, + user: { + id: "u1", + email: "t@t.com", + name: "Test", + userType: "producer", + orgId: null, + roles: [], + }, + }); + + const api = createAuthApi({ client, store }); + await expect(api.verifyOtp("t@t.com", "123456")).resolves.toMatchObject({ + user: { id: "u1" }, + }); + }); + it("verifyOtp extracts token from nested response shapes", async () => { const client = mockClient(); const store = mockStore(); @@ -130,7 +153,7 @@ describe("createAuthApi", () => { expect(headers["Authorization"]).toBe("Bearer tok_explicit"); }); - it("getMe calls GET /auth/me without explicit token (uses client auth)", async () => { + it("getMe without resolver uses explicit no-refresh transport", async () => { const client = mockClient(); (client.fetch as ReturnType).mockResolvedValue({ id: "u1", @@ -147,7 +170,69 @@ describe("createAuthApi", () => { const callArgs = (client.fetch as ReturnType).mock.calls[0]; const opts = callArgs[1] as RequestInit & { skipAuth?: boolean }; - expect(opts.skipAuth).toBeUndefined(); + expect(opts.skipAuth).toBe(true); + }); + + it("current-token getMe shares an active identity read then revalidates", async () => { + let resolveFirst!: (value: Response) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const fullUser = { + id: "u1", + email: "t@t.com", + name: "Test", + userType: "producer" as const, + orgId: "org_1", + roles: ["member"], + createdAt: "2026-01-01T00:00:00.000Z", + }; + const fetchMock = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ ...fullUser, roles: [] }), + }); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + const client = mockClient(); + const api = createAuthApi({ client, store }); + + store.setAccessToken("tok_current", 900); + const joined = api.getMe("tok_current"); + expect(fetchMock).toHaveBeenCalledTimes(1); + resolveFirst({ + ok: true, + status: 200, + json: () => Promise.resolve(fullUser), + } as Response); + await expect(joined).resolves.toMatchObject({ roles: ["member"] }); + + await expect(api.getMe("tok_current")).resolves.toMatchObject({ roles: [] }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(client.fetch).not.toHaveBeenCalled(); + }); + + it("rejects malformed explicit-token /auth/me without refreshing", async () => { + const client = mockClient(); + (client.fetch as ReturnType).mockResolvedValue({ + id: "u1", + email: "t@t.com", + name: "Test", + userType: "producer", + roles: [], + createdAt: "2026-01-01T00:00:00.000Z", + }); + const store = mockStore(); + const api = createAuthApi({ client, store }); + + await expect(api.getMe("tok_external")).rejects.toMatchObject({ + status: 503, + code: "AUTHORITY_UNAVAILABLE", + }); + expect(store.ensureAccessToken).not.toHaveBeenCalled(); }); it("logout calls POST /auth/logout", async () => { @@ -157,7 +242,7 @@ describe("createAuthApi", () => { expect(client.fetch).toHaveBeenCalledWith( "/auth/logout", - expect.objectContaining({ method: "POST" }), + expect.objectContaining({ method: "POST", skipAuth: true }), ); }); }); diff --git a/__tests__/auth-client.test.ts b/__tests__/auth-client.test.ts index 8e52ead..5c2117f 100644 --- a/__tests__/auth-client.test.ts +++ b/__tests__/auth-client.test.ts @@ -1,18 +1,61 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createAuthClient } from "../src/auth-client.js"; +import { createAuthStore } from "../src/auth-store.js"; import { AuthError } from "../src/types.js"; -import type { AuthStore } from "../src/types.js"; +import type { + AuthStore, + AuthUser, + SessionResolution, +} from "../src/types.js"; -function mockStore(token: string | null = "tok_123"): AuthStore { +const userA: AuthUser = { + id: "user_1", + email: "user@example.test", + name: "User", + userType: "importer", + orgId: "org_a", + roles: ["member"], + createdAt: "2026-01-01T00:00:00.000Z", +}; + +function mockStore( + token: string | null = "tok_old", + resolution: SessionResolution = { + status: "ready", + token: "tok_new", + user: userA, + }, +): AuthStore { + let currentToken = token; return { - getAccessToken: vi.fn().mockReturnValue(token), + getAccessToken: vi.fn(() => currentToken), hasAccessToken: vi.fn().mockReturnValue(token !== null), setAccessToken: vi.fn(), clearAccessToken: vi.fn(), ensureAccessToken: vi.fn().mockResolvedValue(token), + resolveSession: vi.fn(async () => { + if (resolution.status === "ready") currentToken = resolution.token; + if (resolution.status === "unauthorized") currentToken = null; + return resolution; + }), + getUserId: vi.fn().mockReturnValue(token ? userA.id : null), + getOrgId: vi.fn().mockReturnValue(token ? userA.orgId : null), + getUserType: vi.fn().mockReturnValue(token ? userA.userType : null), + getEntitlements: vi.fn().mockReturnValue([]), + onOrgChange: vi.fn().mockReturnValue(() => {}), + onAccessTokenSet: vi.fn().mockReturnValue(() => {}), + onLogout: vi.fn().mockReturnValue(() => {}), }; } +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + describe("createAuthClient", () => { beforeEach(() => { global.fetch = vi.fn(); @@ -24,123 +67,191 @@ describe("createAuthClient", () => { it("injects Bearer token on authenticated requests", async () => { const store = mockStore("tok_abc"); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ data: "ok" }), - }); + global.fetch = vi.fn().mockResolvedValue(response({ data: "ok" })); const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); await client.fetch("/some/path"); - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:4000/some/path", - expect.objectContaining({ - headers: expect.any(Headers), - }), - ); - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const headers = callArgs[1].headers as Headers; + const headers = (global.fetch as ReturnType).mock.calls[0][1] + .headers as Headers; expect(headers.get("Authorization")).toBe("Bearer tok_abc"); }); it("skips Bearer token when skipAuth is true", async () => { const store = mockStore("tok_abc"); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ data: "ok" }), - }); + global.fetch = vi.fn().mockResolvedValue(response({ data: "ok" })); const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); await client.fetch("/auth/register", { skipAuth: true }); - const callArgs = (global.fetch as ReturnType).mock.calls[0]; - const headers = callArgs[1].headers as Headers; + const headers = (global.fetch as ReturnType).mock.calls[0][1] + .headers as Headers; expect(headers.get("Authorization")).toBeNull(); }); - it("prepends baseUrl to paths", async () => { - const store = mockStore("tok_abc"); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({}), + it("blocks first transport while token authority is unresolved", async () => { + const store = mockStore("tok_pending"); + (store.getUserId as ReturnType).mockReturnValue(null); + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 503, + code: "SESSION_UNAVAILABLE", }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("retries once after validated same-user same-org refresh", async () => { + const store = mockStore(); + global.fetch = vi + .fn() + .mockResolvedValueOnce(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)) + .mockResolvedValueOnce(response({ data: "success" })); const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); - await client.fetch("/auth/me"); + await expect(client.fetch<{ data: string }>("/api/write")).resolves.toEqual({ + data: "success", + }); - expect(global.fetch).toHaveBeenCalledWith( - "http://localhost:4000/auth/me", - expect.anything(), - ); + expect(store.resolveSession).toHaveBeenCalledWith({ refresh: true }); + const retryHeaders = (global.fetch as ReturnType).mock.calls[1][1] + .headers as Headers; + expect(retryHeaders.get("Authorization")).toBe("Bearer tok_new"); }); - it("retries once on 401 with refresh", async () => { - const store = mockStore("tok_old"); - (store.ensureAccessToken as ReturnType).mockResolvedValue("tok_new"); + it("does not replay an org-A request after refresh resolves org B", async () => { + const store = mockStore("tok_old", { + status: "ready", + token: "tok_b", + user: { ...userA, orgId: "org_b" }, + }); + global.fetch = vi + .fn() + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); + + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 409, + code: "SESSION_CONTINUITY_CHANGED", + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + }); + it("does not replay when ready token was superseded before continuity check", async () => { + const store = mockStore(); + (store.resolveSession as ReturnType).mockImplementation(async () => { + (store.getAccessToken as ReturnType).mockReturnValue("tok_later"); + return { status: "ready", token: "tok_new", user: userA }; + }); global.fetch = vi .fn() - .mockResolvedValueOnce({ - ok: false, - status: 401, - json: () => Promise.resolve({ error: "Unauthorized", code: "UNAUTHORIZED" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ data: "success" }), - }); + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); - const result = await client.fetch<{ data: string }>("/api/me"); + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 409, + code: "SESSION_CONTINUITY_CHANGED", + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); - expect(result.data).toBe("success"); - expect(store.ensureAccessToken).toHaveBeenCalledWith(true); - expect(global.fetch).toHaveBeenCalledTimes(2); + it("preserves credentials and suppresses replay when refresh is unavailable", async () => { + const store = mockStore("tok_old", { + status: "unavailable", + token: "tok_old", + }); + global.fetch = vi + .fn() + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); + + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 503, + code: "SESSION_UNAVAILABLE", + }); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); }); - it("calls onAuthFailure when refresh fails on 401", async () => { - const store = mockStore("tok_old"); - (store.ensureAccessToken as ReturnType).mockResolvedValue(null); + it("returns actual retry failure and preserves validated session", async () => { + const store = mockStore(); + global.fetch = vi + .fn() + .mockResolvedValueOnce(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)) + .mockResolvedValueOnce(response({ error: "Unavailable", code: "UPSTREAM_DOWN" }, 503)); - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 401, - json: () => Promise.resolve({ error: "Unauthorized", code: "UNAUTHORIZED" }), + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 503, + code: "UPSTREAM_DOWN", }); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + }); + it("calls onAuthFailure only for confirmed refresh revocation", async () => { + const store = mockStore("tok_old", { status: "unauthorized" }); + global.fetch = vi + .fn() + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); const onAuthFailure = vi.fn(); - const client = createAuthClient({ baseUrl: "http://localhost:4000", store, onAuthFailure }); - await expect(client.fetch("/api/me")).rejects.toThrow(AuthError); - expect(onAuthFailure).toHaveBeenCalled(); - expect(store.clearAccessToken).toHaveBeenCalled(); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + onAuthFailure, + }); + await expect(client.fetch("/api/write")).rejects.toBeInstanceOf(AuthError); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + expect(store.clearAccessToken).not.toHaveBeenCalled(); }); - it("throws AuthError with code and remainingAttempts on non-401 errors", async () => { - const store = mockStore(null); - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: () => - Promise.resolve({ + it("captures principal before transport and rejects foreign-user refresh", async () => { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.endsWith("/api/write")) { + return Promise.resolve(response({ code: "UNAUTHORIZED" }, 401)); + } + if (url.endsWith("/auth/refresh")) { + return Promise.resolve(response({ accessToken: "tok_other", expiresIn: 900 })); + } + const bearer = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(bearer === "Bearer tok_other" ? { ...userA, id: "user_2" } : userA), + ); + }); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + + await expect(client.fetch("/api/write")).rejects.toMatchObject({ status: 401 }); + expect(store.getAccessToken()).toBeNull(); + expect(fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/write"))).toHaveLength(1); + }); + + it("throws AuthError details on ordinary errors", async () => { + const store = mockStore(null, { status: "unauthorized" }); + global.fetch = vi.fn().mockResolvedValue( + response( + { error: "Invalid OTP code", code: "OTP_INVALID", remainingAttempts: 2, - }), - }); + }, + 400, + ), + ); const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); - - try { - await client.fetch("/auth/verify-otp", { skipAuth: true }); - expect.fail("Should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(AuthError); - const authErr = err as AuthError; - expect(authErr.status).toBe(400); - expect(authErr.code).toBe("OTP_INVALID"); - expect(authErr.message).toBe("Invalid OTP code"); - expect(authErr.remainingAttempts).toBe(2); - } + await expect( + client.fetch("/auth/verify-otp", { skipAuth: true }), + ).rejects.toMatchObject({ + status: 400, + code: "OTP_INVALID", + remainingAttempts: 2, + }); }); }); diff --git a/__tests__/auth-store.test.ts b/__tests__/auth-store.test.ts index 317f543..b34547e 100644 --- a/__tests__/auth-store.test.ts +++ b/__tests__/auth-store.test.ts @@ -286,7 +286,7 @@ describe("createAuthStore — identity from /auth/me (CEL-622)", () => { expect(store.getEntitlements()).toEqual([]); }); - it("identity becomes null (tolerated) when /auth/me fails — does not throw", async () => { + it("clears the current generation on confirmed /auth/me revocation", async () => { global.fetch = routedFetch({ me: null }) as unknown as typeof fetch; const store = createAuthStore({ baseUrl: "http://localhost:4000" }); expect(() => store.setAccessToken("tok", 900)).not.toThrow(); @@ -294,8 +294,7 @@ describe("createAuthStore — identity from /auth/me (CEL-622)", () => { expect(store.getOrgId()).toBeNull(); expect(store.getUserId()).toBeNull(); expect(store.getEntitlements()).toEqual([]); - // Token itself is still retained (the failure is tolerated, like performRefresh). - expect(store.getAccessToken()).toBe("tok"); + expect(store.getAccessToken()).toBeNull(); }); it("clears prior identity synchronously on a token switch (no stale leak before /auth/me resolves)", async () => { diff --git a/__tests__/session-resolution.test.ts b/__tests__/session-resolution.test.ts new file mode 100644 index 0000000..0e79ccd --- /dev/null +++ b/__tests__/session-resolution.test.ts @@ -0,0 +1,342 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAuthStore } from "../src/auth-store.js"; +import type { AuthUser } from "../src/types.js"; + +const userA: AuthUser = { + id: "user_1", + email: "user@example.test", + name: "User", + userType: "importer", + orgId: "org_a", + roles: ["member"], + entitlements: ["importer-dashboard"], + createdAt: "2026-01-01T00:00:00.000Z", +}; + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("atomic session resolution (CEL-1782)", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it.each([ + ["missing orgId", (({ orgId: _orgId, ...rest }) => rest)(userA)], + ["missing userType", (({ userType: _userType, ...rest }) => rest)(userA)], + ["malformed roles", { ...userA, roles: ["member", 4] }], + ["malformed entitlements", { ...userA, entitlements: ["ok", null] }], + ["missing createdAt", (({ createdAt: _createdAt, ...rest }) => rest)(userA)], + ])("treats %s as unavailable without clearing credentials", async (_name, body) => { + global.fetch = vi.fn().mockResolvedValue(response(body)); + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + + store.setAccessToken("tok_a", 900); + const result = await store.resolveSession({ refresh: false }); + + expect(result).toEqual({ status: "unavailable", token: "tok_a" }); + expect(store.getAccessToken()).toBe("tok_a"); + expect(store.getUserId()).toBeNull(); + expect(store.getOrgId()).toBeNull(); + }); + + it("accepts explicit membership-null as authenticated profile", async () => { + const membershipNull: AuthUser = { + ...userA, + userType: null, + orgId: null, + roles: [], + }; + global.fetch = vi.fn().mockResolvedValue(response(membershipNull)); + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + + store.setAccessToken("tok_none", 900); + const result = await store.resolveSession({ refresh: false }); + + expect(result).toMatchObject({ status: "ready", user: membershipNull }); + expect(store.getUserId()).toBe(userA.id); + expect(store.getOrgId()).toBeNull(); + expect(store.getUserType()).toBeNull(); + }); + + it("publishes resolving before credential mutation and ready before legacy events", async () => { + const identities = new Map([ + ["tok_a", userA], + ["tok_b", { ...userA, orgId: "org_b" }], + ]); + global.fetch = vi.fn((_url, init) => { + const token = new Headers(init?.headers).get("Authorization")?.slice(7) ?? ""; + return Promise.resolve(response(identities.get(token))); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + const order: string[] = []; + store.onSessionStateChange((state) => { + if (state.status === "resolving") { + order.push(`resolving:${store.getAccessToken()}:${store.getOrgId()}`); + } else if (state.status === "ready") { + order.push(`state-ready:${store.getAccessToken()}:${store.getOrgId()}`); + } + }); + store.onAccessTokenSet(() => order.push("token")); + store.onOrgChange(() => order.push("org")); + order.length = 0; + + store.setAccessToken("tok_b", 900); + expect(order).toEqual(["resolving:tok_a:org_a"]); + await flush(); + expect(order).toEqual([ + "resolving:tok_a:org_a", + "state-ready:tok_b:org_b", + "token", + "org", + ]); + }); + + it("deduplicates timer and manual refresh into one refresh and identity flight", async () => { + vi.useFakeTimers(); + const refresh = deferred(); + let identityCalls = 0; + const fetchMock = vi.fn((url: string) => { + if (url.includes("/auth/refresh")) return refresh.promise; + identityCalls += 1; + return Promise.resolve(response(userA)); + }); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + refreshBuffer: 60, + }); + store.setAccessToken("tok_a", 120); + await flush(); + + await vi.advanceTimersByTimeAsync(60_000); + const manual = store.resolveSession({ refresh: true }); + refresh.resolve(response({ accessToken: "tok_new", expiresIn: 900 })); + await expect(manual).resolves.toMatchObject({ status: "ready", token: "tok_new" }); + + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("/auth/refresh"))).toHaveLength(1); + expect(identityCalls).toBe(2); + }); + + it("logout during refresh cannot resurrect credentials", async () => { + const refresh = deferred(); + global.fetch = vi.fn((url: string) => + url.includes("/auth/refresh") + ? refresh.promise + : Promise.resolve(response(userA)), + ) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + const resolving = store.resolveSession({ refresh: true }); + store.clearAccessToken(); + refresh.resolve(response({ accessToken: "tok_stale", expiresIn: 900 })); + + await expect(resolving).resolves.toEqual({ status: "superseded" }); + expect(store.getAccessToken()).toBeNull(); + }); + + it("old refresh response cannot clear a newer explicit login", async () => { + const refresh = deferred(); + global.fetch = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/auth/refresh")) return refresh.promise; + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_b" ? { ...userA, orgId: "org_b" } : userA), + ); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + const old = store.resolveSession({ refresh: true }); + store.setAccessToken("tok_b", 900); + await flush(); + refresh.resolve(response({ error: "Unauthorized" }, 401)); + + await expect(old).resolves.toEqual({ status: "superseded" }); + expect(store.getAccessToken()).toBe("tok_b"); + expect(store.getOrgId()).toBe("org_b"); + }); + + it("refreshes once before adopting same-token raw org change", async () => { + let currentRead = 0; + const tokenEvents: Array = []; + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/auth/refresh")) { + return Promise.resolve(response({ accessToken: "tok_b", expiresIn: 900 })); + } + const token = new Headers(init?.headers).get("Authorization"); + if (token === "Bearer tok_b") { + return Promise.resolve(response({ ...userA, orgId: "org_b" })); + } + currentRead += 1; + return Promise.resolve( + response(currentRead === 1 ? userA : { ...userA, orgId: "org_b" }), + ); + }); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.onAccessTokenSet((token) => tokenEvents.push(token)); + store.setAccessToken("tok_a", 900); + await flush(); + + const result = await store.resolveSession({ refresh: false }); + expect(result).toMatchObject({ status: "ready", token: "tok_b" }); + expect(store.getOrgId()).toBe("org_b"); + expect(tokenEvents).toEqual(["tok_a", "tok_b"]); + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("/auth/refresh"))).toHaveLength(1); + }); + + it("retains validated continuity across 503 before raw org mismatch", async () => { + let identityRead = 0; + const fetchMock = vi.fn((url: string) => { + if (url.includes("/auth/refresh")) { + return Promise.resolve(response({ code: "AUTHORITY_UNAVAILABLE" }, 503)); + } + identityRead += 1; + if (identityRead === 1) return Promise.resolve(response(userA)); + if (identityRead === 2) { + return Promise.resolve(response({ code: "AUTHORITY_UNAVAILABLE" }, 503)); + } + return Promise.resolve(response({ ...userA, orgId: "org_b" })); + }); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + await expect(store.resolveSession({ refresh: false })).resolves.toEqual({ + status: "unavailable", + token: "tok_a", + }); + await expect(store.resolveSession({ refresh: false })).resolves.toEqual({ + status: "unavailable", + token: "tok_a", + }); + + expect(store.getOrgId()).toBeNull(); + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("/auth/refresh"))).toHaveLength(1); + }); + + it("fails closed when refresh resolves a different account", async () => { + global.fetch = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/auth/refresh")) { + return Promise.resolve(response({ accessToken: "tok_other", expiresIn: 900 })); + } + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_other" ? { ...userA, id: "user_2" } : userA), + ); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + await expect(store.resolveSession({ refresh: true })).resolves.toEqual({ + status: "unauthorized", + }); + expect(store.getAccessToken()).toBeNull(); + }); + + it("503 and role-removal outcomes preserve correct authority semantics", async () => { + let mode: "ready" | "unavailable" | "removed" = "ready"; + global.fetch = vi.fn(() => { + if (mode === "unavailable") { + return Promise.resolve(response({ code: "AUTHORITY_UNAVAILABLE" }, 503)); + } + return Promise.resolve( + response(mode === "removed" ? { ...userA, roles: [] } : userA), + ); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + const orgChange = vi.fn(); + store.onOrgChange(orgChange); + store.setAccessToken("tok_a", 900); + await flush(); + orgChange.mockClear(); + + mode = "unavailable"; + await expect(store.resolveSession({ refresh: false })).resolves.toEqual({ + status: "unavailable", + token: "tok_a", + }); + expect(store.getAccessToken()).toBe("tok_a"); + expect(store.getUserId()).toBeNull(); + expect(orgChange).not.toHaveBeenCalled(); + + mode = "removed"; + const recovered = await store.resolveSession({ refresh: false }); + expect(recovered).toMatchObject({ status: "ready", user: { roles: [] } }); + expect(store.getUserId()).toBe(userA.id); + expect(orgChange).not.toHaveBeenCalled(); + }); + + it("caller abort returns superseded without aborting shared adoption", async () => { + const identity = deferred(); + let calls = 0; + global.fetch = vi.fn(() => { + calls += 1; + return calls === 1 + ? Promise.resolve(response(userA)) + : identity.promise; + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + const controller = new AbortController(); + const caller = store.resolveSession({ refresh: false, signal: controller.signal }); + controller.abort(); + await expect(caller).resolves.toEqual({ status: "superseded" }); + + identity.resolve(response({ ...userA, roles: [] })); + await flush(); + expect(store.getUserId()).toBe(userA.id); + }); + + it("generation-checks resolving observers before refresh mutation", async () => { + const fetchMock = vi.fn().mockResolvedValue(response(userA)); + global.fetch = fetchMock; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + + const unsubscribe = store.onSessionStateChange((state) => { + if (state.status === "resolving" && state.token === "tok_a") { + unsubscribe(); + store.clearAccessToken(); + } + }); + const result = await store.resolveSession({ refresh: true }); + + expect(result).toEqual({ status: "superseded" }); + expect(store.getAccessToken()).toBeNull(); + expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/auth/refresh"))).toBe(false); + }); +}); diff --git a/src/auth-api.ts b/src/auth-api.ts index ae86126..e48b167 100644 --- a/src/auth-api.ts +++ b/src/auth-api.ts @@ -9,6 +9,7 @@ import type { VerifyOtpResponse, } from "./types.js"; import { extractAccessToken } from "./extract-token.js"; +import { parseAuthUser } from "./auth-user.js"; export function createAuthApi(config: { client: AuthClient; @@ -64,18 +65,56 @@ export function createAuthApi(config: { }, async logout() { - await client.fetch("/auth/logout", { method: "POST" }); + const token = store.getAccessToken(); + await client.fetch("/auth/logout", { + method: "POST", + skipAuth: true, + ...(token + ? { headers: { Authorization: `Bearer ${token}` } } + : {}), + }); }, async getMe(token?: string) { - if (token) { - return client.fetch("/auth/me", { + const currentToken = store.getAccessToken(); + if (store.resolveSession && (!token || token === currentToken)) { + const resolution = await store.resolveSession({ refresh: false }); + if (resolution.status === "ready") return resolution.user; + if (resolution.status === "unauthorized") { + throw new AuthError(401, "UNAUTHORIZED", "Session is unauthorized"); + } + if (resolution.status === "superseded") { + throw new AuthError(409, "SESSION_SUPERSEDED", "Session was superseded"); + } + throw new AuthError( + 503, + "AUTHORITY_UNAVAILABLE", + "Session identity is unavailable", + ); + } + + const raw = token + ? await client.fetch("/auth/me", { method: "GET", skipAuth: true, headers: { Authorization: `Bearer ${token}` }, - }); + }) + : await client.fetch("/auth/me", { + method: "GET", + skipAuth: true, + ...(currentToken + ? { headers: { Authorization: `Bearer ${currentToken}` } } + : {}), + }); + const user = parseAuthUser(raw); + if (!user) { + throw new AuthError( + 503, + "AUTHORITY_UNAVAILABLE", + "Session identity is unavailable", + ); } - return client.fetch("/auth/me", { method: "GET" }); + return user; }, }; } diff --git a/src/auth-client.ts b/src/auth-client.ts index c9b8395..0ae24e2 100644 --- a/src/auth-client.ts +++ b/src/auth-client.ts @@ -1,5 +1,9 @@ import { AuthError } from "./types.js"; import type { AuthClientConfig, AuthClient, AuthErrorResponse } from "./types.js"; +import { + canReplaySession, + captureSessionContinuity, +} from "./session-continuity.js"; export function createAuthClient(config: AuthClientConfig): AuthClient { const { baseUrl, store, onAuthFailure } = config; @@ -30,12 +34,20 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { ): Promise { const { skipAuth, ...init } = options ?? {}; const headers = new Headers(init.headers); + const continuity = skipAuth ? null : captureSessionContinuity(store); if (!skipAuth) { const token = store.getAccessToken(); - if (token) { - headers.set("Authorization", `Bearer ${token}`); + if (!continuity) { + throw token + ? new AuthError( + 503, + "SESSION_UNAVAILABLE", + "Session identity is unavailable", + ) + : new AuthError(401, "UNAUTHORIZED", "Session is unauthorized"); } + headers.set("Authorization", `Bearer ${continuity.token}`); } if (!headers.has("Content-Type") && init.body) { @@ -54,23 +66,64 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { return (await res.json()) as T; } - // 401: attempt one refresh + retry (only for authenticated requests) + // 401: refresh once. Replay only when validated user + tenant continuity + // matches the authority captured before original transport. if (res.status === 401 && !skipAuth) { - const newToken = await store.ensureAccessToken(true); - if (newToken) { - headers.set("Authorization", `Bearer ${newToken}`); - const retryRes = await fetch(url, { - ...init, - headers, - credentials: "include", + if (store.resolveSession) { + const resolution = await store.resolveSession({ + refresh: true, + signal: init.signal ?? undefined, }); - if (retryRes.ok) { - return (await retryRes.json()) as T; + if ( + resolution.status === "ready" && + canReplaySession(continuity, resolution, store) + ) { + headers.set("Authorization", `Bearer ${resolution.token}`); + const retryRes = await fetch(url, { + ...init, + headers, + credentials: "include", + }); + if (retryRes.ok) return (await retryRes.json()) as T; + + const retryError = await parseErrorResponse(retryRes); + throw new AuthError( + retryRes.status, + retryError.code, + retryError.error, + retryError.remainingAttempts, + ); + } + if (resolution.status === "unavailable") { + throw new AuthError( + 503, + "SESSION_UNAVAILABLE", + "Session identity is unavailable", + ); + } + if (resolution.status === "superseded") { + if (init.signal?.aborted) { + throw new DOMException("Request aborted", "AbortError"); + } + throw new AuthError( + 409, + "SESSION_SUPERSEDED", + "Request session was superseded", + ); + } + if (resolution.status === "ready") { + throw new AuthError( + 409, + "SESSION_CONTINUITY_CHANGED", + "Request session authority changed", + ); } + onAuthFailure?.(); + } else { + // Legacy custom stores cannot prove post-refresh continuity. + store.clearAccessToken(); + onAuthFailure?.(); } - // Refresh failed or retry failed - store.clearAccessToken(); - onAuthFailure?.(); } const errBody = await parseErrorResponse(res); diff --git a/src/auth-store.ts b/src/auth-store.ts index b91a0d9..9a72b77 100644 --- a/src/auth-store.ts +++ b/src/auth-store.ts @@ -1,75 +1,22 @@ +import { copyAuthUser, parseAuthUser } from "./auth-user.js"; +import { extractAccessToken } from "./extract-token.js"; import type { AccessTokenSetListener, - AuthStore, AuthStoreConfig, AuthUser, + ConcreteAuthStore, DevLoginFailure, DevLoginResult, LogoutListener, OrgChangeListener, - SessionUserType, + ResolveSessionOptions, + SessionResolution, + SessionState, + SessionStateListener, } from "./types.js"; -import { extractAccessToken } from "./extract-token.js"; - -/** - * Minimal identity shape cached by the auth-store, sourced from `GET /auth/me`. - * - * CEL-622 — the public access token is an opaque, ENCRYPTED JWE (jose - * `EncryptJWT`, alg `dir` / `A256GCM`). It is NOT client-decodable, so the - * store can no longer derive identity from the token. Instead, every time a - * token is acquired or changed, the store fetches `/auth/me` (the authoritative, - * decryptable endpoint that — post-CEL-630 — returns orgId, userId, userType - * AND entitlements) with the bearer token and caches the result here. - */ -interface Identity { - userId: string; - orgId: string | null; - userType: SessionUserType; - entitlements: string[]; -} - -/** - * Map a `/auth/me` AuthUser onto the minimal cached identity. - * - * `AuthUser.id` is the userId. `entitlements` is optional on the wire (older - * backends omit it) → defaults to `[]`. The element filter guards against a - * malformed array carrying non-string entries. - */ -function toIdentity(user: AuthUser): Identity { - const entitlements = Array.isArray(user.entitlements) - ? user.entitlements.filter((e): e is string => typeof e === "string") - : []; - return { - userId: user.id, - orgId: user.orgId ?? null, - userType: user.userType, - entitlements, - }; -} -/** - * Fallback access-token lifetime, in seconds, when the server omits - * `expiresIn`. Mirrors the verify-otp adoption path in `auth-api.ts`. - */ const DEFAULT_ACCESS_TOKEN_TTL = 900; -/** - * Developer-facing copy for each `devLogin()` failure (CEL-1364). - * - * The 404 message frames the outcome as "the backend gate is off" and nothing - * else. The backend deliberately returns an identical 404 for "gate off" and - * "no such user" (T3-1), so any copy that named the account would be both a - * guess and a weakening of that contract. - * - * These strings SHIP in production bundles — they are referenced from - * `devLogin`'s live body, which no bundler can prove unreachable. The - * `ENABLE_TEST_ENDPOINTS` mention is therefore public, which is fine: the flag - * is documented in this package's README and in the backend repo, and knowing - * its name grants nothing when the route is not mounted. Moving the copy behind - * the DEV-only React module would eliminate it, but only by taking the - * ready-to-render `message` off `DevLoginFailure` — a public-API change, not a - * review fixup. - */ const DEV_LOGIN_MESSAGES = { "test-endpoints-disabled": "Dev sign-in unavailable: backend test endpoints are disabled. Set ENABLE_TEST_ENDPOINTS=true on the API and restart it.", @@ -81,41 +28,72 @@ const DEV_LOGIN_MESSAGES = { "Dev sign-in succeeded but the API returned no access token.", } as const; -export function createAuthStore(config: AuthStoreConfig): AuthStore { +type IdentityRead = + | { status: "ready"; user: AuthUser } + | { status: "unavailable" } + | { status: "unauthorized" }; + +interface ReadyBaseline { + token: string; + user: AuthUser; +} + +interface IdentityFlight { + generation: number; + promise: Promise; +} + +interface RefreshFlight { + generation: number; + promise: Promise; +} + +function copySessionState(state: SessionState): SessionState { + return state.status === "ready" + ? { ...state, user: copyAuthUser(state.user) } + : { ...state }; +} + +function copyResolution(resolution: SessionResolution): SessionResolution { + return resolution.status === "ready" + ? { ...resolution, user: copyAuthUser(resolution.user) } + : { ...resolution }; +} + +export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { const { baseUrl, refreshPath = "/auth/refresh", refreshBuffer = 60 } = config; let accessToken: string | null = null; - let identity: Identity | null = null; + let identity: AuthUser | null = null; let refreshTimer: ReturnType | null = null; - let refreshPromise: Promise | null = null; - - // Tracks the orgId observed at the last emitted state so `onOrgChange` only - // fires on an actual transition. `hasEmittedOrgId` disambiguates the two - // meanings `previousOrgId === null` would otherwise carry — "no org emitted - // yet" vs "the current org is admin/null". Without it, the FIRST admin - // identity (orgId null) and an admin login right after `clearToken()` would - // skip `onOrgChange(null)`. Both are reset on logout so a subsequent login - // re-fires `onOrgChange` even into the same org. + let identityFlight: IdentityFlight | null = null; + let refreshFlight: RefreshFlight | null = null; + let tokenGeneration = 0; let previousOrgId: string | null = null; let hasEmittedOrgId = false; - - // Monotonic generation counter. Bumped on every token change (set / clear / - // refresh). An in-flight `/auth/me` resolution only commits if its captured - // generation still matches — this discards stale identity results when the - // token changed again before the previous fetch resolved. - let tokenGeneration = 0; + // Last validated principal/tenant survives transient authority outages. It + // never powers authority getters; it only prevents raw same-token identity + // changes from becoming trusted after an unavailable read. + let continuityBaseline: ReadyBaseline | null = null; + // A refreshed credential may be adopted before its identity read succeeds. + // Preserve its old-principal lineage so a later retry still verifies account + // continuity while permitting a fresh-token organisation transition. + let pendingRefreshBaseline: ReadyBaseline | null = null; + let sessionState: SessionState = { status: "unauthorized" }; const orgChangeListeners = new Set(); const accessTokenSetListeners = new Set(); const logoutListeners = new Set(); + const sessionStateListeners = new Set(); + const stateQueue: SessionState[] = []; + let publishingState = false; function emitOrgChange(orgId: string | null): void { for (const listener of orgChangeListeners) { try { listener(orgId); } catch { - // Swallow listener errors so a single bad subscriber cannot - // break event fan-out to other subscribers. + // Subscriber failures cannot break other observers or session adoption. } } } @@ -125,7 +103,7 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { try { listener(token); } catch { - // Swallow. + // Subscriber failures cannot break other observers or session adoption. } } } @@ -135,205 +113,386 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { try { listener(); } catch { - // Swallow. + // Subscriber failures cannot break other observers or logout. + } + } + } + + function publishSessionState(next: SessionState): void { + stateQueue.push(copySessionState(next)); + if (publishingState) return; + publishingState = true; + try { + while (stateQueue.length > 0) { + const published = stateQueue.shift(); + if (!published) continue; + sessionState = published; + for (const listener of sessionStateListeners) { + try { + listener(copySessionState(published)); + } catch { + // Observers cannot veto or interrupt global session resolution. + } + } } + } finally { + publishingState = false; } } + /** Notify consumers before authority or credentials can change. */ + function beginResolving( + generation: number, + token: string | null, + ): boolean { + publishSessionState({ status: "resolving", token }); + if (generation !== tokenGeneration) return false; + identity = null; + return true; + } + function scheduleRefresh(expiresInSeconds: number): void { if (refreshTimer) clearTimeout(refreshTimer); const delay = Math.max((expiresInSeconds - refreshBuffer) * 1000, 0); refreshTimer = setTimeout(() => { - performRefresh(); + void store.resolveSession({ refresh: true }); }, delay); } - /** - * Fetch `/auth/me` with the bearer token and return the parsed identity, or - * null on any failure (network error, non-2xx, malformed body). - * - * Tolerant by design — mirrors how `performRefresh` swallows errors. A failed - * `/auth/me` must NOT throw out of `setAccessToken` / `performRefresh`; the - * caller treats null identity as "logged in but identity unknown" and the - * getters fall back to null/[]. - */ - async function fetchIdentity(token: string): Promise { + async function fetchIdentity(token: string): Promise { + let response: Response; try { - const res = await fetch(`${baseUrl}/auth/me`, { + response = await fetch(`${baseUrl}/auth/me`, { method: "GET", credentials: "include", headers: { Authorization: `Bearer ${token}` }, }); - if (!res.ok) return null; - const user = (await res.json()) as AuthUser; - if (!user || typeof user.id !== "string" || user.id.length === 0) { - return null; - } - return toIdentity(user); } catch { - return null; + return { status: "unavailable" }; + } + + if (response.status === 401 || response.status === 403) { + return { status: "unauthorized" }; + } + if (!response.ok) return { status: "unavailable" }; + + let raw: unknown; + try { + raw = await response.json(); + } catch { + return { status: "unavailable" }; + } + + const user = parseAuthUser(raw); + return user + ? { status: "ready", user } + : { status: "unavailable" }; + } + + function clearCurrentGeneration(generation: number): boolean { + if (generation !== tokenGeneration) return false; + tokenGeneration += 1; + const clearedGeneration = tokenGeneration; + accessToken = null; + identity = null; + continuityBaseline = null; + pendingRefreshBaseline = null; + previousOrgId = null; + hasEmittedOrgId = false; + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + publishSessionState({ status: "unauthorized" }); + if (tokenGeneration === clearedGeneration && accessToken === null) { + emitAccessTokenSet(null); + } + return true; + } + + function markUnavailable( + generation: number, + token: string | null, + ): SessionResolution { + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; + } + identity = null; + publishSessionState({ status: "unavailable", token }); + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; } + if ( + token && + generation === tokenGeneration && + accessToken === token + ) { + emitAccessTokenSet(token); + } + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; + } + return { status: "unavailable", token }; } - /** - * Commit a freshly-resolved identity and emit the dependent events. - * - * EVENT ORDERING (CRITICAL — consumer realtime hooks depend on it): - * identity is async, so on a token change the sequence is: - * 1. set token (synchronous, in `setAccessToken` / `performRefresh`) - * 2. kick off `fetchIdentity(token)` (async) - * 3. ON RESOLVE → here: - * a. cache identity FIRST (so getters return real values) - * b. emit `onAccessTokenSet(token)` (consumer hooks re-read getOrgId() - * inside this handler — identity must already be cached) - * c. emit `onOrgChange(orgId)` IF orgId actually changed vs the last - * emitted state - * - * The `generation` guard discards this commit if the token changed again - * (set/clear/refresh) while this `/auth/me` was in flight. - */ - function commitIdentity( + function commitReady( generation: number, token: string, - nextIdentity: Identity | null, - ): void { - if (generation !== tokenGeneration) { - // A newer token change superseded this fetch — drop the stale result. - return; + user: AuthUser, + ): SessionResolution { + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; } - identity = nextIdentity; - const nextOrgId = nextIdentity?.orgId ?? null; + identity = copyAuthUser(user); + const nextOrgId = identity.orgId; + continuityBaseline = { token, user: copyAuthUser(identity) }; + pendingRefreshBaseline = null; - // (b) Token-set first — handlers read getOrgId()/getUserId() and must see - // the just-cached identity. + publishSessionState({ status: "ready", token, user: identity }); + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; + } emitAccessTokenSet(token); - - // (c) Org-change on the first emission OR an actual transition. The - // `!hasEmittedOrgId` guard ensures the first identity (incl. an admin - // orgId of null) always fires once. + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; + } if (!hasEmittedOrgId || nextOrgId !== previousOrgId) { hasEmittedOrgId = true; previousOrgId = nextOrgId; emitOrgChange(nextOrgId); } + + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; + } + + return { status: "ready", token, user: copyAuthUser(identity) }; } - /** - * Apply a new access token, kicking off async identity resolution. - * - * The token is committed synchronously (so `getAccessToken()` is immediately - * correct); identity + `onAccessTokenSet` + `onOrgChange` settle only after - * `/auth/me` resolves (see `commitIdentity`). - */ - function applyToken(token: string): void { - tokenGeneration += 1; - const generation = tokenGeneration; - accessToken = token; - // Clear the prior session's identity synchronously so the getters return - // the documented null/[] defaults until the fresh /auth/me resolves — - // otherwise a token switch (e.g. logging in as a different user) would leak - // the previous session's userId/orgId/entitlements until the new fetch - // lands. The realtime consumer hooks read the getters on events (not by - // polling), so this introduces no flicker: a same-user refresh re-commits - // the same orgId without firing `onOrgChange`. - identity = null; - void fetchIdentity(token).then((next) => - commitIdentity(generation, token, next), - ); + function resolveIdentity( + generation: number, + token: string, + baseline: ReadyBaseline | null, + refreshed: boolean, + ): Promise { + if (identityFlight?.generation === generation) { + return identityFlight.promise; + } + + let flight!: IdentityFlight; + const promise = (async (): Promise => { + const read = await fetchIdentity(token); + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; + } + + if (read.status === "unauthorized") { + clearCurrentGeneration(generation); + return { status: "unauthorized" }; + } + if (read.status === "unavailable") { + return markUnavailable(generation, token); + } + + if (baseline && read.user.id !== baseline.user.id) { + if (!refreshed) return runRefresh(generation, baseline); + clearCurrentGeneration(generation); + return { status: "unauthorized" }; + } + + if (baseline && read.user.orgId !== baseline.user.orgId) { + if (!refreshed) { + // Same-token raw membership changes are not authority. Rotate once, + // then validate fresh credentials before adopting the transition. + return runRefresh(generation, baseline); + } + if (token === baseline.token) { + return markUnavailable(generation, token); + } + } + + return commitReady(generation, token, read.user); + })().finally(() => { + if (identityFlight === flight) identityFlight = null; + }); + + flight = { generation, promise }; + identityFlight = flight; + return promise; } - /** - * Clear all token + identity state synchronously and reset org tracking. - * - * `onAccessTokenSet(null)` fires synchronously (no `/auth/me` round-trip on - * logout). `previousOrgId` is reset so a subsequent login re-fires - * `onOrgChange` even when the user logs back into the same org. - */ - function clearToken(): void { - tokenGeneration += 1; // invalidate any in-flight /auth/me - accessToken = null; - identity = null; - previousOrgId = null; - hasEmittedOrgId = false; - emitAccessTokenSet(null); + function currentBaseline(): ReadyBaseline | null { + const baseline = pendingRefreshBaseline ?? continuityBaseline; + return baseline + ? { token: baseline.token, user: copyAuthUser(baseline.user) } + : null; } - async function performRefresh(): Promise { - try { - const res = await fetch(`${baseUrl}${refreshPath}`, { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - }); - if (!res.ok) { - clearToken(); - return null; + function runRefresh( + generation: number, + baseline: ReadyBaseline | null, + ): Promise { + if (refreshFlight?.generation === generation) { + return refreshFlight.promise; + } + if (generation !== tokenGeneration) { + return Promise.resolve({ status: "superseded" }); + } + + if (!beginResolving(generation, accessToken)) { + return Promise.resolve({ status: "superseded" }); + } + + let flight!: RefreshFlight; + const promise = (async (): Promise => { + let response: Response; + try { + response = await fetch(`${baseUrl}${refreshPath}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + } catch { + return markUnavailable(generation, accessToken); } - const json = (await res.json()) as Record; - const token = extractAccessToken(json); - if (token) { - const expiresIn = - typeof json.expiresIn === "number" ? json.expiresIn : 900; - applyToken(token); - scheduleRefresh(expiresIn); + + if (generation !== tokenGeneration) return { status: "superseded" }; + if (response.status === 401 || response.status === 403) { + clearCurrentGeneration(generation); + return { status: "unauthorized" }; } - return token; - } catch { - clearToken(); - return null; + if (!response.ok) return markUnavailable(generation, accessToken); + + let raw: unknown; + try { + raw = await response.json(); + } catch { + return markUnavailable(generation, accessToken); + } + if (generation !== tokenGeneration) return { status: "superseded" }; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return markUnavailable(generation, accessToken); + } + + const json = raw as Record; + const nextToken = extractAccessToken(json); + if (!nextToken) return markUnavailable(generation, accessToken); + + const expiresIn = + typeof json.expiresIn === "number" && Number.isFinite(json.expiresIn) + ? json.expiresIn + : DEFAULT_ACCESS_TOKEN_TTL; + + tokenGeneration += 1; + const nextGeneration = tokenGeneration; + // Manual/timer callers arriving while refreshed identity still resolves + // join this same end-to-end refresh operation. + flight.generation = nextGeneration; + accessToken = nextToken; + identity = null; + pendingRefreshBaseline = baseline + ? { token: baseline.token, user: copyAuthUser(baseline.user) } + : null; + scheduleRefresh(expiresIn); + + return resolveIdentity(nextGeneration, nextToken, baseline, true); + })().finally(() => { + if (refreshFlight === flight) refreshFlight = null; + }); + + flight = { generation, promise }; + refreshFlight = flight; + return promise; + } + + function resolveCurrentSession(): Promise { + const token = accessToken; + const generation = tokenGeneration; + if (!token) return Promise.resolve({ status: "unauthorized" }); + if (identityFlight?.generation === generation) { + return identityFlight.promise; + } + + const baseline = currentBaseline(); + const refreshed = pendingRefreshBaseline !== null; + if (!beginResolving(generation, token)) { + return Promise.resolve({ status: "superseded" }); } + return resolveIdentity(generation, token, baseline, refreshed); } - const store: AuthStore = { - getAccessToken() { - return accessToken; - }, + function waitForCaller( + promise: Promise, + signal?: AbortSignal, + ): Promise { + if (!signal) return promise.then(copyResolution); + if (signal.aborted) return Promise.resolve({ status: "superseded" }); + + return new Promise((resolve) => { + const onAbort = () => resolve({ status: "superseded" }); + signal.addEventListener("abort", onAbort, { once: true }); + void promise.then((result) => { + signal.removeEventListener("abort", onAbort); + resolve(copyResolution(result)); + }); + }); + } - hasAccessToken() { - return accessToken !== null; - }, + function startExplicitToken(token: string, expiresIn: number): void { + const previousGeneration = tokenGeneration; + if (!beginResolving(previousGeneration, token)) return; + tokenGeneration += 1; + const generation = tokenGeneration; + accessToken = token; + identity = null; + continuityBaseline = null; + pendingRefreshBaseline = null; + scheduleRefresh(expiresIn); + void resolveIdentity(generation, token, null, false); + } - setAccessToken(token: string, expiresIn: number) { - applyToken(token); - scheduleRefresh(expiresIn); + let store!: ConcreteAuthStore; + store = { + getAccessToken: () => accessToken, + hasAccessToken: () => accessToken !== null, + + setAccessToken(token, expiresIn) { + startExplicitToken(token, expiresIn); }, clearAccessToken() { - clearToken(); - if (refreshTimer) { - clearTimeout(refreshTimer); - refreshTimer = null; - } - // Emits unconditionally so consumers can rely on `onLogout` as a - // canonical logout signal regardless of prior token state. + clearCurrentGeneration(tokenGeneration); emitLogout(); }, async ensureAccessToken(forceRefresh = false) { - if (!forceRefresh && accessToken) return accessToken; - if (refreshPromise) return refreshPromise; - refreshPromise = performRefresh().finally(() => { - refreshPromise = null; - }); - return refreshPromise; + if (!forceRefresh && accessToken && identity && !identityFlight) { + return accessToken; + } + const result = forceRefresh + ? await store.resolveSession({ refresh: true }) + : await store.resolveSession(); + return result.status === "ready" || result.status === "unavailable" + ? result.token + : null; + }, + + resolveSession(options: ResolveSessionOptions = {}) { + const shouldRefresh = + options.refresh === true || + (options.refresh === undefined && accessToken === null); + const operation = shouldRefresh + ? runRefresh(tokenGeneration, currentBaseline()) + : resolveCurrentSession(); + return waitForCaller(operation, options.signal); }, - /** - * LOCAL-DEV ONLY (CEL-1364) — see the `AuthStore.devLogin` doc comment. - * - * Adoption deliberately routes through `store.setAccessToken()` rather than - * touching `applyToken` / `scheduleRefresh` directly, so there is exactly - * ONE token-adoption path shared with verify-otp: same identity fetch, same - * refresh scheduling, same listener fan-out and ordering. - */ async devLogin(email: string): Promise { - let res: Response; + let response: Response; try { - res = await fetch(`${baseUrl}/test/login`, { + response = await fetch(`${baseUrl}/test/login`, { method: "POST", - // The route also sets the BFF session + refresh cookies the OTP flow - // sets; `include` is what lets a subsequent `/auth/refresh` work. credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), @@ -347,8 +506,8 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { }; } - if (!res.ok) { - if (res.status === 404) { + if (!response.ok) { + if (response.status === 404) { return { ok: false, reason: "test-endpoints-disabled", @@ -356,7 +515,7 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { message: DEV_LOGIN_MESSAGES["test-endpoints-disabled"], }; } - if (res.status === 429) { + if (response.status === 429) { return { ok: false, reason: "rate-limited", @@ -364,7 +523,7 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { message: DEV_LOGIN_MESSAGES["rate-limited"], }; } - if (res.status === 403) { + if (response.status === 403) { return { ok: false, reason: "forbidden", @@ -375,48 +534,36 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { return { ok: false, reason: "unexpected", - status: res.status, - message: `Dev sign-in failed (HTTP ${res.status}).`, + status: response.status, + message: `Dev sign-in failed (HTTP ${response.status}).`, }; } - // Built once: three distinct malformed shapes converge on it below. const malformed: DevLoginFailure = { ok: false, reason: "malformed-response", - status: res.status, + status: response.status, message: DEV_LOGIN_MESSAGES["malformed-response"], }; - let parsed: unknown; + let raw: unknown; try { - parsed = await res.json(); + raw = await response.json(); } catch { return malformed; } - - // `res.json()` resolving is not the same as "we got an object". A body of - // literal `null` (or a bare string/number) parses fine, and - // `extractAccessToken` dereferences its argument — so passing `null` - // through would THROW out of a function whose result type promises it - // never does, leaving the DEV button with no error channel at all. - // Arrays fall through: they are objects, carry no token, and reach the - // same `malformed` below. - if (parsed === null || typeof parsed !== "object") { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return malformed; } - const json = parsed as Record; + const json = raw as Record; const token = extractAccessToken(json); - if (!token) { - return malformed; - } + if (!token) return malformed; const expiresIn = typeof json.expiresIn === "number" ? json.expiresIn : DEFAULT_ACCESS_TOKEN_TTL; - store.setAccessToken(token, expiresIn); return { @@ -428,21 +575,22 @@ export function createAuthStore(config: AuthStoreConfig): AuthStore { }; }, - getUserId() { - return identity?.userId ?? null; - }, + getUserId: () => identity?.id ?? null, + getOrgId: () => identity?.orgId ?? null, + getUserType: () => identity?.userType ?? null, + getEntitlements: () => + identity?.entitlements ? [...identity.entitlements] : [], - getOrgId() { - return identity?.orgId ?? null; - }, - - getUserType() { - return identity?.userType ?? null; - }, - - getEntitlements() { - // Defensive copy; [] when logged out or identity unresolved. - return identity ? [...identity.entitlements] : []; + onSessionStateChange(listener) { + sessionStateListeners.add(listener); + try { + listener(copySessionState(sessionState)); + } catch { + // Immediate delivery has the same isolation as later notifications. + } + return () => { + sessionStateListeners.delete(listener); + }; }, onOrgChange(listener) { diff --git a/src/auth-user.ts b/src/auth-user.ts new file mode 100644 index 0000000..383cdf5 --- /dev/null +++ b/src/auth-user.ts @@ -0,0 +1,70 @@ +import type { AuthUser, SessionUserType } from "./types.js"; + +const SESSION_USER_TYPES = new Set([ + "importer", + "producer", + "distributor", + "admin", +]); + +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +/** Validate and defensively copy the complete `/auth/me` response. */ +export function parseAuthUser(value: unknown): AuthUser | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const user = value as Record; + if ( + typeof user.id !== "string" || + user.id.length === 0 || + typeof user.email !== "string" || + typeof user.name !== "string" || + typeof user.createdAt !== "string" || + !hasOwn(user, "orgId") || + !(user.orgId === null || typeof user.orgId === "string") || + !hasOwn(user, "userType") || + !( + user.userType === null || + (typeof user.userType === "string" && + SESSION_USER_TYPES.has(user.userType as SessionUserType)) + ) || + !Array.isArray(user.roles) || + !user.roles.every((role) => typeof role === "string") || + (user.phone !== undefined && typeof user.phone !== "string") || + (user.entitlements !== undefined && + (!Array.isArray(user.entitlements) || + !user.entitlements.every( + (entitlement) => typeof entitlement === "string", + ))) + ) { + return null; + } + + return { + id: user.id, + email: user.email, + name: user.name, + ...(user.phone === undefined ? {} : { phone: user.phone }), + userType: user.userType as SessionUserType | null, + orgId: user.orgId as string | null, + roles: [...user.roles] as string[], + ...(user.entitlements === undefined + ? {} + : { entitlements: [...user.entitlements] as string[] }), + createdAt: user.createdAt, + }; +} + +export function copyAuthUser(user: AuthUser): AuthUser { + return { + ...user, + roles: [...user.roles], + ...(user.entitlements === undefined + ? {} + : { entitlements: [...user.entitlements] }), + }; +} diff --git a/src/index.ts b/src/index.ts index f186379..20dc09f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,16 @@ export { createAuthClient } from "./auth-client.js"; export { createAuthApi } from "./auth-api.js"; export { validateUserType, hasEntitlement } from "./auth-guard.js"; export { extractAccessToken } from "./extract-token.js"; +export { + captureSessionContinuity, + canReplaySession, +} from "./session-continuity.js"; export { AuthError, type AuthUser, type AuthStore, + type ConcreteAuthStore, type AuthStoreConfig, type AuthClient, type AuthClientConfig, @@ -24,6 +29,11 @@ export { type DashboardLink, type SessionClaims, type SessionUserType, + type ResolveSessionOptions, + type SessionResolution, + type SessionState, + type SessionStateListener, + type SessionContinuity, type OrgChangeListener, type AccessTokenSetListener, type LogoutListener, diff --git a/src/react/login-form.tsx b/src/react/login-form.tsx index 2b2b685..2f76d13 100644 --- a/src/react/login-form.tsx +++ b/src/react/login-form.tsx @@ -218,7 +218,7 @@ export function LoginForm({ if (result.user.userType !== userType) { const msg = `This portal is for ${userType} accounts only.`; setError(msg); - if (onError) onError({ code: "USER_TYPE_MISMATCH", message: msg, authenticatedUserType: result.user.userType }); + if (onError) onError({ code: "USER_TYPE_MISMATCH", message: msg, authenticatedUserType: result.user.userType ?? undefined }); authStore.clearAccessToken(); return; } diff --git a/src/session-continuity.ts b/src/session-continuity.ts new file mode 100644 index 0000000..7d84aa9 --- /dev/null +++ b/src/session-continuity.ts @@ -0,0 +1,36 @@ +import type { + AuthStore, + SessionContinuity, + SessionResolution, +} from "./types.js"; + +/** Capture validated user/tenant authority before request transport begins. */ +export function captureSessionContinuity( + store: AuthStore, +): SessionContinuity | null { + if ( + typeof store.getUserId !== "function" || + typeof store.getOrgId !== "function" + ) { + return null; + } + const token = store.getAccessToken(); + const userId = store.getUserId(); + if (!token || !userId) return null; + return { token, userId, orgId: store.getOrgId() }; +} + +/** Permit 401 replay only across same validated user and organisation. */ +export function canReplaySession( + before: SessionContinuity | null, + after: SessionResolution, + store: AuthStore, +): boolean { + return ( + before !== null && + after.status === "ready" && + store.getAccessToken() === after.token && + after.user.id === before.userId && + after.user.orgId === before.orgId + ); +} diff --git a/src/types.ts b/src/types.ts index 87d51c3..8b5466f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ export interface AuthUser { email: string; name: string; phone?: string; - userType: "importer" | "producer" | "admin"; + userType: SessionUserType | null; orgId: string | null; roles: string[]; /** @@ -69,6 +69,34 @@ export interface AuthStoreConfig { */ export type SessionUserType = "importer" | "producer" | "distributor" | "admin"; +export interface ResolveSessionOptions { + /** Rotate credentials before resolving authority. Defaults to true when no token exists. */ + refresh?: boolean; + /** Cancels only this caller's wait. Shared refresh and identity adoption continue. */ + signal?: AbortSignal; +} + +export type SessionResolution = + | { status: "ready"; token: string; user: AuthUser } + | { status: "unavailable"; token: string | null } + | { status: "unauthorized" } + | { status: "superseded" }; + +export type SessionState = + | { status: "ready"; token: string; user: AuthUser } + | { status: "resolving"; token: string | null } + | { status: "unavailable"; token: string | null } + | { status: "unauthorized" }; + +export type SessionStateListener = (state: SessionState) => void; + +/** Validated authority captured before an authenticated transport starts. */ +export interface SessionContinuity { + token: string; + userId: string; + orgId: string | null; +} + /** * Decoded claims from a V2 access-token JWT. * @@ -141,6 +169,16 @@ export interface AuthStore { clearAccessToken(): void; ensureAccessToken(forceRefresh?: boolean): Promise; + /** + * Resolve credentials and authoritative `/auth/me` identity as one guarded + * operation. Optional here so existing custom stores remain source-compatible. + * `createAuthStore()` always provides this method through `ConcreteAuthStore`. + */ + resolveSession?(options?: ResolveSessionOptions): Promise; + + /** Subscribe to guarded session-resolution state. Immediately receives a snapshot. */ + onSessionStateChange?(listener: SessionStateListener): () => void; + /** * LOCAL-DEV ONLY — mint a session for `email` via the backend's * `POST /test/login`, bypassing the OTP round-trip (CEL-1364). @@ -210,6 +248,11 @@ export interface AuthStore { onLogout(listener: LogoutListener): () => void; } +export interface ConcreteAuthStore extends AuthStore { + resolveSession(options?: ResolveSessionOptions): Promise; + onSessionStateChange(listener: SessionStateListener): () => void; +} + export interface AuthClientConfig { baseUrl: string; store: AuthStore; From 9bfc3fb473a550a1554e2f5c65e534fc6a10e914 Mon Sep 17 00:00:00 2001 From: mjnong Date: Mon, 7 Sep 2026 17:34:02 +0200 Subject: [PATCH 2/9] fix: guard replay against unresolved authority --- __tests__/auth-client.test.ts | 20 ++++++++++++++++++++ src/session-continuity.ts | 2 ++ 2 files changed, 22 insertions(+) diff --git a/__tests__/auth-client.test.ts b/__tests__/auth-client.test.ts index 5c2117f..9c1b460 100644 --- a/__tests__/auth-client.test.ts +++ b/__tests__/auth-client.test.ts @@ -156,6 +156,26 @@ describe("createAuthClient", () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); + it("does not replay when same-token authority starts resolving again", async () => { + const store = mockStore(); + (store.resolveSession as ReturnType).mockImplementation(async () => { + (store.getAccessToken as ReturnType).mockReturnValue("tok_new"); + (store.getUserId as ReturnType).mockReturnValue(null); + (store.getOrgId as ReturnType).mockReturnValue(null); + return { status: "ready", token: "tok_new", user: userA }; + }); + global.fetch = vi + .fn() + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); + + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 409, + code: "SESSION_CONTINUITY_CHANGED", + }); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + it("preserves credentials and suppresses replay when refresh is unavailable", async () => { const store = mockStore("tok_old", { status: "unavailable", diff --git a/src/session-continuity.ts b/src/session-continuity.ts index 7d84aa9..af236a9 100644 --- a/src/session-continuity.ts +++ b/src/session-continuity.ts @@ -30,6 +30,8 @@ export function canReplaySession( before !== null && after.status === "ready" && store.getAccessToken() === after.token && + store.getUserId() === after.user.id && + store.getOrgId() === after.user.orgId && after.user.id === before.userId && after.user.orgId === before.orgId ); From 2f74023a75b9a686a2ce4122cc53339897721371 Mon Sep 17 00:00:00 2001 From: mjnong Date: Mon, 7 Sep 2026 18:22:49 +0200 Subject: [PATCH 3/9] fix: harden session resolution races --- README.md | 15 +- __tests__/auth-api.test.ts | 89 +++++++- __tests__/auth-client.test.ts | 184 ++++++++++++++- __tests__/session-resolution.test.ts | 255 ++++++++++++++++++++- src/auth-api.ts | 14 +- src/auth-client.ts | 73 ++++-- src/auth-store.ts | 329 +++++++++++++++++++-------- src/index.ts | 2 + src/session-continuity.ts | 107 ++++++++- src/types.ts | 19 +- 10 files changed, 936 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index a57cbcc..cf1102b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ import { createAuthApi, createAuthClient, createAuthStore, + resolveSessionForReplay, } from "@cellarnode/auth"; const authStore = createAuthStore({ @@ -48,6 +49,10 @@ if (session.status === "ready") { `onSessionStateChange` immediately reports current state. A synchronous `resolving` notification precedes credential or authority changes, allowing consumers to suspend writes and clear captured tenant queries first. +`getSessionState()` returns the same guarded state as a defensive snapshot; +continuity capture requires that snapshot to remain `ready`. Internal refresh +and identity requests time out after 10 seconds by default; configure +`resolutionTimeoutMs` on `createAuthStore` when needed. Authenticated transports that retry after 401 must capture continuity before their first request and replay only for same validated user and organisation: @@ -57,13 +62,19 @@ const before = captureSessionContinuity(authStore); if (!before) throw new Error("Session authority unavailable"); const response = await fetch(url, init); if (response.status === 401) { - const after = await authStore.resolveSession({ refresh: true, signal: init.signal }); + const after = await resolveSessionForReplay(authStore, before, { + signal: init.signal, + }); if (canReplaySession(before, after, authStore)) { // retry once with after.token } } ``` +`resolveSessionForReplay` centralizes stale-401 handling. It never refreshes a +replacement session. A ready rotated token can be reused once only when +validated user and organisation still match captured request authority. + ### React Components ```tsx @@ -120,7 +131,7 @@ Add this to your CSS file so Tailwind picks up utility classes from the package: ## Exports -- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `captureSessionContinuity`, `canReplaySession`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, `AuthError`, session-resolution types, and `DevLoginResult` +- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `captureSessionContinuity`, `resolveSessionForReplay`, `canReplaySession`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, `AuthError`, session-resolution types, and `DevLoginResult` - `@cellarnode/auth/react` — React: `LoginForm`, `RegisterForm`, `UnauthorizedPage`, `SquircleShift`, `InputOTP` (+ `Group` / `Slot` / `Separator`) `DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`, `readDevLoginEmail` and diff --git a/__tests__/auth-api.test.ts b/__tests__/auth-api.test.ts index c2032fc..c6ba3e3 100644 --- a/__tests__/auth-api.test.ts +++ b/__tests__/auth-api.test.ts @@ -153,6 +153,31 @@ describe("createAuthApi", () => { expect(headers["Authorization"]).toBe("Bearer tok_explicit"); }); + it("treats an empty string as an explicit token without resolving", async () => { + const client = mockClient(); + (client.fetch as ReturnType).mockResolvedValue({ + id: "u1", + email: "t@t.com", + name: "Test", + userType: "producer", + orgId: null, + roles: [], + createdAt: "2026-01-01T00:00:00.000Z", + }); + const store = mockStore(); + store.resolveSession = vi.fn(); + const api = createAuthApi({ client, store }); + + await api.getMe(""); + + expect(store.resolveSession).not.toHaveBeenCalled(); + expect(client.fetch).toHaveBeenCalledWith("/auth/me", { + method: "GET", + skipAuth: true, + headers: { Authorization: "Bearer " }, + }); + }); + it("getMe without resolver uses explicit no-refresh transport", async () => { const client = mockClient(); (client.fetch as ReturnType).mockResolvedValue({ @@ -173,7 +198,7 @@ describe("createAuthApi", () => { expect(opts.skipAuth).toBe(true); }); - it("current-token getMe shares an active identity read then revalidates", async () => { + it("tokenless getMe shares an active identity read then revalidates", async () => { let resolveFirst!: (value: Response) => void; const first = new Promise((resolve) => { resolveFirst = resolve; @@ -201,7 +226,7 @@ describe("createAuthApi", () => { const api = createAuthApi({ client, store }); store.setAccessToken("tok_current", 900); - const joined = api.getMe("tok_current"); + const joined = api.getMe(); expect(fetchMock).toHaveBeenCalledTimes(1); resolveFirst({ ok: true, @@ -210,11 +235,69 @@ describe("createAuthApi", () => { } as Response); await expect(joined).resolves.toMatchObject({ roles: ["member"] }); - await expect(api.getMe("tok_current")).resolves.toMatchObject({ roles: [] }); + await expect(api.getMe()).resolves.toMatchObject({ roles: [] }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(client.fetch).not.toHaveBeenCalled(); }); + it("explicit current-token getMe never enters resolver or refresh paths", async () => { + const client = mockClient(); + (client.fetch as ReturnType).mockResolvedValue({ + id: "u1", + email: "t@t.com", + name: "Test", + userType: "producer", + orgId: "org_1", + roles: [], + createdAt: "2026-01-01T00:00:00.000Z", + }); + const store = mockStore(); + (store.getAccessToken as ReturnType).mockReturnValue("tok_current"); + store.resolveSession = vi.fn(); + const api = createAuthApi({ client, store }); + + await expect(api.getMe("tok_current")).resolves.toMatchObject({ id: "u1" }); + + expect(store.resolveSession).not.toHaveBeenCalled(); + expect(client.fetch).toHaveBeenCalledWith("/auth/me", { + method: "GET", + skipAuth: true, + headers: { Authorization: "Bearer tok_current" }, + }); + }); + + it("rejects an explicit current-token identity response after token replacement", async () => { + let resolveIdentity!: (value: unknown) => void; + const identity = new Promise((resolve) => { + resolveIdentity = resolve; + }); + const client = mockClient(); + (client.fetch as ReturnType).mockReturnValue(identity); + const store = mockStore(); + (store.getAccessToken as ReturnType) + .mockReturnValueOnce("tok_a") + .mockReturnValue("tok_b"); + store.resolveSession = vi.fn(); + const api = createAuthApi({ client, store }); + + const pending = api.getMe("tok_a"); + resolveIdentity({ + id: "u1", + email: "t@t.com", + name: "Test", + userType: "producer", + orgId: "org_1", + roles: [], + createdAt: "2026-01-01T00:00:00.000Z", + }); + + await expect(pending).rejects.toMatchObject({ + status: 409, + code: "SESSION_SUPERSEDED", + }); + expect(store.resolveSession).not.toHaveBeenCalled(); + }); + it("rejects malformed explicit-token /auth/me without refreshing", async () => { const client = mockClient(); (client.fetch as ReturnType).mockResolvedValue({ diff --git a/__tests__/auth-client.test.ts b/__tests__/auth-client.test.ts index 9c1b460..9ce8899 100644 --- a/__tests__/auth-client.test.ts +++ b/__tests__/auth-client.test.ts @@ -31,7 +31,9 @@ function mockStore( getAccessToken: vi.fn(() => currentToken), hasAccessToken: vi.fn().mockReturnValue(token !== null), setAccessToken: vi.fn(), - clearAccessToken: vi.fn(), + clearAccessToken: vi.fn(() => { + currentToken = null; + }), ensureAccessToken: vi.fn().mockResolvedValue(token), resolveSession: vi.fn(async () => { if (resolution.status === "ready") currentToken = resolution.token; @@ -119,6 +121,69 @@ describe("createAuthClient", () => { expect(retryHeaders.get("Authorization")).toBe("Bearer tok_new"); }); + it("does not refresh replacement session after stale request returns 401", async () => { + let resolveFirst!: (response: Response) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const store = mockStore("tok_a"); + const onAuthFailure = vi.fn(); + global.fetch = vi.fn().mockReturnValue(first); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + onAuthFailure, + }); + + const staleRequest = client.fetch("/api/write"); + const userB = { ...userA, id: "user_2", orgId: "org_b" }; + (store.getAccessToken as ReturnType).mockReturnValue("tok_b"); + (store.getUserId as ReturnType).mockReturnValue(userB.id); + (store.getOrgId as ReturnType).mockReturnValue(userB.orgId); + store.getSessionState = vi.fn().mockReturnValue({ + status: "ready", + token: "tok_b", + user: userB, + }); + resolveFirst(response({ code: "UNAUTHORIZED" }, 401)); + + await expect(staleRequest).rejects.toMatchObject({ + status: 409, + code: "SESSION_SUPERSEDED", + }); + expect(store.resolveSession).not.toHaveBeenCalled(); + expect(onAuthFailure).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("reuses ready rotated token for same user and org without refreshing", async () => { + let resolveFirst!: (response: Response) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const store = mockStore("tok_a"); + global.fetch = vi + .fn() + .mockReturnValueOnce(first) + .mockResolvedValueOnce(response({ data: "success" })); + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + + const staleRequest = client.fetch<{ data: string }>("/api/write"); + (store.getAccessToken as ReturnType).mockReturnValue("tok_b"); + store.getSessionState = vi.fn().mockReturnValue({ + status: "ready", + token: "tok_b", + user: userA, + }); + resolveFirst(response({ code: "UNAUTHORIZED" }, 401)); + + await expect(staleRequest).resolves.toEqual({ data: "success" }); + expect(store.resolveSession).not.toHaveBeenCalled(); + const retryHeaders = (global.fetch as ReturnType).mock.calls[1][1] + .headers as Headers; + expect(retryHeaders.get("Authorization")).toBe("Bearer tok_b"); + }); + it("does not replay an org-A request after refresh resolves org B", async () => { const store = mockStore("tok_old", { status: "ready", @@ -132,7 +197,7 @@ describe("createAuthClient", () => { const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); await expect(client.fetch("/api/write")).rejects.toMatchObject({ status: 409, - code: "SESSION_CONTINUITY_CHANGED", + code: "SESSION_SUPERSEDED", }); expect(global.fetch).toHaveBeenCalledTimes(1); expect(store.clearAccessToken).not.toHaveBeenCalled(); @@ -140,8 +205,15 @@ describe("createAuthClient", () => { it("does not replay when ready token was superseded before continuity check", async () => { const store = mockStore(); + let superseded = false; + store.getSessionState = vi.fn(() => + superseded + ? { status: "resolving", token: "tok_later" } + : { status: "ready", token: "tok_old", user: userA }, + ); (store.resolveSession as ReturnType).mockImplementation(async () => { (store.getAccessToken as ReturnType).mockReturnValue("tok_later"); + superseded = true; return { status: "ready", token: "tok_new", user: userA }; }); global.fetch = vi @@ -151,17 +223,24 @@ describe("createAuthClient", () => { const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); await expect(client.fetch("/api/write")).rejects.toMatchObject({ status: 409, - code: "SESSION_CONTINUITY_CHANGED", + code: "SESSION_SUPERSEDED", }); expect(global.fetch).toHaveBeenCalledTimes(1); }); - it("does not replay when same-token authority starts resolving again", async () => { + it("returns unavailable when same-token authority starts resolving again", async () => { const store = mockStore(); + let authorityUnavailable = false; + store.getSessionState = vi.fn(() => + authorityUnavailable + ? { status: "unavailable", token: "tok_new" } + : { status: "ready", token: "tok_old", user: userA }, + ); (store.resolveSession as ReturnType).mockImplementation(async () => { (store.getAccessToken as ReturnType).mockReturnValue("tok_new"); (store.getUserId as ReturnType).mockReturnValue(null); (store.getOrgId as ReturnType).mockReturnValue(null); + authorityUnavailable = true; return { status: "ready", token: "tok_new", user: userA }; }); global.fetch = vi @@ -170,8 +249,8 @@ describe("createAuthClient", () => { const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); await expect(client.fetch("/api/write")).rejects.toMatchObject({ - status: 409, - code: "SESSION_CONTINUITY_CHANGED", + status: 503, + code: "SESSION_UNAVAILABLE", }); expect(global.fetch).toHaveBeenCalledTimes(1); }); @@ -226,6 +305,95 @@ describe("createAuthClient", () => { expect(store.clearAccessToken).not.toHaveBeenCalled(); }); + it("preserves legacy no-resolver 401 logout behavior", async () => { + const store = mockStore("tok_old"); + delete store.resolveSession; + global.fetch = vi + .fn() + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); + const onAuthFailure = vi.fn(); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + onAuthFailure, + }); + + await expect(client.fetch("/api/write")).rejects.toMatchObject({ status: 401 }); + expect(store.clearAccessToken).toHaveBeenCalledTimes(1); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + }); + + it("does not clear replacement session in legacy no-resolver 401 path", async () => { + let resolveFirst!: (response: Response) => void; + const first = new Promise((resolve) => { + resolveFirst = resolve; + }); + const store = mockStore("tok_a"); + delete store.resolveSession; + const onAuthFailure = vi.fn(); + global.fetch = vi.fn().mockReturnValue(first); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + onAuthFailure, + }); + + const staleRequest = client.fetch("/api/write"); + (store.getAccessToken as ReturnType).mockReturnValue("tok_b"); + (store.getUserId as ReturnType).mockReturnValue("user_2"); + (store.getOrgId as ReturnType).mockReturnValue("org_b"); + resolveFirst(response({ code: "UNAUTHORIZED" }, 401)); + + await expect(staleRequest).rejects.toMatchObject({ + status: 409, + code: "SESSION_SUPERSEDED", + }); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + expect(onAuthFailure).not.toHaveBeenCalled(); + }); + + it("suppresses stale unauthorized result after replacement installs", async () => { + const store = mockStore("tok_a", { status: "unauthorized" }); + const userB = { ...userA, id: "user_2", orgId: "org_b" }; + let replaced = false; + (store.getAccessToken as ReturnType).mockImplementation(() => + replaced ? "tok_b" : "tok_a", + ); + (store.getUserId as ReturnType).mockImplementation(() => + replaced ? userB.id : userA.id, + ); + (store.getOrgId as ReturnType).mockImplementation(() => + replaced ? userB.orgId : userA.orgId, + ); + store.getSessionState = vi.fn(() => + replaced + ? { status: "ready", token: "tok_b", user: userB } + : { status: "ready", token: "tok_a", user: userA }, + ); + (store.resolveSession as ReturnType).mockImplementation(async () => { + queueMicrotask(() => { + replaced = true; + }); + return { status: "unauthorized" }; + }); + global.fetch = vi + .fn() + .mockResolvedValue(response({ code: "UNAUTHORIZED" }, 401)); + const onAuthFailure = vi.fn(); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + onAuthFailure, + }); + + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 409, + code: "SESSION_SUPERSEDED", + }); + expect(onAuthFailure).not.toHaveBeenCalled(); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + }); + it("captures principal before transport and rejects foreign-user refresh", async () => { const fetchMock = vi.fn((url: string, init?: RequestInit) => { if (url.endsWith("/api/write")) { @@ -242,9 +410,7 @@ describe("createAuthClient", () => { global.fetch = fetchMock as typeof fetch; const store = createAuthStore({ baseUrl: "http://localhost:4000" }); store.setAccessToken("tok_a", 900); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + await store.resolveSession({ refresh: false }); const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); await expect(client.fetch("/api/write")).rejects.toMatchObject({ status: 401 }); diff --git a/__tests__/session-resolution.test.ts b/__tests__/session-resolution.test.ts index 0e79ccd..c9e1898 100644 --- a/__tests__/session-resolution.test.ts +++ b/__tests__/session-resolution.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAuthClient } from "../src/auth-client.js"; import { createAuthStore } from "../src/auth-store.js"; import type { AuthUser } from "../src/types.js"; @@ -116,6 +117,37 @@ describe("atomic session resolution (CEL-1782)", () => { ]); }); + it("blocks transport started synchronously by a resolving observer", async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + response(url.endsWith("/auth/me") ? userA : { data: "sent" }), + ), + ); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await flush(); + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + let attemptedWrite: Promise | null = null; + store.onSessionStateChange((state) => { + if (state.status === "resolving") { + attemptedWrite = client.fetch("/api/write"); + } + }); + + store.setAccessToken("tok_b", 900); + + expect(store.getUserId()).toBeNull(); + expect(attemptedWrite).not.toBeNull(); + await expect(attemptedWrite).rejects.toMatchObject({ + status: 503, + code: "SESSION_UNAVAILABLE", + }); + expect( + fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/api/write")), + ).toHaveLength(0); + }); + it("deduplicates timer and manual refresh into one refresh and identity flight", async () => { vi.useFakeTimers(); const refresh = deferred(); @@ -142,6 +174,70 @@ describe("atomic session resolution (CEL-1782)", () => { expect(identityCalls).toBe(2); }); + it("installs refresh flight before publishing resolving to reentrant observers", async () => { + const fetchMock = vi.fn((url: string) => + Promise.resolve( + url.includes("/auth/refresh") + ? response({ accessToken: "tok_b", expiresIn: 900 }) + : response(userA), + ), + ); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + + let nested: Promise | null = null; + const unsubscribe = store.onSessionStateChange((state) => { + if (state.status === "resolving" && state.token === "tok_a" && !nested) { + nested = store.resolveSession({ refresh: true }); + } + }); + const outer = store.resolveSession({ refresh: true }); + + await expect(outer).resolves.toMatchObject({ status: "ready", token: "tok_b" }); + await expect(nested).resolves.toMatchObject({ status: "ready", token: "tok_b" }); + unsubscribe(); + expect( + fetchMock.mock.calls.filter(([url]) => String(url).includes("/auth/refresh")), + ).toHaveLength(1); + }); + + it("joins non-refresh callers to refresh and suppresses prior identity commit", async () => { + const staleIdentity = deferred(); + const refresh = deferred(); + let identityCalls = 0; + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/auth/refresh")) return refresh.promise; + identityCalls += 1; + if (identityCalls === 2) return staleIdentity.promise; + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_b" ? { ...userA, roles: [] } : userA), + ); + }); + global.fetch = fetchMock as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + + const stale = store.resolveSession({ refresh: false }); + const refreshing = store.resolveSession({ refresh: true }); + const joined = store.resolveSession({ refresh: false }); + staleIdentity.resolve(response({ ...userA, roles: ["stale"] })); + await expect(stale).resolves.toEqual({ status: "superseded" }); + expect(store.getUserId()).toBeNull(); + + refresh.resolve(response({ accessToken: "tok_b", expiresIn: 900 })); + await expect(refreshing).resolves.toMatchObject({ + status: "ready", + token: "tok_b", + user: { roles: [] }, + }); + await expect(joined).resolves.toMatchObject({ status: "ready", token: "tok_b" }); + expect(identityCalls).toBe(3); + }); + it("logout during refresh cannot resurrect credentials", async () => { const refresh = deferred(); global.fetch = vi.fn((url: string) => @@ -204,7 +300,7 @@ describe("atomic session resolution (CEL-1782)", () => { const store = createAuthStore({ baseUrl: "http://localhost:4000" }); store.onAccessTokenSet((token) => tokenEvents.push(token)); store.setAccessToken("tok_a", 900); - await flush(); + await store.resolveSession({ refresh: false }); const result = await store.resolveSession({ refresh: false }); expect(result).toMatchObject({ status: "ready", token: "tok_b" }); @@ -229,7 +325,7 @@ describe("atomic session resolution (CEL-1782)", () => { global.fetch = fetchMock as typeof fetch; const store = createAuthStore({ baseUrl: "http://localhost:4000" }); store.setAccessToken("tok_a", 900); - await flush(); + await store.resolveSession({ refresh: false }); await expect(store.resolveSession({ refresh: false })).resolves.toEqual({ status: "unavailable", @@ -278,7 +374,7 @@ describe("atomic session resolution (CEL-1782)", () => { const orgChange = vi.fn(); store.onOrgChange(orgChange); store.setAccessToken("tok_a", 900); - await flush(); + await store.resolveSession({ refresh: false }); orgChange.mockClear(); mode = "unavailable"; @@ -339,4 +435,157 @@ describe("atomic session resolution (CEL-1782)", () => { expect(store.getAccessToken()).toBeNull(); expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/auth/refresh"))).toBe(false); }); + + it("returns superseded when unauthorized observer installs replacement", async () => { + const unauthorized = deferred(); + let identityCalls = 0; + global.fetch = vi.fn((_url: string, init?: RequestInit) => { + identityCalls += 1; + if (identityCalls === 2) return unauthorized.promise; + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_b" ? { ...userA, orgId: "org_b" } : userA), + ); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + const tokenEvents: Array = []; + const logout = vi.fn(); + store.onAccessTokenSet((token) => tokenEvents.push(token)); + store.onLogout(logout); + store.onSessionStateChange((state) => { + if (state.status === "unauthorized") store.setAccessToken("tok_b", 900); + }); + const staleUnauthorized = vi.fn(); + store.onSessionStateChange((state) => { + if (state.status === "unauthorized") staleUnauthorized(); + }); + + const stale = store.resolveSession({ refresh: false }); + unauthorized.resolve(response({ code: "UNAUTHORIZED" }, 401)); + + await expect(stale).resolves.toEqual({ status: "superseded" }); + await store.resolveSession({ refresh: false }); + expect(store.getAccessToken()).toBe("tok_b"); + expect(store.getOrgId()).toBe("org_b"); + expect(tokenEvents).not.toContain(null); + expect(logout).not.toHaveBeenCalled(); + expect(staleUnauthorized).not.toHaveBeenCalled(); + }); + + it("suppresses stale logout when clear observer installs replacement", async () => { + global.fetch = vi.fn((_url: string, init?: RequestInit) => { + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_b" ? { ...userA, orgId: "org_b" } : userA), + ); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + const tokenEvents: Array = []; + const logout = vi.fn(); + store.onAccessTokenSet((token) => tokenEvents.push(token)); + store.onLogout(logout); + store.onSessionStateChange((state) => { + if (state.status === "unauthorized") store.setAccessToken("tok_b", 900); + }); + + store.clearAccessToken(); + await store.resolveSession({ refresh: false }); + + expect(store.getAccessToken()).toBe("tok_b"); + expect(tokenEvents).not.toContain(null); + expect(logout).not.toHaveBeenCalled(); + }); + + it("does not publish legacy token-set event for unavailable authority", async () => { + let unavailable = false; + global.fetch = vi.fn(() => + Promise.resolve( + unavailable + ? response({ code: "AUTHORITY_UNAVAILABLE" }, 503) + : response(userA), + ), + ) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + const tokenSet = vi.fn(); + store.onAccessTokenSet(tokenSet); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + tokenSet.mockClear(); + + unavailable = true; + await expect(store.resolveSession({ refresh: false })).resolves.toEqual({ + status: "unavailable", + token: "tok_a", + }); + expect(tokenSet).not.toHaveBeenCalled(); + }); + + it("times out hung identity flight and permits retry", async () => { + vi.useFakeTimers(); + let calls = 0; + global.fetch = vi.fn(() => { + calls += 1; + return calls === 1 + ? Promise.resolve({ + ok: true, + status: 200, + json: () => new Promise(() => {}), + } as Response) + : Promise.resolve(response(userA)); + }) as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + resolutionTimeoutMs: 50, + }); + store.setAccessToken("tok_a", 900); + const timedOut = store.resolveSession({ refresh: false }); + + await vi.advanceTimersByTimeAsync(50); + await expect(timedOut).resolves.toEqual({ + status: "unavailable", + token: "tok_a", + }); + await expect(store.resolveSession({ refresh: false })).resolves.toMatchObject({ + status: "ready", + }); + expect(calls).toBe(2); + }); + + it("times out hung refresh flight and permits retry", async () => { + vi.useFakeTimers(); + let refreshCalls = 0; + global.fetch = vi.fn((url: string) => { + if (!url.includes("/auth/refresh")) return Promise.resolve(response(userA)); + refreshCalls += 1; + return refreshCalls === 1 + ? Promise.resolve({ + ok: true, + status: 200, + json: () => new Promise(() => {}), + } as Response) + : Promise.resolve(response({ accessToken: "tok_b", expiresIn: 900 })); + }) as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + resolutionTimeoutMs: 50, + }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + const timedOut = store.resolveSession({ refresh: true }); + + await vi.advanceTimersByTimeAsync(50); + await expect(timedOut).resolves.toEqual({ + status: "unavailable", + token: "tok_a", + }); + await expect(store.resolveSession({ refresh: true })).resolves.toMatchObject({ + status: "ready", + token: "tok_b", + }); + expect(refreshCalls).toBe(2); + }); }); diff --git a/src/auth-api.ts b/src/auth-api.ts index e48b167..b95f10d 100644 --- a/src/auth-api.ts +++ b/src/auth-api.ts @@ -3,10 +3,10 @@ import type { AuthApi, AuthClient, AuthStore, - AuthUser, RegisterInput, RequestOtpResponse, VerifyOtpResponse, + VerifyOtpUser, } from "./types.js"; import { extractAccessToken } from "./extract-token.js"; import { parseAuthUser } from "./auth-user.js"; @@ -60,7 +60,7 @@ export function createAuthApi(config: { return { accessToken: token, expiresIn, - user: raw.user as AuthUser, + user: raw.user as VerifyOtpUser, }; }, @@ -77,8 +77,9 @@ export function createAuthApi(config: { async getMe(token?: string) { const currentToken = store.getAccessToken(); - if (store.resolveSession && (!token || token === currentToken)) { - const resolution = await store.resolveSession({ refresh: false }); + const explicitCurrentToken = token !== undefined && token === currentToken; + if (store.resolveSession && token === undefined) { + const resolution = await store.resolveSession(); if (resolution.status === "ready") return resolution.user; if (resolution.status === "unauthorized") { throw new AuthError(401, "UNAUTHORIZED", "Session is unauthorized"); @@ -93,7 +94,7 @@ export function createAuthApi(config: { ); } - const raw = token + const raw = token !== undefined ? await client.fetch("/auth/me", { method: "GET", skipAuth: true, @@ -106,6 +107,9 @@ export function createAuthApi(config: { ? { headers: { Authorization: `Bearer ${currentToken}` } } : {}), }); + if (explicitCurrentToken && store.getAccessToken() !== token) { + throw new AuthError(409, "SESSION_SUPERSEDED", "Session was superseded"); + } const user = parseAuthUser(raw); if (!user) { throw new AuthError( diff --git a/src/auth-client.ts b/src/auth-client.ts index 0ae24e2..8ba44b6 100644 --- a/src/auth-client.ts +++ b/src/auth-client.ts @@ -3,6 +3,7 @@ import type { AuthClientConfig, AuthClient, AuthErrorResponse } from "./types.js import { canReplaySession, captureSessionContinuity, + resolveSessionForReplay, } from "./session-continuity.js"; export function createAuthClient(config: AuthClientConfig): AuthClient { @@ -56,6 +57,24 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const url = `${baseUrl}${path}`; + const retryWithToken = async (token: string): Promise => { + headers.set("Authorization", `Bearer ${token}`); + const retryRes = await fetch(url, { + ...init, + headers, + credentials: "include", + }); + if (retryRes.ok) return (await retryRes.json()) as T; + + const retryError = await parseErrorResponse(retryRes); + throw new AuthError( + retryRes.status, + retryError.code, + retryError.error, + retryError.remainingAttempts, + ); + }; + const res = await fetch(url, { ...init, headers, @@ -69,30 +88,32 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // 401: refresh once. Replay only when validated user + tenant continuity // matches the authority captured before original transport. if (res.status === 401 && !skipAuth) { - if (store.resolveSession) { - const resolution = await store.resolveSession({ - refresh: true, + if (!store.resolveSession) { + const current = captureSessionContinuity(store); + if ( + !continuity || + !current || + current.token !== continuity.token || + current.userId !== continuity.userId || + current.orgId !== continuity.orgId + ) { + throw new AuthError( + 409, + "SESSION_SUPERSEDED", + "Request session was superseded", + ); + } + store.clearAccessToken(); + if (store.getAccessToken() === null) onAuthFailure?.(); + } else { + const resolution = await resolveSessionForReplay(store, continuity, { signal: init.signal ?? undefined, }); if ( resolution.status === "ready" && canReplaySession(continuity, resolution, store) ) { - headers.set("Authorization", `Bearer ${resolution.token}`); - const retryRes = await fetch(url, { - ...init, - headers, - credentials: "include", - }); - if (retryRes.ok) return (await retryRes.json()) as T; - - const retryError = await parseErrorResponse(retryRes); - throw new AuthError( - retryRes.status, - retryError.code, - retryError.error, - retryError.remainingAttempts, - ); + return retryWithToken(resolution.token); } if (resolution.status === "unavailable") { throw new AuthError( @@ -112,6 +133,18 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { ); } if (resolution.status === "ready") { + const currentState = store.getSessionState?.(); + if ( + (currentState?.status === "resolving" || + currentState?.status === "unavailable") && + currentState.token === resolution.token + ) { + throw new AuthError( + 503, + "SESSION_UNAVAILABLE", + "Session identity is unavailable", + ); + } throw new AuthError( 409, "SESSION_CONTINUITY_CHANGED", @@ -119,10 +152,6 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { ); } onAuthFailure?.(); - } else { - // Legacy custom stores cannot prove post-refresh continuity. - store.clearAccessToken(); - onAuthFailure?.(); } } diff --git a/src/auth-store.ts b/src/auth-store.ts index 9a72b77..d3a4496 100644 --- a/src/auth-store.ts +++ b/src/auth-store.ts @@ -16,6 +16,7 @@ import type { } from "./types.js"; const DEFAULT_ACCESS_TOKEN_TTL = 900; +const DEFAULT_RESOLUTION_TIMEOUT_MS = 10_000; const DEV_LOGIN_MESSAGES = { "test-endpoints-disabled": @@ -61,7 +62,16 @@ function copyResolution(resolution: SessionResolution): SessionResolution { } export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { - const { baseUrl, refreshPath = "/auth/refresh", refreshBuffer = 60 } = config; + const { + baseUrl, + refreshPath = "/auth/refresh", + refreshBuffer = 60, + resolutionTimeoutMs = DEFAULT_RESOLUTION_TIMEOUT_MS, + } = config; + const requestTimeoutMs = + Number.isFinite(resolutionTimeoutMs) && resolutionTimeoutMs > 0 + ? resolutionTimeoutMs + : DEFAULT_RESOLUTION_TIMEOUT_MS; let accessToken: string | null = null; let identity: AuthUser | null = null; @@ -88,23 +98,31 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { const stateQueue: SessionState[] = []; let publishingState = false; - function emitOrgChange(orgId: string | null): void { + function emitOrgChange( + orgId: string | null, + shouldContinue: () => boolean = () => true, + ): void { for (const listener of orgChangeListeners) { try { listener(orgId); } catch { // Subscriber failures cannot break other observers or session adoption. } + if (!shouldContinue()) break; } } - function emitAccessTokenSet(token: string | null): void { + function emitAccessTokenSet( + token: string | null, + shouldContinue: () => boolean = () => true, + ): void { for (const listener of accessTokenSetListeners) { try { listener(token); } catch { // Subscriber failures cannot break other observers or session adoption. } + if (!shouldContinue()) break; } } @@ -128,11 +146,13 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { if (!published) continue; sessionState = published; for (const listener of sessionStateListeners) { + const queuedBeforeCallback = stateQueue.length; try { listener(copySessionState(published)); } catch { // Observers cannot veto or interrupt global session resolution. } + if (stateQueue.length > queuedBeforeCallback) break; } } } finally { @@ -159,10 +179,51 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { }, delay); } + function withResolutionTimeout( + operation: (signal: AbortSignal) => Promise, + ): Promise { + const controller = new AbortController(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + controller.abort(); + reject(new Error("Session resolution request timed out")); + }, requestTimeoutMs); + let pending: Promise; + try { + pending = operation(controller.signal); + } catch (error) { + clearTimeout(timer); + reject(error); + return; + } + void pending.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); + } + + function fetchResolutionResponse( + url: string, + init: RequestInit, + ): Promise<{ response: Response; raw: unknown }> { + return withResolutionTimeout(async (signal) => { + const response = await fetch(url, { ...init, signal }); + const raw = response.ok ? await response.json() : null; + return { response, raw }; + }); + } + async function fetchIdentity(token: string): Promise { - let response: Response; + let result: { response: Response; raw: unknown }; try { - response = await fetch(`${baseUrl}/auth/me`, { + result = await fetchResolutionResponse(`${baseUrl}/auth/me`, { method: "GET", credentials: "include", headers: { Authorization: `Bearer ${token}` }, @@ -171,18 +232,12 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { return { status: "unavailable" }; } + const { response, raw } = result; if (response.status === 401 || response.status === 403) { return { status: "unauthorized" }; } if (!response.ok) return { status: "unavailable" }; - let raw: unknown; - try { - raw = await response.json(); - } catch { - return { status: "unavailable" }; - } - const user = parseAuthUser(raw); return user ? { status: "ready", user } @@ -197,6 +252,8 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { identity = null; continuityBaseline = null; pendingRefreshBaseline = null; + identityFlight = null; + refreshFlight = null; previousOrgId = null; hasEmittedOrgId = false; if (refreshTimer) { @@ -204,10 +261,12 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { refreshTimer = null; } publishSessionState({ status: "unauthorized" }); - if (tokenGeneration === clearedGeneration && accessToken === null) { - emitAccessTokenSet(null); - } - return true; + if (tokenGeneration !== clearedGeneration || accessToken !== null) return false; + emitAccessTokenSet( + null, + () => tokenGeneration === clearedGeneration && accessToken === null, + ); + return tokenGeneration === clearedGeneration && accessToken === null; } function markUnavailable( @@ -222,16 +281,6 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { if (generation !== tokenGeneration || accessToken !== token) { return { status: "superseded" }; } - if ( - token && - generation === tokenGeneration && - accessToken === token - ) { - emitAccessTokenSet(token); - } - if (generation !== tokenGeneration || accessToken !== token) { - return { status: "superseded" }; - } return { status: "unavailable", token }; } @@ -253,14 +302,20 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { if (generation !== tokenGeneration || accessToken !== token) { return { status: "superseded" }; } - emitAccessTokenSet(token); + emitAccessTokenSet( + token, + () => generation === tokenGeneration && accessToken === token, + ); if (generation !== tokenGeneration || accessToken !== token) { return { status: "superseded" }; } if (!hasEmittedOrgId || nextOrgId !== previousOrgId) { hasEmittedOrgId = true; previousOrgId = nextOrgId; - emitOrgChange(nextOrgId); + emitOrgChange( + nextOrgId, + () => generation === tokenGeneration && accessToken === token, + ); } if (generation !== tokenGeneration || accessToken !== token) { @@ -270,55 +325,81 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { return { status: "ready", token, user: copyAuthUser(identity) }; } - function resolveIdentity( + async function evaluateIdentity( generation: number, token: string, baseline: ReadyBaseline | null, refreshed: boolean, ): Promise { - if (identityFlight?.generation === generation) { - return identityFlight.promise; + const read = await fetchIdentity(token); + if (generation !== tokenGeneration || accessToken !== token) { + return { status: "superseded" }; } - let flight!: IdentityFlight; - const promise = (async (): Promise => { - const read = await fetchIdentity(token); - if (generation !== tokenGeneration || accessToken !== token) { - return { status: "superseded" }; - } + if (read.status === "unauthorized") { + return clearCurrentGeneration(generation) + ? { status: "unauthorized" } + : { status: "superseded" }; + } + if (read.status === "unavailable") { + return markUnavailable(generation, token); + } + + if (baseline && read.user.id !== baseline.user.id) { + if (!refreshed) return runRefresh(generation, baseline); + return clearCurrentGeneration(generation) + ? { status: "unauthorized" } + : { status: "superseded" }; + } - if (read.status === "unauthorized") { - clearCurrentGeneration(generation); - return { status: "unauthorized" }; + if (baseline && read.user.orgId !== baseline.user.orgId) { + if (!refreshed) { + // Same-token raw membership changes are not authority. Rotate once, + // then validate fresh credentials before adopting the transition. + return runRefresh(generation, baseline); } - if (read.status === "unavailable") { + if (token === baseline.token) { return markUnavailable(generation, token); } + } - if (baseline && read.user.id !== baseline.user.id) { - if (!refreshed) return runRefresh(generation, baseline); - clearCurrentGeneration(generation); - return { status: "unauthorized" }; - } + return commitReady(generation, token, read.user); + } - if (baseline && read.user.orgId !== baseline.user.orgId) { - if (!refreshed) { - // Same-token raw membership changes are not authority. Rotate once, - // then validate fresh credentials before adopting the transition. - return runRefresh(generation, baseline); - } - if (token === baseline.token) { - return markUnavailable(generation, token); - } - } + function startIdentityFlight( + generation: number, + token: string, + baseline: ReadyBaseline | null, + refreshed: boolean, + notifyResolving: boolean, + ): Promise { + if (identityFlight?.generation === generation) { + return identityFlight.promise; + } - return commitReady(generation, token, read.user); - })().finally(() => { - if (identityFlight === flight) identityFlight = null; + let settle!: (resolution: SessionResolution) => void; + const promise = new Promise((resolve) => { + settle = resolve; }); - - flight = { generation, promise }; + const flight: IdentityFlight = { generation, promise }; identityFlight = flight; + + if (notifyResolving && !beginResolving(generation, token)) { + if (identityFlight === flight) identityFlight = null; + settle({ status: "superseded" }); + return promise; + } + + void evaluateIdentity(generation, token, baseline, refreshed).then( + (result) => { + if (identityFlight === flight) identityFlight = null; + settle(result); + }, + () => { + if (identityFlight === flight) identityFlight = null; + settle(markUnavailable(generation, token)); + }, + ); return promise; } @@ -333,51 +414,79 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { generation: number, baseline: ReadyBaseline | null, ): Promise { - if (refreshFlight?.generation === generation) { - return refreshFlight.promise; - } + if (refreshFlight) return refreshFlight.promise; if (generation !== tokenGeneration) { return Promise.resolve({ status: "superseded" }); } + if (!baseline && identityFlight?.generation === generation) { + const pendingIdentity = identityFlight.promise; + return pendingIdentity.then((result) => { + if (result.status === "ready") { + return runRefresh(tokenGeneration, { + token: result.token, + user: copyAuthUser(result.user), + }); + } + if ( + result.status === "unavailable" && + generation === tokenGeneration && + accessToken !== null + ) { + return runRefresh(tokenGeneration, null); + } + return result; + }); + } + + let settle!: (resolution: SessionResolution) => void; + const promise = new Promise((resolve) => { + settle = resolve; + }); + const flight: RefreshFlight = { generation, promise }; + refreshFlight = flight; + if (!beginResolving(generation, accessToken)) { - return Promise.resolve({ status: "superseded" }); + if (refreshFlight === flight) refreshFlight = null; + settle({ status: "superseded" }); + return promise; } - let flight!: RefreshFlight; - const promise = (async (): Promise => { - let response: Response; + // Refresh owns a new operation generation before transport begins. Any + // older identity read can no longer publish authority while refresh waits. + tokenGeneration += 1; + const refreshGeneration = tokenGeneration; + flight.generation = refreshGeneration; + + void (async (): Promise => { + let result: { response: Response; raw: unknown }; try { - response = await fetch(`${baseUrl}${refreshPath}`, { + result = await fetchResolutionResponse(`${baseUrl}${refreshPath}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, }); } catch { - return markUnavailable(generation, accessToken); + return markUnavailable(refreshGeneration, accessToken); } - if (generation !== tokenGeneration) return { status: "superseded" }; + const { response, raw } = result; + if (refreshGeneration !== tokenGeneration) return { status: "superseded" }; if (response.status === 401 || response.status === 403) { - clearCurrentGeneration(generation); - return { status: "unauthorized" }; + return clearCurrentGeneration(refreshGeneration) + ? { status: "unauthorized" } + : { status: "superseded" }; } - if (!response.ok) return markUnavailable(generation, accessToken); + if (!response.ok) return markUnavailable(refreshGeneration, accessToken); - let raw: unknown; - try { - raw = await response.json(); - } catch { - return markUnavailable(generation, accessToken); - } - if (generation !== tokenGeneration) return { status: "superseded" }; + if (refreshGeneration !== tokenGeneration) return { status: "superseded" }; if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return markUnavailable(generation, accessToken); + return markUnavailable(refreshGeneration, accessToken); } const json = raw as Record; const nextToken = extractAccessToken(json); - if (!nextToken) return markUnavailable(generation, accessToken); + if (!nextToken) return markUnavailable(refreshGeneration, accessToken); const expiresIn = typeof json.expiresIn === "number" && Number.isFinite(json.expiresIn) @@ -396,13 +505,23 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { : null; scheduleRefresh(expiresIn); - return resolveIdentity(nextGeneration, nextToken, baseline, true); - })().finally(() => { - if (refreshFlight === flight) refreshFlight = null; - }); - - flight = { generation, promise }; - refreshFlight = flight; + return startIdentityFlight( + nextGeneration, + nextToken, + baseline, + true, + false, + ); + })().then( + (result) => { + if (refreshFlight === flight) refreshFlight = null; + settle(result); + }, + () => { + if (refreshFlight === flight) refreshFlight = null; + settle(markUnavailable(refreshGeneration, accessToken)); + }, + ); return promise; } @@ -416,10 +535,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { const baseline = currentBaseline(); const refreshed = pendingRefreshBaseline !== null; - if (!beginResolving(generation, token)) { - return Promise.resolve({ status: "superseded" }); - } - return resolveIdentity(generation, token, baseline, refreshed); + return startIdentityFlight(generation, token, baseline, refreshed, true); } function waitForCaller( @@ -444,12 +560,13 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { if (!beginResolving(previousGeneration, token)) return; tokenGeneration += 1; const generation = tokenGeneration; + refreshFlight = null; accessToken = token; identity = null; continuityBaseline = null; pendingRefreshBaseline = null; scheduleRefresh(expiresIn); - void resolveIdentity(generation, token, null, false); + void startIdentityFlight(generation, token, null, false, false); } let store!: ConcreteAuthStore; @@ -462,12 +579,18 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { }, clearAccessToken() { - clearCurrentGeneration(tokenGeneration); - emitLogout(); + if (clearCurrentGeneration(tokenGeneration)) emitLogout(); }, async ensureAccessToken(forceRefresh = false) { - if (!forceRefresh && accessToken && identity && !identityFlight) { + if ( + !forceRefresh && + accessToken && + identity && + !identityFlight && + !refreshFlight && + sessionState.status === "ready" + ) { return accessToken; } const result = forceRefresh @@ -482,9 +605,11 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { const shouldRefresh = options.refresh === true || (options.refresh === undefined && accessToken === null); - const operation = shouldRefresh - ? runRefresh(tokenGeneration, currentBaseline()) - : resolveCurrentSession(); + const operation = refreshFlight + ? refreshFlight.promise + : shouldRefresh + ? runRefresh(tokenGeneration, currentBaseline()) + : resolveCurrentSession(); return waitForCaller(operation, options.signal); }, @@ -581,6 +706,8 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { getEntitlements: () => identity?.entitlements ? [...identity.entitlements] : [], + getSessionState: () => copySessionState(sessionState), + onSessionStateChange(listener) { sessionStateListeners.add(listener); try { diff --git a/src/index.ts b/src/index.ts index 20dc09f..db35388 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { extractAccessToken } from "./extract-token.js"; export { captureSessionContinuity, canReplaySession, + resolveSessionForReplay, } from "./session-continuity.js"; export { @@ -20,6 +21,7 @@ export { type RegisterInput, type RequestOtpResponse, type VerifyOtpResponse, + type VerifyOtpUser, type AuthErrorResponse, type DevLoginResult, type DevLoginSuccess, diff --git a/src/session-continuity.ts b/src/session-continuity.ts index af236a9..067f5fa 100644 --- a/src/session-continuity.ts +++ b/src/session-continuity.ts @@ -1,5 +1,6 @@ import type { AuthStore, + ResolveSessionOptions, SessionContinuity, SessionResolution, } from "./types.js"; @@ -14,10 +15,22 @@ export function captureSessionContinuity( ) { return null; } + const state = store.getSessionState?.(); + if (state && state.status !== "ready") return null; const token = store.getAccessToken(); const userId = store.getUserId(); - if (!token || !userId) return null; - return { token, userId, orgId: store.getOrgId() }; + const orgId = store.getOrgId(); + if ( + !token || + !userId || + (state?.status === "ready" && + (state.token !== token || + state.user.id !== userId || + state.user.orgId !== orgId)) + ) { + return null; + } + return { token, userId, orgId }; } /** Permit 401 replay only across same validated user and organisation. */ @@ -26,13 +39,97 @@ export function canReplaySession( after: SessionResolution, store: AuthStore, ): boolean { + const current = captureSessionContinuity(store); return ( before !== null && after.status === "ready" && - store.getAccessToken() === after.token && - store.getUserId() === after.user.id && - store.getOrgId() === after.user.orgId && + current !== null && + current.token === after.token && + current.userId === after.user.id && + current.orgId === after.user.orgId && after.user.id === before.userId && after.user.orgId === before.orgId ); } + +/** + * Resolve authority for a 401 retry without refreshing a replacement session. + * A ready replacement credential may be reused only for the captured user and + * organisation. Call `canReplaySession` again immediately before transport. + */ +export async function resolveSessionForReplay( + store: AuthStore, + before: SessionContinuity | null, + options: Pick = {}, +): Promise { + if (!before) return { status: "superseded" }; + + const currentToken = store.getAccessToken(); + const currentState = store.getSessionState?.(); + const current = captureSessionContinuity(store); + + if (currentToken !== before.token) { + if ( + current && + currentState?.status === "ready" && + current.token === currentState.token && + current.userId === before.userId && + current.orgId === before.orgId + ) { + return { + status: "ready", + token: currentState.token, + user: currentState.user, + }; + } + return { status: "superseded" }; + } + + if ( + currentState?.status === "unauthorized" || + ((currentState?.status === "resolving" || + currentState?.status === "unavailable") && + currentState.token !== before.token) || + (currentState?.status === "ready" && + (!current || + current.userId !== before.userId || + current.orgId !== before.orgId)) + ) { + return { status: "superseded" }; + } + + if (!store.resolveSession) return { status: "superseded" }; + const resolution = await store.resolveSession({ + refresh: true, + signal: options.signal, + }); + if (resolution.status === "superseded") return resolution; + if (resolution.status === "unauthorized") { + const unauthorizedState = store.getSessionState?.(); + return store.getAccessToken() === null && + (unauthorizedState === undefined || + unauthorizedState.status === "unauthorized") + ? resolution + : { status: "superseded" }; + } + if (resolution.status === "unavailable") { + const unavailableState = store.getSessionState?.(); + return store.getAccessToken() === resolution.token && + (unavailableState === undefined || + (unavailableState.status === "unavailable" && + unavailableState.token === resolution.token)) + ? resolution + : { status: "superseded" }; + } + if (canReplaySession(before, resolution, store)) return resolution; + + const afterState = store.getSessionState?.(); + if ( + (afterState?.status === "resolving" || + afterState?.status === "unavailable") && + afterState.token === resolution.token + ) { + return { status: "unavailable", token: resolution.token }; + } + return { status: "superseded" }; +} diff --git a/src/types.ts b/src/types.ts index 8b5466f..5eb547d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,10 +28,21 @@ export interface RequestOtpResponse { resendAvailableAt: string; } +/** Sparse user projection returned inline by `/auth/verify-otp`. */ +export interface VerifyOtpUser { + id: string; + email: string; + name: string; + phone: string; + userType: SessionUserType | null; + orgId: string | null; + roles: string[]; +} + export interface VerifyOtpResponse { accessToken: string; expiresIn: number; - user: AuthUser; + user: VerifyOtpUser; } export interface AuthErrorResponse { @@ -56,6 +67,8 @@ export interface AuthStoreConfig { baseUrl: string; refreshPath?: string; refreshBuffer?: number; + /** Maximum duration for internal refresh and identity requests. Default 10000ms. */ + resolutionTimeoutMs?: number; } /** @@ -179,6 +192,9 @@ export interface AuthStore { /** Subscribe to guarded session-resolution state. Immediately receives a snapshot. */ onSessionStateChange?(listener: SessionStateListener): () => void; + /** Return current guarded session state as a defensive snapshot. */ + getSessionState?(): SessionState; + /** * LOCAL-DEV ONLY — mint a session for `email` via the backend's * `POST /test/login`, bypassing the OTP round-trip (CEL-1364). @@ -251,6 +267,7 @@ export interface AuthStore { export interface ConcreteAuthStore extends AuthStore { resolveSession(options?: ResolveSessionOptions): Promise; onSessionStateChange(listener: SessionStateListener): () => void; + getSessionState(): SessionState; } export interface AuthClientConfig { From 6fd611589d8005ca3351fb3c2ad39e03f0115913 Mon Sep 17 00:00:00 2001 From: mjnong Date: Mon, 7 Sep 2026 18:23:45 +0200 Subject: [PATCH 4/9] docs: record session replay authority decision --- ...stale-401-replay-authority-resolution.json | 30 +++++++++++++++++++ .reposkein/summaries/12.jsonl | 1 + .reposkein/summaries/db.jsonl | 1 + .reposkein/summaries/fd.jsonl | 1 + 4 files changed, 33 insertions(+) create mode 100644 .reposkein/decisions/2026-09-07-centralize-stale-401-replay-authority-resolution.json create mode 100644 .reposkein/summaries/12.jsonl create mode 100644 .reposkein/summaries/db.jsonl create mode 100644 .reposkein/summaries/fd.jsonl diff --git a/.reposkein/decisions/2026-09-07-centralize-stale-401-replay-authority-resolution.json b/.reposkein/decisions/2026-09-07-centralize-stale-401-replay-authority-resolution.json new file mode 100644 index 0000000..2f43d17 --- /dev/null +++ b/.reposkein/decisions/2026-09-07-centralize-stale-401-replay-authority-resolution.json @@ -0,0 +1,30 @@ +{ + "id": "adr:2026-09-07-centralize-stale-401-replay-authority-resolution", + "alternatives": "Let each transport adapter refresh and compare tokens independently; replay whenever any replacement access token is available.", + "anchors": [ + { + "hash": "00fdd4c39886ddda1b2894ea7840e1318f09ee3b871d0319a66eff18bce83e8d", + "kind": "Function", + "name": "resolveSessionForReplay", + "node_id": "rs1:7dc1dbe8d512:func:src/session-continuity.ts#resolveSessionForReplay@3", + "path": "src/session-continuity.ts" + } + ], + "body_hash": "v2:4525b2559bef1708547e1890c80eb0981a739792035c7f172f200f8ea806611e", + "consequences": "Adapters share one authority-preserving concurrency contract. Unavailable identity authority blocks replay while retaining the credential. Same-user token rotation remains replayable after validation.", + "context": "Multiple transport adapters handle 401 responses while session credentials can rotate concurrently. Duplicating refresh and replay checks in each adapter permits a stale request to refresh, clear, or replay under a replacement user's session.", + "decided_at": "2026-09-07", + "decided_by": "agent", + "decision": "Route 401 recovery through resolveSessionForReplay. Capture validated user and organisation authority before transport, resolve refresh centrally, and require a final synchronous canReplaySession check immediately before retry transport.", + "paths": [ + "src/session-continuity.ts", + "src/auth-store.ts", + "src/auth-client.ts" + ], + "status": "proposed", + "supersedes": [], + "title": "Centralize stale-401 replay authority resolution", + "trigger": { + "kind": "manual" + } +} diff --git a/.reposkein/summaries/12.jsonl b/.reposkein/summaries/12.jsonl new file mode 100644 index 0000000..5f6079a --- /dev/null +++ b/.reposkein/summaries/12.jsonl @@ -0,0 +1 @@ +{"id":"rs1:7dc1dbe8d512:func:src/session-continuity.ts#captureSessionContinuity@1","semantic_summary":"Captures the current access token with validated user and organisation authority when the store exposes matching ready session state. It returns null for incomplete, unresolved, or internally inconsistent identity state.","summary_at":"2026-09-07","summary_by":"agent","summary_model":"unknown","summary_of_hash":"a06178af15361892c0736394acc439f49b9bcd214eceace9269ab292e23222a1"} diff --git a/.reposkein/summaries/db.jsonl b/.reposkein/summaries/db.jsonl new file mode 100644 index 0000000..cb1c071 --- /dev/null +++ b/.reposkein/summaries/db.jsonl @@ -0,0 +1 @@ +{"id":"rs1:7dc1dbe8d512:func:src/session-continuity.ts#canReplaySession@3","semantic_summary":"Performs the final replay guard by comparing the pre-request authority, resolver result, and current store state. Replay is allowed only when token, user, and organisation remain internally consistent and the authority identity has not changed.","summary_at":"2026-09-07","summary_by":"agent","summary_model":"unknown","summary_of_hash":"0c772c4299effffc0cc301d19333e0e88d47fc86d4c8922a43c473c44722dd62"} diff --git a/.reposkein/summaries/fd.jsonl b/.reposkein/summaries/fd.jsonl new file mode 100644 index 0000000..fd49fd7 --- /dev/null +++ b/.reposkein/summaries/fd.jsonl @@ -0,0 +1 @@ +{"id":"rs1:7dc1dbe8d512:func:src/session-continuity.ts#resolveSessionForReplay@3","semantic_summary":"Resolves authority after a 401 while preventing a stale request from refreshing, clearing, or replaying under a replacement session. It permits replay only when the current validated user and organisation match the authority captured before transport, and converts concurrent or ambiguous outcomes to superseded or unavailable.","summary_at":"2026-09-07","summary_by":"agent","summary_model":"unknown","summary_of_hash":"00fdd4c39886ddda1b2894ea7840e1318f09ee3b871d0319a66eff18bce83e8d"} From 78ed72e6c8725a32a674ef44336639baa3dbbcea Mon Sep 17 00:00:00 2001 From: mjnong Date: Mon, 7 Sep 2026 18:30:49 +0200 Subject: [PATCH 5/9] fix: publish refreshed session authority state --- __tests__/session-resolution.test.ts | 59 ++++++++++++++++++++++++++++ src/auth-store.ts | 20 +++++++++- src/types.ts | 2 +- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/__tests__/session-resolution.test.ts b/__tests__/session-resolution.test.ts index c9e1898..f262088 100644 --- a/__tests__/session-resolution.test.ts +++ b/__tests__/session-resolution.test.ts @@ -174,6 +174,43 @@ describe("atomic session resolution (CEL-1782)", () => { expect(identityCalls).toBe(2); }); + it("publishes the adopted token while refreshed identity is resolving", async () => { + const refreshedIdentity = deferred(); + global.fetch = vi.fn((url: string, init?: RequestInit) => { + if (url.includes("/auth/refresh")) { + return Promise.resolve(response({ accessToken: "tok_b", expiresIn: 900 })); + } + const token = new Headers(init?.headers).get("Authorization"); + return token === "Bearer tok_b" + ? refreshedIdentity.promise + : Promise.resolve(response(userA)); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + + const resolvingTokens: Array = []; + store.onSessionStateChange((state) => { + if (state.status === "resolving") resolvingTokens.push(state.token); + }); + + const refreshing = store.resolveSession({ refresh: true }); + await flush(); + + expect(resolvingTokens).toEqual(["tok_a", "tok_b"]); + expect(store.getAccessToken()).toBe("tok_b"); + expect(store.getSessionState()).toEqual({ + status: "resolving", + token: "tok_b", + }); + + refreshedIdentity.resolve(response(userA)); + await expect(refreshing).resolves.toMatchObject({ + status: "ready", + token: "tok_b", + }); + }); + it("installs refresh flight before publishing resolving to reentrant observers", async () => { const fetchMock = vi.fn((url: string) => Promise.resolve( @@ -500,6 +537,28 @@ describe("atomic session resolution (CEL-1782)", () => { expect(logout).not.toHaveBeenCalled(); }); + it("suppresses remaining logout listeners after replacement login", async () => { + global.fetch = vi.fn((_url: string, init?: RequestInit) => { + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_b" ? { ...userA, orgId: "org_b" } : userA), + ); + }) as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_a", 900); + await store.resolveSession({ refresh: false }); + const staleLogout = vi.fn(); + store.onLogout(() => store.setAccessToken("tok_b", 900)); + store.onLogout(staleLogout); + + store.clearAccessToken(); + await store.resolveSession({ refresh: false }); + + expect(store.getAccessToken()).toBe("tok_b"); + expect(store.getOrgId()).toBe("org_b"); + expect(staleLogout).not.toHaveBeenCalled(); + }); + it("does not publish legacy token-set event for unavailable authority", async () => { let unavailable = false; global.fetch = vi.fn(() => diff --git a/src/auth-store.ts b/src/auth-store.ts index d3a4496..4240110 100644 --- a/src/auth-store.ts +++ b/src/auth-store.ts @@ -126,13 +126,16 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { } } - function emitLogout(): void { + function emitLogout( + shouldContinue: () => boolean = () => true, + ): void { for (const listener of logoutListeners) { try { listener(); } catch { // Subscriber failures cannot break other observers or logout. } + if (!shouldContinue()) break; } } @@ -503,6 +506,13 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { pendingRefreshBaseline = baseline ? { token: baseline.token, user: copyAuthUser(baseline.user) } : null; + publishSessionState({ status: "resolving", token: nextToken }); + if ( + nextGeneration !== tokenGeneration || + accessToken !== nextToken + ) { + return { status: "superseded" }; + } scheduleRefresh(expiresIn); return startIdentityFlight( @@ -579,7 +589,13 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { }, clearAccessToken() { - if (clearCurrentGeneration(tokenGeneration)) emitLogout(); + if (clearCurrentGeneration(tokenGeneration)) { + const clearedGeneration = tokenGeneration; + emitLogout( + () => + tokenGeneration === clearedGeneration && accessToken === null, + ); + } }, async ensureAccessToken(forceRefresh = false) { diff --git a/src/types.ts b/src/types.ts index 5eb547d..9bb4d15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -33,7 +33,7 @@ export interface VerifyOtpUser { id: string; email: string; name: string; - phone: string; + phone?: string; userType: SessionUserType | null; orgId: string | null; roles: string[]; From 4329331036460c119705f104c1836f618bff8487 Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 07:50:22 +0200 Subject: [PATCH 6/9] fix: require secure auth transport --- AGENTS.md | 7 +- CHANGELOG.md | 15 +++ README.md | 7 ++ __tests__/auth-transport.test.ts | 210 +++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- src/auth-client.ts | 7 +- src/auth-store.ts | 14 ++- src/auth-transport.ts | 54 ++++++++ 9 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 __tests__/auth-transport.test.ts create mode 100644 src/auth-transport.ts diff --git a/AGENTS.md b/AGENTS.md index 2e83d2f..cb84447 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,13 @@ The dev-bypass internals (`DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`, ### Core API -- `createAuthStore({ baseUrl })` — holds the access token in a **module-closure variable, not `localStorage`**; durability across reloads comes from the backend's HttpOnly refresh cookie, which `performRefresh()` sends with `credentials: "include"`. `AuthStoreConfig` is `{ baseUrl, refreshPath?, refreshBuffer? }` — there is no storage-adapter seam. Also exposes `devLogin(email)` (CEL-1364) — see "Dev sign-in bypass". +- `createAuthStore({ baseUrl })` — holds the access token in a **module-closure variable, not `localStorage`**; durability across reloads comes from the backend's HttpOnly refresh cookie, which `performRefresh()` sends with `credentials: "include"`. `AuthStoreConfig` is `{ baseUrl, refreshPath?, refreshBuffer?, resolutionTimeoutMs? }` — there is no storage-adapter seam. Also exposes `devLogin(email)` (CEL-1364) — see "Dev sign-in bypass". - `createAuthClient({ baseUrl, store, onAuthFailure })` — fetch wrapper, auto-attaches Bearer, calls `onAuthFailure` on 401. + +Every package-owned request requires HTTPS. HTTP is accepted automatically +only for exact parsed loopback hosts `localhost`, `127.0.0.1`, and `[::1]` for +local development. Requests reject redirects and cannot escape a configured +base URL path. - `createAuthApi({ client, store })` — typed login/register/logout helpers. - `validateUserType(userType)` — `"producer" | "importer" | "distributor" | "admin"`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb124e..ef3132f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.15.0 + +### Added +- Session resolution APIs: `resolveSession`, `getSessionState`, `onSessionStateChange`, continuity capture, and guarded 401 replay. Refresh and identity resolution are generation-checked, single-flight, and bounded by `resolutionTimeoutMs`. +- `VerifyOtpUser` models the sparse user projection returned by `/auth/verify-otp`; `/auth/me` continues to return fully validated `AuthUser`. + +### Changed +- Package-owned auth requests require HTTPS. Exact loopback hosts `localhost`, `127.0.0.1`, and `[::1]` retain automatic HTTP support for local development. Unsafe URLs and redirects fail before credentials can leave the configured origin and base path. +- Session-facing `userType` accepts `"distributor"` and nullable profile values. Consumers must handle `null` before portal routing. + +### Migration +- Code reading `verifyOtp(...).user` must treat it as `VerifyOtpUser`; call `getMe()` or resolve the session before requiring full `AuthUser` fields such as `createdAt`. +- Code switching on `AuthUser.userType` must handle `"distributor"` and `null`. +- Protected startup and 401 retry flows should use the session-resolution API so token, user, and organisation authority come from one guarded generation. + ## 0.14.0 ### Added diff --git a/README.md b/README.md index cf1102b..0422d3c 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ const authClient = createAuthClient({ const authApi = createAuthApi({ client: authClient, store: authStore }); ``` +All package-owned requests require HTTPS. HTTP works automatically only for +exact loopback hosts `localhost`, `127.0.0.1`, and `[::1]` during local +development. Invalid URLs, userinfo, other schemes, non-loopback HTTP, and +request paths that escape the configured origin or base path fail before +network transport. Redirects are rejected so credentials cannot follow a +cross-origin or HTTPS-to-HTTP redirect. + Resolve token and authoritative identity together before enabling protected work. `unavailable` preserves credentials while authority getters fail closed. Caller abort returns `superseded` without cancelling shared adoption. diff --git a/__tests__/auth-transport.test.ts b/__tests__/auth-transport.test.ts new file mode 100644 index 0000000..00a0954 --- /dev/null +++ b/__tests__/auth-transport.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAuthApi } from "../src/auth-api.js"; +import { createAuthClient } from "../src/auth-client.js"; +import { createAuthStore } from "../src/auth-store.js"; +import type { AuthStore, AuthUser } from "../src/types.js"; + +const user: AuthUser = { + id: "user_1", + email: "user@example.test", + name: "User", + userType: "producer", + orgId: "org_1", + roles: ["member"], + createdAt: "2026-01-01T00:00:00.000Z", +}; + +function response(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +function readyStore(): AuthStore { + return { + getAccessToken: vi.fn().mockReturnValue("tok_old"), + hasAccessToken: vi.fn().mockReturnValue(true), + setAccessToken: vi.fn(), + clearAccessToken: vi.fn(), + ensureAccessToken: vi.fn().mockResolvedValue("tok_old"), + getSessionState: vi.fn().mockReturnValue({ + status: "ready", + token: "tok_old", + user, + }), + getUserId: vi.fn().mockReturnValue(user.id), + getOrgId: vi.fn().mockReturnValue(user.orgId), + getUserType: vi.fn().mockReturnValue(user.userType), + getEntitlements: vi.fn().mockReturnValue([]), + onOrgChange: vi.fn().mockReturnValue(() => {}), + onAccessTokenSet: vi.fn().mockReturnValue(() => {}), + onLogout: vi.fn().mockReturnValue(() => {}), + }; +} + +describe("auth transport policy", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it.each([ + "http://api.example.test", + "http://localhost.example.test", + "ftp://api.example.test", + "https://user:secret@api.example.test", + "not a URL", + ])("rejects unsafe base URL %s before OTP transport", async (baseUrl) => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + const client = createAuthClient({ baseUrl, store: readyStore() }); + + await expect( + client.fetch("/auth/request-otp", { + method: "POST", + skipAuth: true, + body: JSON.stringify({ email: "user@example.test" }), + }), + ).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + "http://localhost:4000", + "http://127.0.0.1:4000", + "http://[::1]:4000", + ])("allows exact loopback HTTP host %s", async (baseUrl) => { + const fetchMock = vi.fn().mockResolvedValue(response({ ok: true })); + global.fetch = fetchMock; + const client = createAuthClient({ baseUrl, store: readyStore() }); + + await expect( + client.fetch("/auth/request-otp", { skipAuth: true }), + ).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("preserves base path and forces redirect rejection after caller init", async () => { + const fetchMock = vi.fn().mockResolvedValue(response({ ok: true })); + global.fetch = fetchMock; + const client = createAuthClient({ + baseUrl: "https://api.example.test/v2", + store: readyStore(), + }); + + await client.fetch("/auth/request-otp", { + method: "POST", + skipAuth: true, + redirect: "follow", + body: JSON.stringify({ email: "user@example.test" }), + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.test/v2/auth/request-otp", + expect.objectContaining({ redirect: "error" }), + ); + }); + + it.each(["https://other.example.test/auth/me", "../auth/me"])( + "rejects request URL outside configured base path: %s", + async (path) => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + const client = createAuthClient({ + baseUrl: "https://api.example.test/v2", + store: readyStore(), + }); + + await expect( + client.fetch(path, { skipAuth: true }), + ).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it("applies transport rejection to explicit-token getMe without network", async () => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + const store = readyStore(); + const client = createAuthClient({ + baseUrl: "http://api.example.test", + store, + }); + const api = createAuthApi({ client, store }); + + await expect(api.getMe("tok_explicit")).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("forces redirect rejection on initial request and authenticated retry", async () => { + let token = "tok_old"; + const store = readyStore(); + (store.getAccessToken as ReturnType).mockImplementation( + () => token, + ); + (store.getSessionState as ReturnType).mockImplementation( + () => ({ status: "ready", token, user }), + ); + store.resolveSession = vi.fn(async () => { + token = "tok_new"; + return { status: "ready", token, user }; + }); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(response({ code: "UNAUTHORIZED" }, 401)) + .mockResolvedValueOnce(response({ ok: true })); + global.fetch = fetchMock; + const client = createAuthClient({ + baseUrl: "https://api.example.test/v2", + store, + }); + + await expect( + client.fetch("/api/write", { redirect: "follow" }), + ).resolves.toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ redirect: "error" }), + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ redirect: "error" }), + ); + }); + + it("applies base-path and redirect policy to refresh and identity", async () => { + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.endsWith("/auth/refresh")) { + return Promise.resolve(response({ accessToken: "tok_new", expiresIn: 900 })); + } + return Promise.resolve(response(user)); + }); + global.fetch = fetchMock as unknown as typeof fetch; + const store = createAuthStore({ baseUrl: "https://api.example.test/v2" }); + + await expect(store.resolveSession({ refresh: true })).resolves.toMatchObject({ + status: "ready", + token: "tok_new", + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://api.example.test/v2/auth/refresh", + "https://api.example.test/v2/auth/me", + ]); + expect(fetchMock.mock.calls.every(([, init]) => init?.redirect === "error")).toBe(true); + store.clearAccessToken(); + }); + + it("keeps devLogin nonrejecting while blocking unsafe transport", async () => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + const store = createAuthStore({ baseUrl: "http://api.example.test" }); + + await expect(store.devLogin?.("dev@example.test")).resolves.toMatchObject({ + ok: false, + reason: "network", + status: null, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/package-lock.json b/package-lock.json index e3b5570..4bee26f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cellarnode/auth", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cellarnode/auth", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "dependencies": { "clsx": "^2.1.0" diff --git a/package.json b/package.json index c3a9e06..7c3dbaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cellarnode/auth", - "version": "0.14.0", + "version": "0.15.0", "description": "Shared OTP-based authentication — token store, API client, React UI components for CellarNode dashboards.", "license": "MIT", "type": "module", diff --git a/src/auth-client.ts b/src/auth-client.ts index 8ba44b6..70de1c8 100644 --- a/src/auth-client.ts +++ b/src/auth-client.ts @@ -5,6 +5,7 @@ import { captureSessionContinuity, resolveSessionForReplay, } from "./session-continuity.js"; +import { fetchAuthRequest } from "./auth-transport.js"; export function createAuthClient(config: AuthClientConfig): AuthClient { const { baseUrl, store, onAuthFailure } = config; @@ -55,11 +56,9 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { headers.set("Content-Type", "application/json"); } - const url = `${baseUrl}${path}`; - const retryWithToken = async (token: string): Promise => { headers.set("Authorization", `Bearer ${token}`); - const retryRes = await fetch(url, { + const retryRes = await fetchAuthRequest(baseUrl, path, { ...init, headers, credentials: "include", @@ -75,7 +74,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { ); }; - const res = await fetch(url, { + const res = await fetchAuthRequest(baseUrl, path, { ...init, headers, credentials: "include", diff --git a/src/auth-store.ts b/src/auth-store.ts index 4240110..34fbc1f 100644 --- a/src/auth-store.ts +++ b/src/auth-store.ts @@ -1,5 +1,6 @@ import { copyAuthUser, parseAuthUser } from "./auth-user.js"; import { extractAccessToken } from "./extract-token.js"; +import { fetchAuthRequest } from "./auth-transport.js"; import type { AccessTokenSetListener, AuthStoreConfig, @@ -213,11 +214,14 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { } function fetchResolutionResponse( - url: string, + path: string, init: RequestInit, ): Promise<{ response: Response; raw: unknown }> { return withResolutionTimeout(async (signal) => { - const response = await fetch(url, { ...init, signal }); + const response = await fetchAuthRequest(baseUrl, path, { + ...init, + signal, + }); const raw = response.ok ? await response.json() : null; return { response, raw }; }); @@ -226,7 +230,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { async function fetchIdentity(token: string): Promise { let result: { response: Response; raw: unknown }; try { - result = await fetchResolutionResponse(`${baseUrl}/auth/me`, { + result = await fetchResolutionResponse("/auth/me", { method: "GET", credentials: "include", headers: { Authorization: `Bearer ${token}` }, @@ -464,7 +468,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { void (async (): Promise => { let result: { response: Response; raw: unknown }; try { - result = await fetchResolutionResponse(`${baseUrl}${refreshPath}`, { + result = await fetchResolutionResponse(refreshPath, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, @@ -632,7 +636,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { async devLogin(email: string): Promise { let response: Response; try { - response = await fetch(`${baseUrl}/test/login`, { + response = await fetchAuthRequest(baseUrl, "/test/login", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, diff --git a/src/auth-transport.ts b/src/auth-transport.ts new file mode 100644 index 0000000..d96c503 --- /dev/null +++ b/src/auth-transport.ts @@ -0,0 +1,54 @@ +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); + +function parseAllowedUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new TypeError("Auth request URL is invalid"); + } + + if (url.username || url.password) { + throw new TypeError("Auth request URL must not contain userinfo"); + } + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) + ) { + throw new TypeError( + "Auth requests require HTTPS except for loopback local development", + ); + } + return url; +} + +/** Build one same-origin auth URL while preserving a base URL path prefix. */ +export function resolveAuthRequestUrl(baseUrl: string, path: string): string { + const base = parseAllowedUrl(baseUrl); + const basePath = base.pathname.endsWith("/") + ? base.pathname + : `${base.pathname}/`; + base.pathname = basePath; + base.search = ""; + base.hash = ""; + + const relativePath = path.replace(/^\/+/, ""); + const request = parseAllowedUrl(new URL(relativePath, base).href); + if ( + request.origin !== base.origin || + !request.pathname.startsWith(basePath) + ) { + throw new TypeError("Auth request URL must stay within configured base URL"); + } + return request.href; +} + +/** Send auth traffic without following redirects that could cross transport boundaries. */ +export function fetchAuthRequest( + baseUrl: string, + path: string, + init: RequestInit = {}, +): Promise { + const url = resolveAuthRequestUrl(baseUrl, path); + return fetch(url, { ...init, redirect: "error" }); +} From b93a9e8fb61024baabbaf9f98030ac2f330e87ca Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 08:04:01 +0200 Subject: [PATCH 7/9] fix: reject encoded auth path boundaries --- __tests__/auth-transport.test.ts | 56 ++++++++++++++++++++++++++++++++ src/auth-transport.ts | 4 +++ 2 files changed, 60 insertions(+) diff --git a/__tests__/auth-transport.test.ts b/__tests__/auth-transport.test.ts index 00a0954..56928d4 100644 --- a/__tests__/auth-transport.test.ts +++ b/__tests__/auth-transport.test.ts @@ -124,6 +124,62 @@ describe("auth transport policy", () => { }, ); + it.each([ + "..%2fauth/me", + "..%2Fauth/me", + "..%5cauth/me", + "..%5Cauth/me", + "..%252fauth/me", + "..%252Fauth/me", + "..%255cauth/me", + ])("rejects encoded path-boundary traversal before fetch: %s", async (path) => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + const client = createAuthClient({ + baseUrl: "https://api.example.test/v2", + store: readyStore(), + }); + + await expect( + client.fetch(path, { skipAuth: true }), + ).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + "https://api.example.test/v2%2fprivate", + "https://api.example.test/v2%5Cprivate", + "https://api.example.test/v2%252fprivate", + ])("rejects encoded path boundaries in base URL: %s", async (baseUrl) => { + const fetchMock = vi.fn(); + global.fetch = fetchMock; + const client = createAuthClient({ baseUrl, store: readyStore() }); + + await expect( + client.fetch("/auth/request-otp", { skipAuth: true }), + ).rejects.toBeInstanceOf(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows encoded query values and encoded Unicode path segments", async () => { + const fetchMock = vi.fn().mockResolvedValue(response({ ok: true })); + global.fetch = fetchMock; + const client = createAuthClient({ + baseUrl: "https://api.example.test/v2", + store: readyStore(), + }); + + await client.fetch( + "/products/caf%C3%A9?next=%2Fauth%2Fme&literal=%252f", + { skipAuth: true }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.test/v2/products/caf%C3%A9?next=%2Fauth%2Fme&literal=%252f", + expect.any(Object), + ); + }); + it("applies transport rejection to explicit-token getMe without network", async () => { const fetchMock = vi.fn(); global.fetch = fetchMock; diff --git a/src/auth-transport.ts b/src/auth-transport.ts index d96c503..d961248 100644 --- a/src/auth-transport.ts +++ b/src/auth-transport.ts @@ -1,4 +1,5 @@ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); +const ENCODED_PATH_BOUNDARY = /%(?:2f|5c|25)/i; function parseAllowedUrl(value: string): URL { let url: URL; @@ -11,6 +12,9 @@ function parseAllowedUrl(value: string): URL { if (url.username || url.password) { throw new TypeError("Auth request URL must not contain userinfo"); } + if (ENCODED_PATH_BOUNDARY.test(url.pathname)) { + throw new TypeError("Auth request URL contains an encoded path boundary"); + } if ( url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) From d3cad5a15f752d48ce482bd8d48fb7914661f78f Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 08:11:13 +0200 Subject: [PATCH 8/9] fix: reserve explicit session adoption --- __tests__/auth-client.test.ts | 50 ++++++++++++++ __tests__/session-resolution.test.ts | 98 ++++++++++++++++++++++++++++ src/auth-store.ts | 85 ++++++++++++++++++++---- src/session-continuity.ts | 3 +- 4 files changed, 224 insertions(+), 12 deletions(-) diff --git a/__tests__/auth-client.test.ts b/__tests__/auth-client.test.ts index 9ce8899..7f213e8 100644 --- a/__tests__/auth-client.test.ts +++ b/__tests__/auth-client.test.ts @@ -273,6 +273,34 @@ describe("createAuthClient", () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); + it("keeps same-token outage unavailable when revalidation starts before settlement", async () => { + const store = mockStore("tok_old", { + status: "unavailable", + token: "tok_old", + }); + let state: "ready" | "resolving" = "ready"; + store.getSessionState = vi.fn(() => + state === "ready" + ? { status: "ready", token: "tok_old", user: userA } + : { status: "resolving", token: "tok_old" }, + ); + (store.resolveSession as ReturnType).mockImplementation(async () => { + state = "resolving"; + return { status: "unavailable", token: "tok_old" }; + }); + global.fetch = vi + .fn() + .mockResolvedValue(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)); + + const client = createAuthClient({ baseUrl: "http://localhost:4000", store }); + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 503, + code: "SESSION_UNAVAILABLE", + }); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + it("returns actual retry failure and preserves validated session", async () => { const store = mockStore(); global.fetch = vi @@ -288,6 +316,28 @@ describe("createAuthClient", () => { expect(store.clearAccessToken).not.toHaveBeenCalled(); }); + it("returns retry 401 without clearing session or sending a third request", async () => { + const store = mockStore(); + global.fetch = vi + .fn() + .mockResolvedValueOnce(response({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)) + .mockResolvedValueOnce(response({ error: "Forbidden resource", code: "RESOURCE_DENIED" }, 401)); + const onAuthFailure = vi.fn(); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + onAuthFailure, + }); + + await expect(client.fetch("/api/write")).rejects.toMatchObject({ + status: 401, + code: "RESOURCE_DENIED", + }); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + expect(onAuthFailure).not.toHaveBeenCalled(); + }); + it("calls onAuthFailure only for confirmed refresh revocation", async () => { const store = mockStore("tok_old", { status: "unauthorized" }); global.fetch = vi diff --git a/__tests__/session-resolution.test.ts b/__tests__/session-resolution.test.ts index f262088..92d2e65 100644 --- a/__tests__/session-resolution.test.ts +++ b/__tests__/session-resolution.test.ts @@ -148,6 +148,104 @@ describe("atomic session resolution (CEL-1782)", () => { ).toHaveLength(0); }); + it("joins explicit adoption when a resolving observer requests refresh", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + const token = new Headers(init?.headers).get("Authorization"); + return Promise.resolve( + response(token === "Bearer tok_a" ? { ...userA, orgId: "org_a" } : userA), + ); + }); + global.fetch = fetchMock as unknown as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_b", 900); + await store.resolveSession({ refresh: false }); + + let observerResolution: Promise | null = null; + store.onSessionStateChange((state) => { + if (state.status === "resolving" && state.token === "tok_a") { + observerResolution = store.resolveSession({ refresh: true }); + } + }); + + store.setAccessToken("tok_a", 900); + + await expect(observerResolution).resolves.toMatchObject({ + status: "ready", + token: "tok_a", + }); + expect(store.getAccessToken()).toBe("tok_a"); + expect(store.getSessionState()).toMatchObject({ + status: "ready", + token: "tok_a", + }); + expect( + fetchMock.mock.calls.filter(([url]) => + String(url).endsWith("/auth/refresh"), + ), + ).toHaveLength(0); + }); + + it("lets nested explicit login supersede an adoption without stale events", async () => { + const fetchMock = vi.fn((_url: string, init?: RequestInit) => { + const token = new Headers(init?.headers).get("Authorization")?.slice(7); + return Promise.resolve( + response({ ...userA, orgId: token === "tok_c" ? "org_c" : "org_b" }), + ); + }); + global.fetch = fetchMock as unknown as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_b", 900); + await store.resolveSession({ refresh: false }); + const tokenEvents: Array = []; + store.onAccessTokenSet((token) => tokenEvents.push(token)); + store.onSessionStateChange((state) => { + if (state.status === "resolving" && state.token === "tok_a") { + store.setAccessToken("tok_c", 900); + } + }); + + store.setAccessToken("tok_a", 900); + await expect(store.resolveSession({ refresh: false })).resolves.toMatchObject({ + status: "ready", + token: "tok_c", + }); + + expect(store.getAccessToken()).toBe("tok_c"); + expect(store.getOrgId()).toBe("org_c"); + expect(tokenEvents).toEqual(["tok_c"]); + expect( + fetchMock.mock.calls.some(([, init]) => + new Headers(init?.headers).get("Authorization") === "Bearer tok_a", + ), + ).toBe(false); + }); + + it("lets nested logout supersede explicit adoption", async () => { + const fetchMock = vi.fn().mockResolvedValue(response(userA)); + global.fetch = fetchMock; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_b", 900); + await store.resolveSession({ refresh: false }); + store.onSessionStateChange((state) => { + if (state.status === "resolving" && state.token === "tok_a") { + store.clearAccessToken(); + } + }); + + store.setAccessToken("tok_a", 900); + + await expect(store.resolveSession({ refresh: false })).resolves.toEqual({ + status: "unauthorized", + }); + expect(store.getAccessToken()).toBeNull(); + expect(store.getSessionState()).toEqual({ status: "unauthorized" }); + expect( + fetchMock.mock.calls.some(([, init]) => + new Headers(init?.headers).get("Authorization") === "Bearer tok_a", + ), + ).toBe(false); + }); + it("deduplicates timer and manual refresh into one refresh and identity flight", async () => { vi.useFakeTimers(); const refresh = deferred(); diff --git a/src/auth-store.ts b/src/auth-store.ts index 34fbc1f..63b3b39 100644 --- a/src/auth-store.ts +++ b/src/auth-store.ts @@ -50,6 +50,12 @@ interface RefreshFlight { promise: Promise; } +interface ExplicitAdoptionFlight { + generation: number; + promise: Promise; + settle: (resolution: SessionResolution) => void; +} + function copySessionState(state: SessionState): SessionState { return state.status === "ready" ? { ...state, user: copyAuthUser(state.user) } @@ -79,6 +85,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { let refreshTimer: ReturnType | null = null; let identityFlight: IdentityFlight | null = null; let refreshFlight: RefreshFlight | null = null; + let explicitAdoptionFlight: ExplicitAdoptionFlight | null = null; let tokenGeneration = 0; let previousOrgId: string | null = null; let hasEmittedOrgId = false; @@ -99,6 +106,15 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { const stateQueue: SessionState[] = []; let publishingState = false; + function settleExplicitGeneration( + generation: number, + resolution: SessionResolution, + ): void { + if (explicitAdoptionFlight?.generation === generation) { + explicitAdoptionFlight.settle(resolution); + } + } + function emitOrgChange( orgId: string | null, shouldContinue: () => boolean = () => true, @@ -273,7 +289,12 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { null, () => tokenGeneration === clearedGeneration && accessToken === null, ); - return tokenGeneration === clearedGeneration && accessToken === null; + const cleared = + tokenGeneration === clearedGeneration && accessToken === null; + if (cleared) { + settleExplicitGeneration(generation, { status: "unauthorized" }); + } + return cleared; } function markUnavailable( @@ -288,7 +309,9 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { if (generation !== tokenGeneration || accessToken !== token) { return { status: "superseded" }; } - return { status: "unavailable", token }; + const result: SessionResolution = { status: "unavailable", token }; + settleExplicitGeneration(generation, result); + return result; } function commitReady( @@ -329,7 +352,13 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { return { status: "superseded" }; } - return { status: "ready", token, user: copyAuthUser(identity) }; + const result: SessionResolution = { + status: "ready", + token, + user: copyAuthUser(identity), + }; + settleExplicitGeneration(generation, result); + return result; } async function evaluateIdentity( @@ -569,18 +598,49 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { }); } + function supersedeExplicitAdoption(): void { + const flight = explicitAdoptionFlight; + if (!flight) return; + explicitAdoptionFlight = null; + flight.settle({ status: "superseded" }); + } + function startExplicitToken(token: string, expiresIn: number): void { - const previousGeneration = tokenGeneration; - if (!beginResolving(previousGeneration, token)) return; tokenGeneration += 1; const generation = tokenGeneration; + supersedeExplicitAdoption(); refreshFlight = null; + + let settled = false; + let resolveFlight!: (resolution: SessionResolution) => void; + const promise = new Promise((resolve) => { + resolveFlight = resolve; + }); + const flight: ExplicitAdoptionFlight = { + generation, + promise, + settle(resolution) { + if (settled) return; + settled = true; + if (explicitAdoptionFlight === flight) explicitAdoptionFlight = null; + resolveFlight(resolution); + }, + }; + explicitAdoptionFlight = flight; + + if (!beginResolving(generation, token)) { + flight.settle({ status: "superseded" }); + return; + } accessToken = token; identity = null; continuityBaseline = null; pendingRefreshBaseline = null; scheduleRefresh(expiresIn); - void startIdentityFlight(generation, token, null, false, false); + void startIdentityFlight(generation, token, null, false, false).then( + flight.settle, + () => flight.settle(markUnavailable(generation, token)), + ); } let store!: ConcreteAuthStore; @@ -593,6 +653,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { }, clearAccessToken() { + supersedeExplicitAdoption(); if (clearCurrentGeneration(tokenGeneration)) { const clearedGeneration = tokenGeneration; emitLogout( @@ -625,11 +686,13 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { const shouldRefresh = options.refresh === true || (options.refresh === undefined && accessToken === null); - const operation = refreshFlight - ? refreshFlight.promise - : shouldRefresh - ? runRefresh(tokenGeneration, currentBaseline()) - : resolveCurrentSession(); + const operation = explicitAdoptionFlight + ? explicitAdoptionFlight.promise + : refreshFlight + ? refreshFlight.promise + : shouldRefresh + ? runRefresh(tokenGeneration, currentBaseline()) + : resolveCurrentSession(); return waitForCaller(operation, options.signal); }, diff --git a/src/session-continuity.ts b/src/session-continuity.ts index 067f5fa..1d98511 100644 --- a/src/session-continuity.ts +++ b/src/session-continuity.ts @@ -116,7 +116,8 @@ export async function resolveSessionForReplay( const unavailableState = store.getSessionState?.(); return store.getAccessToken() === resolution.token && (unavailableState === undefined || - (unavailableState.status === "unavailable" && + ((unavailableState.status === "unavailable" || + unavailableState.status === "resolving") && unavailableState.token === resolution.token)) ? resolution : { status: "superseded" }; From 683a6ca9e1884e86dbc2dfbf187b47d38b0a940f Mon Sep 17 00:00:00 2001 From: mjnong Date: Tue, 8 Sep 2026 08:31:17 +0200 Subject: [PATCH 9/9] fix: validate OTP session identity --- CHANGELOG.md | 6 ++-- README.md | 6 ++++ __tests__/auth-api.test.ts | 66 ++++++++++++++++++++++++++++++++++++-- src/auth-api.ts | 14 ++++++-- src/auth-user.ts | 44 +++++++++++++++++++------ src/session-continuity.ts | 7 ++-- 6 files changed, 122 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3132f..5188b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ - Package-owned auth requests require HTTPS. Exact loopback hosts `localhost`, `127.0.0.1`, and `[::1]` retain automatic HTTP support for local development. Unsafe URLs and redirects fail before credentials can leave the configured origin and base path. - Session-facing `userType` accepts `"distributor"` and nullable profile values. Consumers must handle `null` before portal routing. -### Migration -- Code reading `verifyOtp(...).user` must treat it as `VerifyOtpUser`; call `getMe()` or resolve the session before requiring full `AuthUser` fields such as `createdAt`. -- Code switching on `AuthUser.userType` must handle `"distributor"` and `null`. +### Breaking changes +- `VerifyOtpResponse.user` is now `VerifyOtpUser`, which omits `createdAt` and `entitlements`. Code requiring those full-profile fields must call `getMe()` or resolve the session. +- `AuthUser.userType` now permits `"distributor"` and `null`. Exhaustive switches and portal routing must handle both values. - Protected startup and 401 retry flows should use the session-resolution API so token, user, and organisation authority come from one guarded generation. ## 0.14.0 diff --git a/README.md b/README.md index 0422d3c..58a6abd 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,12 @@ if (session.status === "ready") { } ``` +`ensureAccessToken()` remains a legacy credential-only wrapper. It may return a +retained token while authority is transiently `unavailable`; authority getters +still fail closed. Never use its token as proof that user or organisation +authority is ready. Use `resolveSession()` and require `status === "ready"` for +authorization decisions. + `onSessionStateChange` immediately reports current state. A synchronous `resolving` notification precedes credential or authority changes, allowing consumers to suspend writes and clear captured tenant queries first. diff --git a/__tests__/auth-api.test.ts b/__tests__/auth-api.test.ts index c6ba3e3..907b0e9 100644 --- a/__tests__/auth-api.test.ts +++ b/__tests__/auth-api.test.ts @@ -88,7 +88,7 @@ describe("createAuthApi", () => { id: "u1", email: "t@t.com", name: "Test", - userType: "producer", + userType: null, orgId: null, roles: [], }, @@ -96,10 +96,72 @@ describe("createAuthApi", () => { const api = createAuthApi({ client, store }); await expect(api.verifyOtp("t@t.com", "123456")).resolves.toMatchObject({ - user: { id: "u1" }, + user: { id: "u1", userType: null, orgId: null }, }); }); + it.each([ + ["missing user", undefined], + ["empty id", { id: "", email: "t@t.com", name: "Test", userType: "producer", orgId: null, roles: [] }], + ["missing email", { id: "u1", name: "Test", userType: "producer", orgId: null, roles: [] }], + ["missing name", { id: "u1", email: "t@t.com", userType: "producer", orgId: null, roles: [] }], + ["missing userType", { id: "u1", email: "t@t.com", name: "Test", orgId: null, roles: [] }], + ["unknown userType", { id: "u1", email: "t@t.com", name: "Test", userType: "broker", orgId: null, roles: [] }], + ["missing orgId", { id: "u1", email: "t@t.com", name: "Test", userType: "producer", roles: [] }], + ["non-string phone", { id: "u1", email: "t@t.com", name: "Test", phone: 42, userType: "producer", orgId: null, roles: [] }], + ["malformed roles", { id: "u1", email: "t@t.com", name: "Test", userType: "producer", orgId: null, roles: ["member", 4] }], + ])("rejects verify-otp %s before token adoption", async (_case, user) => { + const client = mockClient(); + const store = mockStore(); + (client.fetch as ReturnType).mockResolvedValue({ + accessToken: "tok_invalid", + expiresIn: 900, + user, + }); + const api = createAuthApi({ client, store }); + + await expect(api.verifyOtp("t@t.com", "123456")).rejects.toMatchObject({ + status: 500, + code: "OTP_USER_INVALID", + message: "Invalid user in verify-otp response", + }); + expect(store.setAccessToken).not.toHaveBeenCalled(); + }); + + it("defensively copies verify-otp roles and strips full-profile extras", async () => { + const client = mockClient(); + const store = mockStore(); + const roles = ["member"]; + (client.fetch as ReturnType).mockResolvedValue({ + accessToken: "tok_new", + expiresIn: 900, + user: { + id: "u1", + email: "t@t.com", + name: "Test", + userType: "distributor", + orgId: "org_1", + roles, + createdAt: "ignored", + entitlements: ["ignored"], + }, + }); + const api = createAuthApi({ client, store }); + + const result = await api.verifyOtp("t@t.com", "123456"); + roles.push("admin"); + + expect(result.user).toEqual({ + id: "u1", + email: "t@t.com", + name: "Test", + userType: "distributor", + orgId: "org_1", + roles: ["member"], + }); + expect(store.setAccessToken).toHaveBeenCalledWith("tok_new", 900); + }); + it("verifyOtp extracts token from nested response shapes", async () => { const client = mockClient(); const store = mockStore(); diff --git a/src/auth-api.ts b/src/auth-api.ts index b95f10d..d81fd82 100644 --- a/src/auth-api.ts +++ b/src/auth-api.ts @@ -6,10 +6,9 @@ import type { RegisterInput, RequestOtpResponse, VerifyOtpResponse, - VerifyOtpUser, } from "./types.js"; import { extractAccessToken } from "./extract-token.js"; -import { parseAuthUser } from "./auth-user.js"; +import { parseAuthUser, parseVerifyOtpUser } from "./auth-user.js"; export function createAuthApi(config: { client: AuthClient; @@ -52,6 +51,15 @@ export function createAuthApi(config: { throw new AuthError(500, "TOKEN_EXTRACTION_FAILED", "No access token found in verify-otp response"); } + const user = parseVerifyOtpUser(raw.user); + if (!user) { + throw new AuthError( + 500, + "OTP_USER_INVALID", + "Invalid user in verify-otp response", + ); + } + const expiresIn = typeof raw.expiresIn === "number" ? raw.expiresIn : 900; @@ -60,7 +68,7 @@ export function createAuthApi(config: { return { accessToken: token, expiresIn, - user: raw.user as VerifyOtpUser, + user, }; }, diff --git a/src/auth-user.ts b/src/auth-user.ts index 383cdf5..a45cda2 100644 --- a/src/auth-user.ts +++ b/src/auth-user.ts @@ -1,4 +1,8 @@ -import type { AuthUser, SessionUserType } from "./types.js"; +import type { + AuthUser, + SessionUserType, + VerifyOtpUser, +} from "./types.js"; const SESSION_USER_TYPES = new Set([ "importer", @@ -11,8 +15,7 @@ function hasOwn(value: object, key: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(value, key); } -/** Validate and defensively copy the complete `/auth/me` response. */ -export function parseAuthUser(value: unknown): AuthUser | null { +function parseRequiredUserFields(value: unknown): VerifyOtpUser | null { if (value === null || typeof value !== "object" || Array.isArray(value)) { return null; } @@ -23,7 +26,6 @@ export function parseAuthUser(value: unknown): AuthUser | null { user.id.length === 0 || typeof user.email !== "string" || typeof user.name !== "string" || - typeof user.createdAt !== "string" || !hasOwn(user, "orgId") || !(user.orgId === null || typeof user.orgId === "string") || !hasOwn(user, "userType") || @@ -34,12 +36,7 @@ export function parseAuthUser(value: unknown): AuthUser | null { ) || !Array.isArray(user.roles) || !user.roles.every((role) => typeof role === "string") || - (user.phone !== undefined && typeof user.phone !== "string") || - (user.entitlements !== undefined && - (!Array.isArray(user.entitlements) || - !user.entitlements.every( - (entitlement) => typeof entitlement === "string", - ))) + (user.phone !== undefined && typeof user.phone !== "string") ) { return null; } @@ -52,6 +49,33 @@ export function parseAuthUser(value: unknown): AuthUser | null { userType: user.userType as SessionUserType | null, orgId: user.orgId as string | null, roles: [...user.roles] as string[], + }; +} + +/** Validate and defensively copy the sparse `/auth/verify-otp` user. */ +export function parseVerifyOtpUser(value: unknown): VerifyOtpUser | null { + return parseRequiredUserFields(value); +} + +/** Validate and defensively copy the complete `/auth/me` response. */ +export function parseAuthUser(value: unknown): AuthUser | null { + const required = parseRequiredUserFields(value); + if (!required) return null; + + const user = value as Record; + if ( + typeof user.createdAt !== "string" || + (user.entitlements !== undefined && + (!Array.isArray(user.entitlements) || + !user.entitlements.every( + (entitlement) => typeof entitlement === "string", + ))) + ) { + return null; + } + + return { + ...required, ...(user.entitlements === undefined ? {} : { entitlements: [...user.entitlements] as string[] }), diff --git a/src/session-continuity.ts b/src/session-continuity.ts index 1d98511..59d0333 100644 --- a/src/session-continuity.ts +++ b/src/session-continuity.ts @@ -53,9 +53,10 @@ export function canReplaySession( } /** - * Resolve authority for a 401 retry without refreshing a replacement session. - * A ready replacement credential may be reused only for the captured user and - * organisation. Call `canReplaySession` again immediately before transport. + * Resolve authority for a 401 retry. Reuse an already-ready replacement only + * when its user and organisation match the captured authority; otherwise a + * matching captured credential refreshes. Call `canReplaySession` again + * immediately before transport. */ export async function resolveSessionForReplay( store: AuthStore,