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