@idpartners/client-attestation-sdk 1.0.1 → 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.
@@ -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
  /**
@@ -15,7 +17,11 @@ export interface IntrospectionConfig {
15
17
  endpoint: string;
16
18
  clientId: string;
17
19
  clientSecret: string;
18
- /** Defaults to `"client_secret_basic"`. */
20
+ /**
21
+ * Defaults to `"client_secret_basic"`, also when `null` or `""` (so an unset environment variable can be
22
+ * passed straight in). The validator refuses any other value than the two {@link IntrospectionAuthMethod}s,
23
+ * so a typo can't move the secret into the form body.
24
+ */
19
25
  authMethod?: IntrospectionAuthMethod;
20
26
  }
21
27
  /**
@@ -24,13 +30,25 @@ export interface IntrospectionConfig {
24
30
  * and optional introspection.
25
31
  *
26
32
  * `issuer` and `audiences` are required: a validator that skips either accepts tokens minted for someone
27
- * else. `acceptedTokenTypes` is checked against the JWT `typ` header when the token carries one, so an ID
28
- * token can't stand in for an access token. Pass `null` to turn that check off for an issuer that stamps
29
- * something other than `at+jwt`.
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`.
30
38
  *
31
39
  * An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
32
40
  * allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
33
41
  * refused even when listed (see validation/SEMANTICS.md).
42
+ *
43
+ * `jwksUri` and the introspection `endpoint` must use https unless their host is loopback (`localhost`,
44
+ * `127.0.0.1`, `::1`) or `allowInsecure` is set: over plain http anyone on the path could serve their own
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.
34
52
  */
35
53
  export interface ValidatorConfig {
36
54
  issuer: string;
@@ -43,8 +61,21 @@ export interface ValidatorConfig {
43
61
  /** Clock skew allowed on `exp` / `nbf` / `iat`, in seconds. Defaults to 60. */
44
62
  leewaySeconds?: number;
45
63
  introspection?: IntrospectionConfig;
46
- /** 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. */
47
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;
71
+ /** Allow a plain-http `jwksUri` and introspection `endpoint` on non-loopback hosts. Defaults to `false`. */
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;
48
79
  }
49
80
  /** A {@link ValidatorConfig} with every optional field defaulted. */
50
81
  export interface ResolvedConfig {
@@ -58,4 +89,7 @@ export interface ResolvedConfig {
58
89
  introspection?: IntrospectionConfig;
59
90
  /** Normalised with {@link normaliseType}; `null` when the check is off. */
60
91
  acceptedTokenTypes: string[] | null;
92
+ allowMissingTokenType: boolean;
93
+ /** `null` for no limit. */
94
+ jwksMaxStaleSeconds: number | null;
61
95
  }
@@ -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
@@ -33,8 +35,37 @@ export function normaliseType(value) {
33
35
  const lower = value.toLowerCase();
34
36
  return lower.startsWith("application/") ? lower.slice("application/".length) : lower;
35
37
  }
38
+ const AUTH_METHODS = ["client_secret_basic", "client_secret_post"];
39
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
40
+ /** Refuse a non-https URL unless it is loopback or `allowInsecure` is set: the rule the client applies. */
41
+ function checkSecure(url, what, allowInsecure) {
42
+ if (allowInsecure)
43
+ return;
44
+ let parsed;
45
+ try {
46
+ parsed = new URL(url);
47
+ }
48
+ catch {
49
+ throw new Error(`${what} is not a valid URL: ${url}`);
50
+ }
51
+ if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
52
+ return;
53
+ throw new Error(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
54
+ }
55
+ /**
56
+ * A list option as a copy, refusing anything but an array of strings. JavaScript callers aren't held to
57
+ * the types, and a bare string would otherwise spread into its characters.
58
+ */
59
+ function stringList(value, what) {
60
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
61
+ throw new Error(`${what} must be an array of strings`);
62
+ }
63
+ return [...value];
64
+ }
36
65
  /**
37
- * Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`).
66
+ * Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`). The result
67
+ * shares nothing mutable with `config`: a caller reusing or changing its config object afterwards (its
68
+ * arrays, its JWKS, its introspection settings) can't change a live validator.
38
69
  *
39
70
  * @internal
40
71
  */
@@ -42,20 +73,70 @@ export function resolveConfig(config) {
42
73
  if (!config.issuer) {
43
74
  throw new Error("issuer is required");
44
75
  }
45
- const audiences = typeof config.audiences === "string" ? [config.audiences] : [...(config.audiences ?? [])];
76
+ const audiences = typeof config.audiences === "string" ? [config.audiences] : stringList(config.audiences ?? [], "audiences");
46
77
  if (audiences.length === 0) {
47
78
  throw new Error("at least one audience is required");
48
79
  }
80
+ // NaN would make every exp/nbf/iat comparison false (no time check at all), and "60" would concatenate
81
+ const leeway = config.leewaySeconds ?? 60;
82
+ if (typeof leeway !== "number" || !Number.isSafeInteger(leeway) || leeway < 0) {
83
+ throw new Error("leewaySeconds must be a non-negative integer");
84
+ }
85
+ const allowInsecure = config.allowInsecure ?? false;
86
+ if (typeof allowInsecure !== "boolean") {
87
+ throw new Error("allowInsecure must be a boolean");
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
+ }
97
+ if (config.jwksUri) {
98
+ checkSecure(config.jwksUri, "jwksUri", allowInsecure);
99
+ }
100
+ if (config.introspection?.endpoint) {
101
+ checkSecure(config.introspection.endpoint, "introspection endpoint", allowInsecure);
102
+ }
103
+ // JavaScript callers aren't held to the union type: anything else used to be taken as client_secret_post.
104
+ // Unset (undefined, null or "") is the default, client_secret_basic.
105
+ const authMethod = config.introspection?.authMethod;
106
+ const unset = authMethod == null || authMethod === "";
107
+ if (config.introspection != null && !unset && !AUTH_METHODS.includes(authMethod)) {
108
+ // never echo the value: a secret passed in the wrong field would end up in the message
109
+ throw new Error(`introspection authMethod must be "client_secret_basic" or "client_secret_post", got ${describe(authMethod)}`);
110
+ }
111
+ if (config.introspection != null) {
112
+ for (const field of ["clientId", "clientSecret"]) {
113
+ const value = config.introspection[field];
114
+ if (typeof value !== "string" || value === "") {
115
+ throw new Error(`introspection ${field} must be a non-empty string, got ${value === "" ? "an empty string" : describe(value)}`);
116
+ }
117
+ }
118
+ }
49
119
  const types = config.acceptedTokenTypes === undefined ? DEFAULT_TOKEN_TYPES : config.acceptedTokenTypes;
50
120
  return {
51
121
  issuer: config.issuer,
52
122
  audiences,
53
- jwks: config.jwks,
123
+ // the JWKS provider keeps each JWK and imports it again for each algorithm a token names
124
+ jwks: config.jwks == null ? undefined : structuredClone(config.jwks),
54
125
  jwksUri: config.jwksUri,
55
- requiredScopes: [...(config.requiredScopes ?? [])],
56
- acceptedAlgorithms: [...(config.acceptedAlgorithms ?? DEFAULT_ALGORITHMS)],
57
- leewaySeconds: config.leewaySeconds ?? 60,
58
- introspection: config.introspection,
59
- acceptedTokenTypes: types === null ? null : types.map(normaliseType),
126
+ requiredScopes: stringList(config.requiredScopes ?? [], "requiredScopes"),
127
+ acceptedAlgorithms: stringList(config.acceptedAlgorithms ?? DEFAULT_ALGORITHMS, "acceptedAlgorithms"),
128
+ leewaySeconds: leeway,
129
+ introspection: config.introspection == null
130
+ ? undefined
131
+ : { ...config.introspection, authMethod: config.introspection.authMethod || "client_secret_basic" },
132
+ acceptedTokenTypes: types === null ? null : stringList(types, "acceptedTokenTypes").map(normaliseType),
133
+ allowMissingTokenType,
134
+ jwksMaxStaleSeconds: maxStale,
60
135
  };
61
136
  }
137
+ /** A value's type and size, never its content (it may be a secret). */
138
+ function describe(value) {
139
+ if (typeof value === "string")
140
+ return `a string of ${[...value].length} characters`;
141
+ return value === null ? "null" : `a ${typeof value}`;
142
+ }
@@ -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.0.1";
19
+ export declare const VALIDATOR_VERSION = "1.4.0";
@@ -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.0.1";
12
+ export const VALIDATOR_VERSION = "1.4.0";
@@ -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
@@ -26,6 +28,25 @@ const defaultGet = async (url) => {
26
28
  }
27
29
  return resp.body;
28
30
  };
31
+ /** The JWK members only a private or symmetric key carries (RFC 7518 section 6). */
32
+ const PRIVATE_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "k"];
33
+ /**
34
+ * Whether a JWK may verify a signature: not when its `use` is present and not `sig`, its `key_ops` is present
35
+ * and lacks `verify`, or it carries private members. Checked on the JWK as published, before jose's
36
+ * `importJWK` (which drops `use`) sees it.
37
+ */
38
+ function verifiesSignatures(jwk) {
39
+ if (typeof jwk !== "object" || jwk === null || Array.isArray(jwk)) {
40
+ return false; // not a JWK at all: skipped like any key that can't be read
41
+ }
42
+ if ("use" in jwk && jwk.use !== "sig") {
43
+ return false;
44
+ }
45
+ if ("key_ops" in jwk && !(Array.isArray(jwk.key_ops) && jwk.key_ops.includes("verify"))) {
46
+ return false;
47
+ }
48
+ return !PRIVATE_MEMBERS.some((member) => member in jwk);
49
+ }
29
50
  /** Best-effort JWS `alg` for a JWK that omits `alg`, from its key type / curve. */
30
51
  function algorithmFor(jwk) {
31
52
  if (jwk.kty === "EC") {
@@ -46,10 +67,11 @@ export class JwksProvider {
46
67
  #httpGet;
47
68
  #minRefreshMs;
48
69
  #maxAgeMs;
70
+ #maxStaleMs;
49
71
  #clock;
50
72
  #logger;
51
- /** kid -> key, plus the JWK's declared `alg` (a JWK that declares one is only used for that alg). */
52
- #keys = new Map();
73
+ /** The usable keys, in document order: kid-less JWKs are distinct keys, and the first of a duplicate kid wins. */
74
+ #keys = [];
53
75
  #loaded = null;
54
76
  #inFlight = null;
55
77
  #loadedAt = null;
@@ -62,7 +84,13 @@ export class JwksProvider {
62
84
  this.#httpGet = opts.httpGet ?? defaultGet;
63
85
  this.#minRefreshMs = (opts.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS) * 1000;
64
86
  this.#maxAgeMs = (opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS) * 1000;
65
- this.#clock = opts.clock ?? Date.now;
87
+ this.#maxStaleMs =
88
+ opts.maxStaleSeconds === undefined
89
+ ? DEFAULT_MAX_STALE_SECONDS * 1000
90
+ : opts.maxStaleSeconds === null
91
+ ? null
92
+ : opts.maxStaleSeconds * 1000;
93
+ this.#clock = opts.clock ?? (() => performance.now());
66
94
  this.#logger = opts.logger ?? NOOP_LOGGER;
67
95
  if (opts.jwks != null) {
68
96
  this.#loaded = _a.#parse(opts.jwks).then((keys) => {
@@ -71,11 +99,20 @@ export class JwksProvider {
71
99
  }
72
100
  }
73
101
  static async #parse(jwks) {
74
- const keys = new Map();
75
- for (const jwk of jwks.keys ?? []) {
76
- const alg = jwk.alg ?? algorithmFor(jwk);
102
+ const keys = [];
103
+ for (const published of (jwks.keys ?? [])) {
104
+ if (!verifiesSignatures(published)) {
105
+ continue;
106
+ }
107
+ // Imported without its key_ops: verifiesSignatures has checked them, and jose would hand them to
108
+ // WebCrypto as the key's usages, which refuses a public key with any usage but "verify" (a JWK exported
109
+ // from a key pair says ["sign","verify"]). Every later import of this entry (resolve) uses this copy.
110
+ const { key_ops: _keyOps, ...jwk } = published;
111
+ // a JWK jose can't read at all is skipped; its key type and curve say how to read it
112
+ const inferred = algorithmFor(jwk);
77
113
  try {
78
- keys.set(jwk.kid, { key: await importJWK(jwk, alg), alg: jwk.alg });
114
+ const key = await importJWK(jwk, inferred);
115
+ keys.push({ kid: jwk.kid, jwk, alg: jwk.alg, imported: new Map([[inferred, key]]) });
79
116
  }
80
117
  catch {
81
118
  continue;
@@ -85,34 +122,69 @@ export class JwksProvider {
85
122
  }
86
123
  /**
87
124
  * Resolve the verification key for a header `kid` (and `alg`). Falls back to the single configured key
88
- * when the header carries no `kid`. Returns `undefined` when no key can be found; never rejects — a
89
- * failed fetch leaves its reason in {@link lastError}.
125
+ * when the header carries no `kid`. Returns `undefined` when no key can be found, and `null` when the key
126
+ * found can't be used with `alg` (an EC key of another curve, an RSA key for an EC algorithm); never
127
+ * rejects — a failed fetch leaves its reason in {@link lastError}.
90
128
  */
91
129
  async resolve(kid, alg) {
92
130
  if (this.#loaded) {
93
131
  await this.#loaded;
94
132
  }
95
- if (!this.#static && this.#jwksUri && (this.#lookup(kid) === undefined || this.#stale())) {
96
- await this.#refresh();
133
+ if (!this.#static && this.#jwksUri) {
134
+ const held = this.#lookup(kid, alg) !== undefined;
135
+ if (!held || this.#stale()) {
136
+ await this.#refresh(held);
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
+ }
97
143
  }
98
- const entry = this.#lookup(kid);
99
- if (entry === undefined || (alg != null && entry.alg != null && entry.alg !== alg)) {
144
+ const entry = this.#lookup(kid, alg);
145
+ if (entry === undefined) {
100
146
  return undefined;
101
147
  }
102
- return entry.key;
148
+ const as = alg ?? entry.alg ?? algorithmFor(entry.jwk);
149
+ if (!entry.imported.has(as)) {
150
+ entry.imported.set(as, await importJWK(entry.jwk, as).catch(() => null));
151
+ }
152
+ return entry.imported.get(as) ?? null;
103
153
  }
104
- #lookup(kid) {
154
+ /**
155
+ * For a `kid`, the first key with that `kid` that may be used for `alg`; without one, the sole key when the
156
+ * set holds exactly one. An empty `kid` names no key.
157
+ */
158
+ #lookup(kid, alg) {
159
+ const allowed = (entry) => alg == null || entry.alg == null || entry.alg === alg;
105
160
  if (kid != null) {
106
- return this.#keys.get(kid);
161
+ return kid === "" ? undefined : this.#keys.find((entry) => entry.kid === kid && allowed(entry));
107
162
  }
108
- return this.#keys.size === 1 ? this.#keys.values().next().value : undefined;
163
+ return this.#keys.length === 1 && allowed(this.#keys[0]) ? this.#keys[0] : undefined;
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;
109
171
  }
172
+ /** Whether the set should be refreshed: never fetched, older than the max age, or past the stale limit. */
110
173
  #stale() {
111
- return this.#loadedAt === null || this.#clock() - this.#loadedAt >= this.#maxAgeMs;
174
+ const age = this.#age();
175
+ return age === null || age >= this.#maxAgeMs || this.#pastLimit(age);
112
176
  }
113
- #refresh() {
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());
180
+ }
181
+ /**
182
+ * A caller that already holds a usable (if stale) key never waits for another request's fetch: it goes on
183
+ * with the key it has. One without a key waits for a fetch in flight, which may bring its key.
184
+ */
185
+ #refresh(haveKey) {
114
186
  if (this.#inFlight) {
115
- return this.#inFlight;
187
+ return haveKey ? Promise.resolve() : this.#inFlight;
116
188
  }
117
189
  const now = this.#clock();
118
190
  if (this.#lastAttempt !== null && now - this.#lastAttempt < this.#minRefreshMs) {
@@ -129,7 +201,7 @@ export class JwksProvider {
129
201
  this.#keys = await _a.#parse(JSON.parse(body));
130
202
  this.#loadedAt = this.#clock();
131
203
  this.lastError = null;
132
- emit(this.#logger, "debug", "jwks.fetched", { host: hostOf(this.#jwksUri), keys: this.#keys.size });
204
+ emit(this.#logger, "debug", "jwks.fetched", { host: hostOf(this.#jwksUri), keys: this.#keys.length });
133
205
  }
134
206
  catch (exc) {
135
207
  emit(this.#logger, "warn", "jwks.fetch_failed", {
@@ -137,8 +209,15 @@ export class JwksProvider {
137
209
  reason: failureReason(exc),
138
210
  status: exc instanceof TransportError ? exc.status : undefined,
139
211
  });
140
- // keep serving the keys already held
212
+ // keep serving the keys already held, up to the stale limit
141
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
+ }
142
221
  }
143
222
  finally {
144
223
  this.#inFlight = null;
@@ -34,4 +34,12 @@ export declare class ProtectedResource {
34
34
  metadataUrl(): string;
35
35
  challenge(error?: string, errorDescription?: string | null): string;
36
36
  authenticate(authorizationHeader?: string | null, requiredScopes?: string[]): Promise<AuthDecision>;
37
+ /**
38
+ * {@link authenticate} for tokens checked by RFC 7662 introspection ({@link AccessTokenValidator.validateActive}),
39
+ * such as opaque tokens, with the same rules: the response's `aud` must name this resource, so a response
40
+ * without one is refused (401), and a sender-constrained token (`cnf`) is refused (401). A failed
41
+ * introspection call is not a verdict: it rejects, as `validateActive` does, and the caller answers it (503,
42
+ * say).
43
+ */
44
+ authenticateActive(authorizationHeader?: string | null, requiredScopes?: string[]): Promise<AuthDecision>;
37
45
  }
@@ -10,12 +10,46 @@
10
10
  import * as errors from "./errors.js";
11
11
  import { failure } from "./result.js";
12
12
  const WELL_KNOWN = "/.well-known/oauth-protected-resource";
13
+ const BEARER = "bearer";
13
14
  /** Extract the token from an `Authorization: Bearer <token>` header value, or null. */
14
15
  export function bearerToken(authorizationHeader) {
15
16
  if (!authorizationHeader)
16
17
  return null;
17
- const match = /^\s*Bearer\s+(.+?)\s*$/i.exec(authorizationHeader);
18
- return match ? match[1] : null;
18
+ // One linear pass, no regex: a backtracking pattern here costs quadratic or cubic time on a hostile header.
19
+ // The result is what Go's RE2 (?i)^\s*Bearer\s+(.+?)\s*$ returns, so every port extracts the same token.
20
+ const h = authorizationHeader;
21
+ const n = h.length;
22
+ let i = 0;
23
+ while (i < n && isSpace(h.charCodeAt(i)))
24
+ i++;
25
+ if (n - i <= BEARER.length)
26
+ return null;
27
+ for (let k = 0; k < BEARER.length; k++, i++) {
28
+ if ((h.charCodeAt(i) | 0x20) !== BEARER.charCodeAt(k))
29
+ return null;
30
+ }
31
+ if (!isSpace(h.charCodeAt(i)))
32
+ return null;
33
+ let end = n;
34
+ while (end > i && isSpace(h.charCodeAt(end - 1)))
35
+ end--;
36
+ if (end === i) {
37
+ // Only whitespace after the scheme: RE2 takes the last non-LF whitespace character after the first.
38
+ for (let j = n - 1; j > i; j--) {
39
+ if (h[j] !== "\n")
40
+ return h[j];
41
+ }
42
+ return null;
43
+ }
44
+ let start = i + 1;
45
+ while (isSpace(h.charCodeAt(start)))
46
+ start++;
47
+ const token = h.slice(start, end);
48
+ return token.includes("\n") ? null : token;
49
+ }
50
+ /** RE2's `\s`: space, tab, LF, FF and CR. */
51
+ function isSpace(c) {
52
+ return c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0c || c === 0x0d;
19
53
  }
20
54
  /**
21
55
  * An OAuth-protected resource server. The `validator` must accept `resource` as an audience — that binds
@@ -65,11 +99,24 @@ export class ProtectedResource {
65
99
  return "Bearer " + params.join(", ");
66
100
  }
67
101
  async authenticate(authorizationHeader, requiredScopes) {
102
+ return this.#guard(authorizationHeader, (token) => this.#validator.validate(token, requiredScopes));
103
+ }
104
+ /**
105
+ * {@link authenticate} for tokens checked by RFC 7662 introspection ({@link AccessTokenValidator.validateActive}),
106
+ * such as opaque tokens, with the same rules: the response's `aud` must name this resource, so a response
107
+ * without one is refused (401), and a sender-constrained token (`cnf`) is refused (401). A failed
108
+ * introspection call is not a verdict: it rejects, as `validateActive` does, and the caller answers it (503,
109
+ * say).
110
+ */
111
+ async authenticateActive(authorizationHeader, requiredScopes) {
112
+ return this.#guard(authorizationHeader, (token) => this.#validator.validateActive(token, requiredScopes));
113
+ }
114
+ async #guard(authorizationHeader, validate) {
68
115
  const token = bearerToken(authorizationHeader);
69
116
  if (token == null) {
70
117
  return { authorized: false, status: 401, wwwAuthenticate: this.challenge(), errorDescription: "authentication required" };
71
118
  }
72
- let result = await this.#validator.validate(token, requiredScopes);
119
+ let result = await validate(token);
73
120
  if (result.valid && !result.audience.includes(this.resource)) {
74
121
  result = failure(errors.INVALID_AUDIENCE, "token audience is not this resource");
75
122
  }
@@ -19,7 +19,12 @@ const SEGMENT = /^[A-Za-z0-9_-]*$/;
19
19
  */
20
20
  export class Malformed extends Error {
21
21
  }
22
- function decodeSegment(segment) {
22
+ /**
23
+ * One segment of a compact JWS: unpadded base64url in its canonical form (no non-zero padding bits).
24
+ *
25
+ * @internal
26
+ */
27
+ export function decodeSegment(segment) {
23
28
  if (!SEGMENT.test(segment) || segment.length % 4 === 1) {
24
29
  throw new Malformed("malformed token");
25
30
  }
@@ -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` (when present), `iss`, `exp` present, `exp`, `nbf`, `iat` not in the future, audience,
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;
@@ -92,11 +93,16 @@ export class AccessTokenValidator {
92
93
  return failure(errors.UNSUPPORTED_ALGORITHM, "algorithm not accepted");
93
94
  }
94
95
  const kid = header.kid;
95
- const key = kid == null || typeof kid === "string" ? await this.#jwks.resolve(kid ?? undefined, alg) : null;
96
- if (key == null) {
97
- return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError ?? "no signing key for this kid");
96
+ const key = kid == null || typeof kid === "string" ? await this.#jwks.resolve(kid ?? undefined, alg) : undefined;
97
+ if (key === undefined) {
98
+ // a fetch failure's detail (internal host, dial error, IdP body) went to the logger as jwks.fetch_failed;
99
+ // the description lands in WWW-Authenticate, in front of anyone
100
+ return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError !== null ? "signing keys unavailable" : "no signing key for this kid");
98
101
  }
99
102
  try {
103
+ if (key === null) {
104
+ throw new Error("the key can't be used with this algorithm");
105
+ }
100
106
  await compactVerify(token, key, { algorithms: [alg] });
101
107
  }
102
108
  catch {
@@ -109,9 +115,14 @@ export class AccessTokenValidator {
109
115
  catch (exc) {
110
116
  return failure(errors.INVALID_TOKEN, errMessage(exc));
111
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.
112
121
  const types = this.config.acceptedTokenTypes;
113
122
  const typ = header.typ;
114
- if (typ != null && types !== null && (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
123
+ if (types !== null &&
124
+ !(typ == null && this.config.allowMissingTokenType) &&
125
+ (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
115
126
  return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
116
127
  }
117
128
  if (claims.iss !== this.config.issuer) {
@@ -174,15 +185,14 @@ export class AccessTokenValidator {
174
185
  "Content-Type": "application/x-www-form-urlencoded",
175
186
  Accept: "application/json",
176
187
  };
177
- const authMethod = cfg.authMethod ?? "client_secret_basic";
178
- if (authMethod === "client_secret_basic") {
179
- const raw = `${cfg.clientId}:${cfg.clientSecret}`;
180
- headers.Authorization = `Basic ${base64(raw)}`;
181
- }
182
- else {
188
+ if (cfg.authMethod === "client_secret_post") {
183
189
  form.set("client_id", cfg.clientId);
184
190
  form.set("client_secret", cfg.clientSecret);
185
191
  }
192
+ else {
193
+ // client_secret_basic: resolveConfig refuses anything but the two, or unset
194
+ headers.Authorization = basicAuthorization(cfg.clientId, cfg.clientSecret);
195
+ }
186
196
  try {
187
197
  const data = await this.#httpPost(cfg.endpoint, form.toString(), headers);
188
198
  if (data === null || typeof data !== "object" || Array.isArray(data)) {
@@ -232,8 +242,35 @@ export class AccessTokenValidator {
232
242
  }
233
243
  }
234
244
  }
235
- function base64(value) {
236
- return base64Encode(utf8Encode(value));
245
+ /**
246
+ * The `client_secret_basic` Authorization header value (RFC 6749 section 2.3.1): the client id and the secret
247
+ * are each form-urlencoded (Appendix B), joined with `:` and base64'd. The encoding is the same bytes as
248
+ * Python's `urllib.parse.quote_plus(s, safe="")`: each UTF-8 byte of `A-Z a-z 0-9 - . _ ~` stays as it is, a
249
+ * space becomes `+`, and every other byte is `%XX` with uppercase hex (so `:` is `%3A`, `%` is `%25`, `+` is
250
+ * `%2B` and `*` is `%2A`). Not `encodeURIComponent`, which leaves `! ' ( ) *` alone, nor `URLSearchParams`,
251
+ * which leaves `*` alone and encodes `~`. validation/SEMANTICS.md, Introspection.
252
+ *
253
+ * @internal
254
+ */
255
+ export function basicAuthorization(clientId, clientSecret) {
256
+ return `Basic ${base64Encode(utf8Encode(`${formEncode(clientId)}:${formEncode(clientSecret)}`))}`;
257
+ }
258
+ const HEX = "0123456789ABCDEF";
259
+ function formEncode(value) {
260
+ let out = "";
261
+ for (const b of utf8Encode(value)) {
262
+ const unreserved = (b >= 0x30 && b <= 0x39) || // 0-9
263
+ (b >= 0x41 && b <= 0x5a) || // A-Z
264
+ (b >= 0x61 && b <= 0x7a) || // a-z
265
+ b === 0x2d || b === 0x2e || b === 0x5f || b === 0x7e; // - . _ ~
266
+ if (unreserved)
267
+ out += String.fromCharCode(b);
268
+ else if (b === 0x20)
269
+ out += "+";
270
+ else
271
+ out += "%" + HEX[b >> 4] + HEX[b & 0x0f];
272
+ }
273
+ return out;
237
274
  }
238
275
  function errMessage(exc) {
239
276
  return exc instanceof Error ? exc.message : String(exc);
@@ -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 {};