@hawcx/oauth-client 5.1.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +479 -0
- package/README.md +75 -10
- package/dist/client-assertion.d.ts.map +1 -1
- package/dist/client-assertion.js +6 -2
- package/dist/client-assertion.js.map +1 -1
- package/dist/delegation/exceptions.d.ts +34 -0
- package/dist/delegation/exceptions.d.ts.map +1 -1
- package/dist/delegation/exceptions.js +39 -1
- package/dist/delegation/exceptions.js.map +1 -1
- package/dist/delegation/hawcx.d.ts +6 -2
- package/dist/delegation/hawcx.d.ts.map +1 -1
- package/dist/delegation/hawcx.js.map +1 -1
- package/dist/delegation/index.d.ts +2 -1
- package/dist/delegation/index.d.ts.map +1 -1
- package/dist/delegation/index.js +3 -1
- package/dist/delegation/index.js.map +1 -1
- package/dist/delegation/transport.d.ts +6 -2
- package/dist/delegation/transport.d.ts.map +1 -1
- package/dist/delegation/transport.js +9 -2
- package/dist/delegation/transport.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/url.d.ts +66 -0
- package/dist/internal/url.d.ts.map +1 -0
- package/dist/internal/url.js +155 -0
- package/dist/internal/url.js.map +1 -0
- package/dist/management/jwt_transport.d.ts +150 -0
- package/dist/management/jwt_transport.d.ts.map +1 -0
- package/dist/management/jwt_transport.js +245 -0
- package/dist/management/jwt_transport.js.map +1 -0
- package/dist/management/response_signature.d.ts +65 -0
- package/dist/management/response_signature.d.ts.map +1 -0
- package/dist/management/response_signature.js +170 -0
- package/dist/management/response_signature.js.map +1 -0
- package/dist/oauth.d.ts +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +35 -24
- package/dist/oauth.js.map +1 -1
- package/dist/stepup/client.d.ts +72 -2
- package/dist/stepup/client.d.ts.map +1 -1
- package/dist/stepup/client.js +69 -0
- package/dist/stepup/client.js.map +1 -1
- package/package.json +2 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT-bearer transport for the Hawcx management API (Path A: private_key_jwt).
|
|
3
|
+
*
|
|
4
|
+
* Each request is authenticated with a per-request EdDSA JWT whose `aud` is
|
|
5
|
+
* the full request URL, carrying a `body_sha256` claim over the EXACT bytes
|
|
6
|
+
* sent. This mirrors the wire contract verified end-to-end against hx_auth
|
|
7
|
+
* (see spec.md §management-api-auth / Python SDK Path A implementation).
|
|
8
|
+
*
|
|
9
|
+
* Construction:
|
|
10
|
+
* const transport = new JwtBearerTransport({
|
|
11
|
+
* baseUrl: 'https://api.hawcx.com',
|
|
12
|
+
* configId: 'your-config-id',
|
|
13
|
+
* clientId: 'your-client-id',
|
|
14
|
+
* oidcSigningKey: pkcs8Pem, // Ed25519 PKCS#8 PEM — same key registered in admin console
|
|
15
|
+
* kid: 'key-id',
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* The `http` constructor option accepts an injectable fetch-compatible function
|
|
19
|
+
* (same signature as the global `fetch`) for testing.
|
|
20
|
+
*/
|
|
21
|
+
import { type JWK } from 'jose';
|
|
22
|
+
import type { KeyLike } from 'jose';
|
|
23
|
+
import type { JsonObject } from '../delegation/transport';
|
|
24
|
+
/**
|
|
25
|
+
* A fetch-compatible callable — accepts `(url: string, init: RequestInit)`
|
|
26
|
+
* and returns a `Promise<Response>`. Mirrors the global `fetch` signature so
|
|
27
|
+
* the real `fetch` can be passed unchanged.
|
|
28
|
+
*/
|
|
29
|
+
export type HttpFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
30
|
+
export interface JwtBearerTransportOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Base URL of the management API, e.g. `https://api.hawcx.com`. Must be
|
|
33
|
+
* `https:` (plain `http:` only for `localhost` / `127.0.0.1` / `[::1]`),
|
|
34
|
+
* without credentials, query or fragment. May carry a path prefix
|
|
35
|
+
* (e.g. `https://gateway.example.com/hawcx`). Validated at construction;
|
|
36
|
+
* an invalid value throws `DelegationRequestError`.
|
|
37
|
+
*/
|
|
38
|
+
baseUrl: string;
|
|
39
|
+
/** Tenant config identifier; forwarded as `X-Config-Id`. */
|
|
40
|
+
configId: string;
|
|
41
|
+
/**
|
|
42
|
+
* Ed25519 private signing key. Accepts:
|
|
43
|
+
* - PKCS#8 PEM string (e.g. `"-----BEGIN PRIVATE KEY-----\n…"`)
|
|
44
|
+
* - JWK object `{ kty:'OKP', crv:'Ed25519', d, x, kid? }`
|
|
45
|
+
* - jose `KeyLike` / `CryptoKey` already imported
|
|
46
|
+
*/
|
|
47
|
+
oidcSigningKey: string | JWK | KeyLike;
|
|
48
|
+
/** Key ID embedded in the JWT `kid` header. */
|
|
49
|
+
kid: string;
|
|
50
|
+
/** OAuth client ID; used as both `iss` and `sub` in the per-request JWT. */
|
|
51
|
+
clientId: string;
|
|
52
|
+
/**
|
|
53
|
+
* JWT time-to-live in seconds (default: 60). The server rejects tokens
|
|
54
|
+
* older than its configured TTL; do not set this above 120.
|
|
55
|
+
*/
|
|
56
|
+
jwtTtlSeconds?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Per-request timeout in seconds (default: 15). A stalled
|
|
59
|
+
* (connected-but-silent) server would otherwise hang the call forever and
|
|
60
|
+
* can exhaust connection pools server-side. Mirrors the ECIES
|
|
61
|
+
* `DelegationTransport`'s 15s `AbortController`.
|
|
62
|
+
*/
|
|
63
|
+
timeoutSeconds?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Injectable fetch implementation. Defaults to the global `fetch`
|
|
66
|
+
* (Node >= 18). Pass a capturing fake in tests.
|
|
67
|
+
*/
|
|
68
|
+
http?: HttpFetch;
|
|
69
|
+
/**
|
|
70
|
+
* OIDC issuer whose `${issuer}/.well-known/jwks.json` publishes the key that
|
|
71
|
+
* signs management responses (the same JWKS that verifies ID tokens).
|
|
72
|
+
* Defaults to `baseUrl` — in every Hawcx environment the management API and
|
|
73
|
+
* the OP share one public host (e.g. `https://api.hawcx.com`). Set this
|
|
74
|
+
* only when they differ (e.g. local dev with separate ports). Same URL
|
|
75
|
+
* rules as `baseUrl`.
|
|
76
|
+
*/
|
|
77
|
+
issuer?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Verify the `X-Response-Signature` JWS on every successful response
|
|
80
|
+
* (default: `true`). A response with a missing or invalid signature is
|
|
81
|
+
* rejected with `DelegationResponseError`.
|
|
82
|
+
*
|
|
83
|
+
* Failures throw `ResponseSignatureInvalidError` (bad/missing signature) or
|
|
84
|
+
* `ResponseSignatureUnavailableError` (signing keys could not be fetched —
|
|
85
|
+
* the request may already have been applied server-side); both extend
|
|
86
|
+
* `DelegationResponseError` and expose a stable `code`.
|
|
87
|
+
*
|
|
88
|
+
* **Emergency escape hatch only.** Setting `false` makes the SDK trust any
|
|
89
|
+
* response body it receives, unauthenticated; a one-time process warning is
|
|
90
|
+
* emitted. Do not disable in production.
|
|
91
|
+
*/
|
|
92
|
+
verifyResponseSignature?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Allowed difference in seconds between local time and the response
|
|
95
|
+
* signature's `iat` (default: 300) — same default as the ECIES transport's
|
|
96
|
+
* response timestamp skew.
|
|
97
|
+
*/
|
|
98
|
+
clockSkewSeconds?: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Drop-in transport for the management API that authenticates via a per-request
|
|
102
|
+
* EdDSA JWT (`private_key_jwt`, Path A).
|
|
103
|
+
*
|
|
104
|
+
* The `send` method has the same name and signature as `DelegationTransport.send`
|
|
105
|
+
* so the factory can swap transports behind `StepUpClient` without any change to
|
|
106
|
+
* the business-method layer.
|
|
107
|
+
*/
|
|
108
|
+
export declare class JwtBearerTransport {
|
|
109
|
+
private readonly baseUrl;
|
|
110
|
+
private readonly configId;
|
|
111
|
+
private readonly kid;
|
|
112
|
+
private readonly clientId;
|
|
113
|
+
private readonly jwtTtlSeconds;
|
|
114
|
+
private readonly timeoutMs;
|
|
115
|
+
private readonly http;
|
|
116
|
+
/** `null` only when verification was explicitly disabled. */
|
|
117
|
+
private readonly responseVerifier;
|
|
118
|
+
/** Lazily-imported (and memoised) signing key. */
|
|
119
|
+
private keyPromise?;
|
|
120
|
+
private readonly importKeyFn;
|
|
121
|
+
constructor(options: JwtBearerTransportOptions);
|
|
122
|
+
private importKey;
|
|
123
|
+
/**
|
|
124
|
+
* Mint a per-request EdDSA JWT.
|
|
125
|
+
*
|
|
126
|
+
* @param audience - full request URL (base joined with endpoint), pinned per-call
|
|
127
|
+
* @param bodySha256 - base64url (no-padding) of SHA-256 over the exact body bytes
|
|
128
|
+
*/
|
|
129
|
+
private mintJwt;
|
|
130
|
+
/**
|
|
131
|
+
* Send an authenticated POST to `endpoint` with `payload` serialised as JSON.
|
|
132
|
+
*
|
|
133
|
+
* Matches the signature of `DelegationTransport.send` so the two transports
|
|
134
|
+
* are drop-in substitutes behind `StepUpClient`.
|
|
135
|
+
*
|
|
136
|
+
* @param options.endpoint - API path relative to `baseUrl`, e.g.
|
|
137
|
+
* `/v1/management/stepup/start-token`. Must start with a single `/`;
|
|
138
|
+
* anything that would resolve off the configured origin or out of its path
|
|
139
|
+
* prefix throws `DelegationRequestError` before the request is signed.
|
|
140
|
+
* @param options.payload - request body; serialised once and hashed; the
|
|
141
|
+
* EXACT same bytes are sent and hashed — no double-serialisation.
|
|
142
|
+
* @param options.headers - optional extra headers merged last (win over built-ins)
|
|
143
|
+
*/
|
|
144
|
+
send<TReq extends JsonObject, TRes extends JsonObject>(options: {
|
|
145
|
+
endpoint: string;
|
|
146
|
+
payload: TReq;
|
|
147
|
+
headers?: Record<string, string>;
|
|
148
|
+
}): Promise<TRes>;
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=jwt_transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwt_transport.d.ts","sourceRoot":"","sources":["../../src/management/jwt_transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAmC,KAAK,GAAG,EAAE,MAAM,MAAM,CAAC;AACjE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAc1D;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE9E,MAAM,WAAW,yBAAyB;IACxC;;;;;;OAMG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,4DAA4D;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,cAAc,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC;IACvC,+CAA+C;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;OAaG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgB;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAY;IACjC,6DAA6D;IAC7D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAmC;IAEpE,kDAAkD;IAClD,OAAO,CAAC,UAAU,CAAC,CAAuB;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA6B;gBAE7C,OAAO,EAAE,yBAAyB;IAgE9C,OAAO,CAAC,SAAS;IAajB;;;;;OAKG;YACW,OAAO;IAiBrB;;;;;;;;;;;;;OAaG;IACG,IAAI,CAAC,IAAI,SAAS,UAAU,EAAE,IAAI,SAAS,UAAU,EAAE,OAAO,EAAE;QACpE,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,IAAI,CAAC;QACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KAClC,GAAG,OAAO,CAAC,IAAI,CAAC;CAgGlB"}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* JWT-bearer transport for the Hawcx management API (Path A: private_key_jwt).
|
|
4
|
+
*
|
|
5
|
+
* Each request is authenticated with a per-request EdDSA JWT whose `aud` is
|
|
6
|
+
* the full request URL, carrying a `body_sha256` claim over the EXACT bytes
|
|
7
|
+
* sent. This mirrors the wire contract verified end-to-end against hx_auth
|
|
8
|
+
* (see spec.md §management-api-auth / Python SDK Path A implementation).
|
|
9
|
+
*
|
|
10
|
+
* Construction:
|
|
11
|
+
* const transport = new JwtBearerTransport({
|
|
12
|
+
* baseUrl: 'https://api.hawcx.com',
|
|
13
|
+
* configId: 'your-config-id',
|
|
14
|
+
* clientId: 'your-client-id',
|
|
15
|
+
* oidcSigningKey: pkcs8Pem, // Ed25519 PKCS#8 PEM — same key registered in admin console
|
|
16
|
+
* kid: 'key-id',
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* The `http` constructor option accepts an injectable fetch-compatible function
|
|
20
|
+
* (same signature as the global `fetch`) for testing.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.JwtBearerTransport = void 0;
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const node_crypto_2 = require("node:crypto");
|
|
26
|
+
const jose_1 = require("jose");
|
|
27
|
+
const exceptions_1 = require("../delegation/exceptions");
|
|
28
|
+
const url_1 = require("../internal/url");
|
|
29
|
+
const response_signature_1 = require("./response_signature");
|
|
30
|
+
/** Config/URL errors surface as `DelegationRequestError`, like other request failures. */
|
|
31
|
+
const urlError = (message, cause) => new exceptions_1.DelegationRequestError(message, cause === undefined ? {} : { cause });
|
|
32
|
+
/** Emit the "verification disabled" warning at most once per process. */
|
|
33
|
+
let responseSignatureDisabledWarned = false;
|
|
34
|
+
/**
|
|
35
|
+
* Drop-in transport for the management API that authenticates via a per-request
|
|
36
|
+
* EdDSA JWT (`private_key_jwt`, Path A).
|
|
37
|
+
*
|
|
38
|
+
* The `send` method has the same name and signature as `DelegationTransport.send`
|
|
39
|
+
* so the factory can swap transports behind `StepUpClient` without any change to
|
|
40
|
+
* the business-method layer.
|
|
41
|
+
*/
|
|
42
|
+
class JwtBearerTransport {
|
|
43
|
+
constructor(options) {
|
|
44
|
+
// Parsed and validated once (TOB-HSDK-13): every signed request is built
|
|
45
|
+
// from this structured origin + path prefix, never by string concatenation.
|
|
46
|
+
this.baseUrl = (0, url_1.parseSecureBaseUrl)('baseUrl', options.baseUrl, urlError);
|
|
47
|
+
this.configId = options.configId;
|
|
48
|
+
this.kid = options.kid;
|
|
49
|
+
this.clientId = options.clientId;
|
|
50
|
+
this.jwtTtlSeconds = options.jwtTtlSeconds ?? 60;
|
|
51
|
+
this.timeoutMs = (options.timeoutSeconds ?? 15) * 1000;
|
|
52
|
+
this.http = options.http ?? ((...args) => fetch(...args));
|
|
53
|
+
// Response authentication (TOB-HSDK-11). The JWKS URL is derived from the
|
|
54
|
+
// validated issuer with the same structured join as request URLs.
|
|
55
|
+
//
|
|
56
|
+
// DEFAULT ISSUER = baseUrl, i.e. JWKS = ${baseUrl}/.well-known/jwks.json,
|
|
57
|
+
// fetched without X-Config-Id. This holds today because the OP's single
|
|
58
|
+
// global ISSUER is the management API host and its global JWKS carries the
|
|
59
|
+
// response-signing kid. REVISIT under hawcx_core_oauth#67 (per-tenant
|
|
60
|
+
// issuer / discovery_mode cutover): if a tenant's signing kid moves to a
|
|
61
|
+
// per-tenant JWKS, this default stops containing it and every management
|
|
62
|
+
// call fails closed (ResponseSignatureUnavailable/Invalid). Callers can
|
|
63
|
+
// already override via the `issuer` option; whoever lands #67 must update
|
|
64
|
+
// this default (e.g. derive a per-tenant issuer) and the README.
|
|
65
|
+
const issuer = options.issuer === undefined
|
|
66
|
+
? this.baseUrl
|
|
67
|
+
: (0, url_1.parseSecureBaseUrl)('issuer', options.issuer, urlError);
|
|
68
|
+
if (options.verifyResponseSignature === false) {
|
|
69
|
+
this.responseVerifier = null;
|
|
70
|
+
if (!responseSignatureDisabledWarned) {
|
|
71
|
+
responseSignatureDisabledWarned = true;
|
|
72
|
+
process.emitWarning('[@hawcx/oauth-client] JwtBearerTransport: verifyResponseSignature is disabled — ' +
|
|
73
|
+
'management API responses will NOT be authenticated. Use only as a temporary escape hatch.', { type: 'Warning', code: 'HAWCX_RESPONSE_SIGNATURE_DISABLED' });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const clockSkewSeconds = options.clockSkewSeconds ?? 300;
|
|
78
|
+
if (!Number.isFinite(clockSkewSeconds) || clockSkewSeconds < 0) {
|
|
79
|
+
throw new exceptions_1.DelegationRequestError('clockSkewSeconds must be a non-negative number');
|
|
80
|
+
}
|
|
81
|
+
this.responseVerifier = new response_signature_1.ResponseSignatureVerifier({
|
|
82
|
+
jwksUri: (0, url_1.safeJoinUrl)(issuer, '/.well-known/jwks.json', 'JWKS URL', urlError),
|
|
83
|
+
clockSkewSeconds,
|
|
84
|
+
timeoutMs: this.timeoutMs,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const key = options.oidcSigningKey;
|
|
88
|
+
if (typeof key === 'string') {
|
|
89
|
+
// PKCS#8 PEM
|
|
90
|
+
this.importKeyFn = () => (0, jose_1.importPKCS8)(key, 'EdDSA');
|
|
91
|
+
}
|
|
92
|
+
else if (isJwk(key)) {
|
|
93
|
+
// Plain JWK object
|
|
94
|
+
this.importKeyFn = () => (0, jose_1.importJWK)(key, 'EdDSA');
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Already a jose KeyLike / CryptoKey — wrap in a resolved promise.
|
|
98
|
+
const resolved = Promise.resolve(key);
|
|
99
|
+
this.importKeyFn = () => resolved;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ── Key loading ────────────────────────────────────────────────────────────
|
|
103
|
+
importKey() {
|
|
104
|
+
if (!this.keyPromise) {
|
|
105
|
+
// Don't memoise a rejection — reset so the next call retries.
|
|
106
|
+
this.keyPromise = this.importKeyFn().catch((err) => {
|
|
107
|
+
this.keyPromise = undefined;
|
|
108
|
+
throw err;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return this.keyPromise;
|
|
112
|
+
}
|
|
113
|
+
// ── JWT minting ────────────────────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* Mint a per-request EdDSA JWT.
|
|
116
|
+
*
|
|
117
|
+
* @param audience - full request URL (base joined with endpoint), pinned per-call
|
|
118
|
+
* @param bodySha256 - base64url (no-padding) of SHA-256 over the exact body bytes
|
|
119
|
+
*/
|
|
120
|
+
async mintJwt(audience, bodySha256) {
|
|
121
|
+
const key = await this.importKey();
|
|
122
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
123
|
+
return new jose_1.SignJWT({ body_sha256: bodySha256 })
|
|
124
|
+
.setProtectedHeader({ alg: 'EdDSA', kid: this.kid, typ: 'JWT' })
|
|
125
|
+
.setIssuer(this.clientId)
|
|
126
|
+
.setSubject(this.clientId)
|
|
127
|
+
.setAudience(audience)
|
|
128
|
+
.setIssuedAt(nowSec)
|
|
129
|
+
.setExpirationTime(nowSec + this.jwtTtlSeconds)
|
|
130
|
+
.setJti((0, node_crypto_1.randomUUID)())
|
|
131
|
+
.sign(key);
|
|
132
|
+
}
|
|
133
|
+
// ── Transport ──────────────────────────────────────────────────────────────
|
|
134
|
+
/**
|
|
135
|
+
* Send an authenticated POST to `endpoint` with `payload` serialised as JSON.
|
|
136
|
+
*
|
|
137
|
+
* Matches the signature of `DelegationTransport.send` so the two transports
|
|
138
|
+
* are drop-in substitutes behind `StepUpClient`.
|
|
139
|
+
*
|
|
140
|
+
* @param options.endpoint - API path relative to `baseUrl`, e.g.
|
|
141
|
+
* `/v1/management/stepup/start-token`. Must start with a single `/`;
|
|
142
|
+
* anything that would resolve off the configured origin or out of its path
|
|
143
|
+
* prefix throws `DelegationRequestError` before the request is signed.
|
|
144
|
+
* @param options.payload - request body; serialised once and hashed; the
|
|
145
|
+
* EXACT same bytes are sent and hashed — no double-serialisation.
|
|
146
|
+
* @param options.headers - optional extra headers merged last (win over built-ins)
|
|
147
|
+
*/
|
|
148
|
+
async send(options) {
|
|
149
|
+
const { endpoint, payload, headers: extraHeaders = {} } = options;
|
|
150
|
+
// Canonicalise body ONCE. The same Buffer is both hashed and sent.
|
|
151
|
+
const bodyBytes = Buffer.from(JSON.stringify(payload));
|
|
152
|
+
// SHA-256 → base64url without padding (RFC 7515 §2).
|
|
153
|
+
const hash = (0, node_crypto_2.createHash)('sha256').update(bodyBytes).digest();
|
|
154
|
+
const bodySha256 = hash.toString('base64url').replace(/=+$/, '');
|
|
155
|
+
// Join structurally and assert the result stays on the configured origin
|
|
156
|
+
// BEFORE signing: the JWT `aud` and the request target are this one value.
|
|
157
|
+
const url = (0, url_1.safeJoinUrl)(this.baseUrl, endpoint, 'endpoint', urlError);
|
|
158
|
+
let jwt;
|
|
159
|
+
try {
|
|
160
|
+
jwt = await this.mintJwt(url, bodySha256);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
throw new exceptions_1.DelegationRequestError('Failed to sign management request', { cause: err });
|
|
164
|
+
}
|
|
165
|
+
// Extra headers are merged first so the transport's own auth/routing
|
|
166
|
+
// headers (Authorization, X-Config-Id, X-Hawcx-Protocol-Version) cannot
|
|
167
|
+
// be accidentally overridden by the caller's header bag.
|
|
168
|
+
const requestHeaders = {
|
|
169
|
+
...extraHeaders,
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
'Authorization': `Bearer ${jwt}`,
|
|
172
|
+
'X-Config-Id': this.configId,
|
|
173
|
+
'X-Hawcx-Protocol-Version': '2',
|
|
174
|
+
};
|
|
175
|
+
let response;
|
|
176
|
+
let responseData;
|
|
177
|
+
// Bound every request with a timeout: a connected-but-silent server would
|
|
178
|
+
// otherwise hang the call indefinitely and can exhaust connection pools.
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
181
|
+
try {
|
|
182
|
+
response = await this.http(url, {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
headers: requestHeaders,
|
|
185
|
+
body: bodyBytes,
|
|
186
|
+
signal: controller.signal,
|
|
187
|
+
});
|
|
188
|
+
responseData = Buffer.from(await response.arrayBuffer());
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err instanceof exceptions_1.DelegationRequestError)
|
|
192
|
+
throw err;
|
|
193
|
+
throw new exceptions_1.DelegationRequestError('Request to management API failed', { cause: err });
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
}
|
|
198
|
+
const text = responseData.toString('utf-8');
|
|
199
|
+
// Check the HTTP status BEFORE parsing JSON. A non-JSON error body (e.g. a
|
|
200
|
+
// WAF/proxy HTML 403) must still surface its status as a
|
|
201
|
+
// DelegationRequestError — not get masked as a JSON parse failure that the
|
|
202
|
+
// caller can't distinguish from a malformed 200.
|
|
203
|
+
if (response.status >= 400) {
|
|
204
|
+
let errorBody = text;
|
|
205
|
+
try {
|
|
206
|
+
const errJson = JSON.parse(text);
|
|
207
|
+
if (errJson?.detail)
|
|
208
|
+
errorBody = String(errJson.detail);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Non-JSON error body — keep the raw text.
|
|
212
|
+
}
|
|
213
|
+
throw new exceptions_1.DelegationRequestError(`Management API returned ${response.status}`, {
|
|
214
|
+
statusCode: response.status,
|
|
215
|
+
responseBody: errorBody,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Authenticate every response we are about to return as success — any
|
|
219
|
+
// status below 400, i.e. every 2xx (and any stray 1xx/3xx, which the
|
|
220
|
+
// server never sends signed and so fail closed). Error responses (>= 400)
|
|
221
|
+
// are unsigned by design and were rejected above. The hash covers the raw
|
|
222
|
+
// bytes from `arrayBuffer()`, not `text`: UTF-8 decoding replaces invalid
|
|
223
|
+
// sequences and would let distinct byte strings hash the same.
|
|
224
|
+
if (this.responseVerifier !== null) {
|
|
225
|
+
await this.responseVerifier.verify(response.headers.get(response_signature_1.RESPONSE_SIGNATURE_HEADER), responseData);
|
|
226
|
+
}
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(text);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
throw new exceptions_1.DelegationResponseError('Failed to parse management API response', { cause: err });
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.JwtBearerTransport = JwtBearerTransport;
|
|
238
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
239
|
+
/** Narrow check: is this value a plain JWK object (has `kty` string)? */
|
|
240
|
+
function isJwk(value) {
|
|
241
|
+
return (typeof value === 'object' &&
|
|
242
|
+
value !== null &&
|
|
243
|
+
typeof value.kty === 'string');
|
|
244
|
+
}
|
|
245
|
+
//# sourceMappingURL=jwt_transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"jwt_transport.js","sourceRoot":"","sources":["../../src/management/jwt_transport.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verification of the management API's `X-Response-Signature` (TOB-HSDK-11).
|
|
3
|
+
*
|
|
4
|
+
* Every successful jwt-mode (Path A) management response from hx_auth carries
|
|
5
|
+
* `X-Response-Signature`: a compact JWS signed with the shared OAuth signing
|
|
6
|
+
* key that the OP publishes at `${issuer}/.well-known/jwks.json` — the same
|
|
7
|
+
* JWKS that verifies ID tokens. Its payload is exactly
|
|
8
|
+
*
|
|
9
|
+
* { "body_sha256": base64url(sha256(<exact response body bytes>)), "iat": <unix s> }
|
|
10
|
+
*
|
|
11
|
+
* Verifying it restores the response authentication the legacy ECIES
|
|
12
|
+
* transport always had (signature + timestamp skew on every response).
|
|
13
|
+
*
|
|
14
|
+
* Checks, all fail-closed:
|
|
15
|
+
* - the header is present and is a compact JWS whose protected header names
|
|
16
|
+
* a `kid`;
|
|
17
|
+
* - `alg` is one of ES256 / ES384 / ES512 (the server's KMS key is an EC key
|
|
18
|
+
* and the alg follows its curve). `none`, HS*, RS*, EdDSA are rejected —
|
|
19
|
+
* this forecloses alg-substitution / key-confusion;
|
|
20
|
+
* - the signature verifies against the issuer JWKS (jose `RemoteJWKSet`:
|
|
21
|
+
* cached, and refetched once when the `kid` is unknown — key rotation);
|
|
22
|
+
* - `iat` is present and within ±clockSkewSeconds of local time;
|
|
23
|
+
* - the payload is a response signature, not some other token minted with
|
|
24
|
+
* the same key (TOB-HSDK-9 type confusion): `body_sha256` is required and
|
|
25
|
+
* `aud`, `sub`, `nonce` must be absent — an ID token always carries
|
|
26
|
+
* `aud`/`sub`;
|
|
27
|
+
* - `body_sha256` equals base64url(sha256(raw body bytes)), compared in
|
|
28
|
+
* constant time. The hash is taken over the raw bytes as received, before
|
|
29
|
+
* any decoding or JSON parsing.
|
|
30
|
+
*
|
|
31
|
+
* Failures are split so callers can react correctly:
|
|
32
|
+
* - {@link ResponseSignatureInvalidError} (`code: 'response_signature_invalid'`)
|
|
33
|
+
* — missing/empty header or a signature that does not verify;
|
|
34
|
+
* - {@link ResponseSignatureUnavailableError}
|
|
35
|
+
* (`code: 'response_signature_unavailable'`) — the signing keys could not
|
|
36
|
+
* be obtained (JWKS fetch failed / timed out / non-200 / malformed, or the
|
|
37
|
+
* kid is unknown while jose's refetch cooldown suppresses a refetch). The
|
|
38
|
+
* request may already have been applied server-side.
|
|
39
|
+
* Both subclass `DelegationResponseError` and carry fixed, non-leaky messages.
|
|
40
|
+
*
|
|
41
|
+
* Not part of the public API (not re-exported from the package index).
|
|
42
|
+
*/
|
|
43
|
+
/** Response-signature header name (HTTP headers are case-insensitive). */
|
|
44
|
+
export declare const RESPONSE_SIGNATURE_HEADER = "X-Response-Signature";
|
|
45
|
+
export interface ResponseSignatureVerifierOptions {
|
|
46
|
+
/** Absolute, already-validated JWKS URL (`${issuer}/.well-known/jwks.json`). */
|
|
47
|
+
jwksUri: string;
|
|
48
|
+
/** Allowed |now − iat| in seconds. */
|
|
49
|
+
clockSkewSeconds: number;
|
|
50
|
+
/** Bound on each JWKS fetch, in ms. */
|
|
51
|
+
timeoutMs: number;
|
|
52
|
+
}
|
|
53
|
+
export declare class ResponseSignatureVerifier {
|
|
54
|
+
private readonly jwks;
|
|
55
|
+
private readonly clockSkewSeconds;
|
|
56
|
+
constructor(options: ResponseSignatureVerifierOptions);
|
|
57
|
+
/**
|
|
58
|
+
* Verify `signature` (the header value, or null when absent) over the EXACT
|
|
59
|
+
* response body bytes. Resolves on success; throws
|
|
60
|
+
* {@link ResponseSignatureInvalidError} or
|
|
61
|
+
* {@link ResponseSignatureUnavailableError} otherwise.
|
|
62
|
+
*/
|
|
63
|
+
verify(signature: string | null, body: Uint8Array): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=response_signature.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"response_signature.d.ts","sourceRoot":"","sources":["../../src/management/response_signature.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AASH,0EAA0E;AAC1E,eAAO,MAAM,yBAAyB,yBAAyB,CAAC;AAoBhE,MAAM,WAAW,gCAAgC;IAC/C,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,gBAAgB,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,yBAAyB;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAwC;IAC7D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;gBAE9B,OAAO,EAAE,gCAAgC;IAUrD;;;;;OAKG;IACG,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC;CAgGxE"}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Verification of the management API's `X-Response-Signature` (TOB-HSDK-11).
|
|
4
|
+
*
|
|
5
|
+
* Every successful jwt-mode (Path A) management response from hx_auth carries
|
|
6
|
+
* `X-Response-Signature`: a compact JWS signed with the shared OAuth signing
|
|
7
|
+
* key that the OP publishes at `${issuer}/.well-known/jwks.json` — the same
|
|
8
|
+
* JWKS that verifies ID tokens. Its payload is exactly
|
|
9
|
+
*
|
|
10
|
+
* { "body_sha256": base64url(sha256(<exact response body bytes>)), "iat": <unix s> }
|
|
11
|
+
*
|
|
12
|
+
* Verifying it restores the response authentication the legacy ECIES
|
|
13
|
+
* transport always had (signature + timestamp skew on every response).
|
|
14
|
+
*
|
|
15
|
+
* Checks, all fail-closed:
|
|
16
|
+
* - the header is present and is a compact JWS whose protected header names
|
|
17
|
+
* a `kid`;
|
|
18
|
+
* - `alg` is one of ES256 / ES384 / ES512 (the server's KMS key is an EC key
|
|
19
|
+
* and the alg follows its curve). `none`, HS*, RS*, EdDSA are rejected —
|
|
20
|
+
* this forecloses alg-substitution / key-confusion;
|
|
21
|
+
* - the signature verifies against the issuer JWKS (jose `RemoteJWKSet`:
|
|
22
|
+
* cached, and refetched once when the `kid` is unknown — key rotation);
|
|
23
|
+
* - `iat` is present and within ±clockSkewSeconds of local time;
|
|
24
|
+
* - the payload is a response signature, not some other token minted with
|
|
25
|
+
* the same key (TOB-HSDK-9 type confusion): `body_sha256` is required and
|
|
26
|
+
* `aud`, `sub`, `nonce` must be absent — an ID token always carries
|
|
27
|
+
* `aud`/`sub`;
|
|
28
|
+
* - `body_sha256` equals base64url(sha256(raw body bytes)), compared in
|
|
29
|
+
* constant time. The hash is taken over the raw bytes as received, before
|
|
30
|
+
* any decoding or JSON parsing.
|
|
31
|
+
*
|
|
32
|
+
* Failures are split so callers can react correctly:
|
|
33
|
+
* - {@link ResponseSignatureInvalidError} (`code: 'response_signature_invalid'`)
|
|
34
|
+
* — missing/empty header or a signature that does not verify;
|
|
35
|
+
* - {@link ResponseSignatureUnavailableError}
|
|
36
|
+
* (`code: 'response_signature_unavailable'`) — the signing keys could not
|
|
37
|
+
* be obtained (JWKS fetch failed / timed out / non-200 / malformed, or the
|
|
38
|
+
* kid is unknown while jose's refetch cooldown suppresses a refetch). The
|
|
39
|
+
* request may already have been applied server-side.
|
|
40
|
+
* Both subclass `DelegationResponseError` and carry fixed, non-leaky messages.
|
|
41
|
+
*
|
|
42
|
+
* Not part of the public API (not re-exported from the package index).
|
|
43
|
+
*/
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.ResponseSignatureVerifier = exports.RESPONSE_SIGNATURE_HEADER = void 0;
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const jose_1 = require("jose");
|
|
48
|
+
const exceptions_1 = require("../delegation/exceptions");
|
|
49
|
+
/** Response-signature header name (HTTP headers are case-insensitive). */
|
|
50
|
+
exports.RESPONSE_SIGNATURE_HEADER = 'X-Response-Signature';
|
|
51
|
+
/** EC algorithms the server's KMS signing key can produce — nothing else. */
|
|
52
|
+
const ALLOWED_ALGS = Object.freeze(['ES256', 'ES384', 'ES512']);
|
|
53
|
+
/**
|
|
54
|
+
* Claims that mark a token as something other than a response signature.
|
|
55
|
+
* Deliberately `aud` is forbidden rather than repurposed: request binding
|
|
56
|
+
* (anti-replay within the iat window) is designed server-side in
|
|
57
|
+
* hawcx/hx_auth#278 as dedicated custom claims (`req_jti` / `htm` / `htu` /
|
|
58
|
+
* `cid`), explicitly NOT `aud` — keep this list as is.
|
|
59
|
+
*/
|
|
60
|
+
const FORBIDDEN_CLAIMS = Object.freeze(['aud', 'sub', 'nonce']);
|
|
61
|
+
/** Generic, non-leaky messages; detail (if any) is attached as `cause`. */
|
|
62
|
+
const MSG_MISSING = 'Management API response is missing its signature';
|
|
63
|
+
const MSG_INVALID = 'Management API response signature verification failed';
|
|
64
|
+
const MSG_UNAVAILABLE = 'Management API response signature could not be verified: signing keys unavailable';
|
|
65
|
+
class ResponseSignatureVerifier {
|
|
66
|
+
constructor(options) {
|
|
67
|
+
// jose handles caching (10 min max age), concurrent-fetch coalescing, and a
|
|
68
|
+
// single refetch on an unknown `kid` (rate-limited by its 30s cooldown) —
|
|
69
|
+
// the same machinery HawcxOAuth uses for ID-token verification.
|
|
70
|
+
this.jwks = (0, jose_1.createRemoteJWKSet)(new URL(options.jwksUri), {
|
|
71
|
+
timeoutDuration: options.timeoutMs,
|
|
72
|
+
});
|
|
73
|
+
this.clockSkewSeconds = options.clockSkewSeconds;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Verify `signature` (the header value, or null when absent) over the EXACT
|
|
77
|
+
* response body bytes. Resolves on success; throws
|
|
78
|
+
* {@link ResponseSignatureInvalidError} or
|
|
79
|
+
* {@link ResponseSignatureUnavailableError} otherwise.
|
|
80
|
+
*/
|
|
81
|
+
async verify(signature, body) {
|
|
82
|
+
if (!signature) {
|
|
83
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_MISSING);
|
|
84
|
+
}
|
|
85
|
+
// Set by the key resolver when the keys could not be obtained, as opposed
|
|
86
|
+
// to a signature that was checked and found bad.
|
|
87
|
+
let keysUnavailable = false;
|
|
88
|
+
let payloadBytes;
|
|
89
|
+
try {
|
|
90
|
+
const result = await (0, jose_1.compactVerify)(signature, async (protectedHeader, token) => {
|
|
91
|
+
if (typeof protectedHeader.kid !== 'string' || protectedHeader.kid.length === 0) {
|
|
92
|
+
throw new Error('response signature has no kid');
|
|
93
|
+
}
|
|
94
|
+
// If jose may refetch on a miss (not cooling down), a miss means the
|
|
95
|
+
// kid is absent from a freshly fetched JWKS → invalid. If the
|
|
96
|
+
// cooldown suppresses the refetch, the kid may simply be newer than
|
|
97
|
+
// our cache (rotation) → unavailable.
|
|
98
|
+
const canRefetch = !this.jwks.coolingDown;
|
|
99
|
+
try {
|
|
100
|
+
return await this.jwks(protectedHeader, token);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
if (err instanceof jose_1.errors.JWKSNoMatchingKey) {
|
|
104
|
+
keysUnavailable = !canRefetch;
|
|
105
|
+
}
|
|
106
|
+
else if (!(err instanceof jose_1.errors.JWKSMultipleMatchingKeys)) {
|
|
107
|
+
// Fetch failure, timeout, non-200, malformed JWKS, network error.
|
|
108
|
+
keysUnavailable = true;
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}, { algorithms: [...ALLOWED_ALGS] });
|
|
113
|
+
payloadBytes = result.payload;
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
if (keysUnavailable) {
|
|
117
|
+
throw new exceptions_1.ResponseSignatureUnavailableError(MSG_UNAVAILABLE, { cause: err });
|
|
118
|
+
}
|
|
119
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, { cause: err });
|
|
120
|
+
}
|
|
121
|
+
let claims;
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(Buffer.from(payloadBytes).toString('utf-8'));
|
|
124
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
125
|
+
throw new Error('payload is not a JSON object');
|
|
126
|
+
}
|
|
127
|
+
claims = parsed;
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, { cause: err });
|
|
131
|
+
}
|
|
132
|
+
// Type-confusion guard (TOB-HSDK-9): only a response signature qualifies.
|
|
133
|
+
for (const claim of FORBIDDEN_CLAIMS) {
|
|
134
|
+
if (Object.prototype.hasOwnProperty.call(claims, claim)) {
|
|
135
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, {
|
|
136
|
+
cause: new Error(`unexpected '${claim}' claim in response signature`),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const claimedHash = claims['body_sha256'];
|
|
141
|
+
if (typeof claimedHash !== 'string' || claimedHash.length === 0) {
|
|
142
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, {
|
|
143
|
+
cause: new Error("response signature has no 'body_sha256' claim"),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// Freshness: `iat` required, within the configured skew either way.
|
|
147
|
+
const iat = claims['iat'];
|
|
148
|
+
if (typeof iat !== 'number' || !Number.isFinite(iat)) {
|
|
149
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, {
|
|
150
|
+
cause: new Error("response signature has no numeric 'iat' claim"),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const nowSec = Date.now() / 1000;
|
|
154
|
+
if (iat > nowSec + this.clockSkewSeconds || iat < nowSec - this.clockSkewSeconds) {
|
|
155
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, {
|
|
156
|
+
cause: new Error(`response signature 'iat' outside allowed skew (${this.clockSkewSeconds}s)`),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Body binding: base64url(sha256(raw bytes)), no padding, constant-time.
|
|
160
|
+
const actual = Buffer.from((0, node_crypto_1.createHash)('sha256').update(body).digest('base64url').replace(/=+$/, ''), 'utf-8');
|
|
161
|
+
const claimed = Buffer.from(claimedHash, 'utf-8');
|
|
162
|
+
if (actual.length !== claimed.length || !(0, node_crypto_1.timingSafeEqual)(actual, claimed)) {
|
|
163
|
+
throw new exceptions_1.ResponseSignatureInvalidError(MSG_INVALID, {
|
|
164
|
+
cause: new Error('response body does not match the signed body_sha256'),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
exports.ResponseSignatureVerifier = ResponseSignatureVerifier;
|
|
170
|
+
//# sourceMappingURL=response_signature.js.map
|