@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/index.cjs
CHANGED
|
@@ -43,7 +43,7 @@ async function oauthError(response, fallback) {
|
|
|
43
43
|
}
|
|
44
44
|
} catch {
|
|
45
45
|
}
|
|
46
|
-
const detail = code ??
|
|
46
|
+
const detail = code ?? `HTTP ${response.status}`;
|
|
47
47
|
const retryAfterHeader = response.headers.get("retry-after");
|
|
48
48
|
const retryAfter = retryAfterHeader !== null && /^\d+$/.test(retryAfterHeader.trim()) ? Number(retryAfterHeader.trim()) : void 0;
|
|
49
49
|
return new AuthenticationError(`${fallback}: ${detail}`, code, description, response.status, retryAfter);
|
|
@@ -153,6 +153,26 @@ function json(body, status) {
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// src/issuer.ts
|
|
157
|
+
function assertSecureIssuer(issuer) {
|
|
158
|
+
let url;
|
|
159
|
+
try {
|
|
160
|
+
url = new URL(issuer);
|
|
161
|
+
} catch {
|
|
162
|
+
throw new ConfigurationError("Cbox ID config `issuer` is not a valid URL.");
|
|
163
|
+
}
|
|
164
|
+
if (url.protocol === "https:") {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
168
|
+
if (url.protocol === "http:" && loopback) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
throw new ConfigurationError(
|
|
172
|
+
`Cbox ID config \`issuer\` must be https (got ${url.protocol}//${url.hostname}).`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
// src/frontend.ts
|
|
157
177
|
var CboxIdFrontend = class {
|
|
158
178
|
issuer;
|
|
@@ -173,6 +193,7 @@ var CboxIdFrontend = class {
|
|
|
173
193
|
if (!options.issuer) {
|
|
174
194
|
throw new ConfigurationError("issuer is required.");
|
|
175
195
|
}
|
|
196
|
+
assertSecureIssuer(options.issuer);
|
|
176
197
|
if (!options.publishableKey?.startsWith("pk_")) {
|
|
177
198
|
throw new ConfigurationError(
|
|
178
199
|
"publishableKey must be a pk_test_\u2026 or pk_live_\u2026 key. Client secrets and API keys must never be put in a browser."
|
|
@@ -476,7 +497,7 @@ var Discovery = class {
|
|
|
476
497
|
if (this.cached && this.cached.expiresAt > this.now()) {
|
|
477
498
|
return this.cached.doc;
|
|
478
499
|
}
|
|
479
|
-
const url = `${this.issuer
|
|
500
|
+
const url = `${trimSlash(this.issuer)}/.well-known/openid-configuration`;
|
|
480
501
|
const controller = new AbortController();
|
|
481
502
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
482
503
|
let doc;
|
|
@@ -497,6 +518,11 @@ var Discovery = class {
|
|
|
497
518
|
if (typeof doc.issuer !== "string" || doc.issuer === "") {
|
|
498
519
|
throw new AuthenticationError("The discovery document was missing an issuer.");
|
|
499
520
|
}
|
|
521
|
+
if (trimSlash(doc.issuer) !== trimSlash(this.issuer)) {
|
|
522
|
+
throw new AuthenticationError(
|
|
523
|
+
`The discovery document is for a different issuer (${doc.issuer}).`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
500
526
|
this.cached = { doc, expiresAt: this.now() + this.cacheTtlMs };
|
|
501
527
|
return doc;
|
|
502
528
|
}
|
|
@@ -514,6 +540,9 @@ var Discovery = class {
|
|
|
514
540
|
return typeof value === "string" && value !== "" ? value : null;
|
|
515
541
|
}
|
|
516
542
|
};
|
|
543
|
+
function trimSlash(value) {
|
|
544
|
+
return value.replace(/\/+$/, "");
|
|
545
|
+
}
|
|
517
546
|
|
|
518
547
|
// src/pkce.ts
|
|
519
548
|
function base64url(bytes) {
|
|
@@ -534,6 +563,75 @@ async function challenge(verifier) {
|
|
|
534
563
|
return base64url(new Uint8Array(digest));
|
|
535
564
|
}
|
|
536
565
|
|
|
566
|
+
// src/claims.ts
|
|
567
|
+
var ORGANIZATION_ROLES = /* @__PURE__ */ new Set([
|
|
568
|
+
"owner",
|
|
569
|
+
"admin",
|
|
570
|
+
"developer",
|
|
571
|
+
"member",
|
|
572
|
+
"viewer"
|
|
573
|
+
]);
|
|
574
|
+
function organization(source) {
|
|
575
|
+
const claims = claimsOf(source);
|
|
576
|
+
const id = claims["org"];
|
|
577
|
+
if (typeof id !== "string" || id === "") {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
const name = claims["org_name"];
|
|
581
|
+
const role = claims["org_role"];
|
|
582
|
+
return {
|
|
583
|
+
id,
|
|
584
|
+
name: typeof name === "string" && name !== "" ? name : null,
|
|
585
|
+
role: typeof role === "string" && ORGANIZATION_ROLES.has(role) ? role : null
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function actor(source) {
|
|
589
|
+
return parseActor(claimsOf(source)["act"], 0);
|
|
590
|
+
}
|
|
591
|
+
function isSupportSession(source) {
|
|
592
|
+
return actor(source) !== null;
|
|
593
|
+
}
|
|
594
|
+
function sessionId(source) {
|
|
595
|
+
const sid = claimsOf(source)["sid"];
|
|
596
|
+
return typeof sid === "string" && sid !== "" ? sid : null;
|
|
597
|
+
}
|
|
598
|
+
function roles(source) {
|
|
599
|
+
return stringList(claimsOf(source)["roles"]);
|
|
600
|
+
}
|
|
601
|
+
function permissions(source) {
|
|
602
|
+
return stringList(claimsOf(source)["permissions"]);
|
|
603
|
+
}
|
|
604
|
+
function hasRole(source, role) {
|
|
605
|
+
return roles(source).includes(role);
|
|
606
|
+
}
|
|
607
|
+
function hasPermission(source, permission) {
|
|
608
|
+
return permissions(source).includes(permission);
|
|
609
|
+
}
|
|
610
|
+
var MAX_ACTOR_DEPTH = 8;
|
|
611
|
+
function parseActor(claim, depth) {
|
|
612
|
+
if (claim === void 0 || claim === null) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
if (typeof claim !== "object" || Array.isArray(claim)) {
|
|
616
|
+
return { sub: null, actor: null };
|
|
617
|
+
}
|
|
618
|
+
const record = claim;
|
|
619
|
+
const sub = record["sub"];
|
|
620
|
+
return {
|
|
621
|
+
sub: typeof sub === "string" && sub !== "" ? sub : null,
|
|
622
|
+
actor: depth + 1 < MAX_ACTOR_DEPTH ? parseActor(record["act"], depth + 1) : null
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
function stringList(claim) {
|
|
626
|
+
return Array.isArray(claim) ? claim.filter((value) => typeof value === "string" && value !== "") : [];
|
|
627
|
+
}
|
|
628
|
+
function claimsOf(source) {
|
|
629
|
+
return isUser(source) ? source.claims : source;
|
|
630
|
+
}
|
|
631
|
+
function isUser(source) {
|
|
632
|
+
return typeof source["claims"] === "object" && source["claims"] !== null && typeof source["accessToken"] === "string";
|
|
633
|
+
}
|
|
634
|
+
|
|
537
635
|
// src/vault.ts
|
|
538
636
|
var VaultClient = class {
|
|
539
637
|
constructor(issuer, token, timeoutMs = 1e4) {
|
|
@@ -679,9 +777,7 @@ var CboxIdClient = class {
|
|
|
679
777
|
if (!config.clientId) {
|
|
680
778
|
throw new ConfigurationError("Cbox ID config `clientId` is required.");
|
|
681
779
|
}
|
|
682
|
-
|
|
683
|
-
throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
|
|
684
|
-
}
|
|
780
|
+
assertSecureIssuer(config.issuer);
|
|
685
781
|
this.discovery = new Discovery(
|
|
686
782
|
config.issuer,
|
|
687
783
|
config.timeoutMs ?? 1e4,
|
|
@@ -697,21 +793,29 @@ var CboxIdClient = class {
|
|
|
697
793
|
* to {@link authenticate} on the callback.
|
|
698
794
|
*/
|
|
699
795
|
async createAuthorizationRequest(options = {}) {
|
|
796
|
+
const prompt = promptValue(options.prompt);
|
|
797
|
+
assertOrganizationOptions(options, prompt);
|
|
700
798
|
const codeVerifier = createVerifier();
|
|
701
799
|
const state = options.state ?? randomToken(16);
|
|
702
800
|
const nonce = randomToken(16);
|
|
801
|
+
const redirectUri = options.redirectUri ?? this.config.redirectUri;
|
|
802
|
+
if (redirectUri === void 0 || redirectUri === "") {
|
|
803
|
+
throw new ConfigurationError(
|
|
804
|
+
"A `redirectUri` is required to start the browser sign-in flow. Set it in the config, or pass it to createAuthorizationRequest."
|
|
805
|
+
);
|
|
806
|
+
}
|
|
703
807
|
const params = new URLSearchParams({
|
|
704
808
|
response_type: "code",
|
|
705
809
|
client_id: this.config.clientId,
|
|
706
|
-
redirect_uri:
|
|
810
|
+
redirect_uri: redirectUri,
|
|
707
811
|
scope: (options.scopes ?? this.scopes()).join(" "),
|
|
708
812
|
state,
|
|
709
813
|
nonce,
|
|
710
814
|
code_challenge: await challenge(codeVerifier),
|
|
711
815
|
code_challenge_method: "S256"
|
|
712
816
|
});
|
|
713
|
-
if (
|
|
714
|
-
params.set("prompt",
|
|
817
|
+
if (prompt.length > 0) {
|
|
818
|
+
params.set("prompt", prompt.join(" "));
|
|
715
819
|
}
|
|
716
820
|
if (options.loginHint) {
|
|
717
821
|
params.set("login_hint", options.loginHint);
|
|
@@ -719,6 +823,12 @@ var CboxIdClient = class {
|
|
|
719
823
|
if (typeof options.maxAge === "number") {
|
|
720
824
|
params.set("max_age", String(options.maxAge));
|
|
721
825
|
}
|
|
826
|
+
if (options.organization !== void 0) {
|
|
827
|
+
params.set("organization", options.organization);
|
|
828
|
+
}
|
|
829
|
+
if (options.organizationHint !== void 0) {
|
|
830
|
+
params.set("organization_hint", options.organizationHint);
|
|
831
|
+
}
|
|
722
832
|
const endpoint = await this.discovery.endpoint("authorization_endpoint");
|
|
723
833
|
return {
|
|
724
834
|
url: `${endpoint}?${params.toString()}`,
|
|
@@ -727,9 +837,27 @@ var CboxIdClient = class {
|
|
|
727
837
|
nonce,
|
|
728
838
|
// Carried through so authenticate() can hold the instance to it. Only present
|
|
729
839
|
// when requested (exactOptionalPropertyTypes).
|
|
730
|
-
...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {}
|
|
840
|
+
...typeof options.maxAge === "number" ? { maxAge: options.maxAge } : {},
|
|
841
|
+
...options.organization !== void 0 ? { organization: options.organization } : {}
|
|
731
842
|
};
|
|
732
843
|
}
|
|
844
|
+
/**
|
|
845
|
+
* Switch the signed-in person to another organization: a new authorization bound to
|
|
846
|
+
* `organizationId`. Persist and redirect exactly as for
|
|
847
|
+
* {@link createAuthorizationRequest} — it is one, with `organization` set.
|
|
848
|
+
*
|
|
849
|
+
* Cbox ID already holds the person's session, so this is normally a redirect there and
|
|
850
|
+
* straight back with no sign-in form. The tokens that come back carry the new `org`,
|
|
851
|
+
* `org_role`, `roles` and `permissions`; replace your session with them rather than
|
|
852
|
+
* patching the old one, because every one of those can differ between organizations.
|
|
853
|
+
*
|
|
854
|
+
* A person who is not (or is no longer) an active member of that organization comes
|
|
855
|
+
* back with `error=access_denied`, which {@link authenticate} throws as an
|
|
856
|
+
* `AuthenticationError` with `error === 'access_denied'`.
|
|
857
|
+
*/
|
|
858
|
+
switchOrganization(organizationId, options = {}) {
|
|
859
|
+
return this.createAuthorizationRequest({ ...options, organization: organizationId });
|
|
860
|
+
}
|
|
733
861
|
/**
|
|
734
862
|
* Complete login on your callback route: verify state, exchange the code with the
|
|
735
863
|
* PKCE verifier, verify the id_token, and return the authenticated user.
|
|
@@ -744,7 +872,9 @@ var CboxIdClient = class {
|
|
|
744
872
|
}
|
|
745
873
|
if (params.error) {
|
|
746
874
|
throw new AuthenticationError(
|
|
747
|
-
`Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}
|
|
875
|
+
`Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`,
|
|
876
|
+
params.error,
|
|
877
|
+
params.error_description ?? void 0
|
|
748
878
|
);
|
|
749
879
|
}
|
|
750
880
|
if (!params.code) {
|
|
@@ -766,6 +896,24 @@ var CboxIdClient = class {
|
|
|
766
896
|
"A max_age was requested but no id_token was returned, so the authentication age could not be verified."
|
|
767
897
|
);
|
|
768
898
|
}
|
|
899
|
+
const user = await this.identityFrom(tokens, verified);
|
|
900
|
+
if (stored.organization !== void 0 && user.organizationId !== stored.organization) {
|
|
901
|
+
throw new AuthenticationError(
|
|
902
|
+
`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.`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
return user;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Turn a token response into the verified {@link CboxUser} every flow returns.
|
|
909
|
+
*
|
|
910
|
+
* Shared by the browser flow and the device flow rather than written twice: the checks
|
|
911
|
+
* in here — the UserInfo subject matching the id_token's, the merge that lets UserInfo
|
|
912
|
+
* enrich but never overwrite signed claims — are the ones that decide whether an
|
|
913
|
+
* identity is trustworthy. A second copy is a second place for one of them to be
|
|
914
|
+
* quietly missing, and the flow it was missing from would look exactly as correct.
|
|
915
|
+
*/
|
|
916
|
+
async identityFrom(tokens, verified) {
|
|
769
917
|
const profile = await this.userinfo(tokens.access_token);
|
|
770
918
|
const verifiedSub = verified["sub"];
|
|
771
919
|
const profileSub = profile["sub"];
|
|
@@ -783,6 +931,11 @@ var CboxIdClient = class {
|
|
|
783
931
|
email: typeof claims["email"] === "string" ? claims["email"] : null,
|
|
784
932
|
name: typeof claims["name"] === "string" ? claims["name"] : null,
|
|
785
933
|
organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
|
|
934
|
+
organization: organization(claims),
|
|
935
|
+
roles: roles(claims),
|
|
936
|
+
permissions: permissions(claims),
|
|
937
|
+
actor: actor(claims),
|
|
938
|
+
sessionId: sessionId(claims),
|
|
786
939
|
// Only present when the instance emitted the claim (exactOptionalPropertyTypes).
|
|
787
940
|
...organizations ? { organizations } : {},
|
|
788
941
|
claims,
|
|
@@ -792,6 +945,118 @@ var CboxIdClient = class {
|
|
|
792
945
|
expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
|
|
793
946
|
};
|
|
794
947
|
}
|
|
948
|
+
/**
|
|
949
|
+
* Start the device authorization grant (RFC 8628) — the flow for a program with no
|
|
950
|
+
* browser of its own: a CLI, a CI job, a container, a TV.
|
|
951
|
+
*
|
|
952
|
+
* Print `userCode` and `verificationUri`, then call
|
|
953
|
+
* {@link CboxIdClient.pollDeviceToken}. If the machine has a desktop you may also open
|
|
954
|
+
* `verificationUriComplete`, which fills the code in — but print the code anyway: the
|
|
955
|
+
* machine running your program is often not the one the person is looking at.
|
|
956
|
+
*
|
|
957
|
+
* The scopes are bounded by what the app is REGISTERED for. A device request naming one
|
|
958
|
+
* outside that ceiling is refused with `invalid_scope` rather than quietly reduced,
|
|
959
|
+
* because no browser is in front of it to notice a smaller grant.
|
|
960
|
+
*
|
|
961
|
+
* @throws ConfigurationError when the instance advertises no device endpoint.
|
|
962
|
+
*/
|
|
963
|
+
async requestDeviceAuthorization(scopes) {
|
|
964
|
+
const endpoint = await this.discovery.optionalEndpoint("device_authorization_endpoint");
|
|
965
|
+
if (endpoint === null) {
|
|
966
|
+
throw new ConfigurationError(
|
|
967
|
+
"This instance does not advertise a device_authorization_endpoint, so it does not support CLI sign-in."
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
const body = new URLSearchParams({
|
|
971
|
+
client_id: this.config.clientId,
|
|
972
|
+
scope: (scopes ?? this.scopes()).join(" ")
|
|
973
|
+
});
|
|
974
|
+
if (this.config.clientSecret) {
|
|
975
|
+
body.set("client_secret", this.config.clientSecret);
|
|
976
|
+
}
|
|
977
|
+
const response = await this.post(endpoint, body);
|
|
978
|
+
if (!response.ok) {
|
|
979
|
+
throw await oauthError(response, "Device authorization request failed");
|
|
980
|
+
}
|
|
981
|
+
const payload = await response.json();
|
|
982
|
+
const deviceCode = payload["device_code"];
|
|
983
|
+
const userCode = payload["user_code"];
|
|
984
|
+
const verificationUri = payload["verification_uri"];
|
|
985
|
+
if (typeof deviceCode !== "string" || typeof userCode !== "string" || typeof verificationUri !== "string") {
|
|
986
|
+
throw new AuthenticationError("The device authorization response was incomplete.");
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
deviceCode,
|
|
990
|
+
userCode,
|
|
991
|
+
verificationUri,
|
|
992
|
+
verificationUriComplete: typeof payload["verification_uri_complete"] === "string" ? payload["verification_uri_complete"] : null,
|
|
993
|
+
expiresIn: typeof payload["expires_in"] === "number" ? payload["expires_in"] : 600,
|
|
994
|
+
// RFC 8628 §3.2: absent means 5 seconds. Polling faster than the server allows is
|
|
995
|
+
// answered with `slow_down`, so the default is the one the server assumes too.
|
|
996
|
+
interval: typeof payload["interval"] === "number" ? payload["interval"] : 5
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Poll until the person approves, and return them.
|
|
1001
|
+
*
|
|
1002
|
+
* Blocks. It honours the server's `interval`, backs off permanently by five seconds on
|
|
1003
|
+
* `slow_down` (RFC 8628 §3.5), and stops on the three answers that are final: they
|
|
1004
|
+
* declined, the code expired, or tokens arrived. Pass an `AbortSignal` to give up
|
|
1005
|
+
* early — a CLI should let Ctrl-C work.
|
|
1006
|
+
*
|
|
1007
|
+
* @throws AuthenticationError when the person declines or the code expires.
|
|
1008
|
+
*/
|
|
1009
|
+
async pollDeviceToken(authorization, options = {}) {
|
|
1010
|
+
const endpoint = await this.discovery.endpoint("token_endpoint");
|
|
1011
|
+
const deadline = Date.now() + authorization.expiresIn * 1e3;
|
|
1012
|
+
let intervalMs = authorization.interval * 1e3;
|
|
1013
|
+
for (; ; ) {
|
|
1014
|
+
if (options.signal?.aborted === true) {
|
|
1015
|
+
throw new AuthenticationError("Device sign-in was cancelled.");
|
|
1016
|
+
}
|
|
1017
|
+
if (Date.now() > deadline) {
|
|
1018
|
+
throw new AuthenticationError(
|
|
1019
|
+
"The device code expired before it was approved. Start the sign-in again."
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
await sleep(intervalMs, options.signal);
|
|
1023
|
+
const body = new URLSearchParams({
|
|
1024
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1025
|
+
device_code: authorization.deviceCode,
|
|
1026
|
+
client_id: this.config.clientId
|
|
1027
|
+
});
|
|
1028
|
+
if (this.config.clientSecret) {
|
|
1029
|
+
body.set("client_secret", this.config.clientSecret);
|
|
1030
|
+
}
|
|
1031
|
+
const response = await this.post(endpoint, body);
|
|
1032
|
+
if (response.ok) {
|
|
1033
|
+
const tokens = await response.json();
|
|
1034
|
+
if (!tokens.access_token) {
|
|
1035
|
+
throw new AuthenticationError("The device token response carried no access token.");
|
|
1036
|
+
}
|
|
1037
|
+
const verified = tokens.id_token ? await this.verifyIdToken(tokens.id_token, "") : {};
|
|
1038
|
+
return await this.identityFrom(tokens, verified);
|
|
1039
|
+
}
|
|
1040
|
+
const payload = await response.json().catch(() => ({}));
|
|
1041
|
+
const error = typeof payload["error"] === "string" ? payload["error"] : "invalid_request";
|
|
1042
|
+
if (error === "authorization_pending") {
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (error === "slow_down") {
|
|
1046
|
+
intervalMs += 5e3;
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
if (error === "access_denied") {
|
|
1050
|
+
throw new AuthenticationError("Sign-in was declined.");
|
|
1051
|
+
}
|
|
1052
|
+
if (error === "expired_token") {
|
|
1053
|
+
throw new AuthenticationError(
|
|
1054
|
+
"The device code expired before it was approved. Start the sign-in again."
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
throw await oauthError(response, "Device sign-in failed");
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
795
1060
|
/**
|
|
796
1061
|
* Exchange a refresh token for a fresh access token (OAuth 2.0 `refresh_token`
|
|
797
1062
|
* grant). Cbox ID rotates refresh tokens and detects reuse, so ALWAYS persist the
|
|
@@ -817,6 +1082,9 @@ var CboxIdClient = class {
|
|
|
817
1082
|
if (!tokens.access_token) {
|
|
818
1083
|
throw new AuthenticationError("The refresh response carried no access token.");
|
|
819
1084
|
}
|
|
1085
|
+
if (tokens.id_token) {
|
|
1086
|
+
await this.verifyIdToken(tokens.id_token, "");
|
|
1087
|
+
}
|
|
820
1088
|
return {
|
|
821
1089
|
accessToken: tokens.access_token,
|
|
822
1090
|
// Keep the presented token when the server does not rotate (OAuth 2.0 §6);
|
|
@@ -836,6 +1104,25 @@ var CboxIdClient = class {
|
|
|
836
1104
|
const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
|
|
837
1105
|
return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
|
|
838
1106
|
}
|
|
1107
|
+
/**
|
|
1108
|
+
* The hosted page where a person creates and revokes API keys for your API — Cbox ID's
|
|
1109
|
+
* `/account/api-keys`, preselected to this app (or `clientId`). Keys made there are
|
|
1110
|
+
* checked with `ApiKeyVerifier` from `@cboxdk/id-js/server`.
|
|
1111
|
+
*
|
|
1112
|
+
* `returnTo` becomes a link back to your app, honoured only for an origin the app
|
|
1113
|
+
* registered. `organization` picks which of the person's organizations the keys act in;
|
|
1114
|
+
* omitted, the page uses the one they are in.
|
|
1115
|
+
*/
|
|
1116
|
+
apiKeysUrl(options = {}) {
|
|
1117
|
+
const params = new URLSearchParams({ client_id: options.clientId ?? this.config.clientId });
|
|
1118
|
+
if (options.returnTo) {
|
|
1119
|
+
params.set("return_to", options.returnTo);
|
|
1120
|
+
}
|
|
1121
|
+
if (options.organization) {
|
|
1122
|
+
params.set("organization", options.organization);
|
|
1123
|
+
}
|
|
1124
|
+
return `${this.config.issuer.replace(/\/$/, "")}/account/api-keys?${params.toString()}`;
|
|
1125
|
+
}
|
|
839
1126
|
/**
|
|
840
1127
|
* The RP-initiated logout URL, or null when the instance advertises none.
|
|
841
1128
|
*
|
|
@@ -975,10 +1262,16 @@ var CboxIdClient = class {
|
|
|
975
1262
|
return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
|
|
976
1263
|
}
|
|
977
1264
|
async exchange(code, verifier, redirectUri) {
|
|
1265
|
+
const callback = redirectUri ?? this.config.redirectUri;
|
|
1266
|
+
if (callback === void 0 || callback === "") {
|
|
1267
|
+
throw new ConfigurationError(
|
|
1268
|
+
"A `redirectUri` is required to exchange an authorization code. It must be the same one the authorization request used."
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
978
1271
|
const body = new URLSearchParams({
|
|
979
1272
|
grant_type: "authorization_code",
|
|
980
1273
|
code,
|
|
981
|
-
redirect_uri:
|
|
1274
|
+
redirect_uri: callback,
|
|
982
1275
|
client_id: this.config.clientId,
|
|
983
1276
|
code_verifier: verifier
|
|
984
1277
|
});
|
|
@@ -1062,6 +1355,37 @@ var CboxIdClient = class {
|
|
|
1062
1355
|
}
|
|
1063
1356
|
}
|
|
1064
1357
|
};
|
|
1358
|
+
function promptValue(prompt) {
|
|
1359
|
+
if (prompt === void 0) {
|
|
1360
|
+
return [];
|
|
1361
|
+
}
|
|
1362
|
+
const values = [...new Set(typeof prompt === "string" ? [prompt] : prompt)];
|
|
1363
|
+
if (values.includes("none") && values.length > 1) {
|
|
1364
|
+
throw new ConfigurationError("`prompt: 'none'` cannot be combined with another prompt value.");
|
|
1365
|
+
}
|
|
1366
|
+
return values;
|
|
1367
|
+
}
|
|
1368
|
+
function assertOrganizationOptions(options, prompt) {
|
|
1369
|
+
if (options.organization === "") {
|
|
1370
|
+
throw new ConfigurationError("`organization` is empty. Omit it to sign in without binding to an organization.");
|
|
1371
|
+
}
|
|
1372
|
+
if (options.organizationHint === "") {
|
|
1373
|
+
throw new ConfigurationError("`organizationHint` is empty. Omit it when you have no organization to suggest.");
|
|
1374
|
+
}
|
|
1375
|
+
if (options.organization === void 0) {
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (prompt.includes("select_organization")) {
|
|
1379
|
+
throw new ConfigurationError(
|
|
1380
|
+
"`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."
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
if (prompt.includes("create_organization")) {
|
|
1384
|
+
throw new ConfigurationError(
|
|
1385
|
+
"`organization` binds the sign-in to an existing organization and `prompt: 'create_organization'` creates a new one. Send one or the other."
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1065
1389
|
function timingSafeEqualString(a, b) {
|
|
1066
1390
|
if (a.length !== b.length) {
|
|
1067
1391
|
return false;
|
|
@@ -1090,25 +1414,44 @@ function parseOrganizations(claim) {
|
|
|
1090
1414
|
}
|
|
1091
1415
|
return orgs;
|
|
1092
1416
|
}
|
|
1417
|
+
function sleep(ms, signal) {
|
|
1418
|
+
return new Promise((resolve, reject) => {
|
|
1419
|
+
if (signal?.aborted === true) {
|
|
1420
|
+
reject(new AuthenticationError("Device sign-in was cancelled."));
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
const timer = setTimeout(() => {
|
|
1424
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1425
|
+
resolve();
|
|
1426
|
+
}, ms);
|
|
1427
|
+
const onAbort = () => {
|
|
1428
|
+
clearTimeout(timer);
|
|
1429
|
+
reject(new AuthenticationError("Device sign-in was cancelled."));
|
|
1430
|
+
};
|
|
1431
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1093
1434
|
|
|
1094
1435
|
// src/authz.ts
|
|
1095
1436
|
function defineAuthz(declaration) {
|
|
1096
|
-
const
|
|
1097
|
-
const
|
|
1098
|
-
assertDeclaration(
|
|
1437
|
+
const permissions2 = declaration.permissions ?? [];
|
|
1438
|
+
const roles2 = declaration.roles ?? [];
|
|
1439
|
+
assertDeclaration(permissions2, roles2);
|
|
1099
1440
|
if (declaration.legacyLogin) {
|
|
1100
1441
|
assertLegacyLogin(declaration.legacyLogin);
|
|
1101
1442
|
}
|
|
1102
|
-
return { ...declaration, permissions, roles };
|
|
1443
|
+
return { ...declaration, permissions: permissions2, roles: roles2 };
|
|
1103
1444
|
}
|
|
1104
1445
|
async function buildManifest(declaration) {
|
|
1105
|
-
const
|
|
1106
|
-
const
|
|
1107
|
-
assertDeclaration(
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1446
|
+
const permissions2 = declaration.permissions ?? [];
|
|
1447
|
+
const roles2 = declaration.roles ?? [];
|
|
1448
|
+
assertDeclaration(permissions2, roles2);
|
|
1449
|
+
const version = (await sha256Hex(canonicalManifestJson(permissions2, roles2))).slice(0, 16);
|
|
1450
|
+
const manifest = {
|
|
1451
|
+
version,
|
|
1452
|
+
permissions: [...permissions2].sort((a, b) => byteCompare(a.key, b.key)).map(wirePermission),
|
|
1453
|
+
roles: [...roles2].sort((a, b) => byteCompare(a.key, b.key)).map(wireRole)
|
|
1454
|
+
};
|
|
1112
1455
|
if (declaration.legacyLogin) {
|
|
1113
1456
|
assertLegacyLogin(declaration.legacyLogin);
|
|
1114
1457
|
manifest.legacy_login = declaration.legacyLogin;
|
|
@@ -1165,28 +1508,33 @@ async function mintToken(config, discovery, timeoutMs) {
|
|
|
1165
1508
|
}
|
|
1166
1509
|
return json2.access_token;
|
|
1167
1510
|
}
|
|
1168
|
-
|
|
1511
|
+
var KEY_PATTERN = /^[a-z][a-z0-9_-]*(?:[.:][a-z0-9_-]+)*$/;
|
|
1512
|
+
function assertDeclaration(permissions2, roles2) {
|
|
1169
1513
|
const permissionKeys = /* @__PURE__ */ new Set();
|
|
1170
|
-
for (const permission of
|
|
1514
|
+
for (const permission of permissions2) {
|
|
1171
1515
|
if (!permission.key) {
|
|
1172
1516
|
throw new ConfigurationError("Every permission needs a non-empty `key`.");
|
|
1173
1517
|
}
|
|
1518
|
+
assertKey(permission.key, "Permission");
|
|
1174
1519
|
if (permissionKeys.has(permission.key)) {
|
|
1175
1520
|
throw new ConfigurationError(`Permission "${permission.key}" is declared more than once.`);
|
|
1176
1521
|
}
|
|
1522
|
+
assertFlag(permission.tenantAssignable, `Permission "${permission.key}"`);
|
|
1177
1523
|
permissionKeys.add(permission.key);
|
|
1178
1524
|
}
|
|
1179
1525
|
const roleKeys = /* @__PURE__ */ new Set();
|
|
1180
|
-
for (const role of
|
|
1526
|
+
for (const role of roles2) {
|
|
1181
1527
|
if (!role.key) {
|
|
1182
1528
|
throw new ConfigurationError("Every role needs a non-empty `key`.");
|
|
1183
1529
|
}
|
|
1530
|
+
assertKey(role.key, "Role");
|
|
1184
1531
|
if (!role.name) {
|
|
1185
1532
|
throw new ConfigurationError(`Role "${role.key}" needs a non-empty \`name\`.`);
|
|
1186
1533
|
}
|
|
1187
1534
|
if (roleKeys.has(role.key)) {
|
|
1188
1535
|
throw new ConfigurationError(`Role "${role.key}" is declared more than once.`);
|
|
1189
1536
|
}
|
|
1537
|
+
assertFlag(role.tenantAssignable, `Role "${role.key}"`);
|
|
1190
1538
|
roleKeys.add(role.key);
|
|
1191
1539
|
for (const reference of role.permissions) {
|
|
1192
1540
|
if (!permissionKeys.has(reference)) {
|
|
@@ -1197,25 +1545,54 @@ function assertDeclaration(permissions, roles) {
|
|
|
1197
1545
|
}
|
|
1198
1546
|
}
|
|
1199
1547
|
}
|
|
1200
|
-
function
|
|
1201
|
-
|
|
1548
|
+
function assertKey(key, what) {
|
|
1549
|
+
if (!KEY_PATTERN.test(key)) {
|
|
1550
|
+
throw new ConfigurationError(
|
|
1551
|
+
`${what} key "${key}" is not a lowercase \`feature:action\` slug (e.g. \`invoices:create\`).`
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
function assertFlag(value, owner) {
|
|
1556
|
+
if (value !== void 0 && typeof value !== "boolean") {
|
|
1557
|
+
throw new ConfigurationError(`${owner} \`tenantAssignable\` must be true or false.`);
|
|
1558
|
+
}
|
|
1202
1559
|
}
|
|
1203
|
-
function
|
|
1204
|
-
|
|
1205
|
-
|
|
1560
|
+
function wirePermission(permission) {
|
|
1561
|
+
return {
|
|
1562
|
+
key: permission.key,
|
|
1563
|
+
...permission.description === void 0 ? {} : { description: permission.description },
|
|
1564
|
+
...permission.tenantAssignable === true ? { tenant_assignable: true } : {}
|
|
1565
|
+
};
|
|
1206
1566
|
}
|
|
1207
|
-
function
|
|
1567
|
+
function wireRole(role) {
|
|
1568
|
+
return {
|
|
1569
|
+
key: role.key,
|
|
1570
|
+
name: role.name,
|
|
1571
|
+
...role.description === void 0 ? {} : { description: role.description },
|
|
1572
|
+
permissions: uniqueSorted(role.permissions),
|
|
1573
|
+
...role.tenantAssignable === false ? { tenant_assignable: false } : {}
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
function canonicalManifestJson(permissions2, roles2) {
|
|
1208
1577
|
const canonical = {
|
|
1209
|
-
permissions: [...
|
|
1210
|
-
|
|
1578
|
+
permissions: [...permissions2].sort((a, b) => byteCompare(a.key, b.key)).map((p) => ({
|
|
1579
|
+
key: p.key,
|
|
1580
|
+
description: emptyToNull(p.description),
|
|
1581
|
+
...p.tenantAssignable === true ? { tenant_assignable: true } : {}
|
|
1582
|
+
})),
|
|
1583
|
+
roles: [...roles2].sort((a, b) => byteCompare(a.key, b.key)).map((r) => ({
|
|
1211
1584
|
key: r.key,
|
|
1212
1585
|
name: r.name,
|
|
1213
1586
|
description: emptyToNull(r.description),
|
|
1214
|
-
permissions:
|
|
1587
|
+
permissions: uniqueSorted(r.permissions),
|
|
1588
|
+
...r.tenantAssignable === false ? { tenant_assignable: false } : {}
|
|
1215
1589
|
}))
|
|
1216
1590
|
};
|
|
1217
1591
|
return escapeLikePhp(JSON.stringify(canonical));
|
|
1218
1592
|
}
|
|
1593
|
+
function uniqueSorted(values) {
|
|
1594
|
+
return [...new Set(values)].sort(byteCompare);
|
|
1595
|
+
}
|
|
1219
1596
|
function emptyToNull(value) {
|
|
1220
1597
|
return value === void 0 || value === "" ? null : value;
|
|
1221
1598
|
}
|
|
@@ -1267,13 +1644,21 @@ exports.ConfigurationError = ConfigurationError;
|
|
|
1267
1644
|
exports.FrontendApiError = FrontendApiError;
|
|
1268
1645
|
exports.InvalidStateError = InvalidStateError;
|
|
1269
1646
|
exports.VaultClient = VaultClient;
|
|
1647
|
+
exports.actor = actor;
|
|
1270
1648
|
exports.buildManifest = buildManifest;
|
|
1271
1649
|
exports.challenge = challenge;
|
|
1272
1650
|
exports.createLegacyVerifier = createLegacyVerifier;
|
|
1273
1651
|
exports.createVerifier = createVerifier;
|
|
1274
1652
|
exports.defineAuthz = defineAuthz;
|
|
1653
|
+
exports.hasPermission = hasPermission;
|
|
1654
|
+
exports.hasRole = hasRole;
|
|
1655
|
+
exports.isSupportSession = isSupportSession;
|
|
1656
|
+
exports.organization = organization;
|
|
1657
|
+
exports.permissions = permissions;
|
|
1275
1658
|
exports.publishManifest = publishManifest;
|
|
1276
1659
|
exports.randomToken = randomToken;
|
|
1660
|
+
exports.roles = roles;
|
|
1661
|
+
exports.sessionId = sessionId;
|
|
1277
1662
|
exports.verifyWebhook = verifyWebhook;
|
|
1278
1663
|
//# sourceMappingURL=index.cjs.map
|
|
1279
1664
|
//# sourceMappingURL=index.cjs.map
|