@forjakit/auth-fastify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,527 @@
1
+ # @forjakit/auth-fastify
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @forjakit/auth-fastify
9
+ ```
10
+
11
+ Peer dependencies: `@forjakit/auth`, `fastify >= 5`, `zod >= 3.24`.
12
+
13
+ ## Plugin Registration
14
+
15
+ ```ts
16
+ import Fastify from "fastify";
17
+ import { authPlugin } from "@forjakit/auth-fastify";
18
+ import { createAuthService } from "@forjakit/auth";
19
+
20
+ const app = Fastify();
21
+
22
+ const service = createAuthService({
23
+ storage: myStorage,
24
+ tokens: { secret: process.env.JWT_SECRET! },
25
+ roles: myRoles,
26
+ defaultRole: "client",
27
+ });
28
+
29
+ app.register(authPlugin, {
30
+ service,
31
+ prefix: "/auth",
32
+ tenantResolver: (req) => req.headers["x-tenant-id"] as string,
33
+ rateLimiter: myRateLimiter,
34
+ onPasswordResetToken: async (token, { email, tenantId }) => {
35
+ await sendResetEmail(email, token);
36
+ },
37
+ onVerificationToken: async (token, { email, tenantId }) => {
38
+ await sendVerificationEmail(email, token);
39
+ },
40
+ });
41
+ ```
42
+
43
+ ### AuthPluginOptions
44
+
45
+ | Option | Type | Required | Default | Description |
46
+ | ------ | ---- | -------- | ------- | ----------- |
47
+ | `service` | `AuthService` | Yes | -- | Service instance from `createAuthService` |
48
+ | `prefix` | `string` | No | `""` | Route prefix (e.g., `"/auth"`) |
49
+ | `tenantResolver` | `(req: FastifyRequest) => string` | No | `x-tenant-id` header or subdomain | Extracts tenant ID from the request |
50
+ | `rateLimiter` | `RateLimiter` | No | None | Applied to register, login, and password reset routes |
51
+ | `rateLimitKeyResolver` | `(req: FastifyRequest) => string` | No | `req.ip` | Extracts the rate limit key from a request |
52
+ | `onPasswordResetToken` | `(token, { email, tenantId }) => Promise<void>` | No | -- | Callback to send the reset email |
53
+ | `onVerificationToken` | `(token, { email, tenantId }) => Promise<void>` | No | -- | Callback to send the verification email |
54
+ | `cookie` | `AuthCookieOptions` | No | Disabled | Cookie-based token delivery configuration |
55
+
56
+ ## Routes
57
+
58
+ All routes are relative to the configured `prefix`. Tenant ID is resolved from every request via `tenantResolver`.
59
+
60
+ | Method | Path | Rate Limited | Description |
61
+ | ------ | ---- | ------------ | ----------- |
62
+ | `POST` | `/register` | Yes | Register a new user |
63
+ | `POST` | `/login` | Yes | Login with email and password |
64
+ | `POST` | `/refresh` | No | Exchange refresh token for new token pair |
65
+ | `GET` | `/me` | No | Get the authenticated user |
66
+ | `POST` | `/logout` | No | Blacklist the access token |
67
+ | `POST` | `/request-password-reset` | Yes | Request a password reset token |
68
+ | `POST` | `/reset-password` | No | Reset password with token |
69
+ | `POST` | `/verify-email` | No | Verify email with token |
70
+ | `POST` | `/resend-verification` | No | Resend verification email |
71
+
72
+ ### POST /register
73
+
74
+ Creates a new user and returns tokens.
75
+
76
+ **Request body:**
77
+
78
+ ```json
79
+ {
80
+ "email": "user@example.com",
81
+ "password": "Str0ng!Pass",
82
+ "name": "Jane Doe",
83
+ "role": "client"
84
+ }
85
+ ```
86
+
87
+ Note: `tenantId` is injected by the plugin from `tenantResolver`, not from the request body. `role` is optional and defaults to the configured `defaultRole`.
88
+
89
+ **Response:** `201 Created`
90
+
91
+ ```json
92
+ {
93
+ "user": { "id": "...", "email": "user@example.com", "name": "Jane Doe", "role": "client", "tenantId": "..." },
94
+ "accessToken": "eyJ...",
95
+ "refreshToken": "eyJ..."
96
+ }
97
+ ```
98
+
99
+ 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.
100
+
101
+ ### POST /login
102
+
103
+ Authenticates a user.
104
+
105
+ **Request body:**
106
+
107
+ ```json
108
+ {
109
+ "email": "user@example.com",
110
+ "password": "Str0ng!Pass"
111
+ }
112
+ ```
113
+
114
+ **Response:** `200 OK`
115
+
116
+ ```json
117
+ {
118
+ "user": { "id": "...", "email": "...", "name": "...", "role": "...", "tenantId": "..." },
119
+ "accessToken": "eyJ...",
120
+ "refreshToken": "eyJ..."
121
+ }
122
+ ```
123
+
124
+ ### POST /refresh
125
+
126
+ Exchanges a refresh token for a new token pair.
127
+
128
+ **Request body:**
129
+
130
+ ```json
131
+ {
132
+ "refreshToken": "eyJ..."
133
+ }
134
+ ```
135
+
136
+ **Response:** `200 OK`
137
+
138
+ ```json
139
+ {
140
+ "accessToken": "eyJ...",
141
+ "refreshToken": "eyJ..."
142
+ }
143
+ ```
144
+
145
+ In cookie mode, the refresh token is read from the cookie. The response body contains `{ "message": "Tokens refreshed" }` instead of the tokens.
146
+
147
+ ### GET /me
148
+
149
+ Returns the authenticated user. Requires a valid access token in the `Authorization: Bearer <token>` header or access token cookie.
150
+
151
+ **Response:** `200 OK`
152
+
153
+ ```json
154
+ {
155
+ "id": "...",
156
+ "email": "user@example.com",
157
+ "name": "Jane Doe",
158
+ "role": "client",
159
+ "tenantId": "..."
160
+ }
161
+ ```
162
+
163
+ ### POST /logout
164
+
165
+ Blacklists the access token. Requires `tokenBlacklist` to be configured on the auth service.
166
+
167
+ **Response:** `204 No Content`
168
+
169
+ In cookie mode, token cookies are cleared.
170
+
171
+ ### POST /request-password-reset
172
+
173
+ Generates a reset token and invokes `onPasswordResetToken` if the user exists. Always returns the same response to avoid leaking whether the email is registered.
174
+
175
+ **Request body:**
176
+
177
+ ```json
178
+ {
179
+ "email": "user@example.com"
180
+ }
181
+ ```
182
+
183
+ **Response:** `200 OK`
184
+
185
+ ```json
186
+ {
187
+ "message": "If the email exists, a reset link has been sent"
188
+ }
189
+ ```
190
+
191
+ ### POST /reset-password
192
+
193
+ Validates the reset token and updates the password.
194
+
195
+ **Request body:**
196
+
197
+ ```json
198
+ {
199
+ "token": "abc123...",
200
+ "password": "NewStr0ng!Pass"
201
+ }
202
+ ```
203
+
204
+ **Response:** `200 OK`
205
+
206
+ ```json
207
+ {
208
+ "message": "Password has been reset"
209
+ }
210
+ ```
211
+
212
+ ### POST /verify-email
213
+
214
+ Validates the verification token and marks the email as verified.
215
+
216
+ **Request body:**
217
+
218
+ ```json
219
+ {
220
+ "token": "abc123..."
221
+ }
222
+ ```
223
+
224
+ **Response:** `200 OK`
225
+
226
+ ```json
227
+ {
228
+ "message": "Email verified"
229
+ }
230
+ ```
231
+
232
+ ### POST /resend-verification
233
+
234
+ Generates a new verification token and invokes `onVerificationToken` if the user exists.
235
+
236
+ **Request body:**
237
+
238
+ ```json
239
+ {
240
+ "email": "user@example.com"
241
+ }
242
+ ```
243
+
244
+ **Response:** `200 OK`
245
+
246
+ ```json
247
+ {
248
+ "message": "If the email exists, a verification link has been sent"
249
+ }
250
+ ```
251
+
252
+ ## Cookie-Based Token Delivery
253
+
254
+ When enabled, tokens are delivered as `HttpOnly` cookies instead of in the response body. The plugin automatically registers `@fastify/cookie`.
255
+
256
+ ```ts
257
+ app.register(authPlugin, {
258
+ service,
259
+ cookie: {
260
+ enabled: true,
261
+ secure: true, // default: true in production
262
+ sameSite: "lax", // default: "lax"
263
+ domain: ".example.com", // default: request hostname
264
+ path: "/", // default: "/"
265
+ accessTokenName: "access_token", // default: "access_token"
266
+ refreshTokenName: "refresh_token", // default: "refresh_token"
267
+ },
268
+ });
269
+ ```
270
+
271
+ ### AuthCookieOptions
272
+
273
+ | Option | Type | Required | Default | Description |
274
+ | ------ | ---- | -------- | ------- | ----------- |
275
+ | `enabled` | `boolean` | Yes | -- | Enable cookie-based token delivery |
276
+ | `secure` | `boolean` | No | `true` in production | Set the `Secure` flag (HTTPS only) |
277
+ | `sameSite` | `"strict" \| "lax" \| "none"` | No | `"lax"` | SameSite attribute |
278
+ | `domain` | `string` | No | Request hostname | Cookie domain |
279
+ | `path` | `string` | No | `"/"` | Cookie path |
280
+ | `accessTokenName` | `string` | No | `"access_token"` | Cookie name for the access token |
281
+ | `refreshTokenName` | `string` | No | `"refresh_token"` | Cookie name for the refresh token |
282
+
283
+ **How it works:**
284
+
285
+ - On **login/register**, tokens are set as `HttpOnly` cookies. The response body contains the user data but not the tokens.
286
+ - On **refresh**, the refresh token is read from the cookie. New tokens are set as cookies. The response body contains `{ "message": "Tokens refreshed" }`.
287
+ - On **logout** and **401 errors**, token cookies are cleared (set to empty with `maxAge: 0`).
288
+ - **Token extraction** always checks cookies first, then falls back to the `Authorization: Bearer` header.
289
+
290
+ Cookie max ages: access token = 15 minutes, refresh token = 7 days.
291
+
292
+ ## Middleware
293
+
294
+ Standalone middleware factories for protecting routes in other plugins.
295
+
296
+ ### `authenticate(service, cookieAccessTokenName?)`
297
+
298
+ Fastify preHandler that authenticates the request via Bearer token or cookie and populates `request.authUser`.
299
+
300
+ ```ts
301
+ import { authenticate } from "@forjakit/auth-fastify";
302
+
303
+ app.get("/protected", { preHandler: authenticate(service) }, async (req) => {
304
+ const user = req.authUser;
305
+ return { message: `Hello ${user.name}` };
306
+ });
307
+ ```
308
+
309
+ ### `requireRole(service, ...roles)`
310
+
311
+ Fastify preHandler that authenticates and checks if the user has one of the required roles.
312
+
313
+ ```ts
314
+ import { requireRole } from "@forjakit/auth-fastify";
315
+
316
+ app.delete("/admin-only", { preHandler: requireRole(service, "admin") }, async (req) => {
317
+ return { message: "Admin action performed" };
318
+ });
319
+ ```
320
+
321
+ ### `createCookieMiddleware(service, accessTokenName)`
322
+
323
+ 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.
324
+
325
+ ```ts
326
+ import { createCookieMiddleware } from "@forjakit/auth-fastify";
327
+
328
+ const { authenticate, requireRole } = createCookieMiddleware(service, "access_token");
329
+
330
+ app.get("/profile", { preHandler: authenticate() }, handler);
331
+ app.delete("/admin", { preHandler: requireRole("admin") }, handler);
332
+ ```
333
+
334
+ ### request.authUser
335
+
336
+ All middleware populates `request.authUser` with the authenticated user object:
337
+
338
+ ```ts
339
+ interface FastifyRequest {
340
+ authUser?: {
341
+ id: string;
342
+ email: string;
343
+ name: string;
344
+ role: string;
345
+ tenantId: string;
346
+ };
347
+ }
348
+ ```
349
+
350
+ ## Tenant Resolution
351
+
352
+ By default, the plugin resolves the tenant ID using this strategy:
353
+
354
+ 1. Read the `x-tenant-id` header.
355
+ 2. Fall back to the first subdomain of the request hostname (e.g., `acme.example.com` -> `"acme"`).
356
+ 3. Throw `AuthError` with code `TENANT_REQUIRED` (400) if neither is available.
357
+
358
+ Provide a custom resolver to extract the tenant ID from any source:
359
+
360
+ ```ts
361
+ app.register(authPlugin, {
362
+ service,
363
+ tenantResolver: (req) => {
364
+ // From JWT claims, database lookup, etc.
365
+ return (req as any).user.tenantId;
366
+ },
367
+ });
368
+ ```
369
+
370
+ ## Rate Limiting
371
+
372
+ The plugin accepts a `RateLimiter` interface (defined in `@forjakit/auth`). Rate limiting is applied to `register`, `login`, and `request-password-reset` routes.
373
+
374
+ ```ts
375
+ app.register(authPlugin, {
376
+ service,
377
+ rateLimiter: myRedisRateLimiter,
378
+ rateLimitKeyResolver: (req) => req.ip, // default
379
+ });
380
+ ```
381
+
382
+ When rate limited, the response includes a `Retry-After` header and returns:
383
+
384
+ ```json
385
+ {
386
+ "error": "RATE_LIMITED",
387
+ "message": "Too many requests, retry after 60 seconds"
388
+ }
389
+ ```
390
+
391
+ HTTP status: `429`.
392
+
393
+ ## Error Handling
394
+
395
+ The plugin registers a scoped Fastify error handler that converts known errors into structured JSON responses.
396
+
397
+ ### AuthError Response
398
+
399
+ ```json
400
+ {
401
+ "error": "INVALID_CREDENTIALS",
402
+ "message": "Invalid email or password"
403
+ }
404
+ ```
405
+
406
+ HTTP status codes are taken directly from the `AuthError.statusCode` property. See the core package documentation for the full error code table.
407
+
408
+ ### ZodError Response (Validation Failures)
409
+
410
+ ```json
411
+ {
412
+ "error": "VALIDATION_ERROR",
413
+ "message": "Invalid input",
414
+ "details": [
415
+ { "field": "password", "message": "Password must be at least 8 characters" },
416
+ { "field": "email", "message": "Invalid email" }
417
+ ]
418
+ }
419
+ ```
420
+
421
+ HTTP status: `400`.
422
+
423
+ ### Unhandled Errors
424
+
425
+ Any error that is not an `AuthError` or `ZodError` is re-thrown to Fastify's default error handler.
426
+
427
+ ## Email Callbacks
428
+
429
+ The plugin does not send emails. Instead, it invokes optional callbacks with the raw token and user info. You provide the email delivery logic.
430
+
431
+ ```ts
432
+ app.register(authPlugin, {
433
+ service,
434
+ onPasswordResetToken: async (token, { email, tenantId }) => {
435
+ const resetUrl = `https://app.example.com/reset-password?token=${token}`;
436
+ await emailService.send(email, "Reset your password", resetUrl);
437
+ },
438
+ onVerificationToken: async (token, { email, tenantId }) => {
439
+ const verifyUrl = `https://app.example.com/verify-email?token=${token}`;
440
+ await emailService.send(email, "Verify your email", verifyUrl);
441
+ },
442
+ });
443
+ ```
444
+
445
+ ## Full Integration Example
446
+
447
+ ```ts
448
+ import Fastify from "fastify";
449
+ import { createAuthService } from "@forjakit/auth";
450
+ import {
451
+ createPrismaAuthStorage,
452
+ createPrismaTokenBlacklist,
453
+ createPrismaRefreshTokenStore,
454
+ createPrismaPasswordResetStorage,
455
+ createPrismaPasswordUpdateStorage,
456
+ createPrismaEmailVerificationStorage,
457
+ } from "@forjakit/auth-prisma";
458
+ import { authPlugin, authenticate, requireRole } from "@forjakit/auth-fastify";
459
+ import { PrismaClient } from "@prisma/client";
460
+ import { z } from "zod";
461
+
462
+ const prisma = new PrismaClient();
463
+ const app = Fastify({ logger: true });
464
+
465
+ const roles = z.enum(["admin", "professional", "client"]);
466
+
467
+ const service = createAuthService({
468
+ storage: createPrismaAuthStorage(prisma.user),
469
+ tokens: { secret: process.env.JWT_SECRET! },
470
+ roles,
471
+ defaultRole: "client",
472
+ tokenBlacklist: createPrismaTokenBlacklist(prisma.tokenBlacklist),
473
+ refreshTokenStore: createPrismaRefreshTokenStore(prisma.refreshToken),
474
+ passwordReset: {
475
+ storage: createPrismaPasswordResetStorage(prisma.passwordResetToken),
476
+ passwordStorage: createPrismaPasswordUpdateStorage(prisma.user),
477
+ },
478
+ emailVerification: {
479
+ storage: createPrismaEmailVerificationStorage(prisma.emailVerificationToken, prisma.user),
480
+ },
481
+ });
482
+
483
+ app.register(authPlugin, {
484
+ service,
485
+ prefix: "/auth",
486
+ cookie: { enabled: true, secure: true },
487
+ onPasswordResetToken: async (token, { email }) => {
488
+ await sendResetEmail(email, token);
489
+ },
490
+ onVerificationToken: async (token, { email }) => {
491
+ await sendVerificationEmail(email, token);
492
+ },
493
+ });
494
+
495
+ // Protect other routes
496
+ app.get("/api/profile", { preHandler: authenticate(service) }, async (req) => {
497
+ return req.authUser;
498
+ });
499
+
500
+ app.delete("/api/admin/users/:id", { preHandler: requireRole(service, "admin") }, async (req) => {
501
+ // admin-only logic
502
+ });
503
+
504
+ app.listen({ port: 3000 });
505
+ ```
506
+
507
+ With this setup, the following routes are available:
508
+
509
+ ```
510
+ POST /auth/register
511
+ POST /auth/login
512
+ POST /auth/refresh
513
+ GET /auth/me
514
+ POST /auth/logout
515
+ POST /auth/request-password-reset
516
+ POST /auth/reset-password
517
+ POST /auth/verify-email
518
+ POST /auth/resend-verification
519
+ ```
520
+
521
+ ## Exports
522
+
523
+ ```ts
524
+ export { authPlugin } from "@forjakit/auth-fastify";
525
+ export type { AuthPluginOptions, AuthCookieOptions } from "@forjakit/auth-fastify";
526
+ export { authenticate, requireRole, createCookieMiddleware } from "@forjakit/auth-fastify";
527
+ ```
@@ -0,0 +1,94 @@
1
+ import { FastifyRequest, FastifyInstance, FastifyReply } from 'fastify';
2
+ import { AuthService, RateLimiter } from '@forjakit/auth';
3
+
4
+ declare module "fastify" {
5
+ interface FastifyRequest {
6
+ authUser?: Record<string, unknown>;
7
+ }
8
+ }
9
+ /** Cookie configuration for secure token delivery. */
10
+ interface AuthCookieOptions {
11
+ /** Enable cookie-based token delivery. When true, tokens are set as HttpOnly cookies instead of returned in the response body. */
12
+ enabled: boolean;
13
+ /** Whether to set the Secure flag (HTTPS only). Defaults to `true` in production. */
14
+ secure?: boolean;
15
+ /** SameSite attribute for cookies. Defaults to `"lax"`. */
16
+ sameSite?: "strict" | "lax" | "none";
17
+ /** Cookie domain. Defaults to the request hostname. */
18
+ domain?: string;
19
+ /** Cookie path. Defaults to `"/"`. */
20
+ path?: string;
21
+ /** Name for the access token cookie. Defaults to `"access_token"`. */
22
+ accessTokenName?: string;
23
+ /** Name for the refresh token cookie. Defaults to `"refresh_token"`. */
24
+ refreshTokenName?: string;
25
+ }
26
+ /** Options for registering the auth Fastify plugin. */
27
+ interface AuthPluginOptions {
28
+ /** The auth service instance created via `createAuthService`. */
29
+ service: AuthService;
30
+ /** Custom function to extract the tenant ID from a request. Defaults to `x-tenant-id` header or subdomain. */
31
+ tenantResolver?: (req: FastifyRequest) => string;
32
+ /** Route prefix (e.g., "/auth"). */
33
+ prefix?: string;
34
+ /** Optional rate limiter. Applied to login, register, and password reset routes. */
35
+ rateLimiter?: RateLimiter;
36
+ /** Custom function to extract the rate limit key from a request. Defaults to IP address. */
37
+ rateLimitKeyResolver?: (req: FastifyRequest) => string;
38
+ /** Optional callback invoked with the raw reset token and user info. Use this to send the reset email. */
39
+ onPasswordResetToken?: (token: string, user: {
40
+ email: string;
41
+ tenantId: string;
42
+ }) => Promise<void>;
43
+ /** Optional callback invoked with the raw verification token and user info. Use this to send the verification email. */
44
+ onVerificationToken?: (token: string, user: {
45
+ email: string;
46
+ tenantId: string;
47
+ }) => Promise<void>;
48
+ /** Optional cookie configuration. When enabled, tokens are delivered via HttpOnly cookies instead of the response body. */
49
+ cookie?: AuthCookieOptions;
50
+ }
51
+ /**
52
+ * Fastify plugin that registers auth routes and handles AuthError/ZodError responses.
53
+ * Routes: POST /register, /login, /refresh, /logout, /request-password-reset, /reset-password, /verify-email, /resend-verification. GET /me.
54
+ *
55
+ * @param fastify - Fastify instance.
56
+ * @param options - Plugin options including auth service, tenant resolver, and optional cookie configuration.
57
+ */
58
+ declare function authPlugin(fastify: FastifyInstance, options: AuthPluginOptions): Promise<void>;
59
+ /**
60
+ * Fastify preHandler that authenticates the request via Bearer token or cookie and populates `request.authUser`.
61
+ * @param service - The auth service instance.
62
+ * @param cookieAccessTokenName - Optional cookie name for the access token. When provided, checks cookies before the Authorization header.
63
+ * @returns Fastify preHandler function.
64
+ */
65
+ declare function authenticate(service: AuthService, cookieAccessTokenName?: string): (request: FastifyRequest, reply: FastifyReply) => Promise<undefined>;
66
+ /**
67
+ * Fastify preHandler that authenticates and checks if the user has one of the required roles.
68
+ * @param service - The auth service instance.
69
+ * @param roles - Allowed roles for this route. Optionally, the first argument can be a cookie access token name if it's not a valid role.
70
+ * @returns Fastify preHandler function.
71
+ */
72
+ declare function requireRole(service: AuthService, ...roles: string[]): (request: FastifyRequest, reply: FastifyReply) => Promise<undefined>;
73
+ /**
74
+ * Creates middleware factories that are pre-configured with cookie support.
75
+ * Use this when cookie mode is enabled to avoid passing the cookie name to every middleware call.
76
+ * @param service - The auth service instance.
77
+ * @param accessTokenName - Cookie name for the access token.
78
+ * @returns Object with `authenticate` and `requireRole` middleware factories.
79
+ */
80
+ declare function createCookieMiddleware(service: AuthService, accessTokenName: string): {
81
+ /**
82
+ * Fastify preHandler that authenticates via cookie or Bearer token and populates `request.authUser`.
83
+ * @returns Fastify preHandler function.
84
+ */
85
+ authenticate(): (request: FastifyRequest, reply: FastifyReply) => Promise<undefined>;
86
+ /**
87
+ * Fastify preHandler that authenticates via cookie or Bearer token and checks if the user has one of the required roles.
88
+ * @param roles - Allowed roles for this route.
89
+ * @returns Fastify preHandler function.
90
+ */
91
+ requireRole(...roles: string[]): (request: FastifyRequest, reply: FastifyReply) => Promise<undefined>;
92
+ };
93
+
94
+ export { type AuthCookieOptions, type AuthPluginOptions, authPlugin, authenticate, createCookieMiddleware, requireRole };
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ // src/plugin.ts
2
+ import { AuthError, Errors } from "@forjakit/auth";
3
+ import { ZodError } from "zod";
4
+ function defaultTenantResolver(req) {
5
+ const tenantId = req.headers["x-tenant-id"] ?? req.hostname.split(".")[0];
6
+ if (!tenantId) {
7
+ throw new AuthError("Tenant ID is required", "TENANT_REQUIRED", 400);
8
+ }
9
+ return tenantId;
10
+ }
11
+ function defaultRateLimitKeyResolver(req) {
12
+ return req.ip;
13
+ }
14
+ function setTokenCookies(reply, tokens, cookieOpts) {
15
+ const baseOpts = {
16
+ httpOnly: true,
17
+ secure: cookieOpts.secure,
18
+ sameSite: cookieOpts.sameSite,
19
+ path: cookieOpts.path,
20
+ ...cookieOpts.domain ? { domain: cookieOpts.domain } : {}
21
+ };
22
+ reply.setCookie(cookieOpts.accessTokenName, tokens.accessToken, {
23
+ ...baseOpts,
24
+ maxAge: 15 * 60
25
+ // 15 minutes — matches default access token expiry
26
+ });
27
+ reply.setCookie(cookieOpts.refreshTokenName, tokens.refreshToken, {
28
+ ...baseOpts,
29
+ maxAge: 7 * 24 * 60 * 60
30
+ // 7 days — matches default refresh token expiry
31
+ });
32
+ }
33
+ function clearTokenCookies(reply, cookieOpts) {
34
+ const clearOpts = {
35
+ httpOnly: true,
36
+ path: cookieOpts.path,
37
+ ...cookieOpts.domain ? { domain: cookieOpts.domain } : {},
38
+ maxAge: 0
39
+ };
40
+ reply.setCookie(cookieOpts.accessTokenName, "", clearOpts);
41
+ reply.setCookie(cookieOpts.refreshTokenName, "", clearOpts);
42
+ }
43
+ function extractToken(request, accessTokenName) {
44
+ if (accessTokenName) {
45
+ const cookieToken = request.cookies?.[accessTokenName];
46
+ if (cookieToken) return cookieToken;
47
+ }
48
+ const header = request.headers.authorization;
49
+ if (header?.startsWith("Bearer ")) {
50
+ return header.slice(7);
51
+ }
52
+ throw new AuthError("Missing or invalid authorization header", "INVALID_TOKEN", 401);
53
+ }
54
+ function extractRefreshToken(request, refreshTokenName) {
55
+ if (refreshTokenName) {
56
+ return request.cookies?.[refreshTokenName];
57
+ }
58
+ return void 0;
59
+ }
60
+ async function authPlugin(fastify, options) {
61
+ const {
62
+ service,
63
+ tenantResolver = defaultTenantResolver,
64
+ rateLimiter,
65
+ rateLimitKeyResolver = defaultRateLimitKeyResolver,
66
+ onPasswordResetToken,
67
+ onVerificationToken,
68
+ cookie
69
+ } = options;
70
+ const { schemas } = service;
71
+ const cookieEnabled = cookie?.enabled ?? false;
72
+ const cookieOpts = cookieEnabled ? {
73
+ secure: cookie.secure ?? process.env.NODE_ENV === "production",
74
+ sameSite: cookie.sameSite ?? "lax",
75
+ domain: cookie.domain,
76
+ path: cookie.path ?? "/",
77
+ accessTokenName: cookie.accessTokenName ?? "access_token",
78
+ refreshTokenName: cookie.refreshTokenName ?? "refresh_token"
79
+ } : void 0;
80
+ if (cookieEnabled) {
81
+ await fastify.register(import("@fastify/cookie"));
82
+ }
83
+ function rateLimit(action) {
84
+ if (!rateLimiter) return void 0;
85
+ return async (request, reply) => {
86
+ const key = rateLimitKeyResolver(request);
87
+ const result = await rateLimiter.consume(key, action);
88
+ if (!result.allowed) {
89
+ if (result.retryAfter) {
90
+ reply.header("Retry-After", String(result.retryAfter));
91
+ }
92
+ throw Errors.rateLimited(result.retryAfter);
93
+ }
94
+ };
95
+ }
96
+ fastify.setErrorHandler((error, _request, reply) => {
97
+ if (error instanceof AuthError) {
98
+ const response = { error: error.code, message: error.message };
99
+ if (error.statusCode === 429) {
100
+ reply.header("Retry-After", reply.getHeader("Retry-After") ?? "60");
101
+ }
102
+ if (cookieEnabled && error.statusCode === 401) {
103
+ clearTokenCookies(reply, cookieOpts);
104
+ }
105
+ return reply.status(error.statusCode).send(response);
106
+ }
107
+ if (error instanceof ZodError) {
108
+ return reply.status(400).send({
109
+ error: "VALIDATION_ERROR",
110
+ message: "Invalid input",
111
+ details: error.errors.map((e) => ({
112
+ field: e.path.join("."),
113
+ message: e.message
114
+ }))
115
+ });
116
+ }
117
+ throw error;
118
+ });
119
+ const registerOpts = rateLimit("register") ? { preHandler: rateLimit("register") } : {};
120
+ fastify.post("/register", registerOpts, async (request, reply) => {
121
+ const tenantId = tenantResolver(request);
122
+ const body = request.body;
123
+ const result = await service.register({ ...body, tenantId });
124
+ if (result.verificationToken && onVerificationToken) {
125
+ await onVerificationToken(result.verificationToken, { email: result.user.email, tenantId });
126
+ }
127
+ if (cookieEnabled && cookieOpts) {
128
+ setTokenCookies(reply, result, cookieOpts);
129
+ const { accessToken: _, refreshToken: __, ...rest } = result;
130
+ return reply.status(201).send(rest);
131
+ }
132
+ return reply.status(201).send(result);
133
+ });
134
+ const loginOpts = rateLimit("login") ? { preHandler: rateLimit("login") } : {};
135
+ fastify.post("/login", loginOpts, async (request, reply) => {
136
+ const tenantId = tenantResolver(request);
137
+ const body = request.body;
138
+ const result = await service.login({ ...body, tenantId });
139
+ if (cookieEnabled && cookieOpts) {
140
+ setTokenCookies(reply, result, cookieOpts);
141
+ const { accessToken: _, refreshToken: __, ...rest } = result;
142
+ return reply.status(200).send(rest);
143
+ }
144
+ return reply.status(200).send(result);
145
+ });
146
+ fastify.post("/refresh", async (request, reply) => {
147
+ let refreshToken;
148
+ if (cookieEnabled && cookieOpts) {
149
+ const cookieRefresh = extractRefreshToken(request, cookieOpts.refreshTokenName);
150
+ if (cookieRefresh) {
151
+ refreshToken = cookieRefresh;
152
+ } else {
153
+ const parsed = schemas.RefreshInput.parse(request.body);
154
+ refreshToken = parsed.refreshToken;
155
+ }
156
+ } else {
157
+ const parsed = schemas.RefreshInput.parse(request.body);
158
+ refreshToken = parsed.refreshToken;
159
+ }
160
+ const result = await service.refresh(refreshToken);
161
+ if (cookieEnabled && cookieOpts) {
162
+ setTokenCookies(reply, result, cookieOpts);
163
+ return reply.status(200).send({ message: "Tokens refreshed" });
164
+ }
165
+ return reply.status(200).send(result);
166
+ });
167
+ fastify.get("/me", async (request, reply) => {
168
+ const token = extractToken(request, cookieOpts?.accessTokenName);
169
+ const user = await service.authenticate(token);
170
+ return reply.status(200).send(user);
171
+ });
172
+ fastify.post("/logout", async (request, reply) => {
173
+ const token = extractToken(request, cookieOpts?.accessTokenName);
174
+ await service.logout(token);
175
+ if (cookieEnabled && cookieOpts) {
176
+ clearTokenCookies(reply, cookieOpts);
177
+ }
178
+ return reply.status(204).send();
179
+ });
180
+ const resetReqOpts = rateLimit("request-password-reset") ? { preHandler: rateLimit("request-password-reset") } : {};
181
+ fastify.post("/request-password-reset", resetReqOpts, async (request, reply) => {
182
+ const tenantId = tenantResolver(request);
183
+ const body = request.body;
184
+ const parsed = schemas.RequestPasswordResetInput.parse({ ...body, tenantId });
185
+ const token = await service.requestPasswordReset(parsed.email, parsed.tenantId);
186
+ if (token && onPasswordResetToken) {
187
+ await onPasswordResetToken(token, { email: parsed.email, tenantId });
188
+ }
189
+ return reply.status(200).send({ message: "If the email exists, a reset link has been sent" });
190
+ });
191
+ fastify.post("/reset-password", async (request, reply) => {
192
+ const tenantId = tenantResolver(request);
193
+ const body = request.body;
194
+ await service.resetPassword(
195
+ body.token,
196
+ body.password,
197
+ tenantId
198
+ );
199
+ return reply.status(200).send({ message: "Password has been reset" });
200
+ });
201
+ fastify.post("/verify-email", async (request, reply) => {
202
+ const body = request.body;
203
+ const parsed = schemas.VerifyEmailInput.parse(body);
204
+ await service.verifyEmail(parsed.token);
205
+ return reply.status(200).send({ message: "Email verified" });
206
+ });
207
+ fastify.post("/resend-verification", async (request, reply) => {
208
+ const tenantId = tenantResolver(request);
209
+ const body = request.body;
210
+ const email = body.email;
211
+ const token = await service.resendVerificationEmail(email, tenantId);
212
+ if (token && onVerificationToken) {
213
+ await onVerificationToken(token, { email, tenantId });
214
+ }
215
+ return reply.status(200).send({ message: "If the email exists, a verification link has been sent" });
216
+ });
217
+ }
218
+ function authenticate(service, cookieAccessTokenName) {
219
+ return async (request, reply) => {
220
+ try {
221
+ const token = extractToken(request, cookieAccessTokenName);
222
+ request.authUser = await service.authenticate(token);
223
+ } catch (error) {
224
+ if (error instanceof AuthError) {
225
+ return reply.status(error.statusCode).send({ error: error.code, message: error.message });
226
+ }
227
+ throw error;
228
+ }
229
+ };
230
+ }
231
+ function requireRole(service, ...roles) {
232
+ const checkRole = service.authorize(...roles);
233
+ return async (request, reply) => {
234
+ try {
235
+ const token = extractToken(request);
236
+ const user = await service.authenticate(token);
237
+ checkRole(user);
238
+ request.authUser = user;
239
+ } catch (error) {
240
+ if (error instanceof AuthError) {
241
+ return reply.status(error.statusCode).send({ error: error.code, message: error.message });
242
+ }
243
+ throw error;
244
+ }
245
+ };
246
+ }
247
+ function createCookieMiddleware(service, accessTokenName) {
248
+ return {
249
+ /**
250
+ * Fastify preHandler that authenticates via cookie or Bearer token and populates `request.authUser`.
251
+ * @returns Fastify preHandler function.
252
+ */
253
+ authenticate() {
254
+ return async (request, reply) => {
255
+ try {
256
+ const token = extractToken(request, accessTokenName);
257
+ request.authUser = await service.authenticate(token);
258
+ } catch (error) {
259
+ if (error instanceof AuthError) {
260
+ return reply.status(error.statusCode).send({ error: error.code, message: error.message });
261
+ }
262
+ throw error;
263
+ }
264
+ };
265
+ },
266
+ /**
267
+ * Fastify preHandler that authenticates via cookie or Bearer token and checks if the user has one of the required roles.
268
+ * @param roles - Allowed roles for this route.
269
+ * @returns Fastify preHandler function.
270
+ */
271
+ requireRole(...roles) {
272
+ const checkRole = service.authorize(...roles);
273
+ return async (request, reply) => {
274
+ try {
275
+ const token = extractToken(request, accessTokenName);
276
+ const user = await service.authenticate(token);
277
+ checkRole(user);
278
+ request.authUser = user;
279
+ } catch (error) {
280
+ if (error instanceof AuthError) {
281
+ return reply.status(error.statusCode).send({ error: error.code, message: error.message });
282
+ }
283
+ throw error;
284
+ }
285
+ };
286
+ }
287
+ };
288
+ }
289
+ export {
290
+ authPlugin,
291
+ authenticate,
292
+ createCookieMiddleware,
293
+ requireRole
294
+ };
295
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/plugin.ts"],"sourcesContent":["import type { FastifyInstance, FastifyRequest, FastifyReply } from \"fastify\";\nimport { type AuthService, type RateLimiter, AuthError, Errors } from \"@forjakit/auth\";\nimport { ZodError } from \"zod\";\n\ndeclare module \"fastify\" {\n interface FastifyRequest {\n authUser?: Record<string, unknown>;\n }\n}\n\n/** Cookie configuration for secure token delivery. */\nexport interface AuthCookieOptions {\n /** Enable cookie-based token delivery. When true, tokens are set as HttpOnly cookies instead of returned in the response body. */\n enabled: boolean;\n /** Whether to set the Secure flag (HTTPS only). Defaults to `true` in production. */\n secure?: boolean;\n /** SameSite attribute for cookies. Defaults to `\"lax\"`. */\n sameSite?: \"strict\" | \"lax\" | \"none\";\n /** Cookie domain. Defaults to the request hostname. */\n domain?: string;\n /** Cookie path. Defaults to `\"/\"`. */\n path?: string;\n /** Name for the access token cookie. Defaults to `\"access_token\"`. */\n accessTokenName?: string;\n /** Name for the refresh token cookie. Defaults to `\"refresh_token\"`. */\n refreshTokenName?: string;\n}\n\n/** Options for registering the auth Fastify plugin. */\nexport interface AuthPluginOptions {\n /** The auth service instance created via `createAuthService`. */\n service: AuthService;\n /** Custom function to extract the tenant ID from a request. Defaults to `x-tenant-id` header or subdomain. */\n tenantResolver?: (req: FastifyRequest) => string;\n /** Route prefix (e.g., \"/auth\"). */\n prefix?: string;\n /** Optional rate limiter. Applied to login, register, and password reset routes. */\n rateLimiter?: RateLimiter;\n /** Custom function to extract the rate limit key from a request. Defaults to IP address. */\n rateLimitKeyResolver?: (req: FastifyRequest) => string;\n /** Optional callback invoked with the raw reset token and user info. Use this to send the reset email. */\n onPasswordResetToken?: (token: string, user: { email: string; tenantId: string }) => Promise<void>;\n /** Optional callback invoked with the raw verification token and user info. Use this to send the verification email. */\n onVerificationToken?: (token: string, user: { email: string; tenantId: string }) => Promise<void>;\n /** Optional cookie configuration. When enabled, tokens are delivered via HttpOnly cookies instead of the response body. */\n cookie?: AuthCookieOptions;\n}\n\nfunction defaultTenantResolver(req: FastifyRequest): string {\n const tenantId =\n (req.headers[\"x-tenant-id\"] as string) ??\n req.hostname.split(\".\")[0];\n\n if (!tenantId) {\n throw new AuthError(\"Tenant ID is required\", \"TENANT_REQUIRED\", 400);\n }\n\n return tenantId;\n}\n\nfunction defaultRateLimitKeyResolver(req: FastifyRequest): string {\n return req.ip;\n}\n\n/**\n * Sets access and refresh token cookies on the reply.\n * @param reply - Fastify reply object.\n * @param tokens - Object containing accessToken and refreshToken strings.\n * @param cookieOpts - Resolved cookie options.\n */\nfunction setTokenCookies(\n reply: FastifyReply,\n tokens: { accessToken: string; refreshToken: string },\n cookieOpts: Required<Pick<AuthCookieOptions, \"secure\" | \"sameSite\" | \"path\" | \"accessTokenName\" | \"refreshTokenName\">> & Pick<AuthCookieOptions, \"domain\">\n) {\n const baseOpts = {\n httpOnly: true,\n secure: cookieOpts.secure,\n sameSite: cookieOpts.sameSite,\n path: cookieOpts.path,\n ...(cookieOpts.domain ? { domain: cookieOpts.domain } : {}),\n };\n\n reply.setCookie(cookieOpts.accessTokenName, tokens.accessToken, {\n ...baseOpts,\n maxAge: 15 * 60, // 15 minutes — matches default access token expiry\n });\n\n reply.setCookie(cookieOpts.refreshTokenName, tokens.refreshToken, {\n ...baseOpts,\n maxAge: 7 * 24 * 60 * 60, // 7 days — matches default refresh token expiry\n });\n}\n\n/**\n * Clears token cookies from the reply.\n * @param reply - Fastify reply object.\n * @param cookieOpts - Resolved cookie options.\n */\nfunction clearTokenCookies(\n reply: FastifyReply,\n cookieOpts: Required<Pick<AuthCookieOptions, \"path\" | \"accessTokenName\" | \"refreshTokenName\">> & Pick<AuthCookieOptions, \"domain\">\n) {\n const clearOpts = {\n httpOnly: true,\n path: cookieOpts.path,\n ...(cookieOpts.domain ? { domain: cookieOpts.domain } : {}),\n maxAge: 0,\n };\n\n reply.setCookie(cookieOpts.accessTokenName, \"\", clearOpts);\n reply.setCookie(cookieOpts.refreshTokenName, \"\", clearOpts);\n}\n\n/**\n * Extracts the access token from the request. Checks cookies first (if cookie mode is enabled), then falls back to the Authorization header.\n * @param request - Fastify request object.\n * @param accessTokenName - Cookie name for the access token (undefined if cookie mode is disabled).\n * @returns The raw JWT access token string.\n * @throws AuthError if no token is found.\n */\nfunction extractToken(request: FastifyRequest, accessTokenName?: string): string {\n // Try cookie first\n if (accessTokenName) {\n const cookieToken = (request.cookies as Record<string, string | undefined>)?.[accessTokenName];\n if (cookieToken) return cookieToken;\n }\n\n // Fallback to Authorization header\n const header = request.headers.authorization;\n if (header?.startsWith(\"Bearer \")) {\n return header.slice(7);\n }\n\n throw new AuthError(\"Missing or invalid authorization header\", \"INVALID_TOKEN\", 401);\n}\n\n/**\n * Extracts the refresh token from the request. Checks cookies first (if cookie mode is enabled), then falls back to the request body.\n * @param request - Fastify request object.\n * @param refreshTokenName - Cookie name for the refresh token (undefined if cookie mode is disabled).\n * @returns The raw JWT refresh token string, or undefined if not found in cookies.\n */\nfunction extractRefreshToken(request: FastifyRequest, refreshTokenName?: string): string | undefined {\n if (refreshTokenName) {\n return (request.cookies as Record<string, string | undefined>)?.[refreshTokenName];\n }\n return undefined;\n}\n\n/**\n * Fastify plugin that registers auth routes and handles AuthError/ZodError responses.\n * Routes: POST /register, /login, /refresh, /logout, /request-password-reset, /reset-password, /verify-email, /resend-verification. GET /me.\n *\n * @param fastify - Fastify instance.\n * @param options - Plugin options including auth service, tenant resolver, and optional cookie configuration.\n */\nexport async function authPlugin(\n fastify: FastifyInstance,\n options: AuthPluginOptions\n) {\n const {\n service,\n tenantResolver = defaultTenantResolver,\n rateLimiter,\n rateLimitKeyResolver = defaultRateLimitKeyResolver,\n onPasswordResetToken,\n onVerificationToken,\n cookie,\n } = options;\n const { schemas } = service;\n\n // Resolve cookie options\n const cookieEnabled = cookie?.enabled ?? false;\n const cookieOpts = cookieEnabled\n ? {\n secure: cookie!.secure ?? process.env.NODE_ENV === \"production\",\n sameSite: cookie!.sameSite ?? (\"lax\" as const),\n domain: cookie!.domain,\n path: cookie!.path ?? \"/\",\n accessTokenName: cookie!.accessTokenName ?? \"access_token\",\n refreshTokenName: cookie!.refreshTokenName ?? \"refresh_token\",\n }\n : undefined;\n\n // Register @fastify/cookie if cookie mode is enabled\n if (cookieEnabled) {\n await fastify.register(import(\"@fastify/cookie\"));\n }\n\n // Rate limit preHandler factory\n function rateLimit(action: string) {\n if (!rateLimiter) return undefined;\n return async (request: FastifyRequest, reply: FastifyReply) => {\n const key = rateLimitKeyResolver(request);\n const result = await rateLimiter.consume(key, action);\n if (!result.allowed) {\n if (result.retryAfter) {\n reply.header(\"Retry-After\", String(result.retryAfter));\n }\n throw Errors.rateLimited(result.retryAfter);\n }\n };\n }\n\n // Error handler for AuthError and ZodError\n fastify.setErrorHandler((error, _request, reply) => {\n if (error instanceof AuthError) {\n const response: Record<string, unknown> = { error: error.code, message: error.message };\n if (error.statusCode === 429) {\n reply.header(\"Retry-After\", reply.getHeader(\"Retry-After\") ?? \"60\");\n }\n if (cookieEnabled && error.statusCode === 401) {\n clearTokenCookies(reply, cookieOpts!);\n }\n return reply.status(error.statusCode).send(response);\n }\n if (error instanceof ZodError) {\n return reply.status(400).send({\n error: \"VALIDATION_ERROR\",\n message: \"Invalid input\",\n details: error.errors.map((e) => ({\n field: e.path.join(\".\"),\n message: e.message,\n })),\n });\n }\n throw error;\n });\n\n // POST /register\n const registerOpts = rateLimit(\"register\") ? { preHandler: rateLimit(\"register\") } : {};\n fastify.post(\"/register\", registerOpts, async (request: FastifyRequest, reply: FastifyReply) => {\n const tenantId = tenantResolver(request);\n const body = request.body as Record<string, unknown>;\n const result = await service.register({ ...body, tenantId } as Parameters<typeof service.register>[0]);\n\n if (result.verificationToken && onVerificationToken) {\n await onVerificationToken(result.verificationToken, { email: result.user.email, tenantId });\n }\n\n if (cookieEnabled && cookieOpts) {\n setTokenCookies(reply, result, cookieOpts);\n const { accessToken: _, refreshToken: __, ...rest } = result;\n return reply.status(201).send(rest);\n }\n\n return reply.status(201).send(result);\n });\n\n // POST /login\n const loginOpts = rateLimit(\"login\") ? { preHandler: rateLimit(\"login\") } : {};\n fastify.post(\"/login\", loginOpts, async (request: FastifyRequest, reply: FastifyReply) => {\n const tenantId = tenantResolver(request);\n const body = request.body as Record<string, unknown>;\n const result = await service.login({ ...body, tenantId } as { email: string; password: string; tenantId: string });\n\n if (cookieEnabled && cookieOpts) {\n setTokenCookies(reply, result, cookieOpts);\n const { accessToken: _, refreshToken: __, ...rest } = result;\n return reply.status(200).send(rest);\n }\n\n return reply.status(200).send(result);\n });\n\n // POST /refresh\n fastify.post(\"/refresh\", async (request: FastifyRequest, reply: FastifyReply) => {\n let refreshToken: string;\n\n if (cookieEnabled && cookieOpts) {\n const cookieRefresh = extractRefreshToken(request, cookieOpts.refreshTokenName);\n if (cookieRefresh) {\n refreshToken = cookieRefresh;\n } else {\n const parsed = schemas.RefreshInput.parse(request.body);\n refreshToken = parsed.refreshToken;\n }\n } else {\n const parsed = schemas.RefreshInput.parse(request.body);\n refreshToken = parsed.refreshToken;\n }\n\n const result = await service.refresh(refreshToken);\n\n if (cookieEnabled && cookieOpts) {\n setTokenCookies(reply, result, cookieOpts);\n return reply.status(200).send({ message: \"Tokens refreshed\" });\n }\n\n return reply.status(200).send(result);\n });\n\n // GET /me\n fastify.get(\"/me\", async (request: FastifyRequest, reply: FastifyReply) => {\n const token = extractToken(request, cookieOpts?.accessTokenName);\n const user = await service.authenticate(token);\n\n return reply.status(200).send(user);\n });\n\n // POST /logout\n fastify.post(\"/logout\", async (request: FastifyRequest, reply: FastifyReply) => {\n const token = extractToken(request, cookieOpts?.accessTokenName);\n await service.logout(token);\n\n if (cookieEnabled && cookieOpts) {\n clearTokenCookies(reply, cookieOpts);\n }\n\n return reply.status(204).send();\n });\n\n // POST /request-password-reset\n const resetReqOpts = rateLimit(\"request-password-reset\") ? { preHandler: rateLimit(\"request-password-reset\") } : {};\n fastify.post(\"/request-password-reset\", resetReqOpts, async (request: FastifyRequest, reply: FastifyReply) => {\n const tenantId = tenantResolver(request);\n const body = request.body as Record<string, unknown>;\n const parsed = schemas.RequestPasswordResetInput.parse({ ...body, tenantId });\n\n const token = await service.requestPasswordReset(parsed.email, parsed.tenantId);\n\n if (token && onPasswordResetToken) {\n await onPasswordResetToken(token, { email: parsed.email, tenantId });\n }\n\n // Always return success to avoid leaking whether the email exists\n return reply.status(200).send({ message: \"If the email exists, a reset link has been sent\" });\n });\n\n // POST /reset-password\n fastify.post(\"/reset-password\", async (request: FastifyRequest, reply: FastifyReply) => {\n const tenantId = tenantResolver(request);\n const body = request.body as Record<string, unknown>;\n await service.resetPassword(\n (body as { token: string }).token,\n (body as { password: string }).password,\n tenantId\n );\n\n return reply.status(200).send({ message: \"Password has been reset\" });\n });\n\n // POST /verify-email\n fastify.post(\"/verify-email\", async (request: FastifyRequest, reply: FastifyReply) => {\n const body = request.body as Record<string, unknown>;\n const parsed = schemas.VerifyEmailInput.parse(body);\n await service.verifyEmail(parsed.token);\n\n return reply.status(200).send({ message: \"Email verified\" });\n });\n\n // POST /resend-verification\n fastify.post(\"/resend-verification\", async (request: FastifyRequest, reply: FastifyReply) => {\n const tenantId = tenantResolver(request);\n const body = request.body as Record<string, unknown>;\n const email = (body as { email: string }).email;\n\n const token = await service.resendVerificationEmail(email, tenantId);\n\n if (token && onVerificationToken) {\n await onVerificationToken(token, { email, tenantId });\n }\n\n return reply.status(200).send({ message: \"If the email exists, a verification link has been sent\" });\n });\n}\n\n// -- Middleware factories --\n\n/**\n * Fastify preHandler that authenticates the request via Bearer token or cookie and populates `request.authUser`.\n * @param service - The auth service instance.\n * @param cookieAccessTokenName - Optional cookie name for the access token. When provided, checks cookies before the Authorization header.\n * @returns Fastify preHandler function.\n */\nexport function authenticate(service: AuthService, cookieAccessTokenName?: string) {\n return async (request: FastifyRequest, reply: FastifyReply) => {\n try {\n const token = extractToken(request, cookieAccessTokenName);\n request.authUser = await service.authenticate(token);\n } catch (error) {\n if (error instanceof AuthError) {\n return reply\n .status(error.statusCode)\n .send({ error: error.code, message: error.message });\n }\n throw error;\n }\n };\n}\n\n/**\n * Fastify preHandler that authenticates and checks if the user has one of the required roles.\n * @param service - The auth service instance.\n * @param roles - Allowed roles for this route. Optionally, the first argument can be a cookie access token name if it's not a valid role.\n * @returns Fastify preHandler function.\n */\nexport function requireRole(service: AuthService, ...roles: string[]) {\n const checkRole = service.authorize(...roles);\n\n return async (request: FastifyRequest, reply: FastifyReply) => {\n try {\n const token = extractToken(request);\n const user = await service.authenticate(token);\n checkRole(user);\n request.authUser = user;\n } catch (error) {\n if (error instanceof AuthError) {\n return reply\n .status(error.statusCode)\n .send({ error: error.code, message: error.message });\n }\n throw error;\n }\n };\n}\n\n/**\n * Creates middleware factories that are pre-configured with cookie support.\n * Use this when cookie mode is enabled to avoid passing the cookie name to every middleware call.\n * @param service - The auth service instance.\n * @param accessTokenName - Cookie name for the access token.\n * @returns Object with `authenticate` and `requireRole` middleware factories.\n */\nexport function createCookieMiddleware(service: AuthService, accessTokenName: string) {\n return {\n /**\n * Fastify preHandler that authenticates via cookie or Bearer token and populates `request.authUser`.\n * @returns Fastify preHandler function.\n */\n authenticate() {\n return async (request: FastifyRequest, reply: FastifyReply) => {\n try {\n const token = extractToken(request, accessTokenName);\n request.authUser = await service.authenticate(token);\n } catch (error) {\n if (error instanceof AuthError) {\n return reply\n .status(error.statusCode)\n .send({ error: error.code, message: error.message });\n }\n throw error;\n }\n };\n },\n /**\n * Fastify preHandler that authenticates via cookie or Bearer token and checks if the user has one of the required roles.\n * @param roles - Allowed roles for this route.\n * @returns Fastify preHandler function.\n */\n requireRole(...roles: string[]) {\n const checkRole = service.authorize(...roles);\n\n return async (request: FastifyRequest, reply: FastifyReply) => {\n try {\n const token = extractToken(request, accessTokenName);\n const user = await service.authenticate(token);\n checkRole(user);\n request.authUser = user;\n } catch (error) {\n if (error instanceof AuthError) {\n return reply\n .status(error.statusCode)\n .send({ error: error.code, message: error.message });\n }\n throw error;\n }\n };\n },\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@forjakit/auth-fastify",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "clean": "rm -rf dist",
16
+ "dev": "tsup --watch"
17
+ },
18
+ "dependencies": {
19
+ "@forjakit/auth": "workspace:*",
20
+ "@fastify/cookie": "^11.0.0",
21
+ "fastify": "^5.2.2",
22
+ "zod": "^3.24.2"
23
+ },
24
+ "devDependencies": {
25
+ "tsup": "^8.4.0",
26
+ "typescript": "^5.7.3"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ]
34
+ }