@udibo/oauth2 0.9.2 → 0.11.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/testing/contract/identity-session.d.ts +54 -0
- package/esm/testing/contract/identity-session.js +165 -0
- package/esm/testing/contract/identity-user-store.d.ts +52 -0
- package/esm/testing/contract/identity-user-store.js +176 -0
- package/esm/testing/contract/mod.d.ts +7 -1
- package/esm/testing/contract/mod.js +6 -0
- package/esm/testing/contract/tenant.d.ts +44 -1
- package/esm/testing/contract/tenant.js +253 -1
- package/esm/testing/mod.d.ts +8 -6
- package/esm/testing/mod.js +7 -5
- package/esm/testing/tenant.d.ts +55 -4
- package/esm/testing/tenant.js +190 -17
- package/package.json +1 -1
package/esm/testing/tenant.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { AuthorizationCodeGrant } from "../server/grants/authorization-code.js";
|
|
2
|
+
import { ClientCredentialsGrant, } from "../server/grants/client-credentials.js";
|
|
2
3
|
import { RefreshTokenGrant } from "../server/grants/refresh-token.js";
|
|
3
4
|
import { AuthorizationServer } from "../server/authorization-server.js";
|
|
4
5
|
import { createJwtAccessTokenGenerator, generateSigningKey, StaticSigningKeyProvider, } from "../server/signing-keys.js";
|
|
5
6
|
import { BasicScope } from "../models/scope.js";
|
|
6
|
-
import { InvalidRequestError } from "../errors.js";
|
|
7
|
+
import { AccessDeniedError, InvalidClientError, InvalidRequestError, } from "../errors.js";
|
|
7
8
|
import { safeReturnTo } from "../utils/url.js";
|
|
8
9
|
import { MemoryAuthorizationCodeService, MemoryClientService, MemoryTokenService, MemoryUserService, } from "./services.js";
|
|
9
10
|
const PATHS = {
|
|
@@ -30,10 +31,56 @@ const BUILT_IN_ROLES = [
|
|
|
30
31
|
{ slug: "admin", name: "Admin" },
|
|
31
32
|
{ slug: "member", name: "Member" },
|
|
32
33
|
];
|
|
34
|
+
const ORGANIZATIONS_READ = "identity:organizations:read";
|
|
35
|
+
const MACHINE_SCOPES = [
|
|
36
|
+
ORGANIZATIONS_READ,
|
|
37
|
+
"identity:organizations:write",
|
|
38
|
+
];
|
|
39
|
+
const OIDC_SCOPES = ["openid", "profile", "email"];
|
|
40
|
+
const RESOURCE_GRANTS_READ = "resource_grants.read";
|
|
41
|
+
const RESOURCE_TYPE_MAX_LENGTH = 64;
|
|
42
|
+
const RESOURCE_ID_MAX_LENGTH = 255;
|
|
43
|
+
// deno-lint-ignore no-control-regex
|
|
44
|
+
const ASCII_CONTROL_CHARACTERS = /[\x00-\x1f\x7f]/;
|
|
45
|
+
const UNSTATED_ALLOWLIST = "A client_credentials application has to state the scopes its tokens may " +
|
|
46
|
+
"be granted: pass `scopes`, an empty list to deny every scope.";
|
|
47
|
+
const ORGANIZATION_ID_REFUSAL = "organization_id is only accepted on the refresh_token grant";
|
|
48
|
+
const USER_TOKEN_REFUSAL = "Resource grants are placed and read by your application, not by the person " +
|
|
49
|
+
"using it. Call this with your application's own client credentials after " +
|
|
50
|
+
"deciding, in your own logic, that the share is allowed.";
|
|
51
|
+
const MACHINE_TOKEN_REFUSAL = "A machine credential has no self to answer for.";
|
|
33
52
|
const OWNER = "owner";
|
|
34
53
|
const MANAGER_ROLES = ["owner", "admin"];
|
|
35
54
|
const DEFAULT_ROLES = ["member"];
|
|
36
55
|
const ORGANIZATION_NOT_FOUND = "Organization not found";
|
|
56
|
+
class ConfidentialClientCredentialsGrant extends ClientCredentialsGrant {
|
|
57
|
+
isConfidential;
|
|
58
|
+
constructor(options, isConfidential) {
|
|
59
|
+
super(options);
|
|
60
|
+
this.isConfidential = isConfidential;
|
|
61
|
+
}
|
|
62
|
+
async getAuthenticatedClient(request, body) {
|
|
63
|
+
const client = await super.getAuthenticatedClient(request, body);
|
|
64
|
+
if (!this.isConfidential(client)) {
|
|
65
|
+
throw new InvalidClientError("client authentication failed");
|
|
66
|
+
}
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function machineScopeCeiling(client) {
|
|
71
|
+
return client.scopes?.filter((scope) => MACHINE_SCOPES.includes(scope)) ??
|
|
72
|
+
[];
|
|
73
|
+
}
|
|
74
|
+
function isBuiltInRole(slug) {
|
|
75
|
+
return BUILT_IN_ROLES.some((role) => role.slug === slug);
|
|
76
|
+
}
|
|
77
|
+
function roleSlugOf(permissions) {
|
|
78
|
+
const slug = permissions
|
|
79
|
+
.map((permission) => permission.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""))
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join("-");
|
|
82
|
+
return slug || "grant";
|
|
83
|
+
}
|
|
37
84
|
class TenantTokenService extends MemoryTokenService {
|
|
38
85
|
issuanceOf;
|
|
39
86
|
recordCredential;
|
|
@@ -44,9 +91,19 @@ class TenantTokenService extends MemoryTokenService {
|
|
|
44
91
|
this.recordCredential = recordCredential;
|
|
45
92
|
this.mintJwt = mintJwt;
|
|
46
93
|
}
|
|
94
|
+
acceptedScope(client, user, scope) {
|
|
95
|
+
if (!scope || user)
|
|
96
|
+
return Promise.resolve(scope);
|
|
97
|
+
const requested = [...scope];
|
|
98
|
+
if (requested.some((name) => OIDC_SCOPES.includes(name))) {
|
|
99
|
+
return Promise.resolve(false);
|
|
100
|
+
}
|
|
101
|
+
const ceiling = machineScopeCeiling(client);
|
|
102
|
+
return Promise.resolve(requested.every((name) => ceiling.includes(name)) ? scope : false);
|
|
103
|
+
}
|
|
47
104
|
async generateAccessToken(client, user, scope) {
|
|
48
105
|
const issuance = (user ? this.issuanceOf(user.id) : undefined) ?? {};
|
|
49
|
-
const accessToken = client.accessTokenFormat === "jwt"
|
|
106
|
+
const accessToken = client.accessTokenFormat === "jwt"
|
|
50
107
|
? await this.mintJwt(client, user, scope, issuance.organizationId)
|
|
51
108
|
: await super.generateAccessToken(client, user, scope);
|
|
52
109
|
this.recordCredential(accessToken, issuance, client);
|
|
@@ -220,6 +277,7 @@ export async function createFakeTenant(options) {
|
|
|
220
277
|
const invitations = new Map();
|
|
221
278
|
const resourceTypes = new Set();
|
|
222
279
|
const grants = [];
|
|
280
|
+
const resourceRoleIds = new Map();
|
|
223
281
|
const loginSessions = new Map();
|
|
224
282
|
const linkedAccounts = new Map();
|
|
225
283
|
const credentials = new Map();
|
|
@@ -257,6 +315,8 @@ export async function createFakeTenant(options) {
|
|
|
257
315
|
};
|
|
258
316
|
};
|
|
259
317
|
const userService = new MemoryUserService();
|
|
318
|
+
const confidentialClients = new Set();
|
|
319
|
+
const isConfidential = (client) => confidentialClients.has(client.id);
|
|
260
320
|
const clientService = new MemoryClientService(userService);
|
|
261
321
|
const tokenService = new TenantTokenService({ clientService, userService }, (userId) => pendingIssuance.get(userId), (accessToken, issuance, client) => credentials.set(accessToken, {
|
|
262
322
|
...issuance,
|
|
@@ -267,10 +327,12 @@ export async function createFakeTenant(options) {
|
|
|
267
327
|
signingKeys,
|
|
268
328
|
issuer,
|
|
269
329
|
audience: client.audience ?? client.id,
|
|
270
|
-
userClaims:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
330
|
+
userClaims: user
|
|
331
|
+
? () => ({
|
|
332
|
+
username: user.username,
|
|
333
|
+
...authorizationClaims(user.id, organizationId),
|
|
334
|
+
})
|
|
335
|
+
: undefined,
|
|
274
336
|
})(client, user, scope));
|
|
275
337
|
const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
|
|
276
338
|
const server = new AuthorizationServer({
|
|
@@ -296,9 +358,16 @@ export async function createFakeTenant(options) {
|
|
|
296
358
|
refresh_token: new RefreshTokenGrant({
|
|
297
359
|
resolve: () => ({ clientService, tokenService }),
|
|
298
360
|
}),
|
|
361
|
+
client_credentials: new ConfidentialClientCredentialsGrant({ resolve: () => ({ clientService, tokenService }) }, isConfidential),
|
|
299
362
|
},
|
|
300
363
|
signingKeys,
|
|
301
|
-
scopesSupported: [
|
|
364
|
+
scopesSupported: [
|
|
365
|
+
"openid",
|
|
366
|
+
"profile",
|
|
367
|
+
"email",
|
|
368
|
+
"offline_access",
|
|
369
|
+
...MACHINE_SCOPES,
|
|
370
|
+
],
|
|
302
371
|
userClaims: (user) => ({
|
|
303
372
|
preferred_username: user.username,
|
|
304
373
|
name: user.name,
|
|
@@ -306,10 +375,12 @@ export async function createFakeTenant(options) {
|
|
|
306
375
|
email_verified: user.email ? user.emailVerified ?? true : undefined,
|
|
307
376
|
...organizationClaims(user.id, signedIn?.userId === user.id ? signedIn.organizationId : undefined),
|
|
308
377
|
}),
|
|
378
|
+
canIntrospectToken: (client, token) => isConfidential(client) && client.id === token.client.id,
|
|
309
379
|
introspectionClaims: (token) => token.user
|
|
310
380
|
? authorizationClaims(token.user.id, credentials.get(token.accessToken)?.organizationId)
|
|
311
381
|
: {},
|
|
312
382
|
});
|
|
383
|
+
const isMachineToken = (token) => !token.user;
|
|
313
384
|
const withIssuance = async (userId, issuance, work) => {
|
|
314
385
|
if (!userId)
|
|
315
386
|
return await work();
|
|
@@ -337,7 +408,7 @@ export async function createFakeTenant(options) {
|
|
|
337
408
|
return server.handleAuthError(error);
|
|
338
409
|
}
|
|
339
410
|
if (!token.user) {
|
|
340
|
-
return problem(403,
|
|
411
|
+
return problem(403, MACHINE_TOKEN_REFUSAL, machineRefusal);
|
|
341
412
|
}
|
|
342
413
|
const user = users.get(token.user.id) ?? token.user;
|
|
343
414
|
const organizationId = credentials.get(token.accessToken)?.organizationId;
|
|
@@ -363,8 +434,9 @@ export async function createFakeTenant(options) {
|
|
|
363
434
|
return held;
|
|
364
435
|
};
|
|
365
436
|
const answer = (asked, held) => Object.fromEntries(asked.map((p) => [p, held.has(p)]));
|
|
437
|
+
const personOnly = { reason: "machine_token" };
|
|
366
438
|
const check = async (request) => {
|
|
367
|
-
const caller = await authenticated(request);
|
|
439
|
+
const caller = await authenticated(request, personOnly);
|
|
368
440
|
if (caller instanceof Response)
|
|
369
441
|
return caller;
|
|
370
442
|
const body = await request.json().catch(() => null);
|
|
@@ -404,7 +476,7 @@ export async function createFakeTenant(options) {
|
|
|
404
476
|
});
|
|
405
477
|
};
|
|
406
478
|
const checkBatch = async (request) => {
|
|
407
|
-
const caller = await authenticated(request);
|
|
479
|
+
const caller = await authenticated(request, personOnly);
|
|
408
480
|
if (caller instanceof Response)
|
|
409
481
|
return caller;
|
|
410
482
|
const body = await request.json().catch(() => null);
|
|
@@ -445,7 +517,7 @@ export async function createFakeTenant(options) {
|
|
|
445
517
|
return organization.id;
|
|
446
518
|
};
|
|
447
519
|
const memberships = async (request) => {
|
|
448
|
-
const caller = await authenticated(request);
|
|
520
|
+
const caller = await authenticated(request, personOnly);
|
|
449
521
|
if (caller instanceof Response)
|
|
450
522
|
return caller;
|
|
451
523
|
return Response.json({
|
|
@@ -540,6 +612,17 @@ export async function createFakeTenant(options) {
|
|
|
540
612
|
};
|
|
541
613
|
const token = async (request) => {
|
|
542
614
|
const form = await request.clone().formData().catch(() => null);
|
|
615
|
+
const organizationId = form?.get("organization_id");
|
|
616
|
+
if (typeof organizationId === "string" && organizationId.length > 0 &&
|
|
617
|
+
form?.get("grant_type") !== "refresh_token") {
|
|
618
|
+
return Response.json({
|
|
619
|
+
error: "invalid_request",
|
|
620
|
+
error_description: ORGANIZATION_ID_REFUSAL,
|
|
621
|
+
}, {
|
|
622
|
+
status: 400,
|
|
623
|
+
headers: { "cache-control": "no-store", pragma: "no-cache" },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
543
626
|
const code = form?.get("code");
|
|
544
627
|
const refreshToken = form?.get("refresh_token");
|
|
545
628
|
const signIn = typeof code === "string"
|
|
@@ -1001,11 +1084,23 @@ export async function createFakeTenant(options) {
|
|
|
1001
1084
|
});
|
|
1002
1085
|
});
|
|
1003
1086
|
const accountApi = (handler) => async (request, params) => {
|
|
1004
|
-
const caller = await authenticated(request,
|
|
1087
|
+
const caller = await authenticated(request, personOnly);
|
|
1005
1088
|
if (caller instanceof Response)
|
|
1006
1089
|
return noStore(caller);
|
|
1007
1090
|
return noStore(await handler(caller, request, params));
|
|
1008
1091
|
};
|
|
1092
|
+
const userinfo = async (request) => {
|
|
1093
|
+
try {
|
|
1094
|
+
const { token } = await server.authenticate(request);
|
|
1095
|
+
if (isMachineToken(token)) {
|
|
1096
|
+
throw new AccessDeniedError("client credentials have no end user");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
return server.handleAuthError(error);
|
|
1101
|
+
}
|
|
1102
|
+
return await server.handleUserInfoRequest(request);
|
|
1103
|
+
};
|
|
1009
1104
|
const readAccount = accountApi((caller) => Response.json({
|
|
1010
1105
|
userMetadata: userMetadata.get(caller.user.id) ?? {},
|
|
1011
1106
|
}));
|
|
@@ -1109,6 +1204,70 @@ export async function createFakeTenant(options) {
|
|
|
1109
1204
|
linkedAccounts.delete(account.id);
|
|
1110
1205
|
return new Response(null, { status: 204 });
|
|
1111
1206
|
});
|
|
1207
|
+
const machineCaller = async (request) => {
|
|
1208
|
+
let token;
|
|
1209
|
+
try {
|
|
1210
|
+
({ token } = await server.authenticate(request));
|
|
1211
|
+
}
|
|
1212
|
+
catch (error) {
|
|
1213
|
+
return server.handleAuthError(error);
|
|
1214
|
+
}
|
|
1215
|
+
if (!isMachineToken(token)) {
|
|
1216
|
+
return problem(403, USER_TOKEN_REFUSAL, { reason: "user_token" });
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
client: token.client,
|
|
1220
|
+
scopes: token.scope ? [...token.scope] : [],
|
|
1221
|
+
};
|
|
1222
|
+
};
|
|
1223
|
+
const resourceGrantJson = (grant) => {
|
|
1224
|
+
const holder = grant.subject.type === "user"
|
|
1225
|
+
? users.get(grant.subject.id)
|
|
1226
|
+
: undefined;
|
|
1227
|
+
const organization = grant.subject.type === "organization"
|
|
1228
|
+
? organizations.get(grant.subject.id)
|
|
1229
|
+
: undefined;
|
|
1230
|
+
return {
|
|
1231
|
+
id: grant.id,
|
|
1232
|
+
subjectType: grant.subject.type,
|
|
1233
|
+
subjectId: grant.subject.id,
|
|
1234
|
+
subjectRole: null,
|
|
1235
|
+
subjectName: holder
|
|
1236
|
+
? holder.name ?? holder.username
|
|
1237
|
+
: organization?.name ?? null,
|
|
1238
|
+
subjectActive: holder !== undefined || organization !== undefined,
|
|
1239
|
+
roleId: grant.roleId,
|
|
1240
|
+
builtInRole: isBuiltInRole(grant.role) ? grant.role : null,
|
|
1241
|
+
roleSlug: grant.role,
|
|
1242
|
+
roleName: roleName(grant.role),
|
|
1243
|
+
};
|
|
1244
|
+
};
|
|
1245
|
+
const listResourceGrants = async (request) => {
|
|
1246
|
+
const caller = await machineCaller(request);
|
|
1247
|
+
if (caller instanceof Response)
|
|
1248
|
+
return noStore(caller);
|
|
1249
|
+
if (!caller.scopes.includes(ORGANIZATIONS_READ) ||
|
|
1250
|
+
!caller.client.machinePermissions?.includes(RESOURCE_GRANTS_READ)) {
|
|
1251
|
+
return noStore(problem(404, "Not found"));
|
|
1252
|
+
}
|
|
1253
|
+
const params = new URL(request.url).searchParams;
|
|
1254
|
+
const type = params.get("type");
|
|
1255
|
+
const id = params.get("id");
|
|
1256
|
+
if (!type || type.length > RESOURCE_TYPE_MAX_LENGTH || !id ||
|
|
1257
|
+
id.length > RESOURCE_ID_MAX_LENGTH || ASCII_CONTROL_CHARACTERS.test(id)) {
|
|
1258
|
+
return noStore(invalid("resource", "Pass the resource as `type` and `id` query parameters."));
|
|
1259
|
+
}
|
|
1260
|
+
if (!resourceTypes.has(type)) {
|
|
1261
|
+
return noStore(invalid("type", `No resource type "${type}" is registered for this tenant.`));
|
|
1262
|
+
}
|
|
1263
|
+
return noStore(Response.json({
|
|
1264
|
+
resource: { type, id },
|
|
1265
|
+
grants: grants
|
|
1266
|
+
.filter((grant) => grant.resource.type === type && grant.resource.id === id)
|
|
1267
|
+
.map(resourceGrantJson)
|
|
1268
|
+
.sort((a, b) => a.roleName.localeCompare(b.roleName)),
|
|
1269
|
+
}));
|
|
1270
|
+
};
|
|
1112
1271
|
const routes = {
|
|
1113
1272
|
"GET /.well-known/oauth-authorization-server": (r) => server.handleMetadataRequest(r),
|
|
1114
1273
|
"GET /.well-known/openid-configuration": (r) => server.handleOidcMetadataRequest(r),
|
|
@@ -1116,12 +1275,13 @@ export async function createFakeTenant(options) {
|
|
|
1116
1275
|
[`POST ${PATHS.token}`]: token,
|
|
1117
1276
|
[`POST ${PATHS.revocation}`]: revocation,
|
|
1118
1277
|
[`POST ${PATHS.introspection}`]: (r) => server.handleIntrospectionRequest(r),
|
|
1119
|
-
[`GET ${PATHS.userinfo}`]:
|
|
1120
|
-
[`POST ${PATHS.userinfo}`]:
|
|
1278
|
+
[`GET ${PATHS.userinfo}`]: userinfo,
|
|
1279
|
+
[`POST ${PATHS.userinfo}`]: userinfo,
|
|
1121
1280
|
[`GET ${PATHS.jwks}`]: (r) => server.handleJwksRequest(r),
|
|
1122
1281
|
"GET /api/memberships": memberships,
|
|
1123
1282
|
"POST /api/check": check,
|
|
1124
1283
|
"POST /api/check/batch": checkBatch,
|
|
1284
|
+
"GET /api/resource-grants": listResourceGrants,
|
|
1125
1285
|
};
|
|
1126
1286
|
const segment = "([^/]+)";
|
|
1127
1287
|
const patterns = [
|
|
@@ -1218,10 +1378,17 @@ export async function createFakeTenant(options) {
|
|
|
1218
1378
|
issuer,
|
|
1219
1379
|
fetch: (request) => route(request) ?? Promise.resolve(problem(404, "Not found")),
|
|
1220
1380
|
addClient: async ({ secret, ...client }) => {
|
|
1221
|
-
|
|
1381
|
+
const registered = {
|
|
1222
1382
|
grants: ["authorization_code", "refresh_token"],
|
|
1223
1383
|
...client,
|
|
1224
|
-
}
|
|
1384
|
+
};
|
|
1385
|
+
if (registered.grants?.includes("client_credentials") &&
|
|
1386
|
+
registered.scopes === undefined) {
|
|
1387
|
+
throw new Error(UNSTATED_ALLOWLIST);
|
|
1388
|
+
}
|
|
1389
|
+
await clientService.add(registered, secret);
|
|
1390
|
+
if (secret)
|
|
1391
|
+
confidentialClients.add(registered.id);
|
|
1225
1392
|
for (const uri of client.redirectUris ?? []) {
|
|
1226
1393
|
try {
|
|
1227
1394
|
redirectOrigins.add(new URL(uri).origin);
|
|
@@ -1285,7 +1452,13 @@ export async function createFakeTenant(options) {
|
|
|
1285
1452
|
resourceTypes.add(type);
|
|
1286
1453
|
},
|
|
1287
1454
|
grant: (grant) => {
|
|
1288
|
-
|
|
1455
|
+
const role = grant.role ?? roleSlugOf(grant.permissions);
|
|
1456
|
+
let roleId = isBuiltInRole(role) ? null : resourceRoleIds.get(role);
|
|
1457
|
+
if (roleId === undefined) {
|
|
1458
|
+
roleId = crypto.randomUUID();
|
|
1459
|
+
resourceRoleIds.set(role, roleId);
|
|
1460
|
+
}
|
|
1461
|
+
grants.push({ ...grant, id: crypto.randomUUID(), role, roleId });
|
|
1289
1462
|
},
|
|
1290
1463
|
linkAccount: (userId, account) => {
|
|
1291
1464
|
if (!users.has(userId)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|