@udibo/oauth2 0.7.0 → 0.9.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.
@@ -4,6 +4,7 @@ import { AuthorizationServer } from "../server/authorization-server.js";
4
4
  import { createJwtAccessTokenGenerator, generateSigningKey, StaticSigningKeyProvider, } from "../server/signing-keys.js";
5
5
  import { BasicScope } from "../models/scope.js";
6
6
  import { InvalidRequestError } from "../errors.js";
7
+ import { safeReturnTo } from "../utils/url.js";
7
8
  import { MemoryAuthorizationCodeService, MemoryClientService, MemoryTokenService, MemoryUserService, } from "./services.js";
8
9
  const PATHS = {
9
10
  authorization: "/api/oauth2/authorize",
@@ -15,27 +16,45 @@ const PATHS = {
15
16
  };
16
17
  const MAX_PERMISSIONS = 50;
17
18
  const MAX_BATCH_IDS = 100;
19
+ const DEFAULT_PAGE_SIZE = 20;
20
+ const MAX_PAGE_SIZE = 100;
21
+ const METADATA_MAX_BYTES = 16 * 1024;
22
+ const METADATA_MAX_DEPTH = 8;
23
+ const INVITATION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
24
+ const RETURN_TO_MAX_LENGTH = 2048;
25
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
26
+ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
27
+ const CONTROL_CHARACTERS = /\p{Cc}/u;
28
+ const BUILT_IN_ROLES = [
29
+ { slug: "owner", name: "Owner" },
30
+ { slug: "admin", name: "Admin" },
31
+ { slug: "member", name: "Member" },
32
+ ];
33
+ const OWNER = "owner";
34
+ const MANAGER_ROLES = ["owner", "admin"];
35
+ const DEFAULT_ROLES = ["member"];
36
+ const ORGANIZATION_NOT_FOUND = "Organization not found";
18
37
  class TenantTokenService extends MemoryTokenService {
19
- organizationOf;
38
+ issuanceOf;
20
39
  recordCredential;
21
40
  mintJwt;
22
- constructor(options, organizationOf, recordCredential, mintJwt) {
41
+ constructor(options, issuanceOf, recordCredential, mintJwt) {
23
42
  super(options);
24
- this.organizationOf = organizationOf;
43
+ this.issuanceOf = issuanceOf;
25
44
  this.recordCredential = recordCredential;
26
45
  this.mintJwt = mintJwt;
27
46
  }
28
47
  async generateAccessToken(client, user, scope) {
29
- const organizationId = user ? this.organizationOf(user.id) : undefined;
48
+ const issuance = (user ? this.issuanceOf(user.id) : undefined) ?? {};
30
49
  const accessToken = client.accessTokenFormat === "jwt" && user
31
- ? await this.mintJwt(client, user, scope, organizationId)
50
+ ? await this.mintJwt(client, user, scope, issuance.organizationId)
32
51
  : await super.generateAccessToken(client, user, scope);
33
- this.recordCredential(accessToken, organizationId);
52
+ this.recordCredential(accessToken, issuance, client);
34
53
  return accessToken;
35
54
  }
36
55
  }
37
- function problem(status, detail) {
38
- return Response.json({ status, detail }, {
56
+ function problem(status, detail, extensions = {}) {
57
+ return Response.json({ status, detail, ...extensions }, {
39
58
  status,
40
59
  headers: { "content-type": "application/problem+json" },
41
60
  });
@@ -49,6 +68,128 @@ function asPermissions(value) {
49
68
  }
50
69
  return list;
51
70
  }
71
+ function isRecord(value) {
72
+ return value !== null && typeof value === "object" && !Array.isArray(value);
73
+ }
74
+ async function jsonBody(request) {
75
+ const text = await request.text();
76
+ if (!text)
77
+ return undefined;
78
+ try {
79
+ return JSON.parse(text);
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ async function isEmptyBody(request) {
86
+ const text = await request.text();
87
+ if (!text.trim())
88
+ return true;
89
+ try {
90
+ const body = JSON.parse(text);
91
+ return isRecord(body) && Object.keys(body).length === 0;
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ }
97
+ function depthOf(value) {
98
+ if (value === null || typeof value !== "object")
99
+ return 0;
100
+ const children = Array.isArray(value) ? value : Object.values(value);
101
+ return 1 + Math.max(0, ...children.map(depthOf));
102
+ }
103
+ function metadataProblem(value) {
104
+ if (!isRecord(value))
105
+ return "Metadata must be a JSON object";
106
+ if (depthOf(value) > METADATA_MAX_DEPTH) {
107
+ return `Metadata must not nest deeper than ${METADATA_MAX_DEPTH} levels`;
108
+ }
109
+ const json = JSON.stringify(value);
110
+ if (new TextEncoder().encode(json).length > METADATA_MAX_BYTES) {
111
+ return `Metadata must be at most ${METADATA_MAX_BYTES} bytes`;
112
+ }
113
+ if (json.includes("\\u0000")) {
114
+ return "Metadata must not contain NUL characters";
115
+ }
116
+ return undefined;
117
+ }
118
+ function nameProblem(value) {
119
+ if (typeof value !== "string" || value.length < 1 || value.length > 100 ||
120
+ CONTROL_CHARACTERS.test(value)) {
121
+ return "name must be 1 to 100 characters with no control characters";
122
+ }
123
+ return undefined;
124
+ }
125
+ function slugProblem(value) {
126
+ if (typeof value !== "string" || value.length > 100 || !SLUG.test(value)) {
127
+ return "Must be lowercase letters, numbers and single hyphens";
128
+ }
129
+ return undefined;
130
+ }
131
+ function invalid(field, message) {
132
+ return problem(400, message, { fieldErrors: { [field]: message } });
133
+ }
134
+ function iso(date) {
135
+ return date ? date.toISOString() : null;
136
+ }
137
+ function encodeCursor(id, direction) {
138
+ return btoa(JSON.stringify({ id, direction }));
139
+ }
140
+ function decodeCursor(value) {
141
+ if (!value)
142
+ return null;
143
+ try {
144
+ const cursor = JSON.parse(atob(value));
145
+ return typeof cursor?.id === "string" &&
146
+ (cursor.direction === "next" || cursor.direction === "prev")
147
+ ? cursor
148
+ : null;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ function page(rows, request) {
155
+ const params = new URL(request.url).searchParams;
156
+ const requested = Number(params.get("limit") ?? DEFAULT_PAGE_SIZE);
157
+ const limit = Number.isFinite(requested)
158
+ ? Math.min(Math.max(Math.floor(requested), 1), MAX_PAGE_SIZE)
159
+ : DEFAULT_PAGE_SIZE;
160
+ const sorted = [...rows].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
161
+ const cursor = decodeCursor(params.get("cursor"));
162
+ let data;
163
+ let hasMore;
164
+ if (cursor?.direction === "prev") {
165
+ const before = sorted.filter((row) => row.id < cursor.id);
166
+ data = before.slice(-limit);
167
+ hasMore = before.length > limit;
168
+ }
169
+ else {
170
+ const after = cursor ? sorted.filter((row) => row.id > cursor.id) : sorted;
171
+ data = after.slice(0, limit);
172
+ hasMore = after.length > limit;
173
+ }
174
+ if (data.length === 0) {
175
+ return { data, cursors: { next: null, prev: null }, hasMore: false };
176
+ }
177
+ const first = data[0].id;
178
+ return {
179
+ data,
180
+ cursors: {
181
+ next: encodeCursor(data[data.length - 1].id, "next"),
182
+ prev: sorted.some((row) => row.id < first)
183
+ ? encodeCursor(first, "prev")
184
+ : null,
185
+ },
186
+ hasMore,
187
+ };
188
+ }
189
+ function noStore(response) {
190
+ response.headers.set("cache-control", "no-store");
191
+ return response;
192
+ }
52
193
  /**
53
194
  * Creates a {@linkcode FakeTenant}. Serve its `fetch` on the origin you pass
54
195
  * as `issuer`, then point the app under test at that origin.
@@ -73,27 +214,39 @@ export async function createFakeTenant(options) {
73
214
  const issuer = new URL(options.issuer).origin;
74
215
  const signingKeys = new StaticSigningKeyProvider(await generateSigningKey());
75
216
  const users = new Map();
217
+ const userMetadata = new Map();
76
218
  const organizations = new Map();
219
+ const definedRoles = new Map();
220
+ const invitations = new Map();
77
221
  const resourceTypes = new Set();
78
222
  const grants = [];
79
- const credentialOrganization = new Map();
223
+ const loginSessions = new Map();
224
+ const linkedAccounts = new Map();
225
+ const credentials = new Map();
226
+ const redirectOrigins = new Set();
80
227
  let signedIn = null;
81
- const pendingOrganization = new Map();
82
- const membershipOf = (organizationId, userId) => organizationId
83
- ? organizations.get(organizationId)?.members.get(userId)
84
- : undefined;
228
+ const pendingIssuance = new Map();
229
+ const issuanceQueue = new Map();
230
+ const acceptedRoles = (organizationId, userId) => organizationId
231
+ ? organizations.get(organizationId)?.grants
232
+ .filter((grant) => grant.userId === userId && grant.acceptedAt)
233
+ .map((grant) => grant.role) ?? []
234
+ : [];
235
+ const isMember = (organizationId, userId) => acceptedRoles(organizationId, userId).length > 0;
236
+ const holdsAny = (organizationId, userId, roles) => acceptedRoles(organizationId, userId).some((role) => roles.includes(role));
85
237
  const permissionsIn = (userId, organizationId) => new Set([
86
238
  ...users.get(userId)?.permissions ?? [],
87
- ...membershipOf(organizationId, userId)?.permissions ?? [],
239
+ ...isMember(organizationId, userId)
240
+ ? organizations.get(organizationId).permissions.get(userId) ?? []
241
+ : [],
88
242
  ]);
89
243
  const organizationClaims = (userId, organizationId) => {
90
- const membership = membershipOf(organizationId, userId);
91
- if (!organizationId || !membership)
244
+ if (!organizationId || !isMember(organizationId, userId))
92
245
  return {};
93
246
  return {
94
247
  org_id: organizationId,
95
248
  org_slug: organizations.get(organizationId).slug,
96
- org_roles: membership.roles ?? [],
249
+ org_roles: acceptedRoles(organizationId, userId),
97
250
  };
98
251
  };
99
252
  const authorizationClaims = (userId, organizationId) => {
@@ -105,7 +258,12 @@ export async function createFakeTenant(options) {
105
258
  };
106
259
  const userService = new MemoryUserService();
107
260
  const clientService = new MemoryClientService(userService);
108
- const tokenService = new TenantTokenService({ clientService, userService }, (userId) => pendingOrganization.get(userId), (accessToken, organizationId) => credentialOrganization.set(accessToken, organizationId), (client, user, scope, organizationId) => createJwtAccessTokenGenerator({
261
+ const tokenService = new TenantTokenService({ clientService, userService }, (userId) => pendingIssuance.get(userId), (accessToken, issuance, client) => credentials.set(accessToken, {
262
+ ...issuance,
263
+ boundSessionId: client.type === "third-party"
264
+ ? undefined
265
+ : issuance.sessionId,
266
+ }), (client, user, scope, organizationId) => createJwtAccessTokenGenerator({
109
267
  signingKeys,
110
268
  issuer,
111
269
  audience: client.audience ?? client.id,
@@ -149,21 +307,28 @@ export async function createFakeTenant(options) {
149
307
  ...organizationClaims(user.id, signedIn?.userId === user.id ? signedIn.organizationId : undefined),
150
308
  }),
151
309
  introspectionClaims: (token) => token.user
152
- ? authorizationClaims(token.user.id, credentialOrganization.get(token.accessToken))
310
+ ? authorizationClaims(token.user.id, credentials.get(token.accessToken)?.organizationId)
153
311
  : {},
154
312
  });
155
- const withPendingOrganization = async (userId, organizationId, work) => {
313
+ const withIssuance = async (userId, issuance, work) => {
156
314
  if (!userId)
157
315
  return await work();
158
- pendingOrganization.set(userId, organizationId);
316
+ const previous = issuanceQueue.get(userId);
317
+ const { promise, resolve } = Promise.withResolvers();
318
+ issuanceQueue.set(userId, promise);
319
+ await previous;
320
+ pendingIssuance.set(userId, issuance);
159
321
  try {
160
322
  return await work();
161
323
  }
162
324
  finally {
163
- pendingOrganization.delete(userId);
325
+ pendingIssuance.delete(userId);
326
+ if (issuanceQueue.get(userId) === promise)
327
+ issuanceQueue.delete(userId);
328
+ resolve();
164
329
  }
165
330
  };
166
- const authenticated = async (request) => {
331
+ const authenticated = async (request, machineRefusal = {}) => {
167
332
  let token;
168
333
  try {
169
334
  ({ token } = await server.authenticate(request));
@@ -172,14 +337,16 @@ export async function createFakeTenant(options) {
172
337
  return server.handleAuthError(error);
173
338
  }
174
339
  if (!token.user) {
175
- return problem(403, "A machine credential has no self to answer for.");
340
+ return problem(403, "A machine credential has no self to answer for.", machineRefusal);
176
341
  }
177
- const organizationId = credentialOrganization.get(token.accessToken);
342
+ const user = users.get(token.user.id) ?? token.user;
343
+ const organizationId = credentials.get(token.accessToken)?.organizationId;
178
344
  return {
179
- user: token.user,
180
- organizationId: membershipOf(organizationId, token.user.id)
345
+ user,
346
+ organizationId: isMember(organizationId, user.id)
181
347
  ? organizationId
182
348
  : undefined,
349
+ accessToken: token.accessToken,
183
350
  };
184
351
  };
185
352
  const resourcePermissions = (userId, type, id) => {
@@ -189,7 +356,7 @@ export async function createFakeTenant(options) {
189
356
  continue;
190
357
  const admits = grant.subject.type === "user"
191
358
  ? grant.subject.id === userId
192
- : Boolean(membershipOf(grant.subject.id, userId));
359
+ : isMember(grant.subject.id, userId);
193
360
  if (admits)
194
361
  grant.permissions.forEach((p) => held.add(p));
195
362
  }
@@ -219,7 +386,7 @@ export async function createFakeTenant(options) {
219
386
  }
220
387
  if (resource.type === "organization") {
221
388
  if (!organizations.has(resource.id)) {
222
- return problem(404, "Organization not found");
389
+ return problem(404, ORGANIZATION_NOT_FOUND);
223
390
  }
224
391
  return Response.json({
225
392
  subject: caller.user.id,
@@ -266,13 +433,13 @@ export async function createFakeTenant(options) {
266
433
  const signInOfCode = new Map();
267
434
  const signInOfRefreshToken = new Map();
268
435
  const soleOrganizationOf = (userId) => {
269
- const joined = [...organizations.values()].filter((organization) => organization.members.has(userId));
436
+ const joined = [...organizations.values()].filter((organization) => isMember(organization.id, userId));
270
437
  return joined.length === 1 ? joined[0].id : undefined;
271
438
  };
272
439
  const requestedOrganization = (requested, userId) => {
273
440
  const organization = organizations.get(requested) ??
274
441
  [...organizations.values()].find((o) => o.slug === requested);
275
- if (!organization?.members.has(userId)) {
442
+ if (!organization || !isMember(organization.id, userId)) {
276
443
  throw new InvalidRequestError("organization is not available");
277
444
  }
278
445
  return organization.id;
@@ -283,29 +450,52 @@ export async function createFakeTenant(options) {
283
450
  return caller;
284
451
  return Response.json({
285
452
  memberships: [...organizations.values()]
286
- .filter((organization) => organization.members.has(caller.user.id))
453
+ .filter((organization) => isMember(organization.id, caller.user.id))
287
454
  .map((organization) => ({
288
455
  org_id: organization.id,
289
456
  org_slug: organization.slug,
290
- name: organization.name ?? organization.slug,
291
- roles: organization.members.get(caller.user.id).roles ?? [],
457
+ name: organization.name,
458
+ roles: acceptedRoles(organization.id, caller.user.id),
292
459
  })),
293
460
  cursor: null,
294
461
  });
295
462
  };
463
+ const liveSessionOf = (chosen) => {
464
+ const now = new Date();
465
+ const existing = chosen.sessionId
466
+ ? loginSessions.get(chosen.sessionId)
467
+ : undefined;
468
+ if (existing) {
469
+ existing.lastActiveAt = now;
470
+ return existing.id;
471
+ }
472
+ const session = {
473
+ id: crypto.randomUUID(),
474
+ userId: chosen.userId,
475
+ createdAt: now,
476
+ lastActiveAt: now,
477
+ ipAddress: chosen.ipAddress ?? null,
478
+ userAgent: chosen.userAgent ?? null,
479
+ };
480
+ loginSessions.set(session.id, session);
481
+ chosen.sessionId = session.id;
482
+ return session.id;
483
+ };
296
484
  const authorize = async (request) => {
297
485
  const chosen = signedIn;
298
486
  const requested = new URL(request.url).searchParams.get("organization");
299
487
  let issued;
300
488
  const response = await server.handleAuthorizeRequest(request, () => {
301
489
  const user = chosen ? users.get(chosen.userId) : undefined;
302
- if (!user)
490
+ if (!chosen || !user)
303
491
  return Promise.resolve(null);
492
+ const organizationId = requested
493
+ ? requestedOrganization(requested, user.id)
494
+ : chosen.organizationId ?? soleOrganizationOf(user.id);
304
495
  issued = {
305
496
  userId: user.id,
306
- organizationId: requested
307
- ? requestedOrganization(requested, user.id)
308
- : chosen?.organizationId ?? soleOrganizationOf(user.id),
497
+ organizationId,
498
+ sessionId: liveSessionOf(chosen),
309
499
  };
310
500
  return Promise.resolve({ user });
311
501
  });
@@ -315,6 +505,39 @@ export async function createFakeTenant(options) {
315
505
  signInOfCode.set(code, issued);
316
506
  return response;
317
507
  };
508
+ const isLive = async (accessToken) => await tokenService.getToken(accessToken) !== undefined;
509
+ const liveCredentials = async (matches) => {
510
+ const live = [];
511
+ for (const [accessToken, credential] of credentials) {
512
+ if (matches(credential, accessToken) && await isLive(accessToken)) {
513
+ live.push([accessToken, credential]);
514
+ }
515
+ }
516
+ return live;
517
+ };
518
+ const revokeBoundTo = async (sessionId, keep) => {
519
+ for (const [accessToken, credential] of credentials) {
520
+ if (credential.boundSessionId !== sessionId || accessToken === keep) {
521
+ continue;
522
+ }
523
+ await tokenService.revoke(accessToken);
524
+ credentials.delete(accessToken);
525
+ }
526
+ };
527
+ const endSession = async (sessionId) => {
528
+ loginSessions.delete(sessionId);
529
+ await revokeBoundTo(sessionId);
530
+ };
531
+ const endSessionsOfRevoked = async (wereLive) => {
532
+ for (const [accessToken, credential] of wereLive) {
533
+ if (await isLive(accessToken))
534
+ continue;
535
+ credentials.delete(accessToken);
536
+ if (credential.boundSessionId) {
537
+ await endSession(credential.boundSessionId);
538
+ }
539
+ }
540
+ };
318
541
  const token = async (request) => {
319
542
  const form = await request.clone().formData().catch(() => null);
320
543
  const code = form?.get("code");
@@ -324,21 +547,574 @@ export async function createFakeTenant(options) {
324
547
  : typeof refreshToken === "string"
325
548
  ? signInOfRefreshToken.get(refreshToken)
326
549
  : undefined;
327
- const response = await withPendingOrganization(signIn?.userId, signIn?.organizationId, () => server.handleTokenRequest(request));
328
- if (response.ok && signIn) {
329
- const issued = await response.clone().json();
330
- if (issued.refresh_token) {
331
- signInOfRefreshToken.set(issued.refresh_token, signIn);
550
+ return await withIssuance(signIn?.userId, { organizationId: signIn?.organizationId, sessionId: signIn?.sessionId }, async () => {
551
+ const family = typeof refreshToken === "string" && signIn
552
+ ? await liveCredentials((credential) => credential.signIn === signIn)
553
+ : [];
554
+ const response = await server.handleTokenRequest(request);
555
+ if (!response.ok) {
556
+ await endSessionsOfRevoked(family);
557
+ return response;
332
558
  }
333
- }
559
+ if (signIn) {
560
+ const session = signIn.sessionId
561
+ ? loginSessions.get(signIn.sessionId)
562
+ : undefined;
563
+ if (session)
564
+ session.lastActiveAt = new Date();
565
+ const issued = await response.clone().json();
566
+ const credential = credentials.get(issued.access_token);
567
+ if (credential) {
568
+ credential.signIn = signIn;
569
+ credential.refreshToken = issued.refresh_token;
570
+ }
571
+ if (issued.refresh_token) {
572
+ signInOfRefreshToken.set(issued.refresh_token, signIn);
573
+ }
574
+ if (typeof code === "string" && credential?.boundSessionId) {
575
+ await revokeBoundTo(credential.boundSessionId, issued.access_token);
576
+ }
577
+ }
578
+ return response;
579
+ });
580
+ };
581
+ const revocation = async (request) => {
582
+ const form = await request.clone().formData().catch(() => null);
583
+ const value = form?.get("token");
584
+ const presented = typeof value === "string"
585
+ ? await liveCredentials((credential, accessToken) => accessToken === value || credential.refreshToken === value)
586
+ : [];
587
+ const response = await server.handleRevocationRequest(request);
588
+ await endSessionsOfRevoked(presented);
334
589
  return response;
335
590
  };
591
+ const organizationJson = (organization) => ({
592
+ id: organization.id,
593
+ name: organization.name,
594
+ slug: organization.slug,
595
+ metadata: organization.metadata,
596
+ createdAt: iso(organization.createdAt),
597
+ updatedAt: iso(organization.updatedAt),
598
+ deletedAt: null,
599
+ });
600
+ const grantJson = (grant) => ({
601
+ id: grant.id,
602
+ organizationId: grant.organizationId,
603
+ role: grant.role,
604
+ userId: grant.userId,
605
+ invitedByUserId: grant.invitedByUserId,
606
+ acceptedAt: iso(grant.acceptedAt),
607
+ returnTo: grant.returnTo,
608
+ createdAt: iso(grant.createdAt),
609
+ updatedAt: iso(grant.updatedAt),
610
+ deletedAt: null,
611
+ });
612
+ const invitationJson = (invitation) => ({
613
+ id: invitation.id,
614
+ organizationId: invitation.organizationId,
615
+ email: invitation.email,
616
+ role: invitation.role,
617
+ invitedByUserId: invitation.invitedByUserId,
618
+ expiresAt: iso(invitation.expiresAt),
619
+ acceptedAt: iso(invitation.acceptedAt),
620
+ acceptedByUserId: invitation.acceptedByUserId,
621
+ returnTo: invitation.returnTo,
622
+ createdAt: iso(invitation.createdAt),
623
+ updatedAt: iso(invitation.updatedAt),
624
+ deletedAt: iso(invitation.deletedAt),
625
+ });
626
+ const roleVocabulary = () => [
627
+ ...BUILT_IN_ROLES,
628
+ ...[...definedRoles].map(([slug, name]) => ({ slug, name })),
629
+ ];
630
+ const roleName = (slug) => roleVocabulary().find((role) => role.slug === slug)?.name ?? slug;
631
+ const newGrant = (organization, userId, role, fields) => {
632
+ const now = new Date();
633
+ const grant = {
634
+ id: crypto.randomUUID(),
635
+ organizationId: organization.id,
636
+ userId,
637
+ role,
638
+ ...fields,
639
+ createdAt: now,
640
+ updatedAt: now,
641
+ };
642
+ organization.grants.push(grant);
643
+ return grant;
644
+ };
645
+ const grantOf = (organization, userId, role) => organization.grants.find((grant) => grant.userId === userId && grant.role === role);
646
+ const acceptGrant = (grant) => {
647
+ const now = new Date();
648
+ grant.acceptedAt = now;
649
+ grant.updatedAt = now;
650
+ grant.returnTo = null;
651
+ return grant;
652
+ };
653
+ const grantAccepted = (organization, userId, role, invitedByUserId) => {
654
+ const existing = grantOf(organization, userId, role);
655
+ if (existing?.acceptedAt)
656
+ return existing;
657
+ if (existing)
658
+ return acceptGrant(existing);
659
+ return newGrant(organization, userId, role, {
660
+ invitedByUserId,
661
+ acceptedAt: new Date(),
662
+ returnTo: null,
663
+ });
664
+ };
665
+ const readableOrganization = (caller, organizationId) => {
666
+ const organization = organizations.get(organizationId);
667
+ return organization && isMember(organization.id, caller.user.id)
668
+ ? organization
669
+ : problem(404, ORGANIZATION_NOT_FOUND);
670
+ };
671
+ const managedOrganization = (caller, organizationId) => {
672
+ const organization = organizations.get(organizationId);
673
+ return organization &&
674
+ holdsAny(organization.id, caller.user.id, MANAGER_ROLES)
675
+ ? organization
676
+ : problem(404, ORGANIZATION_NOT_FOUND);
677
+ };
678
+ const ownerTierRefusal = (organization, caller, role) => role === OWNER && !holdsAny(organization.id, caller.user.id, [OWNER])
679
+ ? problem(403, "Only an owner of this organization can offer or revoke its owner role.")
680
+ : undefined;
681
+ const resolveReturnTo = (value) => {
682
+ if (value.length > RETURN_TO_MAX_LENGTH || CONTROL_CHARACTERS.test(value)) {
683
+ return null;
684
+ }
685
+ if (value.startsWith("/")) {
686
+ const path = safeReturnTo(value, "");
687
+ return path === "" ? null : path;
688
+ }
689
+ try {
690
+ const url = new URL(value);
691
+ return (url.protocol === "https:" || url.protocol === "http:") &&
692
+ redirectOrigins.has(url.origin)
693
+ ? url.href
694
+ : null;
695
+ }
696
+ catch {
697
+ return null;
698
+ }
699
+ };
700
+ const userByEmail = (email) => [...users.values()].find((user) => user.email?.toLowerCase() === email);
701
+ const holdsGrantOnResource = (organizationId) => grants.filter((grant) => grant.subject.type === "organization" &&
702
+ grant.subject.id === organizationId);
703
+ const organizationApi = (handler) => async (request, params) => {
704
+ const caller = await authenticated(request);
705
+ if (caller instanceof Response)
706
+ return noStore(caller);
707
+ return noStore(await handler(caller, request, params));
708
+ };
709
+ const listOrganizations = organizationApi((caller, request) => Response.json(page([...organizations.values()]
710
+ .filter((organization) => isMember(organization.id, caller.user.id))
711
+ .map(organizationJson), request)));
712
+ const createOrganization = organizationApi(async (caller, request) => {
713
+ const body = await jsonBody(request);
714
+ if (!isRecord(body))
715
+ return problem(400, "Invalid organization");
716
+ const nameRefusal = nameProblem(body.name);
717
+ if (nameRefusal)
718
+ return invalid("name", nameRefusal);
719
+ const slugRefusal = slugProblem(body.slug);
720
+ if (slugRefusal)
721
+ return invalid("slug", slugRefusal);
722
+ const metadataRefusal = body.metadata === undefined
723
+ ? undefined
724
+ : metadataProblem(body.metadata);
725
+ if (metadataRefusal)
726
+ return invalid("metadata", metadataRefusal);
727
+ const slug = body.slug;
728
+ if ([...organizations.values()].some((o) => o.slug === slug)) {
729
+ return problem(409, "An organization with this slug already exists", {
730
+ constraint: "organizations_tenant_slug_unique",
731
+ });
732
+ }
733
+ const now = new Date();
734
+ const organization = {
735
+ id: crypto.randomUUID(),
736
+ slug,
737
+ name: body.name,
738
+ metadata: body.metadata ?? {},
739
+ createdAt: now,
740
+ updatedAt: now,
741
+ grants: [],
742
+ permissions: new Map(),
743
+ };
744
+ organizations.set(organization.id, organization);
745
+ grantAccepted(organization, caller.user.id, OWNER, null);
746
+ return Response.json(organizationJson(organization), { status: 201 });
747
+ });
748
+ const getOrganization = organizationApi((caller, _request, [id]) => {
749
+ const organization = readableOrganization(caller, id);
750
+ if (organization instanceof Response)
751
+ return organization;
752
+ return Response.json(organizationJson(organization));
753
+ });
754
+ const patchOrganization = organizationApi(async (caller, request, [id]) => {
755
+ const organization = managedOrganization(caller, id);
756
+ if (organization instanceof Response)
757
+ return organization;
758
+ const body = await jsonBody(request);
759
+ if (!isRecord(body))
760
+ return problem(400, "Invalid organization");
761
+ if (body.name !== undefined) {
762
+ const refusal = nameProblem(body.name);
763
+ if (refusal)
764
+ return invalid("name", refusal);
765
+ }
766
+ if (body.slug !== undefined) {
767
+ const refusal = slugProblem(body.slug);
768
+ if (refusal)
769
+ return invalid("slug", refusal);
770
+ if ([...organizations.values()].some((o) => o.slug === body.slug && o.id !== organization.id)) {
771
+ return problem(409, "An organization with this slug already exists", {
772
+ constraint: "organizations_tenant_slug_unique",
773
+ });
774
+ }
775
+ }
776
+ if (body.metadata !== undefined) {
777
+ const refusal = metadataProblem(body.metadata);
778
+ if (refusal)
779
+ return invalid("metadata", refusal);
780
+ }
781
+ if (body.name !== undefined)
782
+ organization.name = body.name;
783
+ if (body.slug !== undefined)
784
+ organization.slug = body.slug;
785
+ if (body.metadata !== undefined) {
786
+ organization.metadata = body.metadata;
787
+ }
788
+ organization.updatedAt = new Date();
789
+ return Response.json(organizationJson(organization));
790
+ });
791
+ const deleteOrganization = organizationApi((caller, _request, [id]) => {
792
+ const organization = managedOrganization(caller, id);
793
+ if (organization instanceof Response)
794
+ return organization;
795
+ if (!holdsAny(organization.id, caller.user.id, [OWNER])) {
796
+ return problem(403, "Only an owner of this organization can delete it.");
797
+ }
798
+ const held = holdsGrantOnResource(organization.id);
799
+ if (held.length > 0) {
800
+ const named = held.slice(0, 3).map((grant) => `${grant.resource.type} ${grant.resource.id}`).join(", ");
801
+ return problem(409, `This organization still holds a role on ${named}. Revoke what it holds before deleting it.`);
802
+ }
803
+ organizations.delete(organization.id);
804
+ return new Response(null, { status: 204 });
805
+ });
806
+ const listMembers = organizationApi((caller, request, [id]) => {
807
+ const organization = readableOrganization(caller, id);
808
+ if (organization instanceof Response)
809
+ return organization;
810
+ return Response.json(page(organization.grants
811
+ .filter((grant) => grant.acceptedAt)
812
+ .map((grant) => {
813
+ const { returnTo: _offerOnly, ...row } = grantJson(grant);
814
+ const holder = users.get(grant.userId);
815
+ return {
816
+ ...row,
817
+ name: holder?.name ?? holder?.username ?? "Unknown user",
818
+ email: holder?.email ?? null,
819
+ };
820
+ }), request));
821
+ });
822
+ const listMemberRoles = organizationApi((caller, _request, [id]) => {
823
+ const organization = managedOrganization(caller, id);
824
+ if (organization instanceof Response)
825
+ return organization;
826
+ return Response.json(roleVocabulary());
827
+ });
828
+ const revokeMembership = organizationApi((caller, _request, [id, userId, role]) => {
829
+ const organization = managedOrganization(caller, id);
830
+ if (organization instanceof Response)
831
+ return organization;
832
+ const refusal = ownerTierRefusal(organization, caller, role);
833
+ if (refusal)
834
+ return refusal;
835
+ const grant = grantOf(organization, userId, role);
836
+ if (role === OWNER && grant?.acceptedAt &&
837
+ organization.grants.filter((g) => g.role === OWNER && g.acceptedAt)
838
+ .length <= 1) {
839
+ return problem(409, "This is the organization's last owner — make someone else an owner before revoking this membership.");
840
+ }
841
+ if (!grant)
842
+ return problem(404, "Organization membership not found");
843
+ organization.grants.splice(organization.grants.indexOf(grant), 1);
844
+ return new Response(null, { status: 204 });
845
+ });
846
+ const invite = organizationApi(async (caller, request, [id]) => {
847
+ const organization = managedOrganization(caller, id);
848
+ if (organization instanceof Response)
849
+ return organization;
850
+ const body = await jsonBody(request);
851
+ if (!isRecord(body))
852
+ return problem(400, "Invalid invitation");
853
+ if (typeof body.email !== "string" || !EMAIL.test(body.email)) {
854
+ return invalid("email", "Enter a valid email address");
855
+ }
856
+ const slugRefusal = slugProblem(body.role);
857
+ if (slugRefusal)
858
+ return invalid("role", slugRefusal);
859
+ if (body.return_to !== undefined &&
860
+ (typeof body.return_to !== "string" || body.return_to.length === 0)) {
861
+ return invalid("return_to", "return_to must be a non-empty string");
862
+ }
863
+ const email = body.email.toLowerCase();
864
+ const role = body.role;
865
+ const refusal = ownerTierRefusal(organization, caller, role);
866
+ if (refusal)
867
+ return refusal;
868
+ const returnTo = typeof body.return_to === "string"
869
+ ? resolveReturnTo(body.return_to)
870
+ : null;
871
+ if (typeof body.return_to === "string" && !returnTo) {
872
+ return invalid("return_to", "return_to must be a path on this tenant's host or an absolute URL on the origin of a redirect URI registered on one of its live applications");
873
+ }
874
+ const offered = [...invitations.values()].find((invitation) => invitation.organizationId === organization.id &&
875
+ invitation.email === email && invitation.role === role &&
876
+ !invitation.acceptedAt && !invitation.deletedAt);
877
+ if (offered) {
878
+ return problem(409, `${email} was already invited to the ${role} role. Withdraw that invitation before sending another.`);
879
+ }
880
+ const undefinedRole = roleVocabulary().some((entry) => entry.slug === role)
881
+ ? undefined
882
+ : invalid("role", `The role "${role}" is not defined`);
883
+ const subject = userByEmail(email);
884
+ if (subject) {
885
+ if (grantOf(organization, subject.id, role)) {
886
+ return problem(409, `That person already holds the ${role} role.`, {
887
+ constraint: "organization_memberships_unique",
888
+ });
889
+ }
890
+ if (undefinedRole)
891
+ return undefinedRole;
892
+ const membership = newGrant(organization, subject.id, role, {
893
+ invitedByUserId: caller.user.id,
894
+ acceptedAt: null,
895
+ returnTo,
896
+ });
897
+ return Response.json({ status: "membership", membership: grantJson(membership) }, { status: 201 });
898
+ }
899
+ if (undefinedRole)
900
+ return undefinedRole;
901
+ const now = new Date();
902
+ const invitation = {
903
+ id: crypto.randomUUID(),
904
+ organizationId: organization.id,
905
+ email,
906
+ role,
907
+ invitedByUserId: caller.user.id,
908
+ expiresAt: new Date(now.getTime() + INVITATION_LIFETIME_MS),
909
+ acceptedAt: null,
910
+ acceptedByUserId: null,
911
+ returnTo,
912
+ createdAt: now,
913
+ updatedAt: now,
914
+ deletedAt: null,
915
+ };
916
+ invitations.set(invitation.id, invitation);
917
+ return Response.json({ status: "invitation", invitation: invitationJson(invitation) }, { status: 201 });
918
+ });
919
+ const listInvitations = organizationApi((caller, request, [id]) => {
920
+ const organization = managedOrganization(caller, id);
921
+ if (organization instanceof Response)
922
+ return organization;
923
+ const status = new URL(request.url).searchParams.get("status") ?? "";
924
+ if (!["", "outstanding", "unaccepted"].includes(status)) {
925
+ return invalid("status", "status must be outstanding or unaccepted");
926
+ }
927
+ const now = Date.now();
928
+ return Response.json(page([...invitations.values()]
929
+ .filter((invitation) => invitation.organizationId === organization.id &&
930
+ !invitation.deletedAt &&
931
+ (status === "" || !invitation.acceptedAt) &&
932
+ (status !== "outstanding" || invitation.expiresAt.getTime() > now))
933
+ .map(invitationJson), request));
934
+ });
935
+ const withdrawInvitation = organizationApi((caller, _request, [id, invitationId]) => {
936
+ const organization = managedOrganization(caller, id);
937
+ if (organization instanceof Response)
938
+ return organization;
939
+ const invitation = invitations.get(invitationId);
940
+ if (!invitation || invitation.organizationId !== organization.id ||
941
+ invitation.acceptedAt || invitation.deletedAt) {
942
+ return problem(404, "Organization invitation not found");
943
+ }
944
+ invitation.deletedAt = new Date();
945
+ invitation.updatedAt = invitation.deletedAt;
946
+ return new Response(null, { status: 204 });
947
+ });
948
+ const pendingGrantsOf = (userId) => [...organizations.values()].flatMap((organization) => organization.grants.filter((grant) => grant.userId === userId && !grant.acceptedAt));
949
+ const listOffers = organizationApi((caller) => {
950
+ const { email, emailVerified = true } = caller.user;
951
+ const unverifiedEmail = email && !emailVerified ? email : null;
952
+ const address = email?.toLowerCase();
953
+ const now = Date.now();
954
+ const invited = address && !unverifiedEmail
955
+ ? [...invitations.values()].filter((invitation) => invitation.email === address && !invitation.acceptedAt &&
956
+ !invitation.deletedAt && invitation.expiresAt.getTime() > now)
957
+ : [];
958
+ return Response.json({
959
+ offers: [...pendingGrantsOf(caller.user.id), ...invited].flatMap((offer) => {
960
+ const organization = organizations.get(offer.organizationId);
961
+ return organization
962
+ ? [{
963
+ id: offer.id,
964
+ organizationName: organization.name,
965
+ roleName: roleName(offer.role),
966
+ }]
967
+ : [];
968
+ }),
969
+ unverifiedEmail,
970
+ });
971
+ });
972
+ const acceptOffer = organizationApi((caller, _request, [offerId]) => {
973
+ const invitation = invitations.get(offerId);
974
+ if (invitation && !invitation.acceptedAt && !invitation.deletedAt) {
975
+ const email = caller.user.email?.toLowerCase();
976
+ if (!email || caller.user.emailVerified === false ||
977
+ email !== invitation.email) {
978
+ return Response.json({ status: "wrong-account" });
979
+ }
980
+ if (invitation.expiresAt.getTime() <= Date.now()) {
981
+ return Response.json({ status: "expired" });
982
+ }
983
+ const organization = organizations.get(invitation.organizationId);
984
+ if (!organization)
985
+ return Response.json({ status: "invalid" });
986
+ invitation.acceptedAt = new Date();
987
+ invitation.acceptedByUserId = caller.user.id;
988
+ invitation.updatedAt = invitation.acceptedAt;
989
+ const membership = grantAccepted(organization, caller.user.id, invitation.role, invitation.invitedByUserId);
990
+ return Response.json({
991
+ status: "accepted",
992
+ membership: grantJson(membership),
993
+ });
994
+ }
995
+ const offered = pendingGrantsOf(caller.user.id).find((grant) => grant.id === offerId);
996
+ if (!offered)
997
+ return Response.json({ status: "invalid" });
998
+ return Response.json({
999
+ status: "accepted",
1000
+ membership: grantJson(acceptGrant(offered)),
1001
+ });
1002
+ });
1003
+ const accountApi = (handler) => async (request, params) => {
1004
+ const caller = await authenticated(request, { reason: "machine_token" });
1005
+ if (caller instanceof Response)
1006
+ return noStore(caller);
1007
+ return noStore(await handler(caller, request, params));
1008
+ };
1009
+ const readAccount = accountApi((caller) => Response.json({
1010
+ userMetadata: userMetadata.get(caller.user.id) ?? {},
1011
+ }));
1012
+ const patchAccount = accountApi(async (caller, request) => {
1013
+ const body = await jsonBody(request);
1014
+ if (!isRecord(body) || Object.keys(body).length !== 1 ||
1015
+ !("userMetadata" in body)) {
1016
+ return problem(400, "Only userMetadata may be written here");
1017
+ }
1018
+ const incoming = metadataProblem(body.userMetadata);
1019
+ if (incoming)
1020
+ return invalid("userMetadata", incoming);
1021
+ const merged = { ...userMetadata.get(caller.user.id) ?? {} };
1022
+ for (const [key, value] of Object.entries(body.userMetadata)) {
1023
+ if (value === null)
1024
+ delete merged[key];
1025
+ else
1026
+ merged[key] = value;
1027
+ }
1028
+ const result = metadataProblem(merged);
1029
+ if (result)
1030
+ return invalid("userMetadata", result);
1031
+ userMetadata.set(caller.user.id, merged);
1032
+ return Response.json({ userMetadata: merged });
1033
+ });
1034
+ const currentSessionOf = (caller) => {
1035
+ const sessionId = credentials.get(caller.accessToken)?.sessionId;
1036
+ return sessionId && loginSessions.has(sessionId) ? sessionId : null;
1037
+ };
1038
+ const listSessions = accountApi((caller) => {
1039
+ const current = currentSessionOf(caller);
1040
+ return Response.json({
1041
+ sessions: [...loginSessions.values()]
1042
+ .filter((session) => session.userId === caller.user.id)
1043
+ .sort((a, b) => Number(b.id === current) - Number(a.id === current) ||
1044
+ b.lastActiveAt.getTime() - a.lastActiveAt.getTime())
1045
+ .map((session) => ({
1046
+ id: session.id,
1047
+ createdAt: iso(session.createdAt),
1048
+ lastActiveAt: iso(session.lastActiveAt),
1049
+ ipAddress: session.ipAddress,
1050
+ userAgent: session.userAgent,
1051
+ current: session.id === current,
1052
+ })),
1053
+ });
1054
+ });
1055
+ const revokeSession = accountApi(async (caller, request, [sessionId]) => {
1056
+ if (!await isEmptyBody(request)) {
1057
+ return problem(400, "The request body must be empty");
1058
+ }
1059
+ if (sessionId === currentSessionOf(caller)) {
1060
+ return problem(409, "This is your current session — use sign out instead.", {
1061
+ reason: "current_session",
1062
+ });
1063
+ }
1064
+ if (loginSessions.get(sessionId)?.userId !== caller.user.id) {
1065
+ return problem(404, "Session not found.");
1066
+ }
1067
+ await endSession(sessionId);
1068
+ return new Response(null, { status: 204 });
1069
+ });
1070
+ const revokeOtherSessions = accountApi(async (caller, request) => {
1071
+ if (!await isEmptyBody(request)) {
1072
+ return problem(400, "The request body must be empty");
1073
+ }
1074
+ const current = currentSessionOf(caller);
1075
+ if (!current) {
1076
+ return problem(409, "Session not found.", {
1077
+ reason: "current_session_required",
1078
+ });
1079
+ }
1080
+ const others = [...loginSessions.values()].filter((session) => session.userId === caller.user.id && session.id !== current);
1081
+ for (const session of others)
1082
+ await endSession(session.id);
1083
+ return Response.json({ revoked: others.length });
1084
+ });
1085
+ const linkedAccountsOf = (userId) => [...linkedAccounts.values()].filter((account) => account.userId === userId);
1086
+ const listLinkedAccounts = accountApi((caller) => Response.json({
1087
+ identities: linkedAccountsOf(caller.user.id).map((account) => ({
1088
+ id: account.id,
1089
+ provider: account.provider,
1090
+ displayName: account.displayName,
1091
+ email: account.email,
1092
+ createdAt: iso(account.createdAt),
1093
+ })),
1094
+ hasPassword: caller.user.hasPassword ?? true,
1095
+ connectable: [],
1096
+ }));
1097
+ const unlinkAccount = accountApi(async (caller, request, [identityId]) => {
1098
+ if (!await isEmptyBody(request)) {
1099
+ return problem(400, "Only an empty request body is accepted");
1100
+ }
1101
+ const account = linkedAccounts.get(identityId);
1102
+ if (account?.userId !== caller.user.id) {
1103
+ return problem(404, "Connected account not found.");
1104
+ }
1105
+ const othersRemain = linkedAccountsOf(caller.user.id).length > 1;
1106
+ if (!othersRemain && !(caller.user.hasPassword ?? true)) {
1107
+ return problem(409, "This is your only way to sign in. Set a password or connect another provider before disconnecting it.", { reason: "last_method" });
1108
+ }
1109
+ linkedAccounts.delete(account.id);
1110
+ return new Response(null, { status: 204 });
1111
+ });
336
1112
  const routes = {
337
1113
  "GET /.well-known/oauth-authorization-server": (r) => server.handleMetadataRequest(r),
338
1114
  "GET /.well-known/openid-configuration": (r) => server.handleOidcMetadataRequest(r),
339
1115
  [`GET ${PATHS.authorization}`]: authorize,
340
1116
  [`POST ${PATHS.token}`]: token,
341
- [`POST ${PATHS.revocation}`]: (r) => server.handleRevocationRequest(r),
1117
+ [`POST ${PATHS.revocation}`]: revocation,
342
1118
  [`POST ${PATHS.introspection}`]: (r) => server.handleIntrospectionRequest(r),
343
1119
  [`GET ${PATHS.userinfo}`]: (r) => server.handleUserInfoRequest(r),
344
1120
  [`POST ${PATHS.userinfo}`]: (r) => server.handleUserInfoRequest(r),
@@ -347,31 +1123,133 @@ export async function createFakeTenant(options) {
347
1123
  "POST /api/check": check,
348
1124
  "POST /api/check/batch": checkBatch,
349
1125
  };
1126
+ const segment = "([^/]+)";
1127
+ const patterns = [
1128
+ ["GET", /^\/api\/organizations$/, listOrganizations],
1129
+ ["POST", /^\/api\/organizations$/, createOrganization],
1130
+ ["GET", /^\/api\/organizations\/offers$/, listOffers],
1131
+ [
1132
+ "POST",
1133
+ new RegExp(`^/api/organizations/offers/${segment}/accept$`),
1134
+ acceptOffer,
1135
+ ],
1136
+ ["GET", new RegExp(`^/api/organizations/${segment}$`), getOrganization],
1137
+ ["PATCH", new RegExp(`^/api/organizations/${segment}$`), patchOrganization],
1138
+ [
1139
+ "DELETE",
1140
+ new RegExp(`^/api/organizations/${segment}$`),
1141
+ deleteOrganization,
1142
+ ],
1143
+ [
1144
+ "GET",
1145
+ new RegExp(`^/api/organizations/${segment}/members$`),
1146
+ listMembers,
1147
+ ],
1148
+ [
1149
+ "GET",
1150
+ new RegExp(`^/api/organizations/${segment}/member-roles$`),
1151
+ listMemberRoles,
1152
+ ],
1153
+ [
1154
+ "DELETE",
1155
+ new RegExp(`^/api/organizations/${segment}/members/${segment}/${segment}$`),
1156
+ revokeMembership,
1157
+ ],
1158
+ [
1159
+ "POST",
1160
+ new RegExp(`^/api/organizations/${segment}/invitations$`),
1161
+ invite,
1162
+ ],
1163
+ [
1164
+ "GET",
1165
+ new RegExp(`^/api/organizations/${segment}/invitations$`),
1166
+ listInvitations,
1167
+ ],
1168
+ [
1169
+ "DELETE",
1170
+ new RegExp(`^/api/organizations/${segment}/invitations/${segment}$`),
1171
+ withdrawInvitation,
1172
+ ],
1173
+ ["GET", /^\/api\/account$/, readAccount],
1174
+ ["PATCH", /^\/api\/account$/, patchAccount],
1175
+ ["GET", /^\/api\/account\/sessions$/, listSessions],
1176
+ [
1177
+ "POST",
1178
+ /^\/api\/account\/sessions\/revoke-others$/,
1179
+ revokeOtherSessions,
1180
+ ],
1181
+ [
1182
+ "DELETE",
1183
+ new RegExp(`^/api/account/sessions/${segment}$`),
1184
+ revokeSession,
1185
+ ],
1186
+ ["GET", /^\/api\/account\/linked-accounts$/, listLinkedAccounts],
1187
+ [
1188
+ "DELETE",
1189
+ new RegExp(`^/api/account/linked-accounts/${segment}$`),
1190
+ unlinkAccount,
1191
+ ],
1192
+ ];
1193
+ const decoded = (segments) => {
1194
+ try {
1195
+ return segments.map(decodeURIComponent);
1196
+ }
1197
+ catch {
1198
+ return undefined;
1199
+ }
1200
+ };
1201
+ const route = (request) => {
1202
+ const { pathname } = new URL(request.url);
1203
+ const exact = routes[`${request.method} ${pathname}`];
1204
+ if (exact)
1205
+ return exact(request);
1206
+ for (const [method, pattern, handler] of patterns) {
1207
+ if (method !== request.method)
1208
+ continue;
1209
+ const match = pattern.exec(pathname);
1210
+ if (!match)
1211
+ continue;
1212
+ const params = decoded(match.slice(1));
1213
+ return params ? handler(request, params) : undefined;
1214
+ }
1215
+ return undefined;
1216
+ };
350
1217
  return {
351
1218
  issuer,
352
- fetch: (request) => {
353
- const route = routes[`${request.method} ${new URL(request.url).pathname}`];
354
- return route
355
- ? route(request)
356
- : Promise.resolve(problem(404, "Not found"));
357
- },
1219
+ fetch: (request) => route(request) ?? Promise.resolve(problem(404, "Not found")),
358
1220
  addClient: async ({ secret, ...client }) => {
359
1221
  await clientService.add({
360
1222
  grants: ["authorization_code", "refresh_token"],
361
1223
  ...client,
362
1224
  }, secret);
1225
+ for (const uri of client.redirectUris ?? []) {
1226
+ try {
1227
+ redirectOrigins.add(new URL(uri).origin);
1228
+ }
1229
+ catch {
1230
+ continue;
1231
+ }
1232
+ }
363
1233
  },
364
1234
  addUser: async (user) => {
365
1235
  await userService.add(user, crypto.randomUUID());
366
1236
  users.set(user.id, user);
1237
+ userMetadata.set(user.id, { ...user.userMetadata ?? {} });
367
1238
  },
368
1239
  addOrganization: (organization) => {
369
1240
  if (organizations.has(organization.id)) {
370
1241
  throw new Error(`Organization "${organization.id}" already exists`);
371
1242
  }
1243
+ const now = new Date();
372
1244
  organizations.set(organization.id, {
373
- ...organization,
374
- members: new Map(),
1245
+ id: organization.id,
1246
+ slug: organization.slug,
1247
+ name: organization.name ?? organization.slug,
1248
+ metadata: {},
1249
+ createdAt: now,
1250
+ updatedAt: now,
1251
+ grants: [],
1252
+ permissions: new Map(),
375
1253
  });
376
1254
  },
377
1255
  addMember: (organizationId, userId, membership = {}) => {
@@ -379,10 +1257,29 @@ export async function createFakeTenant(options) {
379
1257
  if (!organization) {
380
1258
  throw new Error(`Organization "${organizationId}" does not exist`);
381
1259
  }
382
- organization.members.set(userId, membership);
1260
+ organization.grants = organization.grants.filter((grant) => grant.userId !== userId);
1261
+ const roles = membership.roles?.length ? membership.roles : DEFAULT_ROLES;
1262
+ for (const role of new Set(roles)) {
1263
+ newGrant(organization, userId, role, {
1264
+ invitedByUserId: null,
1265
+ acceptedAt: new Date(),
1266
+ returnTo: null,
1267
+ });
1268
+ }
1269
+ organization.permissions.set(userId, membership.permissions ?? []);
383
1270
  },
384
1271
  removeMember: (organizationId, userId) => {
385
- organizations.get(organizationId)?.members.delete(userId);
1272
+ const organization = organizations.get(organizationId);
1273
+ if (!organization)
1274
+ return;
1275
+ organization.grants = organization.grants.filter((grant) => grant.userId !== userId);
1276
+ organization.permissions.delete(userId);
1277
+ },
1278
+ defineOrganizationRole: ({ slug, name }) => {
1279
+ if (BUILT_IN_ROLES.some((role) => role.slug === slug)) {
1280
+ throw new Error(`"${slug}" is a built-in organization role`);
1281
+ }
1282
+ definedRoles.set(slug, name ?? slug);
386
1283
  },
387
1284
  registerResourceType: (type) => {
388
1285
  resourceTypes.add(type);
@@ -390,8 +1287,23 @@ export async function createFakeTenant(options) {
390
1287
  grant: (grant) => {
391
1288
  grants.push(grant);
392
1289
  },
1290
+ linkAccount: (userId, account) => {
1291
+ if (!users.has(userId)) {
1292
+ throw new Error(`User "${userId}" does not exist`);
1293
+ }
1294
+ const linked = {
1295
+ id: crypto.randomUUID(),
1296
+ userId,
1297
+ provider: account.provider,
1298
+ displayName: account.displayName ?? account.provider,
1299
+ email: account.email ?? null,
1300
+ createdAt: new Date(),
1301
+ };
1302
+ linkedAccounts.set(linked.id, linked);
1303
+ return linked.id;
1304
+ },
393
1305
  signInAs: (userId, signIn = {}) => {
394
- signedIn = userId ? { userId, ...signIn } : null;
1306
+ signedIn = userId ? { ...signIn, userId } : null;
395
1307
  },
396
1308
  issueAccessToken: async (request) => {
397
1309
  const client = await clientService.get(request.clientId);
@@ -402,7 +1314,9 @@ export async function createFakeTenant(options) {
402
1314
  if (!user)
403
1315
  throw new Error(`User "${request.userId}" does not exist`);
404
1316
  const scope = new BasicScope(request.scope ?? "openid");
405
- return await withPendingOrganization(user.id, request.organizationId ?? soleOrganizationOf(user.id), async () => {
1317
+ return await withIssuance(user.id, {
1318
+ organizationId: request.organizationId ?? soleOrganizationOf(user.id),
1319
+ }, async () => {
406
1320
  const accessToken = await tokenService.generateAccessToken(client, user, scope);
407
1321
  await tokenService.save({
408
1322
  client,