@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.
@@ -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.
@@ -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