@udibo/oauth2 0.9.1 → 0.10.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.
@@ -126,6 +126,11 @@ export interface AuthorizationServerContext<Client extends ClientInterface, User
126
126
  services: AuthorizationServerServices<Client, User, S>;
127
127
  /** The issuer identifier URL for this request. */
128
128
  issuer?: string;
129
+ /**
130
+ * Scopes advertised for this request in discovery metadata. Overrides the
131
+ * server-wide default, including when set to an empty array.
132
+ */
133
+ scopesSupported?: string[];
129
134
  /** URL of the authorization endpoint. Defaults to `${issuer}/authorize`. */
130
135
  authorizationEndpoint?: string;
131
136
  /** URL of the token endpoint. Defaults to `${issuer}/token`. */
@@ -164,7 +169,10 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
164
169
  resolve: (request: Request) => AuthorizationServerContext<Client, User, S> | Promise<AuthorizationServerContext<Client, User, S>>;
165
170
  /** Grants the server supports, keyed by `grant_type` (each key must equal `grant.grantType`). */
166
171
  grants: AuthorizationServerGrants<Client, User, S>;
167
- /** Scopes supported by this server (advertised in RFC 8414 metadata). */
172
+ /**
173
+ * Default scopes advertised in RFC 8414 metadata when the request context
174
+ * does not supply scopesSupported.
175
+ */
168
176
  scopesSupported?: string[];
169
177
  /**
170
178
  * OIDC issuance keys. Configuring this turns the OIDC provider surface on:
@@ -1099,8 +1099,9 @@ export class AuthorizationServer extends ResourceServer {
1099
1099
  metadata.code_challenge_methods_supported = challengeMethods;
1100
1100
  }
1101
1101
  metadata.response_types_supported = hasAuthCodeGrant ? ["code"] : [];
1102
- if (this.scopesSupported) {
1103
- metadata.scopes_supported = this.scopesSupported;
1102
+ const scopesSupported = context.scopesSupported ?? this.scopesSupported;
1103
+ if (scopesSupported) {
1104
+ metadata.scopes_supported = scopesSupported;
1104
1105
  }
1105
1106
  if (this.signingKeys) {
1106
1107
  this.#advertiseEndpoints(metadata, endpoints, true);
@@ -50,10 +50,13 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
50
50
  * authentication, never a replacement for it, so a confidential client's
51
51
  * secret is still required on this grant.
52
52
  *
53
- * Defaults to `true`: confidential clients authenticate alongside PKCE.
54
- * Public clients, which have no secret, are unaffected. Setting `false`
55
- * explicitly enables legacy behavior where PKCE replaces client credentials;
56
- * use that opt-out only for an integration that requires it.
53
+ * Defaults to `true`: confidential clients authenticate alongside PKCE, and
54
+ * a public client authenticates by `client_id` alone — one that attaches a
55
+ * `client_secret` it was never issued is refused, as on every other grant.
56
+ * Setting `false` explicitly enables legacy behavior where PKCE replaces
57
+ * client credentials: a request carrying a `code_verifier` is looked up by
58
+ * id, so a presented secret is not checked at all. Use that opt-out only for
59
+ * an integration that requires it.
57
60
  *
58
61
  * @default true
59
62
  * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1#section-4.1.3
@@ -17,13 +17,17 @@ export interface ClientServiceInterface<Client extends ClientInterface, User> {
17
17
  * always advertises `none` in `token_endpoint_auth_methods_supported`, so
18
18
  * an implementation that demands a secret from every client makes that
19
19
  * advertisement false.
20
+ * - A **public** client must resolve `undefined` when a non-empty `secret` is
21
+ * presented. It was issued no secret, so a request carrying one is
22
+ * misconfigured or confused about the client's type, and the server answers
23
+ * `invalid_client` rather than silently treating it as public. An empty
24
+ * `secret` carries no credential — a Basic header of `client_id:` decodes
25
+ * to one — and counts as presenting none, so the client resolves.
20
26
  * - A **confidential** client resolves only with its correct `secret`. With
21
27
  * the secret missing or wrong, resolve `undefined`.
22
28
  *
23
29
  * `runClientServiceContractTests` from `@udibo/oauth2/testing/contract` pins
24
- * both rules; run it against your implementation. It does not pin whether a
25
- * public client that presents a `secret` resolves, so do not rely on either
26
- * answer.
30
+ * all three rules; run it against your implementation.
27
31
  *
28
32
  * @param id The presented `client_id` — unauthenticated input.
29
33
  * @param secret The presented `client_secret`, omitted when the request
@@ -82,6 +82,17 @@ export function runClientServiceContractTests(options) {
82
82
  const auth = await clientService.getAuthenticated(client.id);
83
83
  assertEquals(auth?.id, client.id);
84
84
  });
85
+ it("returns undefined for a public client that presents a secret", async () => {
86
+ const client = makeClient(9);
87
+ await options.addClient(clientService, client);
88
+ assertStrictEquals(await clientService.getAuthenticated(client.id, "unissued"), undefined);
89
+ });
90
+ it("returns the public client when the presented secret is empty", async () => {
91
+ const client = makeClient(10);
92
+ await options.addClient(clientService, client);
93
+ const auth = await clientService.getAuthenticated(client.id, "");
94
+ assertEquals(auth?.id, client.id);
95
+ });
85
96
  it("returns undefined for a confidential client when no secret is supplied", async () => {
86
97
  const client = makeClient(3);
87
98
  await options.addClient(clientService, client, "secret");
@@ -39,4 +39,4 @@ export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-s
39
39
  export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
40
40
  export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
41
41
  export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
42
- export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
42
+ export { runTenantContractTests, type TenantContractClient, type TenantContractFixture, type TenantContractMachineClientOptions, type TenantContractMachinePermission, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
@@ -3,7 +3,9 @@
3
3
  * about its signed-in people: what introspection reports, what
4
4
  * `GET /api/memberships` lists, what `POST /api/check` and
5
5
  * `POST /api/check/batch` answer, the organization API under
6
- * `/api/organizations`, and the account API under `/api/account`.
6
+ * `/api/organizations`, and the account API under `/api/account` — and what it
7
+ * answers the application itself: the `client_credentials` grant, and
8
+ * `GET /api/resource-grants` under the application's own machine token.
7
9
  *
8
10
  * It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
9
11
  * real identity service in Udibo's own repository, so the fake an app tests
@@ -94,9 +96,50 @@ export interface TenantContractFixture {
94
96
  * own, unless `options.sameBrowser` says otherwise.
95
97
  */
96
98
  signIn(userId: string, organizationId?: string, options?: TenantContractSignInOptions): Promise<TenantContractTokens>;
99
+ /**
100
+ * Registers an application allowed the `client_credentials` grant and no
101
+ * other, whose tokens may carry `options.scopes` and nothing else, and which
102
+ * a tenant administrator has assigned `options.permissions`. It is
103
+ * confidential unless `options.confidential` is `false`. Returns the
104
+ * credentials it signs in with: its id, and its secret when it has one.
105
+ */
106
+ addMachineClient(options: TenantContractMachineClientOptions): Promise<TenantContractClient>;
97
107
  /** Releases anything `setup` allocated. */
98
108
  cleanup?(): Promise<void> | void;
99
109
  }
110
+ /** An application's credentials at the token endpoint. */
111
+ export interface TenantContractClient {
112
+ /** The client id. */
113
+ id: string;
114
+ /** The client secret, which a public application is never issued. */
115
+ secret?: string;
116
+ }
117
+ /**
118
+ * A management permission a tenant administrator assigns a machine
119
+ * application. The suite assigns `resource_grants.read`, which is what
120
+ * `GET /api/resource-grants` requires.
121
+ */
122
+ export type TenantContractMachinePermission = "resource_grants.read";
123
+ /** What `TenantContractFixture.addMachineClient` registers. */
124
+ export interface TenantContractMachineClientOptions {
125
+ /**
126
+ * The scopes the application's tokens may carry. The suite names
127
+ * `identity:organizations:read` alone, and expects a token asking for any
128
+ * other to be refused.
129
+ */
130
+ scopes: string[];
131
+ /** The permissions an administrator assigned the application. */
132
+ permissions: TenantContractMachinePermission[];
133
+ /**
134
+ * Whether the application is confidential, authenticating with a secret.
135
+ * Defaults to `true`. With `false` it is public and issued no secret, and
136
+ * the suite expects the token endpoint to refuse it the grant as
137
+ * `invalid_client`. A tenant that refuses to register a public application
138
+ * allowed the grant may leave the grant off, since that refusal is client
139
+ * authentication's and comes before the grant is considered.
140
+ */
141
+ confidential?: boolean;
142
+ }
100
143
  /** How `TenantContractFixture.signIn` signs a person in. */
101
144
  export interface TenantContractSignInOptions {
102
145
  /**
@@ -3,7 +3,9 @@
3
3
  * about its signed-in people: what introspection reports, what
4
4
  * `GET /api/memberships` lists, what `POST /api/check` and
5
5
  * `POST /api/check/batch` answer, the organization API under
6
- * `/api/organizations`, and the account API under `/api/account`.
6
+ * `/api/organizations`, and the account API under `/api/account` — and what it
7
+ * answers the application itself: the `client_credentials` grant, and
8
+ * `GET /api/resource-grants` under the application's own machine token.
7
9
  *
8
10
  * It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
9
11
  * real identity service in Udibo's own repository, so the fake an app tests
@@ -931,5 +933,255 @@ export function runTenantContractTests(options) {
931
933
  assertEquals((await linkedOf(stranger.token)).identities.map((entry) => entry.id), [theirs]);
932
934
  });
933
935
  });
936
+ describe("client credentials and the resource-grants API", () => {
937
+ const ORGANIZATIONS_READ = "identity:organizations:read";
938
+ const DIRECT = "type=contract_document&id=direct";
939
+ const RANKED = "type=contract_document&id=ranked";
940
+ let reader;
941
+ let unassigned;
942
+ let readerToken;
943
+ let userinfoEndpoint;
944
+ async function addConfidentialClient(permissions) {
945
+ const client = await tenant.addMachineClient({
946
+ scopes: [ORGANIZATIONS_READ],
947
+ permissions,
948
+ });
949
+ assert(client.secret, "a confidential application is issued a secret");
950
+ return { id: client.id, secret: client.secret };
951
+ }
952
+ async function machineToken(client, scope) {
953
+ const response = await send(metadata.token_endpoint, {
954
+ method: "POST",
955
+ headers: client.secret === undefined
956
+ ? {}
957
+ : { authorization: encodeBasicAuth(client.id, client.secret) },
958
+ body: new URLSearchParams({
959
+ grant_type: "client_credentials",
960
+ ...client.secret === undefined ? { client_id: client.id } : {},
961
+ ...scope === undefined ? {} : { scope },
962
+ }),
963
+ });
964
+ const text = await response.text();
965
+ return {
966
+ status: response.status,
967
+ headers: response.headers,
968
+ body: text ? JSON.parse(text) : null,
969
+ };
970
+ }
971
+ async function issuedTo(client, scope) {
972
+ const issued = await machineToken(client, scope);
973
+ assertEquals(issued.status, 200, JSON.stringify(issued.body));
974
+ assert(issued.body.access_token);
975
+ return issued.body.access_token;
976
+ }
977
+ async function introspectAs(client, accessToken) {
978
+ const response = await send(metadata.introspection_endpoint, {
979
+ method: "POST",
980
+ headers: { authorization: encodeBasicAuth(client.id, client.secret) },
981
+ body: new URLSearchParams({ token: accessToken }),
982
+ });
983
+ assertEquals(response.status, 200);
984
+ return await response.json();
985
+ }
986
+ function grantsOn(accessToken, query) {
987
+ return call(accessToken, "GET", `/api/resource-grants?${query}`);
988
+ }
989
+ beforeAll(async () => {
990
+ const response = await send(new URL("/.well-known/openid-configuration", tenant.issuer));
991
+ ({ userinfo_endpoint: userinfoEndpoint } = await response.json());
992
+ reader = await addConfidentialClient(["resource_grants.read"]);
993
+ unassigned = await addConfidentialClient([]);
994
+ readerToken = await issuedTo(reader, ORGANIZATIONS_READ);
995
+ const ranked = [
996
+ ["contract:review", "user", people.member],
997
+ ["contract:share", "user", people.outsider],
998
+ ["contract:approve", "user", people.leaver],
999
+ ["contract:comment", "user", people.solo],
1000
+ ["contract:audit", "organization", organizations.home],
1001
+ ];
1002
+ for (const [permission, type, id] of ranked) {
1003
+ await tenant.grant({
1004
+ resource: { type: "contract_document", id: "ranked" },
1005
+ subject: { type, id },
1006
+ permissions: [permission],
1007
+ });
1008
+ }
1009
+ });
1010
+ it("issues a machine token with no refresh token, naming no person and no organization", async () => {
1011
+ const issued = await machineToken(reader, ORGANIZATIONS_READ);
1012
+ assertEquals(issued.status, 200, JSON.stringify(issued.body));
1013
+ assertEquals(issued.body.token_type, "Bearer");
1014
+ assert(issued.body.access_token);
1015
+ assertFalse("refresh_token" in issued.body, "a machine token has no refresh token");
1016
+ const claims = await introspectAs(reader, issued.body.access_token);
1017
+ assertEquals(claims.active, true);
1018
+ assertEquals(claims.client_id, reader.id);
1019
+ assertEquals(claims.scope, ORGANIZATIONS_READ);
1020
+ assertFalse(claims.sub, "a machine token names no person");
1021
+ assertFalse(claims.username, "a machine token names no person");
1022
+ assertFalse(claims.org_id, "a machine token belongs to no organization");
1023
+ assertEquals(sorted(claims.permissions), []);
1024
+ });
1025
+ it("answers another application introspecting a machine token that it is inactive", async () => {
1026
+ const claims = await introspectAs(unassigned, readerToken);
1027
+ assertEquals(claims, { active: false });
1028
+ });
1029
+ it("refuses the grant to a public application, which has no secret to authenticate with", async () => {
1030
+ const open = await tenant.addMachineClient({
1031
+ scopes: [ORGANIZATIONS_READ],
1032
+ permissions: [],
1033
+ confidential: false,
1034
+ });
1035
+ assertFalse(open.secret, "a public application is issued no secret");
1036
+ const refused = await machineToken(open, ORGANIZATIONS_READ);
1037
+ assertEquals(refused.status, 401, JSON.stringify(refused.body));
1038
+ assertEquals(refused.body.error, "invalid_client");
1039
+ assertFalse(refused.body.access_token);
1040
+ });
1041
+ it("refuses the grant to an application not registered for it, and refuses a machine token an OIDC scope or one outside its allowlist", async () => {
1042
+ const interactive = await machineToken(tenant.client, ORGANIZATIONS_READ);
1043
+ assertEquals(interactive.status, 401);
1044
+ assertEquals(interactive.body.error, "unauthorized_client");
1045
+ for (const scope of [
1046
+ "openid",
1047
+ `${ORGANIZATIONS_READ} identity:organizations:write`,
1048
+ ]) {
1049
+ const refused = await machineToken(reader, scope);
1050
+ assertEquals(refused.status, 400, scope);
1051
+ assertEquals(refused.body.error, "invalid_scope", scope);
1052
+ }
1053
+ });
1054
+ it("accepts organization_id on the refresh_token grant alone", async () => {
1055
+ const response = await send(metadata.token_endpoint, {
1056
+ method: "POST",
1057
+ headers: { authorization: encodeBasicAuth(reader.id, reader.secret) },
1058
+ body: new URLSearchParams({
1059
+ grant_type: "client_credentials",
1060
+ scope: ORGANIZATIONS_READ,
1061
+ organization_id: organizations.home,
1062
+ }),
1063
+ });
1064
+ const body = await response.json();
1065
+ assertEquals(response.status, 400, JSON.stringify(body));
1066
+ assertEquals(body.error, "invalid_request");
1067
+ });
1068
+ it("refuses a machine token wherever the tenant answers for a person", async () => {
1069
+ const checked = await ask(readerToken, "/api/check", {
1070
+ permissions: ["contract:tenant"],
1071
+ });
1072
+ assertEquals(checked.status, 403);
1073
+ assertEquals(checked.body.reason, "machine_token");
1074
+ const batch = await ask(readerToken, "/api/check/batch", {
1075
+ permissions: ["contract:read"],
1076
+ resource: { type: "contract_document", ids: ["direct"] },
1077
+ });
1078
+ assertEquals(batch.status, 403);
1079
+ assertEquals(batch.body.reason, "machine_token");
1080
+ for (const path of ["/api/memberships", "/api/account"]) {
1081
+ const reply = await call(readerToken, "GET", path);
1082
+ assertEquals(reply.status, 403, path);
1083
+ assertEquals(reply.body.reason, "machine_token", path);
1084
+ }
1085
+ const organizations = await call(readerToken, "GET", "/api/organizations");
1086
+ assertEquals(organizations.status, 403);
1087
+ const userinfo = await send(userinfoEndpoint, {
1088
+ headers: { authorization: `Bearer ${readerToken}` },
1089
+ });
1090
+ await userinfo.body?.cancel();
1091
+ assertEquals(userinfo.status, 401);
1092
+ });
1093
+ it("lists who holds a grant on a resource, roles named, and an empty list where nobody does", async () => {
1094
+ const direct = await grantsOn(readerToken, DIRECT);
1095
+ assertEquals(direct.status, 200, JSON.stringify(direct.body));
1096
+ assertStringIncludes(direct.headers.get("cache-control") ?? "", "no-store");
1097
+ assertEquals(direct.body.resource, {
1098
+ type: "contract_document",
1099
+ id: "direct",
1100
+ });
1101
+ assertEquals(direct.body.grants.length, 1);
1102
+ const [held] = direct.body.grants;
1103
+ assertEquals(Object.keys(held).sort(), [
1104
+ "builtInRole",
1105
+ "id",
1106
+ "roleId",
1107
+ "roleName",
1108
+ "roleSlug",
1109
+ "subjectActive",
1110
+ "subjectId",
1111
+ "subjectName",
1112
+ "subjectRole",
1113
+ "subjectType",
1114
+ ]);
1115
+ assertEquals(held.subjectType, "user");
1116
+ assertEquals(held.subjectId, people.outsider);
1117
+ assertEquals(held.subjectRole, null);
1118
+ assertEquals(held.subjectActive, true);
1119
+ assert(held.subjectName, "a grant names its holder");
1120
+ assert(held.id, "a grant has an id");
1121
+ assert(held.roleId, "a grant names its role");
1122
+ assert(held.roleSlug, "a grant names its role's slug");
1123
+ assert(held.roleName, "a grant names its role's name");
1124
+ assertEquals(held.builtInRole, null);
1125
+ const shared = await grantsOn(readerToken, "type=contract_document&id=shared");
1126
+ assertEquals(shared.status, 200);
1127
+ assertEquals(shared.body.grants.map((grant) => [grant.subjectType, grant.subjectId]), [["organization", organizations.home]]);
1128
+ const nobody = await grantsOn(readerToken, `type=contract_document&id=nobody-${crypto.randomUUID()}`);
1129
+ assertEquals(nobody.status, 200);
1130
+ assertEquals(nobody.body.grants, []);
1131
+ });
1132
+ it("lists the grants on one resource in role-name order", async () => {
1133
+ const ranked = await grantsOn(readerToken, RANKED);
1134
+ assertEquals(ranked.status, 200, JSON.stringify(ranked.body));
1135
+ assertEquals(ranked.body.grants.map((grant) => grant.subjectId).sort(), [
1136
+ people.member,
1137
+ people.outsider,
1138
+ people.leaver,
1139
+ people.solo,
1140
+ organizations.home,
1141
+ ].sort());
1142
+ const names = ranked.body.grants.map((grant) => grant.roleName);
1143
+ assertEquals(new Set(names).size, names.length, "each grant confers a role of its own");
1144
+ assertEquals(names, [...names].sort((a, b) => a.localeCompare(b)));
1145
+ });
1146
+ it("hides the listing from a machine token without the permission or the scope, and refuses a person's token and no token", async () => {
1147
+ const withoutPermission = await issuedTo(unassigned, ORGANIZATIONS_READ);
1148
+ assertEquals((await grantsOn(withoutPermission, DIRECT)).status, 404);
1149
+ const withoutScope = await issuedTo(reader);
1150
+ assertEquals((await grantsOn(withoutScope, DIRECT)).status, 404);
1151
+ const { access_token } = await tenant.signIn(people.member);
1152
+ const person = await call(access_token, "GET", `/api/resource-grants?${DIRECT}`);
1153
+ assertEquals(person.status, 403);
1154
+ assertEquals(person.body.reason, "user_token");
1155
+ assertEquals((await grantsOn(null, DIRECT)).status, 401);
1156
+ });
1157
+ function refusedFields(reply) {
1158
+ assertEquals(reply.status, 400, JSON.stringify(reply.body));
1159
+ const { fieldErrors } = reply.body;
1160
+ return Object.keys(fieldErrors ?? {});
1161
+ }
1162
+ it("refuses a resource type the tenant has not registered, and a resource missing its id", async () => {
1163
+ const unregistered = await grantsOn(readerToken, "type=contract_unregistered&id=direct");
1164
+ assertEquals(refusedFields(unregistered), ["type"]);
1165
+ const missing = await grantsOn(readerToken, "type=contract_document");
1166
+ assertEquals(refusedFields(missing), ["resource"]);
1167
+ });
1168
+ it("bounds the type to 64 characters and the id to 255, refusing ASCII control characters and no other", async () => {
1169
+ const long = await grantsOn(readerToken, `type=${"t".repeat(65)}&id=direct`);
1170
+ assertEquals(refusedFields(long), ["resource"]);
1171
+ const longest = await grantsOn(readerToken, `type=${"t".repeat(64)}&id=direct`);
1172
+ assertEquals(refusedFields(longest), ["type"]);
1173
+ const query = (id) => `type=contract_document&id=${encodeURIComponent(id)}`;
1174
+ const wide = await grantsOn(readerToken, query("i".repeat(256)));
1175
+ assertEquals(refusedFields(wide), ["resource"]);
1176
+ const widest = await grantsOn(readerToken, query("i".repeat(255)));
1177
+ assertEquals(widest.status, 200, JSON.stringify(widest.body));
1178
+ assertEquals(widest.body.grants, []);
1179
+ const control = await grantsOn(readerToken, query("tab\there"));
1180
+ assertEquals(refusedFields(control), ["resource"]);
1181
+ const unicode = await grantsOn(readerToken, query("next\u0085line"));
1182
+ assertEquals(unicode.status, 200, JSON.stringify(unicode.body));
1183
+ assertEquals(unicode.body.grants, []);
1184
+ });
1185
+ });
934
1186
  });
935
1187
  }
@@ -27,11 +27,13 @@
27
27
  * prototypes — never for production.
28
28
  *
29
29
  * - **A fake Udibo Identity tenant.** {@link createFakeTenant} answers a tenant's
30
- * protocol surface, its `/api/check` questions, and its organization and
31
- * account APIs over `fetch`, with the claims a real tenant issues —
32
- * permissions, the organization picked at sign-in, and resource grants — so
33
- * an app built on a Udibo Identity tenant can test its own routes without
34
- * running the identity service.
30
+ * protocol surface, its `/api/check` questions, its organization and
31
+ * account APIs, and — to the app's own machine credential — the
32
+ * `client_credentials` grant and the resource-grants read, over `fetch`,
33
+ * with the claims a real tenant issues — permissions, the organization
34
+ * picked at sign-in, and resource grants — so an app built on a Udibo
35
+ * Identity tenant can test its own routes without running the identity
36
+ * service.
35
37
  *
36
38
  * Nothing in this module is an OAuth2 primitive — only testing support.
37
39
  * Production code should never import from here.
@@ -41,4 +43,4 @@
41
43
  import "../_dnt.polyfills.js";
42
44
  export { MemoryAuthorizationCodeService, type MemoryAuthorizationCodeServiceOptions, MemoryClientService, MemoryDeviceAuthorizationService, type MemoryDeviceAuthorizationServiceOptions, MemoryTokenService, type MemoryTokenServiceOptions, MemoryUserService, type MemoryUserServiceOptions, type MemoryUserShape, } from "./services.js";
43
45
  export { createMemoryAuthorizationServer, type CreateMemoryAuthorizationServerOptions, type MemoryAuthorizationServerClientSeed, type MemoryAuthorizationServerGrants, type MemoryAuthorizationServerHarness, type MemoryAuthorizationServerUserSeed, } from "./server.js";
44
- export { createFakeTenant, type FakeTenant, type FakeTenantClient, type FakeTenantGrant, type FakeTenantLinkedAccount, type FakeTenantMembership, type FakeTenantOptions, type FakeTenantOrganization, type FakeTenantOrganizationRole, type FakeTenantSignIn, type FakeTenantSubject, type FakeTenantTokenRequest, type FakeTenantUser, } from "./tenant.js";
46
+ export { createFakeTenant, type FakeTenant, type FakeTenantClient, type FakeTenantGrant, type FakeTenantLinkedAccount, type FakeTenantMachinePermission, type FakeTenantMembership, type FakeTenantOptions, type FakeTenantOrganization, type FakeTenantOrganizationRole, type FakeTenantSignIn, type FakeTenantSubject, type FakeTenantTokenRequest, type FakeTenantUser, } from "./tenant.js";
@@ -27,11 +27,13 @@
27
27
  * prototypes — never for production.
28
28
  *
29
29
  * - **A fake Udibo Identity tenant.** {@link createFakeTenant} answers a tenant's
30
- * protocol surface, its `/api/check` questions, and its organization and
31
- * account APIs over `fetch`, with the claims a real tenant issues —
32
- * permissions, the organization picked at sign-in, and resource grants — so
33
- * an app built on a Udibo Identity tenant can test its own routes without
34
- * running the identity service.
30
+ * protocol surface, its `/api/check` questions, its organization and
31
+ * account APIs, and — to the app's own machine credential — the
32
+ * `client_credentials` grant and the resource-grants read, over `fetch`,
33
+ * with the claims a real tenant issues — permissions, the organization
34
+ * picked at sign-in, and resource grants — so an app built on a Udibo
35
+ * Identity tenant can test its own routes without running the identity
36
+ * service.
35
37
  *
36
38
  * Nothing in this module is an OAuth2 primitive — only testing support.
37
39
  * Production code should never import from here.
@@ -99,7 +99,7 @@ export declare class MemoryUserService<U extends MemoryUserShape> implements Use
99
99
  * A client is treated as confidential when {@link add} is called with a
100
100
  * secret, and public when it is not — there is no separate flag to
101
101
  * remember. `getAuthenticated` requires the secret for confidential
102
- * clients and accepts a missing secret for public clients.
102
+ * clients, and requires public clients to present none.
103
103
  */
104
104
  export declare class MemoryClientService<C extends ClientInterface, U> implements ClientServiceInterface<C, U> {
105
105
  #private;
@@ -122,7 +122,9 @@ export declare class MemoryClientService<C extends ClientInterface, U> implement
122
122
  /**
123
123
  * Resolves the client when authentication succeeds, otherwise `undefined`.
124
124
  * Confidential clients (registered with a secret) require a matching
125
- * `secret`; public clients (registered without one) ignore it.
125
+ * `secret`; public clients (registered without one) must present none, so a
126
+ * secret they were never issued fails authentication rather than being
127
+ * ignored. An empty `secret` counts as none.
126
128
  */
127
129
  getAuthenticated(id: string, secret?: string): Promise<C | undefined>;
128
130
  /**
@@ -109,7 +109,7 @@ export class MemoryUserService {
109
109
  * A client is treated as confidential when {@link add} is called with a
110
110
  * secret, and public when it is not — there is no separate flag to
111
111
  * remember. `getAuthenticated` requires the secret for confidential
112
- * clients and accepts a missing secret for public clients.
112
+ * clients, and requires public clients to present none.
113
113
  */
114
114
  export class MemoryClientService {
115
115
  #clientsById = new Map();
@@ -150,19 +150,21 @@ export class MemoryClientService {
150
150
  /**
151
151
  * Resolves the client when authentication succeeds, otherwise `undefined`.
152
152
  * Confidential clients (registered with a secret) require a matching
153
- * `secret`; public clients (registered without one) ignore it.
153
+ * `secret`; public clients (registered without one) must present none, so a
154
+ * secret they were never issued fails authentication rather than being
155
+ * ignored. An empty `secret` counts as none.
154
156
  */
155
157
  async getAuthenticated(id, secret) {
156
158
  const client = this.#clientsById.get(id);
157
159
  if (!client)
158
160
  return undefined;
159
161
  const storedHash = this.#secretHashByClientId.get(id);
160
- if (storedHash) {
161
- if (!secret)
162
- return undefined;
163
- if (await sha256Hash(secret) !== storedHash)
164
- return undefined;
165
- }
162
+ if (!storedHash)
163
+ return secret ? undefined : client;
164
+ if (!secret)
165
+ return undefined;
166
+ if (await sha256Hash(secret) !== storedHash)
167
+ return undefined;
166
168
  return client;
167
169
  }
168
170
  /**
@@ -35,10 +35,45 @@ export interface FakeTenantUser {
35
35
  */
36
36
  hasPassword?: boolean;
37
37
  }
38
- /** An OAuth2 application registered in a {@linkcode FakeTenant}. */
38
+ /**
39
+ * A management permission a tenant administrator assigns a machine
40
+ * application, apart from any permission a role carries. A real tenant also
41
+ * knows `resource_grants.write`; the fake answers only the read it serves.
42
+ */
43
+ export type FakeTenantMachinePermission = "resource_grants.read";
44
+ /**
45
+ * An OAuth2 application registered in a {@linkcode FakeTenant}. Its `grants`
46
+ * default to `authorization_code` and `refresh_token`; name
47
+ * `client_credentials` for a machine application, whose token from that grant
48
+ * is the application's own and names no person.
49
+ */
39
50
  export interface FakeTenantClient extends ClientInterface {
40
- /** Omit for a public client. */
51
+ /**
52
+ * Omit for a public client. Only a confidential client may use the
53
+ * `client_credentials` grant (RFC 6749 §4.4), since a public client has no
54
+ * credential to authenticate with (§2.1): a public client asking for it is
55
+ * refused `invalid_client`. Only a confidential client may introspect, and
56
+ * only the tokens issued to it; any other introspection answers
57
+ * `active: false`.
58
+ */
41
59
  secret?: string;
60
+ /**
61
+ * The scopes a `client_credentials` token may carry, as an administrator
62
+ * allowlists them on a real tenant. An application allowed that grant has
63
+ * to state it, as a tenant refuses to register one that does not;
64
+ * {@linkcode FakeTenant.addClient} throws. The fake's machine vocabulary is
65
+ * `identity:organizations:read` and `identity:organizations:write`, so a
66
+ * token asking for any other scope, allowlisted or not, an OIDC scope
67
+ * included, is refused with `invalid_scope`. Ignored on every other grant.
68
+ */
69
+ scopes?: string[];
70
+ /**
71
+ * The permissions a tenant administrator assigned this machine application.
72
+ * `GET /api/resource-grants` answers only a `client_credentials` token of an
73
+ * application holding `resource_grants.read`, and hides itself with a 404
74
+ * from every other. Defaults to none.
75
+ */
76
+ machinePermissions?: FakeTenantMachinePermission[];
42
77
  /**
43
78
  * `"jwt"` issues RFC 9068 access tokens signed with the tenant's JWKS key,
44
79
  * for a resource server that validates locally. Defaults to `"opaque"`.
@@ -97,6 +132,16 @@ export interface FakeTenantGrant {
97
132
  subject: FakeTenantSubject;
98
133
  /** What the subject may do on that one resource. */
99
134
  permissions: string[];
135
+ /**
136
+ * The slug of the role the grant confers, as `GET /api/resource-grants`
137
+ * reports it: `roleSlug`, with the name {@linkcode FakeTenant.defineOrganizationRole}
138
+ * gave it as `roleName`, or the slug again. Defaults to a slug built from
139
+ * `permissions`. A built-in tier (`owner`, `admin`, `member`) is listed as
140
+ * `builtInRole` with no `roleId`, the way a grant an administrator placed
141
+ * is. It changes only how the grant is listed; what it confers stays
142
+ * `permissions`.
143
+ */
144
+ role?: string;
100
145
  }
101
146
  /** An organization role your tenant defines beside the built-in tiers. */
102
147
  export interface FakeTenantOrganizationRole {
@@ -161,7 +206,10 @@ export interface FakeTenantOptions {
161
206
  * caller's own `GET /api/memberships`, the two authorization questions,
162
207
  * `POST /api/check` and `POST /api/check/batch`, the organization API under
163
208
  * `/api/organizations`, and the account API under `/api/account`, with the
164
- * same shapes and refusals a real tenant uses.
209
+ * same shapes and refusals a real tenant uses. To the app itself it answers
210
+ * the `client_credentials` grant, for a client registered with it, and
211
+ * `GET /api/resource-grants` under that machine token, which lists who holds a
212
+ * grant on one resource once the client holds `resource_grants.read`.
165
213
  *
166
214
  * Nothing here is interactive: {@linkcode signInAs} decides who the next
167
215
  * authorization request authenticates, and there is no consent step. Serve
@@ -200,7 +248,10 @@ export interface FakeTenant {
200
248
  defineOrganizationRole(role: FakeTenantOrganizationRole): void;
201
249
  /** Registers a resource type, so `/api/check` accepts it. */
202
250
  registerResourceType(type: string): void;
203
- /** Adds a resource grant. */
251
+ /**
252
+ * Adds a resource grant, which `/api/check` answers from and
253
+ * `GET /api/resource-grants` lists.
254
+ */
204
255
  grant(grant: FakeTenantGrant): void;
205
256
  /**
206
257
  * Links an external account to a person, as the account API lists it, and
@@ -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.1",
3
+ "version": "0.10.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",