Skip to main content

Auth Module

This document describes authentication and related HTTP routes in the API at apps/api/src/modules/auth, and how Clerk is integrated via @clerk/fastify in the current runtime path.

Purpose

The auth module currently:

  • relies on Clerk’s official Fastify plugin (@clerk/fastify) and reads auth state with getAuth(request)
  • rejects unauthenticated requests with 401 Unauthorized
  • resolves the internal application user id (database) from the identity-provider user id (Clerk), with Redis caching
  • exposes an authGuard (Fastify preHandler) for other modules (for example users routes)
  • exposes POST /auth/v1/register to register/resolve user identity links via users module use-cases

Why @clerk/fastify in this architecture

The API runs on Fastify. Clerk exposes a first-class @clerk/fastify package that:

  • registers clerkPlugin on the Fastify instance with secretKey and publishableKey, matching Clerk’s documented setup
  • runs Clerk’s request processing in the correct Fastify lifecycle (configurable hook; default works with preHandler guards)
  • exposes getAuth(request) so HTTP adapters can read isAuthenticated and userId without hand-rolling Request bridges or duplicating Clerk’s authenticateRequest wiring

That choice trades a small amount of framework coupling in the HTTP adapter (the guard calls getAuth) for:

  • fewer custom integration bugs (cookies, headers, and future Clerk behavior changes are handled by the maintained plugin)
  • simpler guard code (no cloning FastifyRequest into a Web Request for Clerk in middleware)
  • clear ownership: Clerk + Fastify concerns live in adapters and the app bootstrap; use-cases stay free of FastifyRequest

Application-layer types such as AuthRequestContext and IdentityProviderPort remain framework-agnostic so you can still test authentication delegation or swap to another provider implementation without importing Fastify in use-cases.
At the moment, the live guard path uses getAuth directly, and createAuthenticateRequestUseCase is not wired into createAuthModule.

Module structure

PathRole
domain/types.tsAuthenticatedIdentity (ids as returned from an identity provider abstraction)
application/ports/identity-provider.port.tsIdentityProviderPortauthenticate(AuthRequestContext) (no Fastify types)
application/use-cases/authenticate-request.tsDelegates authentication to the port (useful for tests or non-Fastify entrypoints)
adapters/provider/clerk-identity-provider.impl.disabled.tsClerk adapter for the port contract (currently disabled/not used by composition)
application/use-cases/resolve-internal-user-id.tsCache + registerByIdentityProviderId to map Clerk id → internal userId
adapters/http/middleware.tsFastify authGuard preHandler: getAuth(request) from @clerk/fastify, then internal id resolution
adapters/http/routes.tsFastify plugin registering POST /auth/v1/register with preHandler: authGuard
composition.tsWires users repo/use case, cache, guard, and routes

::: tip To swap Clerk for another provider while keeping the same guard shape, you could replace the getAuth-based guard with one that calls createAuthenticateRequestUseCase + a new IdentityProviderPort implementation. The port already uses AuthRequestContext, not FastifyRequest, so application code stays decoupled from Fastify. :::

App bootstrap (Clerk plugin)

Clerk is registered once on the root Fastify app in apps/api/src/index.ts:

await app.register(clerkPlugin, {
secretKey: process.env.CLERK_SECRET_KEY!,
publishableKey: process.env.CLERK_PUBLISHABLE_KEY!,
});

Module routes (for example authRoutes) are registered on the same app so getAuth(request) sees Clerk’s populated request state.

Composition dependencies

createAuthModule({ db, cache }) requires:

  • dbPrismaClient (builds userRepo from the users module persistence layer)
  • cache — shared Redis Cache (used to store internal user id by identity-provider user id)

It composes the users registration use case registerByIdentityProviderId so the guard and register route can ensure a row exists in your database for the signed-in Clerk user.

Request authentication flow (authGuard)

  1. OPTIONS requests skip the guard.
  2. The guard calls getAuth(request) from @clerk/fastify (Clerk plugin must be registered on the app).
  3. If auth is missing, not authenticated, or userId is absent, the guard responds with 401 and body { message: "Unauthorized" }.
  4. Otherwise the guard calls resolveInternalUserId(auth.userId) (Clerk user id string):
    • reads Redis key auth:user-id-by-idp:<identityProviderUserId> if present;
    • on miss, calls registerByIdentityProviderId (creates or resolves the user), then caches the internal id (default TTL 10 minutes).
  5. The guard sets request.auth (Fastify request decoration used by handlers):
// request.auth (after authGuard)
{
isAuthenticated: true;
userId: string; // internal DB user id (from cache or registration)
identityProviderUserId: string; // Clerk user id (same as getAuth().userId here)
}

Downstream handlers (users module) should treat request.auth.userId as the stable id for your own tables.

Identity model

After the guard, identityProviderUserId is the Clerk userId string from getAuth. userId is your internal database user id resolved via cache + registration.

HTTP routes (/auth)

MethodPathBehavior
POST/auth/v1/registerRuns authGuard as preHandler, then calls registerByIdentityProviderId with the JSON body (RegisterUserRequest). Returns 201 if a new user was created, 200 if the user already existed.

Current request contract for POST /auth/v1/register requires:

{
identityProviderUserId: string;
}

The handler currently forwards this body to the use-case directly. It does not currently enforce equality between request.auth.identityProviderUserId and the body value.

authRoutes is a Fastify plugin (FastifyPluginAsync). Register it on the root app with await app.register(authModule.routes) when you wire modules into index.ts.

Environment variables (Clerk)

Validated in apps/api/src/shared/kernel/env.ts and used by Clerk:

  • CLERK_SECRET_KEY
  • CLERK_PUBLISHABLE_KEY (required for clerkPlugin)

Other Clerk-related secrets (for example webhooks) live outside this module’s guard path; see webhook docs if applicable.

Composition overview

  • Error handling — how failures are represented without per-handler try/catch.
  • Logging — HTTP request and error logging.