@udibo/oauth2 0.13.0 → 0.14.1
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.
|
@@ -374,10 +374,20 @@ export interface HonoBffOptions {
|
|
|
374
374
|
* Who owns the BFF's session record. Defaults to `"own"`.
|
|
375
375
|
*
|
|
376
376
|
* - `"own"` (default): the BFF creates and owns its session at
|
|
377
|
-
* `/auth/callback` — it always mints a fresh id (`sessionStore.create`)
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
377
|
+
* `/auth/callback` — it always mints a fresh id (`sessionStore.create`).
|
|
378
|
+
* A session the inbound cookie names is replaced: once the code exchange
|
|
379
|
+
* and `resolveUser` succeed, the BFF destroys it (`sessionStore.destroy`)
|
|
380
|
+
* before creating the new one, so signing in again in the same browser —
|
|
381
|
+
* switching organization, a step-up — never leaves the earlier session
|
|
382
|
+
* readable beside the new one. Its refresh token is not revoked: an
|
|
383
|
+
* authorization server may extend a revocation to other tokens of the
|
|
384
|
+
* same grant or sign-in (RFC 7009 §2.1), the new ones included.
|
|
385
|
+
* A callback the authorization server refuses, or whose state check,
|
|
386
|
+
* code exchange or `resolveUser` fails, leaves the existing session as
|
|
387
|
+
* it was; a store failure while creating the replacement leaves the
|
|
388
|
+
* browser signed out. Use when the app has no session here until the
|
|
389
|
+
* OAuth flow completes: standalone BFFs, or apps whose login lives on a
|
|
390
|
+
* separate / external IDP.
|
|
381
391
|
* - `"shared"`: the app already created the session at its own login step,
|
|
382
392
|
* and the BFF **attaches** tokens to that existing session at
|
|
383
393
|
* `/auth/callback` (via `sessionStore.update`) instead of creating its
|
|
@@ -670,9 +670,10 @@ export class HonoBff {
|
|
|
670
670
|
createdAt: now,
|
|
671
671
|
updatedAt: now,
|
|
672
672
|
};
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
673
|
+
const inbound = getCookie(c, this.#cookieName);
|
|
674
|
+
const existing = this.#sessionMode === "shared" ? inbound : undefined;
|
|
675
|
+
if (inbound && !existing)
|
|
676
|
+
await this.#store.destroy(inbound);
|
|
676
677
|
const cookieValue = existing
|
|
677
678
|
? await this.#store.update(existing, data)
|
|
678
679
|
: await this.#store.create(data);
|
|
@@ -73,6 +73,13 @@ export interface TenantContractFixture {
|
|
|
73
73
|
addMember(organizationId: string, userId: string, permissions: string[]): Promise<void>;
|
|
74
74
|
/** Ends a membership. */
|
|
75
75
|
removeMember(organizationId: string, userId: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Defines a tenant-wide role carrying `permissions`, which an
|
|
78
|
+
* organization's manager may grant a member through
|
|
79
|
+
* `POST /api/organizations/:organizationId/members/:userId/roles`, and
|
|
80
|
+
* returns its id.
|
|
81
|
+
*/
|
|
82
|
+
addRole(permissions: string[]): Promise<string>;
|
|
76
83
|
/**
|
|
77
84
|
* Grants `permissions` on one instance of a resource type to a person, or
|
|
78
85
|
* to every member of an organization. Registers the type if it is new.
|
|
@@ -663,6 +663,237 @@ export function runTenantContractTests(options) {
|
|
|
663
663
|
const reply = await call(null, "GET", "/api/organizations");
|
|
664
664
|
assertEquals(reply.status, 401);
|
|
665
665
|
});
|
|
666
|
+
describe("application roles a manager grants a member", () => {
|
|
667
|
+
const APP_PERMISSION = "contract:app";
|
|
668
|
+
let roleId;
|
|
669
|
+
let option;
|
|
670
|
+
beforeAll(async () => {
|
|
671
|
+
roleId = await tenant.addRole([APP_PERMISSION]);
|
|
672
|
+
const listed = await call(seats.admin.token, "GET", `/api/organizations/${organization.id}/member-roles`);
|
|
673
|
+
assertEquals(listed.status, 200);
|
|
674
|
+
const found = listed.body.find((entry) => entry.id === roleId);
|
|
675
|
+
assert(found, "member-roles does not list the role by its id");
|
|
676
|
+
option = found;
|
|
677
|
+
});
|
|
678
|
+
const rolesPath = (userId, organizationId = organization.id) => `/api/organizations/${organizationId}/members/${userId}/roles`;
|
|
679
|
+
async function seated() {
|
|
680
|
+
const seatedPerson = await person();
|
|
681
|
+
await seat(seats.owner.token, organization.id, seatedPerson, "member");
|
|
682
|
+
return seatedPerson;
|
|
683
|
+
}
|
|
684
|
+
async function grantRole(managerToken, userId, id = roleId, organizationId = organization.id) {
|
|
685
|
+
return await call(managerToken, "POST", rolesPath(userId, organizationId), { roleId: id });
|
|
686
|
+
}
|
|
687
|
+
async function heldBy(userId, managerToken = seats.admin.token, organizationId = organization.id) {
|
|
688
|
+
const reply = await call(managerToken, "GET", rolesPath(userId, organizationId));
|
|
689
|
+
assertEquals(reply.status, 200, JSON.stringify(reply.body));
|
|
690
|
+
return reply.body;
|
|
691
|
+
}
|
|
692
|
+
async function holds(accessToken, organizationId) {
|
|
693
|
+
const reply = await ask(accessToken, "/api/check", {
|
|
694
|
+
permissions: [APP_PERMISSION],
|
|
695
|
+
...organizationId
|
|
696
|
+
? { resource: { type: "organization", id: organizationId } }
|
|
697
|
+
: {},
|
|
698
|
+
});
|
|
699
|
+
assertEquals(reply.status, 200);
|
|
700
|
+
return reply.body.results[APP_PERMISSION];
|
|
701
|
+
}
|
|
702
|
+
async function offersOf(accessToken) {
|
|
703
|
+
const reply = await call(accessToken, "GET", "/api/organizations/offers");
|
|
704
|
+
assertEquals(reply.status, 200);
|
|
705
|
+
return reply.body.offers.map((offer) => offer.id).sort();
|
|
706
|
+
}
|
|
707
|
+
it("names a tenant-wide role in member-roles by the id a grant takes, and no built-in tier by any", async () => {
|
|
708
|
+
assertEquals(option.id, roleId);
|
|
709
|
+
assert(option.slug, "the role is listed by its slug");
|
|
710
|
+
assert(option.name, "the role is listed by its name");
|
|
711
|
+
const listed = await call(seats.owner.token, "GET", `/api/organizations/${organization.id}/member-roles`);
|
|
712
|
+
for (const slug of ["owner", "admin", "member"]) {
|
|
713
|
+
const tier = listed.body.find((entry) => entry.slug === slug);
|
|
714
|
+
assert(tier, `the built-in ${slug} tier is listed`);
|
|
715
|
+
assertFalse("id" in tier, `the built-in ${slug} tier has an id`);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
it("grants an accepted member a role that counts inside the organization only", async () => {
|
|
719
|
+
const member = await seated();
|
|
720
|
+
await seat(seats.rival.token, rival.id, member, "member");
|
|
721
|
+
assertFalse(await holds(member.token, organization.id));
|
|
722
|
+
const granted = await grantRole(seats.admin.token, member.id);
|
|
723
|
+
assertEquals(granted.status, 201, JSON.stringify(granted.body));
|
|
724
|
+
assertEquals(granted.body.subjectId, member.id);
|
|
725
|
+
assertEquals(granted.body.roleId, roleId);
|
|
726
|
+
assertEquals(granted.body.builtInRole, null);
|
|
727
|
+
assertEquals(granted.body.scopeType, "organization");
|
|
728
|
+
assertEquals(granted.body.scopeId, organization.id);
|
|
729
|
+
assertEquals(granted.body.created, true);
|
|
730
|
+
assert(await holds(member.token, organization.id));
|
|
731
|
+
assertFalse(await holds(member.token), "a role held in an organization answered at tenant scope");
|
|
732
|
+
assertFalse(await holds(member.token, rival.id), "a role held in one organization answered for another it belongs to");
|
|
733
|
+
assertEquals(await heldBy(member.id), [{
|
|
734
|
+
id: roleId,
|
|
735
|
+
builtInRole: null,
|
|
736
|
+
organizationId: null,
|
|
737
|
+
slug: option.slug,
|
|
738
|
+
name: option.name,
|
|
739
|
+
permissions: [APP_PERMISSION],
|
|
740
|
+
}]);
|
|
741
|
+
const again = await grantRole(seats.owner.token, member.id);
|
|
742
|
+
assertEquals(again.status, 201);
|
|
743
|
+
assertEquals(again.body.id, granted.body.id);
|
|
744
|
+
assertEquals(again.body.created, false);
|
|
745
|
+
assertEquals((await heldBy(member.id)).length, 1);
|
|
746
|
+
});
|
|
747
|
+
it("revokes a role, after which the permission stops answering and the role is not found", async () => {
|
|
748
|
+
const member = await seated();
|
|
749
|
+
assertEquals((await grantRole(seats.admin.token, member.id)).status, 201);
|
|
750
|
+
const revoked = await call(seats.admin.token, "DELETE", `${rolesPath(member.id)}/${roleId}`);
|
|
751
|
+
assertEquals(revoked.status, 204);
|
|
752
|
+
assertFalse(await holds(member.token, organization.id));
|
|
753
|
+
assertEquals(await heldBy(member.id), []);
|
|
754
|
+
const again = await call(seats.admin.token, "DELETE", `${rolesPath(member.id)}/${roleId}`);
|
|
755
|
+
assertEquals(again.status, 404);
|
|
756
|
+
});
|
|
757
|
+
it("refuses a plain member and a stranger every verb with the 404 an unknown organization gets, and changes nothing", async () => {
|
|
758
|
+
const member = await seated();
|
|
759
|
+
assertEquals((await grantRole(seats.admin.token, member.id)).status, 201);
|
|
760
|
+
const unknown = crypto.randomUUID();
|
|
761
|
+
const verbs = [
|
|
762
|
+
["GET", (id) => rolesPath(member.id, id), undefined],
|
|
763
|
+
["POST", (id) => rolesPath(member.id, id), { roleId }],
|
|
764
|
+
[
|
|
765
|
+
"DELETE",
|
|
766
|
+
(id) => `${rolesPath(member.id, id)}/${roleId}`,
|
|
767
|
+
undefined,
|
|
768
|
+
],
|
|
769
|
+
];
|
|
770
|
+
for (const [method, path, body] of verbs) {
|
|
771
|
+
const onUnknown = await call(seats.owner.token, method, path(unknown), body);
|
|
772
|
+
assertEquals(onUnknown.status, 404, `${method} on an unknown id`);
|
|
773
|
+
for (const caller of [seats.member, seats.outsider]) {
|
|
774
|
+
const refused = await call(caller.token, method, path(organization.id), body);
|
|
775
|
+
assertEquals(refused.status, 404, `${method} below the manager tier`);
|
|
776
|
+
assertEquals(refused.body.detail, onUnknown.body.detail);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
assert(await holds(member.token, organization.id), "a refused revoke took the role away");
|
|
780
|
+
});
|
|
781
|
+
it("answers a pending member, a non-member and an unknown user with one 404, granting nothing", async () => {
|
|
782
|
+
const pending = await person();
|
|
783
|
+
const offered = await invite(seats.admin.token, organization.id, pending.email, "member");
|
|
784
|
+
assert(offered.body.status === "membership", offered.body.status);
|
|
785
|
+
const details = new Set();
|
|
786
|
+
for (const userId of [
|
|
787
|
+
pending.id,
|
|
788
|
+
seats.outsider.id,
|
|
789
|
+
crypto.randomUUID(),
|
|
790
|
+
]) {
|
|
791
|
+
const read = await call(seats.admin.token, "GET", rolesPath(userId));
|
|
792
|
+
const granted = await grantRole(seats.admin.token, userId);
|
|
793
|
+
assertEquals([read.status, granted.status], [404, 404], userId);
|
|
794
|
+
details.add(read.body.detail);
|
|
795
|
+
details.add(granted.body.detail);
|
|
796
|
+
}
|
|
797
|
+
assertEquals(details.size, 1, [...details].join(" | "));
|
|
798
|
+
assertEquals((await accept(pending.token, offered.body.membership.id)).status, "accepted");
|
|
799
|
+
assertEquals(await heldBy(pending.id), []);
|
|
800
|
+
assertFalse(await holds(pending.token, organization.id));
|
|
801
|
+
});
|
|
802
|
+
it("refuses a role the tenant does not know with 404, and a grant that names no role with 400", async () => {
|
|
803
|
+
const member = await seated();
|
|
804
|
+
const unknown = await grantRole(seats.admin.token, member.id, crypto.randomUUID());
|
|
805
|
+
assertEquals(unknown.status, 404);
|
|
806
|
+
const unnamed = await call(seats.admin.token, "POST", rolesPath(member.id), {});
|
|
807
|
+
assertEquals(unnamed.status, 400);
|
|
808
|
+
assertEquals(await heldBy(member.id), []);
|
|
809
|
+
});
|
|
810
|
+
it("refuses a manager revoking a built-in role they do not hold there, and answers any other name with 404", async () => {
|
|
811
|
+
const member = await seated();
|
|
812
|
+
const asAdmin = await call(seats.admin.token, "DELETE", `${rolesPath(member.id)}/owner`);
|
|
813
|
+
assertEquals(asAdmin.status, 403);
|
|
814
|
+
const asOwner = await call(seats.owner.token, "DELETE", `${rolesPath(member.id)}/member`);
|
|
815
|
+
assertEquals(asOwner.status, 403, "an owner membership is not a built-in role held there");
|
|
816
|
+
const unnamed = await call(seats.owner.token, "DELETE", `${rolesPath(member.id)}/not-a-role`);
|
|
817
|
+
assertEquals(unnamed.status, 404);
|
|
818
|
+
});
|
|
819
|
+
it("ends the roles and open offers a person's last membership carried, so rejoining restores nothing", async () => {
|
|
820
|
+
const address = newEmail();
|
|
821
|
+
const invited = await invite(seats.owner.token, organization.id, address, "admin");
|
|
822
|
+
assert(invited.body.status === "invitation", invited.body.status);
|
|
823
|
+
const invitationId = invited.body.invitation.id;
|
|
824
|
+
const leaver = await person({ email: address });
|
|
825
|
+
await seat(seats.owner.token, organization.id, leaver, "member");
|
|
826
|
+
const ownerOffer = await invite(seats.owner.token, organization.id, address, "owner");
|
|
827
|
+
assert(ownerOffer.body.status === "membership", ownerOffer.body.status);
|
|
828
|
+
assertEquals((await grantRole(seats.admin.token, leaver.id)).status, 201);
|
|
829
|
+
assertEquals(await offersOf(leaver.token), [invitationId, ownerOffer.body.membership.id].sort());
|
|
830
|
+
const revoked = await call(seats.admin.token, "DELETE", `/api/organizations/${organization.id}/members/${leaver.id}/member`);
|
|
831
|
+
assertEquals(revoked.status, 204);
|
|
832
|
+
assertEquals(await offersOf(leaver.token), []);
|
|
833
|
+
assertFalse((await invitationIdsOf(seats.owner.token, organization.id))
|
|
834
|
+
.includes(invitationId), "an invitation to the leaver's address outlived their membership");
|
|
835
|
+
const read = await call(seats.admin.token, "GET", rolesPath(leaver.id));
|
|
836
|
+
assertEquals(read.status, 404);
|
|
837
|
+
await seat(seats.owner.token, organization.id, leaver, "member");
|
|
838
|
+
assertEquals(await heldBy(leaver.id), []);
|
|
839
|
+
assertFalse(await holds(leaver.token, organization.id), "rejoining restored a role the last membership carried");
|
|
840
|
+
});
|
|
841
|
+
it("keeps a role revoke and a membership's end to the person and organization they name", async () => {
|
|
842
|
+
const address = newEmail();
|
|
843
|
+
const elsewhere = await invite(seats.rival.token, rival.id, address, "member");
|
|
844
|
+
assert(elsewhere.body.status === "invitation", elsewhere.body.status);
|
|
845
|
+
const leaver = await person({ email: address });
|
|
846
|
+
await seat(seats.owner.token, organization.id, leaver, "member");
|
|
847
|
+
const bystander = await seated();
|
|
848
|
+
await seat(seats.rival.token, rival.id, bystander, "member");
|
|
849
|
+
for (const userId of [leaver.id, bystander.id]) {
|
|
850
|
+
assertEquals((await grantRole(seats.admin.token, userId)).status, 201);
|
|
851
|
+
}
|
|
852
|
+
assertEquals((await grantRole(seats.rival.token, bystander.id, roleId, rival.id))
|
|
853
|
+
.status, 201);
|
|
854
|
+
const revoked = await call(seats.admin.token, "DELETE", `/api/organizations/${organization.id}/members/${leaver.id}/member`);
|
|
855
|
+
assertEquals(revoked.status, 204);
|
|
856
|
+
assertEquals((await heldBy(bystander.id)).map((role) => role.id), [roleId], "one person's membership ending took another's role");
|
|
857
|
+
assert((await invitationIdsOf(seats.rival.token, rival.id)).includes(elsewhere.body.invitation.id), "a membership ending here withdrew an invitation elsewhere");
|
|
858
|
+
for (const status of [204, 404]) {
|
|
859
|
+
const taken = await call(seats.admin.token, "DELETE", `${rolesPath(bystander.id)}/${roleId}`);
|
|
860
|
+
assertEquals(taken.status, status);
|
|
861
|
+
}
|
|
862
|
+
assertEquals((await heldBy(bystander.id, seats.rival.token, rival.id)).map((role) => role.id), [roleId], "a revoke here took the role held in another organization");
|
|
863
|
+
});
|
|
864
|
+
it("withdraws only the offer named from someone who is not a member", async () => {
|
|
865
|
+
const invitee = await person();
|
|
866
|
+
const offers = [];
|
|
867
|
+
for (const role of ["member", "admin"]) {
|
|
868
|
+
const offered = await invite(seats.owner.token, organization.id, invitee.email, role);
|
|
869
|
+
assert(offered.body.status === "membership", offered.body.status);
|
|
870
|
+
offers.push(offered.body.membership.id);
|
|
871
|
+
}
|
|
872
|
+
const withdrawn = await call(seats.owner.token, "DELETE", `/api/organizations/${organization.id}/members/${invitee.id}/member`);
|
|
873
|
+
assertEquals(withdrawn.status, 204);
|
|
874
|
+
assertEquals(await offersOf(invitee.token), [offers[1]]);
|
|
875
|
+
});
|
|
876
|
+
it("ends nothing while the person keeps another accepted role there", async () => {
|
|
877
|
+
const stayer = await seated();
|
|
878
|
+
await seat(seats.owner.token, organization.id, stayer, "admin");
|
|
879
|
+
const ownerOffer = await invite(seats.owner.token, organization.id, stayer.email, "owner");
|
|
880
|
+
assert(ownerOffer.body.status === "membership", ownerOffer.body.status);
|
|
881
|
+
assertEquals((await grantRole(seats.owner.token, stayer.id)).status, 201);
|
|
882
|
+
const revoked = await call(seats.owner.token, "DELETE", `/api/organizations/${organization.id}/members/${stayer.id}/member`);
|
|
883
|
+
assertEquals(revoked.status, 204);
|
|
884
|
+
assertEquals((await heldBy(stayer.id)).map((role) => role.id), [
|
|
885
|
+
roleId,
|
|
886
|
+
]);
|
|
887
|
+
assert(await holds(stayer.token, organization.id));
|
|
888
|
+
assertEquals(await offersOf(stayer.token), [
|
|
889
|
+
ownerOffer.body.membership.id,
|
|
890
|
+
]);
|
|
891
|
+
const withdrawn = await call(seats.owner.token, "DELETE", `/api/organizations/${organization.id}/members/${stayer.id}/owner`);
|
|
892
|
+
assertEquals(withdrawn.status, 204);
|
|
893
|
+
assertEquals(await offersOf(stayer.token), []);
|
|
894
|
+
assert(await holds(stayer.token, organization.id), "withdrawing a pending offer ended a role");
|
|
895
|
+
});
|
|
896
|
+
});
|
|
666
897
|
});
|
|
667
898
|
describe("the account API", () => {
|
|
668
899
|
it("answers a new person's own metadata bucket, empty, and no cache keeps it", async () => {
|
|
@@ -778,6 +1009,18 @@ export function runTenantContractTests(options) {
|
|
|
778
1009
|
assertEquals(fromFirst.map((session) => session.id).sort(), listed.map((session) => session.id).sort());
|
|
779
1010
|
assertEquals(currentOf(fromFirst), [listed[1].id]);
|
|
780
1011
|
});
|
|
1012
|
+
it("leaves an earlier sign-in's credential live when another browser signs in", async () => {
|
|
1013
|
+
const id = await tenant.addUser([], { email: newEmail() });
|
|
1014
|
+
const first = await tenant.signIn(id);
|
|
1015
|
+
const second = await tenant.signIn(id);
|
|
1016
|
+
assertEquals((await introspect(first.access_token)).active, true);
|
|
1017
|
+
assertEquals((await introspect(second.access_token)).active, true);
|
|
1018
|
+
const fromFirst = await sessionsOf(first.access_token);
|
|
1019
|
+
const fromSecond = await sessionsOf(second.access_token);
|
|
1020
|
+
assertEquals(fromFirst.length, 2);
|
|
1021
|
+
assertEquals(currentOf(fromFirst).length, 1);
|
|
1022
|
+
assertFalse(currentOf(fromFirst)[0] === currentOf(fromSecond)[0], "two browsers' sign-ins shared one login session");
|
|
1023
|
+
});
|
|
781
1024
|
it("keeps a refreshed credential on the session it was issued from", async () => {
|
|
782
1025
|
const id = await tenant.addUser([], { email: newEmail() });
|
|
783
1026
|
const first = await tenant.signIn(id);
|
package/esm/testing/tenant.d.ts
CHANGED
|
@@ -110,7 +110,11 @@ export interface FakeTenantMembership {
|
|
|
110
110
|
* holds a role. An empty list is also read as `["member"]`.
|
|
111
111
|
*/
|
|
112
112
|
roles?: string[];
|
|
113
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Permissions held inside this organization only, apart from any
|
|
115
|
+
* application role a manager grants the person there. They end with the
|
|
116
|
+
* membership.
|
|
117
|
+
*/
|
|
114
118
|
permissions?: string[];
|
|
115
119
|
}
|
|
116
120
|
/** Who a resource grant is for. */
|
|
@@ -143,12 +147,27 @@ export interface FakeTenantGrant {
|
|
|
143
147
|
*/
|
|
144
148
|
role?: string;
|
|
145
149
|
}
|
|
146
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* A tenant-wide role your tenant defines beside the built-in tiers. A
|
|
152
|
+
* membership or an invitation may name it by slug, and an organization's
|
|
153
|
+
* manager may grant it to a member as an application role by id.
|
|
154
|
+
*/
|
|
147
155
|
export interface FakeTenantOrganizationRole {
|
|
148
156
|
/** The role a membership, an invitation or `org_roles` names. */
|
|
149
157
|
slug: string;
|
|
150
158
|
/** How the role is named to people. Defaults to the slug. */
|
|
151
159
|
name?: string;
|
|
160
|
+
/**
|
|
161
|
+
* The id `member-roles` lists and `POST …/members/:userId/roles` takes.
|
|
162
|
+
* Defaults to a new UUID, or to the id the slug already has when it is
|
|
163
|
+
* defined again.
|
|
164
|
+
*/
|
|
165
|
+
id?: string;
|
|
166
|
+
/**
|
|
167
|
+
* What the role confers inside an organization on a member it is granted
|
|
168
|
+
* to there, through `POST …/members/:userId/roles`. Defaults to none.
|
|
169
|
+
*/
|
|
170
|
+
permissions?: string[];
|
|
152
171
|
}
|
|
153
172
|
/** An external account a person can sign in with. */
|
|
154
173
|
export interface FakeTenantLinkedAccount {
|
|
@@ -171,9 +190,9 @@ export interface FakeTenantSignIn {
|
|
|
171
190
|
* `invalid_request` when the person is not a member.
|
|
172
191
|
*/
|
|
173
192
|
organizationId?: string;
|
|
174
|
-
/** The browser `GET /api/account/sessions` reports for
|
|
193
|
+
/** The browser `GET /api/account/sessions` reports for a new login session. */
|
|
175
194
|
userAgent?: string;
|
|
176
|
-
/** The address `GET /api/account/sessions` reports for
|
|
195
|
+
/** The address `GET /api/account/sessions` reports for a new login session. */
|
|
177
196
|
ipAddress?: string;
|
|
178
197
|
}
|
|
179
198
|
/** What {@linkcode FakeTenant.issueAccessToken} mints a token for. */
|
|
@@ -233,8 +252,9 @@ export interface FakeTenant {
|
|
|
233
252
|
*/
|
|
234
253
|
addMember(organizationId: string, userId: string, membership?: FakeTenantMembership): void;
|
|
235
254
|
/**
|
|
236
|
-
* Ends a membership
|
|
237
|
-
* Email-address invitations remain.
|
|
255
|
+
* Ends a membership, with the application roles it held there, and removes
|
|
256
|
+
* the person's pending membership grants. Email-address invitations remain.
|
|
257
|
+
* From then on introspection and
|
|
238
258
|
* `/api/check` stop answering for that
|
|
239
259
|
* organization on credentials issued in it, and UserInfo drops its `org_*`
|
|
240
260
|
* claims; a JWT access token or id_token already minted keeps its claims
|
|
@@ -242,10 +262,13 @@ export interface FakeTenant {
|
|
|
242
262
|
*/
|
|
243
263
|
removeMember(organizationId: string, userId: string): void;
|
|
244
264
|
/**
|
|
245
|
-
* Defines
|
|
246
|
-
*
|
|
265
|
+
* Defines a tenant-wide role beside the built-in tiers, so invitations may
|
|
266
|
+
* offer it, `member-roles` lists it, and an organization's manager may grant
|
|
267
|
+
* it to a member. Returns its id. Defining a slug again changes its name
|
|
268
|
+
* and permissions and keeps its id. Throws on a built-in slug, on an id
|
|
269
|
+
* another role holds, and on a new id for a slug already defined.
|
|
247
270
|
*/
|
|
248
|
-
defineOrganizationRole(role: FakeTenantOrganizationRole):
|
|
271
|
+
defineOrganizationRole(role: FakeTenantOrganizationRole): string;
|
|
249
272
|
/** Registers a resource type, so `/api/check` accepts it. */
|
|
250
273
|
registerResourceType(type: string): void;
|
|
251
274
|
/**
|
|
@@ -262,10 +285,12 @@ export interface FakeTenant {
|
|
|
262
285
|
* Chooses who the next authorization requests authenticate as, until
|
|
263
286
|
* called again. With nobody chosen, authorize answers `access_denied`.
|
|
264
287
|
*
|
|
265
|
-
* Each
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
288
|
+
* Each authorization request is a sign-in that starts a new login session,
|
|
289
|
+
* which the account API lists, and sets a cookie naming it on the tenant's
|
|
290
|
+
* origin. A request that sends that cookie back, as the same browser does,
|
|
291
|
+
* continues the session instead while it is live and belongs to the person
|
|
292
|
+
* chosen. When a first-party application signs in again in a session it
|
|
293
|
+
* continues, the credentials the session issued before are revoked.
|
|
269
294
|
*/
|
|
270
295
|
signInAs(userId: string | null, signIn?: FakeTenantSignIn): void;
|
|
271
296
|
/**
|
package/esm/testing/tenant.js
CHANGED
|
@@ -52,7 +52,15 @@ const MACHINE_TOKEN_REFUSAL = "A machine credential has no self to answer for.";
|
|
|
52
52
|
const OWNER = "owner";
|
|
53
53
|
const MANAGER_ROLES = ["owner", "admin"];
|
|
54
54
|
const DEFAULT_ROLES = ["member"];
|
|
55
|
+
const BUILT_IN_APP_ROLES = [
|
|
56
|
+
"owner",
|
|
57
|
+
"admin",
|
|
58
|
+
"support",
|
|
59
|
+
"member",
|
|
60
|
+
];
|
|
61
|
+
const LOGIN_SESSION_COOKIE = "fake_tenant_session";
|
|
55
62
|
const ORGANIZATION_NOT_FOUND = "Organization not found";
|
|
63
|
+
const MEMBER_NOT_FOUND = "organization member not found";
|
|
56
64
|
class ConfidentialClientCredentialsGrant extends ClientCredentialsGrant {
|
|
57
65
|
isConfidential;
|
|
58
66
|
constructor(options, isConfidential) {
|
|
@@ -243,6 +251,17 @@ function page(rows, request) {
|
|
|
243
251
|
hasMore,
|
|
244
252
|
};
|
|
245
253
|
}
|
|
254
|
+
function cookieValue(request, name) {
|
|
255
|
+
for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
|
|
256
|
+
const separator = pair.indexOf("=");
|
|
257
|
+
if (separator === -1)
|
|
258
|
+
continue;
|
|
259
|
+
if (pair.slice(0, separator).trim() === name) {
|
|
260
|
+
return pair.slice(separator + 1).trim();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
246
265
|
function noStore(response) {
|
|
247
266
|
response.headers.set("cache-control", "no-store");
|
|
248
267
|
return response;
|
|
@@ -274,6 +293,7 @@ export async function createFakeTenant(options) {
|
|
|
274
293
|
const userMetadata = new Map();
|
|
275
294
|
const organizations = new Map();
|
|
276
295
|
const definedRoles = new Map();
|
|
296
|
+
const appRoles = [];
|
|
277
297
|
const invitations = new Map();
|
|
278
298
|
const resourceTypes = new Set();
|
|
279
299
|
const grants = [];
|
|
@@ -292,10 +312,18 @@ export async function createFakeTenant(options) {
|
|
|
292
312
|
: [];
|
|
293
313
|
const isMember = (organizationId, userId) => acceptedRoles(organizationId, userId).length > 0;
|
|
294
314
|
const holdsAny = (organizationId, userId, roles) => acceptedRoles(organizationId, userId).some((role) => roles.includes(role));
|
|
315
|
+
const definedRoleById = (roleId) => [...definedRoles.values()].find((role) => role.id === roleId);
|
|
316
|
+
const appRolesHeld = (organizationId, userId) => appRoles.filter((assignment) => assignment.organizationId === organizationId &&
|
|
317
|
+
assignment.userId === userId);
|
|
318
|
+
const appRolePermissions = (organizationId, userId) => appRolesHeld(organizationId, userId).flatMap((assignment) => definedRoleById(assignment.roleId)?.permissions ?? []);
|
|
295
319
|
const permissionsIn = (userId, organizationId) => new Set([
|
|
296
320
|
...users.get(userId)?.permissions ?? [],
|
|
297
321
|
...isMember(organizationId, userId)
|
|
298
|
-
?
|
|
322
|
+
? [
|
|
323
|
+
...organizations.get(organizationId).permissions.get(userId) ??
|
|
324
|
+
[],
|
|
325
|
+
...appRolePermissions(organizationId, userId),
|
|
326
|
+
]
|
|
299
327
|
: [],
|
|
300
328
|
]);
|
|
301
329
|
const organizationClaims = (userId, organizationId) => {
|
|
@@ -532,17 +560,17 @@ export async function createFakeTenant(options) {
|
|
|
532
560
|
cursor: null,
|
|
533
561
|
});
|
|
534
562
|
};
|
|
535
|
-
const
|
|
563
|
+
const continuedSession = (request, userId) => {
|
|
564
|
+
const secret = cookieValue(request, LOGIN_SESSION_COOKIE);
|
|
565
|
+
if (!secret)
|
|
566
|
+
return undefined;
|
|
567
|
+
return [...loginSessions.values()].find((session) => session.secret === secret && session.userId === userId);
|
|
568
|
+
};
|
|
569
|
+
const startSession = (chosen) => {
|
|
536
570
|
const now = new Date();
|
|
537
|
-
const existing = chosen.sessionId
|
|
538
|
-
? loginSessions.get(chosen.sessionId)
|
|
539
|
-
: undefined;
|
|
540
|
-
if (existing) {
|
|
541
|
-
existing.lastActiveAt = now;
|
|
542
|
-
return existing.id;
|
|
543
|
-
}
|
|
544
571
|
const session = {
|
|
545
572
|
id: crypto.randomUUID(),
|
|
573
|
+
secret: crypto.randomUUID(),
|
|
546
574
|
userId: chosen.userId,
|
|
547
575
|
createdAt: now,
|
|
548
576
|
lastActiveAt: now,
|
|
@@ -550,13 +578,20 @@ export async function createFakeTenant(options) {
|
|
|
550
578
|
userAgent: chosen.userAgent ?? null,
|
|
551
579
|
};
|
|
552
580
|
loginSessions.set(session.id, session);
|
|
553
|
-
|
|
554
|
-
return session.id;
|
|
581
|
+
return session;
|
|
555
582
|
};
|
|
583
|
+
const sessionCookie = (session) => [
|
|
584
|
+
`${LOGIN_SESSION_COOKIE}=${session.secret}`,
|
|
585
|
+
"Path=/",
|
|
586
|
+
"HttpOnly",
|
|
587
|
+
"SameSite=Lax",
|
|
588
|
+
...issuer.startsWith("https:") ? ["Secure"] : [],
|
|
589
|
+
].join("; ");
|
|
556
590
|
const authorize = async (request) => {
|
|
557
591
|
const chosen = signedIn;
|
|
558
592
|
const requested = new URL(request.url).searchParams.get("organization");
|
|
559
593
|
let issued;
|
|
594
|
+
let started;
|
|
560
595
|
const response = await server.handleAuthorizeRequest(request, () => {
|
|
561
596
|
const user = chosen ? users.get(chosen.userId) : undefined;
|
|
562
597
|
if (!chosen || !user)
|
|
@@ -564,10 +599,15 @@ export async function createFakeTenant(options) {
|
|
|
564
599
|
const organizationId = requested
|
|
565
600
|
? requestedOrganization(requested, user.id)
|
|
566
601
|
: chosen.organizationId ?? soleOrganizationOf(user.id);
|
|
602
|
+
const continued = continuedSession(request, user.id);
|
|
603
|
+
if (continued)
|
|
604
|
+
continued.lastActiveAt = new Date();
|
|
605
|
+
else
|
|
606
|
+
started = startSession(chosen);
|
|
567
607
|
issued = {
|
|
568
608
|
userId: user.id,
|
|
569
609
|
organizationId,
|
|
570
|
-
sessionId:
|
|
610
|
+
sessionId: (continued ?? started).id,
|
|
571
611
|
};
|
|
572
612
|
return Promise.resolve({ user });
|
|
573
613
|
});
|
|
@@ -575,7 +615,15 @@ export async function createFakeTenant(options) {
|
|
|
575
615
|
const code = location ? new URL(location).searchParams.get("code") : null;
|
|
576
616
|
if (code && issued)
|
|
577
617
|
signInOfCode.set(code, issued);
|
|
578
|
-
|
|
618
|
+
if (!started)
|
|
619
|
+
return response;
|
|
620
|
+
const headers = new Headers(response.headers);
|
|
621
|
+
headers.append("set-cookie", sessionCookie(started));
|
|
622
|
+
return new Response(response.body, {
|
|
623
|
+
status: response.status,
|
|
624
|
+
statusText: response.statusText,
|
|
625
|
+
headers,
|
|
626
|
+
});
|
|
579
627
|
};
|
|
580
628
|
const isLive = async (accessToken) => await tokenService.getToken(accessToken) !== undefined;
|
|
581
629
|
const liveCredentials = async (matches) => {
|
|
@@ -708,7 +756,11 @@ export async function createFakeTenant(options) {
|
|
|
708
756
|
});
|
|
709
757
|
const roleVocabulary = () => [
|
|
710
758
|
...BUILT_IN_ROLES,
|
|
711
|
-
...[...definedRoles].map((
|
|
759
|
+
...[...definedRoles.values()].map(({ id, slug, name }) => ({
|
|
760
|
+
id,
|
|
761
|
+
slug,
|
|
762
|
+
name,
|
|
763
|
+
})),
|
|
712
764
|
];
|
|
713
765
|
const roleName = (slug) => roleVocabulary().find((role) => role.slug === slug)?.name ?? slug;
|
|
714
766
|
const newGrant = (organization, userId, role, fields) => {
|
|
@@ -781,6 +833,31 @@ export async function createFakeTenant(options) {
|
|
|
781
833
|
}
|
|
782
834
|
};
|
|
783
835
|
const userByEmail = (email) => [...users.values()].find((user) => user.email?.toLowerCase() === email);
|
|
836
|
+
const endAppRoles = (organizationId, userId) => {
|
|
837
|
+
for (const assignment of [...appRoles]) {
|
|
838
|
+
if (assignment.organizationId === organizationId &&
|
|
839
|
+
(userId === undefined || assignment.userId === userId)) {
|
|
840
|
+
appRoles.splice(appRoles.indexOf(assignment), 1);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
const endMembershipStanding = (organization, userId) => {
|
|
845
|
+
endAppRoles(organization.id, userId);
|
|
846
|
+
organization.permissions.delete(userId);
|
|
847
|
+
organization.grants = organization.grants.filter((grant) => grant.userId !== userId || grant.acceptedAt);
|
|
848
|
+
const email = users.get(userId)?.email?.toLowerCase();
|
|
849
|
+
if (!email)
|
|
850
|
+
return;
|
|
851
|
+
const now = new Date();
|
|
852
|
+
for (const invitation of invitations.values()) {
|
|
853
|
+
if (invitation.organizationId === organization.id &&
|
|
854
|
+
invitation.email === email && !invitation.acceptedAt &&
|
|
855
|
+
!invitation.deletedAt) {
|
|
856
|
+
invitation.deletedAt = now;
|
|
857
|
+
invitation.updatedAt = now;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
};
|
|
784
861
|
const holdsGrantOnResource = (organizationId) => grants.filter((grant) => grant.subject.type === "organization" &&
|
|
785
862
|
grant.subject.id === organizationId);
|
|
786
863
|
const organizationApi = (handler) => async (request, params) => {
|
|
@@ -884,6 +961,7 @@ export async function createFakeTenant(options) {
|
|
|
884
961
|
return problem(409, `This organization still holds a role on ${named}. Revoke what it holds before deleting it.`);
|
|
885
962
|
}
|
|
886
963
|
organizations.delete(organization.id);
|
|
964
|
+
endAppRoles(organization.id);
|
|
887
965
|
return new Response(null, { status: 204 });
|
|
888
966
|
});
|
|
889
967
|
const listMembers = organizationApi((caller, request, [id]) => {
|
|
@@ -924,6 +1002,82 @@ export async function createFakeTenant(options) {
|
|
|
924
1002
|
if (!grant)
|
|
925
1003
|
return problem(404, "Organization membership not found");
|
|
926
1004
|
organization.grants.splice(organization.grants.indexOf(grant), 1);
|
|
1005
|
+
if (grant.acceptedAt && !isMember(organization.id, userId)) {
|
|
1006
|
+
endMembershipStanding(organization, userId);
|
|
1007
|
+
}
|
|
1008
|
+
return new Response(null, { status: 204 });
|
|
1009
|
+
});
|
|
1010
|
+
const heldRoleJson = (role) => ({
|
|
1011
|
+
id: role.id,
|
|
1012
|
+
builtInRole: null,
|
|
1013
|
+
organizationId: null,
|
|
1014
|
+
slug: role.slug,
|
|
1015
|
+
name: role.name,
|
|
1016
|
+
permissions: [...role.permissions],
|
|
1017
|
+
});
|
|
1018
|
+
const listMemberAppRoles = organizationApi((caller, _request, [id, userId]) => {
|
|
1019
|
+
const organization = managedOrganization(caller, id);
|
|
1020
|
+
if (organization instanceof Response)
|
|
1021
|
+
return organization;
|
|
1022
|
+
if (!isMember(organization.id, userId)) {
|
|
1023
|
+
return problem(404, MEMBER_NOT_FOUND);
|
|
1024
|
+
}
|
|
1025
|
+
return Response.json(appRolesHeld(organization.id, userId)
|
|
1026
|
+
.flatMap((assignment) => {
|
|
1027
|
+
const role = definedRoleById(assignment.roleId);
|
|
1028
|
+
return role ? [heldRoleJson(role)] : [];
|
|
1029
|
+
})
|
|
1030
|
+
.sort((a, b) => a.name.localeCompare(b.name)));
|
|
1031
|
+
});
|
|
1032
|
+
const grantMemberAppRole = organizationApi(async (caller, request, [id, userId]) => {
|
|
1033
|
+
const organization = managedOrganization(caller, id);
|
|
1034
|
+
if (organization instanceof Response)
|
|
1035
|
+
return organization;
|
|
1036
|
+
const body = await jsonBody(request);
|
|
1037
|
+
if (!isRecord(body) || typeof body.roleId !== "string" ||
|
|
1038
|
+
body.roleId.length === 0) {
|
|
1039
|
+
return invalid("roleId", "A role id is required");
|
|
1040
|
+
}
|
|
1041
|
+
if (!isMember(organization.id, userId)) {
|
|
1042
|
+
return problem(404, MEMBER_NOT_FOUND);
|
|
1043
|
+
}
|
|
1044
|
+
const role = definedRoleById(body.roleId);
|
|
1045
|
+
if (!role)
|
|
1046
|
+
return problem(404, "organization role not found");
|
|
1047
|
+
const held = appRolesHeld(organization.id, userId).find((assignment) => assignment.roleId === role.id);
|
|
1048
|
+
const assignment = held ?? {
|
|
1049
|
+
id: crypto.randomUUID(),
|
|
1050
|
+
organizationId: organization.id,
|
|
1051
|
+
userId,
|
|
1052
|
+
roleId: role.id,
|
|
1053
|
+
};
|
|
1054
|
+
if (!held)
|
|
1055
|
+
appRoles.push(assignment);
|
|
1056
|
+
return Response.json({
|
|
1057
|
+
id: assignment.id,
|
|
1058
|
+
subjectId: userId,
|
|
1059
|
+
subjectRole: null,
|
|
1060
|
+
roleId: role.id,
|
|
1061
|
+
builtInRole: null,
|
|
1062
|
+
scopeType: "organization",
|
|
1063
|
+
scopeId: organization.id,
|
|
1064
|
+
created: !held,
|
|
1065
|
+
}, { status: 201 });
|
|
1066
|
+
});
|
|
1067
|
+
const revokeMemberAppRole = organizationApi((caller, _request, [id, userId, roleId]) => {
|
|
1068
|
+
const organization = managedOrganization(caller, id);
|
|
1069
|
+
if (organization instanceof Response)
|
|
1070
|
+
return organization;
|
|
1071
|
+
if (BUILT_IN_APP_ROLES.includes(roleId)) {
|
|
1072
|
+
const who = roleId === OWNER
|
|
1073
|
+
? "Only an owner"
|
|
1074
|
+
: "Only an admin or owner";
|
|
1075
|
+
return problem(403, `${who} can grant or revoke ${roleId} access.`);
|
|
1076
|
+
}
|
|
1077
|
+
const held = appRolesHeld(organization.id, userId).find((assignment) => assignment.roleId === roleId);
|
|
1078
|
+
if (!held)
|
|
1079
|
+
return problem(404, "role assignment not found");
|
|
1080
|
+
appRoles.splice(appRoles.indexOf(held), 1);
|
|
927
1081
|
return new Response(null, { status: 204 });
|
|
928
1082
|
});
|
|
929
1083
|
const invite = organizationApi(async (caller, request, [id]) => {
|
|
@@ -1310,6 +1464,21 @@ export async function createFakeTenant(options) {
|
|
|
1310
1464
|
new RegExp(`^/api/organizations/${segment}/member-roles$`),
|
|
1311
1465
|
listMemberRoles,
|
|
1312
1466
|
],
|
|
1467
|
+
[
|
|
1468
|
+
"GET",
|
|
1469
|
+
new RegExp(`^/api/organizations/${segment}/members/${segment}/roles$`),
|
|
1470
|
+
listMemberAppRoles,
|
|
1471
|
+
],
|
|
1472
|
+
[
|
|
1473
|
+
"POST",
|
|
1474
|
+
new RegExp(`^/api/organizations/${segment}/members/${segment}/roles$`),
|
|
1475
|
+
grantMemberAppRole,
|
|
1476
|
+
],
|
|
1477
|
+
[
|
|
1478
|
+
"DELETE",
|
|
1479
|
+
new RegExp(`^/api/organizations/${segment}/members/${segment}/roles/${segment}$`),
|
|
1480
|
+
revokeMemberAppRole,
|
|
1481
|
+
],
|
|
1313
1482
|
[
|
|
1314
1483
|
"DELETE",
|
|
1315
1484
|
new RegExp(`^/api/organizations/${segment}/members/${segment}/${segment}$`),
|
|
@@ -1441,19 +1610,37 @@ export async function createFakeTenant(options) {
|
|
|
1441
1610
|
return;
|
|
1442
1611
|
organization.grants = organization.grants.filter((grant) => grant.userId !== userId);
|
|
1443
1612
|
organization.permissions.delete(userId);
|
|
1613
|
+
endAppRoles(organizationId, userId);
|
|
1444
1614
|
},
|
|
1445
|
-
defineOrganizationRole: ({ slug, name }) => {
|
|
1615
|
+
defineOrganizationRole: ({ slug, name, id, permissions }) => {
|
|
1446
1616
|
if (BUILT_IN_ROLES.some((role) => role.slug === slug)) {
|
|
1447
1617
|
throw new Error(`"${slug}" is a built-in organization role`);
|
|
1448
1618
|
}
|
|
1449
|
-
definedRoles.
|
|
1619
|
+
const existing = definedRoles.get(slug)?.id;
|
|
1620
|
+
if (id !== undefined && existing !== undefined && id !== existing) {
|
|
1621
|
+
throw new Error(`Role "${slug}" already has the id "${existing}"`);
|
|
1622
|
+
}
|
|
1623
|
+
const roleId = id ?? existing ?? crypto.randomUUID();
|
|
1624
|
+
const holder = definedRoleById(roleId);
|
|
1625
|
+
if (holder && holder.slug !== slug) {
|
|
1626
|
+
throw new Error(`Role id "${roleId}" belongs to "${holder.slug}"`);
|
|
1627
|
+
}
|
|
1628
|
+
definedRoles.set(slug, {
|
|
1629
|
+
id: roleId,
|
|
1630
|
+
slug,
|
|
1631
|
+
name: name ?? slug,
|
|
1632
|
+
permissions: [...permissions ?? []],
|
|
1633
|
+
});
|
|
1634
|
+
return roleId;
|
|
1450
1635
|
},
|
|
1451
1636
|
registerResourceType: (type) => {
|
|
1452
1637
|
resourceTypes.add(type);
|
|
1453
1638
|
},
|
|
1454
1639
|
grant: (grant) => {
|
|
1455
1640
|
const role = grant.role ?? roleSlugOf(grant.permissions);
|
|
1456
|
-
let roleId = isBuiltInRole(role)
|
|
1641
|
+
let roleId = isBuiltInRole(role)
|
|
1642
|
+
? null
|
|
1643
|
+
: definedRoles.get(role)?.id ?? resourceRoleIds.get(role);
|
|
1457
1644
|
if (roleId === undefined) {
|
|
1458
1645
|
roleId = crypto.randomUUID();
|
|
1459
1646
|
resourceRoleIds.set(role, roleId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|