@udibo/oauth2 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/_dnt.polyfills.d.ts +14 -0
- package/esm/_dnt.polyfills.js +11 -0
- package/esm/adapters/hono/bff/bff.d.ts +43 -1
- package/esm/adapters/hono/bff/bff.js +67 -9
- package/esm/testing/contract/mod.d.ts +1 -1
- package/esm/testing/contract/tenant.d.ts +49 -9
- package/esm/testing/contract/tenant.js +631 -5
- package/esm/testing/mod.d.ts +6 -5
- package/esm/testing/mod.js +5 -4
- package/esm/testing/tenant.d.ts +88 -16
- package/esm/testing/tenant.js +971 -57
- package/package.json +1 -1
package/esm/testing/tenant.js
CHANGED
|
@@ -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
|
-
|
|
38
|
+
issuanceOf;
|
|
20
39
|
recordCredential;
|
|
21
40
|
mintJwt;
|
|
22
|
-
constructor(options,
|
|
41
|
+
constructor(options, issuanceOf, recordCredential, mintJwt) {
|
|
23
42
|
super(options);
|
|
24
|
-
this.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
...
|
|
239
|
+
...isMember(organizationId, userId)
|
|
240
|
+
? organizations.get(organizationId).permissions.get(userId) ?? []
|
|
241
|
+
: [],
|
|
88
242
|
]);
|
|
89
243
|
const organizationClaims = (userId, organizationId) => {
|
|
90
|
-
|
|
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:
|
|
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) =>
|
|
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,
|
|
310
|
+
? authorizationClaims(token.user.id, credentials.get(token.accessToken)?.organizationId)
|
|
153
311
|
: {},
|
|
154
312
|
});
|
|
155
|
-
const
|
|
313
|
+
const withIssuance = async (userId, issuance, work) => {
|
|
156
314
|
if (!userId)
|
|
157
315
|
return await work();
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
342
|
+
const user = users.get(token.user.id) ?? token.user;
|
|
343
|
+
const organizationId = credentials.get(token.accessToken)?.organizationId;
|
|
178
344
|
return {
|
|
179
|
-
user
|
|
180
|
-
organizationId:
|
|
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
|
-
:
|
|
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,
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
291
|
-
roles: organization.
|
|
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
|
|
307
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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}`]:
|
|
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
|
-
|
|
374
|
-
|
|
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.
|
|
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)
|
|
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 ? {
|
|
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
|
|
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,
|