idp-admin 0.0.0-stage → 0.1.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.
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FakeAdapter = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const errors_1 = require("../errors");
6
+ /**
7
+ * An identity provider that keeps users and groups in memory: the reference implementation every
8
+ * other adapter is tested against, and a provider for tests that need no container or cloud account.
9
+ * Ids are UUID v4s. Nothing is shared between instances or kept after the process exits.
10
+ */
11
+ class FakeAdapter {
12
+ #attributeNames;
13
+ #pageSize;
14
+ // insertion order is creation order, which is the order the list methods return
15
+ #users = new Map();
16
+ #idsByEmail = new Map();
17
+ #groups = new Map();
18
+ #invitations = [];
19
+ #signOuts = [];
20
+ constructor(options = {}) {
21
+ this.#attributeNames = new Set(options.attributes ?? []);
22
+ this.#pageSize = options.pageSize ?? 60;
23
+ }
24
+ /** Every provider invitation sent so far, oldest first: from `createUserWithInvite` and `resendInvite`. */
25
+ get invitations() {
26
+ return this.#invitations.map((invitation) => ({ ...invitation }));
27
+ }
28
+ /** The id of every user signed out at the provider so far, oldest first, once per call. */
29
+ get signOuts() {
30
+ return [...this.#signOuts];
31
+ }
32
+ /** Adds a user who signs in through another identity provider (SSO). Only the fake can do this directly. */
33
+ addFederatedUser(input) {
34
+ return copy(this.#add(input, "federated"));
35
+ }
36
+ async createUser(input) {
37
+ return copy(this.#add(input, "password_change_required"));
38
+ }
39
+ async createUserWithInvite(input) {
40
+ const user = this.#add(input, "password_change_required");
41
+ this.#invitations.push({ userId: user.id, email: user.email });
42
+ return copy(user);
43
+ }
44
+ async getUser(userId) {
45
+ return copy(this.#user(userId));
46
+ }
47
+ async findUserByEmail(email) {
48
+ const id = this.#idsByEmail.get(email);
49
+ return id === undefined ? null : copy(this.#user(id));
50
+ }
51
+ async listUsersPage(cursor) {
52
+ return this.#page([...this.#users.values()].map(copy), cursor);
53
+ }
54
+ async setPassword(userId, _password, options) {
55
+ const user = this.#user(userId);
56
+ // an SSO user signs in elsewhere, so a password here doesn't change how they sign in
57
+ if (user.status !== "federated")
58
+ user.status = options.temporary ? "password_change_required" : "active";
59
+ }
60
+ async setEnabled(userId, enabled) {
61
+ this.#user(userId).enabled = enabled;
62
+ }
63
+ async deleteUser(userId) {
64
+ const user = this.#user(userId);
65
+ for (const members of this.#groups.values())
66
+ members.delete(userId);
67
+ this.#idsByEmail.delete(user.email);
68
+ this.#users.delete(userId);
69
+ }
70
+ async updateAttributes(userId, attributes) {
71
+ const user = this.#user(userId);
72
+ this.#checkAttributes(attributes);
73
+ Object.assign(user.attributes, attributes);
74
+ }
75
+ async resendInvite(userId) {
76
+ const user = this.#user(userId);
77
+ this.#invitations.push({ userId, email: user.email });
78
+ }
79
+ async signOutEverywhere(userId) {
80
+ this.#user(userId);
81
+ this.#signOuts.push(userId);
82
+ }
83
+ async addToGroup(userId, group) {
84
+ this.#user(userId);
85
+ this.#group(group).add(userId);
86
+ }
87
+ async removeFromGroup(userId, group) {
88
+ this.#user(userId);
89
+ this.#group(group).delete(userId);
90
+ }
91
+ async listUserGroupsPage(userId, cursor) {
92
+ this.#user(userId);
93
+ const names = [...this.#groups].filter(([, members]) => members.has(userId)).map(([name]) => name);
94
+ return this.#page(names, cursor);
95
+ }
96
+ async groupExists(group) {
97
+ return this.#groups.has(group);
98
+ }
99
+ async createGroup(group) {
100
+ if (this.#groups.has(group))
101
+ throw new errors_1.GroupExistsError(group);
102
+ this.#groups.set(group, new Set());
103
+ }
104
+ async deleteGroup(group) {
105
+ this.#group(group);
106
+ this.#groups.delete(group);
107
+ }
108
+ async listGroupMembersPage(group, cursor) {
109
+ const members = [...this.#group(group)].map((id) => copy(this.#user(id)));
110
+ return this.#page(members, cursor);
111
+ }
112
+ #add(input, status) {
113
+ if (this.#idsByEmail.has(input.email))
114
+ throw new errors_1.UserExistsError(input.email);
115
+ this.#checkAttributes(input.attributes ?? {});
116
+ const user = {
117
+ id: (0, node_crypto_1.randomUUID)(),
118
+ email: input.email,
119
+ name: input.name ?? null,
120
+ status,
121
+ enabled: true,
122
+ attributes: { ...input.attributes },
123
+ };
124
+ this.#users.set(user.id, user);
125
+ this.#idsByEmail.set(user.email, user.id);
126
+ return user;
127
+ }
128
+ // The stored user, for reading or changing in place; callers get copies.
129
+ #user(userId) {
130
+ const user = this.#users.get(userId);
131
+ if (!user)
132
+ throw new errors_1.UserNotFoundError(userId);
133
+ return user;
134
+ }
135
+ #group(group) {
136
+ const members = this.#groups.get(group);
137
+ if (!members)
138
+ throw new errors_1.GroupNotFoundError(group);
139
+ return members;
140
+ }
141
+ // The type system already rejects undeclared names; this catches callers without it, as a provider would.
142
+ #checkAttributes(attributes) {
143
+ for (const name of Object.keys(attributes)) {
144
+ if (!this.#attributeNames.has(name)) {
145
+ const declared = [...this.#attributeNames].join(", ") || "none";
146
+ throw new TypeError(`Unknown attribute ${name}. Declared attributes: ${declared}`);
147
+ }
148
+ }
149
+ }
150
+ // The cursor is the offset of the page's first item.
151
+ #page(items, cursor) {
152
+ const start = cursor === null ? 0 : Number(cursor);
153
+ const end = start + this.#pageSize;
154
+ return { items: items.slice(start, end), nextCursor: end < items.length ? String(end) : null };
155
+ }
156
+ }
157
+ exports.FakeAdapter = FakeAdapter;
158
+ function copy(user) {
159
+ return { ...user, attributes: { ...user.attributes } };
160
+ }
@@ -0,0 +1,3 @@
1
+ export type { AdapterCreateUserInput, AdapterInviteUserInput, IdpAdapter } from "./adapter";
2
+ export { AccessReductionIncompleteError, GroupExistsError, GroupNotFoundError, IdpAdminError, NotSupportedError, UserExistsError, UserNotFoundError, type AccessReductionAction, type AccessReductionStep, type AnyIdpAdminError, } from "./errors";
3
+ export type { IdpUser, Page, UserAttributes, UserStatus } from "./types";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UserNotFoundError = exports.UserExistsError = exports.NotSupportedError = exports.IdpAdminError = exports.GroupNotFoundError = exports.GroupExistsError = exports.AccessReductionIncompleteError = void 0;
4
+ var errors_1 = require("./errors");
5
+ Object.defineProperty(exports, "AccessReductionIncompleteError", { enumerable: true, get: function () { return errors_1.AccessReductionIncompleteError; } });
6
+ Object.defineProperty(exports, "GroupExistsError", { enumerable: true, get: function () { return errors_1.GroupExistsError; } });
7
+ Object.defineProperty(exports, "GroupNotFoundError", { enumerable: true, get: function () { return errors_1.GroupNotFoundError; } });
8
+ Object.defineProperty(exports, "IdpAdminError", { enumerable: true, get: function () { return errors_1.IdpAdminError; } });
9
+ Object.defineProperty(exports, "NotSupportedError", { enumerable: true, get: function () { return errors_1.NotSupportedError; } });
10
+ Object.defineProperty(exports, "UserExistsError", { enumerable: true, get: function () { return errors_1.UserExistsError; } });
11
+ Object.defineProperty(exports, "UserNotFoundError", { enumerable: true, get: function () { return errors_1.UserNotFoundError; } });
@@ -0,0 +1,53 @@
1
+ import type { AdapterCreateUserInput, IdpAdapter } from "../adapter";
2
+ import type { IdpUser, Page, UserAttributes } from "../types";
3
+ export type KeycloakAdapterOptions<TMap extends Record<string, string>> = {
4
+ issuer: string;
5
+ clientId: string;
6
+ clientSecret: string;
7
+ attributes?: TMap;
8
+ pageSize?: number;
9
+ };
10
+ /** The realm-management roles the client's service account needs. `manage-realm` is for creating and deleting groups. */
11
+ export declare const KEYCLOAK_ADMIN_ROLES: readonly ["manage-users", "view-users", "query-users", "query-groups", "manage-realm"];
12
+ /** Keycloak answered with a status the adapter doesn't turn into an idp-admin error. */
13
+ export declare class KeycloakRequestError extends Error {
14
+ readonly method: string;
15
+ readonly path: string;
16
+ readonly status: number;
17
+ constructor(method: string, path: string, status: number, message: string);
18
+ }
19
+ type Attr<TMap> = Extract<keyof TMap, string>;
20
+ /**
21
+ * Keycloak, through its admin REST API, authenticated as a client's service account. A user's id is
22
+ * their Keycloak user id, which Keycloak puts in the `sub` of their tokens. Groups are top-level realm
23
+ * groups, addressed by name. Keycloak has no provider invitation without SMTP and required-action
24
+ * emails, so `createUserWithInvite` and `resendInvite` aren't implemented.
25
+ */
26
+ export declare class KeycloakAdapter<const TMap extends Record<string, string> = {}> implements IdpAdapter<Attr<TMap>> {
27
+ #private;
28
+ /** The admin API, authenticated, for anything the shared interface doesn't cover. Paths are relative to /admin/realms/<realm>. */
29
+ readonly native: {
30
+ adminUrl: string;
31
+ request(method: string, path: string, body?: unknown): Promise<Response>;
32
+ };
33
+ constructor(options: KeycloakAdapterOptions<TMap>);
34
+ createUser(input: AdapterCreateUserInput<Attr<TMap>>): Promise<IdpUser<Attr<TMap>>>;
35
+ getUser(userId: string): Promise<IdpUser<Attr<TMap>>>;
36
+ findUserByEmail(email: string): Promise<IdpUser<Attr<TMap>> | null>;
37
+ listUsersPage(cursor: string | null): Promise<Page<IdpUser<Attr<TMap>>>>;
38
+ setPassword(userId: string, password: string, options: {
39
+ temporary: boolean;
40
+ }): Promise<void>;
41
+ setEnabled(userId: string, enabled: boolean): Promise<void>;
42
+ deleteUser(userId: string): Promise<void>;
43
+ updateAttributes(userId: string, attributes: UserAttributes<Attr<TMap>>): Promise<void>;
44
+ signOutEverywhere(userId: string): Promise<void>;
45
+ addToGroup(userId: string, group: string): Promise<void>;
46
+ removeFromGroup(userId: string, group: string): Promise<void>;
47
+ listUserGroupsPage(userId: string, cursor: string | null): Promise<Page<string>>;
48
+ groupExists(group: string): Promise<boolean>;
49
+ createGroup(group: string): Promise<void>;
50
+ deleteGroup(group: string): Promise<void>;
51
+ listGroupMembersPage(group: string, cursor: string | null): Promise<Page<IdpUser<Attr<TMap>>>>;
52
+ }
53
+ export {};
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KeycloakAdapter = exports.KeycloakRequestError = exports.KEYCLOAK_ADMIN_ROLES = void 0;
4
+ const errors_1 = require("../errors");
5
+ /** The realm-management roles the client's service account needs. `manage-realm` is for creating and deleting groups. */
6
+ exports.KEYCLOAK_ADMIN_ROLES = ["manage-users", "view-users", "query-users", "query-groups", "manage-realm"];
7
+ /** Keycloak answered with a status the adapter doesn't turn into an idp-admin error. */
8
+ class KeycloakRequestError extends Error {
9
+ method;
10
+ path;
11
+ status;
12
+ constructor(method, path, status, message) {
13
+ super(message);
14
+ this.method = method;
15
+ this.path = path;
16
+ this.status = status;
17
+ this.name = "KeycloakRequestError";
18
+ }
19
+ }
20
+ exports.KeycloakRequestError = KeycloakRequestError;
21
+ // Renewed this long before it expires, so a token never lapses mid-request.
22
+ const TOKEN_EXPIRY_SKEW_MS = 30_000;
23
+ /**
24
+ * Keycloak, through its admin REST API, authenticated as a client's service account. A user's id is
25
+ * their Keycloak user id, which Keycloak puts in the `sub` of their tokens. Groups are top-level realm
26
+ * groups, addressed by name. Keycloak has no provider invitation without SMTP and required-action
27
+ * emails, so `createUserWithInvite` and `resendInvite` aren't implemented.
28
+ */
29
+ class KeycloakAdapter {
30
+ #realmUrl;
31
+ #adminUrl;
32
+ #clientId;
33
+ #clientSecret;
34
+ #attributes;
35
+ #pageSize;
36
+ #token;
37
+ #profile;
38
+ /** The admin API, authenticated, for anything the shared interface doesn't cover. Paths are relative to /admin/realms/<realm>. */
39
+ native;
40
+ constructor(options) {
41
+ const match = options.issuer.replace(/\/+$/, "").match(/^(.*)\/realms\/([^/]+)$/);
42
+ if (!match)
43
+ throw new TypeError(`issuer must look like <host>/realms/<realm>, got ${options.issuer}`);
44
+ const [, host, realm] = match;
45
+ const pageSize = options.pageSize ?? 50;
46
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 99)
47
+ throw new RangeError(`pageSize must be a whole number from 1 to 99, got ${pageSize}`);
48
+ this.#realmUrl = `${host}/realms/${realm}`;
49
+ this.#adminUrl = `${host}/admin/realms/${realm}`;
50
+ this.#clientId = options.clientId;
51
+ this.#clientSecret = options.clientSecret;
52
+ this.#attributes = new Map(Object.entries(options.attributes ?? {}));
53
+ this.#pageSize = pageSize;
54
+ this.native = {
55
+ adminUrl: this.#adminUrl,
56
+ request: async (method, path, body) => this.#fetch(method, path, body),
57
+ };
58
+ }
59
+ async createUser(input) {
60
+ const attributes = await this.#toKeycloakAttributes(input.attributes);
61
+ const response = await this.#request("POST", "/users", {
62
+ body: {
63
+ username: input.email,
64
+ email: input.email,
65
+ emailVerified: true,
66
+ enabled: true,
67
+ ...splitName(input.name),
68
+ attributes,
69
+ credentials: [{ type: "password", value: input.temporaryPassword, temporary: true }],
70
+ },
71
+ conflict: () => new errors_1.UserExistsError(input.email),
72
+ });
73
+ // Keycloak returns the new user's id only in the Location header
74
+ const id = response.headers.get("location").split("/").pop();
75
+ return this.getUser(id);
76
+ }
77
+ async getUser(userId) {
78
+ const response = await this.#request("GET", `/users/${encodeURIComponent(userId)}`, { notFound: () => new errors_1.UserNotFoundError(userId) });
79
+ return this.#toUser(await response.json());
80
+ }
81
+ async findUserByEmail(email) {
82
+ const response = await this.#request("GET", `/users?email=${encodeURIComponent(email)}&exact=true&briefRepresentation=false`);
83
+ const match = (await response.json()).find((user) => user.email === email);
84
+ return match ? this.#toUser(match) : null;
85
+ }
86
+ async listUsersPage(cursor) {
87
+ return this.#page(cursor, "/users?briefRepresentation=false", async (users) => Promise.all(users.map((user) => this.#toUser(user))));
88
+ }
89
+ async setPassword(userId, password, options) {
90
+ await this.#request("PUT", `/users/${encodeURIComponent(userId)}/reset-password`, {
91
+ body: { type: "password", value: password, temporary: options.temporary },
92
+ notFound: () => new errors_1.UserNotFoundError(userId),
93
+ });
94
+ }
95
+ async setEnabled(userId, enabled) {
96
+ // a partial representation: Keycloak leaves every field it doesn't contain unchanged
97
+ await this.#request("PUT", `/users/${encodeURIComponent(userId)}`, { body: { enabled }, notFound: () => new errors_1.UserNotFoundError(userId) });
98
+ }
99
+ async deleteUser(userId) {
100
+ await this.#request("DELETE", `/users/${encodeURIComponent(userId)}`, { notFound: () => new errors_1.UserNotFoundError(userId) });
101
+ }
102
+ async updateAttributes(userId, attributes) {
103
+ const changes = await this.#toKeycloakAttributes(attributes);
104
+ const path = `/users/${encodeURIComponent(userId)}`;
105
+ const current = (await (await this.#request("GET", path, { notFound: () => new errors_1.UserNotFoundError(userId) })).json());
106
+ // Keycloak replaces the whole attribute map, and with its user profile that map includes email and
107
+ // names: send the whole user back, with the attributes this app didn't declare unchanged
108
+ await this.#request("PUT", path, {
109
+ body: { ...current, attributes: { ...current.attributes, ...changes } },
110
+ notFound: () => new errors_1.UserNotFoundError(userId),
111
+ });
112
+ }
113
+ async signOutEverywhere(userId) {
114
+ await this.#request("POST", `/users/${encodeURIComponent(userId)}/logout`, { notFound: () => new errors_1.UserNotFoundError(userId) });
115
+ }
116
+ async addToGroup(userId, group) {
117
+ const groupId = await this.#requireGroupId(group);
118
+ await this.#request("PUT", `/users/${encodeURIComponent(userId)}/groups/${groupId}`, { notFound: () => new errors_1.UserNotFoundError(userId) });
119
+ }
120
+ async removeFromGroup(userId, group) {
121
+ const groupId = await this.#requireGroupId(group);
122
+ await this.#request("DELETE", `/users/${encodeURIComponent(userId)}/groups/${groupId}`, { notFound: () => new errors_1.UserNotFoundError(userId) });
123
+ }
124
+ async listUserGroupsPage(userId, cursor) {
125
+ const path = `/users/${encodeURIComponent(userId)}/groups?briefRepresentation=true`;
126
+ return this.#page(cursor, path, async (groups) => groups.map((group) => group.name), () => new errors_1.UserNotFoundError(userId));
127
+ }
128
+ async groupExists(group) {
129
+ return (await this.#groupId(group)) !== null;
130
+ }
131
+ async createGroup(group) {
132
+ await this.#request("POST", "/groups", { body: { name: group }, conflict: () => new errors_1.GroupExistsError(group) });
133
+ }
134
+ async deleteGroup(group) {
135
+ const groupId = await this.#requireGroupId(group);
136
+ await this.#request("DELETE", `/groups/${groupId}`, { notFound: () => new errors_1.GroupNotFoundError(group) });
137
+ }
138
+ async listGroupMembersPage(group, cursor) {
139
+ const groupId = await this.#requireGroupId(group);
140
+ const path = `/groups/${groupId}/members?briefRepresentation=false`;
141
+ return this.#page(cursor, path, async (users) => Promise.all(users.map((user) => this.#toUser(user))), () => new errors_1.GroupNotFoundError(group));
142
+ }
143
+ // One page from a Keycloak list endpoint that takes first/max. Asks for one item more than a page,
144
+ // so the last page is known to be the last without an extra request.
145
+ async #page(cursor, path, map, notFound) {
146
+ const first = cursor === null ? 0 : Number(cursor);
147
+ const response = await this.#request("GET", `${path}&first=${first}&max=${this.#pageSize + 1}`, { notFound });
148
+ const raw = (await response.json());
149
+ const more = raw.length > this.#pageSize;
150
+ return { items: await map(raw.slice(0, this.#pageSize)), nextCursor: more ? String(first + this.#pageSize) : null };
151
+ }
152
+ async #toUser(user) {
153
+ // a link to an identity provider is the only sign of an SSO user, and Keycloak keeps it off the user
154
+ const links = await this.#request("GET", `/users/${encodeURIComponent(user.id)}/federated-identity`, { notFound: () => new errors_1.UserNotFoundError(user.id) });
155
+ const federated = (await links.json()).length > 0;
156
+ const attributes = {};
157
+ for (const [appName, keycloakName] of this.#attributes) {
158
+ const value = user.attributes?.[keycloakName]?.[0];
159
+ if (value !== undefined)
160
+ attributes[appName] = value;
161
+ }
162
+ return {
163
+ id: user.id,
164
+ email: user.email ?? "",
165
+ name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
166
+ status: statusOf(user, federated),
167
+ enabled: user.enabled ?? false,
168
+ attributes: attributes,
169
+ };
170
+ }
171
+ // Checks every name is declared, then that the realm will keep them: with Keycloak's user profile,
172
+ // an attribute it doesn't declare is dropped without an error unless unmanaged attributes are enabled.
173
+ async #toKeycloakAttributes(attributes) {
174
+ const entries = Object.entries(attributes ?? {});
175
+ const result = {};
176
+ for (const [name, value] of entries) {
177
+ const keycloakName = this.#attributes.get(name);
178
+ if (keycloakName === undefined) {
179
+ const declared = [...this.#attributes.keys()].join(", ") || "none";
180
+ throw new TypeError(`Unknown attribute ${name}. Declared attributes: ${declared}`);
181
+ }
182
+ result[keycloakName] = [value];
183
+ }
184
+ if (entries.length)
185
+ await this.#assertRealmKeeps(Object.keys(result));
186
+ return result;
187
+ }
188
+ async #assertRealmKeeps(keycloakNames) {
189
+ this.#profile ??= this.#request("GET", "/users/profile").then((response) => response.json());
190
+ let profile;
191
+ try {
192
+ profile = await this.#profile;
193
+ }
194
+ catch (error) {
195
+ this.#profile = undefined;
196
+ throw error;
197
+ }
198
+ if (profile.unmanagedAttributePolicy === "ENABLED" || profile.unmanagedAttributePolicy === "ADMIN_EDIT")
199
+ return;
200
+ const declared = new Set((profile.attributes ?? []).map((attribute) => attribute.name));
201
+ const dropped = keycloakNames.find((name) => !declared.has(name));
202
+ if (dropped === undefined)
203
+ return;
204
+ // read the profile again next time, so fixing the realm doesn't need a restart
205
+ this.#profile = undefined;
206
+ throw new Error(`Keycloak would silently drop the attribute ${dropped}: the realm's user profile doesn't declare it and ` +
207
+ "unmanaged attributes are disabled. Declare it in Realm settings → User profile, or set Unmanaged attributes to Enabled.");
208
+ }
209
+ // The id of the top-level group with exactly this name, or null.
210
+ async #groupId(group) {
211
+ const response = await this.#request("GET", `/groups?search=${encodeURIComponent(group)}&exact=true&briefRepresentation=true`);
212
+ const match = (await response.json()).find((candidate) => candidate.name === group);
213
+ return match?.id ?? null;
214
+ }
215
+ async #requireGroupId(group) {
216
+ const id = await this.#groupId(group);
217
+ if (id === null)
218
+ throw new errors_1.GroupNotFoundError(group);
219
+ return id;
220
+ }
221
+ async #request(method, path, options = {}) {
222
+ const response = await this.#fetch(method, path, options.body);
223
+ if (response.ok)
224
+ return response;
225
+ const where = `${method} ${path.split("?")[0]}`;
226
+ if (response.status === 404 && options.notFound)
227
+ throw options.notFound();
228
+ if (response.status === 409 && options.conflict)
229
+ throw options.conflict();
230
+ if (response.status === 403) {
231
+ throw new KeycloakRequestError(method, path, 403, `Keycloak refused ${where} (403). The client's service account needs these realm-management roles: ${exports.KEYCLOAK_ADMIN_ROLES.join(", ")}.`);
232
+ }
233
+ const detail = await response.text().catch(() => "");
234
+ throw new KeycloakRequestError(method, path, response.status, `Keycloak ${where} failed (${response.status}): ${detail}`);
235
+ }
236
+ async #fetch(method, path, body) {
237
+ return fetch(`${this.#adminUrl}${path}`, {
238
+ method,
239
+ headers: {
240
+ authorization: `Bearer ${await this.#accessToken()}`,
241
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
242
+ },
243
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
244
+ });
245
+ }
246
+ // A client-credentials token, shared by concurrent calls and reused until shortly before it expires.
247
+ async #accessToken() {
248
+ const cached = this.#token && (await this.#token.catch(() => undefined));
249
+ if (cached && cached.expiresAt > Date.now())
250
+ return cached.value;
251
+ this.#token = this.#requestToken();
252
+ try {
253
+ return (await this.#token).value;
254
+ }
255
+ catch (error) {
256
+ this.#token = undefined;
257
+ throw error;
258
+ }
259
+ }
260
+ async #requestToken() {
261
+ const response = await fetch(`${this.#realmUrl}/protocol/openid-connect/token`, {
262
+ method: "POST",
263
+ headers: { "content-type": "application/x-www-form-urlencoded" },
264
+ body: new URLSearchParams({ grant_type: "client_credentials", client_id: this.#clientId, client_secret: this.#clientSecret }),
265
+ });
266
+ if (!response.ok) {
267
+ throw new KeycloakRequestError("POST", "/protocol/openid-connect/token", response.status, `Keycloak rejected the client credentials for ${this.#clientId} (${response.status}). The client needs Client authentication ` +
268
+ "and Service account roles enabled, and the right secret.");
269
+ }
270
+ const body = (await response.json());
271
+ return { value: body.access_token, expiresAt: Date.now() + (body.expires_in ?? 60) * 1000 - TOKEN_EXPIRY_SKEW_MS };
272
+ }
273
+ }
274
+ exports.KeycloakAdapter = KeycloakAdapter;
275
+ // Keycloak keeps a first and a last name; the first word is the first name, the rest the last.
276
+ function splitName(name) {
277
+ const [firstName, ...rest] = (name ?? "").trim().split(/\s+/).filter(Boolean);
278
+ return { ...(firstName ? { firstName } : {}), ...(rest.length ? { lastName: rest.join(" ") } : {}) };
279
+ }
280
+ function statusOf(user, federated) {
281
+ if (federated)
282
+ return "federated";
283
+ if (user.requiredActions?.includes("UPDATE_PASSWORD"))
284
+ return "password_change_required";
285
+ if (user.requiredActions?.includes("VERIFY_EMAIL"))
286
+ return "unconfirmed";
287
+ return "active";
288
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Where a user is in their account lifecycle. Whether they can sign in is separate: see `IdpUser.enabled`.
3
+ *
4
+ * - `active`: has a permanent password, or otherwise needs nothing before signing in
5
+ * - `password_change_required`: has a temporary password and must set their own at next sign-in
6
+ * - `federated`: signs in through another identity provider (SSO), so has no password here
7
+ * - `unconfirmed`: signed themselves up and hasn't confirmed their account yet
8
+ */
9
+ export type UserStatus = "active" | "password_change_required" | "federated" | "unconfirmed";
10
+ /**
11
+ * Custom attributes, keyed by the names the app declared in its adapter's config. Values are strings,
12
+ * because that is what providers store; convert other types yourself. With none declared, no key is
13
+ * allowed (an empty `{}` type would accept any key).
14
+ */
15
+ export type UserAttributes<TAttr extends string> = [TAttr] extends [never] ? Record<string, never> : Partial<Record<TAttr, string>>;
16
+ /** A user as every adapter reports them. */
17
+ export type IdpUser<TAttr extends string = never> = {
18
+ id: string;
19
+ email: string;
20
+ name: string | null;
21
+ status: UserStatus;
22
+ enabled: boolean;
23
+ attributes: UserAttributes<TAttr>;
24
+ };
25
+ /** One page of a list, and where the next one starts. */
26
+ export type Page<T> = {
27
+ items: T[];
28
+ nextCursor: string | null;
29
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,72 @@
1
+ # cognito
2
+
3
+ `idp-admin/cognito`: Amazon Cognito user pools, through the admin API of the AWS SDK. [Index](index.md)
4
+
5
+ `@aws-sdk/client-cognito-identity-provider` (v3) is an optional peer dependency: install it alongside idp-admin to use this adapter.
6
+
7
+ ```sh
8
+ npm install idp-admin @aws-sdk/client-cognito-identity-provider
9
+ ```
10
+
11
+ ## `new CognitoAdapter(options)`
12
+
13
+ ```ts
14
+ import { CognitoAdapter } from "idp-admin/cognito"
15
+
16
+ const adapter = new CognitoAdapter({
17
+ userPoolId: process.env.COGNITO_USER_POOL_ID!,
18
+ region: "us-east-1",
19
+ attributes: { company: "custom:companyName" },
20
+ })
21
+ ```
22
+
23
+ | Option | Default | Meaning |
24
+ |---|---|---|
25
+ | `userPoolId` | required | The user pool to manage. |
26
+ | `client` | none | A `CognitoIdentityProviderClient` you configured. When given, `region` and `credentials` are ignored. |
27
+ | `region` | none | The pool's region, to build a client with. Either `client` or `region` is required; with neither, the constructor throws a `TypeError`. |
28
+ | `credentials` | the AWS SDK's credential chain | Credentials for the built client. |
29
+ | `attributes` | none | App attribute name → Cognito attribute name, e.g. `{ company: "custom:companyName" }`. The app names become the adapter's attribute type, so only they compile, and any other name is rejected at runtime with a `TypeError`. Custom attributes must exist in the pool's schema. |
30
+ | `pageSize` | `60` | Most items per page from the list methods, a whole number from 1 to 60 (Cognito's largest page). Anything else throws a `RangeError`. |
31
+
32
+ ## IAM permissions
33
+
34
+ The adapter calls these `cognito-idp` actions on the pool:
35
+
36
+ `AdminCreateUser`, `AdminGetUser`, `ListUsers`, `AdminSetUserPassword`, `AdminEnableUser`, `AdminDisableUser`, `AdminDeleteUser`, `AdminUpdateUserAttributes`, `AdminUserGlobalSignOut`, `AdminAddUserToGroup`, `AdminRemoveUserFromGroup`, `AdminListGroupsForUser`, `ListUsersInGroup`, `GetGroup`, `CreateGroup`, `DeleteGroup`
37
+
38
+ Every one of them can be limited to the pool's ARN, `arn:aws:cognito-idp:<region>:<account>:userpool/<pool id>`.
39
+
40
+ ## Behaviour
41
+
42
+ - **Ids** are the user's `sub`, which their tokens carry. A local user's `sub` is also their Cognito username, so admin calls use it directly. An SSO user's username is provider-qualified (`MicrosoftEntraIDSAML_…`), and Cognito won't take their `sub`: the first call for one looks their username up by `sub` and the adapter remembers it. Lists remember the usernames they see, too.
43
+ - **Emails** are the username, and are stored as verified, because an admin typed them in. Cognito matches them case-insensitively and rejects emails it considers invalid, including ones containing `"`. `findUserByEmail` returns `null` for an email containing `"` without asking Cognito, because Cognito's filter syntax has no way to escape one.
44
+ - **Names** are stored in Cognito's standard `name` attribute.
45
+ - **Status:**
46
+
47
+ | Cognito `UserStatus` | `status` |
48
+ |---|---|
49
+ | `CONFIRMED` | `active` |
50
+ | `FORCE_CHANGE_PASSWORD`, `RESET_REQUIRED`, `COMPROMISED` | `password_change_required` |
51
+ | `UNCONFIRMED` | `unconfirmed` |
52
+ | `EXTERNAL_PROVIDER` | `federated` |
53
+ | anything else | `active` |
54
+
55
+ - **Attributes** are single Cognito attributes under their Cognito names. `updateAttributes` changes only the ones given.
56
+ - **Invitations:** `createUser` sends nothing, because the app sends its own email with the temporary password. `createUserWithInvite` has Cognito generate a password and email its invitation, and `resendInvite` sends it again. Both use the pool's email settings; Cognito's default sender has a low daily limit.
57
+ - **Groups** are addressed by name. Cognito removes a user from a group that doesn't exist without complaint, so `removeFromGroup` checks the group first and throws `GroupNotFoundError`.
58
+ - **Sign-out:** `signOutEverywhere` revokes the user's refresh tokens. Access and ID tokens already issued stay valid until they expire, about an hour by default.
59
+ - **Lists** use Cognito's own page tokens as cursors. Cognito doesn't return users in creation order.
60
+
61
+ ## Errors
62
+
63
+ Cognito's `UsernameExistsException` and `AliasExistsException` become `UserExistsError`, `UserNotFoundException` becomes `UserNotFoundError`, `ResourceNotFoundException` on a group becomes `GroupNotFoundError`, and `GroupExistsException` becomes `GroupExistsError`. Every other Cognito error, such as `TooManyRequestsException`, is thrown unchanged. An unknown attribute name throws a `TypeError` before anything is sent.
64
+
65
+ ## `native`
66
+
67
+ ```ts
68
+ adapter.native.client // the CognitoIdentityProviderClient
69
+ adapter.native.userPoolId
70
+ ```
71
+
72
+ The client and pool, for anything the shared interface doesn't cover.