Authentication Architecture
Authentication Architecture
Section titled βAuthentication ArchitectureβOrdermatic uses a split client/server Clerk architecture in apps/webapp combining @clerk/astro for client-side session token refresh with @repo/auth (packages/auth) for server-side SSR handshakes, Session JWT V2 verification, multi-tier caching, headless client actions, and centralized RBAC.
High-Level Overview
Section titled βHigh-Level OverviewβBrowser (apps/webapp) ββ @clerk/astro integration (astro.config.mjs) β ββ auto-injects clerk-js bootstrap script β keeps __session JWT cookie fresh ββ @repo/auth/headless & @repo/auth/client β ββ imperative client actions (signOut, redirectToSignIn, satellite domain sync) β HTTP Request (Cookie: __session, __clerk_db_jwt)Astro Middleware (apps/webapp/src/middleware.ts β @repo/auth/middleware) ββ Page routes: authenticateRequestForSSR(request) β ββ Valid JWT β verifyToken + signedInAuthObject (Clerk Session JWT V2 o.id/o.rol/o.slg) β ββ Expired-but-refreshable JWT β returns Clerk 307 handshake redirect (never bounces to /sign-in) β ββ Resolves user & organization via in-memory session-cache + Redis cache (5m TTL) ββ API routes: authenticateRequest(request) β ββ Fast-path JWT verification without HTML handshake redirects ββ Populates Astro.locals.{auth, user, organization, authStatus}1. Client-Side Bootstrap (@clerk/astro)
Section titled β1. Client-Side Bootstrap (@clerk/astro)βWhy @clerk/astro Owns the Client Bootstrap
Section titled βWhy @clerk/astro Owns the Client Bootstrapβapps/webapp/astro.config.mjs registers clerk() from @clerk/astro:
import clerk from '@clerk/astro';import react from '@astrojs/react';
export default defineConfig({ // IMPORTANT: react() MUST precede clerk(). // @clerk/astro registers a before-hydration script with a top-level // `await runInjectionScript(...)`. If clerk() runs first, that async module // blocks Vite from executing the @astrojs/react fast-refresh preamble before // React islands evaluate in dev. integrations: [react(), clerk(), tailwindcss()],});- Automatic
__sessionrefresh:@clerk/astroinjects theclerk-jsbootstrap script into every page and runs background token refresh timers so the short-lived__sessionJWT cookie stays fresh while a tab is open. - No React Clerk islands: Because
@clerk/astrohandles client initialization natively,AppChrome.astromounts zero Clerk islands (onlyFeatureFlagInitializer,PostHogInit, andPostHogPageView). - Retired legacy client stack (PR #2640): The legacy bespoke client Clerk stack (
ClerkInitializer.tsx,services/authSync.ts,hooks/useAuth.ts,stores/clerkStore.ts,AuthPerformanceMonitor.tsx, andAuthLoadingBoundary.tsx) has been completely removed.
Headless Client Actions (@repo/auth/headless & @repo/auth/client)
Section titled βHeadless Client Actions (@repo/auth/headless & @repo/auth/client)βWhen client components need imperative auth actions (such as signing out from RailNav.tsx or redirecting unauthenticated users), they use @repo/auth/headless (packages/auth/src/headless.ts) and @repo/auth/client (packages/auth/src/client.ts):
initializeClerk(publishableKey)β singleton access towindow.Clerk(or@clerk/clerk-jsfallback).signOut()β clears the Clerk session and redirects to/sign-in.redirectToSignIn()/redirectToSignUp()β supports satellite domains (staging.ordermatic.cosyncing with primaryapp.ordermatic.cowhenPUBLIC_CLERK_IS_SATELLITE=true).
2. Server-Side Authentication (@repo/auth)
Section titled β2. Server-Side Authentication (@repo/auth)βWhile @clerk/astro manages the browser bootstrap, apps/webapp deliberately uses @repo/auth/middleware (createAuthMiddleware) on the server rather than @clerk/astroβs default clerkMiddleware(). This preserves Ordermaticβs multi-tier caching, Session JWT V2 claim normalization, 4-state locals.authStatus contract, and custom SSR handshake handling.
Subpath Exports in @repo/auth
Section titled βSubpath Exports in @repo/authβ| Import Path | File | Purpose |
|---|---|---|
@repo/auth/middleware |
packages/auth/src/middleware.ts |
createAuthMiddleware factory, route matchers, security headers, and locals.authStatus contract |
@repo/auth/server |
packages/auth/src/server.ts |
authenticateRequest, authenticateRequestForSSR, Session JWT V2 verification, Redis user/org cache, Clerk backend API wrappers |
@repo/auth/astro |
packages/auth/src/astro.ts |
Astro route/API guards (getUser, getOrganization, requireAuth, requireAdmin, requireOrgAdmin, requireSysAdmin, isSysAdmin) |
@repo/auth/rbac |
packages/auth/src/rbac.ts |
Centralized role & metadata evaluators (hasPlatformSysAdminRole, hasWebappOrgAdminNavMetadata, isInternalUser) |
@repo/auth/roles |
packages/auth/src/roles.ts |
Canonical role constants (ROLES.SYS_ADMIN, ROLES.ADMIN, ROLES.INTERNAL_ADMIN, ROLES.ORG_ADMIN, ROLES.MEMBER) |
@repo/auth/headless |
packages/auth/src/headless.ts |
Headless browser Clerk helpers (initializeClerk, signOut, getSessionToken) |
@repo/auth/client |
packages/auth/src/client.ts |
Satellite-aware browser redirects (redirectToSignIn, redirectToSignUp) |
SSR Handshake vs. API Verification (packages/auth/src/server.ts)
Section titled βSSR Handshake vs. API Verification (packages/auth/src/server.ts)βClerkβs __session cookie is a short-lived (~60s) JWT. When a user returns to an idle tab or navigates after __session expires, the cookie is stale even though their Clerk session is still active.
- Page routes call
authenticateRequestForSSR(request):- First checks the in-memory
session-cache.ts(keyed by raw__sessiontoken). - Otherwise calls
clerkClient.authenticateRequest(request, { secretKey, publishableKey, jwtKey }). - If Clerk returns
status === 'handshake':authenticateRequestForSSRextracts the307redirectLocationandSet-Cookieheaders fromrequestState.toAuth()and returns{ handshakeRedirect: Response }.createAuthMiddlewareimmediately returns that 307 response so the browser completes the silent Clerk handshake and lands back on the requested URL with a fresh__sessioncookie β never bouncing a signed-in user to/sign-in.
- First checks the in-memory
- API routes (
/api/*) callauthenticateRequest(request):- Uses fast-path
verifyToken(token, { secretKey, jwtKey })+signedInAuthObject(...)from@clerk/backend/internalwithout triggering HTML handshake redirects. - Falls back to
clerkClient.authenticateRequestifverifyTokenfails.
- Uses fast-path
Clerk Session JWT V2 Normalization
Section titled βClerk Session JWT V2 NormalizationβClerk Session JWT V2 nests organization claims inside an compact o object (o.id, o.rol, o.slg) instead of top-level org_id / org_role / org_slug claims. Calling verifyToken() raw leaves payload.org_id undefined.
@repo/auth/server passes the verified payload through Clerkβs signedInAuthObject( { ... }, token, payload ), which normalizes both v1 and v2 JWTs into { userId, sessionId, orgId, orgRole, orgSlug } before constructing the User and Organization objects.
Multi-Tier Caching
Section titled βMulti-Tier CachingβTo keep SSR latency under a few milliseconds and avoid Clerk Backend API rate limits (429 Too Many Requests):
- In-memory Session Cache (
packages/auth/src/session-cache.ts): Caches the resolved{ user, organization }keyed by the exact__sessionJWT string for the lifetime of that short-lived token. - Redis User Cache (
auth:user:${userId}, 5-minute TTL): Caches the ClerkUserprofile (email,firstName,lastName,imageUrl,publicMetadata,privateMetadata) via@repo/utils/cache. - Redis Organization Cache (
getCachedOrganization, 5-minute TTL): Caches organization metadata (id,name,slug,publicMetadata) and deduplicates concurrent in-flight lookups viainFlightOrgRequests.
The 4-State Astro.locals.authStatus Contract
Section titled βThe 4-State Astro.locals.authStatus ContractβcreateAuthMiddleware sets four explicit states on Astro.locals.authStatus:
locals.authStatus |
Meaning | Middleware Behavior on Protected Page Routes |
|---|---|---|
'authenticated' |
Valid session JWT and locals.user resolved |
Proceeds to render page |
'authenticated_no_user' |
Valid session JWT (locals.auth.userId present), but Clerk Backend API failed/timed out fetching User |
Proceeds (or lets route handle degraded profile) β does not redirect to /sign-in, preventing redirect loops |
'unauthenticated' |
No valid session and no handshake possible | Redirects to /sign-in?redirect_url=... |
'auth_error' |
Unexpected exception during auth middleware execution | Logs error; redirects protected page routes to /sign-in |
3. Role-Based Access Control (@repo/auth/rbac & @repo/auth/astro)
Section titled β3. Role-Based Access Control (@repo/auth/rbac & @repo/auth/astro)βRole checks are centralized in packages/auth/src/rbac.ts and exposed to Astro pages/endpoints via @repo/auth/astro:
- Platform System Admin (
isSysAdmin(locals)/requireSysAdmin(locals)): CheckshasPlatformSysAdminRole(user.publicMetadata)(sys_adminorsys-admininpublicMetadata.role/publicMetadata.roles). Required for/admin/*platform routes and cross-tenant org switching. - Organization Admin (
isOrgAdmin(locals)/requireOrgAdmin(locals)): True if the user is a platform sys_admin, holds Clerk organization roleorg:admin(locals.auth.orgRole === 'org:admin'), or has tenant admin metadata (hasMetadataTenantAdminRole). Gates organization admin settings and navigation items inAppRail.astro. - Internal Staff (
isInternalUser(user)): Used byapps/internal(team.ordermatic.co) to verify@ordermatic.codomain membership plussys_admin,admin, orinternal_adminroles.
4. Platform & Service Authentication Surfaces
Section titled β4. Platform & Service Authentication Surfacesβ| Surface | Mechanism |
|---|---|
apps/webapp (app.ordermatic.co) |
@clerk/astro client bootstrap + @repo/auth SSR middleware (authenticateRequestForSSR, Session JWT V2, Redis cache) |
apps/internal (team.ordermatic.co) |
Cloudflare Access (@ordermatic.co) β Cloudflare Tunnel β erp-prod-internal-edge (mints GCP Cloud Run ID token) β @repo/auth Clerk verification (isInternalUser) |
| Webapp Internal APIs | Shared service token (INTERNAL_SERVICE_TOKEN / WEBAPP_API_KEY) verified via constant-time comparison (validateInternalServiceToken in @repo/utils) |
partner-data-api |
Dual-layer: Cloudflare Access service token JWT at transport edge + app-issued partner API token (Authorization: Bearer, mcp_tokens row scoped to a single connection_id) |
graph-mcp |
Cloudflare Access (@ordermatic.co Zero Trust policy) |
content-mcp |
Static bearer token (MCP_AUTH_TOKEN) |