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 f70f3f1..ab20693 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,11 +145,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 @@ -174,6 +179,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 @@ -232,7 +241,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 @@ -294,7 +303,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'); - }); -});