Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/auth-react/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: "jsdom",
setupFiles: ["<rootDir>/jest.setup.cjs"],
roots: ["<rootDir>/src"],
testMatch: ["**/__tests__/**/*.test.ts?(x)"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.test.json" }]
},
moduleNameMapper: {
"^react-dom/server$": "<rootDir>/../../node_modules/react-dom/server.node.js",
"^(\\.{1,2}/.*)\\.js$": "$1"
}
};
5 changes: 5 additions & 0 deletions packages/auth-react/jest.setup.cjs
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions packages/auth-react/src/__tests__/DiditAuth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
111 changes: 111 additions & 0 deletions packages/auth-react/src/__tests__/core-scopes.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
55 changes: 54 additions & 1 deletion packages/auth-react/src/__tests__/provider.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,6 +45,57 @@ describe("<DiditAuthProvider>", () => {
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(
<DiditAuthProvider client={failing}>
<SignedIn>member area</SignedIn>
<SignedOut>please sign in</SignedOut>
</DiditAuthProvider>
);

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 = () => <span id="language">{useDiditAuthContext().locale}</span>;

Object.defineProperty(globalThis, "navigator", { value: undefined, configurable: true });
const html = renderToString(
<DiditAuthProvider client={client}>
<Language />
</DiditAuthProvider>
);
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,
<DiditAuthProvider client={client}>
<Language />
</DiditAuthProvider>
);
});

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();

Expand Down
29 changes: 25 additions & 4 deletions packages/auth-react/src/context.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<DiditAuthContextValue | null>(null);
Expand All @@ -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 (
Expand Down
51 changes: 47 additions & 4 deletions packages/auth-react/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -39,11 +50,30 @@ export function createCoreAuthClient(options: CoreClientOptions): AuthClient {
const store = createAuthStore({ status: "loading", session: null });
let core: ReturnType<typeof createDiditAuth> | undefined;
let loading: Promise<void> | 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;
}
Expand All @@ -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.");
});

Expand Down
7 changes: 5 additions & 2 deletions packages/auth-react/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading