@veloxts/auth 0.3.3 → 0.3.4
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 +755 -30
- package/dist/adapter.d.ts +710 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +581 -0
- package/dist/adapter.js.map +1 -0
- package/dist/adapters/better-auth.d.ts +271 -0
- package/dist/adapters/better-auth.d.ts.map +1 -0
- package/dist/adapters/better-auth.js +341 -0
- package/dist/adapters/better-auth.js.map +1 -0
- package/dist/adapters/index.d.ts +28 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/csrf.d.ts +294 -0
- package/dist/csrf.d.ts.map +1 -0
- package/dist/csrf.js +396 -0
- package/dist/csrf.js.map +1 -0
- package/dist/guards.d.ts +139 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +247 -0
- package/dist/guards.js.map +1 -0
- package/dist/hash.d.ts +85 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +220 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +25 -32
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +63 -36
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +128 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +363 -0
- package/dist/jwt.js.map +1 -0
- package/dist/middleware.d.ts +87 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +241 -0
- package/dist/middleware.js.map +1 -0
- package/dist/plugin.d.ts +107 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +174 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policies.d.ts +137 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +240 -0
- package/dist/policies.js.map +1 -0
- package/dist/session.d.ts +494 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +795 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +251 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +33 -0
- package/dist/types.js.map +1 -0
- package/package.json +38 -7
package/dist/csrf.d.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF (Cross-Site Request Forgery) Protection for @veloxts/auth
|
|
3
|
+
*
|
|
4
|
+
* Implements the Signed Double Submit Cookie Pattern:
|
|
5
|
+
* - Stateless design (no server-side session storage)
|
|
6
|
+
* - HMAC-signed tokens prevent cookie tampering
|
|
7
|
+
* - Horizontally scalable across server instances
|
|
8
|
+
*
|
|
9
|
+
* @module auth/csrf
|
|
10
|
+
*/
|
|
11
|
+
import type { BaseContext } from '@veloxts/core';
|
|
12
|
+
import type { MiddlewareFunction } from '@veloxts/router';
|
|
13
|
+
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
14
|
+
interface CookieSerializeOptions {
|
|
15
|
+
domain?: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
sameSite?: 'strict' | 'lax' | 'none' | boolean;
|
|
18
|
+
secure?: boolean;
|
|
19
|
+
httpOnly?: boolean;
|
|
20
|
+
maxAge?: number;
|
|
21
|
+
expires?: Date;
|
|
22
|
+
}
|
|
23
|
+
interface FastifyReplyWithCookies extends FastifyReply {
|
|
24
|
+
cookie(name: string, value: string, options?: CookieSerializeOptions): FastifyReply;
|
|
25
|
+
clearCookie(name: string, options?: CookieSerializeOptions): FastifyReply;
|
|
26
|
+
}
|
|
27
|
+
interface FastifyRequestWithCookies extends FastifyRequest {
|
|
28
|
+
cookies: Record<string, string | undefined>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* CSRF validation failure codes
|
|
32
|
+
*/
|
|
33
|
+
export type CsrfErrorCode = 'CSRF_MISSING_TOKEN' | 'CSRF_MISSING_COOKIE' | 'CSRF_TOKEN_MISMATCH' | 'CSRF_INVALID_SIGNATURE' | 'CSRF_TOKEN_EXPIRED' | 'CSRF_ORIGIN_MISMATCH' | 'CSRF_INVALID_FORMAT';
|
|
34
|
+
/**
|
|
35
|
+
* CSRF-specific error class
|
|
36
|
+
*/
|
|
37
|
+
export declare class CsrfError extends Error {
|
|
38
|
+
readonly statusCode: number;
|
|
39
|
+
readonly code: CsrfErrorCode;
|
|
40
|
+
constructor(message: string, code: CsrfErrorCode);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Cookie configuration for CSRF tokens
|
|
44
|
+
*/
|
|
45
|
+
export interface CsrfCookieConfig {
|
|
46
|
+
/**
|
|
47
|
+
* Cookie name for the CSRF token
|
|
48
|
+
* @default 'velox.csrf'
|
|
49
|
+
*/
|
|
50
|
+
name?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Cookie path
|
|
53
|
+
* @default '/'
|
|
54
|
+
*/
|
|
55
|
+
path?: string;
|
|
56
|
+
/**
|
|
57
|
+
* SameSite policy
|
|
58
|
+
* - 'strict': Only same-site requests (most secure)
|
|
59
|
+
* - 'lax': Same-site + top-level navigation from external sites
|
|
60
|
+
* - 'none': All requests (requires Secure; use only with CORS)
|
|
61
|
+
* @default 'lax'
|
|
62
|
+
*/
|
|
63
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
64
|
+
/**
|
|
65
|
+
* Require HTTPS (should be true in production)
|
|
66
|
+
* @default process.env.NODE_ENV === 'production'
|
|
67
|
+
*/
|
|
68
|
+
secure?: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* HttpOnly flag - set to false for header-based CSRF
|
|
71
|
+
* When false: JavaScript can read token for header submission
|
|
72
|
+
* When true: Use hidden form fields only
|
|
73
|
+
* @default false
|
|
74
|
+
*/
|
|
75
|
+
httpOnly?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Domain for the cookie (optional)
|
|
78
|
+
*/
|
|
79
|
+
domain?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Token generation and validation configuration
|
|
83
|
+
*/
|
|
84
|
+
export interface CsrfTokenConfig {
|
|
85
|
+
/**
|
|
86
|
+
* Token entropy in bytes (random portion)
|
|
87
|
+
* @default 32 (256 bits)
|
|
88
|
+
*/
|
|
89
|
+
tokenBytes?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Token expiration in seconds (0 for no expiration)
|
|
92
|
+
* @default 3600 (1 hour)
|
|
93
|
+
*/
|
|
94
|
+
expiresIn?: number;
|
|
95
|
+
/**
|
|
96
|
+
* Secret key for HMAC signature
|
|
97
|
+
* Minimum length: 32 characters
|
|
98
|
+
*/
|
|
99
|
+
secret: string;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Request validation configuration
|
|
103
|
+
*/
|
|
104
|
+
export interface CsrfValidationConfig {
|
|
105
|
+
/**
|
|
106
|
+
* Header name to check for token
|
|
107
|
+
* @default 'x-csrf-token'
|
|
108
|
+
*/
|
|
109
|
+
headerName?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Body field name to check for token
|
|
112
|
+
* @default '_csrf'
|
|
113
|
+
*/
|
|
114
|
+
bodyFieldName?: string;
|
|
115
|
+
/**
|
|
116
|
+
* Query parameter name (disabled by default)
|
|
117
|
+
*/
|
|
118
|
+
queryFieldName?: string;
|
|
119
|
+
/**
|
|
120
|
+
* HTTP methods to validate
|
|
121
|
+
* @default ['POST', 'PUT', 'PATCH', 'DELETE']
|
|
122
|
+
*/
|
|
123
|
+
methods?: ReadonlyArray<string>;
|
|
124
|
+
/**
|
|
125
|
+
* Paths to exclude from CSRF validation
|
|
126
|
+
* @example [/^\/api\/webhooks\//, '/health']
|
|
127
|
+
*/
|
|
128
|
+
excludePaths?: ReadonlyArray<RegExp | string>;
|
|
129
|
+
/**
|
|
130
|
+
* Validate Origin/Referer headers
|
|
131
|
+
* @default true
|
|
132
|
+
*/
|
|
133
|
+
checkOrigin?: boolean;
|
|
134
|
+
/**
|
|
135
|
+
* Allowed origins for validation
|
|
136
|
+
*/
|
|
137
|
+
allowedOrigins?: ReadonlyArray<string>;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Complete CSRF configuration
|
|
141
|
+
*/
|
|
142
|
+
export interface CsrfConfig {
|
|
143
|
+
/**
|
|
144
|
+
* Enable CSRF protection
|
|
145
|
+
* @default true
|
|
146
|
+
*/
|
|
147
|
+
enabled?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Cookie configuration
|
|
150
|
+
*/
|
|
151
|
+
cookie?: CsrfCookieConfig;
|
|
152
|
+
/**
|
|
153
|
+
* Token configuration
|
|
154
|
+
*/
|
|
155
|
+
token: CsrfTokenConfig;
|
|
156
|
+
/**
|
|
157
|
+
* Validation configuration
|
|
158
|
+
*/
|
|
159
|
+
validation?: CsrfValidationConfig;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Parsed CSRF token structure
|
|
163
|
+
*/
|
|
164
|
+
export interface CsrfTokenData {
|
|
165
|
+
/** Random token value (base64url encoded) */
|
|
166
|
+
value: string;
|
|
167
|
+
/** Token creation timestamp (Unix seconds) */
|
|
168
|
+
issuedAt: number;
|
|
169
|
+
/** Token expiration timestamp (Unix seconds) */
|
|
170
|
+
expiresAt: number;
|
|
171
|
+
/** HMAC signature (base64url encoded) */
|
|
172
|
+
signature: string;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Token generation result
|
|
176
|
+
*/
|
|
177
|
+
export interface CsrfTokenResult {
|
|
178
|
+
/** Complete signed token string */
|
|
179
|
+
token: string;
|
|
180
|
+
/** Token expiration timestamp */
|
|
181
|
+
expiresAt: number;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* CSRF token manager for generating and validating tokens
|
|
185
|
+
*/
|
|
186
|
+
export interface CsrfManager {
|
|
187
|
+
/** Generate a new CSRF token */
|
|
188
|
+
generateToken(reply: FastifyReplyWithCookies): CsrfTokenResult;
|
|
189
|
+
/** Validate a CSRF token from request */
|
|
190
|
+
validateToken(request: FastifyRequestWithCookies): void;
|
|
191
|
+
/** Extract token from request */
|
|
192
|
+
extractToken(request: FastifyRequest): string | null;
|
|
193
|
+
/** Parse token string into components */
|
|
194
|
+
parseToken(token: string): CsrfTokenData | null;
|
|
195
|
+
/** Verify token signature */
|
|
196
|
+
verifySignature(token: string): boolean;
|
|
197
|
+
/** Clear the CSRF cookie */
|
|
198
|
+
clearCookie(reply: FastifyReplyWithCookies): void;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* CSRF middleware options for procedures
|
|
202
|
+
*/
|
|
203
|
+
export interface CsrfMiddlewareOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Skip CSRF validation for this procedure
|
|
206
|
+
* @default false
|
|
207
|
+
*/
|
|
208
|
+
skip?: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Override excluded paths for this middleware instance
|
|
211
|
+
*/
|
|
212
|
+
excludePaths?: ReadonlyArray<RegExp | string>;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Extended context with CSRF capabilities
|
|
216
|
+
*/
|
|
217
|
+
export interface CsrfContext {
|
|
218
|
+
csrf: {
|
|
219
|
+
/** Generate a new token */
|
|
220
|
+
generateToken: () => CsrfTokenResult;
|
|
221
|
+
/** Current token from request (if valid) */
|
|
222
|
+
token?: string;
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
declare module '@veloxts/core' {
|
|
226
|
+
interface BaseContext {
|
|
227
|
+
csrf?: CsrfContext['csrf'];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
declare module 'fastify' {
|
|
231
|
+
interface FastifyRequest {
|
|
232
|
+
csrfToken?: string;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Creates a CSRF token manager
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* ```typescript
|
|
240
|
+
* const csrfManager = createCsrfManager({
|
|
241
|
+
* token: { secret: process.env.CSRF_SECRET! },
|
|
242
|
+
* cookie: { secure: true, sameSite: 'strict' },
|
|
243
|
+
* });
|
|
244
|
+
*
|
|
245
|
+
* // Generate token
|
|
246
|
+
* const { token } = csrfManager.generateToken(reply);
|
|
247
|
+
*
|
|
248
|
+
* // Validate token
|
|
249
|
+
* csrfManager.validateToken(request); // Throws on failure
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
export declare function createCsrfManager(config: CsrfConfig): CsrfManager;
|
|
253
|
+
/**
|
|
254
|
+
* Creates CSRF protection middleware for procedures
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```typescript
|
|
258
|
+
* const csrf = createCsrfMiddleware({
|
|
259
|
+
* token: { secret: process.env.CSRF_SECRET! },
|
|
260
|
+
* });
|
|
261
|
+
*
|
|
262
|
+
* // Protect mutations
|
|
263
|
+
* const createPost = procedure()
|
|
264
|
+
* .use(auth.requireAuth())
|
|
265
|
+
* .use(csrf.protect())
|
|
266
|
+
* .input(CreatePostSchema)
|
|
267
|
+
* .mutation(async ({ input, ctx }) => {
|
|
268
|
+
* return db.post.create({ data: input });
|
|
269
|
+
* });
|
|
270
|
+
*
|
|
271
|
+
* // Provide token for forms
|
|
272
|
+
* const getForm = procedure()
|
|
273
|
+
* .use(csrf.provide())
|
|
274
|
+
* .query(async ({ ctx }) => {
|
|
275
|
+
* return { csrfToken: ctx.csrf.generateToken().token };
|
|
276
|
+
* });
|
|
277
|
+
* ```
|
|
278
|
+
*/
|
|
279
|
+
export declare function createCsrfMiddleware(config: CsrfConfig): {
|
|
280
|
+
/** CSRF manager instance */
|
|
281
|
+
manager: CsrfManager;
|
|
282
|
+
/** Protection middleware (validates tokens) */
|
|
283
|
+
protect: <TInput, TContext extends BaseContext, TOutput>(options?: CsrfMiddlewareOptions) => MiddlewareFunction<TInput, TContext, TContext & CsrfContext, TOutput>;
|
|
284
|
+
/** Provider middleware (generates tokens only) */
|
|
285
|
+
provide: <TInput, TContext extends BaseContext, TOutput>() => MiddlewareFunction<TInput, TContext, TContext & CsrfContext, TOutput>;
|
|
286
|
+
/** Generate token directly */
|
|
287
|
+
generateToken: (reply: FastifyReplyWithCookies) => CsrfTokenResult;
|
|
288
|
+
/** Validate token directly */
|
|
289
|
+
validateToken: (request: FastifyRequestWithCookies) => void;
|
|
290
|
+
/** Clear CSRF cookie */
|
|
291
|
+
clearCookie: (reply: FastifyReplyWithCookies) => void;
|
|
292
|
+
};
|
|
293
|
+
export {};
|
|
294
|
+
//# sourceMappingURL=csrf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csrf.d.ts","sourceRoot":"","sources":["../src/csrf.ts"],"names":[],"mappings":"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"}
|
package/dist/csrf.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF (Cross-Site Request Forgery) Protection for @veloxts/auth
|
|
3
|
+
*
|
|
4
|
+
* Implements the Signed Double Submit Cookie Pattern:
|
|
5
|
+
* - Stateless design (no server-side session storage)
|
|
6
|
+
* - HMAC-signed tokens prevent cookie tampering
|
|
7
|
+
* - Horizontally scalable across server instances
|
|
8
|
+
*
|
|
9
|
+
* @module auth/csrf
|
|
10
|
+
*/
|
|
11
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================================================
|
|
15
|
+
const DEFAULT_TOKEN_BYTES = 32;
|
|
16
|
+
const DEFAULT_EXPIRES_IN = 3600; // 1 hour
|
|
17
|
+
const DEFAULT_HEADER_NAME = 'x-csrf-token';
|
|
18
|
+
const DEFAULT_BODY_FIELD = '_csrf';
|
|
19
|
+
const DEFAULT_COOKIE_NAME = 'velox.csrf';
|
|
20
|
+
const MIN_SECRET_LENGTH = 32;
|
|
21
|
+
const CSRF_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
|
22
|
+
/**
|
|
23
|
+
* CSRF-specific error class
|
|
24
|
+
*/
|
|
25
|
+
export class CsrfError extends Error {
|
|
26
|
+
statusCode = 403;
|
|
27
|
+
code;
|
|
28
|
+
constructor(message, code) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'CsrfError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
Error.captureStackTrace?.(this, CsrfError);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Helper Functions
|
|
37
|
+
// ============================================================================
|
|
38
|
+
/**
|
|
39
|
+
* Base64url encode
|
|
40
|
+
*/
|
|
41
|
+
function base64urlEncode(data) {
|
|
42
|
+
return data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
|
43
|
+
}
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// CSRF Manager Implementation
|
|
46
|
+
// ============================================================================
|
|
47
|
+
/**
|
|
48
|
+
* Creates a CSRF token manager
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const csrfManager = createCsrfManager({
|
|
53
|
+
* token: { secret: process.env.CSRF_SECRET! },
|
|
54
|
+
* cookie: { secure: true, sameSite: 'strict' },
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* // Generate token
|
|
58
|
+
* const { token } = csrfManager.generateToken(reply);
|
|
59
|
+
*
|
|
60
|
+
* // Validate token
|
|
61
|
+
* csrfManager.validateToken(request); // Throws on failure
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function createCsrfManager(config) {
|
|
65
|
+
// Validate secret
|
|
66
|
+
const secret = config.token.secret;
|
|
67
|
+
if (!secret || secret.length < MIN_SECRET_LENGTH) {
|
|
68
|
+
throw new Error(`CSRF secret must be at least ${MIN_SECRET_LENGTH} characters. ` +
|
|
69
|
+
'Generate with: openssl rand -base64 32');
|
|
70
|
+
}
|
|
71
|
+
// Resolve configuration with defaults
|
|
72
|
+
const tokenBytes = config.token.tokenBytes ?? DEFAULT_TOKEN_BYTES;
|
|
73
|
+
const expiresIn = config.token.expiresIn ?? DEFAULT_EXPIRES_IN;
|
|
74
|
+
const headerName = (config.validation?.headerName ?? DEFAULT_HEADER_NAME).toLowerCase();
|
|
75
|
+
const bodyFieldName = config.validation?.bodyFieldName ?? DEFAULT_BODY_FIELD;
|
|
76
|
+
const queryFieldName = config.validation?.queryFieldName;
|
|
77
|
+
const methods = config.validation?.methods ?? CSRF_METHODS;
|
|
78
|
+
const checkOrigin = config.validation?.checkOrigin ?? true;
|
|
79
|
+
const allowedOrigins = config.validation?.allowedOrigins ?? [];
|
|
80
|
+
const excludePaths = config.validation?.excludePaths ?? [];
|
|
81
|
+
// Cookie config
|
|
82
|
+
const cookieName = config.cookie?.name ?? DEFAULT_COOKIE_NAME;
|
|
83
|
+
const cookiePath = config.cookie?.path ?? '/';
|
|
84
|
+
const cookieSameSite = config.cookie?.sameSite ?? 'lax';
|
|
85
|
+
const cookieSecure = config.cookie?.secure ?? process.env.NODE_ENV === 'production';
|
|
86
|
+
const cookieHttpOnly = config.cookie?.httpOnly ?? false;
|
|
87
|
+
const cookieDomain = config.cookie?.domain;
|
|
88
|
+
// Security validation: SameSite=none requires Secure flag
|
|
89
|
+
// Per RFC 6265bis, cookies with SameSite=none must be Secure
|
|
90
|
+
if (cookieSameSite === 'none' && !cookieSecure) {
|
|
91
|
+
throw new Error('CSRF cookie with SameSite=none requires Secure flag. ' +
|
|
92
|
+
'Set cookie.secure: true or use a different SameSite policy.');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Create HMAC signature for token data
|
|
96
|
+
*/
|
|
97
|
+
function createSignature(value, issuedAt, expiresAt) {
|
|
98
|
+
const hmac = createHmac('sha256', secret);
|
|
99
|
+
hmac.update(`${value}.${issuedAt}.${expiresAt}`);
|
|
100
|
+
return base64urlEncode(hmac.digest());
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Generate a new CSRF token
|
|
104
|
+
*/
|
|
105
|
+
function generateToken(reply) {
|
|
106
|
+
const value = base64urlEncode(randomBytes(tokenBytes));
|
|
107
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
108
|
+
const expiresAt = expiresIn > 0 ? issuedAt + expiresIn : 0;
|
|
109
|
+
const signature = createSignature(value, issuedAt, expiresAt);
|
|
110
|
+
// Token format: value.issuedAt.expiresAt.signature
|
|
111
|
+
const token = `${value}.${issuedAt}.${expiresAt}.${signature}`;
|
|
112
|
+
// Set cookie using Fastify's cookie API
|
|
113
|
+
reply.cookie(cookieName, token, {
|
|
114
|
+
path: cookiePath,
|
|
115
|
+
sameSite: cookieSameSite,
|
|
116
|
+
secure: cookieSecure,
|
|
117
|
+
httpOnly: cookieHttpOnly,
|
|
118
|
+
domain: cookieDomain,
|
|
119
|
+
maxAge: expiresIn > 0 ? expiresIn : undefined,
|
|
120
|
+
});
|
|
121
|
+
return { token, expiresAt };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Parse token string into components
|
|
125
|
+
*/
|
|
126
|
+
function parseToken(token) {
|
|
127
|
+
const parts = token.split('.');
|
|
128
|
+
if (parts.length !== 4) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const [value, issuedAtStr, expiresAtStr, signature] = parts;
|
|
132
|
+
const issuedAt = parseInt(issuedAtStr, 10);
|
|
133
|
+
const expiresAt = parseInt(expiresAtStr, 10);
|
|
134
|
+
if (Number.isNaN(issuedAt) || Number.isNaN(expiresAt)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return { value, issuedAt, expiresAt, signature };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Verify token signature using timing-safe comparison
|
|
141
|
+
*/
|
|
142
|
+
function verifySignature(token) {
|
|
143
|
+
const parsed = parseToken(token);
|
|
144
|
+
if (!parsed) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
const expectedSignature = createSignature(parsed.value, parsed.issuedAt, parsed.expiresAt);
|
|
148
|
+
const sigBuffer = Buffer.from(parsed.signature, 'utf8');
|
|
149
|
+
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
|
|
150
|
+
if (sigBuffer.length !== expectedBuffer.length) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
return timingSafeEqual(sigBuffer, expectedBuffer);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Extract token from request (header, body, or query)
|
|
157
|
+
*/
|
|
158
|
+
function extractToken(request) {
|
|
159
|
+
// 1. Check header
|
|
160
|
+
const headerToken = request.headers[headerName];
|
|
161
|
+
if (typeof headerToken === 'string' && headerToken.length > 0) {
|
|
162
|
+
return headerToken;
|
|
163
|
+
}
|
|
164
|
+
// 2. Check body
|
|
165
|
+
const body = request.body;
|
|
166
|
+
if (body && typeof body[bodyFieldName] === 'string') {
|
|
167
|
+
return body[bodyFieldName];
|
|
168
|
+
}
|
|
169
|
+
// 3. Check query (if enabled)
|
|
170
|
+
if (queryFieldName) {
|
|
171
|
+
const query = request.query;
|
|
172
|
+
if (query && typeof query[queryFieldName] === 'string') {
|
|
173
|
+
return query[queryFieldName];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Validate Origin/Referer headers
|
|
180
|
+
*/
|
|
181
|
+
function validateOrigin(request) {
|
|
182
|
+
if (!checkOrigin) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const origin = request.headers.origin;
|
|
186
|
+
const referer = request.headers.referer;
|
|
187
|
+
const host = request.headers.host;
|
|
188
|
+
// If no origin or referer, might be same-origin
|
|
189
|
+
if (!origin && !referer) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
let requestOrigin = null;
|
|
193
|
+
if (origin) {
|
|
194
|
+
requestOrigin = origin;
|
|
195
|
+
}
|
|
196
|
+
else if (referer) {
|
|
197
|
+
try {
|
|
198
|
+
requestOrigin = new URL(referer).origin;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Invalid referer URL
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!requestOrigin) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// Check against host
|
|
208
|
+
const protocol = request.protocol ?? 'http';
|
|
209
|
+
const expectedOrigin = `${protocol}://${host}`;
|
|
210
|
+
if (requestOrigin === expectedOrigin) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
// Check against allowed origins
|
|
214
|
+
if (allowedOrigins.includes(requestOrigin)) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
throw new CsrfError(`Origin mismatch: ${requestOrigin} not allowed`, 'CSRF_ORIGIN_MISMATCH');
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check if path should be excluded
|
|
221
|
+
*/
|
|
222
|
+
function isPathExcluded(path) {
|
|
223
|
+
for (const pattern of excludePaths) {
|
|
224
|
+
if (typeof pattern === 'string') {
|
|
225
|
+
if (path === pattern)
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
else if (pattern.test(path)) {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Full token validation
|
|
236
|
+
*/
|
|
237
|
+
function validateToken(request) {
|
|
238
|
+
// Skip non-mutating methods
|
|
239
|
+
const method = request.method.toUpperCase();
|
|
240
|
+
if (!methods.includes(method)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
// Check excluded paths
|
|
244
|
+
const path = request.url.split('?')[0];
|
|
245
|
+
if (isPathExcluded(path)) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// Validate origin first
|
|
249
|
+
validateOrigin(request);
|
|
250
|
+
// Get cookie token
|
|
251
|
+
const cookieToken = request.cookies[cookieName];
|
|
252
|
+
if (!cookieToken) {
|
|
253
|
+
throw new CsrfError('CSRF cookie not found', 'CSRF_MISSING_COOKIE');
|
|
254
|
+
}
|
|
255
|
+
// Get request token
|
|
256
|
+
const requestToken = extractToken(request);
|
|
257
|
+
if (!requestToken) {
|
|
258
|
+
throw new CsrfError('CSRF token not found in request', 'CSRF_MISSING_TOKEN');
|
|
259
|
+
}
|
|
260
|
+
// Tokens must match (double-submit validation)
|
|
261
|
+
// Use timing-safe comparison to prevent timing attacks
|
|
262
|
+
const cookieBuffer = Buffer.from(cookieToken, 'utf8');
|
|
263
|
+
const requestBuffer = Buffer.from(requestToken, 'utf8');
|
|
264
|
+
if (cookieBuffer.length !== requestBuffer.length ||
|
|
265
|
+
!timingSafeEqual(cookieBuffer, requestBuffer)) {
|
|
266
|
+
throw new CsrfError('CSRF token mismatch', 'CSRF_TOKEN_MISMATCH');
|
|
267
|
+
}
|
|
268
|
+
// Verify signature
|
|
269
|
+
if (!verifySignature(requestToken)) {
|
|
270
|
+
throw new CsrfError('Invalid CSRF token signature', 'CSRF_INVALID_SIGNATURE');
|
|
271
|
+
}
|
|
272
|
+
// Check expiration
|
|
273
|
+
const parsed = parseToken(requestToken);
|
|
274
|
+
if (parsed && parsed.expiresAt > 0) {
|
|
275
|
+
const now = Math.floor(Date.now() / 1000);
|
|
276
|
+
if (parsed.expiresAt < now) {
|
|
277
|
+
throw new CsrfError('CSRF token has expired', 'CSRF_TOKEN_EXPIRED');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Clear CSRF cookie
|
|
283
|
+
*/
|
|
284
|
+
function clearCookie(reply) {
|
|
285
|
+
reply.clearCookie(cookieName, {
|
|
286
|
+
path: cookiePath,
|
|
287
|
+
domain: cookieDomain,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
generateToken,
|
|
292
|
+
validateToken,
|
|
293
|
+
extractToken,
|
|
294
|
+
parseToken,
|
|
295
|
+
verifySignature,
|
|
296
|
+
clearCookie,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// ============================================================================
|
|
300
|
+
// Middleware Factory
|
|
301
|
+
// ============================================================================
|
|
302
|
+
/**
|
|
303
|
+
* Creates CSRF protection middleware for procedures
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```typescript
|
|
307
|
+
* const csrf = createCsrfMiddleware({
|
|
308
|
+
* token: { secret: process.env.CSRF_SECRET! },
|
|
309
|
+
* });
|
|
310
|
+
*
|
|
311
|
+
* // Protect mutations
|
|
312
|
+
* const createPost = procedure()
|
|
313
|
+
* .use(auth.requireAuth())
|
|
314
|
+
* .use(csrf.protect())
|
|
315
|
+
* .input(CreatePostSchema)
|
|
316
|
+
* .mutation(async ({ input, ctx }) => {
|
|
317
|
+
* return db.post.create({ data: input });
|
|
318
|
+
* });
|
|
319
|
+
*
|
|
320
|
+
* // Provide token for forms
|
|
321
|
+
* const getForm = procedure()
|
|
322
|
+
* .use(csrf.provide())
|
|
323
|
+
* .query(async ({ ctx }) => {
|
|
324
|
+
* return { csrfToken: ctx.csrf.generateToken().token };
|
|
325
|
+
* });
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
export function createCsrfMiddleware(config) {
|
|
329
|
+
const manager = createCsrfManager(config);
|
|
330
|
+
/**
|
|
331
|
+
* Middleware that validates CSRF tokens on mutations
|
|
332
|
+
*/
|
|
333
|
+
function protect(options = {}) {
|
|
334
|
+
return async ({ ctx, next }) => {
|
|
335
|
+
const reply = ctx.reply;
|
|
336
|
+
const request = ctx.request;
|
|
337
|
+
if (options.skip) {
|
|
338
|
+
return next({
|
|
339
|
+
ctx: {
|
|
340
|
+
...ctx,
|
|
341
|
+
csrf: {
|
|
342
|
+
generateToken: () => manager.generateToken(reply),
|
|
343
|
+
token: undefined,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
// Validate token (throws CsrfError on failure)
|
|
349
|
+
manager.validateToken(request);
|
|
350
|
+
const token = manager.extractToken(ctx.request) ?? undefined;
|
|
351
|
+
// Continue with CSRF context
|
|
352
|
+
return next({
|
|
353
|
+
ctx: {
|
|
354
|
+
...ctx,
|
|
355
|
+
csrf: {
|
|
356
|
+
generateToken: () => manager.generateToken(reply),
|
|
357
|
+
token,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Middleware that only provides token generation (no validation)
|
|
365
|
+
* Use for query procedures where you need to provide tokens
|
|
366
|
+
*/
|
|
367
|
+
function provide() {
|
|
368
|
+
return async ({ ctx, next }) => {
|
|
369
|
+
const reply = ctx.reply;
|
|
370
|
+
return next({
|
|
371
|
+
ctx: {
|
|
372
|
+
...ctx,
|
|
373
|
+
csrf: {
|
|
374
|
+
generateToken: () => manager.generateToken(reply),
|
|
375
|
+
token: manager.extractToken(ctx.request) ?? undefined,
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
/** CSRF manager instance */
|
|
383
|
+
manager,
|
|
384
|
+
/** Protection middleware (validates tokens) */
|
|
385
|
+
protect,
|
|
386
|
+
/** Provider middleware (generates tokens only) */
|
|
387
|
+
provide,
|
|
388
|
+
/** Generate token directly */
|
|
389
|
+
generateToken: (reply) => manager.generateToken(reply),
|
|
390
|
+
/** Validate token directly */
|
|
391
|
+
validateToken: (request) => manager.validateToken(request),
|
|
392
|
+
/** Clear CSRF cookie */
|
|
393
|
+
clearCookie: (reply) => manager.clearCookie(reply),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
//# sourceMappingURL=csrf.js.map
|