npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@forjakit/auth-fastify

v0.1.1

Published

Fastify plugin that exposes `@forjakit/auth` as a REST API. Registers auth routes with tenant isolation, input validation, rate limiting, optional cookie-based token delivery, and structured error responses. Also provides standalone middleware factories f

Readme

@forjakit/auth-fastify

Fastify plugin that exposes @forjakit/auth as a REST API. Registers auth routes with tenant isolation, input validation, rate limiting, optional cookie-based token delivery, and structured error responses. Also provides standalone middleware factories for protecting routes in other plugins.

Installation

pnpm add @forjakit/auth-fastify

Peer dependencies: @forjakit/auth, fastify >= 5, zod >= 3.24.

Plugin Registration

import Fastify from "fastify";
import { authPlugin } from "@forjakit/auth-fastify";
import { createAuthService } from "@forjakit/auth";

const app = Fastify();

const service = createAuthService({
  storage: myStorage,
  tokens: { secret: process.env.JWT_SECRET! },
  roles: myRoles,
  defaultRole: "client",
});

app.register(authPlugin, {
  service,
  prefix: "/auth",
  tenantResolver: (req) => req.headers["x-tenant-id"] as string,
  rateLimiter: myRateLimiter,
  onPasswordResetToken: async (token, { email, tenantId }) => {
    await sendResetEmail(email, token);
  },
  onVerificationToken: async (token, { email, tenantId }) => {
    await sendVerificationEmail(email, token);
  },
});

AuthPluginOptions

| Option | Type | Required | Default | Description | | ------ | ---- | -------- | ------- | ----------- | | service | AuthService | Yes | -- | Service instance from createAuthService | | prefix | string | No | "" | Route prefix (e.g., "/auth") | | tenantResolver | (req: FastifyRequest) => string | No | x-tenant-id header or subdomain | Extracts tenant ID from the request | | rateLimiter | RateLimiter | No | None | Applied to register, login, and password reset routes | | rateLimitKeyResolver | (req: FastifyRequest) => string | No | req.ip | Extracts the rate limit key from a request | | onPasswordResetToken | (token, { email, tenantId }) => Promise<void> | No | -- | Callback to send the reset email | | onVerificationToken | (token, { email, tenantId }) => Promise<void> | No | -- | Callback to send the verification email | | cookie | AuthCookieOptions | No | Disabled | Cookie-based token delivery configuration |

Routes

All routes are relative to the configured prefix. Tenant ID is resolved from every request via tenantResolver.

| Method | Path | Rate Limited | Description | | ------ | ---- | ------------ | ----------- | | POST | /register | Yes | Register a new user | | POST | /login | Yes | Login with email and password | | POST | /refresh | No | Exchange refresh token for new token pair | | GET | /me | No | Get the authenticated user | | POST | /logout | No | Blacklist the access token | | POST | /request-password-reset | Yes | Request a password reset token | | POST | /reset-password | No | Reset password with token | | POST | /verify-email | No | Verify email with token | | POST | /resend-verification | No | Resend verification email |

POST /register

Creates a new user and returns tokens.

Request body:

{
  "email": "[email protected]",
  "password": "Str0ng!Pass",
  "name": "Jane Doe",
  "role": "client"
}

Note: tenantId is injected by the plugin from tenantResolver, not from the request body. role is optional and defaults to the configured defaultRole.

Response: 201 Created

{
  "user": { "id": "...", "email": "[email protected]", "name": "Jane Doe", "role": "client", "tenantId": "..." },
  "accessToken": "eyJ...",
  "refreshToken": "eyJ..."
}

If email verification is enabled and onVerificationToken is provided, the callback is invoked with the raw verification token. The verificationToken is not included in the response.

POST /login

Authenticates a user.

Request body:

{
  "email": "[email protected]",
  "password": "Str0ng!Pass"
}

Response: 200 OK

{
  "user": { "id": "...", "email": "...", "name": "...", "role": "...", "tenantId": "..." },
  "accessToken": "eyJ...",
  "refreshToken": "eyJ..."
}

POST /refresh

Exchanges a refresh token for a new token pair.

Request body:

{
  "refreshToken": "eyJ..."
}

Response: 200 OK

{
  "accessToken": "eyJ...",
  "refreshToken": "eyJ..."
}

In cookie mode, the refresh token is read from the cookie. The response body contains { "message": "Tokens refreshed" } instead of the tokens.

GET /me

Returns the authenticated user. Requires a valid access token in the Authorization: Bearer <token> header or access token cookie.

Response: 200 OK

{
  "id": "...",
  "email": "[email protected]",
  "name": "Jane Doe",
  "role": "client",
  "tenantId": "..."
}

POST /logout

Blacklists the access token. Requires tokenBlacklist to be configured on the auth service.

Response: 204 No Content

In cookie mode, token cookies are cleared.

POST /request-password-reset

Generates a reset token and invokes onPasswordResetToken if the user exists. Always returns the same response to avoid leaking whether the email is registered.

Request body:

{
  "email": "[email protected]"
}

Response: 200 OK

{
  "message": "If the email exists, a reset link has been sent"
}

POST /reset-password

Validates the reset token and updates the password.

Request body:

{
  "token": "abc123...",
  "password": "NewStr0ng!Pass"
}

Response: 200 OK

{
  "message": "Password has been reset"
}

POST /verify-email

Validates the verification token and marks the email as verified.

Request body:

{
  "token": "abc123..."
}

Response: 200 OK

{
  "message": "Email verified"
}

POST /resend-verification

Generates a new verification token and invokes onVerificationToken if the user exists.

Request body:

{
  "email": "[email protected]"
}

Response: 200 OK

{
  "message": "If the email exists, a verification link has been sent"
}

Cookie-Based Token Delivery

When enabled, tokens are delivered as HttpOnly cookies instead of in the response body. The plugin automatically registers @fastify/cookie.

app.register(authPlugin, {
  service,
  cookie: {
    enabled: true,
    secure: true,           // default: true in production
    sameSite: "lax",        // default: "lax"
    domain: ".example.com", // default: request hostname
    path: "/",              // default: "/"
    accessTokenName: "access_token",   // default: "access_token"
    refreshTokenName: "refresh_token", // default: "refresh_token"
  },
});

AuthCookieOptions

| Option | Type | Required | Default | Description | | ------ | ---- | -------- | ------- | ----------- | | enabled | boolean | Yes | -- | Enable cookie-based token delivery | | secure | boolean | No | true in production | Set the Secure flag (HTTPS only) | | sameSite | "strict" \| "lax" \| "none" | No | "lax" | SameSite attribute | | domain | string | No | Request hostname | Cookie domain | | path | string | No | "/" | Cookie path | | accessTokenName | string | No | "access_token" | Cookie name for the access token | | refreshTokenName | string | No | "refresh_token" | Cookie name for the refresh token |

How it works:

  • On login/register, tokens are set as HttpOnly cookies. The response body contains the user data but not the tokens.
  • On refresh, the refresh token is read from the cookie. New tokens are set as cookies. The response body contains { "message": "Tokens refreshed" }.
  • On logout and 401 errors, token cookies are cleared (set to empty with maxAge: 0).
  • Token extraction always checks cookies first, then falls back to the Authorization: Bearer header.

Cookie max ages: access token = 15 minutes, refresh token = 7 days.

Middleware

Standalone middleware factories for protecting routes in other plugins.

authenticate(service, cookieAccessTokenName?)

Fastify preHandler that authenticates the request via Bearer token or cookie and populates request.authUser.

import { authenticate } from "@forjakit/auth-fastify";

app.get("/protected", { preHandler: authenticate(service) }, async (req) => {
  const user = req.authUser;
  return { message: `Hello ${user.name}` };
});

requireRole(service, ...roles)

Fastify preHandler that authenticates and checks if the user has one of the required roles.

import { requireRole } from "@forjakit/auth-fastify";

app.delete("/admin-only", { preHandler: requireRole(service, "admin") }, async (req) => {
  return { message: "Admin action performed" };
});

createCookieMiddleware(service, accessTokenName)

Creates middleware factories pre-configured with cookie support. Use this when cookie mode is enabled to avoid passing the cookie name to every middleware call.

import { createCookieMiddleware } from "@forjakit/auth-fastify";

const { authenticate, requireRole } = createCookieMiddleware(service, "access_token");

app.get("/profile", { preHandler: authenticate() }, handler);
app.delete("/admin", { preHandler: requireRole("admin") }, handler);

request.authUser

All middleware populates request.authUser with the authenticated user object:

interface FastifyRequest {
  authUser?: {
    id: string;
    email: string;
    name: string;
    role: string;
    tenantId: string;
  };
}

Tenant Resolution

By default, the plugin resolves the tenant ID using this strategy:

  1. Read the x-tenant-id header.
  2. Fall back to the first subdomain of the request hostname (e.g., acme.example.com -> "acme").
  3. Throw AuthError with code TENANT_REQUIRED (400) if neither is available.

Provide a custom resolver to extract the tenant ID from any source:

app.register(authPlugin, {
  service,
  tenantResolver: (req) => {
    // From JWT claims, database lookup, etc.
    return (req as any).user.tenantId;
  },
});

Rate Limiting

The plugin accepts a RateLimiter interface (defined in @forjakit/auth). Rate limiting is applied to register, login, and request-password-reset routes.

app.register(authPlugin, {
  service,
  rateLimiter: myRedisRateLimiter,
  rateLimitKeyResolver: (req) => req.ip, // default
});

When rate limited, the response includes a Retry-After header and returns:

{
  "error": "RATE_LIMITED",
  "message": "Too many requests, retry after 60 seconds"
}

HTTP status: 429.

Error Handling

The plugin registers a scoped Fastify error handler that converts known errors into structured JSON responses.

AuthError Response

{
  "error": "INVALID_CREDENTIALS",
  "message": "Invalid email or password"
}

HTTP status codes are taken directly from the AuthError.statusCode property. See the core package documentation for the full error code table.

ZodError Response (Validation Failures)

{
  "error": "VALIDATION_ERROR",
  "message": "Invalid input",
  "details": [
    { "field": "password", "message": "Password must be at least 8 characters" },
    { "field": "email", "message": "Invalid email" }
  ]
}

HTTP status: 400.

Unhandled Errors

Any error that is not an AuthError or ZodError is re-thrown to Fastify's default error handler.

Email Callbacks

The plugin does not send emails. Instead, it invokes optional callbacks with the raw token and user info. You provide the email delivery logic.

app.register(authPlugin, {
  service,
  onPasswordResetToken: async (token, { email, tenantId }) => {
    const resetUrl = `https://app.example.com/reset-password?token=${token}`;
    await emailService.send(email, "Reset your password", resetUrl);
  },
  onVerificationToken: async (token, { email, tenantId }) => {
    const verifyUrl = `https://app.example.com/verify-email?token=${token}`;
    await emailService.send(email, "Verify your email", verifyUrl);
  },
});

Full Integration Example

import Fastify from "fastify";
import { createAuthService } from "@forjakit/auth";
import {
  createPrismaAuthStorage,
  createPrismaTokenBlacklist,
  createPrismaRefreshTokenStore,
  createPrismaPasswordResetStorage,
  createPrismaPasswordUpdateStorage,
  createPrismaEmailVerificationStorage,
} from "@forjakit/auth-prisma";
import { authPlugin, authenticate, requireRole } from "@forjakit/auth-fastify";
import { PrismaClient } from "@prisma/client";
import { z } from "zod";

const prisma = new PrismaClient();
const app = Fastify({ logger: true });

const roles = z.enum(["admin", "professional", "client"]);

const service = createAuthService({
  storage: createPrismaAuthStorage(prisma.user),
  tokens: { secret: process.env.JWT_SECRET! },
  roles,
  defaultRole: "client",
  tokenBlacklist: createPrismaTokenBlacklist(prisma.tokenBlacklist),
  refreshTokenStore: createPrismaRefreshTokenStore(prisma.refreshToken),
  passwordReset: {
    storage: createPrismaPasswordResetStorage(prisma.passwordResetToken),
    passwordStorage: createPrismaPasswordUpdateStorage(prisma.user),
  },
  emailVerification: {
    storage: createPrismaEmailVerificationStorage(prisma.emailVerificationToken, prisma.user),
  },
});

app.register(authPlugin, {
  service,
  prefix: "/auth",
  cookie: { enabled: true, secure: true },
  onPasswordResetToken: async (token, { email }) => {
    await sendResetEmail(email, token);
  },
  onVerificationToken: async (token, { email }) => {
    await sendVerificationEmail(email, token);
  },
});

// Protect other routes
app.get("/api/profile", { preHandler: authenticate(service) }, async (req) => {
  return req.authUser;
});

app.delete("/api/admin/users/:id", { preHandler: requireRole(service, "admin") }, async (req) => {
  // admin-only logic
});

app.listen({ port: 3000 });

With this setup, the following routes are available:

POST /auth/register
POST /auth/login
POST /auth/refresh
GET  /auth/me
POST /auth/logout
POST /auth/request-password-reset
POST /auth/reset-password
POST /auth/verify-email
POST /auth/resend-verification

Exports

export { authPlugin } from "@forjakit/auth-fastify";
export type { AuthPluginOptions, AuthCookieOptions } from "@forjakit/auth-fastify";
export { authenticate, requireRole, createCookieMiddleware } from "@forjakit/auth-fastify";