@veloxts/auth 0.6.84 → 0.6.85

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.
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Auth0 Adapter for @veloxts/auth
3
+ *
4
+ * Integrates Auth0 (https://auth0.com) with VeloxTS's pluggable
5
+ * authentication system. Auth0 is an identity platform providing
6
+ * authentication and authorization services.
7
+ *
8
+ * This adapter uses JWKS (JSON Web Key Sets) for JWT verification,
9
+ * allowing secure token validation without sharing secrets.
10
+ *
11
+ * @module auth/adapters/auth0
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { createAuthAdapterPlugin } from '@veloxts/auth';
16
+ * import { createAuth0Adapter } from '@veloxts/auth/adapters/auth0';
17
+ *
18
+ * const adapter = createAuth0Adapter({
19
+ * domain: process.env.AUTH0_DOMAIN!,
20
+ * audience: process.env.AUTH0_AUDIENCE!,
21
+ * clientId: process.env.AUTH0_CLIENT_ID,
22
+ * });
23
+ *
24
+ * // Simplified API - just pass the adapter
25
+ * const authPlugin = createAuthAdapterPlugin(adapter);
26
+ *
27
+ * app.use(authPlugin);
28
+ * ```
29
+ */
30
+ import { AuthAdapterError, BaseAuthAdapter } from '../adapter.js';
31
+ import { extractBearerToken, validateNonEmptyString } from './utils.js';
32
+ // ============================================================================
33
+ // Constants
34
+ // ============================================================================
35
+ /** Default clock tolerance in seconds for JWT validation */
36
+ const DEFAULT_CLOCK_TOLERANCE_SECONDS = 5;
37
+ /** Default JWKS cache TTL in milliseconds (1 hour) */
38
+ const DEFAULT_JWKS_CACHE_TTL_MS = 3600000;
39
+ /** Minimum interval between JWKS refresh attempts in milliseconds (5 seconds) */
40
+ const MIN_JWKS_REFRESH_INTERVAL_MS = 5000;
41
+ class JWKSCache {
42
+ keys = new Map();
43
+ lastFetch = 0;
44
+ lastRefreshAttempt = 0;
45
+ refreshPromise = null;
46
+ ttl;
47
+ jwksUrl;
48
+ logger;
49
+ constructor(jwksUrl, ttl = DEFAULT_JWKS_CACHE_TTL_MS, logger) {
50
+ this.jwksUrl = jwksUrl;
51
+ this.ttl = ttl;
52
+ this.logger = logger;
53
+ }
54
+ async getKey(kid) {
55
+ const now = Date.now();
56
+ // Refresh cache if expired, but rate limit refresh attempts
57
+ const needsRefresh = now - this.lastFetch > this.ttl || this.keys.size === 0;
58
+ const canRefresh = now - this.lastRefreshAttempt > MIN_JWKS_REFRESH_INTERVAL_MS;
59
+ if (needsRefresh && canRefresh) {
60
+ // Use promise-based locking to prevent concurrent refreshes
61
+ if (!this.refreshPromise) {
62
+ this.lastRefreshAttempt = now;
63
+ this.refreshPromise = this.refresh().finally(() => {
64
+ this.refreshPromise = null;
65
+ });
66
+ }
67
+ await this.refreshPromise;
68
+ }
69
+ return this.keys.get(kid) ?? null;
70
+ }
71
+ async refresh() {
72
+ try {
73
+ const response = await fetch(this.jwksUrl);
74
+ if (!response.ok) {
75
+ throw new Error(`JWKS fetch failed: ${response.status}`);
76
+ }
77
+ const data = (await response.json());
78
+ this.keys.clear();
79
+ for (const key of data.keys) {
80
+ if (key.kid) {
81
+ this.keys.set(key.kid, key);
82
+ }
83
+ }
84
+ this.lastFetch = Date.now();
85
+ }
86
+ catch (error) {
87
+ // If we have cached keys, don't fail completely
88
+ if (this.keys.size > 0) {
89
+ this.logger?.('JWKS refresh failed, using cached keys');
90
+ return;
91
+ }
92
+ throw error;
93
+ }
94
+ }
95
+ }
96
+ // ============================================================================
97
+ // Default JWT Verifier
98
+ // ============================================================================
99
+ /**
100
+ * Create a default JWT verifier using JWKS
101
+ *
102
+ * This implementation uses Web Crypto API for JWT verification
103
+ * to avoid external dependencies like 'jose'.
104
+ *
105
+ * @internal
106
+ */
107
+ function createDefaultVerifier(domain, audience, issuer, clockTolerance, cacheTtl, logger) {
108
+ const jwksUrl = `https://${domain}/.well-known/jwks.json`;
109
+ const cache = new JWKSCache(jwksUrl, cacheTtl, logger);
110
+ return {
111
+ async verify(token) {
112
+ // Parse the JWT
113
+ const parts = token.split('.');
114
+ if (parts.length !== 3) {
115
+ throw new Error('Invalid JWT format');
116
+ }
117
+ // Decode and validate header structure
118
+ const headerJson = base64UrlDecode(parts[0]);
119
+ const header = parseAndValidateHeader(headerJson);
120
+ if (header.alg !== 'RS256') {
121
+ throw new Error(`Unsupported algorithm: ${header.alg}`);
122
+ }
123
+ if (!header.kid) {
124
+ throw new Error('Missing kid in token header');
125
+ }
126
+ // Get the signing key from JWKS
127
+ const key = await cache.getKey(header.kid);
128
+ if (!key) {
129
+ throw new Error(`Unknown signing key: ${header.kid}`);
130
+ }
131
+ // Verify signature using Web Crypto
132
+ const isValid = await verifyRS256Signature(token, key);
133
+ if (!isValid) {
134
+ throw new Error('Invalid token signature');
135
+ }
136
+ // Decode and validate claims structure
137
+ const payloadJson = base64UrlDecode(parts[1]);
138
+ const claims = parseAndValidateClaims(payloadJson);
139
+ const now = Math.floor(Date.now() / 1000);
140
+ // Validate expiration
141
+ if (claims.exp && claims.exp + clockTolerance < now) {
142
+ throw new Error('Token has expired');
143
+ }
144
+ // Validate not before
145
+ if (claims.nbf && claims.nbf - clockTolerance > now) {
146
+ throw new Error('Token not yet valid');
147
+ }
148
+ // Validate issuer
149
+ if (claims.iss !== issuer) {
150
+ throw new Error(`Invalid issuer: expected ${issuer}, got ${claims.iss}`);
151
+ }
152
+ // Validate audience
153
+ const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
154
+ if (!audiences.includes(audience)) {
155
+ throw new Error(`Invalid audience: expected ${audience}`);
156
+ }
157
+ return claims;
158
+ },
159
+ };
160
+ }
161
+ /**
162
+ * Base64URL decode
163
+ *
164
+ * @internal
165
+ */
166
+ function base64UrlDecode(str) {
167
+ // Replace URL-safe characters
168
+ const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
169
+ // Pad if necessary
170
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
171
+ // Decode
172
+ return Buffer.from(padded, 'base64').toString('utf-8');
173
+ }
174
+ /**
175
+ * Parse and validate JWT header structure
176
+ *
177
+ * @param headerJson - JSON string of the JWT header
178
+ * @returns Validated JWT header
179
+ * @throws Error if header is malformed or missing required fields
180
+ *
181
+ * @internal
182
+ */
183
+ function parseAndValidateHeader(headerJson) {
184
+ let parsed;
185
+ try {
186
+ parsed = JSON.parse(headerJson);
187
+ }
188
+ catch {
189
+ throw new Error('Invalid JWT format - malformed header JSON');
190
+ }
191
+ if (typeof parsed !== 'object' || parsed === null) {
192
+ throw new Error('Invalid JWT format - header must be an object');
193
+ }
194
+ const header = parsed;
195
+ if (typeof header.alg !== 'string') {
196
+ throw new Error('Invalid JWT format - missing or invalid alg in header');
197
+ }
198
+ if (typeof header.kid !== 'string') {
199
+ throw new Error('Invalid JWT format - missing or invalid kid in header');
200
+ }
201
+ return {
202
+ alg: header.alg,
203
+ kid: header.kid,
204
+ typ: typeof header.typ === 'string' ? header.typ : undefined,
205
+ };
206
+ }
207
+ /**
208
+ * Parse and validate JWT claims structure
209
+ *
210
+ * Validates that required Auth0 JWT claims are present and have correct types.
211
+ *
212
+ * @param payloadJson - JSON string of the JWT payload
213
+ * @returns Validated Auth0 claims
214
+ * @throws Error if payload is malformed or missing required fields
215
+ *
216
+ * @internal
217
+ */
218
+ function parseAndValidateClaims(payloadJson) {
219
+ let parsed;
220
+ try {
221
+ parsed = JSON.parse(payloadJson);
222
+ }
223
+ catch {
224
+ throw new Error('Invalid JWT format - malformed payload JSON');
225
+ }
226
+ if (typeof parsed !== 'object' || parsed === null) {
227
+ throw new Error('Invalid JWT format - payload must be an object');
228
+ }
229
+ const payload = parsed;
230
+ // Validate required claims
231
+ if (typeof payload.sub !== 'string') {
232
+ throw new Error('Invalid JWT claims - missing or invalid sub');
233
+ }
234
+ if (typeof payload.iat !== 'number') {
235
+ throw new Error('Invalid JWT claims - missing or invalid iat');
236
+ }
237
+ if (typeof payload.exp !== 'number') {
238
+ throw new Error('Invalid JWT claims - missing or invalid exp');
239
+ }
240
+ if (typeof payload.iss !== 'string') {
241
+ throw new Error('Invalid JWT claims - missing or invalid iss');
242
+ }
243
+ if (payload.aud !== undefined && typeof payload.aud !== 'string' && !Array.isArray(payload.aud)) {
244
+ throw new Error('Invalid JWT claims - invalid aud format');
245
+ }
246
+ // Build validated claims object
247
+ const claims = {
248
+ sub: payload.sub,
249
+ iat: payload.iat,
250
+ exp: payload.exp,
251
+ iss: payload.iss,
252
+ aud: payload.aud,
253
+ };
254
+ // Add optional claims if present and valid
255
+ if (typeof payload.nbf === 'number') {
256
+ claims.nbf = payload.nbf;
257
+ }
258
+ if (typeof payload.azp === 'string') {
259
+ claims.azp = payload.azp;
260
+ }
261
+ if (typeof payload.scope === 'string') {
262
+ claims.scope = payload.scope;
263
+ }
264
+ if (Array.isArray(payload.permissions)) {
265
+ claims.permissions = payload.permissions;
266
+ }
267
+ if (typeof payload.org_id === 'string') {
268
+ claims.org_id = payload.org_id;
269
+ }
270
+ if (typeof payload.org_name === 'string') {
271
+ claims.org_name = payload.org_name;
272
+ }
273
+ if (typeof payload.email === 'string') {
274
+ claims.email = payload.email;
275
+ }
276
+ if (typeof payload.email_verified === 'boolean') {
277
+ claims.email_verified = payload.email_verified;
278
+ }
279
+ if (typeof payload.name === 'string') {
280
+ claims.name = payload.name;
281
+ }
282
+ if (typeof payload.nickname === 'string') {
283
+ claims.nickname = payload.nickname;
284
+ }
285
+ if (typeof payload.picture === 'string') {
286
+ claims.picture = payload.picture;
287
+ }
288
+ if (typeof payload.updated_at === 'string') {
289
+ claims.updated_at = payload.updated_at;
290
+ }
291
+ return claims;
292
+ }
293
+ /**
294
+ * Verify RS256 signature using Web Crypto API
295
+ *
296
+ * @internal
297
+ */
298
+ async function verifyRS256Signature(token, jwk) {
299
+ const parts = token.split('.');
300
+ const signatureInput = `${parts[0]}.${parts[1]}`;
301
+ const signature = base64UrlToArrayBuffer(parts[2]);
302
+ // Import the public key
303
+ const cryptoKey = await crypto.subtle.importKey('jwk', {
304
+ kty: jwk.kty,
305
+ n: jwk.n,
306
+ e: jwk.e,
307
+ alg: 'RS256',
308
+ use: 'sig',
309
+ }, {
310
+ name: 'RSASSA-PKCS1-v1_5',
311
+ hash: 'SHA-256',
312
+ }, false, ['verify']);
313
+ // Verify the signature
314
+ const encoder = new TextEncoder();
315
+ return crypto.subtle.verify('RSASSA-PKCS1-v1_5', cryptoKey, signature, encoder.encode(signatureInput));
316
+ }
317
+ /**
318
+ * Convert base64url string to ArrayBuffer
319
+ *
320
+ * @internal
321
+ */
322
+ function base64UrlToArrayBuffer(str) {
323
+ const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
324
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
325
+ const binary = Buffer.from(padded, 'base64');
326
+ return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength);
327
+ }
328
+ // ============================================================================
329
+ // Auth0 Adapter Implementation
330
+ // ============================================================================
331
+ /**
332
+ * Auth0 Adapter
333
+ *
334
+ * Integrates Auth0 with VeloxTS by:
335
+ * - Verifying Auth0 JWTs using JWKS
336
+ * - Extracting user data from token claims
337
+ * - Supporting Auth0 Organizations and RBAC
338
+ *
339
+ * @example
340
+ * ```typescript
341
+ * const adapter = new Auth0Adapter();
342
+ * const plugin = createAuthAdapterPlugin({
343
+ * adapter,
344
+ * config: {
345
+ * name: 'auth0',
346
+ * domain: 'your-tenant.auth0.com',
347
+ * audience: 'https://your-api.example.com',
348
+ * },
349
+ * });
350
+ * ```
351
+ */
352
+ export class Auth0Adapter extends BaseAuthAdapter {
353
+ verifier = null;
354
+ domain = '';
355
+ clientId;
356
+ authHeader = 'authorization';
357
+ constructor() {
358
+ super('auth0', '1.0.0');
359
+ }
360
+ /**
361
+ * Initialize the adapter with Auth0 configuration
362
+ */
363
+ async initialize(fastify, config) {
364
+ await super.initialize(fastify, config);
365
+ // Validate required configuration using shared utility
366
+ try {
367
+ this.domain = validateNonEmptyString(config.domain, 'Auth0 domain');
368
+ }
369
+ catch {
370
+ throw new AuthAdapterError('Auth0 domain is required and cannot be empty', 500, 'ADAPTER_NOT_CONFIGURED');
371
+ }
372
+ let audience;
373
+ try {
374
+ audience = validateNonEmptyString(config.audience, 'Auth0 audience');
375
+ }
376
+ catch {
377
+ throw new AuthAdapterError('Auth0 audience is required and cannot be empty', 500, 'ADAPTER_NOT_CONFIGURED');
378
+ }
379
+ this.clientId = config.clientId;
380
+ this.authHeader = config.authHeader ?? 'authorization';
381
+ // Construct issuer URL
382
+ const issuer = config.issuer ?? `https://${this.domain}/`;
383
+ // Use custom verifier or create default
384
+ // Pass bound debug method directly to avoid closure overhead
385
+ this.verifier =
386
+ config.jwtVerifier ??
387
+ createDefaultVerifier(this.domain, audience, issuer, config.clockTolerance ?? DEFAULT_CLOCK_TOLERANCE_SECONDS, config.jwksCacheTtl ?? DEFAULT_JWKS_CACHE_TTL_MS, this.debug.bind(this));
388
+ this.debug(`Initialized with domain: ${this.domain}`);
389
+ }
390
+ /**
391
+ * Get session from Auth0 JWT
392
+ *
393
+ * Extracts the Bearer token from the Authorization header
394
+ * and verifies it using JWKS.
395
+ */
396
+ async getSession(request) {
397
+ if (!this.verifier) {
398
+ throw new AuthAdapterError('Auth0 adapter not initialized', 500, 'ADAPTER_NOT_CONFIGURED');
399
+ }
400
+ // Extract token from Authorization header
401
+ const authHeaderValue = request.headers[this.authHeader];
402
+ if (!authHeaderValue || typeof authHeaderValue !== 'string') {
403
+ this.debug('No authorization header found');
404
+ return null;
405
+ }
406
+ // Extract Bearer token
407
+ const token = extractBearerToken(authHeaderValue);
408
+ if (!token) {
409
+ this.debug('No Bearer token found in authorization header');
410
+ return null;
411
+ }
412
+ try {
413
+ // Verify the token
414
+ const claims = await this.verifier.verify(token);
415
+ this.debug(`Token verified for user: ${claims.sub}`);
416
+ // Validate azp if clientId is configured
417
+ if (this.clientId && claims.azp && claims.azp !== this.clientId) {
418
+ this.debug(`Invalid authorized party: expected ${this.clientId}, got ${claims.azp}`);
419
+ return null;
420
+ }
421
+ // Transform to VeloxTS format
422
+ return transformAuth0Session(claims);
423
+ }
424
+ catch (error) {
425
+ // Token verification failed
426
+ if (error instanceof Error) {
427
+ this.debug(`Token verification failed: ${error.message}`);
428
+ }
429
+ return null;
430
+ }
431
+ }
432
+ /**
433
+ * Get routes for Auth0
434
+ *
435
+ * Auth0 handles auth on the client side via their SDK.
436
+ * Server only needs to verify tokens, not handle auth routes.
437
+ *
438
+ * If you need to handle Auth0 webhooks or Actions callbacks,
439
+ * override this method.
440
+ */
441
+ getRoutes() {
442
+ return [];
443
+ }
444
+ /**
445
+ * Clean up adapter resources
446
+ */
447
+ async cleanup() {
448
+ await super.cleanup();
449
+ this.verifier = null;
450
+ this.debug('Adapter cleaned up');
451
+ }
452
+ }
453
+ // ============================================================================
454
+ // Helper Functions
455
+ // ============================================================================
456
+ /**
457
+ * Transform Auth0 claims to VeloxTS format
458
+ *
459
+ * @param claims - Verified JWT claims
460
+ * @returns VeloxTS adapter session result
461
+ *
462
+ * @internal
463
+ */
464
+ function transformAuth0Session(claims) {
465
+ // Generate a synthetic session ID from sub and iat
466
+ const sessionId = `${claims.sub}:${claims.iat}`;
467
+ return {
468
+ user: {
469
+ id: claims.sub,
470
+ email: claims.email ?? 'unknown',
471
+ name: claims.name ?? claims.nickname,
472
+ emailVerified: claims.email_verified,
473
+ image: claims.picture,
474
+ providerData: {
475
+ // Include permissions (RBAC)
476
+ ...(claims.permissions && { permissions: claims.permissions }),
477
+ // Include scopes
478
+ ...(claims.scope && { scope: claims.scope.split(' ') }),
479
+ // Include organization data if present
480
+ ...(claims.org_id && { organizationId: claims.org_id }),
481
+ ...(claims.org_name && { organizationName: claims.org_name }),
482
+ // Include other useful claims
483
+ ...(claims.nickname && { nickname: claims.nickname }),
484
+ ...(claims.updated_at && { updatedAt: claims.updated_at }),
485
+ },
486
+ },
487
+ session: {
488
+ sessionId,
489
+ userId: claims.sub,
490
+ expiresAt: claims.exp * 1000, // Convert to Unix ms
491
+ isActive: true,
492
+ providerData: {
493
+ issuedAt: claims.iat * 1000,
494
+ issuer: claims.iss,
495
+ ...(claims.azp && { authorizedParty: claims.azp }),
496
+ ...(claims.aud && { audience: claims.aud }),
497
+ },
498
+ },
499
+ };
500
+ }
501
+ // ============================================================================
502
+ // Factory Function
503
+ // ============================================================================
504
+ /**
505
+ * Create an Auth0 adapter
506
+ *
507
+ * This is the recommended way to create an Auth0 adapter.
508
+ * It returns an adapter instance with the configuration attached.
509
+ *
510
+ * @param config - Adapter configuration
511
+ * @returns Auth0 adapter with configuration
512
+ *
513
+ * @example
514
+ * ```typescript
515
+ * import { createAuth0Adapter } from '@veloxts/auth/adapters/auth0';
516
+ * import { createAuthAdapterPlugin } from '@veloxts/auth';
517
+ *
518
+ * const adapter = createAuth0Adapter({
519
+ * domain: process.env.AUTH0_DOMAIN!,
520
+ * audience: process.env.AUTH0_AUDIENCE!,
521
+ * clientId: process.env.AUTH0_CLIENT_ID, // Optional
522
+ * debug: process.env.NODE_ENV === 'development',
523
+ * });
524
+ *
525
+ * // Simplified API - just pass the adapter
526
+ * const authPlugin = createAuthAdapterPlugin(adapter);
527
+ *
528
+ * app.use(authPlugin);
529
+ * ```
530
+ */
531
+ export function createAuth0Adapter(config) {
532
+ const adapter = new Auth0Adapter();
533
+ // Attach config for easy access when creating plugin
534
+ return Object.assign(adapter, { config });
535
+ }
536
+ // ============================================================================
537
+ // Re-exports
538
+ // ============================================================================
539
+ export { AuthAdapterError } from '../adapter.js';