@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
|
@@ -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
|
}
|
package/esm/testing/mod.d.ts
CHANGED
|
@@ -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,
|
|
31
|
-
* account APIs
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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";
|
package/esm/testing/mod.js
CHANGED
|
@@ -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,
|
|
31
|
-
* account APIs
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
package/esm/testing/tenant.d.ts
CHANGED
|
@@ -35,10 +35,45 @@ export interface FakeTenantUser {
|
|
|
35
35
|
*/
|
|
36
36
|
hasPassword?: boolean;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|