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
7 changes: 7 additions & 0 deletions .changeset/nine-pans-shake.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 15 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
273 changes: 23 additions & 250 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

import {
createSeamlessAuthClient,
CurrentUserResult,
FinishOAuthLoginInput,
LoginStartResult,
Expand All @@ -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<SeamlessAuthResult<MessageResult>>;
Expand Down Expand Up @@ -101,259 +97,36 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
apiHost,
autoDetectPreviousSignin = true,
}) => {
const [user, setUser] = useState<User | null>(null);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [activeOrganization, setActiveOrganization] = useState<Organization | null>(null);
const [stepUpStatus, setStepUpStatus] = useState<StepUpStatus | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(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 (
<AuthContext.Provider
value={{
user,
logout,
logoutAllSessions,
refreshSession: validateToken,
loading,
deleteUser,
isAuthenticated,
hasRole,
hasScopedRole,
apiHost,
markSignedIn,
hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false,
credentials,
organizations,
activeOrganization,
switchOrganization,
listOAuthProviders,
startOAuthLogin,
finishOAuthLogin,
stepUpStatus,
updateCredential,
deleteCredential,
login,
handlePasskeyLogin,
refreshStepUpStatus,
verifyStepUpWithPasskey,
verifyStepUpWithPasskeyPrf,
verifyStepUpWithTotp,
}}
>
{children}
</AuthContext.Provider>
const value = useMemo(
() => ({ ...state, ...session.actions, apiHost }),
[state, session, apiHost]
);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
Loading
Loading