@cboxdk/id-js 0.1.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/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { AuthenticationError, CboxIdClient, CboxIdError, ConfigurationError, InvalidStateError, VaultClient, challenge, createVerifier, randomToken, verifyWebhook } from './chunk-D4OUHK6N.js';
2
+ //# sourceMappingURL=index.js.map
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
@@ -0,0 +1,570 @@
1
+ 'use strict';
2
+
3
+ var server = require('next/server');
4
+ var jose = require('jose');
5
+
6
+ // src/nextjs.ts
7
+
8
+ // src/errors.ts
9
+ var CboxIdError = class extends Error {
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = new.target.name;
13
+ }
14
+ };
15
+ var ConfigurationError = class extends CboxIdError {
16
+ };
17
+ var InvalidStateError = class extends CboxIdError {
18
+ };
19
+ var AuthenticationError = class extends CboxIdError {
20
+ };
21
+
22
+ // src/discovery.ts
23
+ var Discovery = class {
24
+ constructor(issuer, timeoutMs, cacheTtlMs, now = Date.now) {
25
+ this.issuer = issuer;
26
+ this.timeoutMs = timeoutMs;
27
+ this.cacheTtlMs = cacheTtlMs;
28
+ this.now = now;
29
+ }
30
+ issuer;
31
+ timeoutMs;
32
+ cacheTtlMs;
33
+ now;
34
+ cached = null;
35
+ async document() {
36
+ if (this.cached && this.cached.expiresAt > this.now()) {
37
+ return this.cached.doc;
38
+ }
39
+ const url = `${this.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
42
+ let doc;
43
+ try {
44
+ const response = await fetch(url, { signal: controller.signal });
45
+ if (!response.ok) {
46
+ throw new AuthenticationError(`Discovery request failed with status ${response.status}.`);
47
+ }
48
+ doc = await response.json();
49
+ } catch (error) {
50
+ if (error instanceof AuthenticationError) {
51
+ throw error;
52
+ }
53
+ throw new AuthenticationError(`Discovery request failed: ${String(error)}`);
54
+ } finally {
55
+ clearTimeout(timer);
56
+ }
57
+ if (typeof doc.issuer !== "string" || doc.issuer === "") {
58
+ throw new AuthenticationError("The discovery document was missing an issuer.");
59
+ }
60
+ this.cached = { doc, expiresAt: this.now() + this.cacheTtlMs };
61
+ return doc;
62
+ }
63
+ /** A required endpoint from the discovery document. */
64
+ async endpoint(name) {
65
+ const value = (await this.document())[name];
66
+ if (typeof value !== "string" || value === "") {
67
+ throw new ConfigurationError(`The instance does not advertise a ${name}.`);
68
+ }
69
+ return value;
70
+ }
71
+ /** An optional endpoint, or null when the instance advertises none. */
72
+ async optionalEndpoint(name) {
73
+ const value = (await this.document())[name];
74
+ return typeof value === "string" && value !== "" ? value : null;
75
+ }
76
+ };
77
+
78
+ // src/pkce.ts
79
+ function base64url(bytes) {
80
+ let binary = "";
81
+ for (const byte of bytes) {
82
+ binary += String.fromCharCode(byte);
83
+ }
84
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
85
+ }
86
+ function randomToken(bytes = 32) {
87
+ return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
88
+ }
89
+ function createVerifier() {
90
+ return randomToken(32);
91
+ }
92
+ async function challenge(verifier) {
93
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
94
+ return base64url(new Uint8Array(digest));
95
+ }
96
+
97
+ // src/vault.ts
98
+ var VaultClient = class {
99
+ constructor(issuer, token, timeoutMs = 1e4) {
100
+ this.token = token;
101
+ this.timeoutMs = timeoutMs;
102
+ this.base = `${issuer.replace(/\/$/, "")}/api/v1/vault`;
103
+ }
104
+ token;
105
+ timeoutMs;
106
+ base;
107
+ /** Ingest a downstream credential, sealed at rest (scope `vault.manage`). */
108
+ store(input) {
109
+ return this.json("POST", "/secrets", {
110
+ name: input.name,
111
+ provider: input.provider,
112
+ secret: input.secret,
113
+ owner_type: input.ownerType,
114
+ owner_id: input.ownerId,
115
+ expires_at: input.expiresAt
116
+ });
117
+ }
118
+ /** Rotate a secret's value, keeping its id and grants (scope `vault.manage`). */
119
+ rotate(secretId, secret) {
120
+ return this.json("POST", `/secrets/${encodeURIComponent(secretId)}/rotate`, { secret });
121
+ }
122
+ /** Revoke a secret permanently (scope `vault.manage`). */
123
+ async revoke(secretId) {
124
+ await this.request("DELETE", `/secrets/${encodeURIComponent(secretId)}`);
125
+ }
126
+ /** Authorize an agent client to lease a secret (scope `vault.manage`). */
127
+ grant(secretId, clientId, maxTtlSeconds) {
128
+ return this.json("POST", `/secrets/${encodeURIComponent(secretId)}/grants`, {
129
+ client_id: clientId,
130
+ max_ttl_seconds: maxTtlSeconds
131
+ }).then((raw) => {
132
+ const r = raw;
133
+ return { secretId: r.secret_id, clientId: r.client_id, maxTtlSeconds: r.max_ttl_seconds };
134
+ });
135
+ }
136
+ /** Revoke an agent's authorization (scope `vault.manage`). */
137
+ async revokeGrant(secretId, clientId) {
138
+ await this.request(
139
+ "DELETE",
140
+ `/secrets/${encodeURIComponent(secretId)}/grants/${encodeURIComponent(clientId)}`
141
+ );
142
+ }
143
+ /**
144
+ * Redeem a leased credential for immediate use (scope `vault.lease`). The caller
145
+ * is identified by the token's client; a lease with no live grant is refused.
146
+ */
147
+ lease(secretId, purpose) {
148
+ return this.json("POST", `/secrets/${encodeURIComponent(secretId)}/lease`, { purpose }).then((raw) => {
149
+ const r = raw;
150
+ return { secretId: r.secret_id, provider: r.provider, secret: r.secret, expiresAt: r.expires_at };
151
+ });
152
+ }
153
+ async json(method, path, body) {
154
+ const response = await this.request(method, path, body);
155
+ return await response.json();
156
+ }
157
+ async request(method, path, body) {
158
+ const controller = new AbortController();
159
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
160
+ try {
161
+ const init = {
162
+ method,
163
+ headers: {
164
+ authorization: `Bearer ${this.token}`,
165
+ ...body ? { "content-type": "application/json" } : {}
166
+ },
167
+ signal: controller.signal
168
+ };
169
+ if (body) {
170
+ init.body = JSON.stringify(body);
171
+ }
172
+ const response = await fetch(`${this.base}${path}`, init);
173
+ if (!response.ok) {
174
+ throw new AuthenticationError(`Vault ${method} ${path} failed with status ${response.status}.`);
175
+ }
176
+ return response;
177
+ } finally {
178
+ clearTimeout(timer);
179
+ }
180
+ }
181
+ };
182
+
183
+ // src/webhook.ts
184
+ function timingSafeEqual(a, b) {
185
+ if (a.length !== b.length) {
186
+ return false;
187
+ }
188
+ let mismatch = 0;
189
+ for (let i = 0; i < a.length; i++) {
190
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
191
+ }
192
+ return mismatch === 0;
193
+ }
194
+ function toHex(bytes) {
195
+ return Array.from(new Uint8Array(bytes)).map((b) => b.toString(16).padStart(2, "0")).join("");
196
+ }
197
+ async function verifyWebhook(options) {
198
+ const { payload, signatureHeader, secret } = options;
199
+ const toleranceSeconds = options.toleranceSeconds ?? 300;
200
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
201
+ if (!signatureHeader) {
202
+ return false;
203
+ }
204
+ const parts = {};
205
+ for (const segment of signatureHeader.split(",")) {
206
+ const [key2, value] = segment.trim().split("=", 2);
207
+ if (key2) {
208
+ parts[key2] = value ?? "";
209
+ }
210
+ }
211
+ const timestamp = parts["t"] ?? "";
212
+ const signature = parts["v1"] ?? "";
213
+ if (timestamp === "" || signature === "" || !/^\d+$/.test(timestamp)) {
214
+ return false;
215
+ }
216
+ if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
217
+ return false;
218
+ }
219
+ const key = await crypto.subtle.importKey(
220
+ "raw",
221
+ new TextEncoder().encode(secret),
222
+ { name: "HMAC", hash: "SHA-256" },
223
+ false,
224
+ ["sign"]
225
+ );
226
+ const expected = toHex(
227
+ await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`))
228
+ );
229
+ return timingSafeEqual(expected, signature);
230
+ }
231
+
232
+ // src/client.ts
233
+ var CboxIdClient = class {
234
+ constructor(config) {
235
+ this.config = config;
236
+ if (!config.issuer) {
237
+ throw new ConfigurationError("Cbox ID config `issuer` is required.");
238
+ }
239
+ if (!config.clientId) {
240
+ throw new ConfigurationError("Cbox ID config `clientId` is required.");
241
+ }
242
+ if (!config.redirectUri) {
243
+ throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
244
+ }
245
+ this.discovery = new Discovery(
246
+ config.issuer,
247
+ config.timeoutMs ?? 1e4,
248
+ config.cacheTtlMs ?? 36e5
249
+ );
250
+ }
251
+ config;
252
+ discovery;
253
+ jwksByUri = /* @__PURE__ */ new Map();
254
+ /**
255
+ * Begin login. Returns the authorize URL plus the `state`, `codeVerifier` and
256
+ * `nonce` — persist those (signed, httpOnly cookies are ideal) and hand them back
257
+ * to {@link authenticate} on the callback.
258
+ */
259
+ async createAuthorizationRequest(options = {}) {
260
+ const codeVerifier = createVerifier();
261
+ const state = options.state ?? randomToken(16);
262
+ const nonce = randomToken(16);
263
+ const params = new URLSearchParams({
264
+ response_type: "code",
265
+ client_id: this.config.clientId,
266
+ redirect_uri: options.redirectUri ?? this.config.redirectUri,
267
+ scope: (options.scopes ?? this.scopes()).join(" "),
268
+ state,
269
+ nonce,
270
+ code_challenge: await challenge(codeVerifier),
271
+ code_challenge_method: "S256"
272
+ });
273
+ if (options.prompt) {
274
+ params.set("prompt", options.prompt);
275
+ }
276
+ if (options.loginHint) {
277
+ params.set("login_hint", options.loginHint);
278
+ }
279
+ const endpoint = await this.discovery.endpoint("authorization_endpoint");
280
+ return { url: `${endpoint}?${params.toString()}`, state, codeVerifier, nonce };
281
+ }
282
+ /**
283
+ * Complete login on your callback route: verify state, exchange the code with the
284
+ * PKCE verifier, verify the id_token, and return the authenticated user.
285
+ *
286
+ * @throws InvalidStateError when state does not match (forged/stale request)
287
+ * @throws AuthenticationError on any other failure
288
+ */
289
+ async authenticate(input) {
290
+ const { params, stored } = input;
291
+ if (!params.state || !stored.state || !timingSafeEqualString(params.state, stored.state)) {
292
+ throw new InvalidStateError("The login state did not match \u2014 the request may be forged or stale.");
293
+ }
294
+ if (params.error) {
295
+ throw new AuthenticationError(
296
+ `Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`
297
+ );
298
+ }
299
+ if (!params.code) {
300
+ throw new AuthenticationError("The callback was missing an authorization code.");
301
+ }
302
+ const tokens = await this.exchange(params.code, stored.codeVerifier, input.redirectUri);
303
+ if (!tokens.access_token) {
304
+ throw new AuthenticationError("No access token was returned.");
305
+ }
306
+ let claims = {};
307
+ if (tokens.id_token) {
308
+ claims = await this.verifyIdToken(tokens.id_token, stored.nonce);
309
+ }
310
+ claims = { ...claims, ...await this.userinfo(tokens.access_token) };
311
+ const sub = claims["sub"];
312
+ if (typeof sub !== "string" || sub === "") {
313
+ throw new AuthenticationError("The verified token carried no subject.");
314
+ }
315
+ return {
316
+ id: sub,
317
+ email: typeof claims["email"] === "string" ? claims["email"] : null,
318
+ name: typeof claims["name"] === "string" ? claims["name"] : null,
319
+ organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
320
+ claims,
321
+ accessToken: tokens.access_token,
322
+ refreshToken: tokens.refresh_token ?? null,
323
+ idToken: tokens.id_token ?? null,
324
+ expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
325
+ };
326
+ }
327
+ /**
328
+ * The URL of the instance's hosted account/profile page (self-service password,
329
+ * MFA, passkeys, sessions). A signed-in user is authenticated there by their Cbox
330
+ * ID session; `returnTo` is passed so the page can link back to your app.
331
+ */
332
+ profileUrl(returnTo) {
333
+ const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
334
+ return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
335
+ }
336
+ /** The RP-initiated logout URL, or null when the instance advertises none. */
337
+ async logoutUrl(returnTo) {
338
+ const endpoint = await this.discovery.optionalEndpoint("end_session_endpoint");
339
+ if (!endpoint) {
340
+ return null;
341
+ }
342
+ return returnTo ? `${endpoint}?${new URLSearchParams({ post_logout_redirect_uri: returnTo }).toString()}` : endpoint;
343
+ }
344
+ /**
345
+ * A machine (client-credentials) access token for calling Cbox ID APIs as your
346
+ * app, not on a user's behalf. Requires a client secret.
347
+ */
348
+ async machineToken(options = {}) {
349
+ const body = new URLSearchParams({
350
+ grant_type: "client_credentials",
351
+ client_id: this.config.clientId,
352
+ client_secret: this.requireSecret()
353
+ });
354
+ if (options.scopes && options.scopes.length > 0) {
355
+ body.set("scope", options.scopes.join(" "));
356
+ }
357
+ if (options.resource) {
358
+ body.set("resource", options.resource);
359
+ }
360
+ const response = await this.post(await this.discovery.endpoint("token_endpoint"), body);
361
+ if (!response.ok) {
362
+ throw new AuthenticationError(`Machine token request failed: ${await response.text()}`);
363
+ }
364
+ const json = await response.json();
365
+ if (typeof json.access_token !== "string") {
366
+ throw new AuthenticationError("The token response had no access_token.");
367
+ }
368
+ return json.access_token;
369
+ }
370
+ /** The OIDC userinfo claims for an access token. */
371
+ async userinfo(accessToken) {
372
+ const endpoint = await this.discovery.optionalEndpoint("userinfo_endpoint");
373
+ if (!endpoint) {
374
+ return {};
375
+ }
376
+ const response = await this.fetchWithTimeout(endpoint, {
377
+ headers: { authorization: `Bearer ${accessToken}` }
378
+ });
379
+ if (!response.ok) {
380
+ throw new AuthenticationError("Userinfo request failed.");
381
+ }
382
+ return await response.json();
383
+ }
384
+ /**
385
+ * RFC 7662 token introspection (confidential-client auth). Returns the raw
386
+ * response; `active` tells you if the token is currently valid. Requires a secret.
387
+ */
388
+ async introspect(token) {
389
+ const endpoint = await this.discovery.endpoint("introspection_endpoint");
390
+ const basic = btoa(`${this.config.clientId}:${this.requireSecret()}`);
391
+ const response = await this.post(
392
+ endpoint,
393
+ new URLSearchParams({ token }),
394
+ { authorization: `Basic ${basic}` }
395
+ );
396
+ if (!response.ok) {
397
+ throw new AuthenticationError("Introspection request failed.");
398
+ }
399
+ return await response.json();
400
+ }
401
+ /** Verify a Cbox ID webhook / inline-action signature. See {@link verifyWebhook}. */
402
+ verifyWebhook(options) {
403
+ return verifyWebhook(options);
404
+ }
405
+ /**
406
+ * A Token Vault client bound to an access token. Obtain the token with
407
+ * {@link machineToken} (scope `vault.manage` to provision/grant, `vault.lease` to
408
+ * redeem), then call `store` / `grant` / `lease` etc.
409
+ */
410
+ vault(accessToken) {
411
+ return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
412
+ }
413
+ async exchange(code, verifier, redirectUri) {
414
+ const body = new URLSearchParams({
415
+ grant_type: "authorization_code",
416
+ code,
417
+ redirect_uri: redirectUri ?? this.config.redirectUri,
418
+ client_id: this.config.clientId,
419
+ code_verifier: verifier
420
+ });
421
+ if (this.config.clientSecret) {
422
+ body.set("client_secret", this.config.clientSecret);
423
+ }
424
+ const response = await this.post(await this.discovery.endpoint("token_endpoint"), body);
425
+ if (!response.ok) {
426
+ throw new AuthenticationError(`Token exchange failed: ${await response.text()}`);
427
+ }
428
+ return await response.json();
429
+ }
430
+ async verifyIdToken(idToken, nonce) {
431
+ const jwksUri = await this.discovery.endpoint("jwks_uri");
432
+ let resolver = this.jwksByUri.get(jwksUri);
433
+ if (!resolver) {
434
+ resolver = jose.createRemoteJWKSet(new URL(jwksUri));
435
+ this.jwksByUri.set(jwksUri, resolver);
436
+ }
437
+ let payload;
438
+ try {
439
+ ({ payload } = await jose.jwtVerify(idToken, resolver, {
440
+ issuer: this.config.issuer,
441
+ audience: this.config.clientId
442
+ }));
443
+ } catch (error) {
444
+ throw new AuthenticationError(`The id_token could not be verified: ${String(error)}`);
445
+ }
446
+ if (nonce && payload["nonce"] !== nonce) {
447
+ throw new AuthenticationError("The id_token nonce did not match \u2014 possible replay.");
448
+ }
449
+ return payload;
450
+ }
451
+ scopes() {
452
+ const scopes = this.config.scopes;
453
+ return scopes && scopes.length > 0 ? scopes : ["openid", "profile", "email"];
454
+ }
455
+ accountPath() {
456
+ const path = this.config.accountPath;
457
+ return path && path !== "" ? `/${path.replace(/^\//, "")}` : "/settings";
458
+ }
459
+ requireSecret() {
460
+ if (!this.config.clientSecret) {
461
+ throw new ConfigurationError("This call requires a `clientSecret`, but none is configured.");
462
+ }
463
+ return this.config.clientSecret;
464
+ }
465
+ post(url, body, headers = {}) {
466
+ return this.fetchWithTimeout(url, {
467
+ method: "POST",
468
+ headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
469
+ body
470
+ });
471
+ }
472
+ async fetchWithTimeout(url, init) {
473
+ const controller = new AbortController();
474
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 1e4);
475
+ try {
476
+ return await fetch(url, { ...init, signal: controller.signal });
477
+ } finally {
478
+ clearTimeout(timer);
479
+ }
480
+ }
481
+ };
482
+ function timingSafeEqualString(a, b) {
483
+ if (a.length !== b.length) {
484
+ return false;
485
+ }
486
+ let mismatch = 0;
487
+ for (let i = 0; i < a.length; i++) {
488
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
489
+ }
490
+ return mismatch === 0;
491
+ }
492
+
493
+ // src/nextjs.ts
494
+ var COOKIE = {
495
+ state: "cbox_id_state",
496
+ verifier: "cbox_id_verifier",
497
+ nonce: "cbox_id_nonce"
498
+ };
499
+ var TEMP_COOKIE_MAX_AGE = 600;
500
+ function createCboxId(config) {
501
+ const resolved = resolveConfig(config);
502
+ const client = new CboxIdClient(resolved);
503
+ const tempCookieOptions = {
504
+ httpOnly: true,
505
+ secure: true,
506
+ sameSite: "lax",
507
+ path: "/",
508
+ maxAge: TEMP_COOKIE_MAX_AGE
509
+ };
510
+ return {
511
+ client,
512
+ async signIn(options = {}) {
513
+ const request = await client.createAuthorizationRequest(options);
514
+ const response = server.NextResponse.redirect(request.url);
515
+ response.cookies.set(COOKIE.state, request.state, tempCookieOptions);
516
+ response.cookies.set(COOKIE.verifier, request.codeVerifier, tempCookieOptions);
517
+ response.cookies.set(COOKIE.nonce, request.nonce, tempCookieOptions);
518
+ return response;
519
+ },
520
+ async callback(request) {
521
+ return client.authenticate({
522
+ params: {
523
+ code: request.nextUrl.searchParams.get("code"),
524
+ state: request.nextUrl.searchParams.get("state"),
525
+ error: request.nextUrl.searchParams.get("error"),
526
+ error_description: request.nextUrl.searchParams.get("error_description")
527
+ },
528
+ stored: {
529
+ state: request.cookies.get(COOKIE.state)?.value ?? "",
530
+ codeVerifier: request.cookies.get(COOKIE.verifier)?.value ?? "",
531
+ nonce: request.cookies.get(COOKIE.nonce)?.value ?? ""
532
+ }
533
+ });
534
+ },
535
+ profileUrl(returnTo) {
536
+ return client.profileUrl(returnTo);
537
+ },
538
+ profileRedirect(returnTo) {
539
+ return server.NextResponse.redirect(client.profileUrl(returnTo));
540
+ },
541
+ signOutUrl(returnTo) {
542
+ return client.logoutUrl(returnTo);
543
+ }
544
+ };
545
+ }
546
+ function resolveConfig(config) {
547
+ const issuer = config?.issuer ?? process.env["CBOX_ID_ISSUER"];
548
+ const clientId = config?.clientId ?? process.env["CBOX_ID_CLIENT_ID"];
549
+ const redirectUri = config?.redirectUri ?? process.env["CBOX_ID_REDIRECT_URI"];
550
+ const clientSecret = config?.clientSecret ?? process.env["CBOX_ID_CLIENT_SECRET"];
551
+ if (!issuer || !clientId || !redirectUri) {
552
+ throw new ConfigurationError(
553
+ "Cbox ID needs issuer, clientId and redirectUri \u2014 pass them to createCboxId() or set CBOX_ID_ISSUER / CBOX_ID_CLIENT_ID / CBOX_ID_REDIRECT_URI."
554
+ );
555
+ }
556
+ return {
557
+ issuer,
558
+ clientId,
559
+ redirectUri,
560
+ ...clientSecret ? { clientSecret } : {},
561
+ ...config?.scopes ? { scopes: config.scopes } : {},
562
+ ...config?.accountPath ? { accountPath: config.accountPath } : {},
563
+ ...config?.timeoutMs ? { timeoutMs: config.timeoutMs } : {},
564
+ ...config?.cacheTtlMs ? { cacheTtlMs: config.cacheTtlMs } : {}
565
+ };
566
+ }
567
+
568
+ exports.createCboxId = createCboxId;
569
+ //# sourceMappingURL=nextjs.cjs.map
570
+ //# sourceMappingURL=nextjs.cjs.map