@idpartners/client-attestation-sdk 0.2.0 → 1.0.1
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 +35 -4
- package/dist/attester.d.ts +93 -0
- package/dist/attester.js +139 -0
- package/dist/builders.d.ts +4 -8
- package/dist/builders.js +18 -10
- package/dist/evidence.d.ts +4 -19
- package/dist/evidence.js +38 -20
- package/dist/http.d.ts +1 -41
- package/dist/http.js +30 -5
- package/dist/index.d.ts +11 -3
- package/dist/index.js +8 -2
- package/dist/issuance.d.ts +22 -3
- package/dist/issuance.js +37 -30
- package/dist/logging.d.ts +12 -0
- package/dist/logging.js +77 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +64 -0
- package/dist/signer.d.ts +2 -3
- package/dist/signer.js +9 -4
- package/dist/signing-key-pair.d.ts +8 -14
- package/dist/signing-key-pair.js +20 -5
- package/dist/svid-source.d.ts +15 -1
- package/dist/svid-source.js +20 -7
- package/dist/token-source.d.ts +43 -16
- package/dist/token-source.js +162 -75
- package/dist/validator/config.d.ts +5 -8
- package/dist/validator/config.js +22 -5
- package/dist/validator/index.d.ts +4 -3
- package/dist/validator/index.js +3 -3
- package/dist/validator/jwks.d.ts +0 -37
- package/dist/validator/jwks.js +17 -2
- package/dist/validator/resource.d.ts +1 -6
- package/dist/validator/resource.js +10 -1
- package/dist/validator/result.d.ts +0 -2
- package/dist/validator/result.js +2 -0
- package/dist/validator/strict.d.ts +1 -0
- package/dist/validator/strict.js +202 -0
- package/dist/validator/validator.d.ts +8 -0
- package/dist/validator/validator.js +120 -77
- package/package.json +9 -6
|
@@ -6,83 +6,126 @@
|
|
|
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
|
*/
|
|
9
|
-
import { compactVerify
|
|
9
|
+
import { compactVerify } from "jose";
|
|
10
|
+
import { base64Encode, utf8Encode } from "../runtime.js";
|
|
10
11
|
import * as errors from "./errors.js";
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
12
|
+
import * as strict from "./strict.js";
|
|
13
|
+
import { isForbiddenAlgorithm, normaliseType, resolveConfig, } from "./config.js";
|
|
14
|
+
import { TransportError } from "../errors.js";
|
|
15
|
+
import { request } from "../http.js";
|
|
16
|
+
import { emit, failureReason, NOOP_LOGGER, urlParts } from "../logging.js";
|
|
13
17
|
import { JwksProvider } from "./jwks.js";
|
|
14
18
|
import { failure, success } from "./result.js";
|
|
19
|
+
/**
|
|
20
|
+
* An introspection response larger than this is refused.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_INTROSPECTION_BYTES = 1024 * 1024;
|
|
15
25
|
const defaultPost = async (url, body, headers) => {
|
|
16
|
-
// the shared transport never follows a redirect
|
|
17
|
-
|
|
26
|
+
// the shared transport never follows a redirect (it would carry the resource server's credentials) and
|
|
27
|
+
// refuses a body over the cap
|
|
28
|
+
const resp = await request(url, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
body,
|
|
31
|
+
headers,
|
|
32
|
+
timeoutMs: 10_000,
|
|
33
|
+
maxBytes: MAX_INTROSPECTION_BYTES,
|
|
34
|
+
});
|
|
18
35
|
if (!resp.ok) {
|
|
19
|
-
throw new
|
|
36
|
+
throw new TransportError(`introspection failed: ${resp.status}`, "status", { status: resp.status });
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return strict.parseObject(utf8Encode(resp.body), "introspection response");
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
throw new TransportError(`introspection endpoint returned ${errMessage(err)}`, "json", {
|
|
43
|
+
status: resp.status,
|
|
44
|
+
});
|
|
20
45
|
}
|
|
21
|
-
return jsonOk(resp);
|
|
22
46
|
};
|
|
23
47
|
/** Validates access tokens for a resource server. */
|
|
24
48
|
export class AccessTokenValidator {
|
|
25
49
|
config;
|
|
26
50
|
#jwks;
|
|
27
51
|
#httpPost;
|
|
52
|
+
#logger;
|
|
53
|
+
#clock;
|
|
28
54
|
constructor(config, opts = {}) {
|
|
29
55
|
this.config = resolveConfig(config);
|
|
30
56
|
this.#jwks = new JwksProvider({
|
|
31
57
|
jwks: this.config.jwks,
|
|
32
58
|
jwksUri: this.config.jwksUri,
|
|
33
59
|
httpGet: opts.httpGet,
|
|
60
|
+
logger: opts.logger,
|
|
34
61
|
});
|
|
35
62
|
this.#httpPost = opts.httpPost ?? defaultPost;
|
|
63
|
+
this.#logger = opts.logger ?? NOOP_LOGGER;
|
|
64
|
+
this.#clock = opts.clock ?? Date.now;
|
|
36
65
|
}
|
|
37
66
|
/**
|
|
38
67
|
* Locally validate a JWT access token. Returns the first failure in the fixed contract order, or a
|
|
39
68
|
* success result exposing subject / scopes / audience / claims.
|
|
40
69
|
*/
|
|
41
70
|
async validate(token, requiredScopes) {
|
|
71
|
+
return this.#verdict("jwt", await this.#validate(token, requiredScopes));
|
|
72
|
+
}
|
|
73
|
+
/** Log a verdict (its error code only, never the token) and return it. */
|
|
74
|
+
#verdict(mode, result) {
|
|
75
|
+
emit(this.#logger, result.valid ? "debug" : "info", "token.validated", { result: result.valid ? "valid" : result.error, mode });
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
async #validate(token, requiredScopes) {
|
|
42
79
|
const required = requiredScopes ?? this.config.requiredScopes;
|
|
43
|
-
let
|
|
80
|
+
let parts;
|
|
44
81
|
try {
|
|
45
|
-
|
|
82
|
+
parts = strict.splitCompact(token);
|
|
46
83
|
}
|
|
47
|
-
catch
|
|
48
|
-
|
|
84
|
+
catch {
|
|
85
|
+
// never echo token data to the caller: the description lands in WWW-Authenticate
|
|
86
|
+
return failure(errors.INVALID_TOKEN, "malformed token");
|
|
49
87
|
}
|
|
88
|
+
const { header } = parts;
|
|
50
89
|
const alg = header.alg;
|
|
51
|
-
if (alg
|
|
90
|
+
if (typeof alg !== "string" || isForbiddenAlgorithm(alg) || !this.config.acceptedAlgorithms.includes(alg)) {
|
|
52
91
|
// never echo the unverified header alg back to the caller (it lands in WWW-Authenticate)
|
|
53
92
|
return failure(errors.UNSUPPORTED_ALGORITHM, "algorithm not accepted");
|
|
54
93
|
}
|
|
55
|
-
const
|
|
94
|
+
const kid = header.kid;
|
|
95
|
+
const key = kid == null || typeof kid === "string" ? await this.#jwks.resolve(kid ?? undefined, alg) : null;
|
|
56
96
|
if (key == null) {
|
|
57
97
|
return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError ?? "no signing key for this kid");
|
|
58
98
|
}
|
|
99
|
+
try {
|
|
100
|
+
await compactVerify(token, key, { algorithms: [alg] });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return failure(errors.INVALID_SIGNATURE, "signature verification failed");
|
|
104
|
+
}
|
|
59
105
|
let claims;
|
|
60
106
|
try {
|
|
61
|
-
|
|
62
|
-
claims = JSON.parse(new TextDecoder().decode(payload));
|
|
107
|
+
claims = strict.parseObject(parts.payload);
|
|
63
108
|
}
|
|
64
109
|
catch (exc) {
|
|
65
|
-
return failure(errors.
|
|
66
|
-
}
|
|
67
|
-
if (claims === null || typeof claims !== "object" || Array.isArray(claims)) {
|
|
68
|
-
return failure(errors.INVALID_TOKEN, "payload is not a JSON object");
|
|
110
|
+
return failure(errors.INVALID_TOKEN, errMessage(exc));
|
|
69
111
|
}
|
|
70
112
|
const types = this.config.acceptedTokenTypes;
|
|
71
|
-
|
|
113
|
+
const typ = header.typ;
|
|
114
|
+
if (typ != null && types !== null && (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
|
|
72
115
|
return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
|
|
73
116
|
}
|
|
74
117
|
if (claims.iss !== this.config.issuer) {
|
|
75
118
|
return failure(errors.INVALID_ISSUER, "unexpected issuer");
|
|
76
119
|
}
|
|
77
|
-
const now = Math.floor(
|
|
120
|
+
const now = Math.floor(this.#clock() / 1000);
|
|
78
121
|
const leeway = this.config.leewaySeconds;
|
|
79
122
|
let exp;
|
|
80
123
|
let nbf;
|
|
81
124
|
let iat;
|
|
82
125
|
try {
|
|
83
|
-
exp = numericDate(claims, "exp");
|
|
84
|
-
nbf = numericDate(claims, "nbf");
|
|
85
|
-
iat = numericDate(claims, "iat");
|
|
126
|
+
exp = strict.numericDate(claims, "exp");
|
|
127
|
+
nbf = strict.numericDate(claims, "nbf");
|
|
128
|
+
iat = strict.numericDate(claims, "iat");
|
|
86
129
|
}
|
|
87
130
|
catch (exc) {
|
|
88
131
|
return failure(errors.INVALID_TOKEN, errMessage(exc));
|
|
@@ -99,16 +142,22 @@ export class AccessTokenValidator {
|
|
|
99
142
|
if (iat !== undefined && now + leeway < iat) {
|
|
100
143
|
return failure(errors.ISSUED_IN_FUTURE, "token was issued in the future");
|
|
101
144
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
145
|
+
try {
|
|
146
|
+
const audience = strict.audience(claims);
|
|
147
|
+
if (!this.config.audiences.some((a) => audience.includes(a))) {
|
|
148
|
+
return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
|
|
149
|
+
}
|
|
150
|
+
const granted = strict.scopes(claims);
|
|
151
|
+
const missing = required.filter((s) => !granted.includes(s));
|
|
152
|
+
if (missing.length > 0) {
|
|
153
|
+
return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
|
|
154
|
+
}
|
|
155
|
+
strict.subject(claims);
|
|
156
|
+
return success(claims, granted, audience);
|
|
105
157
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (missing.length > 0) {
|
|
109
|
-
return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
|
|
158
|
+
catch (exc) {
|
|
159
|
+
return failure(errors.INVALID_TOKEN, errMessage(exc));
|
|
110
160
|
}
|
|
111
|
-
return success(claims, granted, audience);
|
|
112
161
|
}
|
|
113
162
|
/**
|
|
114
163
|
* RFC 7662: POST the token to the AS introspection endpoint and return the parsed response.
|
|
@@ -134,63 +183,57 @@ export class AccessTokenValidator {
|
|
|
134
183
|
form.set("client_id", cfg.clientId);
|
|
135
184
|
form.set("client_secret", cfg.clientSecret);
|
|
136
185
|
}
|
|
137
|
-
|
|
186
|
+
try {
|
|
187
|
+
const data = await this.#httpPost(cfg.endpoint, form.toString(), headers);
|
|
188
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
189
|
+
throw new TransportError("introspection endpoint returned JSON that is not an object", "json");
|
|
190
|
+
}
|
|
191
|
+
return data;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
emit(this.#logger, "warn", "http.failed", {
|
|
195
|
+
step: "introspection",
|
|
196
|
+
method: "POST",
|
|
197
|
+
...urlParts(cfg.endpoint),
|
|
198
|
+
status: err instanceof TransportError ? err.status : undefined,
|
|
199
|
+
reason: failureReason(err),
|
|
200
|
+
});
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
138
203
|
}
|
|
139
204
|
/**
|
|
140
205
|
* Introspect the token and enforce `active` plus scope/audience from the response.
|
|
141
206
|
*/
|
|
142
207
|
async validateActive(token, requiredScopes) {
|
|
208
|
+
return this.#verdict("introspection", await this.#validateActive(token, requiredScopes));
|
|
209
|
+
}
|
|
210
|
+
async #validateActive(token, requiredScopes) {
|
|
143
211
|
const data = await this.introspect(token);
|
|
144
|
-
|
|
212
|
+
// RFC 7662 section 2.2: active is a boolean, so only JSON true counts
|
|
213
|
+
if (data.active !== true) {
|
|
145
214
|
return failure(errors.INACTIVE, "token is not active");
|
|
146
215
|
}
|
|
147
216
|
const required = requiredScopes ?? this.config.requiredScopes;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
audience.length > 0 &&
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const value = claims[name];
|
|
165
|
-
if (value == null) {
|
|
166
|
-
return undefined;
|
|
167
|
-
}
|
|
168
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
169
|
-
throw new Error(`${name} is not a NumericDate`);
|
|
170
|
-
}
|
|
171
|
-
return Math.trunc(value);
|
|
172
|
-
}
|
|
173
|
-
/** Normalize an `aud` claim (string | string[] | absent) to a list of strings. */
|
|
174
|
-
function asList(value) {
|
|
175
|
-
if (value == null) {
|
|
176
|
-
return [];
|
|
177
|
-
}
|
|
178
|
-
return Array.isArray(value) ? value.map(String) : [String(value)];
|
|
179
|
-
}
|
|
180
|
-
/** Granted scopes from `scope` (space-delimited string) or `scp` (array). */
|
|
181
|
-
function scopesOf(claims) {
|
|
182
|
-
const scope = claims.scope;
|
|
183
|
-
if (typeof scope === "string") {
|
|
184
|
-
return scope.split(/\s+/).filter((s) => s.length > 0);
|
|
185
|
-
}
|
|
186
|
-
const scp = claims.scp;
|
|
187
|
-
if (Array.isArray(scp)) {
|
|
188
|
-
return scp.map(String);
|
|
217
|
+
try {
|
|
218
|
+
const granted = strict.scopes(data);
|
|
219
|
+
const missing = required.filter((s) => !granted.includes(s));
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
|
|
222
|
+
}
|
|
223
|
+
const audience = strict.audience(data);
|
|
224
|
+
if (audience.length > 0 && !this.config.audiences.some((a) => audience.includes(a))) {
|
|
225
|
+
return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
|
|
226
|
+
}
|
|
227
|
+
strict.subject(data);
|
|
228
|
+
return success(data, granted, audience);
|
|
229
|
+
}
|
|
230
|
+
catch (exc) {
|
|
231
|
+
return failure(errors.INVALID_TOKEN, errMessage(exc));
|
|
232
|
+
}
|
|
189
233
|
}
|
|
190
|
-
return [];
|
|
191
234
|
}
|
|
192
235
|
function base64(value) {
|
|
193
|
-
return
|
|
236
|
+
return base64Encode(utf8Encode(value));
|
|
194
237
|
}
|
|
195
238
|
function errMessage(exc) {
|
|
196
239
|
return exc instanceof Error ? exc.message : String(exc);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idpartners/client-attestation-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"prepack": "npm run build",
|
|
29
29
|
"test": "vitest run",
|
|
30
30
|
"coverage": "vitest run --coverage",
|
|
31
|
+
"test:edge": "node test/edge/run-workerd.mjs",
|
|
31
32
|
"emit": "tsx emit.ts",
|
|
32
33
|
"validate-emit": "tsx validate-emit.ts"
|
|
33
34
|
},
|
|
@@ -43,13 +44,15 @@
|
|
|
43
44
|
},
|
|
44
45
|
"homepage": "https://github.com/ID-Partners/client-attestation-sdk/tree/main/typescript",
|
|
45
46
|
"dependencies": {
|
|
46
|
-
"jose": "^6.
|
|
47
|
+
"jose": "^6.2.12"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"@types/node": "^20.0.0",
|
|
50
|
-
"@vitest/coverage-v8": "^
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
51
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
52
|
+
"esbuild": "^0.28.1",
|
|
53
|
+
"tsx": "^4.23.15",
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
|
+
"vitest": "^4.1.11",
|
|
56
|
+
"workerd": "1.20261007.1"
|
|
54
57
|
}
|
|
55
58
|
}
|