@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.
@@ -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" && user
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
- username: user.username,
272
- ...authorizationClaims(user.id, organizationId),
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: ["openid", "profile", "email", "offline_access"],
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, "A machine credential has no self to answer for.", machineRefusal);
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, { reason: "machine_token" });
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}`]: (r) => server.handleUserInfoRequest(r),
1120
- [`POST ${PATHS.userinfo}`]: (r) => server.handleUserInfoRequest(r),
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
- await clientService.add({
1381
+ const registered = {
1222
1382
  grants: ["authorization_code", "refresh_token"],
1223
1383
  ...client,
1224
- }, secret);
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
- grants.push(grant);
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.9.2",
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",