Overview
Authentication adapter over Clerk, with a vendor-free surface for apps, a simulated mode and server-side helpers.
@workspace/auth is the only package allowed to import Clerk. Apps mount a provider, read auth through hooks and types that never mention the vendor, and the API verifies tokens and webhooks through adapters that implement the training ports.
The package covers three runtimes:
| Runtime | Entry points | Used by |
|---|---|---|
| Browser, Vite + React | @workspace/auth, /clerk, /simulated, /config, /impersonation-handoff, /styles.css | apps/app |
| Browser + server, Next.js | /nextjs, /server/nextjs | apps/admin |
| Node | /server, /server/testing, /testing/playwright | apps/api, e2e suites |
Architecture
@workspace/auth
types.ts AuthPort, AuthSession, AuthComponents, ImpersonationState
hooks/ Store-backed hooks (useAuthPort, useAuthSession, useImpersonation…)
internal/store.ts Zustand vanilla store: the single source of auth state
internal/clerk/ Clerk provider, sync to the store, localization
internal/simulated/ Vendor-free provider driven by the host
internal/impersonation* Start/stop actions and per-tab persistence
impersonation-handoff.ts URL-fragment handoff from the admin to the app
config.ts parseAuthConfig (zod)
avatar-file.ts Avatar validation and AvatarUpdateError
nextjs/ Next.js client provider and hooks
server/ Node adapters: token verification, webhooks, admin API, session tokens
testing/ Playwright helpers and Clerk test-user poolThe store in the middle
Every client hook reads a Zustand vanilla store that holds userId, isLoaded, isSignedIn, the AuthSession, the active impersonation and the signOut function. Two providers write into it:
createClerkAuthmounts Clerk and syncs its hooks into the store during render, so consumers see fresh state on their first render and never get a flash redirect on reload.createSimulatedAuthloads the store from a scripted state without any vendor code. The dev scenarios and component tests use it.
Both expose the same AuthComponents shape, so the host is written once:
import type { AuthComponents } from '@workspace/auth';
async function loadClerkAuth(): Promise<AuthComponents> {
const { createClerkAuth } = await import('@workspace/auth/clerk');
return createClerkAuth({
publishableKey: getEnv().auth.publishableKey,
useLanguage: useAppLanguage,
signInUrl: ROUTES.auth.signIn,
signUpUrl: ROUTES.auth.signUp,
afterAuthUrl: '/',
});
}
const auth = shell?.auth ?? (await loadClerkAuth());
const AuthProvider = auth.Provider;
<AuthProvider pending={<AppLoading />}>
<App />
</AuthProvider>The provider suspends its children until auth has loaded, keeping pending visible. The sign-in and sign-up routes render auth.SignIn and auth.SignUp.
Design principles
- One vendor import site: Clerk types and hooks never leave the package. Apps type against
AuthPort,AuthSessionandAuthComponents. - Stable references: the store absorbs Clerk's referential instability.
useAuthPortonly re-renders on identity, loading or impersonation changes, never on a token refresh. - Effective identity:
getUserId()returns the impersonated subject while an impersonation is active,getActorId()always returns the signed-in user. See Impersonation. - Roles live in the database: the server helpers read a role from Clerk public metadata only to seed an invited account. The
accountsrow stays the source of truth afterwards. - Vanilla config: the package takes a
publishableKey, apps map their env to it withparseAuthConfig.
Quick start
Read auth state
import { useAuthPort, useAuthSession } from '@workspace/auth';
const auth = useAuthPort();
auth.isAuthenticated(); // boolean
auth.getUserId(); // effective user id, or null
const session = useAuthSession();
const token = await session?.getToken(); // bearer for the APIVerify a bearer on the API
import { verifyToken } from '@workspace/auth/server';
const payload = await verifyToken(token, { secretKey: env.CLERK_SECRET_KEY });
// { userId, sessionId, email }Protect a Next.js app
import { createAuthProxy } from '@workspace/auth/server/nextjs';
export default createAuthProxy({
publicRoutes: ['/sign-in(.*)', '/api(.*)'],
signInUrl: '/sign-in',
});Exports
| Path | Description |
|---|---|
@workspace/auth | Store-backed hooks, AuthPort and friends, avatar validation |
@workspace/auth/clerk | createClerkAuth, password hooks (loads Clerk) |
@workspace/auth/simulated | createSimulatedAuth, useSimulatedAuth, SimulatedAuthState |
@workspace/auth/types | The shared types alone, no runtime |
@workspace/auth/config | authConfigSchema, parseAuthConfig |
@workspace/auth/impersonation-handoff | Build and parse the admin → app handoff URL |
@workspace/auth/styles.css | Tailwind @source directive for the package classes |
@workspace/auth/nextjs | Next.js client: AuthProvider, SignIn, session hooks |
@workspace/auth/server | Node adapters: verifyToken, webhook verifier and handlers, admin service, HMAC session tokens |
@workspace/auth/server/nextjs | Next.js server: session reads, createAuthProxy, invitations, role sync |
@workspace/auth/server/testing | Faker factories for Clerk webhook payloads and signed requests |
@workspace/auth/testing/playwright | Clerk testing mode and the per-worker user pool |