@idpartners/client-attestation-sdk 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attester.d.ts +9 -3
- package/dist/attester.js +4 -2
- package/dist/credential.d.ts +2 -0
- package/dist/credential.js +2 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/svid-source.d.ts +4 -3
- package/dist/svid-source.js +10 -13
- package/dist/token-source.d.ts +1 -0
- package/dist/token-source.js +56 -20
- package/dist/validator/config.d.ts +28 -4
- package/dist/validator/config.js +12 -0
- package/dist/validator/index.d.ts +2 -2
- package/dist/validator/index.js +2 -2
- package/dist/validator/jwks.js +36 -2
- package/dist/validator/validator.js +8 -2
- package/dist/www-authenticate.d.ts +7 -0
- package/dist/www-authenticate.js +114 -0
- package/package.json +2 -1
package/dist/attester.d.ts
CHANGED
|
@@ -7,8 +7,14 @@ export interface AttesterMetadata {
|
|
|
7
7
|
attestationEndpoint?: string;
|
|
8
8
|
/** The authorization server's token endpoint. A profile fills in its own fallback. */
|
|
9
9
|
tokenEndpoint?: string;
|
|
10
|
-
/** The
|
|
10
|
+
/** The attester's challenge endpoint (CAS section 4.1), if the attester issues challenges for the mint. */
|
|
11
11
|
challengeEndpoint?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The authorization server's challenge endpoint (draft -11 section 6.3), if it issues challenges for
|
|
14
|
+
* the token request: `POST`ed before each token request, and its `attestation_challenge` carried as
|
|
15
|
+
* the PoP JWT's `challenge` claim or the DPoP proof's `nonce`. PingFederate: `/federation/attestation-challenge`.
|
|
16
|
+
*/
|
|
17
|
+
tokenChallengeEndpoint?: string;
|
|
12
18
|
/** The Client Attestation PoP `aud` (the AS issuer); falls back to the token endpoint URL. */
|
|
13
19
|
popAudience?: string;
|
|
14
20
|
/** The raw discovery document, when there was one; returned by `ClientAttestation.discovery()`. */
|
|
@@ -85,8 +91,8 @@ export declare const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt"
|
|
|
85
91
|
* The attester this SDK was built against (PingFederate), and the default.
|
|
86
92
|
*
|
|
87
93
|
* - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
|
|
88
|
-
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and
|
|
89
|
-
* `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
94
|
+
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint`, `token_challenge_endpoint` and
|
|
95
|
+
* `pop_audience`. With no `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
90
96
|
* - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
|
|
91
97
|
* [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
|
|
92
98
|
* [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
|
package/dist/attester.js
CHANGED
|
@@ -86,8 +86,8 @@ export const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
|
|
|
86
86
|
* The attester this SDK was built against (PingFederate), and the default.
|
|
87
87
|
*
|
|
88
88
|
* - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
|
|
89
|
-
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and
|
|
90
|
-
* `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
89
|
+
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint`, `token_challenge_endpoint` and
|
|
90
|
+
* `pop_audience`. With no `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
91
91
|
* - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
|
|
92
92
|
* [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
|
|
93
93
|
* [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
|
|
@@ -109,6 +109,7 @@ export class PingFederateProfile {
|
|
|
109
109
|
// discovery document still works from the host alone.
|
|
110
110
|
tokenEndpoint: str("token_endpoint") ?? attesterUrl + PingFederateProfile.DEFAULT_TOKEN_PATH,
|
|
111
111
|
challengeEndpoint: str("challenge_endpoint"),
|
|
112
|
+
tokenChallengeEndpoint: str("token_challenge_endpoint"),
|
|
112
113
|
popAudience: str("pop_audience"),
|
|
113
114
|
document: doc,
|
|
114
115
|
};
|
|
@@ -154,6 +155,7 @@ export function metadataDocument(metadata) {
|
|
|
154
155
|
["attestation_endpoint", metadata.attestationEndpoint],
|
|
155
156
|
["challenge_endpoint", metadata.challengeEndpoint],
|
|
156
157
|
["token_endpoint", metadata.tokenEndpoint],
|
|
158
|
+
["token_challenge_endpoint", metadata.tokenChallengeEndpoint],
|
|
157
159
|
["pop_audience", metadata.popAudience],
|
|
158
160
|
];
|
|
159
161
|
for (const [key, value] of fields)
|
package/dist/credential.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { SigningKeyPair } from "./signing-key-pair.js";
|
|
|
2
2
|
export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
3
3
|
export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
4
4
|
export declare const DPOP_HEADER = "DPoP";
|
|
5
|
+
/** The response header a `use_attestation_challenge` refusal carries a fresh challenge in (draft -11 section 6.1). */
|
|
6
|
+
export declare const CHALLENGE_HEADER = "OAuth-Client-Attestation-Challenge";
|
|
5
7
|
/**
|
|
6
8
|
* The Attester-issued attestation JWT plus the instance key it is bound to. Produces the
|
|
7
9
|
* token-request headers — PoP-JWT mode or DPoP combined mode — minting a fresh proof each call.
|
package/dist/credential.js
CHANGED
|
@@ -6,6 +6,8 @@ import { requireText } from "./signing-key-pair.js";
|
|
|
6
6
|
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
7
7
|
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
8
8
|
export const DPOP_HEADER = "DPoP";
|
|
9
|
+
/** The response header a `use_attestation_challenge` refusal carries a fresh challenge in (draft -11 section 6.1). */
|
|
10
|
+
export const CHALLENGE_HEADER = "OAuth-Client-Attestation-Challenge";
|
|
9
11
|
/**
|
|
10
12
|
* The Attester-issued attestation JWT plus the instance key it is bound to. Produces the
|
|
11
13
|
* token-request headers — PoP-JWT mode or DPoP combined mode — minting a fresh proof each call.
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { SigningKeyPair } from "./signing-key-pair.js";
|
|
9
9
|
export { ClientAttestationBuilder, DpopProofBuilder, PopBuilder, ATTESTATION_TYP, POP_TYP, DPOP_TYP, } from "./builders.js";
|
|
10
|
-
export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, } from "./credential.js";
|
|
10
|
+
export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, CHALLENGE_HEADER, } from "./credential.js";
|
|
11
11
|
export { OpenBaoTransitSigner } from "./signer.js";
|
|
12
12
|
export type { JwsSigner } from "./signer.js";
|
|
13
13
|
export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
|
|
@@ -21,6 +21,10 @@ export type { AttesterMetadata, AttesterProfile, AttesterTransport, MintResult }
|
|
|
21
21
|
export type { TransportFailure } from "./errors.js";
|
|
22
22
|
export { NOOP_LOGGER } from "./logging.js";
|
|
23
23
|
export type { Logger, LogAttributes, LogLevel } from "./logging.js";
|
|
24
|
-
export declare const VERSION = "1.
|
|
25
|
-
/**
|
|
26
|
-
|
|
24
|
+
export declare const VERSION = "1.4.0";
|
|
25
|
+
/**
|
|
26
|
+
* draft-ietf-oauth-attestation-based-client-auth revision this port implements. -11 (the Go, Java and
|
|
27
|
+
* Python ports are at -10): the authorization server's challenge endpoint and the
|
|
28
|
+
* `use_attestation_challenge` retry, with combined mode on RFC 9449 nonces alone.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DRAFT_REVISION = "11";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export { SigningKeyPair } from "./signing-key-pair.js";
|
|
9
9
|
export { ClientAttestationBuilder, DpopProofBuilder, PopBuilder, ATTESTATION_TYP, POP_TYP, DPOP_TYP, } from "./builders.js";
|
|
10
|
-
export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, } from "./credential.js";
|
|
10
|
+
export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, CHALLENGE_HEADER, } from "./credential.js";
|
|
11
11
|
export { OpenBaoTransitSigner } from "./signer.js";
|
|
12
12
|
export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
|
|
13
13
|
export { StaticSvidSource, HttpSvidSource, SpireWorkloadApiSvidSource, } from "./svid-source.js";
|
|
@@ -15,6 +15,10 @@ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDe
|
|
|
15
15
|
export { ClientAttestation, ClientAttestationError, TransportError, DEFAULT_EXPIRY_SKEW_SECONDS, } from "./token-source.js";
|
|
16
16
|
export { MintRequest, PingFederateProfile } from "./attester.js";
|
|
17
17
|
export { NOOP_LOGGER } from "./logging.js";
|
|
18
|
-
export const VERSION = "1.
|
|
19
|
-
/**
|
|
20
|
-
|
|
18
|
+
export const VERSION = "1.4.0";
|
|
19
|
+
/**
|
|
20
|
+
* draft-ietf-oauth-attestation-based-client-auth revision this port implements. -11 (the Go, Java and
|
|
21
|
+
* Python ports are at -10): the authorization server's challenge endpoint and the
|
|
22
|
+
* `use_attestation_challenge` retry, with combined mode on RFC 9449 nonces alone.
|
|
23
|
+
*/
|
|
24
|
+
export const DRAFT_REVISION = "11";
|
package/dist/svid-source.d.ts
CHANGED
|
@@ -34,9 +34,10 @@ export declare class HttpSvidSource implements SvidSource {
|
|
|
34
34
|
* and at most 1 MiB in all (a helper the agent started can inherit the pipe and keep writing to it or hold
|
|
35
35
|
* it open), then the pipe is closed. On exit status 0 what was read is parsed; on any other status, or if
|
|
36
36
|
* the agent was killed, `fetch` rejects. So output a helper writes after the agent has exited never turns a
|
|
37
|
-
* success into a failure, and never keeps `fetch` waiting longer than that.
|
|
38
|
-
*
|
|
39
|
-
* that
|
|
37
|
+
* success into a failure, and never keeps `fetch` waiting longer than that. A token is only taken from a
|
|
38
|
+
* line that ends in a newline, whether or not stdout was read to its end: if reading stopped at the 1 MiB
|
|
39
|
+
* or at the end of the 250 ms the last line may be cut off, and no port can tell that from an agent whose
|
|
40
|
+
* last line lacks a newline (real spire-agent output always ends in one), so every port refuses it.
|
|
40
41
|
*
|
|
41
42
|
* Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
|
|
42
43
|
* a `ClientAttestationError` on a runtime without it.
|
package/dist/svid-source.js
CHANGED
|
@@ -65,9 +65,10 @@ export class HttpSvidSource {
|
|
|
65
65
|
* and at most 1 MiB in all (a helper the agent started can inherit the pipe and keep writing to it or hold
|
|
66
66
|
* it open), then the pipe is closed. On exit status 0 what was read is parsed; on any other status, or if
|
|
67
67
|
* the agent was killed, `fetch` rejects. So output a helper writes after the agent has exited never turns a
|
|
68
|
-
* success into a failure, and never keeps `fetch` waiting longer than that.
|
|
69
|
-
*
|
|
70
|
-
* that
|
|
68
|
+
* success into a failure, and never keeps `fetch` waiting longer than that. A token is only taken from a
|
|
69
|
+
* line that ends in a newline, whether or not stdout was read to its end: if reading stopped at the 1 MiB
|
|
70
|
+
* or at the end of the 250 ms the last line may be cut off, and no port can tell that from an agent whose
|
|
71
|
+
* last line lacks a newline (real spire-agent output always ends in one), so every port refuses it.
|
|
71
72
|
*
|
|
72
73
|
* Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
|
|
73
74
|
* a `ClientAttestationError` on a runtime without it.
|
|
@@ -86,7 +87,7 @@ export class SpireWorkloadApiSvidSource {
|
|
|
86
87
|
const { spawn } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
|
|
87
88
|
// stdout only: stderr goes to the null device, so a log line or version string the agent writes there
|
|
88
89
|
// is never parsed, and a flood of it is never held in memory.
|
|
89
|
-
const
|
|
90
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
90
91
|
const args = ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath];
|
|
91
92
|
const child = spawn(this.spireAgentBin, args, {
|
|
92
93
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -96,7 +97,6 @@ export class SpireWorkloadApiSvidSource {
|
|
|
96
97
|
const chunks = [];
|
|
97
98
|
let size = 0;
|
|
98
99
|
let over = false; // past the cap while the agent was running
|
|
99
|
-
let ended = false; // stdout was read to its end
|
|
100
100
|
let exited;
|
|
101
101
|
let settled = false;
|
|
102
102
|
let grace;
|
|
@@ -114,7 +114,7 @@ export class SpireWorkloadApiSvidSource {
|
|
|
114
114
|
reject(Object.assign(new Error(message), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
|
|
115
115
|
}
|
|
116
116
|
else if (code === 0) {
|
|
117
|
-
resolve(
|
|
117
|
+
resolve(concatUtf8(chunks, size));
|
|
118
118
|
}
|
|
119
119
|
else {
|
|
120
120
|
reject(spireAgentExitFailure(code, signal, child.killed));
|
|
@@ -141,10 +141,6 @@ export class SpireWorkloadApiSvidSource {
|
|
|
141
141
|
size += chunk.length;
|
|
142
142
|
chunks.push(chunk);
|
|
143
143
|
});
|
|
144
|
-
child.stdout?.on("end", () => {
|
|
145
|
-
if (!settled)
|
|
146
|
-
ended = true;
|
|
147
|
-
});
|
|
148
144
|
child.stdout?.on("error", () => { }); // the outcome comes from "exit" or "close"
|
|
149
145
|
child.on("error", (err) => {
|
|
150
146
|
if (settled)
|
|
@@ -164,9 +160,10 @@ export class SpireWorkloadApiSvidSource {
|
|
|
164
160
|
});
|
|
165
161
|
child.on("close", (code, signal) => settle(code, signal));
|
|
166
162
|
});
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
163
|
+
// A token is only taken from a line that ends in a newline: the line after the last newline may have been
|
|
164
|
+
// cut off (reading stopped at the cap or the end of the grace period), and whether stdout was read to its
|
|
165
|
+
// end can't tell that apart from an agent whose last line lacks a newline, so every port refuses it.
|
|
166
|
+
const svid = parseSpireJwtOutput(stdout.slice(0, stdout.lastIndexOf("\n") + 1));
|
|
170
167
|
if (svid)
|
|
171
168
|
return svid;
|
|
172
169
|
// The output may hold a token or bundle that failed to parse, so the error gives only its size.
|
package/dist/token-source.d.ts
CHANGED
package/dist/token-source.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { base64url } from "jose";
|
|
23
23
|
import { checkHtu, DpopProofBuilder, htuParts, normalizeHtu } from "./builders.js";
|
|
24
|
-
import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
24
|
+
import { CHALLENGE_HEADER, ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
25
25
|
import { ClientAttestationError, TransportError } from "./errors.js";
|
|
26
26
|
import { autoDetectEvidence } from "./evidence.js";
|
|
27
27
|
import { MintRequest, PingFederateProfile, metadataDocument, } from "./attester.js";
|
|
@@ -29,6 +29,7 @@ import { checkSecure, jsonOk, request, snippet } from "./http.js";
|
|
|
29
29
|
import { emit, failureReason, hostOf, NOOP_LOGGER, urlParts } from "./logging.js";
|
|
30
30
|
import { monotonicMs, utf8Decode } from "./runtime.js";
|
|
31
31
|
import { SigningKeyPair } from "./signing-key-pair.js";
|
|
32
|
+
import { isDpopNonceChallenge } from "./www-authenticate.js";
|
|
32
33
|
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
33
34
|
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
34
35
|
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
@@ -41,6 +42,8 @@ const PIN_REQUIRED = "evidenceAudience is required: set it to the audience your
|
|
|
41
42
|
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
42
43
|
const USE_DPOP_NONCE = "use_dpop_nonce";
|
|
43
44
|
const DPOP_NONCE_HEADER = "DPoP-Nonce";
|
|
45
|
+
/** draft -11 section 6.1: the refusal a server that uses challenges answers a PoP-JWT request without one. */
|
|
46
|
+
const USE_ATTESTATION_CHALLENGE = "use_attestation_challenge";
|
|
44
47
|
/** At most this much of a 400 body is read to look for `use_dpop_nonce`; a longer body is not a challenge. */
|
|
45
48
|
const MAX_NONCE_CHALLENGE_BODY = 64 * 1024;
|
|
46
49
|
/** RFC 9449 nonces are short opaque strings: a longer `DPoP-Nonce` is ignored, as if there were none. */
|
|
@@ -57,6 +60,7 @@ const STEP_LABELS = {
|
|
|
57
60
|
discover: "attesterUrl",
|
|
58
61
|
mint: "attestation_endpoint",
|
|
59
62
|
challenge: "challenge_endpoint",
|
|
63
|
+
token_challenge: "token_challenge_endpoint",
|
|
60
64
|
};
|
|
61
65
|
/**
|
|
62
66
|
* Gets and caches an access token using client attestation.
|
|
@@ -122,6 +126,14 @@ export class ClientAttestation {
|
|
|
122
126
|
this.baseUrl = normalizeBaseUrl(url);
|
|
123
127
|
this.#checkSecure(this.baseUrl, "attesterUrl");
|
|
124
128
|
}
|
|
129
|
+
// A client given an evidence source is set up to mint, and a client that discovers its attester needs the
|
|
130
|
+
// pin to mint: without it the discovery document would choose the audience the workload's identity token
|
|
131
|
+
// is for. Refused here, at startup, not at the first token(); last, so a bad URL or option is reported as
|
|
132
|
+
// itself first. A pin-less client with auto-detected evidence still builds (attesterMetadata() needs no
|
|
133
|
+
// pin); runFlow refuses its token().
|
|
134
|
+
if (this.#evidence != null && !this.#evidenceAudience && !this.#staticMetadata) {
|
|
135
|
+
throw new ClientAttestationError(PIN_REQUIRED);
|
|
136
|
+
}
|
|
125
137
|
}
|
|
126
138
|
// ── public API ──────────────────────────────────────────────────────────────────────────────────
|
|
127
139
|
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
@@ -325,7 +337,7 @@ export class ClientAttestation {
|
|
|
325
337
|
const svid = await evidenceSource.fetch(audience);
|
|
326
338
|
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
327
339
|
const instanceKey = await SigningKeyPair.generate(this.#algorithm);
|
|
328
|
-
const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
|
|
340
|
+
const challenge = await this.#fetchChallenge(meta.challengeEndpoint, "attester");
|
|
329
341
|
const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
|
|
330
342
|
instanceKey,
|
|
331
343
|
evidence: svid,
|
|
@@ -349,10 +361,17 @@ export class ClientAttestation {
|
|
|
349
361
|
// Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
|
|
350
362
|
// proxy in between) — so prefer the advertised value.
|
|
351
363
|
const popAudience = meta.popAudience || tokenEndpoint;
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
|
|
364
|
+
// The challenge the token-endpoint proof carries: the authorization server's own, from its challenge
|
|
365
|
+
// endpoint (draft -11 section 6.3), when the metadata names one; else the attester's (see
|
|
366
|
+
// `challengeAtToken`: some attesters consume the challenge the moment the mint's instance proof uses
|
|
367
|
+
// it, and reject a token request that presents the same value a second time; against one of those,
|
|
368
|
+
// only the mint gets the challenge). A challenge the server demands in a refusal (section 6.1
|
|
369
|
+
// `use_attestation_challenge`, or RFC 9449 `use_dpop_nonce` in combined mode) wins over either.
|
|
370
|
+
const tokenChallenge = meta.tokenChallengeEndpoint
|
|
371
|
+
? await this.#fetchChallenge(meta.tokenChallengeEndpoint, "authorization_server")
|
|
372
|
+
: this.#challengeAtToken
|
|
373
|
+
? challenge
|
|
374
|
+
: undefined;
|
|
356
375
|
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
357
376
|
const obtained = (response) => {
|
|
358
377
|
const lifetime = tokenLifetime(response.expires_in);
|
|
@@ -366,12 +385,23 @@ export class ClientAttestation {
|
|
|
366
385
|
return { response, key: instanceKey };
|
|
367
386
|
};
|
|
368
387
|
if (this.#popMethod === POP_JWT) {
|
|
369
|
-
|
|
370
|
-
|
|
388
|
+
let res = await this.#postToken(tokenEndpoint, await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null));
|
|
389
|
+
// draft -11 section 6.1: a server that uses challenges refuses a request without a usable one as
|
|
390
|
+
// 400 use_attestation_challenge and always carries a fresh challenge in
|
|
391
|
+
// OAuth-Client-Attestation-Challenge. Send once more with that one. A refusal with no header
|
|
392
|
+
// (or one too long to be a challenge), any other error, or a second refusal is the error the
|
|
393
|
+
// caller sees: a server that keeps refusing is not retried in a loop.
|
|
394
|
+
const served = usableNonce(res.headers.get(CHALLENGE_HEADER));
|
|
395
|
+
if (res.status === 400 && served && jsonError(res.body) === USE_ATTESTATION_CHALLENGE) {
|
|
396
|
+
emit(this.#logger, "info", "pop.challenge_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
397
|
+
res = await this.#postToken(tokenEndpoint, await credential.popHeaders(clientId, popAudience, served));
|
|
398
|
+
}
|
|
399
|
+
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
371
400
|
}
|
|
372
|
-
// DPoP combined, the only other value (checked at the top of the flow): the
|
|
373
|
-
//
|
|
374
|
-
//
|
|
401
|
+
// DPoP combined, the only other value (checked at the top of the flow): the challenge rides as the
|
|
402
|
+
// DPoP nonce, and RFC 9449's nonce mechanism is the only challenge mechanism in this mode (draft -11
|
|
403
|
+
// section 5.2: no `challenge` claim, no OAuth-Client-Attestation-Challenge). If the AS demands its
|
|
404
|
+
// own nonce (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
|
|
375
405
|
let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
|
|
376
406
|
const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
|
|
377
407
|
if (serverNonce)
|
|
@@ -382,21 +412,26 @@ export class ClientAttestation {
|
|
|
382
412
|
}
|
|
383
413
|
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
384
414
|
}
|
|
385
|
-
|
|
415
|
+
/**
|
|
416
|
+
* `POST` a challenge endpoint - the attester's (CAS section 4.1, for the mint) or the authorization
|
|
417
|
+
* server's (draft -11 section 6.3, for the token request); both answer `{"attestation_challenge"}`.
|
|
418
|
+
*/
|
|
419
|
+
async #fetchChallenge(endpoint, at) {
|
|
386
420
|
if (!endpoint)
|
|
387
421
|
return undefined;
|
|
388
|
-
|
|
389
|
-
|
|
422
|
+
const step = at === "attester" ? "challenge" : "token_challenge";
|
|
423
|
+
this.#checkSecure(endpoint, STEP_LABELS[step]);
|
|
424
|
+
// The server advertised a challenge, so going on without one weakens replay protection: fail
|
|
390
425
|
// closed unless the caller opted out with requireChallenge: false.
|
|
391
426
|
let body;
|
|
392
427
|
try {
|
|
393
|
-
body = await this.#getJson(
|
|
428
|
+
body = await this.#getJson(step, "POST", endpoint);
|
|
394
429
|
}
|
|
395
430
|
catch (err) {
|
|
396
431
|
if (!(err instanceof ClientAttestationError))
|
|
397
432
|
throw err;
|
|
398
433
|
if (!this.#requireChallenge) {
|
|
399
|
-
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
434
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
|
|
400
435
|
return undefined;
|
|
401
436
|
}
|
|
402
437
|
throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
|
|
@@ -407,11 +442,11 @@ export class ClientAttestation {
|
|
|
407
442
|
}
|
|
408
443
|
const challenge = body.attestation_challenge;
|
|
409
444
|
if (typeof challenge === "string" && challenge) {
|
|
410
|
-
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
|
|
445
|
+
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint), at });
|
|
411
446
|
return challenge;
|
|
412
447
|
}
|
|
413
448
|
if (!this.#requireChallenge) {
|
|
414
|
-
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
449
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
|
|
415
450
|
return undefined;
|
|
416
451
|
}
|
|
417
452
|
throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
|
|
@@ -568,8 +603,9 @@ function replayable(body) {
|
|
|
568
603
|
*/
|
|
569
604
|
async function isNonceChallenge(res, timeoutMs) {
|
|
570
605
|
if (res.status === 401) {
|
|
571
|
-
|
|
572
|
-
|
|
606
|
+
// fetch's Headers hands back the fields combined as one list (RFC 9110 section 5.3)
|
|
607
|
+
const challenge = res.headers.get("WWW-Authenticate");
|
|
608
|
+
return challenge !== null && isDpopNonceChallenge([challenge]);
|
|
573
609
|
}
|
|
574
610
|
if (res.status === 400) {
|
|
575
611
|
const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
|
|
@@ -6,6 +6,8 @@ import type { JSONWebKeySet } from "jose";
|
|
|
6
6
|
export declare const DEFAULT_ALGORITHMS: readonly string[];
|
|
7
7
|
/** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
|
|
8
8
|
export declare const DEFAULT_TOKEN_TYPES: readonly string[];
|
|
9
|
+
/** How long keys fetched from `jwksUri` are still used after the last successful fetch: 24 hours. */
|
|
10
|
+
export declare const DEFAULT_JWKS_MAX_STALE_SECONDS: number;
|
|
9
11
|
/** How the resource server authenticates to the AS when introspecting. */
|
|
10
12
|
export type IntrospectionAuthMethod = "client_secret_basic" | "client_secret_post";
|
|
11
13
|
/**
|
|
@@ -28,9 +30,11 @@ export interface IntrospectionConfig {
|
|
|
28
30
|
* and optional introspection.
|
|
29
31
|
*
|
|
30
32
|
* `issuer` and `audiences` are required: a validator that skips either accepts tokens minted for someone
|
|
31
|
-
* else. `acceptedTokenTypes` is checked against the JWT `typ` header
|
|
32
|
-
* token
|
|
33
|
-
*
|
|
33
|
+
* else. `acceptedTokenTypes` is checked against the JWT `typ` header, so an ID token can't stand in for an
|
|
34
|
+
* access token: a token without `typ` is refused like one with the wrong `typ`. For an issuer that mints
|
|
35
|
+
* access tokens without `typ`, `allowMissingTokenType: true` accepts a missing `typ` while a wrong one is
|
|
36
|
+
* still refused. Pass `null` to turn the check off altogether, for an issuer that stamps something other
|
|
37
|
+
* than `at+jwt`.
|
|
34
38
|
*
|
|
35
39
|
* An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
|
|
36
40
|
* allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
|
|
@@ -39,6 +43,12 @@ export interface IntrospectionConfig {
|
|
|
39
43
|
* `jwksUri` and the introspection `endpoint` must use https unless their host is loopback (`localhost`,
|
|
40
44
|
* `127.0.0.1`, `::1`) or `allowInsecure` is set: over plain http anyone on the path could serve their own
|
|
41
45
|
* keys, or read the resource server's client secret.
|
|
46
|
+
*
|
|
47
|
+
* Keys fetched from `jwksUri` are refreshed after an hour, and a failed refresh keeps serving them for up to
|
|
48
|
+
* `jwksMaxStaleSeconds` after the last successful fetch (default {@link DEFAULT_JWKS_MAX_STALE_SECONDS}).
|
|
49
|
+
* Past that, every token fails with `key_not_found` (`signing keys unavailable`) until a fetch succeeds, so a
|
|
50
|
+
* retired key the issuer no longer publishes can't verify tokens indefinitely while the JWKS endpoint is
|
|
51
|
+
* unreachable. `null` removes the limit: availability over that protection.
|
|
42
52
|
*/
|
|
43
53
|
export interface ValidatorConfig {
|
|
44
54
|
issuer: string;
|
|
@@ -51,10 +61,21 @@ export interface ValidatorConfig {
|
|
|
51
61
|
/** Clock skew allowed on `exp` / `nbf` / `iat`, in seconds. Defaults to 60. */
|
|
52
62
|
leewaySeconds?: number;
|
|
53
63
|
introspection?: IntrospectionConfig;
|
|
54
|
-
/** Defaults to {@link DEFAULT_TOKEN_TYPES}; `null` disables the `typ` check. */
|
|
64
|
+
/** Defaults to {@link DEFAULT_TOKEN_TYPES}; a token without `typ` is refused. `null` disables the `typ` check. */
|
|
55
65
|
acceptedTokenTypes?: string[] | null;
|
|
66
|
+
/**
|
|
67
|
+
* Accept a token without `typ` (or with `"typ": null`) while still refusing a wrong `typ`, for an issuer
|
|
68
|
+
* that mints access tokens without one. Defaults to `false`.
|
|
69
|
+
*/
|
|
70
|
+
allowMissingTokenType?: boolean;
|
|
56
71
|
/** Allow a plain-http `jwksUri` and introspection `endpoint` on non-loopback hosts. Defaults to `false`. */
|
|
57
72
|
allowInsecure?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* How long keys fetched from `jwksUri` are still used when the refresh keeps failing, in seconds since the
|
|
75
|
+
* last successful fetch. A positive integer; defaults to {@link DEFAULT_JWKS_MAX_STALE_SECONDS}. `null`
|
|
76
|
+
* for no limit.
|
|
77
|
+
*/
|
|
78
|
+
jwksMaxStaleSeconds?: number | null;
|
|
58
79
|
}
|
|
59
80
|
/** A {@link ValidatorConfig} with every optional field defaulted. */
|
|
60
81
|
export interface ResolvedConfig {
|
|
@@ -68,4 +89,7 @@ export interface ResolvedConfig {
|
|
|
68
89
|
introspection?: IntrospectionConfig;
|
|
69
90
|
/** Normalised with {@link normaliseType}; `null` when the check is off. */
|
|
70
91
|
acceptedTokenTypes: string[] | null;
|
|
92
|
+
allowMissingTokenType: boolean;
|
|
93
|
+
/** `null` for no limit. */
|
|
94
|
+
jwksMaxStaleSeconds: number | null;
|
|
71
95
|
}
|
package/dist/validator/config.js
CHANGED
|
@@ -12,6 +12,8 @@ export const DEFAULT_ALGORITHMS = Object.freeze([
|
|
|
12
12
|
]);
|
|
13
13
|
/** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
|
|
14
14
|
export const DEFAULT_TOKEN_TYPES = Object.freeze(["at+jwt"]);
|
|
15
|
+
/** How long keys fetched from `jwksUri` are still used after the last successful fetch: 24 hours. */
|
|
16
|
+
export const DEFAULT_JWKS_MAX_STALE_SECONDS = 24 * 3600;
|
|
15
17
|
/**
|
|
16
18
|
* `none` (in any case) and the HMAC algorithms are refused whatever `acceptedAlgorithms` says: an unsigned
|
|
17
19
|
* token proves nothing, and an HMAC "signature" checked against an issuer's public key is forgeable by
|
|
@@ -84,6 +86,14 @@ export function resolveConfig(config) {
|
|
|
84
86
|
if (typeof allowInsecure !== "boolean") {
|
|
85
87
|
throw new Error("allowInsecure must be a boolean");
|
|
86
88
|
}
|
|
89
|
+
const allowMissingTokenType = config.allowMissingTokenType ?? false;
|
|
90
|
+
if (typeof allowMissingTokenType !== "boolean") {
|
|
91
|
+
throw new Error("allowMissingTokenType must be a boolean");
|
|
92
|
+
}
|
|
93
|
+
const maxStale = config.jwksMaxStaleSeconds === undefined ? DEFAULT_JWKS_MAX_STALE_SECONDS : config.jwksMaxStaleSeconds;
|
|
94
|
+
if (maxStale !== null && (typeof maxStale !== "number" || !Number.isSafeInteger(maxStale) || maxStale <= 0)) {
|
|
95
|
+
throw new Error("jwksMaxStaleSeconds must be a positive integer, or null for no limit");
|
|
96
|
+
}
|
|
87
97
|
if (config.jwksUri) {
|
|
88
98
|
checkSecure(config.jwksUri, "jwksUri", allowInsecure);
|
|
89
99
|
}
|
|
@@ -120,6 +130,8 @@ export function resolveConfig(config) {
|
|
|
120
130
|
? undefined
|
|
121
131
|
: { ...config.introspection, authMethod: config.introspection.authMethod || "client_secret_basic" },
|
|
122
132
|
acceptedTokenTypes: types === null ? null : stringList(types, "acceptedTokenTypes").map(normaliseType),
|
|
133
|
+
allowMissingTokenType,
|
|
134
|
+
jwksMaxStaleSeconds: maxStale,
|
|
123
135
|
};
|
|
124
136
|
}
|
|
125
137
|
/** A value's type and size, never its content (it may be a secret). */
|
|
@@ -9,11 +9,11 @@ export { AccessTokenValidator } from "./validator.js";
|
|
|
9
9
|
export type { HttpPost, Claims, ValidatorOptions } from "./validator.js";
|
|
10
10
|
export type { HttpGet } from "./jwks.js";
|
|
11
11
|
export type { Logger, LogAttributes, LogLevel } from "../logging.js";
|
|
12
|
-
export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
|
|
12
|
+
export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES, DEFAULT_JWKS_MAX_STALE_SECONDS } from "./config.js";
|
|
13
13
|
export type { ValidatorConfig, ResolvedConfig, IntrospectionConfig, IntrospectionAuthMethod, } from "./config.js";
|
|
14
14
|
export type { ValidationResult } from "./result.js";
|
|
15
15
|
export type { ValidationError } from "./errors.js";
|
|
16
16
|
export * as errors from "./errors.js";
|
|
17
17
|
export { ProtectedResource, bearerToken } from "./resource.js";
|
|
18
18
|
export type { ProtectedResourceMetadata, AuthDecision } from "./resource.js";
|
|
19
|
-
export declare const VALIDATOR_VERSION = "1.
|
|
19
|
+
export declare const VALIDATOR_VERSION = "1.4.0";
|
package/dist/validator/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `client_attestation_sdk.token_validator` package so every port reports the same verdict for the shared vectors.
|
|
7
7
|
*/
|
|
8
8
|
export { AccessTokenValidator } from "./validator.js";
|
|
9
|
-
export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
|
|
9
|
+
export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES, DEFAULT_JWKS_MAX_STALE_SECONDS } from "./config.js";
|
|
10
10
|
export * as errors from "./errors.js";
|
|
11
11
|
export { ProtectedResource, bearerToken } from "./resource.js";
|
|
12
|
-
export const VALIDATOR_VERSION = "1.
|
|
12
|
+
export const VALIDATOR_VERSION = "1.4.0";
|
package/dist/validator/jwks.js
CHANGED
|
@@ -17,6 +17,8 @@ export const MAX_JWKS_BYTES = 512 * 1024;
|
|
|
17
17
|
export const DEFAULT_MIN_REFRESH_SECONDS = 30;
|
|
18
18
|
/** @internal */
|
|
19
19
|
export const DEFAULT_MAX_AGE_SECONDS = 3600;
|
|
20
|
+
/** How long fetched keys are still served after the last successful fetch (config `DEFAULT_JWKS_MAX_STALE_SECONDS`). @internal */
|
|
21
|
+
export const DEFAULT_MAX_STALE_SECONDS = 24 * 3600;
|
|
20
22
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
21
23
|
const defaultGet = async (url) => {
|
|
22
24
|
// the shared transport refuses redirects and caps the body at MAX_JWKS_BYTES while reading it
|
|
@@ -65,6 +67,7 @@ export class JwksProvider {
|
|
|
65
67
|
#httpGet;
|
|
66
68
|
#minRefreshMs;
|
|
67
69
|
#maxAgeMs;
|
|
70
|
+
#maxStaleMs;
|
|
68
71
|
#clock;
|
|
69
72
|
#logger;
|
|
70
73
|
/** The usable keys, in document order: kid-less JWKs are distinct keys, and the first of a duplicate kid wins. */
|
|
@@ -81,6 +84,12 @@ export class JwksProvider {
|
|
|
81
84
|
this.#httpGet = opts.httpGet ?? defaultGet;
|
|
82
85
|
this.#minRefreshMs = (opts.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS) * 1000;
|
|
83
86
|
this.#maxAgeMs = (opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS) * 1000;
|
|
87
|
+
this.#maxStaleMs =
|
|
88
|
+
opts.maxStaleSeconds === undefined
|
|
89
|
+
? DEFAULT_MAX_STALE_SECONDS * 1000
|
|
90
|
+
: opts.maxStaleSeconds === null
|
|
91
|
+
? null
|
|
92
|
+
: opts.maxStaleSeconds * 1000;
|
|
84
93
|
this.#clock = opts.clock ?? (() => performance.now());
|
|
85
94
|
this.#logger = opts.logger ?? NOOP_LOGGER;
|
|
86
95
|
if (opts.jwks != null) {
|
|
@@ -126,6 +135,11 @@ export class JwksProvider {
|
|
|
126
135
|
if (!held || this.#stale()) {
|
|
127
136
|
await this.#refresh(held);
|
|
128
137
|
}
|
|
138
|
+
if (this.#expired()) {
|
|
139
|
+
// past the stale limit without a successful refresh: the keys held are not served
|
|
140
|
+
this.lastError ??= "JWKS fetch failed: cached keys are past the stale limit";
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
129
143
|
}
|
|
130
144
|
const entry = this.#lookup(kid, alg);
|
|
131
145
|
if (entry === undefined) {
|
|
@@ -148,8 +162,21 @@ export class JwksProvider {
|
|
|
148
162
|
}
|
|
149
163
|
return this.#keys.length === 1 && allowed(this.#keys[0]) ? this.#keys[0] : undefined;
|
|
150
164
|
}
|
|
165
|
+
/** Milliseconds since the last successful fetch, or `null` before one. */
|
|
166
|
+
#age() {
|
|
167
|
+
return this.#loadedAt === null ? null : this.#clock() - this.#loadedAt;
|
|
168
|
+
}
|
|
169
|
+
#pastLimit(age) {
|
|
170
|
+
return this.#maxStaleMs !== null && age !== null && age >= this.#maxStaleMs;
|
|
171
|
+
}
|
|
172
|
+
/** Whether the set should be refreshed: never fetched, older than the max age, or past the stale limit. */
|
|
151
173
|
#stale() {
|
|
152
|
-
|
|
174
|
+
const age = this.#age();
|
|
175
|
+
return age === null || age >= this.#maxAgeMs || this.#pastLimit(age);
|
|
176
|
+
}
|
|
177
|
+
/** Whether the keys held are past the stale limit, and so not served until a fetch succeeds. */
|
|
178
|
+
#expired() {
|
|
179
|
+
return this.#pastLimit(this.#age());
|
|
153
180
|
}
|
|
154
181
|
/**
|
|
155
182
|
* A caller that already holds a usable (if stale) key never waits for another request's fetch: it goes on
|
|
@@ -182,8 +209,15 @@ export class JwksProvider {
|
|
|
182
209
|
reason: failureReason(exc),
|
|
183
210
|
status: exc instanceof TransportError ? exc.status : undefined,
|
|
184
211
|
});
|
|
185
|
-
// keep serving the keys already held
|
|
212
|
+
// keep serving the keys already held, up to the stale limit
|
|
186
213
|
this.lastError = `JWKS fetch failed: ${exc instanceof Error ? exc.message : String(exc)}`;
|
|
214
|
+
const age = this.#age();
|
|
215
|
+
if (this.#pastLimit(age)) {
|
|
216
|
+
emit(this.#logger, "warn", "jwks.stale", {
|
|
217
|
+
host: hostOf(this.#jwksUri),
|
|
218
|
+
age: Math.floor(age / 1000),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
187
221
|
}
|
|
188
222
|
finally {
|
|
189
223
|
this.#inFlight = null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Access-token validation: JWT signature + claim checks, and optional RFC 7662 introspection.
|
|
3
3
|
*
|
|
4
4
|
* `validate` does local JWT validation in a fixed order — algorithm accepted, key resolvable,
|
|
5
|
-
* signature, `typ` (
|
|
5
|
+
* signature, `typ` (unless the check is off), `iss`, `exp` present, `exp`, `nbf`, `iat` not in the future, audience,
|
|
6
6
|
* then scope — returning the first failure's stable error code. That order is part of the cross-language contract so every port reports the same verdict.
|
|
7
7
|
* `validateActive` instead uses RFC 7662 introspection.
|
|
8
8
|
*/
|
|
@@ -57,6 +57,7 @@ export class AccessTokenValidator {
|
|
|
57
57
|
jwks: this.config.jwks,
|
|
58
58
|
jwksUri: this.config.jwksUri,
|
|
59
59
|
httpGet: opts.httpGet,
|
|
60
|
+
maxStaleSeconds: this.config.jwksMaxStaleSeconds,
|
|
60
61
|
logger: opts.logger,
|
|
61
62
|
});
|
|
62
63
|
this.#httpPost = opts.httpPost ?? defaultPost;
|
|
@@ -114,9 +115,14 @@ export class AccessTokenValidator {
|
|
|
114
115
|
catch (exc) {
|
|
115
116
|
return failure(errors.INVALID_TOKEN, errMessage(exc));
|
|
116
117
|
}
|
|
118
|
+
// A token without typ (or with "typ": null) is refused like one with the wrong typ: an ID token or
|
|
119
|
+
// another JWT the issuer signs without a typ must not pass as an access token. allowMissingTokenType
|
|
120
|
+
// accepts the missing typ only; a wrong one is still refused.
|
|
117
121
|
const types = this.config.acceptedTokenTypes;
|
|
118
122
|
const typ = header.typ;
|
|
119
|
-
if (
|
|
123
|
+
if (types !== null &&
|
|
124
|
+
!(typ == null && this.config.allowMissingTokenType) &&
|
|
125
|
+
(typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
|
|
120
126
|
return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
|
|
121
127
|
}
|
|
122
128
|
if (claims.iss !== this.config.issuer) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WWW-Authenticate parsing (RFC 9110 section 11.6.1) by the rule every port shares (vectors/README.md, "The
|
|
3
|
+
* WWW-Authenticate rule"; cases in vectors/www-authenticate.json). Anything that does not follow the grammar
|
|
4
|
+
* makes the whole value malformed, so a caller that acts on a challenge fails closed.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WWW-Authenticate parsing (RFC 9110 section 11.6.1) by the rule every port shares (vectors/README.md, "The
|
|
3
|
+
* WWW-Authenticate rule"; cases in vectors/www-authenticate.json). Anything that does not follow the grammar
|
|
4
|
+
* makes the whole value malformed, so a caller that acts on a challenge fails closed.
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
const TCHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/;
|
|
8
|
+
const TOKEN68 = /[\-._~+/0-9A-Za-z]/;
|
|
9
|
+
function ows(s, i) {
|
|
10
|
+
while (i < s.length && (s[i] === " " || s[i] === "\t"))
|
|
11
|
+
i++;
|
|
12
|
+
return i;
|
|
13
|
+
}
|
|
14
|
+
function token(s, i) {
|
|
15
|
+
while (i < s.length && TCHAR.test(s[i]))
|
|
16
|
+
i++;
|
|
17
|
+
return i;
|
|
18
|
+
}
|
|
19
|
+
function token68(s, i) {
|
|
20
|
+
const start = i;
|
|
21
|
+
while (i < s.length && TOKEN68.test(s[i]))
|
|
22
|
+
i++;
|
|
23
|
+
if (i === start)
|
|
24
|
+
return undefined;
|
|
25
|
+
while (s[i] === "=")
|
|
26
|
+
i++;
|
|
27
|
+
const k = ows(s, i);
|
|
28
|
+
return k === s.length || s[k] === "," ? i : undefined;
|
|
29
|
+
}
|
|
30
|
+
function qdtext(c) {
|
|
31
|
+
return c === 0x09 || c === 0x20 || c === 0x21 || (c >= 0x23 && c <= 0x5b) || (c >= 0x5d && c <= 0x7e) || c >= 0x80;
|
|
32
|
+
}
|
|
33
|
+
function quotedPairChar(c) {
|
|
34
|
+
return c === 0x09 || (c >= 0x20 && c <= 0x7e) || c >= 0x80;
|
|
35
|
+
}
|
|
36
|
+
/** A token or quoted-string at `i`: [value, index after it], or undefined. */
|
|
37
|
+
function value(s, i) {
|
|
38
|
+
if (s[i] === '"') {
|
|
39
|
+
let out = "";
|
|
40
|
+
for (i++; i < s.length;) {
|
|
41
|
+
const c = s.charCodeAt(i);
|
|
42
|
+
if (c === 0x22)
|
|
43
|
+
return [out, i + 1];
|
|
44
|
+
if (c === 0x5c) {
|
|
45
|
+
if (i + 1 >= s.length || !quotedPairChar(s.charCodeAt(i + 1)))
|
|
46
|
+
return undefined;
|
|
47
|
+
out += s[i + 1];
|
|
48
|
+
i += 2;
|
|
49
|
+
}
|
|
50
|
+
else if (qdtext(c)) {
|
|
51
|
+
out += s[i];
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const k = token(s, i);
|
|
61
|
+
return k > i ? [s.slice(i, k), k] : undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Every field value of a WWW-Authenticate header, combined as one list (RFC 9110 section 5.3), as
|
|
65
|
+
* challenges; undefined when the combined value is malformed.
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
68
|
+
export function parseWwwAuthenticate(values) {
|
|
69
|
+
const s = values.join(", ");
|
|
70
|
+
const out = [];
|
|
71
|
+
let i = 0;
|
|
72
|
+
for (;;) {
|
|
73
|
+
while (i < s.length && (s[i] === " " || s[i] === "\t" || s[i] === ","))
|
|
74
|
+
i++;
|
|
75
|
+
if (i >= s.length)
|
|
76
|
+
return out;
|
|
77
|
+
const start = i;
|
|
78
|
+
i = token(s, i);
|
|
79
|
+
if (i === start)
|
|
80
|
+
return undefined;
|
|
81
|
+
const name = s.slice(start, i).toLowerCase();
|
|
82
|
+
const j = ows(s, i);
|
|
83
|
+
if (s[j] === "=") {
|
|
84
|
+
if (out.length === 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
const got = value(s, ows(s, j + 1));
|
|
87
|
+
if (got === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const params = out[out.length - 1].params;
|
|
90
|
+
if (params.has(name))
|
|
91
|
+
return undefined;
|
|
92
|
+
params.set(name, got[0]);
|
|
93
|
+
i = got[1];
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
out.push({ scheme: name, params: new Map() });
|
|
97
|
+
const k = j > i ? token68(s, j) : undefined;
|
|
98
|
+
if (k === undefined) {
|
|
99
|
+
i = j; // its first auth-param (if any) follows without a comma
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
i = k;
|
|
103
|
+
}
|
|
104
|
+
i = ows(s, i);
|
|
105
|
+
if (i < s.length && s[i] !== ",")
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** A DPoP challenge whose error is exactly "use_dpop_nonce" (RFC 9449 section 9). @internal */
|
|
110
|
+
export function isDpopNonceChallenge(values) {
|
|
111
|
+
const challenges = parseWwwAuthenticate(values);
|
|
112
|
+
return challenges !== undefined &&
|
|
113
|
+
challenges.some((c) => c.scheme === "dpop" && c.params.get("error") === "use_dpop_nonce");
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idpartners/client-attestation-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"@types/node": "^20.0.0",
|
|
51
51
|
"@vitest/coverage-v8": "^4.1.11",
|
|
52
52
|
"esbuild": "^0.28.1",
|
|
53
|
+
"fast-check": "4.10.2",
|
|
53
54
|
"tsx": "^4.23.15",
|
|
54
55
|
"typescript": "^7.0.2",
|
|
55
56
|
"vitest": "^4.1.11",
|