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