diff --git a/packages/auth-react/jest.config.cjs b/packages/auth-react/jest.config.cjs index 11d8596..7fa786f 100644 --- a/packages/auth-react/jest.config.cjs +++ b/packages/auth-react/jest.config.cjs @@ -1,12 +1,14 @@ /** @type {import('jest').Config} */ module.exports = { testEnvironment: "jsdom", + setupFiles: ["/jest.setup.cjs"], roots: ["/src"], testMatch: ["**/__tests__/**/*.test.ts?(x)"], transform: { "^.+\\.tsx?$": ["ts-jest", { tsconfig: "/tsconfig.test.json" }] }, moduleNameMapper: { + "^react-dom/server$": "/../../node_modules/react-dom/server.node.js", "^(\\.{1,2}/.*)\\.js$": "$1" } }; diff --git a/packages/auth-react/jest.setup.cjs b/packages/auth-react/jest.setup.cjs new file mode 100644 index 0000000..e1293da --- /dev/null +++ b/packages/auth-react/jest.setup.cjs @@ -0,0 +1,5 @@ +// React 19's server renderer expects these Node globals, which jsdom lacks. +const { TextDecoder, TextEncoder } = require("node:util"); + +global.TextEncoder ??= TextEncoder; +global.TextDecoder ??= TextDecoder; diff --git a/packages/auth-react/src/__tests__/DiditAuth.test.tsx b/packages/auth-react/src/__tests__/DiditAuth.test.tsx index 7cd1d2e..4b7a745 100644 --- a/packages/auth-react/src/__tests__/DiditAuth.test.tsx +++ b/packages/auth-react/src/__tests__/DiditAuth.test.tsx @@ -111,6 +111,16 @@ describe("createCoreAuthClient", () => { expect(core.protection.verify).toHaveBeenCalledWith({ code: "123456" }); }); + it("keeps the session when a token refresh has none cached yet", async () => { + const client = createCoreAuthClient({ publishableKey: "pk_test_example" }); + await client.load(); + const listener = core.onAuthStateChange.mock.calls[0][0] as (event: string, value: unknown) => void; + + listener("SIGNED_IN", coreSession); + listener("TOKEN_REFRESHED", null); + expect(client.getSnapshot()).toEqual({ status: "signed-in", session }); + }); + it("follows the core's auth state changes", async () => { const client = createCoreAuthClient({ publishableKey: "pk_test_example" }); await client.load(); diff --git a/packages/auth-react/src/__tests__/core-scopes.test.tsx b/packages/auth-react/src/__tests__/core-scopes.test.tsx new file mode 100644 index 0000000..20f4f12 --- /dev/null +++ b/packages/auth-react/src/__tests__/core-scopes.test.tsx @@ -0,0 +1,111 @@ +// Two provider scopes are two AuthClients over one browser session. They must agree on who is +// signed in without repeating the one-time redirect exchange. +const coreSession = { + userId: "00000000-0000-4000-8000-000000000002", + sessionId: "00000000-0000-4000-8000-000000000001", + applicationId: "00000000-0000-4000-8000-000000000003", + vendorData: null, + email: "maria.oliveira@example.com", + emailVerified: true, + phone: null, + methods: ["password"], + mfaVerified: false, + verifiedIdentity: null, + organizations: [], + expiresAt: "2099-11-11T08:30:56Z" +}; + +let signedIn = false; +let pending = true; +let exchanges = 0; +const listeners = new Set<(event: string, session: unknown) => void>(); + +function emit(event: string, session: unknown) { + listeners.forEach((listener) => listener(event, session)); +} + +function makeCore() { + return { + handleRedirectCallback: jest.fn(async () => { + if (!pending) throw new Error("invalid_state"); + pending = false; + exchange(); + return { session: coreSession, returnTo: "/account" }; + }), + getSession: jest.fn(async () => (signedIn ? coreSession : null)), + onAuthStateChange: jest.fn((listener: (event: string, session: unknown) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }), + signIn: jest.fn(async () => undefined), + signOut: jest.fn(async () => { + signedIn = false; + emit("SIGNED_OUT", null); + }), + reauthenticate: jest.fn(async () => undefined), + stepUp: jest.fn(), + handleStepUpCallback: jest.fn(), + getToken: jest.fn(async () => (signedIn ? "access-token" : null)), + password: { signIn: jest.fn() }, + emailCode: { start: jest.fn(), verify: jest.fn() }, + phone: { start: jest.fn(), verify: jest.fn() }, + protection: { verify: jest.fn(), passkey: jest.fn() }, + mfa: { verify: jest.fn(), setup: jest.fn() }, + agents: { list: jest.fn(async () => []), revoke: jest.fn(async () => undefined) } + }; +} + +function exchange() { + exchanges += 1; + signedIn = true; + emit("SIGNED_IN", coreSession); +} + +const createDiditAuth = jest.fn(() => makeCore()); +jest.mock("@didit-protocol/auth", () => ({ createDiditAuth }), { virtual: true }); + +import { waitFor } from "@testing-library/react"; + +import { createCoreAuthClient } from "../core"; + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "error").mockImplementation(() => undefined); + signedIn = false; + pending = true; + exchanges = 0; + listeners.clear(); + window.history.replaceState(null, "", "/callback?code=c&state=s"); +}); + +afterEach(() => jest.restoreAllMocks()); + +it("settles two provider scopes on the one session and exchanges the redirect once", async () => { + const first = createCoreAuthClient({ publishableKey: "pk_test_example" }); + const second = createCoreAuthClient({ publishableKey: "pk_test_example" }); + first.subscribe(() => undefined); + second.subscribe(() => undefined); + + await Promise.all([first.load(), second.load()]); + await waitFor(() => expect(second.getSnapshot().status).toBe("signed-in")); + expect(first.getSnapshot().status).toBe("signed-in"); + expect(second.getSnapshot().session?.user.id).toBe(coreSession.userId); + expect(exchanges).toBe(1); + expect(window.location.pathname).toBe("/account"); +}); + +it("tells the other scope when one signs out", async () => { + const first = createCoreAuthClient({ publishableKey: "pk_test_example" }); + const second = createCoreAuthClient({ publishableKey: "pk_test_example" }); + const stopFirst = first.subscribe(() => undefined); + const stopSecond = second.subscribe(() => undefined); + await Promise.all([first.load(), second.load()]); + await waitFor(() => expect(second.getSnapshot().status).toBe("signed-in")); + + await first.signOut(); + + await waitFor(() => expect(second.getSnapshot().status).toBe("signed-out")); + expect(first.getSnapshot().status).toBe("signed-out"); + stopFirst(); + stopSecond(); +}); diff --git a/packages/auth-react/src/__tests__/provider.test.tsx b/packages/auth-react/src/__tests__/provider.test.tsx index 2564c79..b42f287 100644 --- a/packages/auth-react/src/__tests__/provider.test.tsx +++ b/packages/auth-react/src/__tests__/provider.test.tsx @@ -1,4 +1,6 @@ -import { act, render, renderHook, screen } from "@testing-library/react"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; import type { ReactNode } from "react"; import { DiditAuthProvider, useDiditAuthContext } from "../context"; @@ -43,6 +45,57 @@ describe("", () => { jest.restoreAllMocks(); }); + it("reads as signed out when the client cannot load the session", async () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + const { client } = testClient({ session: "loading" }); + const failing = { ...client, load: async () => { throw new Error("network down"); } }; + + render( + + member area + please sign in + + ); + + await screen.findByText("please sign in"); + expect(screen.queryByText("member area")).toBeNull(); + jest.restoreAllMocks(); + }); + + it("hydrates with the default language without a mismatch, then follows the browser", async () => { + const { client } = testClient(); + const original = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const errors = jest.spyOn(console, "error").mockImplementation(() => undefined); + const Language = () => {useDiditAuthContext().locale}; + + Object.defineProperty(globalThis, "navigator", { value: undefined, configurable: true }); + const html = renderToString( + + + + ); + Object.defineProperty(globalThis, "navigator", { value: { language: "es-MX" }, configurable: true }); + expect(html).toContain(">en<"); + const container = document.createElement("div"); + container.innerHTML = html; + document.body.append(container); + + await act(async () => { + hydrateRoot( + container, + + + + ); + }); + + expect(errors.mock.calls.map((call) => String(call[0])).filter((text) => /hydrat/i.test(text))).toEqual([]); + await waitFor(() => expect(container.querySelector("#language")?.textContent).toBe("es")); + container.remove(); + if (original) Object.defineProperty(globalThis, "navigator", original); + jest.restoreAllMocks(); + }); + it("resolves the language to a built-in one", () => { const { client } = testClient(); diff --git a/packages/auth-react/src/context.tsx b/packages/auth-react/src/context.tsx index 148b87f..e933054 100644 --- a/packages/auth-react/src/context.tsx +++ b/packages/auth-react/src/context.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, useEffect, useMemo, type CSSProperties, type ReactNode } from "react"; +import { createContext, useContext, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; import { resolveLocale } from "./i18n.js"; import { stylesheet } from "./styles.js"; @@ -10,6 +10,8 @@ interface DiditAuthContextValue { client: AuthClient; appearance: Appearance; locale: string; + /** The client could not load the session: components read as signed out instead of waiting forever. */ + sessionUnavailable: boolean; } const DiditAuthContext = createContext(null); @@ -26,17 +28,36 @@ export interface DiditAuthProviderProps { /** Makes an authentication client available to every Didit component and hook below it. */ export function DiditAuthProvider({ client, appearance, locale, nonce, children }: DiditAuthProviderProps) { + const [sessionUnavailable, setSessionUnavailable] = useState(false); + // The browser language is read after mount, so server rendering and hydration agree on English. + const [browserLocale, setBrowserLocale] = useState("en"); + useEffect(function loadSession() { - client.load().catch(() => console.error("[didit-auth] Could not load the session.")); + let live = true; + setSessionUnavailable(false); + client.load().catch(() => { + if (!live) return; + setSessionUnavailable(true); + console.error("[didit-auth] Could not load the session."); + }); + + return () => { + live = false; + }; }, [client]); + useEffect(function readBrowserLocale() { + if (typeof navigator !== "undefined") setBrowserLocale(navigator.language); + }, []); + const value = useMemo( () => ({ client, appearance: appearance ?? {}, - locale: resolveLocale(locale ?? (typeof navigator === "undefined" ? "en" : navigator.language)) + locale: resolveLocale(locale ?? browserLocale), + sessionUnavailable }), - [client, appearance, locale] + [client, appearance, locale, browserLocale, sessionUnavailable] ); return ( diff --git a/packages/auth-react/src/core.ts b/packages/auth-react/src/core.ts index 5c2c5e3..3bcbb41 100644 --- a/packages/auth-react/src/core.ts +++ b/packages/auth-react/src/core.ts @@ -14,6 +14,17 @@ export interface CoreClientOptions { language?: "en" | "es" | "fr" | "de" | "ja"; } +// Every client mounted on this page shares the browser's one session. When one of them restores, +// signs in or signs out, it tells the others to re-read the session, so two provider scopes on the +// same page never disagree about who is signed in. A client adds its listener once mounted. +const pagePeers = new Set<() => void>(); + +function announceSessionChange(source: () => void): void { + pagePeers.forEach((refresh) => { + if (refresh !== source) refresh(); + }); +} + function toSession(session: CoreSession): DiditSession; function toSession(session: CoreSession | null): DiditSession | null; function toSession(session: CoreSession | null): DiditSession | null { @@ -39,11 +50,30 @@ export function createCoreAuthClient(options: CoreClientOptions): AuthClient { const store = createAuthStore({ status: "loading", session: null }); let core: ReturnType | undefined; let loading: Promise | null = null; + let subscribers = 0; + + // Re-reads the shared browser session, so this client follows a sign-in or sign-out another + // client on the same page just made (a second provider scope, or one that armed after it). + function refreshFromPage(): void { + if (!core) return; + core.getSession().then( + (session) => store.set(snapshotOf(toSession(session))), + () => undefined + ); + } function getCore() { if (core) return core; core = createDiditAuth(options); - core.onAuthStateChange((_event, session) => store.set(snapshotOf(toSession(session)))); + core.onAuthStateChange((event, session) => { + const next = toSession(session); + + // A refresh keeps the session it renewed; it reports null only while the core has no + // cached session yet (a token renewed before the first read), which must not sign out. + if (event === "TOKEN_REFRESHED" && !next) return; + store.set(snapshotOf(next)); + announceSessionChange(refreshFromPage); + }); return core; } @@ -54,17 +84,30 @@ export function createCoreAuthClient(options: CoreClientOptions): AuthClient { const callback = query.has("state") && (query.has("code") || query.has("error")) ? await client.handleRedirectCallback() : null; const target = safeReturnTo(callback?.returnTo); + const next = snapshotOf(toSession(callback?.session ?? await client.getSession())); - store.set(snapshotOf(toSession(callback?.session ?? await client.getSession()))); + // A peer may have established the session while this client was behind; never undo it with a + // stale "no session" read. + if (next.status === "signed-in" || store.get().status !== "signed-in") store.set(next); + announceSessionChange(refreshFromPage); if (target) window.history.replaceState(window.history.state, "", target); } return { getSnapshot: store.get, - subscribe: store.subscribe, + subscribe(listener) { + const unsubscribe = store.subscribe(listener); + if (subscribers++ === 0) pagePeers.add(refreshFromPage); + + return () => { + unsubscribe(); + if (--subscribers === 0) pagePeers.delete(refreshFromPage); + }; + }, load() { loading ??= restore().catch(() => { - store.set(snapshotOf(null)); + // A peer may have established the session while this restore was failing: keep it. + if (store.get().status !== "signed-in") store.set(snapshotOf(null)); console.error("[didit-auth] Could not restore the session."); }); diff --git a/packages/auth-react/src/hooks.ts b/packages/auth-react/src/hooks.ts index 75ba1ec..96a11f0 100644 --- a/packages/auth-react/src/hooks.ts +++ b/packages/auth-react/src/hooks.ts @@ -14,9 +14,12 @@ import type { } from "./types.js"; export function useAuthSnapshot(): AuthSnapshot { - const { client } = useDiditAuthContext(); + const { client, sessionUnavailable } = useDiditAuthContext(); + const snapshot = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot); - return useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot); + // A client that cannot load the session reads as signed out, so protected screens render their + // guest state instead of waiting on a load that already failed. + return sessionUnavailable && snapshot.status === "loading" ? { status: "signed-out", session: null } : snapshot; } export interface UseAuthResult extends HeadlessSignIn {