@stackwright-pro/auth 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/dist/index.js ADDED
@@ -0,0 +1,1182 @@
1
+ 'use strict';
2
+
3
+ var zod = require('zod');
4
+ var x509 = require('@peculiar/x509');
5
+ var jose = require('jose');
6
+ var react = require('react');
7
+ var jsxRuntime = require('react/jsx-runtime');
8
+
9
+ function _interopNamespace(e) {
10
+ if (e && e.__esModule) return e;
11
+ var n = Object.create(null);
12
+ if (e) {
13
+ Object.keys(e).forEach(function (k) {
14
+ if (k !== 'default') {
15
+ var d = Object.getOwnPropertyDescriptor(e, k);
16
+ Object.defineProperty(n, k, d.get ? d : {
17
+ enumerable: true,
18
+ get: function () { return e[k]; }
19
+ });
20
+ }
21
+ });
22
+ }
23
+ n.default = e;
24
+ return Object.freeze(n);
25
+ }
26
+
27
+ var jose__namespace = /*#__PURE__*/_interopNamespace(jose);
28
+
29
+ // src/schemas/auth-schemas.ts
30
+ var pkiConfigSchema = zod.z.object({
31
+ type: zod.z.literal("pki"),
32
+ profile: zod.z.enum(["dod_cac", "piv", "custom"]),
33
+ source: zod.z.enum(["gateway_headers", "direct_tls"]),
34
+ headerPrefix: zod.z.string().optional().default("x-client-cert-"),
35
+ verifiedHeader: zod.z.string().optional().default("x-client-cert-verified"),
36
+ requiredValue: zod.z.string().optional().default("SUCCESS"),
37
+ caChain: zod.z.string().optional(),
38
+ requiredOU: zod.z.array(zod.z.string()).optional(),
39
+ allowedIssuers: zod.z.array(zod.z.string()).optional()
40
+ });
41
+ var oidcConfigSchema = zod.z.object({
42
+ type: zod.z.literal("oidc"),
43
+ provider: zod.z.enum([
44
+ "cognito",
45
+ "azure_ad",
46
+ "authentik",
47
+ "keycloak",
48
+ "okta",
49
+ "auth0",
50
+ "custom"
51
+ ]),
52
+ discoveryUrl: zod.z.string().url(),
53
+ clientId: zod.z.string(),
54
+ clientSecret: zod.z.string(),
55
+ redirectUri: zod.z.string().optional(),
56
+ claimsMapping: zod.z.object({
57
+ user_id: zod.z.string().optional(),
58
+ email: zod.z.string().optional(),
59
+ name: zod.z.string().optional(),
60
+ roles: zod.z.string().optional()
61
+ }).optional(),
62
+ quirks: zod.z.object({
63
+ skipIssuerCheck: zod.z.boolean().optional(),
64
+ useRefreshTokenRotation: zod.z.boolean().optional()
65
+ }).optional()
66
+ });
67
+ var authConfigSchema = zod.z.discriminatedUnion("type", [
68
+ pkiConfigSchema,
69
+ oidcConfigSchema
70
+ ]);
71
+ var componentAuthSchema = zod.z.object({
72
+ required_roles: zod.z.array(zod.z.string()).optional(),
73
+ required_permissions: zod.z.array(zod.z.string()).optional(),
74
+ fallback: zod.z.enum(["hide", "placeholder", "message"]).optional().default("hide"),
75
+ fallback_message: zod.z.string().optional()
76
+ }).optional();
77
+ var rbacConfigSchema = zod.z.object({
78
+ roles: zod.z.array(
79
+ zod.z.object({
80
+ name: zod.z.string(),
81
+ permissions: zod.z.array(zod.z.string()).optional()
82
+ })
83
+ ),
84
+ protected_routes: zod.z.array(
85
+ zod.z.object({
86
+ path: zod.z.string(),
87
+ roles: zod.z.array(zod.z.string())
88
+ })
89
+ ).optional(),
90
+ public_routes: zod.z.array(zod.z.string()).optional()
91
+ });
92
+ var authUserSchema = zod.z.object({
93
+ id: zod.z.string(),
94
+ email: zod.z.string().email().optional(),
95
+ name: zod.z.string().optional(),
96
+ roles: zod.z.array(zod.z.string()),
97
+ permissions: zod.z.array(zod.z.string()).optional(),
98
+ metadata: zod.z.record(zod.z.string(), zod.z.any()).optional()
99
+ });
100
+ var authSessionSchema = zod.z.object({
101
+ user: authUserSchema,
102
+ expiresAt: zod.z.number(),
103
+ issuedAt: zod.z.number(),
104
+ refreshToken: zod.z.string().optional()
105
+ });
106
+ function parseCertificate(pemOrDer) {
107
+ const cert = new x509.X509Certificate(pemOrDer);
108
+ const subjectAttrs = cert.subject.split(",").map((s) => s.trim());
109
+ const issuerAttrs = cert.issuer.split(",").map((s) => s.trim());
110
+ return {
111
+ subject: parseNameAttributes(subjectAttrs),
112
+ issuer: {
113
+ commonName: extractAttribute(issuerAttrs, "CN"),
114
+ organization: extractAttribute(issuerAttrs, "O")
115
+ },
116
+ serialNumber: cert.serialNumber,
117
+ notBefore: cert.notBefore,
118
+ notAfter: cert.notAfter,
119
+ isValid: Date.now() >= cert.notBefore.getTime() && Date.now() <= cert.notAfter.getTime()
120
+ };
121
+ }
122
+ function extractAttribute(attrs, key) {
123
+ for (const attr of attrs) {
124
+ const [attrKey, ...valueParts] = attr.split("=");
125
+ if (attrKey.trim().toUpperCase() === key.toUpperCase()) {
126
+ return valueParts.join("=").trim();
127
+ }
128
+ }
129
+ return void 0;
130
+ }
131
+ function extractAttributes(attrs, key) {
132
+ const results = [];
133
+ for (const attr of attrs) {
134
+ const [attrKey, ...valueParts] = attr.split("=");
135
+ if (attrKey.trim().toUpperCase() === key.toUpperCase()) {
136
+ results.push(valueParts.join("=").trim());
137
+ }
138
+ }
139
+ return results;
140
+ }
141
+ function parseNameAttributes(attrs) {
142
+ return {
143
+ commonName: extractAttribute(attrs, "CN"),
144
+ email: extractAttribute(attrs, "E") || extractAttribute(attrs, "emailAddress"),
145
+ organizationalUnit: extractAttributes(attrs, "OU"),
146
+ organization: extractAttribute(attrs, "O"),
147
+ country: extractAttribute(attrs, "C")
148
+ };
149
+ }
150
+ function extractEDIPI(cert) {
151
+ const EDIPI_OID = "2.16.840.1.101.2.1.11.42";
152
+ for (const ext of cert.extensions) {
153
+ if (ext.type === EDIPI_OID) {
154
+ const value = ext.value;
155
+ return Buffer.from(value).toString("hex");
156
+ }
157
+ }
158
+ return void 0;
159
+ }
160
+ function validateDoDCAC(parsed) {
161
+ const hasDoDOU = parsed.subject.organizationalUnit?.some(
162
+ (ou) => ou.toUpperCase().includes("DOD") || ou === "DoD"
163
+ );
164
+ if (!parsed.isValid) {
165
+ return false;
166
+ }
167
+ if (!hasDoDOU) {
168
+ return false;
169
+ }
170
+ return true;
171
+ }
172
+ function parseCertFromHeaders(headers, prefix = "x-client-cert-") {
173
+ const dnHeader = headers[`${prefix}dn`] || headers[`${prefix}subject-dn`];
174
+ const serialHeader = headers[`${prefix}serial`];
175
+ const verifiedHeader = headers[`${prefix}verified`];
176
+ if (!dnHeader) {
177
+ return null;
178
+ }
179
+ const subject = parseDN(dnHeader);
180
+ return {
181
+ subject,
182
+ issuer: {},
183
+ // Not available from headers
184
+ serialNumber: serialHeader || "unknown",
185
+ notBefore: /* @__PURE__ */ new Date(0),
186
+ // Not available from headers
187
+ notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1e3),
188
+ // Assume valid for a year
189
+ isValid: verifiedHeader?.toUpperCase() === "SUCCESS"
190
+ };
191
+ }
192
+ function parseDN(dn) {
193
+ const parts = dn.split(",").map((p) => p.trim());
194
+ const result = {
195
+ organizationalUnit: []
196
+ };
197
+ for (const part of parts) {
198
+ const [key, ...valueParts] = part.split("=");
199
+ const value = valueParts.join("=");
200
+ switch (key.toUpperCase()) {
201
+ case "CN":
202
+ result.commonName = value;
203
+ break;
204
+ case "E":
205
+ case "EMAILADDRESS":
206
+ result.email = value;
207
+ break;
208
+ case "OU":
209
+ result.organizationalUnit?.push(value);
210
+ break;
211
+ case "O":
212
+ result.organization = value;
213
+ break;
214
+ case "C":
215
+ result.country = value;
216
+ break;
217
+ }
218
+ }
219
+ return result;
220
+ }
221
+
222
+ // src/providers/pki-provider.ts
223
+ var PKIProvider = class {
224
+ constructor(config) {
225
+ this.config = config;
226
+ }
227
+ async authenticate(context) {
228
+ let parsed = null;
229
+ if (this.config.source === "gateway_headers") {
230
+ if (!context.headers) {
231
+ return null;
232
+ }
233
+ parsed = parseCertFromHeaders(context.headers, this.config.headerPrefix);
234
+ } else {
235
+ const certData = context.headers?.["x-client-cert"];
236
+ if (!certData) {
237
+ return null;
238
+ }
239
+ parsed = parseCertificate(certData);
240
+ }
241
+ if (!parsed) {
242
+ return null;
243
+ }
244
+ if (!parsed.isValid) {
245
+ return null;
246
+ }
247
+ if (this.config.profile === "dod_cac") {
248
+ if (!validateDoDCAC(parsed)) {
249
+ return null;
250
+ }
251
+ }
252
+ if (this.config.requiredOU) {
253
+ const hasRequiredOU = this.config.requiredOU.some(
254
+ (required) => parsed.subject.organizationalUnit?.some((ou) => ou.includes(required))
255
+ );
256
+ if (!hasRequiredOU) {
257
+ return null;
258
+ }
259
+ }
260
+ if (this.config.allowedIssuers) {
261
+ const issuerCN = parsed.issuer.commonName;
262
+ if (!issuerCN || !this.config.allowedIssuers.includes(issuerCN)) {
263
+ return null;
264
+ }
265
+ }
266
+ const user = {
267
+ id: parsed.serialNumber,
268
+ // Use serial as unique ID
269
+ name: parsed.subject.commonName,
270
+ email: parsed.subject.email,
271
+ roles: this.extractRolesFromCertificate(parsed),
272
+ metadata: {
273
+ organizationalUnit: parsed.subject.organizationalUnit,
274
+ organization: parsed.subject.organization,
275
+ country: parsed.subject.country,
276
+ issuer: parsed.issuer.commonName,
277
+ notBefore: parsed.notBefore.toISOString(),
278
+ notAfter: parsed.notAfter.toISOString()
279
+ }
280
+ };
281
+ return user;
282
+ }
283
+ async validate(session) {
284
+ if (Date.now() > session.expiresAt) {
285
+ return false;
286
+ }
287
+ return true;
288
+ }
289
+ /**
290
+ * Extract roles from certificate based on organizational units
291
+ * Can be customized per deployment via subclassing
292
+ */
293
+ extractRolesFromCertificate(parsed) {
294
+ const roles = [];
295
+ const ous = parsed.subject.organizationalUnit || [];
296
+ if (ous.some((ou) => ou.includes("ADMIN") || ou.includes("ADMINISTRATOR"))) {
297
+ roles.push("ADMIN");
298
+ } else if (ous.some((ou) => ou.includes("ANALYST"))) {
299
+ roles.push("ANALYST");
300
+ } else {
301
+ roles.push("VIEWER");
302
+ }
303
+ return roles;
304
+ }
305
+ };
306
+
307
+ // src/oidc/discovery.ts
308
+ async function discoverOIDC(discoveryUrl) {
309
+ const response = await fetch(discoveryUrl);
310
+ if (!response.ok) {
311
+ throw new Error(`OIDC discovery failed: ${response.statusText}`);
312
+ }
313
+ const metadata = await response.json();
314
+ if (!metadata.issuer || !metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.jwks_uri) {
315
+ throw new Error("Invalid OIDC metadata: missing required fields");
316
+ }
317
+ return metadata;
318
+ }
319
+ function buildAuthorizationUrl(metadata, clientId, redirectUri, state, scopes = ["openid", "profile", "email"]) {
320
+ const url = new URL(metadata.authorization_endpoint);
321
+ url.searchParams.set("client_id", clientId);
322
+ url.searchParams.set("redirect_uri", redirectUri);
323
+ url.searchParams.set("response_type", "code");
324
+ url.searchParams.set("scope", scopes.join(" "));
325
+ if (state) {
326
+ url.searchParams.set("state", state);
327
+ }
328
+ return url.toString();
329
+ }
330
+ async function exchangeCodeForTokens(metadata, code, clientId, clientSecret, redirectUri) {
331
+ const response = await fetch(metadata.token_endpoint, {
332
+ method: "POST",
333
+ headers: {
334
+ "Content-Type": "application/x-www-form-urlencoded",
335
+ "Authorization": `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`
336
+ },
337
+ body: new URLSearchParams({
338
+ grant_type: "authorization_code",
339
+ code,
340
+ redirect_uri: redirectUri
341
+ })
342
+ });
343
+ if (!response.ok) {
344
+ const error = await response.text();
345
+ throw new Error(`Token exchange failed: ${error}`);
346
+ }
347
+ return await response.json();
348
+ }
349
+ async function refreshAccessToken(metadata, refreshToken, clientId, clientSecret) {
350
+ const response = await fetch(metadata.token_endpoint, {
351
+ method: "POST",
352
+ headers: {
353
+ "Content-Type": "application/x-www-form-urlencoded",
354
+ "Authorization": `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`
355
+ },
356
+ body: new URLSearchParams({
357
+ grant_type: "refresh_token",
358
+ refresh_token: refreshToken
359
+ })
360
+ });
361
+ if (!response.ok) {
362
+ throw new Error("Token refresh failed");
363
+ }
364
+ return await response.json();
365
+ }
366
+ async function validateIdToken(idToken, jwksUri, issuer, clientId, skipIssuerCheck = false) {
367
+ const JWKS = jose__namespace.createRemoteJWKSet(new URL(jwksUri));
368
+ const { payload } = await jose__namespace.jwtVerify(idToken, JWKS, {
369
+ issuer: skipIssuerCheck ? void 0 : issuer,
370
+ audience: clientId
371
+ });
372
+ return payload;
373
+ }
374
+
375
+ // src/oidc/providers/keycloak-adapter.ts
376
+ var KeycloakAdapter = class {
377
+ /**
378
+ * Normalize Keycloak issuer (remove /auth prefix if present)
379
+ *
380
+ * Keycloak's issuer URLs changed between versions:
381
+ * - Pre-17: https://keycloak.example.com/auth/realms/myrealm
382
+ * - Post-17: https://keycloak.example.com/realms/myrealm
383
+ *
384
+ * This normalizes to the post-17 format.
385
+ *
386
+ * @param issuer - Issuer from token or metadata
387
+ * @returns Normalized issuer
388
+ */
389
+ static normalizeIssuer(issuer) {
390
+ return issuer.replace("/auth/realms/", "/realms/");
391
+ }
392
+ /**
393
+ * Map Keycloak-specific claims to standard format
394
+ *
395
+ * Keycloak stores roles and groups in non-standard locations:
396
+ * - Roles: realm_access.roles (not standard)
397
+ * - Groups: groups array (sometimes, if configured)
398
+ * - Username: preferred_username (not always 'name')
399
+ *
400
+ * @param tokenPayload - Raw JWT payload from Keycloak
401
+ * @returns Normalized claims matching AuthUser interface
402
+ */
403
+ static mapClaims(tokenPayload) {
404
+ return {
405
+ user_id: tokenPayload.sub,
406
+ email: tokenPayload.email,
407
+ name: tokenPayload.name || tokenPayload.preferred_username,
408
+ roles: tokenPayload.realm_access?.roles || [],
409
+ groups: tokenPayload.groups || [],
410
+ // Keep original payload for advanced use cases
411
+ ...tokenPayload
412
+ };
413
+ }
414
+ /**
415
+ * Keycloak refresh tokens should be refreshed earlier than spec suggests
416
+ *
417
+ * Keycloak's refresh token rotation is buggy and sometimes fails if you
418
+ * wait too long. Refresh aggressively when less than 10 minutes remain.
419
+ *
420
+ * @param expiresIn - Seconds until token expires
421
+ * @returns true if token should be refreshed now
422
+ */
423
+ static shouldRefreshToken(expiresIn) {
424
+ return expiresIn < 600;
425
+ }
426
+ };
427
+
428
+ // src/providers/oidc-provider.ts
429
+ var OIDCProvider = class {
430
+ constructor(config) {
431
+ this.config = config;
432
+ }
433
+ metadata = null;
434
+ /**
435
+ * Initialize provider by discovering OIDC configuration
436
+ *
437
+ * Call this during app startup to pre-fetch OIDC metadata.
438
+ * If not called, metadata will be lazily loaded on first use.
439
+ */
440
+ async initialize() {
441
+ this.metadata = await discoverOIDC(this.config.discoveryUrl);
442
+ }
443
+ /**
444
+ * Get metadata (lazy load if not initialized)
445
+ *
446
+ * @returns OIDC metadata
447
+ */
448
+ async getMetadata() {
449
+ if (!this.metadata) {
450
+ await this.initialize();
451
+ }
452
+ return this.metadata;
453
+ }
454
+ /**
455
+ * Authenticate user by exchanging authorization code for tokens
456
+ *
457
+ * This is called after the user is redirected back from the OIDC provider
458
+ * with an authorization code in the query parameters.
459
+ *
460
+ * @param context - Auth context with query params containing authorization code
461
+ * @returns Authenticated user or null if no code present
462
+ * @throws Error if token exchange or validation fails
463
+ */
464
+ async authenticate(context) {
465
+ const code = context.query?.code;
466
+ const redirectUri = this.config.redirectUri || "/api/auth/callback";
467
+ if (!code) {
468
+ return null;
469
+ }
470
+ const metadata = await this.getMetadata();
471
+ const tokens = await exchangeCodeForTokens(
472
+ metadata,
473
+ code,
474
+ this.config.clientId,
475
+ this.config.clientSecret,
476
+ redirectUri
477
+ );
478
+ const issuer = this.config.quirks?.skipIssuerCheck ? void 0 : this.config.provider === "keycloak" ? KeycloakAdapter.normalizeIssuer(metadata.issuer) : metadata.issuer;
479
+ const claims = await validateIdToken(
480
+ tokens.id_token,
481
+ metadata.jwks_uri,
482
+ issuer || metadata.issuer,
483
+ this.config.clientId,
484
+ this.config.quirks?.skipIssuerCheck
485
+ );
486
+ const mappedClaims = this.config.provider === "keycloak" ? KeycloakAdapter.mapClaims(claims) : claims;
487
+ const user = {
488
+ id: this.getClaimValue(mappedClaims, this.config.claimsMapping?.user_id, "sub"),
489
+ email: this.getClaimValue(mappedClaims, this.config.claimsMapping?.email, "email"),
490
+ name: this.getClaimValue(mappedClaims, this.config.claimsMapping?.name, "name"),
491
+ roles: this.extractRoles(mappedClaims),
492
+ metadata: {
493
+ provider: this.config.provider,
494
+ originalClaims: mappedClaims
495
+ }
496
+ };
497
+ return user;
498
+ }
499
+ /**
500
+ * Validate session (check if token is still valid)
501
+ *
502
+ * Simple time-based validation. For more security, you could:
503
+ * - Call the userinfo endpoint
504
+ * - Verify token hasn't been revoked
505
+ * - Check against a session store
506
+ *
507
+ * @param session - Session to validate
508
+ * @returns true if session is still valid
509
+ */
510
+ async validate(session) {
511
+ if (Date.now() > session.expiresAt) {
512
+ return false;
513
+ }
514
+ return true;
515
+ }
516
+ /**
517
+ * Refresh session using refresh token
518
+ *
519
+ * When the access token expires, use the refresh token to get new tokens
520
+ * without requiring the user to re-authenticate.
521
+ *
522
+ * @param session - Session with refresh token
523
+ * @returns Updated session with new tokens, or null if refresh failed
524
+ */
525
+ async refresh(session) {
526
+ if (!session.refreshToken) {
527
+ return null;
528
+ }
529
+ const metadata = await this.getMetadata();
530
+ try {
531
+ const tokens = await refreshAccessToken(
532
+ metadata,
533
+ session.refreshToken,
534
+ this.config.clientId,
535
+ this.config.clientSecret
536
+ );
537
+ return {
538
+ ...session,
539
+ expiresAt: Date.now() + tokens.expires_in * 1e3,
540
+ refreshToken: tokens.refresh_token || session.refreshToken
541
+ };
542
+ } catch (error) {
543
+ return null;
544
+ }
545
+ }
546
+ /**
547
+ * Get authorization URL for initiating OIDC login
548
+ *
549
+ * Redirect users to this URL to start the authentication flow.
550
+ *
551
+ * @param redirectUri - Where to redirect after authentication
552
+ * @param state - CSRF protection token (recommended)
553
+ * @returns Authorization URL
554
+ */
555
+ async getAuthorizationUrl(redirectUri, state) {
556
+ const metadata = await this.getMetadata();
557
+ return buildAuthorizationUrl(metadata, this.config.clientId, redirectUri, state);
558
+ }
559
+ /**
560
+ * Extract claim value with fallback
561
+ *
562
+ * Tries custom mapped key first, then falls back to default key.
563
+ *
564
+ * @param claims - JWT claims
565
+ * @param mappedKey - Custom mapped key from config
566
+ * @param defaultKey - Default OIDC key
567
+ * @returns Claim value or undefined
568
+ */
569
+ getClaimValue(claims, mappedKey, defaultKey) {
570
+ if (mappedKey && claims[mappedKey]) {
571
+ return claims[mappedKey];
572
+ }
573
+ if (defaultKey && claims[defaultKey]) {
574
+ return claims[defaultKey];
575
+ }
576
+ return void 0;
577
+ }
578
+ /**
579
+ * Extract roles from claims (provider-specific logic)
580
+ *
581
+ * Different OIDC providers store roles in different places:
582
+ * - Standard: 'roles' claim
583
+ * - Keycloak: realm_access.roles
584
+ * - Cognito: cognito:groups
585
+ * - Azure AD: roles claim
586
+ *
587
+ * @param claims - JWT claims
588
+ * @returns Array of role strings
589
+ */
590
+ extractRoles(claims) {
591
+ if (this.config.claimsMapping?.roles) {
592
+ const rolesValue = claims[this.config.claimsMapping.roles];
593
+ if (Array.isArray(rolesValue)) {
594
+ return rolesValue;
595
+ }
596
+ if (typeof rolesValue === "string") {
597
+ return [rolesValue];
598
+ }
599
+ }
600
+ if (Array.isArray(claims.roles)) {
601
+ return claims.roles;
602
+ }
603
+ if (claims.realm_access?.roles) {
604
+ return claims.realm_access.roles;
605
+ }
606
+ if (claims["cognito:groups"]) {
607
+ return claims["cognito:groups"];
608
+ }
609
+ return [];
610
+ }
611
+ };
612
+ var SessionManager = class {
613
+ secret;
614
+ sessionDuration;
615
+ algorithm;
616
+ issuer;
617
+ audience;
618
+ constructor(config) {
619
+ if (config.secret.length < 32) {
620
+ throw new Error("Session secret must be at least 32 characters");
621
+ }
622
+ this.secret = new TextEncoder().encode(config.secret);
623
+ this.sessionDuration = config.sessionDuration || 900;
624
+ this.algorithm = config.algorithm || "HS256";
625
+ this.issuer = config.issuer;
626
+ this.audience = config.audience;
627
+ }
628
+ /**
629
+ * Create a new session for authenticated user
630
+ */
631
+ async createSession(user, refreshToken) {
632
+ const now = Date.now();
633
+ const expiresAt = now + this.sessionDuration * 1e3;
634
+ const session = {
635
+ user,
636
+ issuedAt: now,
637
+ expiresAt,
638
+ refreshToken
639
+ };
640
+ return session;
641
+ }
642
+ /**
643
+ * Sign session into a JWT
644
+ */
645
+ async signSession(session) {
646
+ const jwt = await new jose__namespace.SignJWT({
647
+ user: session.user,
648
+ refreshToken: session.refreshToken
649
+ }).setProtectedHeader({ alg: this.algorithm }).setIssuedAt(session.issuedAt / 1e3).setExpirationTime(session.expiresAt / 1e3).setIssuer(this.issuer || "stackwright-auth").setAudience(this.audience || "stackwright-app").sign(this.secret);
650
+ return jwt;
651
+ }
652
+ /**
653
+ * Verify and decode session JWT
654
+ */
655
+ async verifySession(jwt) {
656
+ try {
657
+ const { payload } = await jose__namespace.jwtVerify(jwt, this.secret, {
658
+ issuer: this.issuer || "stackwright-auth",
659
+ audience: this.audience || "stackwright-app"
660
+ });
661
+ const session = {
662
+ user: payload.user,
663
+ issuedAt: (payload.iat || 0) * 1e3,
664
+ // Convert back to milliseconds
665
+ expiresAt: (payload.exp || 0) * 1e3,
666
+ refreshToken: payload.refreshToken
667
+ };
668
+ return session;
669
+ } catch (error) {
670
+ return null;
671
+ }
672
+ }
673
+ /**
674
+ * Check if session is expired
675
+ */
676
+ isExpired(session) {
677
+ return Date.now() > session.expiresAt;
678
+ }
679
+ /**
680
+ * Check if session should be refreshed (within 5 minutes of expiry)
681
+ */
682
+ shouldRefresh(session) {
683
+ const timeUntilExpiry = session.expiresAt - Date.now();
684
+ const REFRESH_THRESHOLD = 5 * 60 * 1e3;
685
+ return timeUntilExpiry < REFRESH_THRESHOLD && timeUntilExpiry > 0;
686
+ }
687
+ /**
688
+ * Refresh session (extend expiration)
689
+ */
690
+ async refreshSession(session) {
691
+ return {
692
+ ...session,
693
+ issuedAt: Date.now(),
694
+ expiresAt: Date.now() + this.sessionDuration * 1e3
695
+ };
696
+ }
697
+ /**
698
+ * Serialize session to string (for cookies/localStorage)
699
+ */
700
+ async serialize(session) {
701
+ return this.signSession(session);
702
+ }
703
+ /**
704
+ * Deserialize session from string
705
+ */
706
+ async deserialize(serialized) {
707
+ return this.verifySession(serialized);
708
+ }
709
+ };
710
+
711
+ // src/session/cookie-helpers.ts
712
+ function serializeCookie(name, value, options = {}) {
713
+ const {
714
+ domain,
715
+ path = "/",
716
+ maxAge,
717
+ httpOnly = true,
718
+ secure = process.env.NODE_ENV === "production",
719
+ sameSite = "lax"
720
+ } = options;
721
+ const parts = [
722
+ `${name}=${encodeURIComponent(value)}`
723
+ ];
724
+ if (domain) {
725
+ parts.push(`Domain=${domain}`);
726
+ }
727
+ parts.push(`Path=${path}`);
728
+ if (maxAge !== void 0) {
729
+ parts.push(`Max-Age=${maxAge}`);
730
+ }
731
+ if (httpOnly) {
732
+ parts.push("HttpOnly");
733
+ }
734
+ if (secure) {
735
+ parts.push("Secure");
736
+ }
737
+ if (sameSite) {
738
+ parts.push(`SameSite=${sameSite.charAt(0).toUpperCase() + sameSite.slice(1)}`);
739
+ }
740
+ return parts.join("; ");
741
+ }
742
+ function parseCookies(cookieHeader) {
743
+ if (!cookieHeader) {
744
+ return {};
745
+ }
746
+ const cookies = {};
747
+ for (const cookie of cookieHeader.split(";")) {
748
+ const [key, ...valueParts] = cookie.trim().split("=");
749
+ if (key) {
750
+ cookies[key] = decodeURIComponent(valueParts.join("="));
751
+ }
752
+ }
753
+ return cookies;
754
+ }
755
+ function clearCookie(name, options = {}) {
756
+ return serializeCookie(name, "", {
757
+ ...options,
758
+ maxAge: 0
759
+ });
760
+ }
761
+
762
+ // src/rbac/rbac-engine.ts
763
+ var RBACEngine = class {
764
+ config;
765
+ rolePermissions;
766
+ constructor(config) {
767
+ this.config = config;
768
+ this.rolePermissions = this.buildRolePermissionMap();
769
+ }
770
+ /**
771
+ * Build internal map of role → permissions for fast lookups
772
+ */
773
+ buildRolePermissionMap() {
774
+ const map = /* @__PURE__ */ new Map();
775
+ for (const role of this.config.roles) {
776
+ map.set(role.name, new Set(role.permissions || []));
777
+ }
778
+ return map;
779
+ }
780
+ /**
781
+ * Check if user has a specific role
782
+ */
783
+ hasRole(user, role) {
784
+ return user.roles.includes(role);
785
+ }
786
+ /**
787
+ * Check if user has any of the specified roles
788
+ */
789
+ hasAnyRole(user, roles) {
790
+ return roles.some((role) => this.hasRole(user, role));
791
+ }
792
+ /**
793
+ * Check if user has all of the specified roles
794
+ */
795
+ hasAllRoles(user, roles) {
796
+ return roles.every((role) => this.hasRole(user, role));
797
+ }
798
+ /**
799
+ * Check if user has a specific permission
800
+ * Permissions are checked both:
801
+ * 1. Directly in user.permissions array
802
+ * 2. Through role-based permissions from config
803
+ */
804
+ hasPermission(user, permission) {
805
+ if (user.permissions?.includes(permission)) {
806
+ return true;
807
+ }
808
+ for (const role of user.roles) {
809
+ const permissions = this.rolePermissions.get(role);
810
+ if (permissions?.has(permission)) {
811
+ return true;
812
+ }
813
+ if (permissions) {
814
+ for (const p of permissions) {
815
+ if (p.endsWith(":*")) {
816
+ const prefix = p.slice(0, -1);
817
+ if (permission.startsWith(prefix)) {
818
+ return true;
819
+ }
820
+ }
821
+ }
822
+ }
823
+ }
824
+ return false;
825
+ }
826
+ /**
827
+ * Check if user has any of the specified permissions
828
+ */
829
+ hasAnyPermission(user, permissions) {
830
+ return permissions.some((permission) => this.hasPermission(user, permission));
831
+ }
832
+ /**
833
+ * Check if user has all of the specified permissions
834
+ */
835
+ hasAllPermissions(user, permissions) {
836
+ return permissions.every((permission) => this.hasPermission(user, permission));
837
+ }
838
+ /**
839
+ * Check if route is public (no auth required)
840
+ */
841
+ isPublicRoute(path) {
842
+ if (!this.config.public_routes) {
843
+ return false;
844
+ }
845
+ return this.config.public_routes.some((publicPath) => {
846
+ return this.matchPath(path, publicPath);
847
+ });
848
+ }
849
+ /**
850
+ * Check if user can access a route based on protected_routes config
851
+ */
852
+ canAccessRoute(user, path) {
853
+ if (this.isPublicRoute(path)) {
854
+ return true;
855
+ }
856
+ if (!user) {
857
+ return false;
858
+ }
859
+ if (!this.config.protected_routes || this.config.protected_routes.length === 0) {
860
+ return true;
861
+ }
862
+ const matchingRoute = this.config.protected_routes.find((route) => {
863
+ return this.matchPath(path, route.path);
864
+ });
865
+ if (!matchingRoute) {
866
+ return true;
867
+ }
868
+ return this.hasAnyRole(user, matchingRoute.roles);
869
+ }
870
+ /**
871
+ * Check if user can access a component based on component auth config
872
+ */
873
+ canAccessComponent(user, authConfig) {
874
+ if (!authConfig) {
875
+ return true;
876
+ }
877
+ if (!user) {
878
+ return false;
879
+ }
880
+ if (authConfig.required_roles && authConfig.required_roles.length > 0) {
881
+ if (!this.hasAnyRole(user, authConfig.required_roles)) {
882
+ return false;
883
+ }
884
+ }
885
+ if (authConfig.required_permissions && authConfig.required_permissions.length > 0) {
886
+ if (!this.hasAllPermissions(user, authConfig.required_permissions)) {
887
+ return false;
888
+ }
889
+ }
890
+ return true;
891
+ }
892
+ // Match a path against a pattern with wildcard support
893
+ matchPath(path, pattern) {
894
+ if (path === pattern) {
895
+ return true;
896
+ }
897
+ if (!pattern.includes("*")) {
898
+ return false;
899
+ }
900
+ const regexPattern = pattern.replace(/\*/g, ".*").replace(/\//g, "\\/");
901
+ const regex = new RegExp(`^${regexPattern}$`);
902
+ return regex.test(path);
903
+ }
904
+ };
905
+ var AuthContext = react.createContext(null);
906
+ function useAuth() {
907
+ const context = react.useContext(AuthContext);
908
+ if (!context) {
909
+ throw new Error("useAuth must be used within AuthProvider");
910
+ }
911
+ return context;
912
+ }
913
+ function useRequireAuth() {
914
+ const auth = useAuth();
915
+ if (!auth.isAuthenticated) {
916
+ console.warn("useRequireAuth: User is not authenticated");
917
+ return null;
918
+ }
919
+ return auth;
920
+ }
921
+ function AuthProvider({
922
+ user,
923
+ session,
924
+ rbacConfig,
925
+ isLoading = false,
926
+ children
927
+ }) {
928
+ const rbac = react.useMemo(() => new RBACEngine(rbacConfig), [rbacConfig]);
929
+ const value = react.useMemo(() => ({
930
+ user,
931
+ session,
932
+ isAuthenticated: user !== null,
933
+ isLoading,
934
+ hasRole: (role) => {
935
+ if (!user) return false;
936
+ return rbac.hasRole(user, role);
937
+ },
938
+ hasPermission: (permission) => {
939
+ if (!user) return false;
940
+ return rbac.hasPermission(user, permission);
941
+ },
942
+ hasAnyRole: (roles) => {
943
+ if (!user) return false;
944
+ return rbac.hasAnyRole(user, roles);
945
+ },
946
+ hasAllPermissions: (permissions) => {
947
+ if (!user) return false;
948
+ return rbac.hasAllPermissions(user, permissions);
949
+ }
950
+ }), [user, session, isLoading, rbac]);
951
+ return /* @__PURE__ */ jsxRuntime.jsx(AuthContext.Provider, { value, children });
952
+ }
953
+
954
+ // src/profiles/dod-cac.ts
955
+ var DOD_CAC_PROFILE = {
956
+ profile: "dod_cac",
957
+ source: "gateway_headers",
958
+ headerPrefix: "x-client-cert-",
959
+ verifiedHeader: "x-client-cert-verified",
960
+ requiredValue: "SUCCESS",
961
+ requiredOU: ["DOD", "DoD", "U.S. Government"],
962
+ // DoD Root CAs (add current list)
963
+ // These are sample CA names - verify against current DoD PKI infrastructure
964
+ allowedIssuers: [
965
+ // DoD Interoperability Root CA 2
966
+ "CN=DoD Interoperability Root CA 2",
967
+ // DoD Root CA 3-6 (current generation)
968
+ "CN=DoD Root CA 3",
969
+ "CN=DoD Root CA 4",
970
+ "CN=DoD Root CA 5",
971
+ "CN=DoD Root CA 6",
972
+ // DoD ID CAs (email/PIV auth)
973
+ "CN=DOD ID CA-59",
974
+ "CN=DOD ID CA-60",
975
+ "CN=DOD ID CA-61",
976
+ "CN=DOD ID CA-62",
977
+ "CN=DOD ID CA-63",
978
+ "CN=DOD ID CA-64",
979
+ "CN=DOD ID CA-65",
980
+ "CN=DOD ID CA-66",
981
+ "CN=DOD ID CA-67",
982
+ "CN=DOD ID CA-68",
983
+ "CN=DOD ID CA-69",
984
+ "CN=DOD ID CA-70",
985
+ // DoD SW CAs (software/device auth)
986
+ "CN=DOD SW CA-59",
987
+ "CN=DOD SW CA-60",
988
+ "CN=DOD SW CA-61",
989
+ "CN=DOD SW CA-62",
990
+ "CN=DOD SW CA-63",
991
+ "CN=DOD SW CA-64",
992
+ "CN=DOD SW CA-65",
993
+ "CN=DOD SW CA-66",
994
+ // Legacy DoD Email CAs (being phased out but may still be in use)
995
+ "CN=DOD EMAIL CA-59",
996
+ "CN=DOD EMAIL CA-60",
997
+ "CN=DOD EMAIL CA-61",
998
+ "CN=DOD EMAIL CA-62",
999
+ "CN=DOD EMAIL CA-63",
1000
+ "CN=DOD EMAIL CA-64",
1001
+ "CN=DOD EMAIL CA-65",
1002
+ "CN=DOD EMAIL CA-66"
1003
+ ]
1004
+ };
1005
+ function createDoDCACConfig(overrides) {
1006
+ return {
1007
+ type: "pki",
1008
+ ...DOD_CAC_PROFILE,
1009
+ ...overrides
1010
+ };
1011
+ }
1012
+ function createDoDCACDevConfig() {
1013
+ return {
1014
+ type: "pki",
1015
+ profile: "dod_cac",
1016
+ source: "gateway_headers",
1017
+ headerPrefix: "x-client-cert-",
1018
+ verifiedHeader: "x-client-cert-verified",
1019
+ requiredValue: "SUCCESS",
1020
+ // Only require 'DOD' in OU for dev
1021
+ requiredOU: ["DOD"],
1022
+ // Don't restrict issuers in dev mode
1023
+ allowedIssuers: void 0
1024
+ };
1025
+ }
1026
+ var FallbackComponents = {
1027
+ /**
1028
+ * Hide component (render nothing)
1029
+ */
1030
+ hide: () => null,
1031
+ /**
1032
+ * Show placeholder message
1033
+ */
1034
+ placeholder: ({ className }) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: className || "auth-placeholder", style: {
1035
+ padding: "1rem",
1036
+ border: "1px dashed #ccc",
1037
+ borderRadius: "4px",
1038
+ color: "#666",
1039
+ fontStyle: "italic",
1040
+ textAlign: "center"
1041
+ }, children: "Content requires authorization" }),
1042
+ /**
1043
+ * Show custom message
1044
+ */
1045
+ message: ({ message, className }) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: className || "auth-message", style: {
1046
+ padding: "1rem",
1047
+ border: "1px solid #f0ad4e",
1048
+ borderRadius: "4px",
1049
+ backgroundColor: "#fcf8e3",
1050
+ color: "#8a6d3b"
1051
+ }, children: message || "Unauthorized" })
1052
+ };
1053
+ function withAuth(Component, authConfig) {
1054
+ if (!authConfig) {
1055
+ return Component;
1056
+ }
1057
+ const WrappedComponent = (props) => {
1058
+ const auth = useAuth();
1059
+ if (authConfig.required_roles && authConfig.required_roles.length > 0) {
1060
+ if (!auth.hasAnyRole(authConfig.required_roles)) {
1061
+ return renderFallback(authConfig);
1062
+ }
1063
+ }
1064
+ if (authConfig.required_permissions && authConfig.required_permissions.length > 0) {
1065
+ if (!auth.hasAllPermissions(authConfig.required_permissions)) {
1066
+ return renderFallback(authConfig);
1067
+ }
1068
+ }
1069
+ return /* @__PURE__ */ jsxRuntime.jsx(Component, { ...props });
1070
+ };
1071
+ const componentName = Component.displayName || Component.name || "Component";
1072
+ WrappedComponent.displayName = `withAuth(${componentName})`;
1073
+ return WrappedComponent;
1074
+ }
1075
+ function renderFallback(authConfig) {
1076
+ const fallbackType = authConfig.fallback || "hide";
1077
+ switch (fallbackType) {
1078
+ case "hide":
1079
+ return FallbackComponents.hide();
1080
+ case "placeholder":
1081
+ return FallbackComponents.placeholder({});
1082
+ case "message":
1083
+ return FallbackComponents.message({
1084
+ message: authConfig.fallback_message
1085
+ });
1086
+ default:
1087
+ return null;
1088
+ }
1089
+ }
1090
+ function withAuthFallback(Component, authConfig, FallbackComponent) {
1091
+ const WrappedComponent = (props) => {
1092
+ const auth = useAuth();
1093
+ const isAuthorized = checkAuthorization(auth, authConfig);
1094
+ if (!isAuthorized) {
1095
+ return /* @__PURE__ */ jsxRuntime.jsx(FallbackComponent, {});
1096
+ }
1097
+ return /* @__PURE__ */ jsxRuntime.jsx(Component, { ...props });
1098
+ };
1099
+ const componentName = Component.displayName || Component.name || "Component";
1100
+ WrappedComponent.displayName = `withAuthFallback(${componentName})`;
1101
+ return WrappedComponent;
1102
+ }
1103
+ function checkAuthorization(auth, authConfig) {
1104
+ if (authConfig.required_roles && authConfig.required_roles.length > 0) {
1105
+ if (!auth.hasAnyRole(authConfig.required_roles)) {
1106
+ return false;
1107
+ }
1108
+ }
1109
+ if (authConfig.required_permissions && authConfig.required_permissions.length > 0) {
1110
+ if (!auth.hasAllPermissions(authConfig.required_permissions)) {
1111
+ return false;
1112
+ }
1113
+ }
1114
+ return true;
1115
+ }
1116
+
1117
+ // src/registration.ts
1118
+ var authDecoratorRegistry = {
1119
+ decorator: null
1120
+ };
1121
+ function registerAuthDecorator() {
1122
+ authDecoratorRegistry.decorator = withAuth;
1123
+ if (typeof window !== "undefined" && window.__STACKWRIGHT_DEBUG__) {
1124
+ console.log("\u{1F510} Auth decorator registered");
1125
+ }
1126
+ }
1127
+ function getAuthDecorator() {
1128
+ return authDecoratorRegistry.decorator;
1129
+ }
1130
+ function maybeWrapWithAuth(Component, authConfig) {
1131
+ const decorator = getAuthDecorator();
1132
+ if (!decorator || !authConfig) {
1133
+ return Component;
1134
+ }
1135
+ return decorator(Component, authConfig);
1136
+ }
1137
+ function hasAuthConfig(item) {
1138
+ if (!item || typeof item !== "object") {
1139
+ return false;
1140
+ }
1141
+ return "auth" in item;
1142
+ }
1143
+
1144
+ exports.AuthContext = AuthContext;
1145
+ exports.AuthProvider = AuthProvider;
1146
+ exports.DOD_CAC_PROFILE = DOD_CAC_PROFILE;
1147
+ exports.KeycloakAdapter = KeycloakAdapter;
1148
+ exports.OIDCProvider = OIDCProvider;
1149
+ exports.PKIProvider = PKIProvider;
1150
+ exports.RBACEngine = RBACEngine;
1151
+ exports.SessionManager = SessionManager;
1152
+ exports.authConfigSchema = authConfigSchema;
1153
+ exports.authSessionSchema = authSessionSchema;
1154
+ exports.authUserSchema = authUserSchema;
1155
+ exports.buildAuthorizationUrl = buildAuthorizationUrl;
1156
+ exports.clearCookie = clearCookie;
1157
+ exports.componentAuthSchema = componentAuthSchema;
1158
+ exports.createDoDCACConfig = createDoDCACConfig;
1159
+ exports.createDoDCACDevConfig = createDoDCACDevConfig;
1160
+ exports.discoverOIDC = discoverOIDC;
1161
+ exports.exchangeCodeForTokens = exchangeCodeForTokens;
1162
+ exports.extractEDIPI = extractEDIPI;
1163
+ exports.getAuthDecorator = getAuthDecorator;
1164
+ exports.hasAuthConfig = hasAuthConfig;
1165
+ exports.maybeWrapWithAuth = maybeWrapWithAuth;
1166
+ exports.oidcConfigSchema = oidcConfigSchema;
1167
+ exports.parseCertFromHeaders = parseCertFromHeaders;
1168
+ exports.parseCertificate = parseCertificate;
1169
+ exports.parseCookies = parseCookies;
1170
+ exports.pkiConfigSchema = pkiConfigSchema;
1171
+ exports.rbacConfigSchema = rbacConfigSchema;
1172
+ exports.refreshAccessToken = refreshAccessToken;
1173
+ exports.registerAuthDecorator = registerAuthDecorator;
1174
+ exports.serializeCookie = serializeCookie;
1175
+ exports.useAuth = useAuth;
1176
+ exports.useRequireAuth = useRequireAuth;
1177
+ exports.validateDoDCAC = validateDoDCAC;
1178
+ exports.validateIdToken = validateIdToken;
1179
+ exports.withAuth = withAuth;
1180
+ exports.withAuthFallback = withAuthFallback;
1181
+ //# sourceMappingURL=index.js.map
1182
+ //# sourceMappingURL=index.js.map