@startsimpli/auth
v0.4.25
Published
Shared authentication package for StartSimpli Next.js apps
Maintainers
Readme
@startsimpli/auth
Shared authentication for every StartSimpli frontend. JWT lifecycle, React
context, an authFetch with transparent 401-retry, Next.js server helpers,
session-storage adapters for web and React Native, and an in-memory mock
backend for tests/demos.
Drives auth in raise-simpli, market-simpli, trade-simpli, vault-web,
and the examples/mobile-rn Expo demo on the in-progress
react-native-enablement branch.
Subpath exports
The package ships TypeScript source — consumers bundle it directly. The
package.json exports map is the contract:
| Import | Use from | Notes |
|---|---|---|
| @startsimpli/auth | anywhere (client-safe) | Re-exports ./client + ./types + ./utils (no next/headers). |
| @startsimpli/auth/client | browser / RN | AuthProvider, useAuth, authFetch, session-storage factories, mock backend. |
| @startsimpli/auth/server | Next.js server | getServerSession, requireAuth, createAuthMiddleware, withAuth, withRole, getTokenFromRequest. Uses next/headers — never import from a client component. |
| @startsimpli/auth/token | React Native | DOM-free TokenAuthClient + SecureTokenStorage (Keychain/Keystore via expo-secure-store). |
| @startsimpli/auth/components | browser | GoogleSignInButton, OAuthCallback, useOAuthCallback, OAuthConnectionCard. |
| @startsimpli/auth/email | server | createEmailService (Resend, optional peer). |
| @startsimpli/auth/types | anywhere | Shared types — Session, AuthUser, AuthConfig, CompanyRole. |
The native/web split for storage is resolved by Metro file extensions (
secure-session-storage.native.ts/secure-token-storage.native.ts), not a./nativesubpath. Import the samecreateSecureSessionStorage/SecureTokenStoragefrom@startsimpli/auth/client(or/token) and the bundler picks the right file.expo-secure-storeis an optional peer; when the native module is missing, both adapters degrade to in-memory storage instead of crashing.
Quick start (Next.js)
// app/layout.tsx
'use client';
import { AuthProvider } from '@startsimpli/auth/client';
export default function RootLayout({ children }) {
return (
<html>
<body>
<AuthProvider
config={{
apiBaseUrl: process.env.NEXT_PUBLIC_API_URL!,
loginPath: '/auth/signin',
}}
>
{children}
</AuthProvider>
</body>
</html>
);
}// any client component
'use client';
import { useAuth } from '@startsimpli/auth/client';
export function Header() {
const { user, isAuthenticated, logout } = useAuth();
if (!isAuthenticated) return null;
return (
<div>
Hi {user!.firstName} · <button onClick={logout}>Sign out</button>
</div>
);
}authFetch — token attach + 401-retry + base-URL resolution
authFetch is the only HTTP helper every app should use for authenticated
calls. It:
- Pulls the current access token from storage (see below) and attaches
Authorization: Bearer <token>if no header is set already. - Resolves relative URLs against
NEXT_PUBLIC_API_URL(orNEXT_PUBLIC_API_BASE_URL) viaresolveAuthUrl. - Sets
credentials: 'include'by default so the refresh cookie travels. - On
401, callsrefreshAccessToken()once (concurrent 401s share one refresh promise) and retries the original request with the new token. - If the refresh fails or the retried request is still
401, clears the stored token and firesnotifySessionExpired()— which calls the callback registered viasetOnSessionExpired(wired automatically byAuthProvider).
import { authFetch } from '@startsimpli/auth/client';
const res = await authFetch('/api/v1/me/'); // resolved to NEXT_PUBLIC_API_URL
if (res.ok) {
const me = await res.json();
}@startsimpli/api's FetchWrapper routes its own 401-after-refresh through
the same notifySessionExpired sink so every consumer hits a single redirect
path.
Refresh-token classification
refreshAccessToken distinguishes "session is dead" from "backend is sick":
| Status | Result |
|---|---|
| 401 / 403 / 400 | Clear the stored token, return null. |
| 5xx / network error | Throw TransientRefreshError. Do NOT log the user out — the access token still lives, callers should surface a retry. |
| 200 | Return the new access token; persist via setAccessToken. |
Storage adapters (SessionStorage)
Backends that own their session (the mock backend, the React Native
TokenAuthClient, offline-first clients) need somewhere to persist it across
reloads/app restarts. The shared contract:
interface SessionStorage {
load(): Promise<Session | null>;
save(session: Session): Promise<void>;
clear(): Promise<void>;
}Factories in @startsimpli/auth/client:
createMemorySessionStorage()— the safe default for SSR + tests.createWebSessionStorage({ key?, storage? })—localStorageby default; silently falls back to memory when no Storage is available.createSecureSessionStorage(key?)— Metro-resolved. On the web it delegates tocreateWebSessionStorage; on React Native it persists to the iOS Keychain / Android Keystore viaexpo-secure-store. Falls back to in-memory when the native module isn't in the build.createRememberAwareSessionStorage(persistent, shouldRemember, transient?)— wraps two storages. WhenshouldRemember()is true at save time, the session goes topersistentandtransientis cleared; otherwise the reverse.loadalways readspersistent, so a session restores only if the last save was "remembered".
The web Django client doesn't take a SessionStorage at all — its refresh
token lives in an httpOnly cookie managed by the browser. The token-mode
client (@startsimpli/auth/token) is the one that needs storage on RN.
React Native parity (TokenAuthClient)
// On RN (Expo)
import { TokenAuthClient, SecureTokenStorage } from '@startsimpli/auth/token';
const auth = new TokenAuthClient({
apiBaseUrl: 'https://api.example.com',
storage: new SecureTokenStorage(),
});This is the same lifecycle as the web AuthClient (login, refresh, logout,
getCurrentUser) but DOM-free: no cookies, no window/document. It opts
into the backend's token mode via X-Auth-Mode: token and persists the
refresh token through the injected TokenStorage. See
examples/mobile-rn/App.tsx for the wiring used by the Expo demo.
Server helpers (Next.js)
// middleware.ts
import { createAuthMiddleware } from '@startsimpli/auth/server';
export const middleware = createAuthMiddleware({
loginPath: '/auth/signin',
publicPaths: ['/auth/signin', '/auth/signup', '/auth/forgot-password'],
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};The middleware uses a two-tier check: it lets the request through if either
a fresh access cookie (auth_session / access_token) is present OR a
refresh_token cookie is present (the client will refresh on mount). This
avoids the every-30-minute redirect dance when the access JWT expires
mid-session (see raise-simpli-qcw).
Route-handler guards
// app/api/me/route.ts
import { NextResponse, type NextRequest } from 'next/server';
import { withAuth } from '@startsimpli/auth/server';
export const GET = withAuth(async (req: NextRequest, token: string) => {
const res = await fetch(`${process.env.API_BASE_URL}/api/v1/auth/me/`, {
headers: { Authorization: `Bearer ${token}` },
});
return NextResponse.json(await res.json());
});withRole(requiredRole, getUserRole, handler) adds an owner > admin >
member > viewer check on top. Both extract the token via
getRequestToken(req) (NextRequest).
Framework-agnostic token extraction
getTokenFromRequest(req: Request) works on any Request-compatible
object (Node, Edge, Bun). It checks Authorization: Bearer ... then falls
back to an access_token cookie. It returns the raw token string without
expiry validation — used by vault-web API routes that hand the token
straight to a downstream Django call.
Server components
// app/dashboard/page.tsx
import { getServerSession } from '@startsimpli/auth/server';
import { redirect } from 'next/navigation';
export default async function Dashboard() {
const session = await getServerSession(process.env.API_BASE_URL!);
if (!session) redirect('/auth/signin');
return <div>Welcome, {session.user.firstName}</div>;
}validateSession(apiBaseUrl) and requireAuth(apiBaseUrl) are the
shorter forms. refreshServerToken(apiBaseUrl) hits the refresh endpoint
from a server context with cookies forwarded.
Mock backend (tests, Storybook, offline demos)
createMockAuthBackend() returns a full AuthBackend implementation —
exactly what AuthProvider needs, with no network. Pair it with any
SessionStorage to survive reloads.
import {
AuthProvider,
createMockAuthBackend,
createWebSessionStorage,
} from '@startsimpli/auth/client';
const backend = createMockAuthBackend({
accounts: [
{ email: '[email protected]', password: 'hunter2',
user: { id: 'u1', email: '[email protected]', firstName: 'Demo',
lastName: 'User', isEmailVerified: true,
createdAt: '', updatedAt: '' } },
],
storage: createWebSessionStorage(),
});
export default function App() {
return <AuthProvider backend={backend}>{/* ... */}</AuthProvider>;
}Pass backend instead of config and AuthProvider skips constructing
the Django AuthClient entirely. The mock surface adds requestPasswordReset,
resetPassword, requestEmailVerification, verifyEmail, and
upsertAccount on top of the base contract — see
packages/billing/src/mock for an example consumer.
Verification
pnpm --filter @startsimpli/auth test # 140 tests across 17 files
pnpm --filter @startsimpli/auth type-checkThe suite covers auth-client, authFetch retry, middleware, the mock
backend, the secure-storage adapters (native + web), the token-auth core,
permissions, and validation.
Browser verification is not optional. Any sign-in / sign-out / OAuth
flow change MUST be driven through a localhost dev server with
mcp__debugg-ai__check_app_in_browser — type-checks and unit tests do not
prove a redirect chain works. See the MCP-default rule in
/Users/qosha/Repos/start-simpli/CLAUDE.md (rule 10 / the
"feedback_verify_ui_with_mcp" memory).
Shared-package policy
Per CLAUDE.md rule 9, every app-side auth helper that another app might
plausibly want belongs here — not in apps/*/src. No new
AuthProvider, no app-local authFetch wrapper, no per-app useAuth
hook. Extend this package instead. The current consumers all go through
the public surface above.
License
Private package for the StartSimpli monorepo.
