@cboxdk/id-js 0.14.0 → 0.17.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 +237 -2
- package/dist/chunk-ASOQN47M.js +81 -0
- package/dist/chunk-ASOQN47M.js.map +1 -0
- package/dist/{chunk-UPRO73GX.js → chunk-G3CD7X4U.js} +334 -71
- package/dist/chunk-G3CD7X4U.js.map +1 -0
- package/dist/chunk-WYTGLCMF.js +154 -0
- package/dist/chunk-WYTGLCMF.js.map +1 -0
- package/dist/{client-CecDIu_N.d.cts → client-CEXddiCR.d.cts} +81 -136
- package/dist/{client-CecDIu_N.d.ts → client-CdwdigzG.d.ts} +81 -136
- package/dist/index.cjs +419 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -4
- package/dist/index.d.ts +39 -4
- package/dist/index.js +64 -25
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +531 -24
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +29 -8
- package/dist/nextjs.d.ts +29 -8
- package/dist/nextjs.js +41 -12
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +220 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +105 -0
- package/dist/server.d.ts +105 -0
- package/dist/server.js +4 -0
- package/dist/server.js.map +1 -0
- package/dist/types-BYZldock.d.cts +316 -0
- package/dist/types-BYZldock.d.ts +316 -0
- package/package.json +6 -1
- package/dist/chunk-UPRO73GX.js.map +0 -1
package/dist/nextjs.cjs
CHANGED
|
@@ -46,12 +46,181 @@ async function oauthError(response, fallback) {
|
|
|
46
46
|
}
|
|
47
47
|
} catch {
|
|
48
48
|
}
|
|
49
|
-
const detail = code ??
|
|
49
|
+
const detail = code ?? `HTTP ${response.status}`;
|
|
50
50
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
51
51
|
const retryAfter = retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader.trim()) ? Number(retryAfterHeader.trim()) : void 0;
|
|
52
52
|
return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// src/issuer.ts
|
|
56
|
+
function assertSecureIssuer(issuer) {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(issuer);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
|
|
62
|
+
}
|
|
63
|
+
if (url.protocol === "https:") {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
67
|
+
if (url.protocol === "http:" && loopback) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw new ConfigurationError(
|
|
71
|
+
`Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src/api-keys.ts
|
|
76
|
+
var MAX_API_KEY_CACHE_TTL_MS = 6e4;
|
|
77
|
+
var MAX_CACHE_ENTRIES = 1e3;
|
|
78
|
+
var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
|
|
79
|
+
"owner",
|
|
80
|
+
"admin",
|
|
81
|
+
"developer",
|
|
82
|
+
"member",
|
|
83
|
+
"viewer"
|
|
84
|
+
]);
|
|
85
|
+
var ApiKeyVerifier = class {
|
|
86
|
+
constructor(config) {
|
|
87
|
+
this.config = config;
|
|
88
|
+
if (typeof globalThis.document !== "undefined") {
|
|
89
|
+
throw new ConfigurationError(
|
|
90
|
+
"API key verification uses your client secret and must run on your server, never in a browser."
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (!config.issuer || !config.clientId || !config.clientSecret) {
|
|
94
|
+
throw new ConfigurationError("Verifying API keys needs issuer, clientId and clientSecret.");
|
|
95
|
+
}
|
|
96
|
+
assertSecureIssuer(config.issuer);
|
|
97
|
+
const cacheTtlMs = config.cacheTtlMs ?? 0;
|
|
98
|
+
if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0 || cacheTtlMs > MAX_API_KEY_CACHE_TTL_MS) {
|
|
99
|
+
throw new ConfigurationError(
|
|
100
|
+
`cacheTtlMs must be between 0 and ${MAX_API_KEY_CACHE_TTL_MS}: a cached answer keeps a revoked key working for that long.`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
this.endpoint = `${config.issuer.replace(/\/$/, "")}/oauth/api-keys/verify`;
|
|
104
|
+
this.timeoutMs = config.timeoutMs ?? 1e4;
|
|
105
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
106
|
+
}
|
|
107
|
+
config;
|
|
108
|
+
endpoint;
|
|
109
|
+
timeoutMs;
|
|
110
|
+
cacheTtlMs;
|
|
111
|
+
cache = /* @__PURE__ */ new Map();
|
|
112
|
+
/**
|
|
113
|
+
* Ask Cbox ID whether `key` is good for this app, and what it may do.
|
|
114
|
+
*
|
|
115
|
+
* Resolves `{ active: false }` for every kind of bad key, without a request for an empty
|
|
116
|
+
* one. Throws `AuthenticationError` when the call itself fails (wrong client
|
|
117
|
+
* credentials, an unreachable instance) or when the answer cannot be trusted — one that
|
|
118
|
+
* names another app's `client_id`, or does not have the promised shape. Both mean "do
|
|
119
|
+
* not let this request in"; the difference is that a throw is something to look at.
|
|
120
|
+
*/
|
|
121
|
+
async verifyApiKey(key) {
|
|
122
|
+
if (typeof key !== "string" || key.trim() === "") {
|
|
123
|
+
return { active: false };
|
|
124
|
+
}
|
|
125
|
+
const cacheKey = this.cacheTtlMs > 0 ? await digest(key) : null;
|
|
126
|
+
if (cacheKey !== null) {
|
|
127
|
+
const hit = this.cache.get(cacheKey);
|
|
128
|
+
if (hit && hit.until > Date.now()) {
|
|
129
|
+
return hit.answer;
|
|
130
|
+
}
|
|
131
|
+
this.cache.delete(cacheKey);
|
|
132
|
+
}
|
|
133
|
+
const answer = this.parse(await this.request(key));
|
|
134
|
+
if (cacheKey !== null && answer.active) {
|
|
135
|
+
this.remember(cacheKey, answer);
|
|
136
|
+
}
|
|
137
|
+
return answer;
|
|
138
|
+
}
|
|
139
|
+
async request(key) {
|
|
140
|
+
const controller = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
142
|
+
let response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetch(this.endpoint, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
authorization: `Basic ${btoa(`${this.config.clientId}:${this.config.clientSecret}`)}`,
|
|
148
|
+
accept: "application/json",
|
|
149
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
150
|
+
},
|
|
151
|
+
body: new URLSearchParams({ key }),
|
|
152
|
+
signal: controller.signal
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new AuthenticationError(`API key verification request failed: ${String(error)}`);
|
|
156
|
+
} finally {
|
|
157
|
+
clearTimeout(timer);
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw await oauthError(response, "API key verification failed");
|
|
161
|
+
}
|
|
162
|
+
return response.json().catch(() => null);
|
|
163
|
+
}
|
|
164
|
+
parse(body) {
|
|
165
|
+
if (typeof body !== "object" || body === null) {
|
|
166
|
+
throw new AuthenticationError("API key verification returned a body that is not a JSON object.");
|
|
167
|
+
}
|
|
168
|
+
const record = body;
|
|
169
|
+
if (record["active"] !== true) {
|
|
170
|
+
return { active: false };
|
|
171
|
+
}
|
|
172
|
+
const keyId = record["key_id"];
|
|
173
|
+
const sub = record["sub"];
|
|
174
|
+
const org = record["org"];
|
|
175
|
+
const clientId = record["client_id"];
|
|
176
|
+
const expiresAt = record["expires_at"];
|
|
177
|
+
const permissions2 = record["permissions"];
|
|
178
|
+
if (!nonEmpty(keyId) || !nonEmpty(sub) || !nonEmpty(org) || !nonEmpty(clientId) || !Array.isArray(permissions2) || expiresAt !== null && expiresAt !== void 0 && typeof expiresAt !== "string") {
|
|
179
|
+
throw new AuthenticationError("API key verification returned an active answer without the promised fields.");
|
|
180
|
+
}
|
|
181
|
+
if (clientId !== this.config.clientId) {
|
|
182
|
+
throw new AuthenticationError(
|
|
183
|
+
`API key verification answered for client ${clientId}, not this app (${this.config.clientId}); refusing it.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (typeof expiresAt === "string") {
|
|
187
|
+
const expiry = Date.parse(expiresAt);
|
|
188
|
+
if (Number.isNaN(expiry) || expiry <= Date.now()) {
|
|
189
|
+
return { active: false };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const role = record["org_role"];
|
|
193
|
+
return {
|
|
194
|
+
active: true,
|
|
195
|
+
key_id: keyId,
|
|
196
|
+
sub,
|
|
197
|
+
org,
|
|
198
|
+
org_role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null,
|
|
199
|
+
permissions: permissions2.filter((value) => nonEmpty(value)),
|
|
200
|
+
client_id: clientId,
|
|
201
|
+
expires_at: typeof expiresAt === "string" ? expiresAt : null
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
remember(cacheKey, answer) {
|
|
205
|
+
const expiry = answer.expires_at === null ? Number.POSITIVE_INFINITY : Date.parse(answer.expires_at);
|
|
206
|
+
const until = Math.min(Date.now() + this.cacheTtlMs, expiry);
|
|
207
|
+
if (this.cache.size >= MAX_CACHE_ENTRIES) {
|
|
208
|
+
const oldest = this.cache.keys().next();
|
|
209
|
+
if (!oldest.done) {
|
|
210
|
+
this.cache.delete(oldest.value);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.cache.set(cacheKey, { answer, until });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
function nonEmpty(value) {
|
|
217
|
+
return typeof value === "string" && value !== "";
|
|
218
|
+
}
|
|
219
|
+
async function digest(key) {
|
|
220
|
+
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
|
|
221
|
+
return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
222
|
+
}
|
|
223
|
+
|
|
55
224
|
// src/discovery.ts
|
|
56
225
|
var Discovery = class {
|
|
57
226
|
constructor(issuer, timeoutMs, cacheTtlMs, now = Date.now) {
|
|
@@ -69,7 +238,7 @@ var Discovery = class {
|
|
|
69
238
|
if (this.cached && this.cached.expiresAt > this.now()) {
|
|
70
239
|
return this.cached.doc;
|
|
71
240
|
}
|
|
72
|
-
const url = `${this.issuer
|
|
241
|
+
const url = `${trimSlash(this.issuer)}/.well-known/openid-configuration`;
|
|
73
242
|
const controller = new AbortController();
|
|
74
243
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
75
244
|
let doc;
|
|
@@ -90,6 +259,11 @@ var Discovery = class {
|
|
|
90
259
|
if (typeof doc.issuer !== "string" || doc.issuer === "") {
|
|
91
260
|
throw new AuthenticationError("The discovery document was missing an issuer.");
|
|
92
261
|
}
|
|
262
|
+
if (trimSlash(doc.issuer) !== trimSlash(this.issuer)) {
|
|
263
|
+
throw new AuthenticationError(
|
|
264
|
+
`The discovery document is for a different issuer (${doc.issuer}).`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
93
267
|
this.cached = { doc, expiresAt: this.now() + this.cacheTtlMs };
|
|
94
268
|
return doc;
|
|
95
269
|
}
|
|
@@ -107,6 +281,9 @@ var Discovery = class {
|
|
|
107
281
|
return typeof value === "string" && value !== "" ? value : null;
|
|
108
282
|
}
|
|
109
283
|
};
|
|
284
|
+
function trimSlash(value) {
|
|
285
|
+
return value.replace(/\/+$/, "");
|
|
286
|
+
}
|
|
110
287
|
|
|
111
288
|
// src/pkce.ts
|
|
112
289
|
function base64url(bytes) {
|
|
@@ -123,8 +300,68 @@ function createVerifier() {
|
|
|
123
300
|
return randomToken(32);
|
|
124
301
|
}
|
|
125
302
|
async function challenge(verifier) {
|
|
126
|
-
const
|
|
127
|
-
return base64url(new Uint8Array(
|
|
303
|
+
const digest2 = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
304
|
+
return base64url(new Uint8Array(digest2));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/claims.ts
|
|
308
|
+
var ORGANIZATION_ROLES2 = /* @__PURE__ */ new Set([
|
|
309
|
+
"owner",
|
|
310
|
+
"admin",
|
|
311
|
+
"developer",
|
|
312
|
+
"member",
|
|
313
|
+
"viewer"
|
|
314
|
+
]);
|
|
315
|
+
function organization(source) {
|
|
316
|
+
const claims = claimsOf(source);
|
|
317
|
+
const id = claims["org"];
|
|
318
|
+
if (typeof id !== "string" || id === "") {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const name = claims["org_name"];
|
|
322
|
+
const role = claims["org_role"];
|
|
323
|
+
return {
|
|
324
|
+
id,
|
|
325
|
+
name: typeof name === "string" && name !== "" ? name : null,
|
|
326
|
+
role: typeof role === "string" && ORGANIZATION_ROLES2.has(role) ? role : null
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function actor(source) {
|
|
330
|
+
return parseActor(claimsOf(source)["act"], 0);
|
|
331
|
+
}
|
|
332
|
+
function sessionId(source) {
|
|
333
|
+
const sid = claimsOf(source)["sid"];
|
|
334
|
+
return typeof sid === "string" && sid !== "" ? sid : null;
|
|
335
|
+
}
|
|
336
|
+
function roles(source) {
|
|
337
|
+
return stringList(claimsOf(source)["roles"]);
|
|
338
|
+
}
|
|
339
|
+
function permissions(source) {
|
|
340
|
+
return stringList(claimsOf(source)["permissions"]);
|
|
341
|
+
}
|
|
342
|
+
var MAX_ACTOR_DEPTH = 8;
|
|
343
|
+
function parseActor(claim, depth) {
|
|
344
|
+
if (claim === void 0 || claim === null) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
if (typeof claim !== "object" || Array.isArray(claim)) {
|
|
348
|
+
return { sub: null, actor: null };
|
|
349
|
+
}
|
|
350
|
+
const record = claim;
|
|
351
|
+
const sub = record["sub"];
|
|
352
|
+
return {
|
|
353
|
+
sub: typeof sub === "string" && sub !== "" ? sub : null,
|
|
354
|
+
actor: depth + 1 < MAX_ACTOR_DEPTH ? parseActor(record["act"], depth + 1) : null
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function stringList(claim) {
|
|
358
|
+
return Array.isArray(claim) ? claim.filter((value) => typeof value === "string" && value !== "") : [];
|
|
359
|
+
}
|
|
360
|
+
function claimsOf(source) {
|
|
361
|
+
return isUser(source) ? source.claims : source;
|
|
362
|
+
}
|
|
363
|
+
function isUser(source) {
|
|
364
|
+
return typeof source["claims"] === "object" && source["claims"] !== null && typeof source["accessToken"] === "string";
|
|
128
365
|
}
|
|
129
366
|
|
|
130
367
|
// src/vault.ts
|
|
@@ -272,9 +509,7 @@ var CboxIdClient = class {
|
|
|
272
509
|
if (!config.clientId) {
|
|
273
510
|
throw new ConfigurationError("Cbox ID config `clientId` is required.");
|
|
274
511
|
}
|
|
275
|
-
|
|
276
|
-
throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
|
|
277
|
-
}
|
|
512
|
+
assertSecureIssuer(config.issuer);
|
|
278
513
|
this.discovery = new Discovery(
|
|
279
514
|
config.issuer,
|
|
280
515
|
config.timeoutMs ?? 1e4,
|
|
@@ -290,21 +525,29 @@ var CboxIdClient = class {
|
|
|
290
525
|
* to {@link authenticate} on the callback.
|
|
291
526
|
*/
|
|
292
527
|
async createAuthorizationRequest(options = {}) {
|
|
528
|
+
const prompt = promptValue(options.prompt);
|
|
529
|
+
assertOrganizationOptions(options, prompt);
|
|
293
530
|
const codeVerifier = createVerifier();
|
|
294
531
|
const state = options.state ?? randomToken(16);
|
|
295
532
|
const nonce = randomToken(16);
|
|
533
|
+
const redirectUri = options.redirectUri ?? this.config.redirectUri;
|
|
534
|
+
if (redirectUri === void 0 || redirectUri === "") {
|
|
535
|
+
throw new ConfigurationError(
|
|
536
|
+
"A `redirectUri` is required to start the browser sign-in flow. Set it in the config, or pass it to createAuthorizationRequest."
|
|
537
|
+
);
|
|
538
|
+
}
|
|
296
539
|
const params = new URLSearchParams({
|
|
297
540
|
response_type: "code",
|
|
298
541
|
client_id: this.config.clientId,
|
|
299
|
-
redirect_uri:
|
|
542
|
+
redirect_uri: redirectUri,
|
|
300
543
|
scope: (options.scopes ?? this.scopes()).join(" "),
|
|
301
544
|
state,
|
|
302
545
|
nonce,
|
|
303
546
|
code_challenge: await challenge(codeVerifier),
|
|
304
547
|
code_challenge_method: "S256"
|
|
305
548
|
});
|
|
306
|
-
if (
|
|
307
|
-
params.set("prompt",
|
|
549
|
+
if (prompt.length > 0) {
|
|
550
|
+
params.set("prompt", prompt.join(" "));
|
|
308
551
|
}
|
|
309
552
|
if (options.loginHint) {
|
|
310
553
|
params.set("login_hint", options.loginHint);
|
|
@@ -312,6 +555,12 @@ var CboxIdClient = class {
|
|
|
312
555
|
if (typeof options.maxAge === "number") {
|
|
313
556
|
params.set("max_age", String(options.maxAge));
|
|
314
557
|
}
|
|
558
|
+
if (options.organization !== void 0) {
|
|
559
|
+
params.set("organization", options.organization);
|
|
560
|
+
}
|
|
561
|
+
if (options.organizationHint !== void 0) {
|
|
562
|
+
params.set("organization_hint", options.organizationHint);
|
|
563
|
+
}
|
|
315
564
|
const endpoint = await this.discovery.endpoint("authorization_endpoint");
|
|
316
565
|
return {
|
|
317
566
|
url: `${endpoint}?${params.toString()}`,
|
|
@@ -320,9 +569,27 @@ var CboxIdClient = class {
|
|
|
320
569
|
nonce,
|
|
321
570
|
// Carried through so authenticate() can hold the instance to it. Only present
|
|
322
571
|
// when requested (exactOptionalPropertyTypes).
|
|
323
|
-
...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {}
|
|
572
|
+
...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {},
|
|
573
|
+
...options.organization !== void 0 ? { organization: options.organization } : {}
|
|
324
574
|
};
|
|
325
575
|
}
|
|
576
|
+
/**
|
|
577
|
+
* Switch the signed-in person to another organization: a new authorization bound to
|
|
578
|
+
* `organizationId`. Persist and redirect exactly as for
|
|
579
|
+
* {@link createAuthorizationRequest} — it is one, with `organization` set.
|
|
580
|
+
*
|
|
581
|
+
* Cbox ID already holds the person's session, so this is normally a redirect there and
|
|
582
|
+
* straight back with no sign-in form. The tokens that come back carry the new `org`,
|
|
583
|
+
* `org_role`, `roles` and `permissions`; replace your session with them rather than
|
|
584
|
+
* patching the old one, because every one of those can differ between organizations.
|
|
585
|
+
*
|
|
586
|
+
* A person who is not (or is no longer) an active member of that organization comes
|
|
587
|
+
* back with `error=access_denied`, which {@link authenticate} throws as an
|
|
588
|
+
* `AuthenticationError` with `error === 'access_denied'`.
|
|
589
|
+
*/
|
|
590
|
+
switchOrganization(organizationId, options = {}) {
|
|
591
|
+
return this.createAuthorizationRequest({ ...options, organization: organizationId });
|
|
592
|
+
}
|
|
326
593
|
/**
|
|
327
594
|
* Complete login on your callback route: verify state, exchange the code with the
|
|
328
595
|
* PKCE verifier, verify the id_token, and return the authenticated user.
|
|
@@ -337,7 +604,9 @@ var CboxIdClient = class {
|
|
|
337
604
|
}
|
|
338
605
|
if (params.error) {
|
|
339
606
|
throw new AuthenticationError(
|
|
340
|
-
`Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}
|
|
607
|
+
`Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`,
|
|
608
|
+
params.error,
|
|
609
|
+
params.error_description ?? void 0
|
|
341
610
|
);
|
|
342
611
|
}
|
|
343
612
|
if (!params.code) {
|
|
@@ -359,6 +628,24 @@ var CboxIdClient = class {
|
|
|
359
628
|
"A max_age was requested but no id_token was returned, so the authentication age could not be verified."
|
|
360
629
|
);
|
|
361
630
|
}
|
|
631
|
+
const user = await this.identityFrom(tokens, verified);
|
|
632
|
+
if (stored.organization !== void 0 && user.organizationId !== stored.organization) {
|
|
633
|
+
throw new AuthenticationError(
|
|
634
|
+
`The sign-in was bound to organization ${stored.organization}, but the tokens are for ${user.organizationId ?? "no organization"}. The instance may not support organization selection.`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return user;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Turn a token response into the verified {@link CboxUser} every flow returns.
|
|
641
|
+
*
|
|
642
|
+
* Shared by the browser flow and the device flow rather than written twice: the checks
|
|
643
|
+
* in here — the UserInfo subject matching the id_token's, the merge that lets UserInfo
|
|
644
|
+
* enrich but never overwrite signed claims — are the ones that decide whether an
|
|
645
|
+
* identity is trustworthy. A second copy is a second place for one of them to be
|
|
646
|
+
* quietly missing, and the flow it was missing from would look exactly as correct.
|
|
647
|
+
*/
|
|
648
|
+
async identityFrom(tokens, verified) {
|
|
362
649
|
const profile = await this.userinfo(tokens.access_token);
|
|
363
650
|
const verifiedSub = verified["sub"];
|
|
364
651
|
const profileSub = profile["sub"];
|
|
@@ -376,6 +663,11 @@ var CboxIdClient = class {
|
|
|
376
663
|
email: typeof claims["email"] === "string" ? claims["email"] : null,
|
|
377
664
|
name: typeof claims["name"] === "string" ? claims["name"] : null,
|
|
378
665
|
organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
|
|
666
|
+
organization: organization(claims),
|
|
667
|
+
roles: roles(claims),
|
|
668
|
+
permissions: permissions(claims),
|
|
669
|
+
actor: actor(claims),
|
|
670
|
+
sessionId: sessionId(claims),
|
|
379
671
|
// Only present when the instance emitted the claim (exactOptionalPropertyTypes).
|
|
380
672
|
...organizations ? { organizations } : {},
|
|
381
673
|
claims,
|
|
@@ -385,6 +677,118 @@ var CboxIdClient = class {
|
|
|
385
677
|
expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
|
|
386
678
|
};
|
|
387
679
|
}
|
|
680
|
+
/**
|
|
681
|
+
* Start the device authorization grant (RFC 8628) — the flow for a program with no
|
|
682
|
+
* browser of its own: a CLI, a CI job, a container, a TV.
|
|
683
|
+
*
|
|
684
|
+
* Print `userCode` and `verificationUri`, then call
|
|
685
|
+
* {@link CboxIdClient.pollDeviceToken}. If the machine has a desktop you may also open
|
|
686
|
+
* `verificationUriComplete`, which fills the code in — but print the code anyway: the
|
|
687
|
+
* machine running your program is often not the one the person is looking at.
|
|
688
|
+
*
|
|
689
|
+
* The scopes are bounded by what the app is REGISTERED for. A device request naming one
|
|
690
|
+
* outside that ceiling is refused with `invalid_scope` rather than quietly reduced,
|
|
691
|
+
* because no browser is in front of it to notice a smaller grant.
|
|
692
|
+
*
|
|
693
|
+
* @throws ConfigurationError when the instance advertises no device endpoint.
|
|
694
|
+
*/
|
|
695
|
+
async requestDeviceAuthorization(scopes) {
|
|
696
|
+
const endpoint = await this.discovery.optionalEndpoint("device_authorization_endpoint");
|
|
697
|
+
if (endpoint === null) {
|
|
698
|
+
throw new ConfigurationError(
|
|
699
|
+
"This instance does not advertise a device_authorization_endpoint, so it does not support CLI sign-in."
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const body = new URLSearchParams({
|
|
703
|
+
client_id: this.config.clientId,
|
|
704
|
+
scope: (scopes ?? this.scopes()).join(" ")
|
|
705
|
+
});
|
|
706
|
+
if (this.config.clientSecret) {
|
|
707
|
+
body.set("client_secret", this.config.clientSecret);
|
|
708
|
+
}
|
|
709
|
+
const response = await this.post(endpoint, body);
|
|
710
|
+
if (!response.ok) {
|
|
711
|
+
throw await oauthError(response, "Device authorization request failed");
|
|
712
|
+
}
|
|
713
|
+
const payload = await response.json();
|
|
714
|
+
const deviceCode = payload["device_code"];
|
|
715
|
+
const userCode = payload["user_code"];
|
|
716
|
+
const verificationUri = payload["verification_uri"];
|
|
717
|
+
if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
|
|
718
|
+
throw new AuthenticationError("The device authorization response was incomplete.");
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
deviceCode,
|
|
722
|
+
userCode,
|
|
723
|
+
verificationUri,
|
|
724
|
+
verificationUriComplete: typeof payload["verification_uri_complete"] === "string" ? payload["verification_uri_complete"] : null,
|
|
725
|
+
expiresIn: typeof payload["expires_in"] === "number" ? payload["expires_in"] : 600,
|
|
726
|
+
// RFC 8628 §3.2: absent means 5 seconds. Polling faster than the server allows is
|
|
727
|
+
// answered with `slow_down`, so the default is the one the server assumes too.
|
|
728
|
+
interval: typeof payload["interval"] === "number" ? payload["interval"] : 5
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Poll until the person approves, and return them.
|
|
733
|
+
*
|
|
734
|
+
* Blocks. It honours the server's `interval`, backs off permanently by five seconds on
|
|
735
|
+
* `slow_down` (RFC 8628 §3.5), and stops on the three answers that are final: they
|
|
736
|
+
* declined, the code expired, or tokens arrived. Pass an `AbortSignal` to give up
|
|
737
|
+
* early — a CLI should let Ctrl-C work.
|
|
738
|
+
*
|
|
739
|
+
* @throws AuthenticationError when the person declines or the code expires.
|
|
740
|
+
*/
|
|
741
|
+
async pollDeviceToken(authorization, options = {}) {
|
|
742
|
+
const endpoint = await this.discovery.endpoint("token_endpoint");
|
|
743
|
+
const deadline = Date.now() + authorization.expiresIn * 1e3;
|
|
744
|
+
let intervalMs = authorization.interval * 1e3;
|
|
745
|
+
for (; ; ) {
|
|
746
|
+
if (options.signal?.aborted === true) {
|
|
747
|
+
throw new AuthenticationError("Device sign-in was cancelled.");
|
|
748
|
+
}
|
|
749
|
+
if (Date.now() > deadline) {
|
|
750
|
+
throw new AuthenticationError(
|
|
751
|
+
"The device code expired before it was approved. Start the sign-in again."
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
await sleep(intervalMs, options.signal);
|
|
755
|
+
const body = new URLSearchParams({
|
|
756
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
757
|
+
device_code: authorization.deviceCode,
|
|
758
|
+
client_id: this.config.clientId
|
|
759
|
+
});
|
|
760
|
+
if (this.config.clientSecret) {
|
|
761
|
+
body.set("client_secret", this.config.clientSecret);
|
|
762
|
+
}
|
|
763
|
+
const response = await this.post(endpoint, body);
|
|
764
|
+
if (response.ok) {
|
|
765
|
+
const tokens = await response.json();
|
|
766
|
+
if (!tokens.access_token) {
|
|
767
|
+
throw new AuthenticationError("The device token response carried no access token.");
|
|
768
|
+
}
|
|
769
|
+
const verified = tokens.id_token ? await this.verifyIdToken(tokens.id_token, "") : {};
|
|
770
|
+
return await this.identityFrom(tokens, verified);
|
|
771
|
+
}
|
|
772
|
+
const payload = await response.json().catch(() => ({}));
|
|
773
|
+
const error = typeof payload["error"] === "string" ? payload["error"] : "invalid_request";
|
|
774
|
+
if (error === "authorization_pending") {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (error === "slow_down") {
|
|
778
|
+
intervalMs += 5e3;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (error === "access_denied") {
|
|
782
|
+
throw new AuthenticationError("Sign-in was declined.");
|
|
783
|
+
}
|
|
784
|
+
if (error === "expired_token") {
|
|
785
|
+
throw new AuthenticationError(
|
|
786
|
+
"The device code expired before it was approved. Start the sign-in again."
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
throw await oauthError(response, "Device sign-in failed");
|
|
790
|
+
}
|
|
791
|
+
}
|
|
388
792
|
/**
|
|
389
793
|
* Exchange a refresh token for a fresh access token (OAuth 2.0 `refresh_token`
|
|
390
794
|
* grant). Cbox ID rotates refresh tokens and detects reuse, so ALWAYS persist the
|
|
@@ -410,6 +814,9 @@ var CboxIdClient = class {
|
|
|
410
814
|
if (!tokens.access_token) {
|
|
411
815
|
throw new AuthenticationError("The refresh response carried no access token.");
|
|
412
816
|
}
|
|
817
|
+
if (tokens.id_token) {
|
|
818
|
+
await this.verifyIdToken(tokens.id_token, "");
|
|
819
|
+
}
|
|
413
820
|
return {
|
|
414
821
|
accessToken: tokens.access_token,
|
|
415
822
|
// Keep the presented token when the server does not rotate (OAuth 2.0 §6);
|
|
@@ -429,6 +836,25 @@ var CboxIdClient = class {
|
|
|
429
836
|
const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
|
|
430
837
|
return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
|
|
431
838
|
}
|
|
839
|
+
/**
|
|
840
|
+
* The hosted page where a person creates and revokes API keys for your API — Cbox ID's
|
|
841
|
+
* `/account/api-keys`, preselected to this app (or `clientId`). Keys made there are
|
|
842
|
+
* checked with `ApiKeyVerifier` from `@cboxdk/id-js/server`.
|
|
843
|
+
*
|
|
844
|
+
* `returnTo` becomes a link back to your app, honoured only for an origin the app
|
|
845
|
+
* registered. `organization` picks which of the person's organizations the keys act in;
|
|
846
|
+
* omitted, the page uses the one they are in.
|
|
847
|
+
*/
|
|
848
|
+
apiKeysUrl(options = {}) {
|
|
849
|
+
const params = new URLSearchParams({ client_id: options.clientId ?? this.config.clientId });
|
|
850
|
+
if (options.returnTo) {
|
|
851
|
+
params.set("return_to", options.returnTo);
|
|
852
|
+
}
|
|
853
|
+
if (options.organization) {
|
|
854
|
+
params.set("organization", options.organization);
|
|
855
|
+
}
|
|
856
|
+
return `${this.config.issuer.replace(/\/$/, "")}/account/api-keys?${params.toString()}`;
|
|
857
|
+
}
|
|
432
858
|
/**
|
|
433
859
|
* The RP-initiated logout URL, or null when the instance advertises none.
|
|
434
860
|
*
|
|
@@ -568,10 +994,16 @@ var CboxIdClient = class {
|
|
|
568
994
|
return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
|
|
569
995
|
}
|
|
570
996
|
async exchange(code, verifier, redirectUri) {
|
|
997
|
+
const callback = redirectUri ?? this.config.redirectUri;
|
|
998
|
+
if (callback === void 0 || callback === "") {
|
|
999
|
+
throw new ConfigurationError(
|
|
1000
|
+
"A `redirectUri` is required to exchange an authorization code. It must be the same one the authorization request used."
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
571
1003
|
const body = new URLSearchParams({
|
|
572
1004
|
grant_type: "authorization_code",
|
|
573
1005
|
code,
|
|
574
|
-
redirect_uri:
|
|
1006
|
+
redirect_uri: callback,
|
|
575
1007
|
client_id: this.config.clientId,
|
|
576
1008
|
code_verifier: verifier
|
|
577
1009
|
});
|
|
@@ -655,6 +1087,37 @@ var CboxIdClient = class {
|
|
|
655
1087
|
}
|
|
656
1088
|
}
|
|
657
1089
|
};
|
|
1090
|
+
function promptValue(prompt) {
|
|
1091
|
+
if (prompt === void 0) {
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
1094
|
+
const values = [...new Set(typeof prompt === "string" ? [prompt] : prompt)];
|
|
1095
|
+
if (values.includes("none") && values.length > 1) {
|
|
1096
|
+
throw new ConfigurationError("`prompt: 'none'` cannot be combined with another prompt value.");
|
|
1097
|
+
}
|
|
1098
|
+
return values;
|
|
1099
|
+
}
|
|
1100
|
+
function assertOrganizationOptions(options, prompt) {
|
|
1101
|
+
if (options.organization === "") {
|
|
1102
|
+
throw new ConfigurationError("`organization` is empty. Omit it to sign in without binding to an organization.");
|
|
1103
|
+
}
|
|
1104
|
+
if (options.organizationHint === "") {
|
|
1105
|
+
throw new ConfigurationError("`organizationHint` is empty. Omit it when you have no organization to suggest.");
|
|
1106
|
+
}
|
|
1107
|
+
if (options.organization === void 0) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (prompt.includes("select_organization")) {
|
|
1111
|
+
throw new ConfigurationError(
|
|
1112
|
+
"`organization` binds the sign-in to one organization, so `prompt: 'select_organization'` has nothing to choose. Use `organizationHint` to preselect an organization in the picker instead."
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
if (prompt.includes("create_organization")) {
|
|
1116
|
+
throw new ConfigurationError(
|
|
1117
|
+
"`organization` binds the sign-in to an existing organization and `prompt: 'create_organization'` creates a new one. Send one or the other."
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
658
1121
|
function timingSafeEqualString(a, b) {
|
|
659
1122
|
if (a.length !== b.length) {
|
|
660
1123
|
return false;
|
|
@@ -683,6 +1146,23 @@ function parseOrganizations(claim) {
|
|
|
683
1146
|
}
|
|
684
1147
|
return orgs;
|
|
685
1148
|
}
|
|
1149
|
+
function sleep(ms, signal) {
|
|
1150
|
+
return new Promise((resolve, reject) => {
|
|
1151
|
+
if (signal?.aborted === true) {
|
|
1152
|
+
reject(new AuthenticationError("Device sign-in was cancelled."));
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
const timer = setTimeout(() => {
|
|
1156
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1157
|
+
resolve();
|
|
1158
|
+
}, ms);
|
|
1159
|
+
const onAbort = () => {
|
|
1160
|
+
clearTimeout(timer);
|
|
1161
|
+
reject(new AuthenticationError("Device sign-in was cancelled."));
|
|
1162
|
+
};
|
|
1163
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
686
1166
|
|
|
687
1167
|
// src/nextjs.ts
|
|
688
1168
|
var COOKIE = {
|
|
@@ -691,12 +1171,15 @@ var COOKIE = {
|
|
|
691
1171
|
nonce: "cbox_id_nonce",
|
|
692
1172
|
// The step-up requirement has to survive the redirect like the nonce does: a
|
|
693
1173
|
// `maxAge` the callback cannot see is a `maxAge` nothing verifies.
|
|
694
|
-
maxAge: "cbox_id_max_age"
|
|
1174
|
+
maxAge: "cbox_id_max_age",
|
|
1175
|
+
// Likewise the organization a switch bound to: the callback refuses tokens for another.
|
|
1176
|
+
organization: "cbox_id_organization"
|
|
695
1177
|
};
|
|
696
1178
|
var TEMP_COOKIE_MAX_AGE = 600;
|
|
697
1179
|
function createCboxId(config) {
|
|
698
1180
|
const resolved = resolveConfig(config);
|
|
699
1181
|
const client = new CboxIdClient(resolved);
|
|
1182
|
+
let apiKeys;
|
|
700
1183
|
const tempCookieOptions = {
|
|
701
1184
|
httpOnly: true,
|
|
702
1185
|
secure: true,
|
|
@@ -704,22 +1187,33 @@ function createCboxId(config) {
|
|
|
704
1187
|
path: "/",
|
|
705
1188
|
maxAge: TEMP_COOKIE_MAX_AGE
|
|
706
1189
|
};
|
|
1190
|
+
const redirectTo = (request) => {
|
|
1191
|
+
const response = server.NextResponse.redirect(request.url);
|
|
1192
|
+
response.cookies.set(COOKIE.state, request.state, tempCookieOptions);
|
|
1193
|
+
response.cookies.set(COOKIE.verifier, request.codeVerifier, tempCookieOptions);
|
|
1194
|
+
response.cookies.set(COOKIE.nonce, request.nonce, tempCookieOptions);
|
|
1195
|
+
if (typeof request.maxAge === "number") {
|
|
1196
|
+
response.cookies.set(COOKIE.maxAge, String(request.maxAge), tempCookieOptions);
|
|
1197
|
+
}
|
|
1198
|
+
if (request.organization !== void 0) {
|
|
1199
|
+
response.cookies.set(COOKIE.organization, request.organization, tempCookieOptions);
|
|
1200
|
+
} else {
|
|
1201
|
+
response.cookies.delete(COOKIE.organization);
|
|
1202
|
+
}
|
|
1203
|
+
return response;
|
|
1204
|
+
};
|
|
707
1205
|
return {
|
|
708
1206
|
client,
|
|
709
1207
|
async signIn(options = {}) {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
response.cookies.set(COOKIE.nonce, request.nonce, tempCookieOptions);
|
|
715
|
-
if (typeof request.maxAge === "number") {
|
|
716
|
-
response.cookies.set(COOKIE.maxAge, String(request.maxAge), tempCookieOptions);
|
|
717
|
-
}
|
|
718
|
-
return response;
|
|
1208
|
+
return redirectTo(await client.createAuthorizationRequest(options));
|
|
1209
|
+
},
|
|
1210
|
+
async switchOrganization(organizationId, options = {}) {
|
|
1211
|
+
return redirectTo(await client.switchOrganization(organizationId, options));
|
|
719
1212
|
},
|
|
720
1213
|
async callback(request) {
|
|
721
1214
|
const storedMaxAge = request.cookies.get(COOKIE.maxAge)?.value;
|
|
722
1215
|
const maxAge = storedMaxAge !== void 0 ? Number(storedMaxAge) : void 0;
|
|
1216
|
+
const organization2 = request.cookies.get(COOKIE.organization)?.value;
|
|
723
1217
|
return client.authenticate({
|
|
724
1218
|
params: {
|
|
725
1219
|
code: request.nextUrl.searchParams.get("code"),
|
|
@@ -731,7 +1225,8 @@ function createCboxId(config) {
|
|
|
731
1225
|
state: request.cookies.get(COOKIE.state)?.value ?? "",
|
|
732
1226
|
codeVerifier: request.cookies.get(COOKIE.verifier)?.value ?? "",
|
|
733
1227
|
nonce: request.cookies.get(COOKIE.nonce)?.value ?? "",
|
|
734
|
-
...maxAge !== void 0 && Number.isFinite(maxAge) ? { maxAge } : {}
|
|
1228
|
+
...maxAge !== void 0 && Number.isFinite(maxAge) ? { maxAge } : {},
|
|
1229
|
+
...organization2 !== void 0 && organization2 !== "" ? { organization: organization2 } : {}
|
|
735
1230
|
}
|
|
736
1231
|
});
|
|
737
1232
|
},
|
|
@@ -741,6 +1236,18 @@ function createCboxId(config) {
|
|
|
741
1236
|
profileRedirect(returnTo) {
|
|
742
1237
|
return server.NextResponse.redirect(client.profileUrl(returnTo));
|
|
743
1238
|
},
|
|
1239
|
+
apiKeysUrl(options) {
|
|
1240
|
+
return client.apiKeysUrl(options);
|
|
1241
|
+
},
|
|
1242
|
+
verifyApiKey(key) {
|
|
1243
|
+
apiKeys ??= new ApiKeyVerifier({
|
|
1244
|
+
issuer: resolved.issuer,
|
|
1245
|
+
clientId: resolved.clientId,
|
|
1246
|
+
clientSecret: resolved.clientSecret ?? "",
|
|
1247
|
+
...resolved.timeoutMs ? { timeoutMs: resolved.timeoutMs } : {}
|
|
1248
|
+
});
|
|
1249
|
+
return apiKeys.verifyApiKey(key);
|
|
1250
|
+
},
|
|
744
1251
|
signOutUrl(returnTo, idTokenHint) {
|
|
745
1252
|
return client.logoutUrl(returnTo, idTokenHint);
|
|
746
1253
|
}
|