@digilogiclabs/platform-core 1.5.0 → 1.5.1

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/dist/auth.mjs ADDED
@@ -0,0 +1,900 @@
1
+ // src/auth/keycloak.ts
2
+ var KEYCLOAK_DEFAULT_ROLES = [
3
+ "offline_access",
4
+ "uma_authorization"
5
+ ];
6
+ function parseKeycloakRoles(accessToken, additionalDefaultRoles = []) {
7
+ if (!accessToken) return [];
8
+ try {
9
+ const parts = accessToken.split(".");
10
+ if (parts.length !== 3) return [];
11
+ const payload = parts[1];
12
+ const decoded = JSON.parse(atob(payload));
13
+ const realmRoles = decoded.realm_roles ?? decoded.realm_access?.roles;
14
+ if (!Array.isArray(realmRoles)) return [];
15
+ const filterSet = /* @__PURE__ */ new Set([
16
+ ...KEYCLOAK_DEFAULT_ROLES,
17
+ ...additionalDefaultRoles
18
+ ]);
19
+ return realmRoles.filter(
20
+ (role) => typeof role === "string" && !filterSet.has(role)
21
+ );
22
+ } catch {
23
+ return [];
24
+ }
25
+ }
26
+ function hasRole(roles, role) {
27
+ return roles?.includes(role) ?? false;
28
+ }
29
+ function hasAnyRole(roles, requiredRoles) {
30
+ if (!roles || roles.length === 0) return false;
31
+ return requiredRoles.some((role) => roles.includes(role));
32
+ }
33
+ function hasAllRoles(roles, requiredRoles) {
34
+ if (!roles || roles.length === 0) return false;
35
+ return requiredRoles.every((role) => roles.includes(role));
36
+ }
37
+ function isTokenExpired(expiresAt, bufferMs = 6e4) {
38
+ if (!expiresAt) return true;
39
+ return Date.now() >= expiresAt - bufferMs;
40
+ }
41
+ function buildTokenRefreshParams(config, refreshToken) {
42
+ return new URLSearchParams({
43
+ grant_type: "refresh_token",
44
+ client_id: config.clientId,
45
+ client_secret: config.clientSecret,
46
+ refresh_token: refreshToken
47
+ });
48
+ }
49
+ function getTokenEndpoint(issuer) {
50
+ const base = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer;
51
+ return `${base}/protocol/openid-connect/token`;
52
+ }
53
+ function getEndSessionEndpoint(issuer) {
54
+ const base = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer;
55
+ return `${base}/protocol/openid-connect/logout`;
56
+ }
57
+ async function refreshKeycloakToken(config, refreshToken, additionalDefaultRoles) {
58
+ try {
59
+ const endpoint = getTokenEndpoint(config.issuer);
60
+ const params = buildTokenRefreshParams(config, refreshToken);
61
+ const response = await fetch(endpoint, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
64
+ body: params
65
+ });
66
+ if (!response.ok) {
67
+ const body = await response.text().catch(() => "");
68
+ return {
69
+ ok: false,
70
+ error: `Token refresh failed: HTTP ${response.status} - ${body}`
71
+ };
72
+ }
73
+ const data = await response.json();
74
+ return {
75
+ ok: true,
76
+ tokens: {
77
+ accessToken: data.access_token,
78
+ refreshToken: data.refresh_token ?? refreshToken,
79
+ idToken: data.id_token,
80
+ expiresAt: Date.now() + data.expires_in * 1e3,
81
+ roles: parseKeycloakRoles(data.access_token, additionalDefaultRoles)
82
+ }
83
+ };
84
+ } catch (error) {
85
+ return {
86
+ ok: false,
87
+ error: error instanceof Error ? error.message : "Token refresh failed"
88
+ };
89
+ }
90
+ }
91
+
92
+ // src/auth/nextjs-keycloak.ts
93
+ function buildAuthCookies(config = {}) {
94
+ const { domain, sessionToken = true, callbackUrl = true } = config;
95
+ const isProduction = process.env.NODE_ENV === "production";
96
+ const cookieDomain = isProduction ? domain : void 0;
97
+ const baseOptions = {
98
+ httpOnly: true,
99
+ sameSite: "lax",
100
+ path: "/",
101
+ secure: isProduction,
102
+ domain: cookieDomain
103
+ };
104
+ const cookies = {
105
+ pkceCodeVerifier: {
106
+ name: "authjs.pkce.code_verifier",
107
+ options: { ...baseOptions }
108
+ },
109
+ state: {
110
+ name: "authjs.state",
111
+ options: { ...baseOptions }
112
+ }
113
+ };
114
+ if (sessionToken) {
115
+ cookies.sessionToken = {
116
+ name: isProduction ? "__Secure-authjs.session-token" : "authjs.session-token",
117
+ options: { ...baseOptions }
118
+ };
119
+ }
120
+ if (callbackUrl) {
121
+ cookies.callbackUrl = {
122
+ name: isProduction ? "__Secure-authjs.callback-url" : "authjs.callback-url",
123
+ options: { ...baseOptions }
124
+ };
125
+ }
126
+ return cookies;
127
+ }
128
+ function buildRedirectCallback(config = {}) {
129
+ const { allowWwwVariant = false } = config;
130
+ return async ({ url, baseUrl }) => {
131
+ if (url.startsWith("/")) return `${baseUrl}${url}`;
132
+ try {
133
+ if (new URL(url).origin === baseUrl) return url;
134
+ } catch {
135
+ return baseUrl;
136
+ }
137
+ if (allowWwwVariant) {
138
+ try {
139
+ const urlHost = new URL(url).hostname;
140
+ const baseHost = new URL(baseUrl).hostname;
141
+ if (urlHost === `www.${baseHost}` || baseHost === `www.${urlHost}`) {
142
+ return url;
143
+ }
144
+ } catch {
145
+ }
146
+ }
147
+ return baseUrl;
148
+ };
149
+ }
150
+ function buildKeycloakCallbacks(config) {
151
+ const {
152
+ issuer,
153
+ clientId,
154
+ clientSecret,
155
+ defaultRoles = [],
156
+ debug = process.env.NODE_ENV === "development"
157
+ } = config;
158
+ const kcConfig = { issuer, clientId, clientSecret };
159
+ function log(message, meta) {
160
+ if (debug) {
161
+ console.log(`[Auth] ${message}`, meta ? JSON.stringify(meta) : "");
162
+ }
163
+ }
164
+ return {
165
+ /**
166
+ * JWT callback — stores Keycloak tokens and handles refresh.
167
+ *
168
+ * Compatible with Auth.js v5 JWT callback signature.
169
+ */
170
+ async jwt({
171
+ token,
172
+ user,
173
+ account
174
+ }) {
175
+ if (user) {
176
+ token.id = token.sub ?? user.id;
177
+ }
178
+ if (account?.provider === "keycloak") {
179
+ token.accessToken = account.access_token;
180
+ token.refreshToken = account.refresh_token;
181
+ token.idToken = account.id_token;
182
+ token.roles = parseKeycloakRoles(
183
+ account.access_token,
184
+ defaultRoles
185
+ );
186
+ token.accessTokenExpires = account.expires_at ? account.expires_at * 1e3 : Date.now() + 3e5;
187
+ return token;
188
+ }
189
+ if (!isTokenExpired(token.accessTokenExpires)) {
190
+ return token;
191
+ }
192
+ if (token.refreshToken) {
193
+ log("Token expired, attempting refresh...");
194
+ const result = await refreshKeycloakToken(
195
+ kcConfig,
196
+ token.refreshToken,
197
+ defaultRoles
198
+ );
199
+ if (result.ok) {
200
+ token.accessToken = result.tokens.accessToken;
201
+ token.idToken = result.tokens.idToken ?? token.idToken;
202
+ token.refreshToken = result.tokens.refreshToken ?? token.refreshToken;
203
+ token.accessTokenExpires = result.tokens.expiresAt;
204
+ token.roles = result.tokens.roles;
205
+ delete token.error;
206
+ log("Token refreshed OK");
207
+ return token;
208
+ }
209
+ log("Token refresh failed", { error: result.error });
210
+ return { ...token, error: "RefreshTokenError" };
211
+ }
212
+ log("Token expired but no refresh token available");
213
+ return { ...token, error: "RefreshTokenError" };
214
+ },
215
+ /**
216
+ * Session callback — maps JWT fields to the session object.
217
+ *
218
+ * Compatible with Auth.js v5 session callback signature.
219
+ */
220
+ async session({
221
+ session,
222
+ token
223
+ }) {
224
+ const user = session.user;
225
+ if (user) {
226
+ user.id = token.id || token.sub;
227
+ user.roles = token.roles || [];
228
+ }
229
+ session.idToken = token.idToken;
230
+ session.accessToken = token.accessToken;
231
+ if (token.error) {
232
+ session.error = token.error;
233
+ }
234
+ return session;
235
+ }
236
+ };
237
+ }
238
+
239
+ // src/auth/api-security.ts
240
+ var StandardRateLimitPresets = {
241
+ /** General API: 100/min, 200/min authenticated */
242
+ apiGeneral: {
243
+ limit: 100,
244
+ windowSeconds: 60,
245
+ authenticatedLimit: 200
246
+ },
247
+ /** Admin operations: 100/min (admins are trusted) */
248
+ adminAction: {
249
+ limit: 100,
250
+ windowSeconds: 60
251
+ },
252
+ /** AI/expensive operations: 20/hour, 50/hour authenticated */
253
+ aiRequest: {
254
+ limit: 20,
255
+ windowSeconds: 3600,
256
+ authenticatedLimit: 50
257
+ },
258
+ /** Auth attempts: 5/15min with 15min block */
259
+ authAttempt: {
260
+ limit: 5,
261
+ windowSeconds: 900,
262
+ blockDurationSeconds: 900
263
+ },
264
+ /** Contact/public forms: 10/hour */
265
+ publicForm: {
266
+ limit: 10,
267
+ windowSeconds: 3600,
268
+ blockDurationSeconds: 1800
269
+ },
270
+ /** Checkout/billing: 10/hour with 1hr block */
271
+ checkout: {
272
+ limit: 10,
273
+ windowSeconds: 3600,
274
+ blockDurationSeconds: 3600
275
+ }
276
+ };
277
+ function resolveRateLimitIdentifier(session, clientIp) {
278
+ if (session?.user?.id) {
279
+ return { identifier: `user:${session.user.id}`, isAuthenticated: true };
280
+ }
281
+ if (session?.user?.email) {
282
+ return {
283
+ identifier: `email:${session.user.email}`,
284
+ isAuthenticated: true
285
+ };
286
+ }
287
+ return { identifier: `ip:${clientIp}`, isAuthenticated: false };
288
+ }
289
+ function extractClientIp(getHeader) {
290
+ return getHeader("cf-connecting-ip") || getHeader("x-real-ip") || getHeader("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
291
+ }
292
+ function buildRateLimitHeaders(limit, remaining, resetAtMs) {
293
+ return {
294
+ "X-RateLimit-Limit": String(limit),
295
+ "X-RateLimit-Remaining": String(Math.max(0, remaining)),
296
+ "X-RateLimit-Reset": String(Math.ceil(resetAtMs / 1e3))
297
+ };
298
+ }
299
+ function buildErrorBody(error, extra) {
300
+ return { error, ...extra };
301
+ }
302
+ var WrapperPresets = {
303
+ /** Public route: no auth, rate limited */
304
+ public: {
305
+ requireAuth: false,
306
+ requireAdmin: false,
307
+ rateLimit: "apiGeneral"
308
+ },
309
+ /** Authenticated route: requires session */
310
+ authenticated: {
311
+ requireAuth: true,
312
+ requireAdmin: false,
313
+ rateLimit: "apiGeneral"
314
+ },
315
+ /** Admin route: requires session with admin role */
316
+ admin: {
317
+ requireAuth: true,
318
+ requireAdmin: true,
319
+ rateLimit: "adminAction"
320
+ },
321
+ /** Legacy admin: accepts session OR bearer token */
322
+ legacyAdmin: {
323
+ requireAuth: true,
324
+ requireAdmin: true,
325
+ allowBearerToken: true,
326
+ rateLimit: "adminAction"
327
+ },
328
+ /** AI/expensive: requires auth, strict rate limit */
329
+ ai: {
330
+ requireAuth: true,
331
+ requireAdmin: false,
332
+ rateLimit: "aiRequest"
333
+ },
334
+ /** Cron: no rate limit, admin-level access */
335
+ cron: {
336
+ requireAuth: true,
337
+ requireAdmin: false,
338
+ skipRateLimit: true,
339
+ skipAudit: false
340
+ }
341
+ };
342
+
343
+ // src/auth/schemas.ts
344
+ import { z } from "zod";
345
+
346
+ // src/security.ts
347
+ var URL_PROTOCOL_PATTERN = /(https?:\/\/|ftp:\/\/|www\.)\S+/i;
348
+ var URL_DOMAIN_PATTERN = /\b[\w.-]+\.(com|net|org|io|co|dev|app|xyz|info|biz|me|us|uk|edu|gov)\b/i;
349
+ var HTML_TAG_PATTERN = /<[^>]*>/;
350
+
351
+ // src/auth/schemas.ts
352
+ var EmailSchema = z.string().trim().toLowerCase().email("Invalid email address");
353
+ var PasswordSchema = z.string().min(8, "Password must be at least 8 characters").max(100, "Password must be less than 100 characters");
354
+ var SlugSchema = z.string().min(1, "Slug is required").max(100, "Slug must be less than 100 characters").regex(
355
+ /^[a-z0-9-]+$/,
356
+ "Slug can only contain lowercase letters, numbers, and hyphens"
357
+ );
358
+ var PhoneSchema = z.string().regex(/^[\d\s()+.\-]{7,20}$/, "Invalid phone number format");
359
+ var PersonNameSchema = z.string().min(2, "Name must be at least 2 characters").max(100, "Name must be less than 100 characters").regex(
360
+ /^[a-zA-Z\s\-']+$/,
361
+ "Name can only contain letters, spaces, hyphens and apostrophes"
362
+ );
363
+ function createSafeTextSchema(options) {
364
+ const {
365
+ min,
366
+ max,
367
+ allowHtml = false,
368
+ allowUrls = false,
369
+ fieldName = "Text"
370
+ } = options ?? {};
371
+ let schema = z.string();
372
+ if (min !== void 0)
373
+ schema = schema.min(min, `${fieldName} must be at least ${min} characters`);
374
+ if (max !== void 0)
375
+ schema = schema.max(
376
+ max,
377
+ `${fieldName} must be less than ${max} characters`
378
+ );
379
+ if (!allowHtml && !allowUrls) {
380
+ return schema.refine((val) => !HTML_TAG_PATTERN.test(val), "HTML tags are not allowed").refine(
381
+ (val) => !URL_PROTOCOL_PATTERN.test(val),
382
+ "Links are not allowed for security reasons"
383
+ ).refine(
384
+ (val) => !URL_DOMAIN_PATTERN.test(val),
385
+ "Links are not allowed for security reasons"
386
+ );
387
+ }
388
+ if (!allowHtml) {
389
+ return schema.refine(
390
+ (val) => !HTML_TAG_PATTERN.test(val),
391
+ "HTML tags are not allowed"
392
+ );
393
+ }
394
+ if (!allowUrls) {
395
+ return schema.refine(
396
+ (val) => !URL_PROTOCOL_PATTERN.test(val),
397
+ "Links are not allowed for security reasons"
398
+ ).refine(
399
+ (val) => !URL_DOMAIN_PATTERN.test(val),
400
+ "Links are not allowed for security reasons"
401
+ );
402
+ }
403
+ return schema;
404
+ }
405
+ var PaginationSchema = z.object({
406
+ page: z.coerce.number().int().positive().default(1),
407
+ limit: z.coerce.number().int().positive().max(100).default(20),
408
+ sortBy: z.string().optional(),
409
+ sortOrder: z.enum(["asc", "desc"]).default("desc")
410
+ });
411
+ var DateRangeSchema = z.object({
412
+ startDate: z.string().datetime(),
413
+ endDate: z.string().datetime()
414
+ }).refine((data) => new Date(data.startDate) <= new Date(data.endDate), {
415
+ message: "Start date must be before end date"
416
+ });
417
+ var SearchQuerySchema = z.object({
418
+ query: z.string().min(1).max(200).trim(),
419
+ page: z.coerce.number().int().positive().default(1),
420
+ limit: z.coerce.number().int().positive().max(50).default(10)
421
+ });
422
+ var LoginSchema = z.object({
423
+ email: EmailSchema,
424
+ password: PasswordSchema
425
+ });
426
+ var SignupSchema = z.object({
427
+ email: EmailSchema,
428
+ password: PasswordSchema,
429
+ name: z.string().min(2).max(100).optional()
430
+ });
431
+
432
+ // src/auth/feature-flags.ts
433
+ function detectStage() {
434
+ const stage = process.env.DEPLOYMENT_STAGE;
435
+ if (stage === "staging" || stage === "preview") return stage;
436
+ if (process.env.NODE_ENV === "production") return "production";
437
+ return "development";
438
+ }
439
+ function resolveFlagValue(value) {
440
+ if (typeof value === "boolean") return value;
441
+ const envValue = process.env[value.envVar];
442
+ if (envValue === void 0 || envValue === "") {
443
+ return value.default ?? false;
444
+ }
445
+ return envValue === "true" || envValue === "1";
446
+ }
447
+ function createFeatureFlags(definitions) {
448
+ return {
449
+ /**
450
+ * Resolve all flags for the current environment.
451
+ * Call this once at startup or per-request for dynamic flags.
452
+ */
453
+ resolve(stage) {
454
+ const currentStage = stage ?? detectStage();
455
+ const resolved = {};
456
+ for (const [key, def] of Object.entries(definitions)) {
457
+ const stageKey = currentStage === "preview" ? "staging" : currentStage;
458
+ resolved[key] = resolveFlagValue(def[stageKey]);
459
+ }
460
+ return resolved;
461
+ },
462
+ /**
463
+ * Check if a single flag is enabled.
464
+ */
465
+ isEnabled(flag, stage) {
466
+ const currentStage = stage ?? detectStage();
467
+ const def = definitions[flag];
468
+ const stageKey = currentStage === "preview" ? "staging" : currentStage;
469
+ return resolveFlagValue(def[stageKey]);
470
+ },
471
+ /**
472
+ * Get the flag definitions (for introspection/admin UI).
473
+ */
474
+ definitions
475
+ };
476
+ }
477
+ function buildAllowlist(config) {
478
+ const fromEnv = config.envVar ? process.env[config.envVar]?.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean) ?? [] : [];
479
+ const fallback = config.fallback?.map((e) => e.toLowerCase()) ?? [];
480
+ return [.../* @__PURE__ */ new Set([...fromEnv, ...fallback])];
481
+ }
482
+ function isAllowlisted(email, allowlist) {
483
+ return allowlist.includes(email.toLowerCase());
484
+ }
485
+
486
+ // src/auth/rate-limiter.ts
487
+ var CommonRateLimits = {
488
+ /** General API: 100/min, 200/min authenticated */
489
+ apiGeneral: {
490
+ limit: 100,
491
+ windowSeconds: 60,
492
+ authenticatedLimit: 200
493
+ },
494
+ /** Admin actions: 100/min */
495
+ adminAction: {
496
+ limit: 100,
497
+ windowSeconds: 60
498
+ },
499
+ /** Auth attempts: 10/15min with 30min block */
500
+ authAttempt: {
501
+ limit: 10,
502
+ windowSeconds: 900,
503
+ blockDurationSeconds: 1800
504
+ },
505
+ /** AI/expensive requests: 20/hour, 50/hour authenticated */
506
+ aiRequest: {
507
+ limit: 20,
508
+ windowSeconds: 3600,
509
+ authenticatedLimit: 50
510
+ },
511
+ /** Public form submissions: 5/hour with 1hr block */
512
+ publicForm: {
513
+ limit: 5,
514
+ windowSeconds: 3600,
515
+ blockDurationSeconds: 3600
516
+ },
517
+ /** Checkout/billing: 10/hour with 1hr block */
518
+ checkout: {
519
+ limit: 10,
520
+ windowSeconds: 3600,
521
+ blockDurationSeconds: 3600
522
+ }
523
+ };
524
+ function createMemoryRateLimitStore() {
525
+ const windows = /* @__PURE__ */ new Map();
526
+ const blocks = /* @__PURE__ */ new Map();
527
+ const cleanupInterval = setInterval(() => {
528
+ const now = Date.now();
529
+ for (const [key, entry] of windows) {
530
+ if (entry.expiresAt < now) windows.delete(key);
531
+ }
532
+ for (const [key, expiry] of blocks) {
533
+ if (expiry < now) blocks.delete(key);
534
+ }
535
+ }, 60 * 1e3);
536
+ if (cleanupInterval.unref) {
537
+ cleanupInterval.unref();
538
+ }
539
+ return {
540
+ async increment(key, windowMs, now) {
541
+ const windowStart = now - windowMs;
542
+ let entry = windows.get(key);
543
+ if (!entry) {
544
+ entry = { timestamps: [], expiresAt: now + windowMs + 6e4 };
545
+ windows.set(key, entry);
546
+ }
547
+ entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
548
+ entry.timestamps.push(now);
549
+ entry.expiresAt = now + windowMs + 6e4;
550
+ return { count: entry.timestamps.length };
551
+ },
552
+ async isBlocked(key) {
553
+ const expiry = blocks.get(key);
554
+ if (!expiry || expiry < Date.now()) {
555
+ blocks.delete(key);
556
+ return { blocked: false, ttlMs: 0 };
557
+ }
558
+ return { blocked: true, ttlMs: expiry - Date.now() };
559
+ },
560
+ async setBlock(key, durationSeconds) {
561
+ blocks.set(key, Date.now() + durationSeconds * 1e3);
562
+ },
563
+ async reset(key) {
564
+ windows.delete(key);
565
+ blocks.delete(`block:${key}`);
566
+ blocks.delete(key);
567
+ }
568
+ };
569
+ }
570
+ var defaultStore;
571
+ function getDefaultStore() {
572
+ if (!defaultStore) {
573
+ defaultStore = createMemoryRateLimitStore();
574
+ }
575
+ return defaultStore;
576
+ }
577
+ async function checkRateLimit(operation, identifier, rule, options = {}) {
578
+ const store = options.store ?? getDefaultStore();
579
+ const limit = options.isAuthenticated && rule.authenticatedLimit ? rule.authenticatedLimit : rule.limit;
580
+ const now = Date.now();
581
+ const windowMs = rule.windowSeconds * 1e3;
582
+ const resetAt = now + windowMs;
583
+ const key = `ratelimit:${operation}:${identifier}`;
584
+ const blockKey = `ratelimit:block:${operation}:${identifier}`;
585
+ try {
586
+ if (rule.blockDurationSeconds) {
587
+ const blockStatus = await store.isBlocked(blockKey);
588
+ if (blockStatus.blocked) {
589
+ return {
590
+ allowed: false,
591
+ remaining: 0,
592
+ resetAt: now + blockStatus.ttlMs,
593
+ current: limit + 1,
594
+ limit,
595
+ retryAfterSeconds: Math.ceil(blockStatus.ttlMs / 1e3)
596
+ };
597
+ }
598
+ }
599
+ const { count } = await store.increment(key, windowMs, now);
600
+ if (count > limit) {
601
+ options.logger?.warn("Rate limit exceeded", {
602
+ operation,
603
+ identifier,
604
+ current: count,
605
+ limit
606
+ });
607
+ if (rule.blockDurationSeconds) {
608
+ await store.setBlock(blockKey, rule.blockDurationSeconds);
609
+ }
610
+ return {
611
+ allowed: false,
612
+ remaining: 0,
613
+ resetAt,
614
+ current: count,
615
+ limit,
616
+ retryAfterSeconds: Math.ceil(windowMs / 1e3)
617
+ };
618
+ }
619
+ return {
620
+ allowed: true,
621
+ remaining: limit - count,
622
+ resetAt,
623
+ current: count,
624
+ limit,
625
+ retryAfterSeconds: 0
626
+ };
627
+ } catch (error) {
628
+ options.logger?.error("Rate limit check failed, allowing request", {
629
+ error: error instanceof Error ? error.message : String(error),
630
+ operation,
631
+ identifier
632
+ });
633
+ return {
634
+ allowed: true,
635
+ remaining: limit,
636
+ resetAt,
637
+ current: 0,
638
+ limit,
639
+ retryAfterSeconds: 0
640
+ };
641
+ }
642
+ }
643
+ async function getRateLimitStatus(operation, identifier, rule, store) {
644
+ const s = store ?? getDefaultStore();
645
+ const key = `ratelimit:${operation}:${identifier}`;
646
+ const now = Date.now();
647
+ const windowMs = rule.windowSeconds * 1e3;
648
+ try {
649
+ const { count } = await s.increment(key, windowMs, now);
650
+ return {
651
+ allowed: count <= rule.limit,
652
+ remaining: Math.max(0, rule.limit - count),
653
+ resetAt: now + windowMs,
654
+ current: count,
655
+ limit: rule.limit,
656
+ retryAfterSeconds: count > rule.limit ? Math.ceil(windowMs / 1e3) : 0
657
+ };
658
+ } catch {
659
+ return null;
660
+ }
661
+ }
662
+ async function resetRateLimitForKey(operation, identifier, store) {
663
+ const s = store ?? getDefaultStore();
664
+ const key = `ratelimit:${operation}:${identifier}`;
665
+ const blockKey = `ratelimit:block:${operation}:${identifier}`;
666
+ await s.reset(key);
667
+ await s.reset(blockKey);
668
+ }
669
+ function buildRateLimitResponseHeaders(result) {
670
+ const headers = {
671
+ "X-RateLimit-Limit": String(result.limit),
672
+ "X-RateLimit-Remaining": String(result.remaining),
673
+ "X-RateLimit-Reset": String(Math.ceil(result.resetAt / 1e3))
674
+ };
675
+ if (!result.allowed) {
676
+ headers["Retry-After"] = String(result.retryAfterSeconds);
677
+ }
678
+ return headers;
679
+ }
680
+ function resolveIdentifier(session, clientIp) {
681
+ if (session?.user?.id) {
682
+ return { identifier: `user:${session.user.id}`, isAuthenticated: true };
683
+ }
684
+ if (session?.user?.email) {
685
+ return {
686
+ identifier: `email:${session.user.email}`,
687
+ isAuthenticated: true
688
+ };
689
+ }
690
+ return { identifier: `ip:${clientIp ?? "unknown"}`, isAuthenticated: false };
691
+ }
692
+
693
+ // src/auth/audit.ts
694
+ var StandardAuditActions = {
695
+ // Authentication
696
+ LOGIN_SUCCESS: "auth.login.success",
697
+ LOGIN_FAILURE: "auth.login.failure",
698
+ LOGOUT: "auth.logout",
699
+ SESSION_REFRESH: "auth.session.refresh",
700
+ PASSWORD_CHANGE: "auth.password.change",
701
+ PASSWORD_RESET: "auth.password.reset",
702
+ // Billing
703
+ CHECKOUT_START: "billing.checkout.start",
704
+ CHECKOUT_COMPLETE: "billing.checkout.complete",
705
+ SUBSCRIPTION_CREATE: "billing.subscription.create",
706
+ SUBSCRIPTION_CANCEL: "billing.subscription.cancel",
707
+ SUBSCRIPTION_UPDATE: "billing.subscription.update",
708
+ PAYMENT_FAILED: "billing.payment.failed",
709
+ // Admin
710
+ ADMIN_LOGIN: "admin.login",
711
+ ADMIN_USER_VIEW: "admin.user.view",
712
+ ADMIN_USER_UPDATE: "admin.user.update",
713
+ ADMIN_CONFIG_CHANGE: "admin.config.change",
714
+ // Security Events
715
+ RATE_LIMIT_EXCEEDED: "security.rate_limit.exceeded",
716
+ INVALID_INPUT: "security.input.invalid",
717
+ UNAUTHORIZED_ACCESS: "security.access.unauthorized",
718
+ OWNERSHIP_VIOLATION: "security.ownership.violation",
719
+ WEBHOOK_SIGNATURE_INVALID: "security.webhook.signature_invalid",
720
+ // Data
721
+ DATA_EXPORT: "data.export",
722
+ DATA_DELETE: "data.delete",
723
+ DATA_UPDATE: "data.update"
724
+ };
725
+ function extractAuditIp(request) {
726
+ if (!request) return void 0;
727
+ const headers = [
728
+ "cf-connecting-ip",
729
+ // Cloudflare
730
+ "x-real-ip",
731
+ // Nginx
732
+ "x-forwarded-for",
733
+ // Standard proxy
734
+ "x-client-ip"
735
+ // Apache
736
+ ];
737
+ for (const header of headers) {
738
+ const value = request.headers.get(header);
739
+ if (value) {
740
+ return value.split(",")[0]?.trim();
741
+ }
742
+ }
743
+ return void 0;
744
+ }
745
+ function extractAuditUserAgent(request) {
746
+ return request?.headers.get("user-agent") ?? void 0;
747
+ }
748
+ function extractAuditRequestId(request) {
749
+ return request?.headers.get("x-request-id") ?? crypto.randomUUID();
750
+ }
751
+ function createAuditActor(session) {
752
+ if (!session?.user) {
753
+ return { id: "anonymous", type: "anonymous" };
754
+ }
755
+ return {
756
+ id: session.user.id ?? session.user.email ?? "unknown",
757
+ email: session.user.email ?? void 0,
758
+ type: "user"
759
+ };
760
+ }
761
+ var defaultLogger = {
762
+ info: (msg, meta) => console.log(msg, meta ? JSON.stringify(meta) : ""),
763
+ warn: (msg, meta) => console.warn(msg, meta ? JSON.stringify(meta) : ""),
764
+ error: (msg, meta) => console.error(msg, meta ? JSON.stringify(meta) : "")
765
+ };
766
+ function createAuditLogger(options = {}) {
767
+ const { persist, logger = defaultLogger } = options;
768
+ async function log(event, request) {
769
+ const record = {
770
+ id: crypto.randomUUID(),
771
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
772
+ ip: extractAuditIp(request),
773
+ userAgent: extractAuditUserAgent(request),
774
+ requestId: extractAuditRequestId(request),
775
+ ...event
776
+ };
777
+ const logFn = event.outcome === "failure" || event.outcome === "blocked" ? logger.warn : logger.info;
778
+ logFn(`[AUDIT] ${event.action}`, {
779
+ auditId: record.id,
780
+ actor: record.actor,
781
+ action: record.action,
782
+ resource: record.resource,
783
+ outcome: record.outcome,
784
+ ip: record.ip,
785
+ metadata: record.metadata,
786
+ reason: record.reason
787
+ });
788
+ if (persist) {
789
+ try {
790
+ await persist(record);
791
+ } catch (error) {
792
+ logger.error("Failed to persist audit log", {
793
+ error: error instanceof Error ? error.message : String(error),
794
+ auditId: record.id
795
+ });
796
+ }
797
+ }
798
+ return record;
799
+ }
800
+ function createTimedAudit(event, request) {
801
+ const startTime = Date.now();
802
+ return {
803
+ success: async (metadata) => {
804
+ return log(
805
+ {
806
+ ...event,
807
+ outcome: "success",
808
+ metadata: {
809
+ ...event.metadata,
810
+ ...metadata,
811
+ durationMs: Date.now() - startTime
812
+ }
813
+ },
814
+ request
815
+ );
816
+ },
817
+ failure: async (reason, metadata) => {
818
+ return log(
819
+ {
820
+ ...event,
821
+ outcome: "failure",
822
+ reason,
823
+ metadata: {
824
+ ...event.metadata,
825
+ ...metadata,
826
+ durationMs: Date.now() - startTime
827
+ }
828
+ },
829
+ request
830
+ );
831
+ },
832
+ blocked: async (reason, metadata) => {
833
+ return log(
834
+ {
835
+ ...event,
836
+ outcome: "blocked",
837
+ reason,
838
+ metadata: {
839
+ ...event.metadata,
840
+ ...metadata,
841
+ durationMs: Date.now() - startTime
842
+ }
843
+ },
844
+ request
845
+ );
846
+ }
847
+ };
848
+ }
849
+ return { log, createTimedAudit };
850
+ }
851
+ export {
852
+ CommonRateLimits,
853
+ DateRangeSchema,
854
+ EmailSchema,
855
+ KEYCLOAK_DEFAULT_ROLES,
856
+ LoginSchema,
857
+ PaginationSchema,
858
+ PasswordSchema,
859
+ PersonNameSchema,
860
+ PhoneSchema,
861
+ SearchQuerySchema,
862
+ SignupSchema,
863
+ SlugSchema,
864
+ StandardAuditActions,
865
+ StandardRateLimitPresets,
866
+ WrapperPresets,
867
+ buildAllowlist,
868
+ buildAuthCookies,
869
+ buildErrorBody,
870
+ buildKeycloakCallbacks,
871
+ buildRateLimitHeaders,
872
+ buildRateLimitResponseHeaders,
873
+ buildRedirectCallback,
874
+ buildTokenRefreshParams,
875
+ checkRateLimit,
876
+ createAuditActor,
877
+ createAuditLogger,
878
+ createFeatureFlags,
879
+ createMemoryRateLimitStore,
880
+ createSafeTextSchema,
881
+ detectStage,
882
+ extractAuditIp,
883
+ extractAuditRequestId,
884
+ extractAuditUserAgent,
885
+ extractClientIp,
886
+ getEndSessionEndpoint,
887
+ getRateLimitStatus,
888
+ getTokenEndpoint,
889
+ hasAllRoles,
890
+ hasAnyRole,
891
+ hasRole,
892
+ isAllowlisted,
893
+ isTokenExpired,
894
+ parseKeycloakRoles,
895
+ refreshKeycloakToken,
896
+ resetRateLimitForKey,
897
+ resolveIdentifier,
898
+ resolveRateLimitIdentifier
899
+ };
900
+ //# sourceMappingURL=auth.mjs.map