@udibo/oauth2 0.13.0 → 0.14.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.
@@ -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);
@@ -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
- /** Permissions held inside this organization only. */
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
- /** An organization role your tenant defines beside the built-in tiers. */
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 this sign-in. */
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 this sign-in. */
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 and removes the person's pending membership grants.
237
- * Email-address invitations remain. From then on introspection and
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 an organization role beside the built-in tiers, so invitations
246
- * may offer it and `member-roles` lists it. Throws on a built-in slug.
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): void;
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 call is a new browser: the first authorization after it starts a
266
- * login session, which the account API lists, and later ones reuse it until
267
- * that session is ended. When a first-party application signs in again in
268
- * that browser, the credentials the session issued before are revoked.
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
  /**
@@ -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
- ? organizations.get(organizationId).permissions.get(userId) ?? []
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 liveSessionOf = (chosen) => {
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
- chosen.sessionId = session.id;
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: liveSessionOf(chosen),
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
- return response;
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(([slug, name]) => ({ slug, name })),
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.set(slug, name ?? slug);
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) ? null : resourceRoleIds.get(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.13.0",
3
+ "version": "0.14.0",
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",