@idpartners/client-attestation-sdk 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +111 -2
- package/dist/builders.d.ts +96 -0
- package/dist/builders.js +276 -0
- package/dist/credential.d.ts +22 -0
- package/dist/credential.js +44 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +28 -0
- package/dist/evidence.d.ts +70 -0
- package/dist/evidence.js +150 -0
- package/dist/http.d.ts +41 -0
- package/dist/http.js +158 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/issuance.d.ts +42 -0
- package/dist/issuance.js +110 -0
- package/dist/signer.d.ts +33 -0
- package/dist/signer.js +116 -0
- package/dist/signing-key-pair.d.ts +50 -0
- package/dist/signing-key-pair.js +115 -0
- package/dist/svid-source.d.ts +27 -0
- package/dist/svid-source.js +64 -0
- package/dist/token-source.d.ts +120 -0
- package/dist/token-source.js +424 -0
- package/dist/validator/config.d.ts +64 -0
- package/dist/validator/config.js +44 -0
- package/dist/validator/errors.d.ts +20 -0
- package/dist/validator/errors.js +18 -0
- package/dist/validator/index.d.ts +18 -0
- package/dist/validator/index.js +12 -0
- package/dist/validator/jwks.d.ts +39 -0
- package/dist/validator/jwks.js +135 -0
- package/dist/validator/resource.d.ts +42 -0
- package/dist/validator/resource.js +96 -0
- package/dist/validator/result.d.ts +19 -0
- package/dist/validator/result.js +26 -0
- package/dist/validator/validator.d.ts +33 -0
- package/dist/validator/validator.js +197 -0
- package/package.json +53 -4
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access-token validation: JWT signature + claim checks, and optional RFC 7662 introspection.
|
|
3
|
+
*
|
|
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,
|
|
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
|
+
* `validateActive` instead uses RFC 7662 introspection.
|
|
8
|
+
*/
|
|
9
|
+
import { compactVerify, decodeProtectedHeader } from "jose";
|
|
10
|
+
import * as errors from "./errors.js";
|
|
11
|
+
import { normaliseType, resolveConfig, } from "./config.js";
|
|
12
|
+
import { jsonOk, request } from "../http.js";
|
|
13
|
+
import { JwksProvider } from "./jwks.js";
|
|
14
|
+
import { failure, success } from "./result.js";
|
|
15
|
+
const defaultPost = async (url, body, headers) => {
|
|
16
|
+
// the shared transport never follows a redirect: it would carry the resource server's credentials
|
|
17
|
+
const resp = await request(url, { method: "POST", body, headers, timeoutMs: 10_000 });
|
|
18
|
+
if (!resp.ok) {
|
|
19
|
+
throw new Error(`introspection failed: ${resp.status}`);
|
|
20
|
+
}
|
|
21
|
+
return jsonOk(resp);
|
|
22
|
+
};
|
|
23
|
+
/** Validates access tokens for a resource server. */
|
|
24
|
+
export class AccessTokenValidator {
|
|
25
|
+
config;
|
|
26
|
+
#jwks;
|
|
27
|
+
#httpPost;
|
|
28
|
+
constructor(config, opts = {}) {
|
|
29
|
+
this.config = resolveConfig(config);
|
|
30
|
+
this.#jwks = new JwksProvider({
|
|
31
|
+
jwks: this.config.jwks,
|
|
32
|
+
jwksUri: this.config.jwksUri,
|
|
33
|
+
httpGet: opts.httpGet,
|
|
34
|
+
});
|
|
35
|
+
this.#httpPost = opts.httpPost ?? defaultPost;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Locally validate a JWT access token. Returns the first failure in the fixed contract order, or a
|
|
39
|
+
* success result exposing subject / scopes / audience / claims.
|
|
40
|
+
*/
|
|
41
|
+
async validate(token, requiredScopes) {
|
|
42
|
+
const required = requiredScopes ?? this.config.requiredScopes;
|
|
43
|
+
let header;
|
|
44
|
+
try {
|
|
45
|
+
header = decodeProtectedHeader(token);
|
|
46
|
+
}
|
|
47
|
+
catch (exc) {
|
|
48
|
+
return failure(errors.INVALID_TOKEN, `malformed token: ${errMessage(exc)}`);
|
|
49
|
+
}
|
|
50
|
+
const alg = header.alg;
|
|
51
|
+
if (alg == null || !this.config.acceptedAlgorithms.includes(alg)) {
|
|
52
|
+
// never echo the unverified header alg back to the caller (it lands in WWW-Authenticate)
|
|
53
|
+
return failure(errors.UNSUPPORTED_ALGORITHM, "algorithm not accepted");
|
|
54
|
+
}
|
|
55
|
+
const key = await this.#jwks.resolve(header.kid, alg);
|
|
56
|
+
if (key == null) {
|
|
57
|
+
return failure(errors.KEY_NOT_FOUND, this.#jwks.lastError ?? "no signing key for this kid");
|
|
58
|
+
}
|
|
59
|
+
let claims;
|
|
60
|
+
try {
|
|
61
|
+
const { payload } = await compactVerify(token, key, { algorithms: [alg] });
|
|
62
|
+
claims = JSON.parse(new TextDecoder().decode(payload));
|
|
63
|
+
}
|
|
64
|
+
catch (exc) {
|
|
65
|
+
return failure(errors.INVALID_SIGNATURE, errMessage(exc));
|
|
66
|
+
}
|
|
67
|
+
if (claims === null || typeof claims !== "object" || Array.isArray(claims)) {
|
|
68
|
+
return failure(errors.INVALID_TOKEN, "payload is not a JSON object");
|
|
69
|
+
}
|
|
70
|
+
const types = this.config.acceptedTokenTypes;
|
|
71
|
+
if (header.typ != null && types !== null && !types.includes(normaliseType(String(header.typ)))) {
|
|
72
|
+
return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
|
|
73
|
+
}
|
|
74
|
+
if (claims.iss !== this.config.issuer) {
|
|
75
|
+
return failure(errors.INVALID_ISSUER, "unexpected issuer");
|
|
76
|
+
}
|
|
77
|
+
const now = Math.floor(Date.now() / 1000);
|
|
78
|
+
const leeway = this.config.leewaySeconds;
|
|
79
|
+
let exp;
|
|
80
|
+
let nbf;
|
|
81
|
+
let iat;
|
|
82
|
+
try {
|
|
83
|
+
exp = numericDate(claims, "exp");
|
|
84
|
+
nbf = numericDate(claims, "nbf");
|
|
85
|
+
iat = numericDate(claims, "iat");
|
|
86
|
+
}
|
|
87
|
+
catch (exc) {
|
|
88
|
+
return failure(errors.INVALID_TOKEN, errMessage(exc));
|
|
89
|
+
}
|
|
90
|
+
if (exp === undefined) {
|
|
91
|
+
return failure(errors.MISSING_EXPIRY, "token has no exp claim");
|
|
92
|
+
}
|
|
93
|
+
if (now > exp + leeway) {
|
|
94
|
+
return failure(errors.EXPIRED, "token has expired");
|
|
95
|
+
}
|
|
96
|
+
if (nbf !== undefined && now + leeway < nbf) {
|
|
97
|
+
return failure(errors.NOT_YET_VALID, "token is not yet valid");
|
|
98
|
+
}
|
|
99
|
+
if (iat !== undefined && now + leeway < iat) {
|
|
100
|
+
return failure(errors.ISSUED_IN_FUTURE, "token was issued in the future");
|
|
101
|
+
}
|
|
102
|
+
const audience = asList(claims.aud);
|
|
103
|
+
if (!this.config.audiences.some((a) => audience.includes(a))) {
|
|
104
|
+
return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
|
|
105
|
+
}
|
|
106
|
+
const granted = scopesOf(claims);
|
|
107
|
+
const missing = required.filter((s) => !granted.includes(s));
|
|
108
|
+
if (missing.length > 0) {
|
|
109
|
+
return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
|
|
110
|
+
}
|
|
111
|
+
return success(claims, granted, audience);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* RFC 7662: POST the token to the AS introspection endpoint and return the parsed response.
|
|
115
|
+
*/
|
|
116
|
+
async introspect(token, tokenTypeHint = "access_token") {
|
|
117
|
+
const cfg = this.config.introspection;
|
|
118
|
+
if (cfg == null) {
|
|
119
|
+
throw new Error("no introspection endpoint configured");
|
|
120
|
+
}
|
|
121
|
+
const form = new URLSearchParams();
|
|
122
|
+
form.set("token", token);
|
|
123
|
+
form.set("token_type_hint", tokenTypeHint);
|
|
124
|
+
const headers = {
|
|
125
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
126
|
+
Accept: "application/json",
|
|
127
|
+
};
|
|
128
|
+
const authMethod = cfg.authMethod ?? "client_secret_basic";
|
|
129
|
+
if (authMethod === "client_secret_basic") {
|
|
130
|
+
const raw = `${cfg.clientId}:${cfg.clientSecret}`;
|
|
131
|
+
headers.Authorization = `Basic ${base64(raw)}`;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
form.set("client_id", cfg.clientId);
|
|
135
|
+
form.set("client_secret", cfg.clientSecret);
|
|
136
|
+
}
|
|
137
|
+
return this.#httpPost(cfg.endpoint, form.toString(), headers);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Introspect the token and enforce `active` plus scope/audience from the response.
|
|
141
|
+
*/
|
|
142
|
+
async validateActive(token, requiredScopes) {
|
|
143
|
+
const data = await this.introspect(token);
|
|
144
|
+
if (!data.active) {
|
|
145
|
+
return failure(errors.INACTIVE, "token is not active");
|
|
146
|
+
}
|
|
147
|
+
const required = requiredScopes ?? this.config.requiredScopes;
|
|
148
|
+
const granted = scopesOf(data);
|
|
149
|
+
const missing = required.filter((s) => !granted.includes(s));
|
|
150
|
+
if (missing.length > 0) {
|
|
151
|
+
return failure(errors.INSUFFICIENT_SCOPE, `missing scopes: ${missing.join(" ")}`);
|
|
152
|
+
}
|
|
153
|
+
const audience = asList(data.aud);
|
|
154
|
+
if (this.config.audiences.length > 0 &&
|
|
155
|
+
audience.length > 0 &&
|
|
156
|
+
!this.config.audiences.some((a) => audience.includes(a))) {
|
|
157
|
+
return failure(errors.INVALID_AUDIENCE, "token audience is not accepted");
|
|
158
|
+
}
|
|
159
|
+
return success(data, granted, audience);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** A NumericDate claim as an integer, `undefined` when absent. Anything but a finite number is malformed. */
|
|
163
|
+
function numericDate(claims, name) {
|
|
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);
|
|
189
|
+
}
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
function base64(value) {
|
|
193
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
194
|
+
}
|
|
195
|
+
function errMessage(exc) {
|
|
196
|
+
return exc instanceof Error ? exc.message : String(exc);
|
|
197
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idpartners/client-attestation-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./validator": {
|
|
14
|
+
"types": "./dist/validator/index.d.ts",
|
|
15
|
+
"import": "./dist/validator/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"prepack": "npm run build",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"coverage": "vitest run --coverage",
|
|
31
|
+
"emit": "tsx emit.ts",
|
|
32
|
+
"validate-emit": "tsx validate-emit.ts"
|
|
33
|
+
},
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"author": "ID Partners Pty Ltd",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/ID-Partners/client-attestation-sdk.git",
|
|
42
|
+
"directory": "typescript"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/ID-Partners/client-attestation-sdk/tree/main/typescript",
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"jose": "^6.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20.0.0",
|
|
50
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
51
|
+
"tsx": "^4.0.0",
|
|
52
|
+
"typescript": "^5.4.0",
|
|
53
|
+
"vitest": "^2.0.0"
|
|
54
|
+
}
|
|
55
|
+
}
|