From 88e2b4e6666d4e1b6147b4b07f49e2378e3cce98 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 28 Jul 2026 19:59:26 -0400 Subject: [PATCH] refactor(session): move session state into a framework-agnostic store The session state machine lived in AuthProvider, so a Vue or Angular binding would have had to reimplement it and would have drifted on auth state. Move it to src/session as a store exposing getState, subscribe, actions, and destroy, and rebind AuthProvider on top with useSyncExternalStore. The provider's public API is unchanged, so the existing provider tests are the contract. Two behavior changes come with it: - A failed session check no longer calls the logout endpoint. The server has already rejected the session, so it is cleared locally. Previously every anonymous page load fired a DELETE /logout for a session that did not exist. - Reading a previous sign-in goes through a storage port that falls back to memory when there is no localStorage, replacing the usePreviousSignIn hook. That keeps the store safe to create during server-side rendering. The store also guards against a slow session read overwriting a newer one, which React's batching used to hide, and keeps snapshot references stable so useSyncExternalStore does not loop. Phase 1 of #64: the store stays in this repo and unexported until a second binding exists to validate its API. Refs #64 --- .changeset/nine-pans-shake.md | 7 + AGENTS.md | 21 +- eslint.config.mjs | 1 + src/AuthProvider.tsx | 273 ++--------------------- src/hooks/usePreviousSignIn.ts | 31 --- src/session/createAuthSession.ts | 357 +++++++++++++++++++++++++++++++ src/session/storage.ts | 56 +++++ tests/authProvider.test.tsx | 29 ++- tests/authSession.test.ts | 304 ++++++++++++++++++++++++++ tests/sessionStorage.ssr.test.ts | 38 ++++ tests/sessionStorage.test.ts | 59 +++++ tests/usePreviousSignin.test.tsx | 56 ----- 12 files changed, 879 insertions(+), 353 deletions(-) create mode 100644 .changeset/nine-pans-shake.md delete mode 100644 src/hooks/usePreviousSignIn.ts create mode 100644 src/session/createAuthSession.ts create mode 100644 src/session/storage.ts create mode 100644 tests/authSession.test.ts create mode 100644 tests/sessionStorage.ssr.test.ts create mode 100644 tests/sessionStorage.test.ts delete mode 100644 tests/usePreviousSignin.test.tsx diff --git a/.changeset/nine-pans-shake.md b/.changeset/nine-pans-shake.md new file mode 100644 index 0000000..b094efd --- /dev/null +++ b/.changeset/nine-pans-shake.md @@ -0,0 +1,7 @@ +--- +'@seamless-auth/react': patch +--- + +Stop calling the logout endpoint when the session check fails. A failed `/users/me` means the server already considers the session unusable, so the SDK now clears it locally instead of sending a `DELETE /logout` for a session that does not exist. Previously every anonymous page load fired that second request. + +Session state now lives in a framework-agnostic store behind `AuthProvider`, which reads it through `useSyncExternalStore`. The provider's public API is unchanged. Reading a previous sign-in goes through a storage port that falls back to memory when there is no `localStorage`, so the store is safe to create during server-side rendering. diff --git a/AGENTS.md b/AGENTS.md index 3cab325..3a92037 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,11 +144,16 @@ Public API changes should be treated deliberately: The current package is organized around a shared SDK core with optional UI layered on top: +- `src/session/createAuthSession.ts` + - framework-agnostic session store: `getState`, `subscribe`, `actions`, `destroy` + - owns the session state machine, so a non-React binding does not reimplement it + - validates the session with `/users/me` and clears it locally when that fails +- `src/session/storage.ts` + - `SessionStoragePort` plus browser, memory, and default implementations + - the store's only browser dependency, which is what keeps it SSR safe - `src/AuthProvider.tsx` - - owns auth/session state - - exposes the main provider context - - validates the session with `/users/me` - - exposes refresh, login, logout, user deletion, and credential actions + - React binding over the session store, via `useSyncExternalStore` + - exposes the main provider context and holds no session state of its own - `src/client/createSeamlessAuthClient.ts` - shared headless auth client - contains the backend request choreography for login, registration, OTP, magic-link, passkey flows, and credential mutations @@ -173,6 +178,10 @@ Important architectural reality: - the internal-only auth context path is gone - built-in screens now use public primitives instead of hidden refresh helpers +- the session state machine lives in `src/session`, not in the provider. It is + lint-enforced framework agnostic, so keep React and router imports out of it. + This is phase 1 of #64: the store stays in this repo and unexported until a + second binding exists to validate its API - remaining work is mostly docs, examples, and incremental polish rather than major extraction plumbing ## Backend Endpoints Assumed By The SDK @@ -231,7 +240,7 @@ That means future work should usually build on the current public surface rather Bias toward these patterns: - add reusable behavior to the headless client first, then expose it through React hooks or provider helpers as needed -- keep `AuthProvider` as the source of truth for auth/session state +- keep the session store in `src/session` as the source of truth for auth/session state, and keep `AuthProvider` a thin binding over it - use `refreshSession()` when custom flows need to synchronize provider state after a successful auth step - export types intentionally from `src/index.ts` - keep built-in views thin and aligned with public APIs @@ -293,7 +302,7 @@ Avoid these patterns unless the user explicitly asks for them: - exporting unstable internals without documenting them - changing endpoint assumptions without checking the server/api repos - leaving README or repo guidance out of sync with the actual exports -- creating a second source of truth for session state outside `AuthProvider` +- creating a second source of truth for session state outside the session store Commit hygiene, attribution, comments, TODOs, and public-facing-text rules live in Working Standards above. diff --git a/eslint.config.mjs b/eslint.config.mjs index f3ae41b..82571ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -60,6 +60,7 @@ export default [ // shared package for non-React adapters. See #64. files: [ 'src/client/**/*.ts', + 'src/session/**/*.ts', 'src/fetchWithAuth.ts', 'src/scopedRoles.ts', 'src/types.ts', diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 9c3dd00..fb0fcb6 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -5,7 +5,6 @@ */ import { - createSeamlessAuthClient, CurrentUserResult, FinishOAuthLoginInput, LoginStartResult, @@ -20,20 +19,17 @@ import { } from '@/client/createSeamlessAuthClient'; import type { SeamlessAuthResult } from '@/client/result'; import { PasskeyPrfInput } from '@/client/webauthnPrf'; +import { createAuthSession } from '@/session/createAuthSession'; import { Credential, Organization, User } from '@/types'; import React, { createContext, ReactNode, - useCallback, useContext, useEffect, useMemo, - useState, + useSyncExternalStore, } from 'react'; -import { usePreviousSignIn } from './hooks/usePreviousSignIn'; -import { hasScopedRole as rolesGrantScopedAccess } from './scopedRoles'; - export interface AuthContextType { user: User | null; logout: () => Promise>; @@ -101,259 +97,36 @@ export const AuthProvider: React.FC = ({ apiHost, autoDetectPreviousSignin = true, }) => { - const [user, setUser] = useState(null); - const [credentials, setCredentials] = useState([]); - const [organizations, setOrganizations] = useState([]); - const [activeOrganization, setActiveOrganization] = useState(null); - const [stepUpStatus, setStepUpStatus] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [loading, setLoading] = useState(true); - const { hasSignedInBefore, markSignedIn } = usePreviousSignIn(); - - const authClient = useMemo( + const session = useMemo( () => - createSeamlessAuthClient({ + createAuthSession({ apiHost, + detectPreviousSignIn: autoDetectPreviousSignin, }), - [apiHost] + [apiHost, autoDetectPreviousSignin] ); - const login = (identifier: string, passkeyAvailable: boolean) => - authClient.login({ identifier, passkeyAvailable }); - - const handlePasskeyLogin = async () => { - const result = await authClient.loginWithPasskey(); - - if (!result.error) { - await validateToken(); - } - - return result; - }; - - const resetAuthState = useCallback(() => { - setIsAuthenticated(false); - setUser(null); - setCredentials([]); - setOrganizations([]); - setActiveOrganization(null); - setStepUpStatus(null); - }, []); - - const logout = useCallback(async () => { - // The client reports failures through its result, so there is nothing to - // catch. The finally is deliberate: local auth state has to be cleared even - // when the server call fails, otherwise the UI keeps presenting a signed-in - // user whose session is already gone. - try { - return await authClient.logout(); - } finally { - resetAuthState(); - } - }, [authClient, resetAuthState]); - - const logoutAllSessions = useCallback(async () => { - // The client reports failures through its result, so there is nothing to - // catch. The finally is deliberate: local auth state has to be cleared even - // when the server call fails, otherwise the UI keeps presenting a signed-in - // user whose session is already gone. - try { - return await authClient.logoutAllSessions(); - } finally { - resetAuthState(); - } - }, [authClient, resetAuthState]); - - const deleteUser = async () => { - const result = await authClient.deleteUser(); - - if (!result.error) { - resetAuthState(); - } - - return result; - }; - - const hasRole = (role: string) => user?.roles?.includes(role); - const hasScopedRole = (role: string | string[]) => - user ? rolesGrantScopedAccess(user.roles, role) : undefined; - - const validateToken = useCallback(async () => { - setLoading(true); - - const result = await authClient.getCurrentUser(); - - if (result.error) { - // An unusable session is cleared rather than left half-applied. - await logout(); - setLoading(false); - return result; - } - - setUser(result.data.user); - setCredentials(result.data.credentials ?? []); - setOrganizations(result.data.organizations ?? []); - setActiveOrganization(result.data.activeOrganization ?? null); - setIsAuthenticated(true); - setLoading(false); - - return result; - }, [authClient, logout]); - - const updateCredential = async (credential: Credential) => { - const { data, error } = await authClient.updateCredential({ - friendlyName: credential.friendlyName, - id: credential.id, - }); - - if (error) { - return { data: null, error }; - } - - const updatedCredential = data.credential; - - setCredentials(currentCredentials => - currentCredentials.map(currentCredential => - currentCredential.id === updatedCredential.id - ? { ...currentCredential, ...updatedCredential } - : currentCredential - ) - ); - - return { data: updatedCredential, error: null }; - }; - - const deleteCredential = async (credentialId: string) => { - const result = await authClient.deleteCredential(credentialId); - - if (!result.error) { - setCredentials(currentCredentials => - currentCredentials.filter(credential => credential.id !== credentialId) - ); - } - - return result; - }; - - const switchOrganization = async (organizationId: string) => { - const result = await authClient.switchOrganization(organizationId); - - if (!result.error) { - await validateToken(); - } - - return result; - }; - - const listOAuthProviders = () => authClient.listOAuthProviders(); - - const startOAuthLogin = (input: StartOAuthLoginInput) => - authClient.startOAuthLogin(input); - - const finishOAuthLogin = async (input: FinishOAuthLoginInput) => { - const result = await authClient.finishOAuthLogin(input); - - if (!result.error) { - await validateToken(); - } - - return result; - }; - - const refreshStepUpStatus = useCallback(async () => { - const result = await authClient.getStepUpStatus(); - - setStepUpStatus(result.error ? null : result.data); - - return result; - }, [authClient]); - - const verifyStepUpWithPasskey = useCallback(async () => { - const result = await authClient.verifyStepUpWithPasskey(); - - if (!result.error) { - setStepUpStatus(result.data); - } - - return result; - }, [authClient]); - - const verifyStepUpWithPasskeyPrf = useCallback( - async (input: PasskeyPrfInput) => { - const result = await authClient.verifyStepUpWithPasskeyPrf(input); - - if (!result.error) { - setStepUpStatus({ - fresh: result.data.fresh, - method: result.data.method, - verifiedAt: result.data.verifiedAt, - expiresAt: result.data.expiresAt, - maxAgeSeconds: result.data.maxAgeSeconds, - }); - } - - return result; - }, - [authClient] - ); - - const verifyStepUpWithTotp = useCallback( - async (code: string) => { - const result = await authClient.verifyStepUpWithTotp(code); - - if (!result.error) { - setStepUpStatus(result.data); - } - - return result; - }, - [authClient] + // The store is the source of truth; React only reads snapshots from it. The + // server snapshot is the same call because the store reaches browser storage + // through a port that falls back to memory when there is none. + const state = useSyncExternalStore( + session.subscribe, + session.getState, + session.getState ); useEffect(() => { - void validateToken(); - }, [validateToken]); + void session.actions.refreshSession(); - useEffect(() => { - if (user && isAuthenticated) { - markSignedIn(); - } - }, [user, isAuthenticated, markSignedIn]); + return () => { + session.destroy(); + }; + }, [session]); - return ( - - {children} - + const value = useMemo( + () => ({ ...state, ...session.actions, apiHost }), + [state, session, apiHost] ); + + return {children}; }; diff --git a/src/hooks/usePreviousSignIn.ts b/src/hooks/usePreviousSignIn.ts deleted file mode 100644 index 9347b18..0000000 --- a/src/hooks/usePreviousSignIn.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import { useEffect, useState } from 'react'; - -export function usePreviousSignIn(storageKey = 'seamlessauth_seen') { - const [hasSignedInBefore, setHasSignedInBefore] = useState(false); - - useEffect(() => { - try { - const seen = localStorage.getItem(storageKey); - if (seen === 'true') setHasSignedInBefore(true); - } catch { - // silent fail if storage not available - } - }, [storageKey]); - - const markSignedIn = () => { - try { - localStorage.setItem(storageKey, 'true'); - setHasSignedInBefore(true); - } catch { - // ignore storage errors (e.g. private mode) - } - }; - - return { hasSignedInBefore, markSignedIn }; -} diff --git a/src/session/createAuthSession.ts b/src/session/createAuthSession.ts new file mode 100644 index 0000000..80c1544 --- /dev/null +++ b/src/session/createAuthSession.ts @@ -0,0 +1,357 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + createSeamlessAuthClient, + CurrentUserResult, + FinishOAuthLoginInput, + LoginStartResult, + MessageResult, + OAuthProvidersResult, + OrganizationSwitchResult, + PasskeyLoginData, + StartOAuthLoginInput, + StartOAuthLoginResult, + StepUpPrfData, + StepUpStatus, +} from '../client/createSeamlessAuthClient'; +import type { SeamlessAuthResult } from '../client/result'; +import { PasskeyPrfInput } from '../client/webauthnPrf'; +import { hasScopedRole as rolesGrantScopedAccess } from '../scopedRoles'; +import { Credential, Organization, User } from '../types'; +import { createDefaultStorage, SessionStoragePort } from './storage'; + +const PREVIOUS_SIGN_IN_KEY = 'seamlessauth_seen'; + +/** Everything a UI binding renders from. Replaced wholesale on every change. */ +export interface AuthSessionState { + user: User | null; + credentials: Credential[]; + organizations: Organization[]; + activeOrganization: Organization | null; + stepUpStatus: StepUpStatus | null; + isAuthenticated: boolean; + loading: boolean; + hasSignedInBefore: boolean; +} + +export interface AuthSessionActions { + login: ( + identifier: string, + passkeyAvailable: boolean + ) => Promise>; + handlePasskeyLogin: () => Promise>; + refreshSession: () => Promise>; + logout: () => Promise>; + logoutAllSessions: () => Promise>; + deleteUser: () => Promise>; + updateCredential: (credential: Credential) => Promise>; + deleteCredential: (credentialId: string) => Promise>; + switchOrganization: ( + organizationId: string + ) => Promise>; + listOAuthProviders: () => Promise>; + startOAuthLogin: ( + input: StartOAuthLoginInput + ) => Promise>; + finishOAuthLogin: ( + input: FinishOAuthLoginInput + ) => Promise>; + refreshStepUpStatus: () => Promise>; + verifyStepUpWithPasskey: () => Promise>; + verifyStepUpWithPasskeyPrf: ( + input: PasskeyPrfInput + ) => Promise>; + verifyStepUpWithTotp: (code: string) => Promise>; + hasRole: (role: string) => boolean | undefined; + hasScopedRole: (role: string | string[]) => boolean | undefined; + markSignedIn: () => void; +} + +/** + * Framework-agnostic session store. + * + * `getState` and `subscribe` are the shape React's `useSyncExternalStore` wants, + * and they adapt directly to a Vue ref or an Angular observable, so bindings stay + * thin instead of each reimplementing this state machine. + */ +export interface AuthSession { + getState: () => AuthSessionState; + subscribe: (listener: () => void) => () => void; + actions: AuthSessionActions; + destroy: () => void; +} + +export interface AuthSessionOptions { + apiHost: string; + storage?: SessionStoragePort; + /** + * When false, a previous sign-in is still recorded but never surfaced, so a UI + * cannot branch on it. + */ + detectPreviousSignIn?: boolean; +} + +const SIGNED_OUT = { + user: null, + credentials: [], + organizations: [], + activeOrganization: null, + stepUpStatus: null, + isAuthenticated: false, +} satisfies Partial; + +export function createAuthSession(options: AuthSessionOptions): AuthSession { + const { apiHost, detectPreviousSignIn = true } = options; + const client = createSeamlessAuthClient({ apiHost }); + const storage = options.storage ?? createDefaultStorage(); + const listeners = new Set<() => void>(); + + let destroyed = false; + // Guards against a slow earlier session read overwriting a newer one, which + // React's batching used to hide. + let refreshGeneration = 0; + + let state: AuthSessionState = { + ...SIGNED_OUT, + loading: true, + hasSignedInBefore: + detectPreviousSignIn && storage.get(PREVIOUS_SIGN_IN_KEY) === 'true', + }; + + /** + * `getState` has to return the same reference until something actually + * changes, otherwise `useSyncExternalStore` re-renders forever. + */ + function setState(patch: Partial) { + const next = { ...state, ...patch }; + const changed = (Object.keys(patch) as (keyof AuthSessionState)[]).some( + key => state[key] !== next[key] + ); + + if (!changed) { + return; + } + + state = next; + listeners.forEach(listener => listener()); + } + + function markSignedIn() { + storage.set(PREVIOUS_SIGN_IN_KEY, 'true'); + + if (detectPreviousSignIn) { + setState({ hasSignedInBefore: true }); + } + } + + /** Drop local session state without calling the server. */ + function clearSession() { + setState(SIGNED_OUT); + } + + async function logout() { + // The client reports failures through its result, so there is nothing to + // catch. The finally is deliberate: local auth state has to be cleared even + // when the server call fails, otherwise the UI keeps presenting a signed-in + // user whose session is already gone. + try { + return await client.logout(); + } finally { + clearSession(); + } + } + + async function logoutAllSessions() { + try { + return await client.logoutAllSessions(); + } finally { + clearSession(); + } + } + + async function refreshSession() { + const generation = ++refreshGeneration; + + setState({ loading: true }); + + const result = await client.getCurrentUser(); + + if (destroyed || generation !== refreshGeneration) { + return result; + } + + if (result.error) { + // The session is unusable, so it is dropped locally. Calling the logout + // endpoint here would fire a request for a session the server has already + // rejected, on every anonymous page load. + clearSession(); + setState({ loading: false }); + + return result; + } + + setState({ + user: result.data.user, + credentials: result.data.credentials ?? [], + organizations: result.data.organizations ?? [], + activeOrganization: result.data.activeOrganization ?? null, + isAuthenticated: true, + loading: false, + }); + + if (!state.hasSignedInBefore) { + markSignedIn(); + } + + return result; + } + + async function refreshAfter( + run: () => Promise> + ): Promise> { + const result = await run(); + + if (!result.error) { + await refreshSession(); + } + + return result; + } + + const actions: AuthSessionActions = { + login: (identifier, passkeyAvailable) => + client.login({ identifier, passkeyAvailable }), + + handlePasskeyLogin: () => refreshAfter(() => client.loginWithPasskey()), + + refreshSession, + logout, + logoutAllSessions, + + deleteUser: async () => { + const result = await client.deleteUser(); + + if (!result.error) { + clearSession(); + } + + return result; + }, + + updateCredential: async credential => { + const { data, error } = await client.updateCredential({ + friendlyName: credential.friendlyName, + id: credential.id, + }); + + if (error) { + return { data: null, error }; + } + + const updated = data.credential; + + setState({ + credentials: state.credentials.map(current => + current.id === updated.id ? { ...current, ...updated } : current + ), + }); + + // Callers get the credential itself rather than the response wrapper. + return { data: updated, error: null }; + }, + + deleteCredential: async credentialId => { + const result = await client.deleteCredential(credentialId); + + if (!result.error) { + setState({ + credentials: state.credentials.filter( + credential => credential.id !== credentialId + ), + }); + } + + return result; + }, + + switchOrganization: organizationId => + refreshAfter(() => client.switchOrganization(organizationId)), + + listOAuthProviders: () => client.listOAuthProviders(), + startOAuthLogin: input => client.startOAuthLogin(input), + finishOAuthLogin: input => refreshAfter(() => client.finishOAuthLogin(input)), + + refreshStepUpStatus: async () => { + const result = await client.getStepUpStatus(); + + // A status that cannot be read is not a stale status: it is no status. + setState({ stepUpStatus: result.error ? null : result.data }); + + return result; + }, + + verifyStepUpWithPasskey: async () => { + const result = await client.verifyStepUpWithPasskey(); + + if (!result.error) { + setState({ stepUpStatus: result.data }); + } + + return result; + }, + + verifyStepUpWithPasskeyPrf: async input => { + const result = await client.verifyStepUpWithPasskeyPrf(input); + + if (!result.error) { + setState({ + stepUpStatus: { + fresh: result.data.fresh, + method: result.data.method, + verifiedAt: result.data.verifiedAt, + expiresAt: result.data.expiresAt, + maxAgeSeconds: result.data.maxAgeSeconds, + }, + }); + } + + return result; + }, + + verifyStepUpWithTotp: async code => { + const result = await client.verifyStepUpWithTotp(code); + + if (!result.error) { + setState({ stepUpStatus: result.data }); + } + + return result; + }, + + hasRole: role => state.user?.roles?.includes(role), + hasScopedRole: role => + state.user ? rolesGrantScopedAccess(state.user.roles, role) : undefined, + + markSignedIn, + }; + + return { + getState: () => state, + subscribe: listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + actions, + destroy: () => { + destroyed = true; + listeners.clear(); + }, + }; +} diff --git a/src/session/storage.ts b/src/session/storage.ts new file mode 100644 index 0000000..a491a62 --- /dev/null +++ b/src/session/storage.ts @@ -0,0 +1,56 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +/** + * The session store's only browser dependency, expressed as a port so the store + * itself stays framework and environment agnostic. Implementations never throw: + * private browsing and disabled storage are normal, and neither is a reason to + * fail an auth flow. + */ +export interface SessionStoragePort { + get(key: string): string | null; + set(key: string, value: string): void; +} + +export function createMemoryStorage(): SessionStoragePort { + const values = new Map(); + + return { + get: key => values.get(key) ?? null, + set: (key, value) => { + values.set(key, value); + }, + }; +} + +export function createBrowserStorage(): SessionStoragePort { + return { + get: key => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set: (key, value) => { + try { + localStorage.setItem(key, value); + } catch { + // Storage can be unavailable, for example in private mode. + } + }, + }; +} + +/** + * Browser storage where there is a `localStorage`, memory otherwise. The memory + * fallback is what keeps the store usable during server-side rendering. + */ +export function createDefaultStorage(): SessionStoragePort { + return typeof localStorage === 'undefined' + ? createMemoryStorage() + : createBrowserStorage(); +} diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index 6a315b4..6ac784a 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -170,12 +170,15 @@ describe('AuthProvider', () => { expect(screen.getByTestId('hasScopedRoleAdminRead')).toHaveTextContent('true'); }); - it('logs out if token validation fails (bad response)', async () => { - // Both calls need a response: the failed /users/me, and the logout that - // follows it. - mockFetchWithAuthImpl - .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({}) } as any) - .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any); + // A rejected session read means the server already considers the session + // unusable, so the session is dropped locally. Calling the logout endpoint + // here would fire a request on every anonymous page load. + it('clears the session without calling logout when validation fails', async () => { + mockFetchWithAuthImpl.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({}), + } as any); await act(async () => { render( @@ -188,12 +191,16 @@ describe('AuthProvider', () => { await waitFor(() => { expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); }); + + expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(1); + expect(mockFetchWithAuthImpl).not.toHaveBeenCalledWith( + '/logout', + expect.objectContaining({ method: 'DELETE' }) + ); }); - it('logs out if token validation throws', async () => { - mockFetchWithAuthImpl - .mockRejectedValueOnce(new Error('network down')) - .mockResolvedValueOnce({ ok: true, json: async () => ({}) } as any); + it('clears the session without calling logout when validation throws', async () => { + mockFetchWithAuthImpl.mockRejectedValueOnce(new Error('network down')); await act(async () => { render( @@ -206,6 +213,8 @@ describe('AuthProvider', () => { await waitFor(() => { expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); }); + + expect(mockFetchWithAuthImpl).toHaveBeenCalledTimes(1); }); it('refreshes step-up status on demand', async () => { diff --git a/tests/authSession.test.ts b/tests/authSession.test.ts new file mode 100644 index 0000000..a077d57 --- /dev/null +++ b/tests/authSession.test.ts @@ -0,0 +1,304 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { createAuthSession } from '../src/session/createAuthSession'; +import { createMemoryStorage, SessionStoragePort } from '../src/session/storage'; +import { createFetchWithAuth } from '../src/fetchWithAuth'; + +jest.mock('../src/fetchWithAuth'); + +const mockFetchWithAuth = jest.fn(); + +(createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); + +const apiHost = 'https://api.example.com'; + +const user = { id: '1', email: 'test@example.com', phone: '', roles: ['admin'] }; + +const okResponse = (body: unknown = {}) => + ({ ok: true, json: async () => body }) as unknown as Response; + +const failedResponse = (status = 401, body: unknown = {}) => + ({ ok: false, status, json: async () => body }) as unknown as Response; + +const buildSession = (storage: SessionStoragePort = createMemoryStorage()) => + createAuthSession({ apiHost, storage }); + +describe('createAuthSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + (createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth); + }); + + it('starts signed out and loading, before anything is requested', () => { + const session = buildSession(); + + expect(session.getState()).toEqual({ + user: null, + credentials: [], + organizations: [], + activeOrganization: null, + stepUpStatus: null, + isAuthenticated: false, + loading: true, + hasSignedInBefore: false, + }); + expect(mockFetchWithAuth).not.toHaveBeenCalled(); + }); + + it('populates the session and notifies subscribers', async () => { + mockFetchWithAuth.mockResolvedValueOnce( + okResponse({ user, credentials: [{ id: 'cred-1' }] }) + ); + + const session = buildSession(); + const listener = jest.fn(); + session.subscribe(listener); + + await session.actions.refreshSession(); + + expect(session.getState()).toMatchObject({ + user, + credentials: [{ id: 'cred-1' }], + isAuthenticated: true, + loading: false, + }); + expect(listener).toHaveBeenCalled(); + }); + + it('returns the same snapshot reference until something changes', async () => { + mockFetchWithAuth.mockResolvedValue(okResponse({ user, credentials: [] })); + + const session = buildSession(); + await session.actions.refreshSession(); + + const snapshot = session.getState(); + + // A no-op transition must not produce a new object, or useSyncExternalStore + // re-renders forever. + session.actions.markSignedIn(); + + expect(session.getState()).toBe(snapshot); + }); + + it('clears the session locally instead of calling logout when validation fails', async () => { + mockFetchWithAuth.mockResolvedValueOnce(failedResponse()); + + const session = buildSession(); + const { error } = await session.actions.refreshSession(); + + expect(error).not.toBeNull(); + expect(session.getState()).toMatchObject({ + user: null, + isAuthenticated: false, + loading: false, + }); + expect(mockFetchWithAuth).toHaveBeenCalledTimes(1); + }); + + it('clears local state even when the logout request fails', async () => { + mockFetchWithAuth.mockResolvedValueOnce(okResponse({ user, credentials: [] })); + + const session = buildSession(); + await session.actions.refreshSession(); + + mockFetchWithAuth.mockResolvedValueOnce(failedResponse(500)); + + const { error } = await session.actions.logout(); + + expect(error).not.toBeNull(); + expect(session.getState()).toMatchObject({ user: null, isAuthenticated: false }); + }); + + it('ignores a slow session read that a newer one has already superseded', async () => { + let resolveFirst: (response: Response) => void = () => {}; + + mockFetchWithAuth + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }) + ) + .mockResolvedValueOnce( + okResponse({ user: { ...user, email: 'newest@example.com' }, credentials: [] }) + ); + + const session = buildSession(); + const stale = session.actions.refreshSession(); + await session.actions.refreshSession(); + + resolveFirst( + okResponse({ user: { ...user, email: 'stale@example.com' }, credentials: [] }) + ); + await stale; + + expect(session.getState().user?.email).toBe('newest@example.com'); + }); + + it('drops updates once destroyed', async () => { + mockFetchWithAuth.mockResolvedValueOnce(okResponse({ user, credentials: [] })); + + const session = buildSession(); + const listener = jest.fn(); + session.subscribe(listener); + + const pending = session.actions.refreshSession(); + session.destroy(); + await pending; + + expect(session.getState().isAuthenticated).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + describe('previous sign-in', () => { + it('reads the stored flag when the session is created', () => { + const storage = createMemoryStorage(); + storage.set('seamlessauth_seen', 'true'); + + expect(buildSession(storage).getState().hasSignedInBefore).toBe(true); + }); + + it('records a sign-in through the storage port', async () => { + const storage = createMemoryStorage(); + mockFetchWithAuth.mockResolvedValueOnce(okResponse({ user, credentials: [] })); + + const session = buildSession(storage); + await session.actions.refreshSession(); + + expect(storage.get('seamlessauth_seen')).toBe('true'); + expect(session.getState().hasSignedInBefore).toBe(true); + }); + + it('records but never surfaces the flag when detection is off', async () => { + const storage = createMemoryStorage(); + const session = createAuthSession({ + apiHost, + storage, + detectPreviousSignIn: false, + }); + + session.actions.markSignedIn(); + + expect(storage.get('seamlessauth_seen')).toBe('true'); + expect(session.getState().hasSignedInBefore).toBe(false); + }); + + it('surfaces a storage port that breaks the never-throw contract', () => { + const storage: SessionStoragePort = { + get: () => { + throw new Error('denied'); + }, + set: () => { + throw new Error('denied'); + }, + }; + + // The browser port swallows these, so a port that throws is a caller bug + // rather than something the store hides. What matters is that it surfaces + // instead of corrupting session state. + expect(() => buildSession(storage)).toThrow('denied'); + }); + }); + + describe('step-up', () => { + it('clears the status when it cannot be loaded', async () => { + mockFetchWithAuth.mockResolvedValueOnce( + okResponse({ fresh: true, method: 'totp', maxAgeSeconds: 300 }) + ); + + const session = buildSession(); + await session.actions.refreshStepUpStatus(); + expect(session.getState().stepUpStatus).not.toBeNull(); + + mockFetchWithAuth.mockResolvedValueOnce(failedResponse(500)); + await session.actions.refreshStepUpStatus(); + + expect(session.getState().stepUpStatus).toBeNull(); + }); + + it('leaves the status untouched when a verification fails', async () => { + mockFetchWithAuth.mockResolvedValueOnce( + okResponse({ + fresh: true, + method: 'totp', + verifiedAt: null, + expiresAt: null, + maxAgeSeconds: 300, + }) + ); + + const session = buildSession(); + await session.actions.refreshStepUpStatus(); + + const before = session.getState().stepUpStatus; + + mockFetchWithAuth.mockResolvedValueOnce(failedResponse(400)); + await session.actions.verifyStepUpWithTotp('000000'); + + expect(session.getState().stepUpStatus).toBe(before); + }); + }); + + describe('credentials', () => { + const loadWithCredential = async () => { + mockFetchWithAuth.mockResolvedValueOnce( + okResponse({ + user, + credentials: [{ id: 'cred-1', friendlyName: 'Old passkey' }], + }) + ); + + const session = buildSession(); + await session.actions.refreshSession(); + + return session; + }; + + it('returns the credential itself rather than the response wrapper', async () => { + const session = await loadWithCredential(); + + mockFetchWithAuth.mockResolvedValueOnce( + okResponse({ credential: { id: 'cred-1', friendlyName: 'Renamed' } }) + ); + + const { data } = await session.actions.updateCredential({ + id: 'cred-1', + friendlyName: 'Renamed', + } as never); + + expect(data).toEqual({ id: 'cred-1', friendlyName: 'Renamed' }); + expect(session.getState().credentials[0]).toMatchObject({ + friendlyName: 'Renamed', + }); + }); + + it('removes a deleted credential from state', async () => { + const session = await loadWithCredential(); + + mockFetchWithAuth.mockResolvedValueOnce(okResponse({ message: 'Success' })); + await session.actions.deleteCredential('cred-1'); + + expect(session.getState().credentials).toEqual([]); + }); + }); + + describe('role checks', () => { + it('reports roles from the loaded user and undefined without one', async () => { + const session = buildSession(); + + expect(session.actions.hasRole('admin')).toBeUndefined(); + expect(session.actions.hasScopedRole('admin:read')).toBeUndefined(); + + mockFetchWithAuth.mockResolvedValueOnce(okResponse({ user, credentials: [] })); + await session.actions.refreshSession(); + + expect(session.actions.hasRole('admin')).toBe(true); + expect(session.actions.hasRole('owner')).toBe(false); + expect(session.actions.hasScopedRole('admin:read')).toBe(true); + }); + }); +}); diff --git a/tests/sessionStorage.ssr.test.ts b/tests/sessionStorage.ssr.test.ts new file mode 100644 index 0000000..874445a --- /dev/null +++ b/tests/sessionStorage.ssr.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + * + * @jest-environment node + */ + +import { createAuthSession } from '@/session/createAuthSession'; +import { createDefaultStorage } from '@/session/storage'; + +jest.mock('@/fetchWithAuth', () => ({ + createFetchWithAuth: () => jest.fn(), +})); + +// Runs under the node environment, where `localStorage` genuinely does not +// exist. The memory fallback is what makes the store safe to create during +// server-side rendering. +describe('session storage in a server environment', () => { + it('falls back to memory storage without a localStorage global', () => { + expect(typeof localStorage).toBe('undefined'); + + const storage = createDefaultStorage(); + storage.set('seamlessauth_seen', 'true'); + + expect(storage.get('seamlessauth_seen')).toBe('true'); + }); + + it('creates a session without touching browser APIs', () => { + const session = createAuthSession({ apiHost: 'https://api.example.com' }); + + expect(session.getState()).toMatchObject({ + isAuthenticated: false, + loading: true, + hasSignedInBefore: false, + }); + }); +}); diff --git a/tests/sessionStorage.test.ts b/tests/sessionStorage.test.ts new file mode 100644 index 0000000..28d2045 --- /dev/null +++ b/tests/sessionStorage.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + createBrowserStorage, + createDefaultStorage, + createMemoryStorage, +} from '../src/session/storage'; + +describe('session storage ports', () => { + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it('reads back what it stored, in memory', () => { + const storage = createMemoryStorage(); + + expect(storage.get('seamlessauth_seen')).toBeNull(); + + storage.set('seamlessauth_seen', 'true'); + + expect(storage.get('seamlessauth_seen')).toBe('true'); + }); + + it('reads and writes localStorage in the browser', () => { + const storage = createBrowserStorage(); + + storage.set('seamlessauth_seen', 'true'); + + expect(localStorage.getItem('seamlessauth_seen')).toBe('true'); + expect(storage.get('seamlessauth_seen')).toBe('true'); + }); + + // Private mode and blocked storage are normal, and neither is a reason to fail + // an auth flow. + it('treats unavailable browser storage as an absent value', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied'); + }); + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('denied'); + }); + + const storage = createBrowserStorage(); + + expect(() => storage.set('seamlessauth_seen', 'true')).not.toThrow(); + expect(storage.get('seamlessauth_seen')).toBeNull(); + }); + + it('defaults to browser storage where localStorage exists', () => { + createDefaultStorage().set('seamlessauth_seen', 'true'); + + expect(localStorage.getItem('seamlessauth_seen')).toBe('true'); + }); +}); diff --git a/tests/usePreviousSignin.test.tsx b/tests/usePreviousSignin.test.tsx deleted file mode 100644 index a7e9887..0000000 --- a/tests/usePreviousSignin.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright © 2026 Fells Code, LLC - * Licensed under the GNU Affero General Public License v3.0 - * See LICENSE file in the project root for full license information - */ - -import { renderHook, act } from '@testing-library/react'; -import { usePreviousSignIn } from '../src/hooks/usePreviousSignIn'; - -describe('usePreviousSignIn', () => { - beforeEach(() => { - jest.restoreAllMocks(); - localStorage.clear(); - }); - - it('returns false by default when no flag is stored', () => { - const { result } = renderHook(() => usePreviousSignIn()); - expect(result.current.hasSignedInBefore).toBe(false); - }); - - it('returns true if localStorage contains the flag', () => { - localStorage.setItem('seamlessauth_seen', 'true'); - const { result } = renderHook(() => usePreviousSignIn()); - expect(result.current.hasSignedInBefore).toBe(true); - }); - - it('marks signed in and persists to localStorage', () => { - const { result } = renderHook(() => usePreviousSignIn()); - expect(result.current.hasSignedInBefore).toBe(false); - - act(() => { - result.current.markSignedIn(); - }); - - expect(result.current.hasSignedInBefore).toBe(true); - expect(localStorage.getItem('seamlessauth_seen')).toBe('true'); - }); - - it('handles storage errors gracefully', () => { - const setItemSpy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('Storage disabled'); - }); - - const { result } = renderHook(() => usePreviousSignIn()); - expect(() => result.current.markSignedIn()).not.toThrow(); - setItemSpy.mockRestore(); - }); - - it('supports a custom storage key', () => { - const { result } = renderHook(() => usePreviousSignIn('custom_key')); - act(() => { - result.current.markSignedIn(); - }); - expect(localStorage.getItem('custom_key')).toBe('true'); - }); -});