@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/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/lib/auth/oauth.js +736 -1
- package/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/crypto-field.js +274 -17
- package/lib/mail-auth.js +333 -0
- package/lib/middleware/fetch-metadata.js +115 -14
- package/lib/middleware/security-headers.js +47 -0
- package/lib/observability.js +39 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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, {
|
|
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
|
};
|