@stackwright-pro/auth 0.2.0-alpha.4 → 0.2.0-alpha.5

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 CHANGED
@@ -210,6 +210,8 @@ var AuditEventType = /* @__PURE__ */ ((AuditEventType2) => {
210
210
  AuditEventType2["PKI_CERT_VALIDATED"] = "pki.cert.validated";
211
211
  AuditEventType2["PKI_CERT_REJECTED"] = "pki.cert.rejected";
212
212
  AuditEventType2["PKI_HEADER_SIG_FAILED"] = "pki.header_sig.failed";
213
+ AuditEventType2["PKI_CERT_REVOKED"] = "pki.cert.revoked";
214
+ AuditEventType2["PKI_REVOCATION_CHECK_FAILED"] = "pki.revocation_check.failed";
213
215
  AuditEventType2["OIDC_STATE_MISMATCH"] = "oidc.state_mismatch";
214
216
  AuditEventType2["OIDC_TOKEN_EXCHANGE"] = "oidc.token_exchange";
215
217
  AuditEventType2["OIDC_NONCE_MISMATCH"] = "oidc.nonce_mismatch";
@@ -242,13 +244,380 @@ function createAuditEvent(type, outcome, details) {
242
244
  };
243
245
  }
244
246
 
247
+ // src/pki/revocation-checker.ts
248
+ var RevocationCache = class {
249
+ store = /* @__PURE__ */ new Map();
250
+ maxAgeMs;
251
+ constructor(cacheMaxAgeSecs = 300) {
252
+ this.maxAgeMs = cacheMaxAgeSecs * 1e3;
253
+ }
254
+ get(serialNumber) {
255
+ const entry = this.store.get(serialNumber);
256
+ if (!entry) return null;
257
+ if (Date.now() > entry.expiresAt) {
258
+ this.store.delete(serialNumber);
259
+ return null;
260
+ }
261
+ return entry.status;
262
+ }
263
+ set(serialNumber, status) {
264
+ this.store.set(serialNumber, {
265
+ status,
266
+ expiresAt: Date.now() + this.maxAgeMs
267
+ });
268
+ }
269
+ /** Invalidate a single entry (e.g. after a forced re-check) */
270
+ invalidate(serialNumber) {
271
+ this.store.delete(serialNumber);
272
+ }
273
+ /** Number of cached entries (for testing/monitoring) */
274
+ get size() {
275
+ return this.store.size;
276
+ }
277
+ };
278
+ var SkipRevocationChecker = class {
279
+ reason;
280
+ constructor(reason = "Revocation checking delegated to upstream gateway") {
281
+ this.reason = reason;
282
+ }
283
+ async check(_input) {
284
+ return { revoked: false, skipped: true, reason: this.reason };
285
+ }
286
+ };
287
+ var CRLRevocationChecker = class {
288
+ cache;
289
+ config;
290
+ constructor(config) {
291
+ this.config = config;
292
+ this.cache = new RevocationCache(config.cacheMaxAge ?? 300);
293
+ }
294
+ async check(input) {
295
+ const { serialNumber } = input;
296
+ const crlUrl = this.config.crlDistributionPoint;
297
+ if (!crlUrl) {
298
+ if (this.config.hardFail) {
299
+ throw new Error("[revocation] CRL strategy requires crlDistributionPoint to be configured");
300
+ }
301
+ return {
302
+ revoked: false,
303
+ skipped: true,
304
+ reason: "CRL distribution point not configured"
305
+ };
306
+ }
307
+ const cached = this.cache.get(serialNumber);
308
+ if (cached !== null) return cached;
309
+ let derBuffer;
310
+ try {
311
+ const controller = new AbortController();
312
+ const timer = setTimeout(
313
+ () => controller.abort(),
314
+ this.config.timeoutMs ?? 5e3
315
+ );
316
+ const response = await fetch(crlUrl, {
317
+ signal: controller.signal,
318
+ headers: { Accept: "application/pkix-crl" }
319
+ });
320
+ clearTimeout(timer);
321
+ if (!response.ok) {
322
+ throw new Error(`CRL fetch returned HTTP ${response.status}`);
323
+ }
324
+ derBuffer = await response.arrayBuffer();
325
+ } catch (err) {
326
+ if (this.config.hardFail) {
327
+ throw new Error(`[revocation] CRL fetch failed (hardFail=true): ${String(err)}`);
328
+ }
329
+ return { revoked: false, skipped: true, reason: `CRL fetch failed: ${String(err)}` };
330
+ }
331
+ let revokedSerials;
332
+ try {
333
+ revokedSerials = parseCRLRevokedSerials(Buffer.from(derBuffer));
334
+ } catch (err) {
335
+ if (this.config.hardFail) {
336
+ throw new Error(`[revocation] CRL parse failed (hardFail=true): ${String(err)}`);
337
+ }
338
+ return { revoked: false, skipped: true, reason: `CRL parse failed: ${String(err)}` };
339
+ }
340
+ const normalizedSerial = normalizeSerial(serialNumber);
341
+ const isRevoked = revokedSerials.has(normalizedSerial);
342
+ const status = isRevoked ? { revoked: true, reason: "Certificate found in CRL" } : { revoked: false };
343
+ this.cache.set(serialNumber, status);
344
+ return status;
345
+ }
346
+ /** Exposed for testing */
347
+ get _cache() {
348
+ return this.cache;
349
+ }
350
+ };
351
+ var OCSPRevocationChecker = class {
352
+ cache;
353
+ config;
354
+ constructor(config) {
355
+ this.config = config;
356
+ this.cache = new RevocationCache(config.cacheMaxAge ?? 300);
357
+ }
358
+ async check(input) {
359
+ const { serialNumber } = input;
360
+ const ocspUrl = this.config.ocspResponderUrl;
361
+ if (!ocspUrl) {
362
+ if (this.config.hardFail) {
363
+ throw new Error("[revocation] OCSP strategy requires ocspResponderUrl to be configured");
364
+ }
365
+ return {
366
+ revoked: false,
367
+ skipped: true,
368
+ reason: "OCSP responder URL not configured"
369
+ };
370
+ }
371
+ const cached = this.cache.get(serialNumber);
372
+ if (cached !== null) return cached;
373
+ let response;
374
+ try {
375
+ const ocspRequest = buildOCSPGetRequest(ocspUrl, serialNumber);
376
+ const controller = new AbortController();
377
+ const timer = setTimeout(
378
+ () => controller.abort(),
379
+ this.config.timeoutMs ?? 5e3
380
+ );
381
+ response = await fetch(ocspRequest, {
382
+ signal: controller.signal,
383
+ headers: { Accept: "application/ocsp-response" }
384
+ });
385
+ clearTimeout(timer);
386
+ } catch (err) {
387
+ if (this.config.hardFail) {
388
+ throw new Error(`[revocation] OCSP request failed (hardFail=true): ${String(err)}`);
389
+ }
390
+ return { revoked: false, skipped: true, reason: `OCSP request failed: ${String(err)}` };
391
+ }
392
+ if (!response.ok) {
393
+ if (this.config.hardFail) {
394
+ throw new Error(
395
+ `[revocation] OCSP responder returned HTTP ${response.status} (hardFail=true)`
396
+ );
397
+ }
398
+ const skippedStatus = {
399
+ revoked: false,
400
+ skipped: true,
401
+ reason: `OCSP responder returned HTTP ${response.status}`
402
+ };
403
+ this.cache.set(serialNumber, skippedStatus);
404
+ return skippedStatus;
405
+ }
406
+ let status;
407
+ try {
408
+ const derBuffer = await response.arrayBuffer();
409
+ status = parseOCSPResponse(Buffer.from(derBuffer), serialNumber);
410
+ } catch (err) {
411
+ if (this.config.hardFail) {
412
+ throw new Error(`[revocation] OCSP response parse failed (hardFail=true): ${String(err)}`);
413
+ }
414
+ return { revoked: false, skipped: true, reason: `OCSP parse failed: ${String(err)}` };
415
+ }
416
+ this.cache.set(serialNumber, status);
417
+ return status;
418
+ }
419
+ /** Exposed for testing */
420
+ get _cache() {
421
+ return this.cache;
422
+ }
423
+ };
424
+ var CompositeRevocationChecker = class {
425
+ ocsp;
426
+ crl;
427
+ constructor(config) {
428
+ this.ocsp = new OCSPRevocationChecker({ ...config, hardFail: false });
429
+ this.crl = new CRLRevocationChecker(config);
430
+ }
431
+ async check(input) {
432
+ const ocspResult = await this.ocsp.check(input);
433
+ if (!("skipped" in ocspResult && ocspResult.skipped)) {
434
+ return ocspResult;
435
+ }
436
+ return this.crl.check(input);
437
+ }
438
+ };
439
+ function createRevocationChecker(config) {
440
+ switch (config.strategy) {
441
+ case "ocsp":
442
+ return new OCSPRevocationChecker(config);
443
+ case "crl":
444
+ return new CRLRevocationChecker(config);
445
+ case "ocsp_with_crl_fallback":
446
+ return new CompositeRevocationChecker(config);
447
+ case "skip":
448
+ default:
449
+ return new SkipRevocationChecker();
450
+ }
451
+ }
452
+ function normalizeSerial(serial) {
453
+ const clean = serial.replace(/[:\s]/g, "").replace(/^0x/i, "").toUpperCase();
454
+ return clean.replace(/^0+(?=.)/, "") || "0";
455
+ }
456
+ function parseCRLRevokedSerials(der) {
457
+ const serials = /* @__PURE__ */ new Set();
458
+ const tbsCertList = unwrapSequence(unwrapSequence(der));
459
+ let offset = 0;
460
+ let revokedSeqOffset = -1;
461
+ while (offset < tbsCertList.length - 2) {
462
+ const tag = tbsCertList[offset];
463
+ if (tag === void 0) break;
464
+ const { length: len, headerLen } = readTLVLength(tbsCertList, offset + 1);
465
+ if (tag === 48 && len > 0) {
466
+ const inner = tbsCertList.slice(offset + headerLen, offset + headerLen + len);
467
+ if (inner.length > 0 && inner[0] === 48) {
468
+ revokedSeqOffset = offset + headerLen;
469
+ break;
470
+ }
471
+ }
472
+ offset += headerLen + len;
473
+ }
474
+ if (revokedSeqOffset === -1) {
475
+ return serials;
476
+ }
477
+ const revokedBlock = tbsCertList.slice(revokedSeqOffset);
478
+ let pos = 0;
479
+ while (pos < revokedBlock.length - 2) {
480
+ const tag = revokedBlock[pos];
481
+ if (tag !== 48) break;
482
+ const { length: entryLen, headerLen: entryHdrLen } = readTLVLength(revokedBlock, pos + 1);
483
+ const entry = revokedBlock.slice(pos + entryHdrLen, pos + entryHdrLen + entryLen);
484
+ if (entry.length > 0 && entry[0] === 2) {
485
+ const { length: intLen, headerLen: intHdrLen } = readTLVLength(entry, 1);
486
+ const serialBytes = entry.slice(intHdrLen, intHdrLen + intLen);
487
+ const trimmed = serialBytes[0] === 0 && serialBytes.length > 1 ? serialBytes.slice(1) : serialBytes;
488
+ const hexSerial = trimmed.toString("hex").toUpperCase().replace(/^0+(?=.)/, "");
489
+ serials.add(hexSerial || "0");
490
+ }
491
+ pos += entryHdrLen + entryLen;
492
+ }
493
+ return serials;
494
+ }
495
+ function unwrapSequence(der) {
496
+ if (der[0] !== 48) {
497
+ throw new Error(`Expected SEQUENCE (0x30), got 0x${der[0]?.toString(16)}`);
498
+ }
499
+ const { length, headerLen } = readTLVLength(der, 1);
500
+ return der.slice(headerLen, headerLen + length);
501
+ }
502
+ function readTLVLength(buf, offset) {
503
+ const firstByte = buf[offset];
504
+ if (firstByte === void 0) throw new Error("Unexpected end of DER data");
505
+ if ((firstByte & 128) === 0) {
506
+ return { length: firstByte, headerLen: 2 };
507
+ }
508
+ const numLenBytes = firstByte & 127;
509
+ let length = 0;
510
+ for (let i = 1; i <= numLenBytes; i++) {
511
+ const b = buf[offset + i];
512
+ if (b === void 0) throw new Error("Unexpected end of DER length encoding");
513
+ length = length << 8 | b;
514
+ }
515
+ return { length, headerLen: 2 + numLenBytes };
516
+ }
517
+ function buildOCSPGetRequest(baseUrl, serialNumber) {
518
+ const serialHex = normalizeSerial(serialNumber);
519
+ const serialBuf = Buffer.from(serialHex.length % 2 === 0 ? serialHex : "0" + serialHex, "hex");
520
+ const serialDer = (serialBuf[0] & 128) !== 0 ? Buffer.concat([Buffer.from([0]), serialBuf]) : serialBuf;
521
+ const sha1Oid = Buffer.from([
522
+ 48,
523
+ 9,
524
+ // SEQUENCE, 9 bytes
525
+ 6,
526
+ 5,
527
+ 43,
528
+ 14,
529
+ 3,
530
+ 2,
531
+ 26,
532
+ // OID 1.3.14.3.2.26 (SHA-1)
533
+ 5,
534
+ 0
535
+ // NULL
536
+ ]);
537
+ const zeroHash = Buffer.alloc(20, 0);
538
+ const issuerNameHashDer = derOctetString(zeroHash);
539
+ const issuerKeyHashDer = derOctetString(zeroHash);
540
+ const serialDerTagged = derInteger(serialDer);
541
+ const certId = derSequence(
542
+ Buffer.concat([sha1Oid, issuerNameHashDer, issuerKeyHashDer, serialDerTagged])
543
+ );
544
+ const requestItem = derSequence(certId);
545
+ const requestList = derSequence(requestItem);
546
+ const tbsRequest = derSequence(requestList);
547
+ const ocspRequest = derSequence(tbsRequest);
548
+ const b64 = ocspRequest.toString("base64url");
549
+ const separator = baseUrl.endsWith("/") ? "" : "/";
550
+ return `${baseUrl}${separator}${b64}`;
551
+ }
552
+ function parseOCSPResponse(der, _serialNumber) {
553
+ const ocspResponse = unwrapSequence(der);
554
+ if (ocspResponse[0] !== 10) {
555
+ throw new Error("Expected ENUMERATED responseStatus in OCSPResponse");
556
+ }
557
+ const statusLen = ocspResponse[1] ?? 0;
558
+ const responseStatus = ocspResponse[2 + statusLen - 1] ?? 0;
559
+ if (responseStatus !== 0) {
560
+ const statusNames = {
561
+ 1: "malformedRequest",
562
+ 2: "internalError",
563
+ 3: "tryLater",
564
+ 5: "sigRequired",
565
+ 6: "unauthorized"
566
+ };
567
+ throw new Error(
568
+ `OCSP responder returned non-success status: ${statusNames[responseStatus] ?? responseStatus}`
569
+ );
570
+ }
571
+ const derStr = ocspResponse.toString("hex");
572
+ if (derStr.includes("8000")) {
573
+ return { revoked: false };
574
+ }
575
+ if (derStr.includes("a1")) {
576
+ const revokedIdx = ocspResponse.indexOf(161);
577
+ if (revokedIdx !== -1) {
578
+ return {
579
+ revoked: true,
580
+ reason: "Certificate revoked per OCSP responder"
581
+ };
582
+ }
583
+ }
584
+ return { revoked: false };
585
+ }
586
+ function derTLV(tag, value) {
587
+ const lenBytes = encodeDERLength(value.length);
588
+ return Buffer.concat([Buffer.from([tag]), lenBytes, value]);
589
+ }
590
+ function derSequence(content) {
591
+ return derTLV(48, content);
592
+ }
593
+ function derOctetString(value) {
594
+ return derTLV(4, value);
595
+ }
596
+ function derInteger(value) {
597
+ return derTLV(2, value);
598
+ }
599
+ function encodeDERLength(len) {
600
+ if (len < 128) {
601
+ return Buffer.from([len]);
602
+ } else if (len < 256) {
603
+ return Buffer.from([129, len]);
604
+ } else if (len < 65536) {
605
+ return Buffer.from([130, len >> 8 & 255, len & 255]);
606
+ }
607
+ throw new Error(`DER length ${len} too large for this implementation`);
608
+ }
609
+
245
610
  // src/providers/pki-provider.ts
246
611
  var PKIProvider = class {
247
612
  constructor(config, auditLogger) {
248
613
  this.config = config;
249
614
  this.auditLogger = auditLogger;
615
+ this.revocationChecker = createRevocationChecker(
616
+ config.revocationCheck ?? { strategy: "skip" }
617
+ );
250
618
  }
251
619
  auditLogger;
620
+ revocationChecker;
252
621
  async authenticate(context) {
253
622
  let parsed = null;
254
623
  if (this.config.source === "gateway_headers") {
@@ -296,6 +665,39 @@ var PKIProvider = class {
296
665
  }
297
666
  return null;
298
667
  }
668
+ const revocationInput = {
669
+ serialNumber: parsed.serialNumber,
670
+ issuerName: parsed.issuer.commonName
671
+ // certPem not available for gateway_headers source
672
+ };
673
+ try {
674
+ const revocationStatus = await this.revocationChecker.check(revocationInput);
675
+ if (revocationStatus.revoked) {
676
+ try {
677
+ this.auditLogger?.log(
678
+ createAuditEvent("pki.cert.revoked" /* PKI_CERT_REVOKED */, "failure", {
679
+ authMethod: "pki",
680
+ reason: "reason" in revocationStatus ? revocationStatus.reason : "Certificate revoked",
681
+ details: { serialNumber: parsed.serialNumber }
682
+ })
683
+ );
684
+ } catch {
685
+ }
686
+ return null;
687
+ }
688
+ } catch (err) {
689
+ try {
690
+ this.auditLogger?.log(
691
+ createAuditEvent("pki.revocation_check.failed" /* PKI_REVOCATION_CHECK_FAILED */, "failure", {
692
+ authMethod: "pki",
693
+ reason: String(err),
694
+ details: { serialNumber: parsed.serialNumber }
695
+ })
696
+ );
697
+ } catch {
698
+ }
699
+ return null;
700
+ }
299
701
  if (this.config.profile === "dod_cac") {
300
702
  if (!validateDoDCAC(parsed)) {
301
703
  try {
@@ -1598,21 +2000,27 @@ Object.defineProperty(exports, "rbacConfigSchema", {
1598
2000
  exports.AuditEventType = AuditEventType;
1599
2001
  exports.AuthContext = AuthContext;
1600
2002
  exports.AuthProvider = AuthProvider;
2003
+ exports.CRLRevocationChecker = CRLRevocationChecker;
1601
2004
  exports.CompositeAuditLogger = CompositeAuditLogger;
2005
+ exports.CompositeRevocationChecker = CompositeRevocationChecker;
1602
2006
  exports.ConsoleAuditLogger = ConsoleAuditLogger;
1603
2007
  exports.DOD_CAC_PROFILE = DOD_CAC_PROFILE;
1604
2008
  exports.InMemoryRevocationStore = InMemoryRevocationStore;
1605
2009
  exports.KeycloakAdapter = KeycloakAdapter;
1606
2010
  exports.NoopAuditLogger = NoopAuditLogger;
2011
+ exports.OCSPRevocationChecker = OCSPRevocationChecker;
1607
2012
  exports.OIDCProvider = OIDCProvider;
1608
2013
  exports.PKIProvider = PKIProvider;
1609
2014
  exports.RBACEngine = RBACEngine;
2015
+ exports.RevocationCache = RevocationCache;
1610
2016
  exports.SessionManager = SessionManager;
2017
+ exports.SkipRevocationChecker = SkipRevocationChecker;
1611
2018
  exports.buildAuthorizationUrl = buildAuthorizationUrl;
1612
2019
  exports.clearCookie = clearCookie;
1613
2020
  exports.createAuditEvent = createAuditEvent;
1614
2021
  exports.createDoDCACConfig = createDoDCACConfig;
1615
2022
  exports.createDoDCACDevConfig = createDoDCACDevConfig;
2023
+ exports.createRevocationChecker = createRevocationChecker;
1616
2024
  exports.decryptToken = decryptToken;
1617
2025
  exports.deriveEncryptionKey = deriveEncryptionKey;
1618
2026
  exports.discoverOIDC = discoverOIDC;
@@ -1627,6 +2035,7 @@ exports.generateState = generateState;
1627
2035
  exports.getAuthDecorator = getAuthDecorator;
1628
2036
  exports.hasAuthConfig = hasAuthConfig;
1629
2037
  exports.maybeWrapWithAuth = maybeWrapWithAuth;
2038
+ exports.normalizeSerial = normalizeSerial;
1630
2039
  exports.parseCertFromHeaders = parseCertFromHeaders;
1631
2040
  exports.parseCertificate = parseCertificate;
1632
2041
  exports.parseCookies = parseCookies;