@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.
- package/README.md +142 -22
- package/dist/attester.d.ts +23 -9
- package/dist/attester.js +40 -14
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/credential.d.ts +2 -0
- package/dist/credential.js +2 -0
- package/dist/errors.d.ts +6 -2
- package/dist/errors.js +6 -2
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +46 -8
- package/dist/http.js +69 -14
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/issuance.d.ts +19 -5
- package/dist/issuance.js +38 -15
- package/dist/logging.js +41 -7
- package/dist/runtime.js +9 -0
- package/dist/signer.d.ts +8 -1
- package/dist/signer.js +32 -5
- package/dist/signing-key-pair.d.ts +6 -0
- package/dist/signing-key-pair.js +91 -5
- package/dist/svid-source.d.ts +14 -2
- package/dist/svid-source.js +200 -16
- package/dist/token-source.d.ts +28 -4
- package/dist/token-source.js +285 -63
- package/dist/validator/config.d.ts +39 -5
- package/dist/validator/config.js +89 -8
- package/dist/validator/index.d.ts +2 -2
- package/dist/validator/index.js +2 -2
- package/dist/validator/jwks.js +101 -22
- package/dist/validator/resource.d.ts +8 -0
- package/dist/validator/resource.js +50 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +50 -13
- package/dist/www-authenticate.d.ts +7 -0
- package/dist/www-authenticate.js +114 -0
- package/package.json +2 -1
|
@@ -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
|
-
/**
|
|
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
|
|
28
|
-
* token
|
|
29
|
-
*
|
|
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
|
}
|
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
|
|
@@ -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] :
|
|
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
|
-
|
|
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:
|
|
56
|
-
acceptedAlgorithms:
|
|
57
|
-
leewaySeconds:
|
|
58
|
-
introspection: config.introspection
|
|
59
|
-
|
|
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
|
|
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.0
|
|
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
|
|
@@ -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
|
-
/**
|
|
52
|
-
#keys =
|
|
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.#
|
|
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 =
|
|
75
|
-
for (const
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
89
|
-
*
|
|
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
|
|
96
|
-
|
|
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
|
|
144
|
+
const entry = this.#lookup(kid, alg);
|
|
145
|
+
if (entry === undefined) {
|
|
100
146
|
return undefined;
|
|
101
147
|
}
|
|
102
|
-
|
|
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
|
-
|
|
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.
|
|
161
|
+
return kid === "" ? undefined : this.#keys.find((entry) => entry.kid === kid && allowed(entry));
|
|
107
162
|
}
|
|
108
|
-
return this.#keys.
|
|
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
|
-
|
|
174
|
+
const age = this.#age();
|
|
175
|
+
return age === null || age >= this.#maxAgeMs || this.#pastLimit(age);
|
|
112
176
|
}
|
|
113
|
-
|
|
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.
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
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
|
}
|
package/dist/validator/strict.js
CHANGED
|
@@ -19,7 +19,12 @@ const SEGMENT = /^[A-Za-z0-9_-]*$/;
|
|
|
19
19
|
*/
|
|
20
20
|
export class Malformed extends Error {
|
|
21
21
|
}
|
|
22
|
-
|
|
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` (
|
|
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) :
|
|
96
|
-
if (key
|
|
97
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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 {};
|