@cboxdk/id-js 0.20.0 → 0.21.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/CHANGELOG.md +28 -0
- package/README.md +27 -0
- package/dist/agent.cjs +613 -0
- package/dist/agent.cjs.map +1 -0
- package/dist/agent.d.cts +400 -0
- package/dist/agent.d.ts +400 -0
- package/dist/agent.js +527 -0
- package/dist/agent.js.map +1 -0
- package/dist/{chunk-YGLMLIKL.js → chunk-T5LCQOLS.js} +4 -22
- package/dist/chunk-T5LCQOLS.js.map +1 -0
- package/dist/chunk-VGQASHTI.js +22 -0
- package/dist/chunk-VGQASHTI.js.map +1 -0
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/nextjs.js +2 -1
- package/dist/nextjs.js.map +1 -1
- package/package.json +11 -3
- package/dist/chunk-YGLMLIKL.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
All notable changes to `@cboxdk/id-js` are recorded here. Earlier releases are described
|
|
4
4
|
in their [GitHub releases](https://github.com/cboxdk/id-js/releases).
|
|
5
5
|
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
## [0.21.0] - 2026-11-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `@cboxdk/id-js/agent`: OpenID CIBA (poll mode) and the RFC 8628 device grant for agents and
|
|
13
|
+
CLIs, so they no longer need their own polling loop. `startBackchannel` / `pollBackchannel` /
|
|
14
|
+
`waitForBackchannelToken`, `startDeviceAuthorization` / `pollDeviceAuthorization` /
|
|
15
|
+
`waitForDeviceToken` and `refreshAgentTokens`. The wait helpers use the server's `interval`,
|
|
16
|
+
add 5 s on `slow_down` (and on 429, waiting at least `Retry-After`), keep polling on
|
|
17
|
+
`authorization_pending`, stop at `expires_in`, and take an `AbortSignal` and an injectable
|
|
18
|
+
`sleep`. `resource` (RFC 8707) is re-sent on every poll. No `user_code` is ever sent.
|
|
19
|
+
- `registerAgentClient()`: RFC 7591 dynamic registration with `private_key_jwt` (an ES256 or
|
|
20
|
+
RS256 key from `generateClientKeyPair()`, sent inline as `jwks`) or `none`.
|
|
21
|
+
`createClientAssertion()` signs the RFC 7523 assertion (`aud` = token endpoint, single-use
|
|
22
|
+
`jti`, at most 60 s). `startBackchannel` also accepts a keyless `none` client (sends
|
|
23
|
+
`client_id` only), which Cbox ID allows only where the resource permits it.
|
|
24
|
+
- `discoverAuthorizationServer()` reads `/.well-known/oauth-authorization-server` and checks
|
|
25
|
+
the issuer.
|
|
26
|
+
- `createBindingCode()`: a short code from an alphabet without look-alike characters, for the
|
|
27
|
+
`binding_message` that Cbox ID requires from self-registered clients.
|
|
28
|
+
- Typed errors: `AccessDeniedError`, `ExpiredTokenError`, `InvalidGrantError`,
|
|
29
|
+
`InvalidTargetError`, `InvalidClientError`, `UnknownUserIdError`,
|
|
30
|
+
`InvalidBindingMessageError`, `SlowDownError` (with `retryAfter`), all subclasses of
|
|
31
|
+
`AgentGrantError` (an `AuthenticationError`), plus `GrantCancelledError`.
|
|
32
|
+
- Guide: `docs/ciba-and-device-grant.md`.
|
|
33
|
+
|
|
6
34
|
## [0.20.0] - 2026-11-07
|
|
7
35
|
|
|
8
36
|
### Upgrade
|
package/README.md
CHANGED
|
@@ -276,6 +276,33 @@ want the person re-approving every hour.
|
|
|
276
276
|
See [Sign in from a CLI](https://github.com/cboxdk/cbox-id/blob/main/docs/getting-started/sign-in-from-a-cli.md)
|
|
277
277
|
for the protocol itself and where to store the tokens.
|
|
278
278
|
|
|
279
|
+
### Agents and self-registering CLIs (CIBA and device grant)
|
|
280
|
+
|
|
281
|
+
`@cboxdk/id-js/agent` is for a CLI or an agent that registers itself (RFC 7591) and gets a
|
|
282
|
+
token for a person without a redirect: OpenID CIBA, where the person taps Approve in Cbox ID,
|
|
283
|
+
or the device grant. It generates a `private_key_jwt` key with WebCrypto, signs the client
|
|
284
|
+
assertions, and polls following the server's rules (`interval`, `slow_down`, 429
|
|
285
|
+
`Retry-After`, expiry, Ctrl-C). Refusals are typed errors.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { startBackchannel, waitForBackchannelToken, createBindingCode } from '@cboxdk/id-js/agent';
|
|
289
|
+
|
|
290
|
+
const code = createBindingCode(); // e.g. "K7QM", shown beside Approve
|
|
291
|
+
const authorization = await startBackchannel({
|
|
292
|
+
issuer, client, loginHint: 'ada@acme.com', scope: 'openid mcp:read',
|
|
293
|
+
bindingMessage: `Brain CLI: ${code}`, resource: 'https://app.cboxbrain.com/mcp',
|
|
294
|
+
});
|
|
295
|
+
console.log(`Approve in Cbox ID. Check that it shows ${code}.`);
|
|
296
|
+
const tokens = await waitForBackchannelToken({ issuer, client, authorization });
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Self-registered clients must send `resource` and `binding_message`. Register with
|
|
300
|
+
`private_key_jwt` for CIBA: a keyless (`none`) client can use CIBA only where the resource
|
|
301
|
+
allows it, and is otherwise refused with `invalid_target`.
|
|
302
|
+
|
|
303
|
+
See [docs/ciba-and-device-grant.md](docs/ciba-and-device-grant.md) for when to use each flow,
|
|
304
|
+
a complete Node CLI and the error table.
|
|
305
|
+
|
|
279
306
|
## In the browser (publishable keys)
|
|
280
307
|
|
|
281
308
|
Everything above needs a server: it holds your client secret. A **publishable key** is the
|
package/dist/agent.cjs
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jose = require('jose');
|
|
4
|
+
|
|
5
|
+
// src/errors.ts
|
|
6
|
+
var CboxIdError = class extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = new.target.name;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var ConfigurationError = class extends CboxIdError {
|
|
13
|
+
};
|
|
14
|
+
var AuthenticationError = class extends CboxIdError {
|
|
15
|
+
constructor(message, error, errorDescription, status, retryAfter) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.error = error;
|
|
18
|
+
this.errorDescription = errorDescription;
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.retryAfter = retryAfter;
|
|
21
|
+
}
|
|
22
|
+
error;
|
|
23
|
+
errorDescription;
|
|
24
|
+
status;
|
|
25
|
+
retryAfter;
|
|
26
|
+
/** Whether waiting `retryAfter` seconds and repeating the same request is worth it. */
|
|
27
|
+
get isRateLimited() {
|
|
28
|
+
return this.status === 429;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// src/issuer.ts
|
|
33
|
+
function assertSecureIssuer(issuer) {
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(issuer);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
|
|
39
|
+
}
|
|
40
|
+
if (url.protocol === "https:") {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
44
|
+
if (url.protocol === "http:" && loopback) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
throw new ConfigurationError(
|
|
48
|
+
`Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/agent/errors.ts
|
|
53
|
+
var AgentGrantError = class extends AuthenticationError {
|
|
54
|
+
};
|
|
55
|
+
var AccessDeniedError = class extends AgentGrantError {
|
|
56
|
+
};
|
|
57
|
+
var ExpiredTokenError = class extends AgentGrantError {
|
|
58
|
+
};
|
|
59
|
+
var InvalidGrantError = class extends AgentGrantError {
|
|
60
|
+
};
|
|
61
|
+
var InvalidTargetError = class extends AgentGrantError {
|
|
62
|
+
};
|
|
63
|
+
var InvalidClientError = class extends AgentGrantError {
|
|
64
|
+
};
|
|
65
|
+
var UnknownUserIdError = class extends AgentGrantError {
|
|
66
|
+
};
|
|
67
|
+
var InvalidBindingMessageError = class extends AgentGrantError {
|
|
68
|
+
};
|
|
69
|
+
var SlowDownError = class extends AgentGrantError {
|
|
70
|
+
};
|
|
71
|
+
var GrantCancelledError = class extends CboxIdError {
|
|
72
|
+
};
|
|
73
|
+
var BY_CODE = {
|
|
74
|
+
access_denied: AccessDeniedError,
|
|
75
|
+
expired_token: ExpiredTokenError,
|
|
76
|
+
invalid_grant: InvalidGrantError,
|
|
77
|
+
invalid_target: InvalidTargetError,
|
|
78
|
+
invalid_client: InvalidClientError,
|
|
79
|
+
unknown_user_id: UnknownUserIdError,
|
|
80
|
+
invalid_binding_message: InvalidBindingMessageError,
|
|
81
|
+
slow_down: SlowDownError
|
|
82
|
+
};
|
|
83
|
+
async function readOAuthFailure(response) {
|
|
84
|
+
const text = await response.text().catch(() => "");
|
|
85
|
+
let error;
|
|
86
|
+
let errorDescription;
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(text);
|
|
89
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
90
|
+
const record = parsed;
|
|
91
|
+
error = typeof record["error"] === "string" ? record["error"] : void 0;
|
|
92
|
+
errorDescription = typeof record["error_description"] === "string" ? record["error_description"] : void 0;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
if (response.status === 429 && error === void 0) {
|
|
97
|
+
error = "slow_down";
|
|
98
|
+
}
|
|
99
|
+
const header = response.headers.get("retry-after");
|
|
100
|
+
const retryAfter = header !== null && /^\d+$/.test(header.trim()) ? Number(header.trim()) : void 0;
|
|
101
|
+
return { error, errorDescription, status: response.status, retryAfter };
|
|
102
|
+
}
|
|
103
|
+
function grantError(failure, fallback) {
|
|
104
|
+
const Klass = (failure.error !== void 0 ? BY_CODE[failure.error] : void 0) ?? AgentGrantError;
|
|
105
|
+
const detail = failure.error ?? `HTTP ${failure.status}`;
|
|
106
|
+
return new Klass(`${fallback}: ${detail}`, failure.error, failure.errorDescription, failure.status, failure.retryAfter);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/agent/http.ts
|
|
110
|
+
async function discoverAuthorizationServer(issuer, options = {}) {
|
|
111
|
+
if (!issuer) {
|
|
112
|
+
throw new ConfigurationError("An `issuer` is required.");
|
|
113
|
+
}
|
|
114
|
+
assertSecureIssuer(issuer);
|
|
115
|
+
const url = `${trimSlash(issuer)}/.well-known/oauth-authorization-server`;
|
|
116
|
+
const response = await send(url, { method: "GET", headers: { accept: "application/json" } }, options);
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
throw new AuthenticationError(`Discovery request failed with status ${response.status}.`, void 0, void 0, response.status);
|
|
119
|
+
}
|
|
120
|
+
let doc;
|
|
121
|
+
try {
|
|
122
|
+
doc = await response.json();
|
|
123
|
+
} catch {
|
|
124
|
+
throw new AuthenticationError("The authorization server metadata was not JSON.");
|
|
125
|
+
}
|
|
126
|
+
if (typeof doc !== "object" || doc === null) {
|
|
127
|
+
throw new AuthenticationError("The authorization server metadata was not an object.");
|
|
128
|
+
}
|
|
129
|
+
const metadata = doc;
|
|
130
|
+
if (typeof metadata.issuer !== "string" || trimSlash(metadata.issuer) !== trimSlash(issuer)) {
|
|
131
|
+
throw new AuthenticationError(`The authorization server metadata is for a different issuer (${String(metadata.issuer)}).`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof metadata.token_endpoint !== "string" || metadata.token_endpoint === "") {
|
|
134
|
+
throw new AuthenticationError("The authorization server metadata has no token_endpoint.");
|
|
135
|
+
}
|
|
136
|
+
return metadata;
|
|
137
|
+
}
|
|
138
|
+
function resolveMetadata(options) {
|
|
139
|
+
return options.metadata !== void 0 ? Promise.resolve(options.metadata) : discoverAuthorizationServer(options.issuer, options);
|
|
140
|
+
}
|
|
141
|
+
function requireEndpoint(metadata, name, what) {
|
|
142
|
+
const value = metadata[name];
|
|
143
|
+
if (typeof value !== "string" || value === "") {
|
|
144
|
+
throw new ConfigurationError(`This Cbox ID instance does not advertise a ${name}, so it does not support ${what}.`);
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
async function send(url, init, options) {
|
|
149
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 1e4);
|
|
152
|
+
const onAbort = () => controller.abort();
|
|
153
|
+
if (options.signal?.aborted === true) {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
throw new GrantCancelledError("The request was cancelled.");
|
|
156
|
+
}
|
|
157
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
158
|
+
try {
|
|
159
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (options.signal?.aborted === true) {
|
|
162
|
+
throw new GrantCancelledError("The request was cancelled.");
|
|
163
|
+
}
|
|
164
|
+
if (controller.signal.aborted) {
|
|
165
|
+
throw new AuthenticationError(`The request to ${new URL(url).origin} timed out.`);
|
|
166
|
+
}
|
|
167
|
+
throw new AuthenticationError(`The request to ${new URL(url).origin} failed: ${String(error)}`);
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function trimSlash(value) {
|
|
174
|
+
return value.replace(/\/+$/, "");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/pkce.ts
|
|
178
|
+
function base64url(bytes) {
|
|
179
|
+
let binary = "";
|
|
180
|
+
for (const byte of bytes) {
|
|
181
|
+
binary += String.fromCharCode(byte);
|
|
182
|
+
}
|
|
183
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
184
|
+
}
|
|
185
|
+
function randomToken(bytes = 32) {
|
|
186
|
+
return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/agent/client-auth.ts
|
|
190
|
+
async function generateClientKeyPair(alg = "ES256") {
|
|
191
|
+
const { publicKey, privateKey } = await jose.generateKeyPair(alg, { extractable: true });
|
|
192
|
+
const publicBare = await jose.exportJWK(publicKey);
|
|
193
|
+
const kid = await jose.calculateJwkThumbprint(publicBare, "sha256");
|
|
194
|
+
const publicJwk = { ...publicBare, kid, alg, use: "sig" };
|
|
195
|
+
const privateJwk = { ...await jose.exportJWK(privateKey), kid, alg, use: "sig" };
|
|
196
|
+
return { alg, kid, publicJwk, privateJwk, privateKey };
|
|
197
|
+
}
|
|
198
|
+
async function createClientAssertion(auth, audience, options = {}) {
|
|
199
|
+
const lifetime = Math.min(Math.max(options.lifetimeSeconds ?? 60, 1), 60);
|
|
200
|
+
const nowSeconds = Math.floor((options.now ?? Date.now)() / 1e3);
|
|
201
|
+
const { key, alg, kid } = await signingKey(auth);
|
|
202
|
+
const header = { alg };
|
|
203
|
+
if (kid !== void 0) {
|
|
204
|
+
header.kid = kid;
|
|
205
|
+
}
|
|
206
|
+
return new jose.SignJWT({}).setProtectedHeader(header).setIssuer(auth.clientId).setSubject(auth.clientId).setAudience(audience).setJti(randomToken(16)).setIssuedAt(nowSeconds).setExpirationTime(nowSeconds + lifetime).sign(key);
|
|
207
|
+
}
|
|
208
|
+
async function applyClientAuth(auth, body, headers, audience) {
|
|
209
|
+
if (!auth.clientId) {
|
|
210
|
+
throw new ConfigurationError("A `clientId` is required.");
|
|
211
|
+
}
|
|
212
|
+
switch (auth.method) {
|
|
213
|
+
case "private_key_jwt":
|
|
214
|
+
body.set("client_id", auth.clientId);
|
|
215
|
+
body.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
|
|
216
|
+
body.set("client_assertion", await createClientAssertion(auth, audience));
|
|
217
|
+
return;
|
|
218
|
+
case "client_secret_basic":
|
|
219
|
+
if (!auth.clientSecret) {
|
|
220
|
+
throw new ConfigurationError("`client_secret_basic` needs a `clientSecret`.");
|
|
221
|
+
}
|
|
222
|
+
headers["authorization"] = `Basic ${btoa(`${formEncode(auth.clientId)}:${formEncode(auth.clientSecret)}`)}`;
|
|
223
|
+
return;
|
|
224
|
+
case "client_secret_post":
|
|
225
|
+
if (!auth.clientSecret) {
|
|
226
|
+
throw new ConfigurationError("`client_secret_post` needs a `clientSecret`.");
|
|
227
|
+
}
|
|
228
|
+
body.set("client_id", auth.clientId);
|
|
229
|
+
body.set("client_secret", auth.clientSecret);
|
|
230
|
+
return;
|
|
231
|
+
case "none":
|
|
232
|
+
body.set("client_id", auth.clientId);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function signingKey(auth) {
|
|
237
|
+
const source = auth.privateKey;
|
|
238
|
+
if (isCryptoKey(source)) {
|
|
239
|
+
const alg2 = auth.alg ?? algFromCryptoKey(source);
|
|
240
|
+
return { key: source, alg: alg2, kid: auth.kid };
|
|
241
|
+
}
|
|
242
|
+
if (typeof source !== "object" || source === null || typeof source.d !== "string") {
|
|
243
|
+
throw new ConfigurationError("`privateKey` must be a CryptoKey or a private JWK (with `d`).");
|
|
244
|
+
}
|
|
245
|
+
const alg = auth.alg ?? algFromJwk(source);
|
|
246
|
+
const key = await jose.importJWK(source, alg);
|
|
247
|
+
return { key, alg, kid: auth.kid ?? (typeof source.kid === "string" ? source.kid : void 0) };
|
|
248
|
+
}
|
|
249
|
+
function isCryptoKey(value) {
|
|
250
|
+
return typeof CryptoKey !== "undefined" && value instanceof CryptoKey;
|
|
251
|
+
}
|
|
252
|
+
function algFromCryptoKey(key) {
|
|
253
|
+
if (key.algorithm.name === "ECDSA") {
|
|
254
|
+
return "ES256";
|
|
255
|
+
}
|
|
256
|
+
if (key.algorithm.name === "RSASSA-PKCS1-v1_5") {
|
|
257
|
+
return "RS256";
|
|
258
|
+
}
|
|
259
|
+
throw new ConfigurationError(`Unsupported client key algorithm ${key.algorithm.name}; use ES256 or RS256.`);
|
|
260
|
+
}
|
|
261
|
+
function algFromJwk(jwk) {
|
|
262
|
+
if (jwk.alg === "ES256" || jwk.alg === "RS256") {
|
|
263
|
+
return jwk.alg;
|
|
264
|
+
}
|
|
265
|
+
if (jwk.alg === void 0 && jwk.kty === "EC" && jwk.crv === "P-256") {
|
|
266
|
+
return "ES256";
|
|
267
|
+
}
|
|
268
|
+
if (jwk.alg === void 0 && jwk.kty === "RSA") {
|
|
269
|
+
return "RS256";
|
|
270
|
+
}
|
|
271
|
+
throw new ConfigurationError("The private JWK is not an ES256 (P-256) or RS256 key.");
|
|
272
|
+
}
|
|
273
|
+
function formEncode(value) {
|
|
274
|
+
return encodeURIComponent(value).replace(/%20/g, "+");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/agent/register.ts
|
|
278
|
+
var CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba";
|
|
279
|
+
var DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
280
|
+
async function registerAgentClient(options) {
|
|
281
|
+
if (!options.clientName) {
|
|
282
|
+
throw new ConfigurationError("`clientName` is required.");
|
|
283
|
+
}
|
|
284
|
+
if (options.grantTypes.length === 0) {
|
|
285
|
+
throw new ConfigurationError("`grantTypes` must name at least one grant.");
|
|
286
|
+
}
|
|
287
|
+
const metadata = await resolveMetadata(options);
|
|
288
|
+
const endpoint = requireEndpoint(metadata, "registration_endpoint", "dynamic client registration");
|
|
289
|
+
const method = options.tokenEndpointAuthMethod ?? "private_key_jwt";
|
|
290
|
+
const keyPair = method === "private_key_jwt" ? options.keyPair ?? await generateClientKeyPair(options.alg ?? "ES256") : null;
|
|
291
|
+
const document = {
|
|
292
|
+
...options.extraMetadata,
|
|
293
|
+
client_name: options.clientName,
|
|
294
|
+
grant_types: options.grantTypes,
|
|
295
|
+
token_endpoint_auth_method: method,
|
|
296
|
+
// Neither CIBA nor the device grant has a redirect; say so rather than let the server
|
|
297
|
+
// assume an authorization-code client.
|
|
298
|
+
redirect_uris: [],
|
|
299
|
+
response_types: []
|
|
300
|
+
};
|
|
301
|
+
if (options.scope !== void 0) {
|
|
302
|
+
document["scope"] = scopeString(options.scope);
|
|
303
|
+
}
|
|
304
|
+
if (keyPair !== null) {
|
|
305
|
+
document["jwks"] = { keys: [keyPair.publicJwk] };
|
|
306
|
+
}
|
|
307
|
+
const headers = { "content-type": "application/json", accept: "application/json" };
|
|
308
|
+
if (options.initialAccessToken) {
|
|
309
|
+
headers["authorization"] = `Bearer ${options.initialAccessToken}`;
|
|
310
|
+
}
|
|
311
|
+
const response = await send(endpoint, { method: "POST", headers, body: JSON.stringify(document) }, options);
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
throw grantError(await readOAuthFailure(response), "Client registration failed");
|
|
314
|
+
}
|
|
315
|
+
const raw = await response.json().catch(() => null);
|
|
316
|
+
const clientId = raw?.["client_id"];
|
|
317
|
+
if (raw === null || typeof clientId !== "string" || clientId === "") {
|
|
318
|
+
throw new AuthenticationError("The registration response carried no client_id.");
|
|
319
|
+
}
|
|
320
|
+
const clientSecret = typeof raw["client_secret"] === "string" ? raw["client_secret"] : null;
|
|
321
|
+
const registeredMethod = typeof raw["token_endpoint_auth_method"] === "string" ? raw["token_endpoint_auth_method"] : method;
|
|
322
|
+
let auth;
|
|
323
|
+
if (keyPair !== null) {
|
|
324
|
+
auth = { method: "private_key_jwt", clientId, privateKey: keyPair.privateKey, alg: keyPair.alg, kid: keyPair.kid };
|
|
325
|
+
} else if (clientSecret !== null) {
|
|
326
|
+
auth = { method: "client_secret_basic", clientId, clientSecret };
|
|
327
|
+
} else {
|
|
328
|
+
auth = { method: "none", clientId };
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
clientId,
|
|
332
|
+
clientSecret,
|
|
333
|
+
registrationAccessToken: typeof raw["registration_access_token"] === "string" ? raw["registration_access_token"] : null,
|
|
334
|
+
registrationClientUri: typeof raw["registration_client_uri"] === "string" ? raw["registration_client_uri"] : null,
|
|
335
|
+
tokenEndpointAuthMethod: registeredMethod,
|
|
336
|
+
grantTypes: Array.isArray(raw["grant_types"]) ? raw["grant_types"].filter((g) => typeof g === "string") : options.grantTypes,
|
|
337
|
+
scope: typeof raw["scope"] === "string" ? raw["scope"] : null,
|
|
338
|
+
keyPair,
|
|
339
|
+
auth,
|
|
340
|
+
raw
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function scopeString(scope) {
|
|
344
|
+
return (typeof scope === "string" ? scope.split(/\s+/) : scope).filter((s) => s !== "").join(" ");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/agent/grants.ts
|
|
348
|
+
async function startBackchannel(options) {
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
const metadata = await resolveMetadata(options);
|
|
351
|
+
const endpoint = requireEndpoint(metadata, "backchannel_authentication_endpoint", "CIBA");
|
|
352
|
+
if (!options.loginHint) {
|
|
353
|
+
throw new ConfigurationError("`loginHint` is required.");
|
|
354
|
+
}
|
|
355
|
+
const resource = resourceList(options.resource);
|
|
356
|
+
const body = new URLSearchParams({ login_hint: options.loginHint, scope: scopeString(options.scope) });
|
|
357
|
+
if (options.bindingMessage !== "") {
|
|
358
|
+
body.set("binding_message", options.bindingMessage);
|
|
359
|
+
}
|
|
360
|
+
for (const r of resource) {
|
|
361
|
+
body.append("resource", r);
|
|
362
|
+
}
|
|
363
|
+
if (options.requestedExpiry !== void 0) {
|
|
364
|
+
body.set("requested_expiry", String(options.requestedExpiry));
|
|
365
|
+
}
|
|
366
|
+
if (options.nonce !== void 0 && options.nonce !== "") {
|
|
367
|
+
body.set("nonce", options.nonce);
|
|
368
|
+
}
|
|
369
|
+
const payload = await postForm(endpoint, body, options, metadata, "Backchannel authentication request failed");
|
|
370
|
+
const authReqId = payload["auth_req_id"];
|
|
371
|
+
if (typeof authReqId !== "string" || authReqId === "") {
|
|
372
|
+
throw new AuthenticationError("The backchannel authentication response carried no auth_req_id.");
|
|
373
|
+
}
|
|
374
|
+
const expiresIn = positiveNumber(payload["expires_in"]) ?? 120;
|
|
375
|
+
return {
|
|
376
|
+
authReqId,
|
|
377
|
+
expiresIn,
|
|
378
|
+
// CIBA Core §7.3: absent means 5 seconds.
|
|
379
|
+
interval: positiveNumber(payload["interval"]) ?? 5,
|
|
380
|
+
expiresAt: now + expiresIn * 1e3,
|
|
381
|
+
resource
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
async function pollBackchannel(options) {
|
|
385
|
+
const body = new URLSearchParams({ grant_type: CIBA_GRANT_TYPE, auth_req_id: options.authorization.authReqId });
|
|
386
|
+
return pollToken(body, options.authorization.resource, options, "Backchannel token request failed");
|
|
387
|
+
}
|
|
388
|
+
async function waitForBackchannelToken(options) {
|
|
389
|
+
const metadata = await resolveMetadata(options);
|
|
390
|
+
const authorization = options.authorization;
|
|
391
|
+
return waitLoop(authorization, options, () => pollBackchannel({ ...options, metadata }), "backchannel request");
|
|
392
|
+
}
|
|
393
|
+
async function startDeviceAuthorization(options) {
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
const metadata = await resolveMetadata(options);
|
|
396
|
+
const endpoint = requireEndpoint(metadata, "device_authorization_endpoint", "the device grant");
|
|
397
|
+
const resource = resourceList(options.resource);
|
|
398
|
+
const body = new URLSearchParams();
|
|
399
|
+
if (options.scope !== void 0) {
|
|
400
|
+
body.set("scope", scopeString(options.scope));
|
|
401
|
+
}
|
|
402
|
+
for (const r of resource) {
|
|
403
|
+
body.append("resource", r);
|
|
404
|
+
}
|
|
405
|
+
const payload = await postForm(endpoint, body, options, metadata, "Device authorization request failed");
|
|
406
|
+
const deviceCode = payload["device_code"];
|
|
407
|
+
const userCode = payload["user_code"];
|
|
408
|
+
const verificationUri = payload["verification_uri"];
|
|
409
|
+
if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
|
|
410
|
+
throw new AuthenticationError("The device authorization response was incomplete.");
|
|
411
|
+
}
|
|
412
|
+
const expiresIn = positiveNumber(payload["expires_in"]) ?? 600;
|
|
413
|
+
return {
|
|
414
|
+
deviceCode,
|
|
415
|
+
userCode,
|
|
416
|
+
verificationUri,
|
|
417
|
+
verificationUriComplete: typeof payload["verification_uri_complete"] === "string" ? payload["verification_uri_complete"] : null,
|
|
418
|
+
expiresIn,
|
|
419
|
+
// RFC 8628 §3.2: absent means 5 seconds.
|
|
420
|
+
interval: positiveNumber(payload["interval"]) ?? 5,
|
|
421
|
+
expiresAt: now + expiresIn * 1e3,
|
|
422
|
+
resource
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
async function pollDeviceAuthorization(options) {
|
|
426
|
+
const body = new URLSearchParams({ grant_type: DEVICE_CODE_GRANT_TYPE, device_code: options.authorization.deviceCode });
|
|
427
|
+
return pollToken(body, options.authorization.resource, options, "Device token request failed");
|
|
428
|
+
}
|
|
429
|
+
async function waitForDeviceToken(options) {
|
|
430
|
+
const metadata = await resolveMetadata(options);
|
|
431
|
+
return waitLoop(options.authorization, options, () => pollDeviceAuthorization({ ...options, metadata }), "device code");
|
|
432
|
+
}
|
|
433
|
+
async function refreshAgentTokens(options) {
|
|
434
|
+
if (!options.refreshToken) {
|
|
435
|
+
throw new ConfigurationError("`refreshToken` is required.");
|
|
436
|
+
}
|
|
437
|
+
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: options.refreshToken });
|
|
438
|
+
if (options.scope !== void 0) {
|
|
439
|
+
body.set("scope", scopeString(options.scope));
|
|
440
|
+
}
|
|
441
|
+
const result = await pollToken(body, resourceList(options.resource), options, "Token refresh failed", false);
|
|
442
|
+
return result.tokens;
|
|
443
|
+
}
|
|
444
|
+
async function waitLoop(authorization, options, poll, what) {
|
|
445
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
446
|
+
const now = options.now ?? Date.now;
|
|
447
|
+
let intervalMs = Math.max(authorization.interval, 0) * 1e3;
|
|
448
|
+
let nextWaitMs = intervalMs;
|
|
449
|
+
for (; ; ) {
|
|
450
|
+
throwIfAborted(options.signal);
|
|
451
|
+
if (now() >= authorization.expiresAt) {
|
|
452
|
+
throw new ExpiredTokenError(`The ${what} expired before it was approved. Start again.`, "expired_token");
|
|
453
|
+
}
|
|
454
|
+
await sleep(nextWaitMs, options.signal);
|
|
455
|
+
throwIfAborted(options.signal);
|
|
456
|
+
if (now() >= authorization.expiresAt) {
|
|
457
|
+
throw new ExpiredTokenError(`The ${what} expired before it was approved. Start again.`, "expired_token");
|
|
458
|
+
}
|
|
459
|
+
const result = await poll();
|
|
460
|
+
if (result.status === "complete") {
|
|
461
|
+
return result.tokens;
|
|
462
|
+
}
|
|
463
|
+
if (result.status === "slow_down") {
|
|
464
|
+
intervalMs += 5e3;
|
|
465
|
+
nextWaitMs = Math.max(intervalMs, (result.retryAfter ?? 0) * 1e3);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
nextWaitMs = intervalMs;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function pollToken(body, resource, options, fallback, polling = true) {
|
|
472
|
+
const metadata = await resolveMetadata(options);
|
|
473
|
+
const endpoint = requireEndpoint(metadata, "token_endpoint", "token requests");
|
|
474
|
+
for (const r of resource) {
|
|
475
|
+
body.append("resource", r);
|
|
476
|
+
}
|
|
477
|
+
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
478
|
+
await applyClientAuth(options.client, body, headers, metadata.token_endpoint);
|
|
479
|
+
const response = await send(endpoint, { method: "POST", headers, body }, options);
|
|
480
|
+
if (response.ok) {
|
|
481
|
+
return { status: "complete", tokens: await tokensFrom(response) };
|
|
482
|
+
}
|
|
483
|
+
const failure = await readOAuthFailure(response);
|
|
484
|
+
if (polling && failure.error === "authorization_pending") {
|
|
485
|
+
return { status: "pending" };
|
|
486
|
+
}
|
|
487
|
+
if (polling && failure.error === "slow_down") {
|
|
488
|
+
return { status: "slow_down", retryAfter: failure.retryAfter ?? null };
|
|
489
|
+
}
|
|
490
|
+
throw grantError(failure, fallback);
|
|
491
|
+
}
|
|
492
|
+
async function postForm(endpoint, body, options, metadata, fallback) {
|
|
493
|
+
const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
494
|
+
await applyClientAuth(options.client, body, headers, metadata.token_endpoint);
|
|
495
|
+
const response = await send(endpoint, { method: "POST", headers, body }, options);
|
|
496
|
+
if (!response.ok) {
|
|
497
|
+
throw grantError(await readOAuthFailure(response), fallback);
|
|
498
|
+
}
|
|
499
|
+
const payload = await response.json().catch(() => null);
|
|
500
|
+
if (typeof payload !== "object" || payload === null) {
|
|
501
|
+
throw new AuthenticationError(`${fallback}: the response was not a JSON object.`);
|
|
502
|
+
}
|
|
503
|
+
return payload;
|
|
504
|
+
}
|
|
505
|
+
async function tokensFrom(response) {
|
|
506
|
+
const raw = await response.json().catch(() => null);
|
|
507
|
+
const accessToken = raw?.["access_token"];
|
|
508
|
+
if (raw === null || typeof accessToken !== "string" || accessToken === "") {
|
|
509
|
+
throw new AuthenticationError("The token response carried no access_token.");
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
accessToken,
|
|
513
|
+
tokenType: typeof raw["token_type"] === "string" ? raw["token_type"] : "Bearer",
|
|
514
|
+
expiresIn: typeof raw["expires_in"] === "number" ? raw["expires_in"] : null,
|
|
515
|
+
refreshToken: typeof raw["refresh_token"] === "string" ? raw["refresh_token"] : null,
|
|
516
|
+
idToken: typeof raw["id_token"] === "string" ? raw["id_token"] : null,
|
|
517
|
+
scope: typeof raw["scope"] === "string" ? raw["scope"] : null,
|
|
518
|
+
raw
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function resourceList(resource) {
|
|
522
|
+
if (resource === void 0) {
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
const list = typeof resource === "string" ? [resource] : [...resource];
|
|
526
|
+
for (const r of list) {
|
|
527
|
+
let url;
|
|
528
|
+
try {
|
|
529
|
+
url = new URL(r);
|
|
530
|
+
} catch {
|
|
531
|
+
throw new ConfigurationError(`\`resource\` must be an absolute URI (got ${r}).`);
|
|
532
|
+
}
|
|
533
|
+
if (url.hash !== "") {
|
|
534
|
+
throw new ConfigurationError("`resource` must not contain a fragment.");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return list;
|
|
538
|
+
}
|
|
539
|
+
function positiveNumber(value) {
|
|
540
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
541
|
+
}
|
|
542
|
+
function throwIfAborted(signal) {
|
|
543
|
+
if (signal?.aborted === true) {
|
|
544
|
+
throw new GrantCancelledError("The wait for approval was cancelled.");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function defaultSleep(ms, signal) {
|
|
548
|
+
return new Promise((resolve, reject) => {
|
|
549
|
+
if (signal?.aborted === true) {
|
|
550
|
+
reject(new GrantCancelledError("The wait for approval was cancelled."));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const onAbort = () => {
|
|
554
|
+
clearTimeout(timer);
|
|
555
|
+
reject(new GrantCancelledError("The wait for approval was cancelled."));
|
|
556
|
+
};
|
|
557
|
+
const timer = setTimeout(() => {
|
|
558
|
+
signal?.removeEventListener("abort", onAbort);
|
|
559
|
+
resolve();
|
|
560
|
+
}, ms);
|
|
561
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// src/agent/binding.ts
|
|
566
|
+
var BINDING_CODE_ALPHABET = "ACDEFGHJKMNPQRTUVWXY34679";
|
|
567
|
+
function createBindingCode(length = 4) {
|
|
568
|
+
if (!Number.isInteger(length) || length < 1 || length > 32) {
|
|
569
|
+
throw new ConfigurationError("A binding code length must be an integer from 1 to 32.");
|
|
570
|
+
}
|
|
571
|
+
const size = BINDING_CODE_ALPHABET.length;
|
|
572
|
+
const limit = 256 - 256 % size;
|
|
573
|
+
let code = "";
|
|
574
|
+
while (code.length < length) {
|
|
575
|
+
for (const byte of crypto.getRandomValues(new Uint8Array(length * 2))) {
|
|
576
|
+
if (byte < limit && code.length < length) {
|
|
577
|
+
code += BINDING_CODE_ALPHABET[byte % size];
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return code;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
exports.AccessDeniedError = AccessDeniedError;
|
|
585
|
+
exports.AgentGrantError = AgentGrantError;
|
|
586
|
+
exports.AuthenticationError = AuthenticationError;
|
|
587
|
+
exports.BINDING_CODE_ALPHABET = BINDING_CODE_ALPHABET;
|
|
588
|
+
exports.CIBA_GRANT_TYPE = CIBA_GRANT_TYPE;
|
|
589
|
+
exports.CboxIdError = CboxIdError;
|
|
590
|
+
exports.ConfigurationError = ConfigurationError;
|
|
591
|
+
exports.DEVICE_CODE_GRANT_TYPE = DEVICE_CODE_GRANT_TYPE;
|
|
592
|
+
exports.ExpiredTokenError = ExpiredTokenError;
|
|
593
|
+
exports.GrantCancelledError = GrantCancelledError;
|
|
594
|
+
exports.InvalidBindingMessageError = InvalidBindingMessageError;
|
|
595
|
+
exports.InvalidClientError = InvalidClientError;
|
|
596
|
+
exports.InvalidGrantError = InvalidGrantError;
|
|
597
|
+
exports.InvalidTargetError = InvalidTargetError;
|
|
598
|
+
exports.SlowDownError = SlowDownError;
|
|
599
|
+
exports.UnknownUserIdError = UnknownUserIdError;
|
|
600
|
+
exports.createBindingCode = createBindingCode;
|
|
601
|
+
exports.createClientAssertion = createClientAssertion;
|
|
602
|
+
exports.discoverAuthorizationServer = discoverAuthorizationServer;
|
|
603
|
+
exports.generateClientKeyPair = generateClientKeyPair;
|
|
604
|
+
exports.pollBackchannel = pollBackchannel;
|
|
605
|
+
exports.pollDeviceAuthorization = pollDeviceAuthorization;
|
|
606
|
+
exports.refreshAgentTokens = refreshAgentTokens;
|
|
607
|
+
exports.registerAgentClient = registerAgentClient;
|
|
608
|
+
exports.startBackchannel = startBackchannel;
|
|
609
|
+
exports.startDeviceAuthorization = startDeviceAuthorization;
|
|
610
|
+
exports.waitForBackchannelToken = waitForBackchannelToken;
|
|
611
|
+
exports.waitForDeviceToken = waitForDeviceToken;
|
|
612
|
+
//# sourceMappingURL=agent.cjs.map
|
|
613
|
+
//# sourceMappingURL=agent.cjs.map
|