Server
Node adapters for the API, token verification, Clerk webhooks, the admin API and signed session tokens.
Import from @workspace/auth/server. Each export implements a port declared in @workspace/training/application/ports, so the API's composition root wires it and the domain never sees Clerk.
| Export | Implements |
|---|---|
verifyToken | The bearer check of the API auth middleware |
createClerkWebhookVerifier | WebhookVerifier<"clerk"> |
createClerkAccountsWebhookHandlers | IngestedEventHandler<"clerk">[] |
createClerkIdentityProvider | IdentityProvider |
createHmacSessionTokenService | SessionTokenService |
verifyToken
Verifies a Clerk session JWT with the secret key and returns the claims the API needs. Throws on an invalid or expired token.
import { verifyToken } from '@workspace/auth/server';
const router = createRouter({
auth: {
verifyToken: async (token) => {
try {
return await verifyToken(token, { secretKey: env.CLERK_SECRET_KEY });
} catch {
return null;
}
},
},
});Prop
Type
The API's identity middleware sets the verified identity as auth on every request, null for an anonymous caller; requireAuth then refuses a protected route without one, sets userId and userEmail, and resolves the optional impersonation header on top. See Impersonation.
Clerk webhooks
Two pieces: a verifier that turns a raw request into an IngestedEvent, and the handlers that react to user events. The event ingestion service of training sits between them: it persists the event, deduplicates on externalId and dispatches by (provider, eventType).
CLERK_PROVIDER
The literal "clerk", also exported as the ClerkProvider type. The API composes its WebhookProvider union from each adapter's literal, so the domain stays provider-agnostic.
createClerkWebhookVerifier
import { createClerkWebhookVerifier } from '@workspace/auth/server';
const verifier = createClerkWebhookVerifier(env.CLERK_WEBHOOK_SIGNING_SECRET);
const event = await verifier.verify(request);
// { provider: 'clerk', externalId: svixId, eventType: 'user.updated', occurredAt, payload }Verification checks the svix-id, svix-timestamp and svix-signature headers and the signature itself, then validates that the body is an event envelope with a type and a data object. externalId is the svix delivery id, Clerk's idempotency key.
createClerkAccountsWebhookHandlers
Bridges user.created, user.updated and user.deleted to the accounts service.
import { createClerkAccountsWebhookHandlers } from '@workspace/auth/server';
const registry = createIngestedEventHandlerRegistry([
...createClerkAccountsWebhookHandlers(accountsService),
...stripeHandlers,
]);| Event | Effect |
|---|---|
user.created | Builds an IdentitySnapshot (id, primary e-mail, names, avatar, role from public metadata, signup source, updatedAt). With a signup source it creates or updates the account; without one it only updates an existing account, and the first GET /accounts/me creates it |
user.updated | The same snapshot without signup source: updates an existing account, never creates one nor changes its source |
user.deleted | Detaches the identity from its account |
A user without a primary e-mail is logged and skipped. The payloads are typed with Clerk's own UserJSON and DeletedObjectJSON, so a field rename in Clerk fails at compile time.
Signup source
Every account creation path (the user.created webhook, the API's first GET /accounts/me, the admin's first request) reads the user's signup source by one rule: publicMetadata.signupSource, set server-side by an admin invitation, wins over unsafeMetadata.signupSource, set by the sign-up form of the app (createClerkAuth({ signupSource })). Unknown values are ignored; without a source, no account is created.
createClerkIdentityProvider
The IdentityProvider over Clerk's Backend API. Each user is read into the snapshot its user.created event carries (lowercased primary email, names, avatar, role, signup source, updatedAt), by the one mapping the user.* webhook (its payload first put in the Backend API's shape) and getSessionIdentitySnapshot share; a user without a primary email reads as null.
getIdentity(id)serves the API's firstGET /accounts/mewhen it finds no account. No source answers409 ACCOUNT_SIGNUP_SOURCE_MISSING; an unreachable Clerk fails the read, and the webhook creates the account later.listIdentities()pages through every user, for the CLI command that syncs them into the database.
import { createClerkIdentityProvider } from '@workspace/auth/server';
const identityProvider = createClerkIdentityProvider(env.CLERK_SECRET_KEY);
const snapshots = await identityProvider.listIdentities();createHmacSessionTokenService
A SessionTokenService for the anonymous player sessions of the telemetry routes. It has nothing to do with Clerk: the token is base64url(payload).base64url(hmac-sha256) over { sid, issuedAt }, compared in constant time.
import { createHmacSessionTokenService } from '@workspace/auth/server';
const sessionTokens = createHmacSessionTokenService({ secret: env.SESSION_SIGNING_SECRET });
const minted = sessionTokens.mint(); // { token, sid, issuedAt, expiresAt }
const { sid } = sessionTokens.verify(token); // throws SessionTokenVerificationError
const renewed = sessionTokens.renew(token); // same sid, fresh issuedAt, expiry ignoredProp
Type
ttlMs defaults to DEFAULT_SESSION_TOKEN_TTL_MS from the port, one day. verify throws a SessionTokenVerificationError from @workspace/training with reason malformed, invalid-signature or expired; renew accepts an expired token but not a tampered one.