@blamejs/core 0.14.19 → 0.14.20

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/lib/auth/oauth.js CHANGED
@@ -115,6 +115,7 @@ var safeJson = require("../safe-json");
115
115
  var safeUrl = require("../safe-url");
116
116
  var { URL } = require("node:url");
117
117
  var { defineClass } = require("../framework-error");
118
+ var validateOpts = require("../validate-opts");
118
119
  var lazyRequire = require("../lazy-require");
119
120
  // Shared JOSE defenses (CVE-2026-22817 alg/kty cross-check +
120
121
  // CVE-2026-23552 constant-time iss compare). Top-of-file per project
@@ -348,6 +349,644 @@ function _jwkToKey(jwk) {
348
349
  }
349
350
  }
350
351
 
352
+ // ---- RFC 9396 Rich Authorization Requests (RAR) ----
353
+ //
354
+ // The client requests fine-grained, typed authorization via the
355
+ // `authorization_details` parameter (a JSON array of objects each
356
+ // carrying a required `type`). The authorization server returns the
357
+ // GRANTED `authorization_details` in the token response (RFC 9396 §7);
358
+ // the client cross-checks granted against requested so an AS (hostile
359
+ // or buggy) cannot silently broaden the grant — a granted detail whose
360
+ // `type` was never requested, or whose array-valued sub-fields
361
+ // (`locations` / `actions` / `datatypes` / `privileges`) exceed the
362
+ // requested set, is refused. This is the client-side mirror of the
363
+ // AS-side subset rule (RFC 9396 §6.3) and defends against an upstream
364
+ // privilege-escalation.
365
+
366
+ // Sub-fields whose values are bounded arrays of strings; a granted
367
+ // value here MUST be a subset of the requested value for the same type
368
+ // (RFC 9396 §2.1 — locations / actions / datatypes / privileges are the
369
+ // registered array-valued common data fields; `privileges` is the most
370
+ // authority-bearing of them, so an unchecked over-grant here is the
371
+ // sharpest escalation).
372
+ var RAR_SUBSET_FIELDS = Object.freeze(["locations", "actions", "datatypes", "privileges"]);
373
+
374
+ // Cap on a serialized authorization_details payload. RFC 9396 puts no
375
+ // fixed limit; 64 KiB matches the step-up RAR parser and refuses a
376
+ // pathological array without touching legitimate transaction payloads.
377
+ var RAR_MAX_BYTES = C.BYTES.kib(64);
378
+
379
+ // Validate the request-side authorization_details array. Config-time
380
+ // entry-point → THROW on bad shape (operator typo at boot). Mirrors
381
+ // step-up.parseAuthorizationDetails but operates on an already-parsed
382
+ // array (the operator passes opts.authorizationDetails as JS objects).
383
+ function _validateAuthorizationDetailsArray(value, label) {
384
+ if (!Array.isArray(value)) {
385
+ throw new OAuthError("auth-oauth/bad-authorization-details",
386
+ label + ": authorizationDetails must be an array of typed objects (RFC 9396 §2)");
387
+ }
388
+ for (var i = 0; i < value.length; i += 1) {
389
+ var entry = value[i];
390
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
391
+ throw new OAuthError("auth-oauth/bad-authorization-details",
392
+ label + ": authorizationDetails[" + i + "] must be an object");
393
+ }
394
+ if (typeof entry.type !== "string" || entry.type.length === 0) {
395
+ throw new OAuthError("auth-oauth/bad-authorization-details",
396
+ label + ": authorizationDetails[" + i + "] missing required 'type' field (RFC 9396 §2)");
397
+ }
398
+ }
399
+ return value;
400
+ }
401
+
402
+ // True when `grantedVal` (an array of strings) contains an element not
403
+ // present in `requestedVal`. Anything the AS grants outside the request
404
+ // is an over-grant. A non-array granted value where the request was an
405
+ // array, or a granted array where no request entry constrained it, is
406
+ // also treated as exceeding.
407
+ function _arraySubfieldExceeds(grantedVal, requestedVal) {
408
+ if (grantedVal === undefined) return false; // not granted → can't exceed
409
+ if (!Array.isArray(grantedVal)) {
410
+ // AS returned a non-array where RAR defines an array field. If the
411
+ // request didn't carry this field at all, an unconstrained scalar
412
+ // is an over-grant; if it matches the requested scalar exactly it
413
+ // is fine (lenient toward non-conforming-but-equal AS output).
414
+ return !(requestedVal !== undefined &&
415
+ !Array.isArray(requestedVal) &&
416
+ grantedVal === requestedVal);
417
+ }
418
+ if (!Array.isArray(requestedVal)) return grantedVal.length > 0;
419
+ for (var i = 0; i < grantedVal.length; i += 1) {
420
+ if (requestedVal.indexOf(grantedVal[i]) === -1) return true;
421
+ }
422
+ return false;
423
+ }
424
+
425
+ // Decide whether a single granted authorization_detail exceeds what was
426
+ // requested. `requestedForType` is the matching request entry (same
427
+ // type) or null when the type was never requested.
428
+ function _grantedDetailExceeds(granted, requestedForType) {
429
+ if (!requestedForType) return true; // type never requested
430
+ for (var i = 0; i < RAR_SUBSET_FIELDS.length; i += 1) {
431
+ var f = RAR_SUBSET_FIELDS[i];
432
+ if (_arraySubfieldExceeds(granted[f], requestedForType[f])) return true;
433
+ }
434
+ return false;
435
+ }
436
+
437
+ // Cross-check the granted authorization_details from a token response
438
+ // against what the client requested. Returns the normalized granted
439
+ // array (or null when the AS returned none). `requested` is the
440
+ // validated request array (or null/undefined when RAR was not used).
441
+ //
442
+ // strict=true (default when requested details were sent): refuse on any
443
+ // over-grant. strict=false: surface but don't throw (operator audits).
444
+ function _crossCheckGrantedAuthorizationDetails(grantedRaw, requested, strict) {
445
+ if (grantedRaw === undefined || grantedRaw === null) return null;
446
+ if (!Array.isArray(grantedRaw)) {
447
+ throw new OAuthError("auth-oauth/bad-granted-authorization-details",
448
+ "token response authorization_details must be a JSON array (RFC 9396 §7)");
449
+ }
450
+ // Bound the parse cost of an attacker-influenced upstream payload.
451
+ if (Buffer.byteLength(JSON.stringify(grantedRaw), "utf8") > RAR_MAX_BYTES) {
452
+ throw new OAuthError("auth-oauth/granted-authorization-details-too-large",
453
+ "token response authorization_details exceeds " + RAR_MAX_BYTES + " bytes");
454
+ }
455
+ if (requested === undefined || requested === null) return grantedRaw;
456
+ for (var i = 0; i < grantedRaw.length; i += 1) {
457
+ var granted = grantedRaw[i];
458
+ if (!granted || typeof granted !== "object" || Array.isArray(granted) ||
459
+ typeof granted.type !== "string") {
460
+ throw new OAuthError("auth-oauth/bad-granted-authorization-details",
461
+ "token response authorization_details[" + i + "] is not a typed object (RFC 9396 §2)");
462
+ }
463
+ // Find a requested entry of the SAME type. Exact string equality on
464
+ // the type field — never a substring scan.
465
+ var match = null;
466
+ for (var j = 0; j < requested.length; j += 1) {
467
+ if (requested[j].type === granted.type) { match = requested[j]; break; }
468
+ }
469
+ if (_grantedDetailExceeds(granted, match)) {
470
+ if (strict) {
471
+ throw new OAuthError("auth-oauth/authorization-details-over-grant",
472
+ "token response granted an authorization_detail (type='" + granted.type +
473
+ "') that exceeds the request — refusing per RFC 9396 §7 (broadened grant). " +
474
+ "Operators that intentionally accept asymmetric grants pass " +
475
+ "verifyAuthorizationDetails: false.");
476
+ }
477
+ }
478
+ }
479
+ return grantedRaw;
480
+ }
481
+
482
+ // ---- OAuth 2.0 Attestation-Based Client Authentication ----
483
+ // (draft-ietf-oauth-attestation-based-client-auth-08)
484
+ //
485
+ // A FAPI / wallet client authenticates with two HTTP headers instead of
486
+ // a client_secret:
487
+ // OAuth-Client-Attestation — a JWT signed by the client's
488
+ // BACKEND ("Attester"), binding the
489
+ // client_id to a per-instance public
490
+ // key via a `cnf` claim (§4).
491
+ // OAuth-Client-Attestation-PoP — a JWT signed by the per-instance
492
+ // PRIVATE key (the one named in the
493
+ // attestation's `cnf`), proving the
494
+ // instance possesses that key (§5).
495
+ //
496
+ // The framework signs these with node:crypto directly — this is the
497
+ // classical-JWS interop case (the Attester / instance keys are RS/PS/ES/
498
+ // EdDSA), distinct from lib/auth/jwt.js which signs framework tokens
499
+ // PQC-only. HMAC ("none" included) is refused on both JWTs.
500
+
501
+ // Asymmetric JWS algorithms accepted for attestation + PoP. HMAC and
502
+ // "none" are intentionally absent (draft §5.2 requires an asymmetric
503
+ // signature for the PoP; we apply the same floor to the attestation).
504
+ var ATTESTATION_ALGS = Object.freeze([
505
+ "RS256", "RS384", "RS512",
506
+ "PS256", "PS384", "PS512",
507
+ "ES256", "ES384", "ES512",
508
+ "EdDSA",
509
+ ]);
510
+
511
+ // Cap on an attestation / PoP JWT. HTTP-header-borne JWTs are small;
512
+ // 16 KiB refuses a pathological header without touching real tokens.
513
+ var MAX_ATTESTATION_JWT_BYTES = C.BYTES.kib(16);
514
+
515
+ // Default acceptable PoP age (draft §8 step "iat within an acceptable
516
+ // time window"). Operator-tunable via opts.maxPopAgeSec.
517
+ var DEFAULT_POP_MAX_AGE_SEC = C.TIME.minutes(5) / C.TIME.seconds(1);
518
+
519
+ // Sign/verify params keyed by alg — superset of _verifyParamsForAlg that
520
+ // also covers EdDSA (used only on the attestation path; the ID-token
521
+ // verifier keeps its own narrower table untouched).
522
+ function _attestationCryptoParams(alg) {
523
+ if (alg === "EdDSA") return { hash: null };
524
+ return _verifyParamsForAlg(alg);
525
+ }
526
+
527
+ function _toAttestationPrivateKey(value, label) {
528
+ if (!value) {
529
+ throw new OAuthError("auth-oauth/attestation-no-key", label + ": privateKey is required");
530
+ }
531
+ if (value instanceof nodeCrypto.KeyObject) return value;
532
+ try {
533
+ if (typeof value === "string" || Buffer.isBuffer(value)) {
534
+ return nodeCrypto.createPrivateKey({ key: value, format: "pem" });
535
+ }
536
+ if (typeof value === "object" && value.kty) {
537
+ return nodeCrypto.createPrivateKey({ key: value, format: "jwk" });
538
+ }
539
+ } catch (e) {
540
+ throw new OAuthError("auth-oauth/attestation-bad-key",
541
+ label + ": private key parse failed: " + ((e && e.message) || String(e)));
542
+ }
543
+ throw new OAuthError("auth-oauth/attestation-bad-key",
544
+ label + ": privateKey must be a PEM string/Buffer, JWK object, or KeyObject");
545
+ }
546
+
547
+ // EC curve → the one ES* alg whose hash matches it (RFC 7518 §3.4).
548
+ var _EC_CURVE_ALG = { prime256v1: "ES256", secp384r1: "ES384", secp521r1: "ES512" };
549
+
550
+ // Resolve the JWS alg for an attestation / PoP signature. When the caller
551
+ // gives no `algorithm`, infer the default that matches the key type so a
552
+ // non-EC attester key (RSA, Ed25519) yields a self-consistent JWS — header
553
+ // alg ⇄ signature key — instead of a fixed `ES256` header signed with the
554
+ // real key, which `verifyClientAttestation`'s alg/kty cross-check would
555
+ // then reject. An explicit alg incompatible with the key is refused BEFORE
556
+ // signing rather than producing a self-invalid attestation.
557
+ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
558
+ var kty = privateKey.asymmetricKeyType;
559
+ var defaultAlg, compatible;
560
+ if (kty === "ec") {
561
+ var curve = (privateKey.asymmetricKeyDetails && privateKey.asymmetricKeyDetails.namedCurve) || "";
562
+ defaultAlg = _EC_CURVE_ALG[curve];
563
+ if (!defaultAlg) {
564
+ throw new OAuthError("auth-oauth/attestation-key-unsupported",
565
+ label + ": EC curve '" + curve + "' has no attestation JWS alg (use P-256 / P-384 / P-521)");
566
+ }
567
+ compatible = [defaultAlg]; // an EC curve pins exactly one ES alg
568
+ } else if (kty === "rsa") {
569
+ defaultAlg = "RS256";
570
+ compatible = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"];
571
+ } else if (kty === "rsa-pss") {
572
+ defaultAlg = "PS256";
573
+ compatible = ["PS256", "PS384", "PS512"]; // an RSA-PSS key cannot produce an RS* signature
574
+ } else if (kty === "ed25519" || kty === "ed448") {
575
+ defaultAlg = "EdDSA";
576
+ compatible = ["EdDSA"];
577
+ } else {
578
+ throw new OAuthError("auth-oauth/attestation-key-unsupported",
579
+ label + ": key type '" + String(kty) + "' is not a supported attestation key (EC / RSA / Ed25519 / Ed448)");
580
+ }
581
+ if (explicitAlg === undefined || explicitAlg === null) return defaultAlg;
582
+ if (ATTESTATION_ALGS.indexOf(explicitAlg) === -1) {
583
+ throw new OAuthError("auth-oauth/attestation-alg-not-accepted",
584
+ label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
585
+ }
586
+ if (compatible.indexOf(explicitAlg) === -1) {
587
+ throw new OAuthError("auth-oauth/attestation-alg-key-mismatch",
588
+ label + ": alg '" + explicitAlg + "' is incompatible with the " + kty +
589
+ " key (compatible: " + compatible.join(", ") + ")");
590
+ }
591
+ return explicitAlg;
592
+ }
593
+
594
+ function _signAttestationJws(header, payload, privateKey, alg) {
595
+ var params = _attestationCryptoParams(alg);
596
+ var headerB64 = _b64urlEncode(Buffer.from(JSON.stringify(header), "utf8"));
597
+ var payloadB64 = _b64urlEncode(Buffer.from(JSON.stringify(payload), "utf8"));
598
+ var signingInput = headerB64 + "." + payloadB64;
599
+ var sig;
600
+ var input = Buffer.from(signingInput, "ascii");
601
+ if (params.hash === null) {
602
+ sig = nodeCrypto.sign(null, input, privateKey); // EdDSA — no prehash
603
+ } else {
604
+ var keyParam = { key: privateKey };
605
+ if (params.padding !== undefined) keyParam.padding = params.padding;
606
+ if (params.saltLength !== undefined) keyParam.saltLength = params.saltLength;
607
+ if (params.dsaEncoding !== undefined) keyParam.dsaEncoding = params.dsaEncoding;
608
+ sig = nodeCrypto.sign(params.hash, input, keyParam);
609
+ }
610
+ return signingInput + "." + _b64urlEncode(sig);
611
+ }
612
+
613
+ // Verify a compact JWS against an already-imported public KeyObject. The
614
+ // alg is read from the header but MUST equal expectedAlg AND match the
615
+ // key's kty (via the shared cross-check) — no alg-confusion window.
616
+ function _verifyAttestationJws(jws, publicKeyJwk, label) {
617
+ if (typeof jws !== "string" || jws.length === 0) {
618
+ throw new OAuthError("auth-oauth/attestation-malformed", label + ": JWT must be a non-empty string");
619
+ }
620
+ if (jws.length > MAX_ATTESTATION_JWT_BYTES) {
621
+ throw new OAuthError("auth-oauth/attestation-too-large",
622
+ label + ": JWT exceeds " + MAX_ATTESTATION_JWT_BYTES + " bytes");
623
+ }
624
+ var parts = jws.split(".");
625
+ if (parts.length === 5) {
626
+ throw new OAuthError("auth-oauth/attestation-jwe-refused",
627
+ label + ": 5-segment JWE refused — attestation JWTs are JWS only");
628
+ }
629
+ if (parts.length !== 3) {
630
+ throw new OAuthError("auth-oauth/attestation-malformed", label + ": JWT is not 3 segments");
631
+ }
632
+ var header, payload;
633
+ try {
634
+ header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
635
+ payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
636
+ } catch (e) {
637
+ throw new OAuthError("auth-oauth/attestation-malformed",
638
+ label + ": header/payload decode failed: " + ((e && e.message) || String(e)));
639
+ }
640
+ if (!header || typeof header.alg !== "string") {
641
+ throw new OAuthError("auth-oauth/attestation-malformed", label + ": header missing 'alg'");
642
+ }
643
+ if (ATTESTATION_ALGS.indexOf(header.alg) === -1) {
644
+ throw new OAuthError("auth-oauth/attestation-alg-not-accepted",
645
+ label + ": alg '" + header.alg + "' is not an accepted attestation algorithm " +
646
+ "(HMAC / none refused — alg-allowlist gate)");
647
+ }
648
+ if (header.crit !== undefined && header.crit !== null) {
649
+ throw new OAuthError("auth-oauth/attestation-crit-not-supported",
650
+ label + ": JWS 'crit' header is not supported (RFC 7515 §4.1.11)");
651
+ }
652
+ // CVE-2026-22817 — cross-check alg against the key's kty before verify.
653
+ jwtExternal._assertAlgKtyMatch(header.alg, publicKeyJwk);
654
+ var keyObject = _jwkToKey(publicKeyJwk);
655
+ var params = _attestationCryptoParams(header.alg);
656
+ var signingInput = parts[0] + "." + parts[1];
657
+ var sig = _b64urlDecode(parts[2]);
658
+ var verifyOpts = { key: keyObject };
659
+ if (params.padding !== undefined) verifyOpts.padding = params.padding;
660
+ if (params.saltLength !== undefined) verifyOpts.saltLength = params.saltLength;
661
+ if (params.dsaEncoding !== undefined) verifyOpts.dsaEncoding = params.dsaEncoding;
662
+ var ok;
663
+ try {
664
+ ok = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
665
+ } catch (verifyErr) {
666
+ throw new OAuthError("auth-oauth/attestation-bad-signature",
667
+ label + ": signature verification raised: " + ((verifyErr && verifyErr.message) || String(verifyErr)));
668
+ }
669
+ if (!ok) {
670
+ throw new OAuthError("auth-oauth/attestation-bad-signature", label + ": signature verification failed");
671
+ }
672
+ return { header: header, payload: payload };
673
+ }
674
+
675
+ // Strip a JWK down to its public components only — a private half MUST
676
+ // never reach the attestation's cnf claim. Mirrors the dpop.buildProof
677
+ // public-only embed.
678
+ function _publicCnfJwk(jwk, label) {
679
+ if (!jwk || typeof jwk !== "object") {
680
+ throw new OAuthError("auth-oauth/attestation-bad-cnf",
681
+ label + ": instanceKeyJwk (public JWK for the cnf claim) is required");
682
+ }
683
+ if (jwk.kty === "EC") return { kty: "EC", crv: jwk.crv, x: jwk.x, y: jwk.y };
684
+ if (jwk.kty === "OKP") return { kty: "OKP", crv: jwk.crv, x: jwk.x };
685
+ if (jwk.kty === "RSA") return { kty: "RSA", e: jwk.e, n: jwk.n };
686
+ throw new OAuthError("auth-oauth/attestation-bad-cnf",
687
+ label + ": instanceKeyJwk.kty='" + jwk.kty + "' is not an asymmetric public JWK");
688
+ }
689
+
690
+ /**
691
+ * @primitive b.auth.oauth.buildClientAttestation
692
+ * @signature b.auth.oauth.buildClientAttestation(opts)
693
+ * @since 0.14.20
694
+ * @status experimental
695
+ * @related b.auth.oauth.buildClientAttestationPop, b.auth.oauth.verifyClientAttestation
696
+ *
697
+ * Builds the `OAuth-Client-Attestation` JWT defined by
698
+ * draft-ietf-oauth-attestation-based-client-auth-08 §4. The client's
699
+ * backend ("Attester") signs a JWT binding the `client_id` (in `sub`)
700
+ * to a per-instance public key carried in the RFC 7800 `cnf` claim.
701
+ * The companion PoP (`buildClientAttestationPop`) then proves the
702
+ * instance holds the matching private key — together they replace a
703
+ * shared `client_secret` for FAPI / wallet clients.
704
+ *
705
+ * The JWT is a classical JWS (RS/PS/ES/EdDSA) signed via `node:crypto`;
706
+ * HMAC and `none` are refused. This is the interop case distinct from
707
+ * `b.auth.jwt`, which signs framework tokens PQC-only.
708
+ *
709
+ * Opt-in / additive: a client that never calls this behaves as before.
710
+ *
711
+ * @opts
712
+ * {
713
+ * clientId: string, // → sub claim (required)
714
+ * attesterPrivateKey: KeyObject|PEM|JWK, // Attester signing key (required)
715
+ * instanceKeyJwk: object, // instance PUBLIC JWK → cnf.jwk (required)
716
+ * algorithm?: string, // JWS alg (default: inferred from the key type — ES256/384/512, RS256, or EdDSA)
717
+ * expiresInSec?: number, // exp = iat + this (default: 300)
718
+ * nbf?: number, // optional not-before (epoch seconds)
719
+ * iat?: number, // override issued-at (epoch seconds)
720
+ * extraClaims?: object, // merged without overriding spec fields
721
+ * }
722
+ *
723
+ * @example
724
+ * var att = b.auth.oauth.buildClientAttestation({
725
+ * clientId: "wallet-app",
726
+ * attesterPrivateKey: attesterKey,
727
+ * instanceKeyJwk: instancePublicJwk,
728
+ * });
729
+ * // → "eyJ0eXAiOiJvYXV0aC1jbGllbnQtYXR0ZXN0YXRpb24rand0Ii..."
730
+ */
731
+ function buildClientAttestation(aopts) {
732
+ aopts = aopts || {};
733
+ validateOpts(aopts, [
734
+ "clientId", "attesterPrivateKey", "instanceKeyJwk", "algorithm",
735
+ "expiresInSec", "nbf", "iat", "extraClaims",
736
+ ], "auth.oauth.buildClientAttestation");
737
+ validateOpts.requireNonEmptyString(aopts.clientId,
738
+ "buildClientAttestation: clientId", OAuthError, "auth-oauth/attestation-no-client-id");
739
+ validateOpts.optionalPositiveInt(aopts.expiresInSec,
740
+ "buildClientAttestation: expiresInSec", OAuthError, "auth-oauth/attestation-bad-expiry");
741
+ var key = _toAttestationPrivateKey(aopts.attesterPrivateKey, "buildClientAttestation");
742
+ var alg = _resolveAttestationAlg(aopts.algorithm, key, "buildClientAttestation");
743
+ var cnfJwk = _publicCnfJwk(aopts.instanceKeyJwk, "buildClientAttestation");
744
+ var iatSec = typeof aopts.iat === "number" ? aopts.iat : Math.floor(Date.now() / C.TIME.seconds(1));
745
+ var ttl = typeof aopts.expiresInSec === "number" ? aopts.expiresInSec : DEFAULT_POP_MAX_AGE_SEC;
746
+ var payload = {
747
+ sub: aopts.clientId, // draft §4.1 — sub = client_id
748
+ iat: iatSec,
749
+ exp: iatSec + ttl,
750
+ cnf: { jwk: cnfJwk }, // draft §4.1 — RFC 7800 cnf
751
+ };
752
+ if (typeof aopts.nbf === "number") payload.nbf = aopts.nbf;
753
+ // Operator extra claims merged WITHOUT overriding the spec-required
754
+ // fields (no prototype-pollution: only own enumerable keys, reserved
755
+ // names rejected).
756
+ if (aopts.extraClaims && typeof aopts.extraClaims === "object" && !Array.isArray(aopts.extraClaims)) {
757
+ var ck = Object.keys(aopts.extraClaims);
758
+ for (var i = 0; i < ck.length; i += 1) {
759
+ var k = ck[i];
760
+ if (k === "__proto__" || k === "constructor" || k === "prototype") continue;
761
+ if (Object.prototype.hasOwnProperty.call(payload, k)) continue; // never override spec fields
762
+ payload[k] = aopts.extraClaims[k];
763
+ }
764
+ }
765
+ return _signAttestationJws(
766
+ { typ: "oauth-client-attestation+jwt", alg: alg }, payload, key, alg);
767
+ }
768
+
769
+ /**
770
+ * @primitive b.auth.oauth.buildClientAttestationPop
771
+ * @signature b.auth.oauth.buildClientAttestationPop(opts)
772
+ * @since 0.14.20
773
+ * @status experimental
774
+ * @related b.auth.oauth.buildClientAttestation, b.auth.oauth.verifyClientAttestation
775
+ *
776
+ * Builds the `OAuth-Client-Attestation-PoP` JWT defined by
777
+ * draft-ietf-oauth-attestation-based-client-auth-08 §5. Signed by the
778
+ * per-instance PRIVATE key whose public half lives in the attestation's
779
+ * `cnf` claim, it proves the instance possesses that key for this
780
+ * request. `aud` MUST be the authorization server's issuer; `jti` is a
781
+ * fresh per-request identifier the AS tracks for replay defense.
782
+ *
783
+ * Asymmetric JWS only (RS/PS/ES/EdDSA) — MAC / `none` are refused.
784
+ *
785
+ * Opt-in / additive.
786
+ *
787
+ * @opts
788
+ * {
789
+ * instancePrivateKey: KeyObject|PEM|JWK, // matches cnf.jwk (required)
790
+ * audience: string, // AS issuer URL → aud (required)
791
+ * algorithm?: string, // JWS alg (default: inferred from the key type — ES256/384/512, RS256, or EdDSA)
792
+ * challenge?: string, // server-issued nonce → challenge claim
793
+ * jti?: string, // override jti (default: fresh CSPRNG)
794
+ * iat?: number, // override issued-at (epoch seconds)
795
+ * expiresInSec?: number, // optional exp = iat + this
796
+ * }
797
+ *
798
+ * @example
799
+ * var pop = b.auth.oauth.buildClientAttestationPop({
800
+ * instancePrivateKey: instanceKey,
801
+ * audience: "https://as.example.com",
802
+ * });
803
+ * // send both headers on the token request:
804
+ * // OAuth-Client-Attestation: <att>
805
+ * // OAuth-Client-Attestation-PoP: <pop>
806
+ */
807
+ function buildClientAttestationPop(popts) {
808
+ popts = popts || {};
809
+ validateOpts(popts, [
810
+ "instancePrivateKey", "audience", "algorithm", "challenge",
811
+ "jti", "iat", "expiresInSec",
812
+ ], "auth.oauth.buildClientAttestationPop");
813
+ validateOpts.requireNonEmptyString(popts.audience,
814
+ "buildClientAttestationPop: audience (AS issuer)", OAuthError, "auth-oauth/attestation-pop-no-aud");
815
+ validateOpts.optionalNonEmptyString(popts.challenge,
816
+ "buildClientAttestationPop: challenge", OAuthError, "auth-oauth/attestation-pop-bad-challenge");
817
+ validateOpts.optionalPositiveInt(popts.expiresInSec,
818
+ "buildClientAttestationPop: expiresInSec", OAuthError, "auth-oauth/attestation-pop-bad-expiry");
819
+ var key = _toAttestationPrivateKey(popts.instancePrivateKey, "buildClientAttestationPop");
820
+ var alg = _resolveAttestationAlg(popts.algorithm, key, "buildClientAttestationPop");
821
+ var iatSec = typeof popts.iat === "number" ? popts.iat : Math.floor(Date.now() / C.TIME.seconds(1));
822
+ var jti = typeof popts.jti === "string" && popts.jti.length > 0
823
+ ? popts.jti : _generateRandomToken(STATE_NONCE_BYTES);
824
+ var payload = {
825
+ aud: popts.audience, // draft §5.2 — AS issuer
826
+ jti: jti, // draft §5.2 — replay detection
827
+ iat: iatSec, // draft §5.2
828
+ };
829
+ if (typeof popts.expiresInSec === "number") payload.exp = iatSec + popts.expiresInSec;
830
+ if (typeof popts.challenge === "string" && popts.challenge.length > 0) {
831
+ payload.challenge = popts.challenge; // draft §5.2 — server nonce
832
+ }
833
+ return _signAttestationJws(
834
+ { typ: "oauth-client-attestation-pop+jwt", alg: alg }, payload, key, alg);
835
+ }
836
+
837
+ /**
838
+ * @primitive b.auth.oauth.verifyClientAttestation
839
+ * @signature b.auth.oauth.verifyClientAttestation(attestationJwt, popJwt, opts)
840
+ * @since 0.14.20
841
+ * @status experimental
842
+ * @related b.auth.oauth.buildClientAttestation, b.auth.oauth.buildClientAttestationPop
843
+ *
844
+ * Verifies a `OAuth-Client-Attestation` + `OAuth-Client-Attestation-PoP`
845
+ * header pair, performing the authorization-server checks of
846
+ * draft-ietf-oauth-attestation-based-client-auth-08 §8: the attestation
847
+ * signature against a TRUSTED Attester key; the PoP signature against
848
+ * the attestation's `cnf` key (never the Attester's); attestation `exp`
849
+ * freshness; PoP `aud` == this AS issuer (constant-time); PoP `iat`
850
+ * within `maxPopAgeSec`; optional server-challenge binding; and `jti`
851
+ * replay defense via an operator-supplied atomic check-and-insert.
852
+ *
853
+ * Async (returns a Promise) so the `jti` replay store can be an async
854
+ * Redis / DB check-and-insert. Resolves to `{ clientId, cnfJwk,
855
+ * attestation, pop }` on success; rejects with a typed `OAuthError` on
856
+ * any failure. Opt-in / additive — an AS that doesn't accept
857
+ * attestation-based auth never calls it.
858
+ *
859
+ * @opts
860
+ * {
861
+ * attesterJwk: object, // trusted Attester PUBLIC JWK (required)
862
+ * expectedAudience: string, // this AS issuer URL (required)
863
+ * expectedClientId?: string, // request client_id; must equal attestation sub
864
+ * challenge?: string, // server-issued nonce the PoP must echo
865
+ * maxPopAgeSec?: number, // PoP iat freshness window (default: 300)
866
+ * clockSkewSec?: number, // allowed skew (default: 60)
867
+ * seenJti?: function, // (jti, iat) → truthy when UNSEEN (atomic); may return a Promise (async store)
868
+ * }
869
+ *
870
+ * @example
871
+ * var v = await b.auth.oauth.verifyClientAttestation(
872
+ * req.headers["oauth-client-attestation"],
873
+ * req.headers["oauth-client-attestation-pop"],
874
+ * { attesterJwk: trustedAttesterJwk, expectedAudience: "https://as.example.com",
875
+ * seenJti: function (jti) { return jtiStore.checkAndInsert(jti); } });
876
+ * // → { clientId: "wallet-app", cnfJwk: {...}, attestation: {...}, pop: {...} }
877
+ */
878
+ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
879
+ vopts = vopts || {};
880
+ validateOpts(vopts, [
881
+ "attesterJwk", "expectedAudience", "expectedClientId", "challenge",
882
+ "maxPopAgeSec", "clockSkewSec", "seenJti",
883
+ ], "auth.oauth.verifyClientAttestation");
884
+ if (!vopts.attesterJwk || typeof vopts.attesterJwk !== "object") {
885
+ throw new OAuthError("auth-oauth/attestation-no-attester-jwk",
886
+ "verifyClientAttestation: opts.attesterJwk (trusted Attester public JWK) is required");
887
+ }
888
+ validateOpts.requireNonEmptyString(vopts.expectedAudience,
889
+ "verifyClientAttestation: expectedAudience (this AS issuer)", OAuthError,
890
+ "auth-oauth/attestation-no-expected-aud");
891
+
892
+ // 1. Attestation signature against the TRUSTED attester key.
893
+ var att = _verifyAttestationJws(attestationJwt, vopts.attesterJwk, "client-attestation");
894
+ var ap = att.payload || {};
895
+ if (typeof ap.sub !== "string" || ap.sub.length === 0) {
896
+ throw new OAuthError("auth-oauth/attestation-no-sub",
897
+ "client-attestation: missing 'sub' (client_id) claim");
898
+ }
899
+ if (!ap.cnf || typeof ap.cnf !== "object" || !ap.cnf.jwk || typeof ap.cnf.jwk !== "object") {
900
+ throw new OAuthError("auth-oauth/attestation-no-cnf",
901
+ "client-attestation: missing 'cnf.jwk' confirmation key (RFC 7800)");
902
+ }
903
+ var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
904
+ var skewSec = typeof vopts.clockSkewSec === "number" ? vopts.clockSkewSec : (C.TIME.minutes(1) / C.TIME.seconds(1));
905
+ if (typeof ap.exp !== "number" || ap.exp + skewSec < nowSec) {
906
+ throw new OAuthError("auth-oauth/attestation-expired",
907
+ "client-attestation: expired (exp=" + ap.exp + ", now=" + nowSec + ")");
908
+ }
909
+ if (typeof ap.nbf === "number" && ap.nbf - skewSec > nowSec) {
910
+ throw new OAuthError("auth-oauth/attestation-not-yet-valid", "client-attestation: nbf in the future");
911
+ }
912
+ if (vopts.expectedClientId !== undefined && vopts.expectedClientId !== null) {
913
+ // Exact equality (constant-time) — defends against a client_id the
914
+ // request claims that the attestation never bound (draft §8 step 10).
915
+ if (!_constantTimeStrEq(String(vopts.expectedClientId), ap.sub)) {
916
+ throw new OAuthError("auth-oauth/attestation-client-id-mismatch",
917
+ "client-attestation: sub does not match the request's client_id");
918
+ }
919
+ }
920
+
921
+ // 2. PoP signature against the attestation's cnf key (NOT the attester).
922
+ var pop = _verifyAttestationJws(popJwt, ap.cnf.jwk, "client-attestation-pop");
923
+ var pp = pop.payload || {};
924
+ // aud MUST be THIS AS issuer (constant-time, exact). Attacker-replayed
925
+ // PoP minted for a different AS is refused (draft §8 step 7).
926
+ if (typeof pp.aud !== "string" || !_constantTimeStrEq(vopts.expectedAudience, pp.aud)) {
927
+ throw new OAuthError("auth-oauth/attestation-pop-aud-mismatch",
928
+ "client-attestation-pop: aud does not match this authorization server's issuer");
929
+ }
930
+ if (typeof pp.jti !== "string" || pp.jti.length === 0) {
931
+ throw new OAuthError("auth-oauth/attestation-pop-no-jti", "client-attestation-pop: missing 'jti'");
932
+ }
933
+ if (typeof pp.iat !== "number") {
934
+ throw new OAuthError("auth-oauth/attestation-pop-no-iat", "client-attestation-pop: missing 'iat'");
935
+ }
936
+ var maxAge = typeof vopts.maxPopAgeSec === "number" ? vopts.maxPopAgeSec : DEFAULT_POP_MAX_AGE_SEC;
937
+ if (pp.iat - skewSec > nowSec) {
938
+ throw new OAuthError("auth-oauth/attestation-pop-iat-future", "client-attestation-pop: iat in the future");
939
+ }
940
+ if (pp.iat + maxAge + skewSec < nowSec) {
941
+ throw new OAuthError("auth-oauth/attestation-pop-stale",
942
+ "client-attestation-pop: iat older than maxPopAgeSec (" + maxAge + "s)");
943
+ }
944
+ if (typeof pp.exp === "number" && pp.exp + skewSec < nowSec) {
945
+ throw new OAuthError("auth-oauth/attestation-pop-expired", "client-attestation-pop: expired");
946
+ }
947
+ // challenge binding when the AS issued one (draft §8 step 5/6).
948
+ if (vopts.challenge !== undefined && vopts.challenge !== null) {
949
+ if (typeof pp.challenge !== "string" || !_constantTimeStrEq(String(vopts.challenge), pp.challenge)) {
950
+ throw new OAuthError("auth-oauth/attestation-pop-challenge-mismatch",
951
+ "client-attestation-pop: challenge does not match the server-issued value");
952
+ }
953
+ }
954
+ // Replay defense (draft §12.1). Atomic check-and-insert contract:
955
+ // returns truthy when the jti was UNSEEN (first sighting). The result
956
+ // MAY be a Promise (Redis/DB store) — it is awaited so an async store's
957
+ // resolved `false` (a replayed jti) refuses, instead of comparing a
958
+ // never-`false` Promise object. Hot dep — a thrown / rejected callback
959
+ // is surfaced as a typed error, not swallowed.
960
+ if (typeof vopts.seenJti === "function") {
961
+ var unseen;
962
+ try {
963
+ unseen = vopts.seenJti(pp.jti, pp.iat);
964
+ if (unseen && typeof unseen.then === "function") unseen = await unseen;
965
+ } catch (e) {
966
+ throw new OAuthError("auth-oauth/attestation-pop-seen-callback-failed",
967
+ "client-attestation-pop: seenJti() callback threw: " + ((e && e.message) || String(e)));
968
+ }
969
+ if (unseen === false) {
970
+ throw new OAuthError("auth-oauth/attestation-pop-replay",
971
+ "client-attestation-pop: jti already seen (replay refused, draft §12.1)");
972
+ }
973
+ }
974
+ return {
975
+ clientId: ap.sub,
976
+ cnfJwk: ap.cnf.jwk,
977
+ attestation: ap,
978
+ pop: pp,
979
+ };
980
+ }
981
+
982
+ // Constant-time string equality. b.crypto.timingSafeEqual accepts
983
+ // strings, returns false (never throws) on length mismatch, and refuses
984
+ // non-string/Buffer input — so it carries the timing + type discipline.
985
+ function _constantTimeStrEq(a, b) {
986
+ if (typeof a !== "string" || typeof b !== "string") return false;
987
+ return cryptoTimingSafeEqual(a, b);
988
+ }
989
+
351
990
  // ---- core ----
352
991
 
353
992
  function create(opts) {
@@ -609,6 +1248,17 @@ function create(opts) {
609
1248
  if (uopts.prompt) params.set("prompt", uopts.prompt);
610
1249
  if (uopts.loginHint) params.set("login_hint", uopts.loginHint);
611
1250
  if (uopts.maxAge != null) params.set("max_age", String(uopts.maxAge));
1251
+ // RFC 9396 — fine-grained authorization request. Validated at this
1252
+ // entry-point (THROW on bad shape) then serialized as the JSON-array
1253
+ // `authorization_details` parameter. The validated array is returned
1254
+ // so the caller can thread it into exchangeCode for the granted-vs-
1255
+ // requested cross-check.
1256
+ var requestedAuthzDetails = null;
1257
+ if (uopts.authorizationDetails !== undefined) {
1258
+ requestedAuthzDetails = _validateAuthorizationDetailsArray(
1259
+ uopts.authorizationDetails, "authorizationUrl");
1260
+ params.set("authorization_details", JSON.stringify(requestedAuthzDetails));
1261
+ }
612
1262
  // Operator-supplied additional params (audience, resource, etc.).
613
1263
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
614
1264
  var ek = Object.keys(uopts.extraParams);
@@ -621,6 +1271,7 @@ function create(opts) {
621
1271
  nonce: nonce,
622
1272
  verifier: pkceVals ? pkceVals.verifier : null,
623
1273
  challenge: pkceVals ? pkceVals.challenge : null,
1274
+ authorizationDetails: requestedAuthzDetails,
624
1275
  };
625
1276
  }
626
1277
 
@@ -655,9 +1306,25 @@ function create(opts) {
655
1306
  body.set("client_id", clientId);
656
1307
  if (clientSecret) body.set("client_secret", clientSecret);
657
1308
  if (eopts.verifier) body.set("code_verifier", eopts.verifier);
1309
+ // RFC 9396 — the operator threads the requested authorization_details
1310
+ // (the validated array returned from authorizationUrl /
1311
+ // pushAuthorizationRequest) so the granted set in the token response
1312
+ // can be cross-checked. The array is also re-sent on the token
1313
+ // request, which RFC 9396 §6.3 allows for narrowing the grant.
1314
+ var requestedAuthzDetails = null;
1315
+ if (eopts.authorizationDetails !== undefined && eopts.authorizationDetails !== null) {
1316
+ requestedAuthzDetails = _validateAuthorizationDetailsArray(
1317
+ eopts.authorizationDetails, "exchangeCode");
1318
+ body.set("authorization_details", JSON.stringify(requestedAuthzDetails));
1319
+ }
658
1320
 
659
1321
  var tokens = await _postForm(endpoint, body);
660
- return await _normalizeTokens(tokens, { nonce: eopts.nonce, skipNonceCheck: eopts.skipNonceCheck });
1322
+ return await _normalizeTokens(tokens, {
1323
+ nonce: eopts.nonce,
1324
+ skipNonceCheck: eopts.skipNonceCheck,
1325
+ requestedAuthorizationDetails: requestedAuthzDetails,
1326
+ verifyAuthorizationDetails: eopts.verifyAuthorizationDetails,
1327
+ });
661
1328
  }
662
1329
 
663
1330
  async function refreshAccessToken(refreshToken, ropts) {
@@ -1042,6 +1709,21 @@ function create(opts) {
1042
1709
  picture: v.claims.picture,
1043
1710
  };
1044
1711
  }
1712
+ // RFC 9396 §7 — surface the GRANTED authorization_details and, when
1713
+ // the operator threaded the requested array through, cross-check it.
1714
+ // strict by default whenever a request was sent (refuse an over-
1715
+ // grant); operators that intentionally accept asymmetric grants pass
1716
+ // verifyAuthorizationDetails: false.
1717
+ if (raw.authorization_details !== undefined) {
1718
+ var strict = vopts.requestedAuthorizationDetails != null &&
1719
+ vopts.verifyAuthorizationDetails !== false;
1720
+ tokens.authorizationDetails = _crossCheckGrantedAuthorizationDetails(
1721
+ raw.authorization_details,
1722
+ vopts.requestedAuthorizationDetails != null ? vopts.requestedAuthorizationDetails : null,
1723
+ strict);
1724
+ } else {
1725
+ tokens.authorizationDetails = null;
1726
+ }
1045
1727
  return tokens;
1046
1728
  }
1047
1729
 
@@ -1347,6 +2029,14 @@ function create(opts) {
1347
2029
  if (uopts.loginHint) body.set("login_hint", uopts.loginHint);
1348
2030
  if (uopts.maxAge != null) body.set("max_age", String(uopts.maxAge));
1349
2031
  if (clientSecret) body.set("client_secret", clientSecret);
2032
+ // RFC 9396 — push the fine-grained authorization request through PAR
2033
+ // identically to the redirect path (validated, then JSON-serialized).
2034
+ var requestedAuthzDetails = null;
2035
+ if (uopts.authorizationDetails !== undefined) {
2036
+ requestedAuthzDetails = _validateAuthorizationDetailsArray(
2037
+ uopts.authorizationDetails, "pushAuthorizationRequest");
2038
+ body.set("authorization_details", JSON.stringify(requestedAuthzDetails));
2039
+ }
1350
2040
  if (uopts.extraParams && typeof uopts.extraParams === "object") {
1351
2041
  var ek = Object.keys(uopts.extraParams);
1352
2042
  for (var i = 0; i < ek.length; i++) body.set(ek[i], String(uopts.extraParams[ek[i]]));
@@ -1371,6 +2061,7 @@ function create(opts) {
1371
2061
  challenge: pkceVals.challenge,
1372
2062
  requestUri: rv.request_uri,
1373
2063
  expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
2064
+ authorizationDetails: requestedAuthzDetails,
1374
2065
  };
1375
2066
  }
1376
2067
 
@@ -2151,6 +2842,42 @@ function create(opts) {
2151
2842
  });
2152
2843
  }
2153
2844
 
2845
+ // draft-ietf-oauth-attestation-based-client-auth — convenience that
2846
+ // builds BOTH headers for THIS client. clientId is taken from create();
2847
+ // audience defaults to the configured issuer (the AS the client talks
2848
+ // to). The instance attestation/PoP keys are passed per call.
2849
+ function clientAttestationHeaders(copts) {
2850
+ copts = copts || {};
2851
+ var audience = copts.audience || issuer;
2852
+ if (!audience) {
2853
+ throw new OAuthError("auth-oauth/attestation-no-aud",
2854
+ "clientAttestationHeaders: opts.audience (AS issuer) is required when the client " +
2855
+ "was created without an issuer");
2856
+ }
2857
+ var attestation = buildClientAttestation({
2858
+ clientId: clientId,
2859
+ attesterPrivateKey: copts.attesterPrivateKey,
2860
+ instanceKeyJwk: copts.instanceKeyJwk,
2861
+ algorithm: copts.algorithm,
2862
+ expiresInSec: copts.expiresInSec,
2863
+ });
2864
+ var pop = buildClientAttestationPop({
2865
+ instancePrivateKey: copts.instancePrivateKey,
2866
+ audience: audience,
2867
+ algorithm: copts.popAlgorithm || copts.algorithm,
2868
+ challenge: copts.challenge,
2869
+ expiresInSec: copts.popExpiresInSec,
2870
+ });
2871
+ return {
2872
+ attestation: attestation,
2873
+ pop: pop,
2874
+ headers: {
2875
+ "OAuth-Client-Attestation": attestation,
2876
+ "OAuth-Client-Attestation-PoP": pop,
2877
+ },
2878
+ };
2879
+ }
2880
+
2154
2881
  return {
2155
2882
  authorizationUrl: authorizationUrl,
2156
2883
  exchangeCode: exchangeCode,
@@ -2175,6 +2902,7 @@ function create(opts) {
2175
2902
  pollDeviceCode: pollDeviceCode,
2176
2903
  exchangeToken: exchangeToken,
2177
2904
  nativeSsoExchange: nativeSsoExchange,
2905
+ clientAttestationHeaders: clientAttestationHeaders,
2178
2906
  // Diagnostic / power-user surface
2179
2907
  issuer: issuer,
2180
2908
  clientId: clientId,
@@ -2189,10 +2917,17 @@ module.exports = {
2189
2917
  PRESETS: PRESETS,
2190
2918
  OAuthError: OAuthError,
2191
2919
  DEFAULT_ACCEPTED_ALGS: DEFAULT_ACCEPTED_ALGS,
2920
+ ATTESTATION_ALGS: ATTESTATION_ALGS,
2921
+ // draft-ietf-oauth-attestation-based-client-auth — issuer-agnostic
2922
+ // builders + validator (usable without a create()'d client).
2923
+ buildClientAttestation: buildClientAttestation,
2924
+ buildClientAttestationPop: buildClientAttestationPop,
2925
+ verifyClientAttestation: verifyClientAttestation,
2192
2926
  // Internal helpers exposed for tests
2193
2927
  _generatePkce: _generatePkce,
2194
2928
  _generateRandomToken: _generateRandomToken,
2195
2929
  _b64urlEncode: _b64urlEncode,
2196
2930
  _b64urlDecode: _b64urlDecode,
2197
2931
  _verifyParamsForAlg: _verifyParamsForAlg,
2932
+ _crossCheckGrantedAuthorizationDetails: _crossCheckGrantedAuthorizationDetails,
2198
2933
  };