@udibo/oauth2 0.1.0 → 0.3.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.
@@ -37,3 +37,4 @@ export { runTokenFlowStoreContractTests, type TokenFlowStoreContractOptions, } f
37
37
  export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-store.js";
38
38
  export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
39
39
  export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
40
+ export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractTokens, } from "./tenant.js";
@@ -37,3 +37,4 @@ export { runTokenFlowStoreContractTests, } from "./token-flow-store.js";
37
37
  export { runOtpStoreContractTests, } from "./otp-store.js";
38
38
  export { runRateLimitStoreContractTests, } from "./rate-limit-store.js";
39
39
  export { runLockoutStoreContractTests, } from "./lockout-store.js";
40
+ export { runTenantContractTests, } from "./tenant.js";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Contract test suite for a Udibo tenant's authorization answers: what
3
+ * introspection reports about a signed-in person, what `GET /api/memberships`
4
+ * lists for them, and what `POST /api/check` and `POST /api/check/batch`
5
+ * answer.
6
+ *
7
+ * It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
8
+ * real identity service in Udibo's own repository, so the fake an app tests
9
+ * with answers the way the tenant it ships against does. Run it against
10
+ * another stand-in to hold it to the same answers.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import {
15
+ * runTenantContractTests,
16
+ * type TenantContractFixture,
17
+ * } from "@udibo/oauth2/testing/contract";
18
+ *
19
+ * declare function startSeededTenant(): Promise<TenantContractFixture>;
20
+ *
21
+ * runTenantContractTests({ setup: startSeededTenant });
22
+ * ```
23
+ *
24
+ * @module
25
+ */
26
+ /** A token response from a completed sign-in. */
27
+ export interface TenantContractTokens {
28
+ /** The access token the sign-in issued. */
29
+ access_token: string;
30
+ /** The refresh token the sign-in issued. */
31
+ refresh_token: string;
32
+ }
33
+ /** A tenant under test, and how the suite seeds it. */
34
+ export interface TenantContractFixture {
35
+ /** The tenant's origin; its RFC 8414 metadata is served beneath it. */
36
+ issuer: string;
37
+ /**
38
+ * A confidential client the suite signs in with, introspects with and
39
+ * refreshes with. It must be allowed the `refresh_token` grant.
40
+ */
41
+ client: {
42
+ id: string;
43
+ secret: string;
44
+ };
45
+ /**
46
+ * How the suite reaches the tenant. Defaults to the global `fetch`; supply
47
+ * one when the tenant's host resolves only through a proxy.
48
+ */
49
+ fetch?: typeof fetch;
50
+ /** Adds a person holding `permissions` tenant-wide, and returns their id. */
51
+ addUser(permissions: string[]): Promise<string>;
52
+ /** Adds an organization and returns its id. */
53
+ addOrganization(): Promise<string>;
54
+ /**
55
+ * Makes a person a `member` of an organization, holding `permissions`
56
+ * inside it.
57
+ */
58
+ addMember(organizationId: string, userId: string, permissions: string[]): Promise<void>;
59
+ /** Ends a membership. */
60
+ removeMember(organizationId: string, userId: string): Promise<void>;
61
+ /**
62
+ * Grants `permissions` on one instance of a resource type to a person, or
63
+ * to every member of an organization. Registers the type if it is new.
64
+ */
65
+ grant(grant: {
66
+ resource: {
67
+ type: string;
68
+ id: string;
69
+ };
70
+ subject: {
71
+ type: "user" | "organization";
72
+ id: string;
73
+ };
74
+ permissions: string[];
75
+ }): Promise<void>;
76
+ /**
77
+ * Signs the person in through the authorization-code flow and returns the
78
+ * tokens it issued. With an organization named, the credential is issued in
79
+ * it; with none, the sign-in picks nothing, leaving the tenant to decide.
80
+ */
81
+ signIn(userId: string, organizationId?: string): Promise<TenantContractTokens>;
82
+ /** Releases anything `setup` allocated. */
83
+ cleanup?(): Promise<void> | void;
84
+ }
85
+ /** Options for {@link runTenantContractTests}. */
86
+ export interface TenantContractOptions {
87
+ /** Overrides the top-level `describe` name. */
88
+ describeName?: string;
89
+ /** Builds the tenant once for the whole suite. */
90
+ setup(): Promise<TenantContractFixture> | TenantContractFixture;
91
+ }
92
+ /**
93
+ * Registers the tenant contract suite. The permissions and resource type it
94
+ * seeds are namespaced `contract`, so a tenant shared with other suites is
95
+ * safe as long as nothing else uses that namespace.
96
+ */
97
+ export declare function runTenantContractTests(options: TenantContractOptions): void;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Contract test suite for a Udibo tenant's authorization answers: what
3
+ * introspection reports about a signed-in person, what `GET /api/memberships`
4
+ * lists for them, and what `POST /api/check` and `POST /api/check/batch`
5
+ * answer.
6
+ *
7
+ * It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
8
+ * real identity service in Udibo's own repository, so the fake an app tests
9
+ * with answers the way the tenant it ships against does. Run it against
10
+ * another stand-in to hold it to the same answers.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import {
15
+ * runTenantContractTests,
16
+ * type TenantContractFixture,
17
+ * } from "@udibo/oauth2/testing/contract";
18
+ *
19
+ * declare function startSeededTenant(): Promise<TenantContractFixture>;
20
+ *
21
+ * runTenantContractTests({ setup: startSeededTenant });
22
+ * ```
23
+ *
24
+ * @module
25
+ */
26
+ import { assert, assertEquals, assertFalse } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
27
+ import { afterAll, beforeAll, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
28
+ import { encodeBasicAuth } from "../../utils/basic-auth.js";
29
+ /**
30
+ * Registers the tenant contract suite. The permissions and resource type it
31
+ * seeds are namespaced `contract`, so a tenant shared with other suites is
32
+ * safe as long as nothing else uses that namespace.
33
+ */
34
+ export function runTenantContractTests(options) {
35
+ describe(options.describeName ?? "Udibo tenant contract", () => {
36
+ let tenant;
37
+ let metadata;
38
+ let send;
39
+ const people = {
40
+ member: "",
41
+ outsider: "",
42
+ leaver: "",
43
+ solo: "",
44
+ };
45
+ const organizations = { home: "", other: "", unjoined: "" };
46
+ beforeAll(async () => {
47
+ tenant = await options.setup();
48
+ send = tenant.fetch ?? fetch;
49
+ const response = await send(new URL("/.well-known/oauth-authorization-server", tenant.issuer));
50
+ metadata = await response.json();
51
+ people.member = await tenant.addUser(["contract:tenant"]);
52
+ people.outsider = await tenant.addUser([]);
53
+ people.leaver = await tenant.addUser([]);
54
+ people.solo = await tenant.addUser([]);
55
+ organizations.home = await tenant.addOrganization();
56
+ organizations.other = await tenant.addOrganization();
57
+ organizations.unjoined = await tenant.addOrganization();
58
+ await tenant.addMember(organizations.home, people.member, [
59
+ "contract:home",
60
+ ]);
61
+ await tenant.addMember(organizations.other, people.member, [
62
+ "contract:other",
63
+ ]);
64
+ await tenant.addMember(organizations.home, people.outsider, []);
65
+ await tenant.addMember(organizations.home, people.leaver, [
66
+ "contract:home",
67
+ ]);
68
+ await tenant.addMember(organizations.other, people.solo, [
69
+ "contract:other",
70
+ ]);
71
+ await tenant.grant({
72
+ resource: { type: "contract_document", id: "direct" },
73
+ subject: { type: "user", id: people.outsider },
74
+ permissions: ["contract:read"],
75
+ });
76
+ await tenant.grant({
77
+ resource: { type: "contract_document", id: "shared" },
78
+ subject: { type: "organization", id: organizations.home },
79
+ permissions: ["contract:write"],
80
+ });
81
+ });
82
+ afterAll(async () => {
83
+ await tenant?.cleanup?.();
84
+ });
85
+ const clientAuth = () => encodeBasicAuth(tenant.client.id, tenant.client.secret);
86
+ async function introspect(accessToken) {
87
+ const response = await send(metadata.introspection_endpoint, {
88
+ method: "POST",
89
+ headers: { authorization: clientAuth() },
90
+ body: new URLSearchParams({ token: accessToken }),
91
+ });
92
+ assertEquals(response.status, 200);
93
+ return await response.json();
94
+ }
95
+ async function ask(accessToken, path, body) {
96
+ const response = await send(new URL(path, tenant.issuer), {
97
+ method: "POST",
98
+ headers: {
99
+ authorization: `Bearer ${accessToken}`,
100
+ "content-type": "application/json",
101
+ },
102
+ body: JSON.stringify(body),
103
+ });
104
+ const text = await response.text();
105
+ return { status: response.status, body: text ? JSON.parse(text) : {} };
106
+ }
107
+ const sorted = (values) => [...values ?? []].sort();
108
+ describe("introspection", () => {
109
+ it("reports tenant-wide permissions and no organization for a sign-in that picked none", async () => {
110
+ const { access_token } = await tenant.signIn(people.member);
111
+ const claims = await introspect(access_token);
112
+ assertEquals(claims.active, true);
113
+ assertEquals(claims.sub, people.member);
114
+ assertEquals(sorted(claims.permissions), ["contract:tenant"]);
115
+ assertFalse(claims.org_id, "a sign-in with no organization names none");
116
+ });
117
+ it("picks a person's only organization when the sign-in names none", async () => {
118
+ const { access_token } = await tenant.signIn(people.solo);
119
+ const claims = await introspect(access_token);
120
+ assertEquals(claims.org_id, organizations.other);
121
+ assertEquals(sorted(claims.permissions), ["contract:other"]);
122
+ });
123
+ it("adds the picked organization and its permissions, and no other's", async () => {
124
+ const { access_token } = await tenant.signIn(people.member, organizations.home);
125
+ const claims = await introspect(access_token);
126
+ assertEquals(claims.org_id, organizations.home);
127
+ assertEquals(sorted(claims.permissions), [
128
+ "contract:home",
129
+ "contract:tenant",
130
+ ]);
131
+ });
132
+ it("keeps a refreshed credential in the organization it was issued for", async () => {
133
+ const issued = await tenant.signIn(people.member, organizations.other);
134
+ await tenant.signIn(people.member, organizations.home);
135
+ const response = await send(metadata.token_endpoint, {
136
+ method: "POST",
137
+ headers: { authorization: clientAuth() },
138
+ body: new URLSearchParams({
139
+ grant_type: "refresh_token",
140
+ refresh_token: issued.refresh_token,
141
+ }),
142
+ });
143
+ assertEquals(response.status, 200);
144
+ const refreshed = await response.json();
145
+ const claims = await introspect(refreshed.access_token);
146
+ assertEquals(claims.org_id, organizations.other);
147
+ });
148
+ it("stops answering for an organization once the membership ends", async () => {
149
+ const { access_token } = await tenant.signIn(people.leaver, organizations.home);
150
+ await tenant.removeMember(organizations.home, people.leaver);
151
+ const claims = await introspect(access_token);
152
+ assertFalse(claims.org_id);
153
+ assertEquals(sorted(claims.permissions), []);
154
+ });
155
+ });
156
+ describe("GET /api/memberships", () => {
157
+ it("lists the organizations the caller belongs to, and no others", async () => {
158
+ const { access_token } = await tenant.signIn(people.member);
159
+ const response = await send(new URL("/api/memberships", tenant.issuer), { headers: { authorization: `Bearer ${access_token}` } });
160
+ assertEquals(response.status, 200);
161
+ const body = await response.json();
162
+ assertEquals(body.memberships.map((membership) => membership.org_id).sort(), [organizations.home, organizations.other].sort());
163
+ for (const membership of body.memberships) {
164
+ assertEquals(membership.roles, ["member"]);
165
+ assert(membership.org_slug, "every membership names its slug");
166
+ assert(membership.name, "every membership names its organization");
167
+ }
168
+ assertEquals(body.cursor, null);
169
+ });
170
+ it("refuses a caller with no bearer token", async () => {
171
+ const response = await send(new URL("/api/memberships", tenant.issuer));
172
+ await response.body?.cancel();
173
+ assertEquals(response.status, 401);
174
+ });
175
+ });
176
+ describe("POST /api/check", () => {
177
+ it("answers the credential's own organization when no resource is named", async () => {
178
+ const { access_token } = await tenant.signIn(people.member, organizations.home);
179
+ const answer = await ask(access_token, "/api/check", {
180
+ permissions: ["contract:home", "contract:other", "contract:tenant"],
181
+ });
182
+ assertEquals(answer.status, 200);
183
+ assertEquals(answer.body, {
184
+ subject: people.member,
185
+ resource: { type: "organization", id: organizations.home },
186
+ results: {
187
+ "contract:home": true,
188
+ "contract:other": false,
189
+ "contract:tenant": true,
190
+ },
191
+ });
192
+ });
193
+ it("answers tenant scope alone, naming no resource, for a credential with no organization", async () => {
194
+ const { access_token } = await tenant.signIn(people.member);
195
+ const answer = await ask(access_token, "/api/check", {
196
+ permissions: ["contract:home", "contract:tenant"],
197
+ });
198
+ assertEquals(answer.body, {
199
+ subject: people.member,
200
+ resource: null,
201
+ results: { "contract:home": false, "contract:tenant": true },
202
+ });
203
+ });
204
+ it("answers another organization the caller belongs to when it is named", async () => {
205
+ const { access_token } = await tenant.signIn(people.member, organizations.home);
206
+ const answer = await ask(access_token, "/api/check", {
207
+ permissions: ["contract:other"],
208
+ resource: { type: "organization", id: organizations.other },
209
+ });
210
+ assertEquals(answer.status, 200);
211
+ assertEquals(answer.body.results, { "contract:other": true });
212
+ });
213
+ it("answers an organization the caller does not belong to from tenant-wide permissions alone", async () => {
214
+ const { access_token } = await tenant.signIn(people.member);
215
+ const answer = await ask(access_token, "/api/check", {
216
+ permissions: ["contract:other", "contract:tenant"],
217
+ resource: { type: "organization", id: organizations.unjoined },
218
+ });
219
+ assertEquals(answer.status, 200);
220
+ assertEquals(answer.body.results, {
221
+ "contract:other": false,
222
+ "contract:tenant": true,
223
+ });
224
+ });
225
+ it("answers an organization that does not exist as not found", async () => {
226
+ const { access_token } = await tenant.signIn(people.member);
227
+ const answer = await ask(access_token, "/api/check", {
228
+ permissions: ["contract:tenant"],
229
+ resource: { type: "organization", id: crypto.randomUUID() },
230
+ });
231
+ assertEquals(answer.status, 404);
232
+ });
233
+ it("answers a resource from a grant to the person and from one to their organization", async () => {
234
+ const { access_token } = await tenant.signIn(people.outsider);
235
+ const direct = await ask(access_token, "/api/check", {
236
+ permissions: ["contract:read", "contract:write"],
237
+ resource: { type: "contract_document", id: "direct" },
238
+ });
239
+ assertEquals(direct.status, 200);
240
+ assertEquals(direct.body.results, {
241
+ "contract:read": true,
242
+ "contract:write": false,
243
+ });
244
+ const shared = await ask(access_token, "/api/check", {
245
+ permissions: ["contract:write"],
246
+ resource: { type: "contract_document", id: "shared" },
247
+ });
248
+ assertEquals(shared.body.results, { "contract:write": true });
249
+ });
250
+ it("leaves organization permissions out of a resource answer", async () => {
251
+ const { access_token } = await tenant.signIn(people.member, organizations.home);
252
+ const answer = await ask(access_token, "/api/check", {
253
+ permissions: ["contract:home", "contract:tenant"],
254
+ resource: { type: "contract_document", id: "direct" },
255
+ });
256
+ assertEquals(answer.body.results, {
257
+ "contract:home": false,
258
+ "contract:tenant": true,
259
+ });
260
+ });
261
+ it("refuses a resource type the tenant has not registered", async () => {
262
+ const { access_token } = await tenant.signIn(people.member);
263
+ const answer = await ask(access_token, "/api/check", {
264
+ permissions: ["contract:read"],
265
+ resource: { type: "contract_unregistered", id: "x" },
266
+ });
267
+ assertEquals(answer.status, 400);
268
+ });
269
+ it("refuses a caller with no bearer token", async () => {
270
+ const response = await send(new URL("/api/check", tenant.issuer), {
271
+ method: "POST",
272
+ headers: { "content-type": "application/json" },
273
+ body: JSON.stringify({ permissions: ["contract:tenant"] }),
274
+ });
275
+ await response.body?.cancel();
276
+ assertEquals(response.status, 401);
277
+ });
278
+ });
279
+ describe("POST /api/check/batch", () => {
280
+ it("answers each candidate id as /api/check would", async () => {
281
+ const { access_token } = await tenant.signIn(people.outsider);
282
+ const answer = await ask(access_token, "/api/check/batch", {
283
+ permissions: ["contract:read"],
284
+ resource: { type: "contract_document", ids: ["direct", "unseen"] },
285
+ });
286
+ assertEquals(answer.status, 200);
287
+ assertEquals(answer.body, {
288
+ subject: people.outsider,
289
+ resource: { type: "contract_document" },
290
+ results: {
291
+ direct: { "contract:read": true },
292
+ unseen: { "contract:read": false },
293
+ },
294
+ });
295
+ });
296
+ it("refuses more than a hundred candidates", async () => {
297
+ const { access_token } = await tenant.signIn(people.outsider);
298
+ const answer = await ask(access_token, "/api/check/batch", {
299
+ permissions: ["contract:read"],
300
+ resource: {
301
+ type: "contract_document",
302
+ ids: Array.from({ length: 101 }, (_, i) => `id-${i}`),
303
+ },
304
+ });
305
+ assert(answer.status === 400, `answered ${answer.status}`);
306
+ });
307
+ });
308
+ });
309
+ }
@@ -24,6 +24,12 @@
24
24
  * user / client / scope types. Suitable for tests, examples, and
25
25
  * prototypes — never for production.
26
26
  *
27
+ * - **A fake Udibo tenant.** {@link createFakeTenant} answers a tenant's
28
+ * protocol surface and its `/api/check` questions over `fetch`, with the
29
+ * claims a real tenant issues — permissions, the organization picked at
30
+ * sign-in, and resource grants — so an app built on a Udibo tenant can test
31
+ * its own routes without running the identity service.
32
+ *
27
33
  * Nothing in this module is an OAuth2 primitive — only testing support.
28
34
  * Production code should never import from here.
29
35
  *
@@ -32,3 +38,4 @@
32
38
  import "../_dnt.polyfills.js";
33
39
  export { MemoryAuthorizationCodeService, type MemoryAuthorizationCodeServiceOptions, MemoryClientService, MemoryDeviceAuthorizationService, type MemoryDeviceAuthorizationServiceOptions, MemoryTokenService, type MemoryTokenServiceOptions, MemoryUserService, type MemoryUserServiceOptions, type MemoryUserShape, } from "./services.js";
34
40
  export { createMemoryAuthorizationServer, type CreateMemoryAuthorizationServerOptions, type MemoryAuthorizationServerClientSeed, type MemoryAuthorizationServerGrants, type MemoryAuthorizationServerHarness, type MemoryAuthorizationServerUserSeed, } from "./server.js";
41
+ export { createFakeTenant, type FakeTenant, type FakeTenantClient, type FakeTenantGrant, type FakeTenantMembership, type FakeTenantOptions, type FakeTenantOrganization, type FakeTenantSignIn, type FakeTenantSubject, type FakeTenantTokenRequest, type FakeTenantUser, } from "./tenant.js";
@@ -24,6 +24,12 @@
24
24
  * user / client / scope types. Suitable for tests, examples, and
25
25
  * prototypes — never for production.
26
26
  *
27
+ * - **A fake Udibo tenant.** {@link createFakeTenant} answers a tenant's
28
+ * protocol surface and its `/api/check` questions over `fetch`, with the
29
+ * claims a real tenant issues — permissions, the organization picked at
30
+ * sign-in, and resource grants — so an app built on a Udibo tenant can test
31
+ * its own routes without running the identity service.
32
+ *
27
33
  * Nothing in this module is an OAuth2 primitive — only testing support.
28
34
  * Production code should never import from here.
29
35
  *
@@ -32,3 +38,4 @@
32
38
  import "../_dnt.polyfills.js";
33
39
  export { MemoryAuthorizationCodeService, MemoryClientService, MemoryDeviceAuthorizationService, MemoryTokenService, MemoryUserService, } from "./services.js";
34
40
  export { createMemoryAuthorizationServer, } from "./server.js";
41
+ export { createFakeTenant, } from "./tenant.js";
@@ -0,0 +1,166 @@
1
+ import type { ClientInterface } from "../models/client.js";
2
+ /** A person in a {@linkcode FakeTenant}. */
3
+ export interface FakeTenantUser {
4
+ /** The subject every token and answer names. */
5
+ id: string;
6
+ /** The `username` introspection reports. */
7
+ username: string;
8
+ /** The `name` claim UserInfo reports. */
9
+ name?: string;
10
+ /** The `email` claim UserInfo reports. */
11
+ email?: string;
12
+ /** The `email_verified` claim UserInfo reports. Defaults to `true`. */
13
+ emailVerified?: boolean;
14
+ /** Permissions held tenant-wide, in every organization and on every resource. */
15
+ permissions?: string[];
16
+ }
17
+ /** An OAuth2 application registered in a {@linkcode FakeTenant}. */
18
+ export interface FakeTenantClient extends ClientInterface {
19
+ /** Omit for a public client. */
20
+ secret?: string;
21
+ /**
22
+ * `"jwt"` issues RFC 9068 access tokens signed with the tenant's JWKS key,
23
+ * for a resource server that validates locally. Defaults to `"opaque"`.
24
+ */
25
+ accessTokenFormat?: "opaque" | "jwt";
26
+ /** A JWT access token's `aud`. Defaults to the client id. */
27
+ audience?: string;
28
+ }
29
+ /** An organization in a {@linkcode FakeTenant}. */
30
+ export interface FakeTenantOrganization {
31
+ /** The id `org_id` carries and every scoped answer is keyed by. */
32
+ id: string;
33
+ /** The handle `org_slug` carries. Never an authorization key. */
34
+ slug: string;
35
+ /** The display name `/api/memberships` reports. Defaults to the slug. */
36
+ name?: string;
37
+ }
38
+ /** A person's place in one organization. */
39
+ export interface FakeTenantMembership {
40
+ /**
41
+ * The membership's roles, as `org_roles` and `/api/memberships` report
42
+ * them — `member`, `admin` or `owner` on a real tenant.
43
+ */
44
+ roles?: string[];
45
+ /** Permissions held inside this organization only. */
46
+ permissions?: string[];
47
+ }
48
+ /** Who a resource grant is for. */
49
+ export type FakeTenantSubject = {
50
+ type: "user";
51
+ id: string;
52
+ } | {
53
+ type: "organization";
54
+ id: string;
55
+ };
56
+ /** Permissions on one resource instance, for one subject. */
57
+ export interface FakeTenantGrant {
58
+ /** A type registered with {@linkcode FakeTenant.registerResourceType}, and your id for the instance. */
59
+ resource: {
60
+ type: string;
61
+ id: string;
62
+ };
63
+ /** A user, or an organization whose every member it admits. */
64
+ subject: FakeTenantSubject;
65
+ /** What the subject may do on that one resource. */
66
+ permissions: string[];
67
+ }
68
+ /** How the next sign-in resolves. */
69
+ export interface FakeTenantSignIn {
70
+ /**
71
+ * The organization the person picks while signing in. Tokens issued from
72
+ * that sign-in carry its `org_id`, `org_slug` and `org_roles`, and its
73
+ * permissions join theirs. Omit it and a person in exactly one organization
74
+ * gets that one, as on a real tenant; a person in several gets none. An
75
+ * `organization` parameter on the authorization request, by id or slug,
76
+ * overrides both for that credential, and is refused with
77
+ * `invalid_request` when the person is not a member.
78
+ */
79
+ organizationId?: string;
80
+ }
81
+ /** What {@linkcode FakeTenant.issueAccessToken} mints a token for. */
82
+ export interface FakeTenantTokenRequest {
83
+ /** The registered client the token is issued to. */
84
+ clientId: string;
85
+ /** The person the token is for. */
86
+ userId: string;
87
+ /**
88
+ * The organization the token answers for. Omit it and a person in exactly
89
+ * one organization gets that one; a person in several gets none.
90
+ */
91
+ organizationId?: string;
92
+ /** The granted scope. Defaults to `openid`. */
93
+ scope?: string;
94
+ }
95
+ /** Options for {@linkcode createFakeTenant}. */
96
+ export interface FakeTenantOptions {
97
+ /**
98
+ * The origin the tenant is served on, exactly as the app under test
99
+ * configures its issuer. Every advertised endpoint and every JWT `iss` is
100
+ * built from it.
101
+ */
102
+ issuer: string;
103
+ }
104
+ /**
105
+ * A stand-in for a Udibo tenant, for testing an app that signs in against one
106
+ * without running the identity service. It answers the tenant's protocol
107
+ * surface — discovery, authorize, token, introspection, UserInfo, JWKS — the
108
+ * caller's own `GET /api/memberships`, and the two authorization questions,
109
+ * `POST /api/check` and `POST /api/check/batch`, with the same shapes a real
110
+ * tenant uses.
111
+ *
112
+ * Nothing here is interactive: {@linkcode signInAs} decides who the next
113
+ * authorization request authenticates, and there is no consent step. Serve
114
+ * {@linkcode fetch} on the origin passed as `issuer`.
115
+ */
116
+ export interface FakeTenant {
117
+ /** The origin this tenant answers as. */
118
+ readonly issuer: string;
119
+ /** Handles one request to the tenant. Serve it on {@linkcode issuer}. */
120
+ fetch(request: Request): Promise<Response>;
121
+ /** Registers an application. Throws on a duplicate id. */
122
+ addClient(client: FakeTenantClient): Promise<void>;
123
+ /** Adds a person. Throws on a duplicate id or username. */
124
+ addUser(user: FakeTenantUser): Promise<void>;
125
+ /** Adds an organization. Throws on a duplicate id. */
126
+ addOrganization(organization: FakeTenantOrganization): void;
127
+ /** Makes a person a member of an organization, replacing any earlier membership. */
128
+ addMember(organizationId: string, userId: string, membership?: FakeTenantMembership): void;
129
+ /** Ends a membership. Credentials issued in that organization stop answering for it. */
130
+ removeMember(organizationId: string, userId: string): void;
131
+ /** Registers a resource type, so `/api/check` accepts it. */
132
+ registerResourceType(type: string): void;
133
+ /** Adds a resource grant. */
134
+ grant(grant: FakeTenantGrant): void;
135
+ /**
136
+ * Chooses who the next authorization requests authenticate as, until
137
+ * called again. With nobody chosen, authorize answers `login_required`.
138
+ */
139
+ signInAs(userId: string | null, signIn?: FakeTenantSignIn): void;
140
+ /**
141
+ * Mints an access token without the browser flow, for testing an API
142
+ * directly. JWT or opaque as the client is registered.
143
+ */
144
+ issueAccessToken(request: FakeTenantTokenRequest): Promise<string>;
145
+ }
146
+ /**
147
+ * Creates a {@linkcode FakeTenant}. Serve its `fetch` on the origin you pass
148
+ * as `issuer`, then point the app under test at that origin.
149
+ *
150
+ * @example
151
+ * ```ts
152
+ * import { createFakeTenant } from "@udibo/oauth2/testing";
153
+ *
154
+ * const server = Deno.serve(
155
+ * { hostname: "127.0.0.1", port: 0, onListen() {} },
156
+ * (request) => tenant.fetch(request),
157
+ * );
158
+ * const tenant = await createFakeTenant({
159
+ * issuer: `http://127.0.0.1:${server.addr.port}`,
160
+ * });
161
+ * await tenant.addUser({ id: "user-1", username: "ada" });
162
+ * tenant.signInAs("user-1");
163
+ * await server.shutdown();
164
+ * ```
165
+ */
166
+ export declare function createFakeTenant(options: FakeTenantOptions): Promise<FakeTenant>;
@@ -0,0 +1,418 @@
1
+ import { AuthorizationCodeGrant } from "../server/grants/authorization-code.js";
2
+ import { RefreshTokenGrant } from "../server/grants/refresh-token.js";
3
+ import { AuthorizationServer } from "../server/authorization-server.js";
4
+ import { createJwtAccessTokenGenerator, generateSigningKey, StaticSigningKeyProvider, } from "../server/signing-keys.js";
5
+ import { BasicScope } from "../models/scope.js";
6
+ import { InvalidRequestError } from "../errors.js";
7
+ import { MemoryAuthorizationCodeService, MemoryClientService, MemoryTokenService, MemoryUserService, } from "./services.js";
8
+ const PATHS = {
9
+ authorization: "/api/oauth2/authorize",
10
+ token: "/api/oauth2/token",
11
+ revocation: "/api/oauth2/revoke",
12
+ introspection: "/api/oauth2/introspect",
13
+ userinfo: "/api/oauth2/userinfo",
14
+ jwks: "/api/oauth2/jwks",
15
+ };
16
+ const MAX_PERMISSIONS = 50;
17
+ const MAX_BATCH_IDS = 100;
18
+ class TenantTokenService extends MemoryTokenService {
19
+ organizationOf;
20
+ recordCredential;
21
+ mintJwt;
22
+ constructor(options, organizationOf, recordCredential, mintJwt) {
23
+ super(options);
24
+ this.organizationOf = organizationOf;
25
+ this.recordCredential = recordCredential;
26
+ this.mintJwt = mintJwt;
27
+ }
28
+ async generateAccessToken(client, user, scope) {
29
+ const organizationId = user ? this.organizationOf(user.id) : undefined;
30
+ const accessToken = client.accessTokenFormat === "jwt" && user
31
+ ? await this.mintJwt(client, user, scope, organizationId)
32
+ : await super.generateAccessToken(client, user, scope);
33
+ this.recordCredential(accessToken, organizationId);
34
+ return accessToken;
35
+ }
36
+ }
37
+ function problem(status, detail) {
38
+ return Response.json({ status, detail }, {
39
+ status,
40
+ headers: { "content-type": "application/problem+json" },
41
+ });
42
+ }
43
+ function asPermissions(value) {
44
+ const list = typeof value === "string" ? [value] : value;
45
+ if (!Array.isArray(list) || list.length === 0 ||
46
+ list.length > MAX_PERMISSIONS ||
47
+ !list.every((entry) => typeof entry === "string" && entry.length > 0)) {
48
+ return undefined;
49
+ }
50
+ return list;
51
+ }
52
+ /**
53
+ * Creates a {@linkcode FakeTenant}. Serve its `fetch` on the origin you pass
54
+ * as `issuer`, then point the app under test at that origin.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { createFakeTenant } from "@udibo/oauth2/testing";
59
+ *
60
+ * const server = Deno.serve(
61
+ * { hostname: "127.0.0.1", port: 0, onListen() {} },
62
+ * (request) => tenant.fetch(request),
63
+ * );
64
+ * const tenant = await createFakeTenant({
65
+ * issuer: `http://127.0.0.1:${server.addr.port}`,
66
+ * });
67
+ * await tenant.addUser({ id: "user-1", username: "ada" });
68
+ * tenant.signInAs("user-1");
69
+ * await server.shutdown();
70
+ * ```
71
+ */
72
+ export async function createFakeTenant(options) {
73
+ const issuer = new URL(options.issuer).origin;
74
+ const signingKeys = new StaticSigningKeyProvider(await generateSigningKey());
75
+ const users = new Map();
76
+ const organizations = new Map();
77
+ const resourceTypes = new Set();
78
+ const grants = [];
79
+ const credentialOrganization = new Map();
80
+ let signedIn = null;
81
+ const pendingOrganization = new Map();
82
+ const membershipOf = (organizationId, userId) => organizationId
83
+ ? organizations.get(organizationId)?.members.get(userId)
84
+ : undefined;
85
+ const permissionsIn = (userId, organizationId) => new Set([
86
+ ...users.get(userId)?.permissions ?? [],
87
+ ...membershipOf(organizationId, userId)?.permissions ?? [],
88
+ ]);
89
+ const organizationClaims = (userId, organizationId) => {
90
+ const membership = membershipOf(organizationId, userId);
91
+ if (!organizationId || !membership)
92
+ return {};
93
+ return {
94
+ org_id: organizationId,
95
+ org_slug: organizations.get(organizationId).slug,
96
+ org_roles: membership.roles ?? [],
97
+ };
98
+ };
99
+ const authorizationClaims = (userId, organizationId) => {
100
+ const claims = organizationClaims(userId, organizationId);
101
+ return {
102
+ permissions: [...permissionsIn(userId, claims.org_id)],
103
+ ...claims,
104
+ };
105
+ };
106
+ const userService = new MemoryUserService();
107
+ const clientService = new MemoryClientService(userService);
108
+ const tokenService = new TenantTokenService({ clientService, userService }, (userId) => pendingOrganization.get(userId), (accessToken, organizationId) => credentialOrganization.set(accessToken, organizationId), (client, user, scope, organizationId) => createJwtAccessTokenGenerator({
109
+ signingKeys,
110
+ issuer,
111
+ audience: client.audience ?? client.id,
112
+ userClaims: () => ({
113
+ username: user.username,
114
+ ...authorizationClaims(user.id, organizationId),
115
+ }),
116
+ })(client, user, scope));
117
+ const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
118
+ const server = new AuthorizationServer({
119
+ resolve: () => ({
120
+ services: { clientService, tokenService },
121
+ issuer,
122
+ authorizationEndpoint: `${issuer}${PATHS.authorization}`,
123
+ tokenEndpoint: `${issuer}${PATHS.token}`,
124
+ revocationEndpoint: `${issuer}${PATHS.revocation}`,
125
+ introspectionEndpoint: `${issuer}${PATHS.introspection}`,
126
+ userinfoEndpoint: `${issuer}${PATHS.userinfo}`,
127
+ jwksEndpoint: `${issuer}${PATHS.jwks}`,
128
+ }),
129
+ grants: {
130
+ authorization_code: new AuthorizationCodeGrant({
131
+ resolve: () => ({
132
+ clientService,
133
+ tokenService,
134
+ authorizationCodeService,
135
+ }),
136
+ allowRefreshToken: true,
137
+ }),
138
+ refresh_token: new RefreshTokenGrant({
139
+ resolve: () => ({ clientService, tokenService }),
140
+ }),
141
+ },
142
+ signingKeys,
143
+ scopesSupported: ["openid", "profile", "email", "offline_access"],
144
+ userClaims: (user) => ({
145
+ preferred_username: user.username,
146
+ name: user.name,
147
+ email: user.email,
148
+ email_verified: user.email ? user.emailVerified ?? true : undefined,
149
+ ...organizationClaims(user.id, signedIn?.userId === user.id ? signedIn.organizationId : undefined),
150
+ }),
151
+ introspectionClaims: (token) => token.user
152
+ ? authorizationClaims(token.user.id, credentialOrganization.get(token.accessToken))
153
+ : {},
154
+ });
155
+ const withPendingOrganization = async (userId, organizationId, work) => {
156
+ if (!userId)
157
+ return await work();
158
+ pendingOrganization.set(userId, organizationId);
159
+ try {
160
+ return await work();
161
+ }
162
+ finally {
163
+ pendingOrganization.delete(userId);
164
+ }
165
+ };
166
+ const authenticated = async (request) => {
167
+ let token;
168
+ try {
169
+ ({ token } = await server.authenticate(request));
170
+ }
171
+ catch (error) {
172
+ return server.handleAuthError(error);
173
+ }
174
+ if (!token.user) {
175
+ return problem(403, "A machine credential has no self to answer for.");
176
+ }
177
+ const organizationId = credentialOrganization.get(token.accessToken);
178
+ return {
179
+ user: token.user,
180
+ organizationId: membershipOf(organizationId, token.user.id)
181
+ ? organizationId
182
+ : undefined,
183
+ };
184
+ };
185
+ const resourcePermissions = (userId, type, id) => {
186
+ const held = permissionsIn(userId);
187
+ for (const grant of grants) {
188
+ if (grant.resource.type !== type || grant.resource.id !== id)
189
+ continue;
190
+ const admits = grant.subject.type === "user"
191
+ ? grant.subject.id === userId
192
+ : Boolean(membershipOf(grant.subject.id, userId));
193
+ if (admits)
194
+ grant.permissions.forEach((p) => held.add(p));
195
+ }
196
+ return held;
197
+ };
198
+ const answer = (asked, held) => Object.fromEntries(asked.map((p) => [p, held.has(p)]));
199
+ const check = async (request) => {
200
+ const caller = await authenticated(request);
201
+ if (caller instanceof Response)
202
+ return caller;
203
+ const body = await request.json().catch(() => null);
204
+ const asked = asPermissions(body?.permissions);
205
+ if (!asked)
206
+ return problem(400, "permissions is invalid");
207
+ const resource = body?.resource;
208
+ if (resource === undefined) {
209
+ return Response.json({
210
+ subject: caller.user.id,
211
+ resource: caller.organizationId
212
+ ? { type: "organization", id: caller.organizationId }
213
+ : null,
214
+ results: answer(asked, permissionsIn(caller.user.id, caller.organizationId)),
215
+ });
216
+ }
217
+ if (typeof resource?.type !== "string" || typeof resource.id !== "string") {
218
+ return problem(400, "resource is invalid");
219
+ }
220
+ if (resource.type === "organization") {
221
+ if (!organizations.has(resource.id)) {
222
+ return problem(404, "Organization not found");
223
+ }
224
+ return Response.json({
225
+ subject: caller.user.id,
226
+ resource: { type: "organization", id: resource.id },
227
+ results: answer(asked, permissionsIn(caller.user.id, resource.id)),
228
+ });
229
+ }
230
+ if (!resourceTypes.has(resource.type)) {
231
+ return problem(400, `resource.type "${resource.type}" is not registered`);
232
+ }
233
+ return Response.json({
234
+ subject: caller.user.id,
235
+ resource: { type: resource.type, id: resource.id },
236
+ results: answer(asked, resourcePermissions(caller.user.id, resource.type, resource.id)),
237
+ });
238
+ };
239
+ const checkBatch = async (request) => {
240
+ const caller = await authenticated(request);
241
+ if (caller instanceof Response)
242
+ return caller;
243
+ const body = await request.json().catch(() => null);
244
+ const asked = asPermissions(body?.permissions);
245
+ if (!asked)
246
+ return problem(400, "permissions is invalid");
247
+ const type = body?.resource?.type;
248
+ const ids = body?.resource?.ids;
249
+ if (typeof type !== "string" || !Array.isArray(ids) || ids.length === 0 ||
250
+ ids.length > MAX_BATCH_IDS ||
251
+ !ids.every((id) => typeof id === "string" && id.length > 0)) {
252
+ return problem(400, "resource is invalid");
253
+ }
254
+ if (!resourceTypes.has(type)) {
255
+ return problem(400, `resource.type "${type}" is not registered`);
256
+ }
257
+ return Response.json({
258
+ subject: caller.user.id,
259
+ resource: { type },
260
+ results: Object.fromEntries(ids.map((id) => [
261
+ id,
262
+ answer(asked, resourcePermissions(caller.user.id, type, id)),
263
+ ])),
264
+ });
265
+ };
266
+ const signInOfCode = new Map();
267
+ const signInOfRefreshToken = new Map();
268
+ const soleOrganizationOf = (userId) => {
269
+ const joined = [...organizations.values()].filter((organization) => organization.members.has(userId));
270
+ return joined.length === 1 ? joined[0].id : undefined;
271
+ };
272
+ const requestedOrganization = (requested, userId) => {
273
+ const organization = organizations.get(requested) ??
274
+ [...organizations.values()].find((o) => o.slug === requested);
275
+ if (!organization?.members.has(userId)) {
276
+ throw new InvalidRequestError("organization is not available");
277
+ }
278
+ return organization.id;
279
+ };
280
+ const memberships = async (request) => {
281
+ const caller = await authenticated(request);
282
+ if (caller instanceof Response)
283
+ return caller;
284
+ return Response.json({
285
+ memberships: [...organizations.values()]
286
+ .filter((organization) => organization.members.has(caller.user.id))
287
+ .map((organization) => ({
288
+ org_id: organization.id,
289
+ org_slug: organization.slug,
290
+ name: organization.name ?? organization.slug,
291
+ roles: organization.members.get(caller.user.id).roles ?? [],
292
+ })),
293
+ cursor: null,
294
+ });
295
+ };
296
+ const authorize = async (request) => {
297
+ const chosen = signedIn;
298
+ const requested = new URL(request.url).searchParams.get("organization");
299
+ let issued;
300
+ const response = await server.handleAuthorizeRequest(request, () => {
301
+ const user = chosen ? users.get(chosen.userId) : undefined;
302
+ if (!user)
303
+ return Promise.resolve(null);
304
+ issued = {
305
+ userId: user.id,
306
+ organizationId: requested
307
+ ? requestedOrganization(requested, user.id)
308
+ : chosen?.organizationId ?? soleOrganizationOf(user.id),
309
+ };
310
+ return Promise.resolve({ user });
311
+ });
312
+ const location = response.headers.get("location");
313
+ const code = location ? new URL(location).searchParams.get("code") : null;
314
+ if (code && issued)
315
+ signInOfCode.set(code, issued);
316
+ return response;
317
+ };
318
+ const token = async (request) => {
319
+ const form = await request.clone().formData().catch(() => null);
320
+ const code = form?.get("code");
321
+ const refreshToken = form?.get("refresh_token");
322
+ const signIn = typeof code === "string"
323
+ ? signInOfCode.get(code)
324
+ : typeof refreshToken === "string"
325
+ ? signInOfRefreshToken.get(refreshToken)
326
+ : undefined;
327
+ const response = await withPendingOrganization(signIn?.userId, signIn?.organizationId, () => server.handleTokenRequest(request));
328
+ if (response.ok && signIn) {
329
+ const issued = await response.clone().json();
330
+ if (issued.refresh_token) {
331
+ signInOfRefreshToken.set(issued.refresh_token, signIn);
332
+ }
333
+ }
334
+ return response;
335
+ };
336
+ const routes = {
337
+ "GET /.well-known/oauth-authorization-server": (r) => server.handleMetadataRequest(r),
338
+ "GET /.well-known/openid-configuration": (r) => server.handleOidcMetadataRequest(r),
339
+ [`GET ${PATHS.authorization}`]: authorize,
340
+ [`POST ${PATHS.token}`]: token,
341
+ [`POST ${PATHS.revocation}`]: (r) => server.handleRevocationRequest(r),
342
+ [`POST ${PATHS.introspection}`]: (r) => server.handleIntrospectionRequest(r),
343
+ [`GET ${PATHS.userinfo}`]: (r) => server.handleUserInfoRequest(r),
344
+ [`POST ${PATHS.userinfo}`]: (r) => server.handleUserInfoRequest(r),
345
+ [`GET ${PATHS.jwks}`]: (r) => server.handleJwksRequest(r),
346
+ "GET /api/memberships": memberships,
347
+ "POST /api/check": check,
348
+ "POST /api/check/batch": checkBatch,
349
+ };
350
+ return {
351
+ issuer,
352
+ fetch: (request) => {
353
+ const route = routes[`${request.method} ${new URL(request.url).pathname}`];
354
+ return route
355
+ ? route(request)
356
+ : Promise.resolve(problem(404, "Not found"));
357
+ },
358
+ addClient: async ({ secret, ...client }) => {
359
+ await clientService.add({
360
+ grants: ["authorization_code", "refresh_token"],
361
+ ...client,
362
+ }, secret);
363
+ },
364
+ addUser: async (user) => {
365
+ await userService.add(user, crypto.randomUUID());
366
+ users.set(user.id, user);
367
+ },
368
+ addOrganization: (organization) => {
369
+ if (organizations.has(organization.id)) {
370
+ throw new Error(`Organization "${organization.id}" already exists`);
371
+ }
372
+ organizations.set(organization.id, {
373
+ ...organization,
374
+ members: new Map(),
375
+ });
376
+ },
377
+ addMember: (organizationId, userId, membership = {}) => {
378
+ const organization = organizations.get(organizationId);
379
+ if (!organization) {
380
+ throw new Error(`Organization "${organizationId}" does not exist`);
381
+ }
382
+ organization.members.set(userId, membership);
383
+ },
384
+ removeMember: (organizationId, userId) => {
385
+ organizations.get(organizationId)?.members.delete(userId);
386
+ },
387
+ registerResourceType: (type) => {
388
+ resourceTypes.add(type);
389
+ },
390
+ grant: (grant) => {
391
+ grants.push(grant);
392
+ },
393
+ signInAs: (userId, signIn = {}) => {
394
+ signedIn = userId ? { userId, ...signIn } : null;
395
+ },
396
+ issueAccessToken: async (request) => {
397
+ const client = await clientService.get(request.clientId);
398
+ const user = users.get(request.userId);
399
+ if (!client) {
400
+ throw new Error(`Client "${request.clientId}" does not exist`);
401
+ }
402
+ if (!user)
403
+ throw new Error(`User "${request.userId}" does not exist`);
404
+ const scope = new BasicScope(request.scope ?? "openid");
405
+ return await withPendingOrganization(user.id, request.organizationId ?? soleOrganizationOf(user.id), async () => {
406
+ const accessToken = await tokenService.generateAccessToken(client, user, scope);
407
+ await tokenService.save({
408
+ client,
409
+ user,
410
+ scope,
411
+ accessToken,
412
+ accessTokenExpiresAt: await tokenService.accessTokenExpiresAt(client, user, scope),
413
+ });
414
+ return accessToken;
415
+ });
416
+ },
417
+ };
418
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",