idp-admin 0.1.1 → 0.2.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/README.md CHANGED
@@ -6,13 +6,7 @@ Apps that sign people in through an identity provider (Cognito, Keycloak, Entra
6
6
 
7
7
  idp-admin is that layer, written once.
8
8
 
9
- > **Status:** 0.x, so the API may still change between minor versions. The adapters are published and usable directly; `IdpAdmin`, the class that adds the shared rules on top of them (including the access-reduced hook below), isn't built yet.
10
-
11
- ```sh
12
- npm install idp-admin
13
- ```
14
-
15
- The reference for every entry point is in [docs](docs/index.md).
9
+ > **Status:** 0.x, so the API may still change between minor versions. See the [quick start](#quick-start) and the [reference](docs/index.md).
16
10
 
17
11
  ## Why it exists
18
12
 
@@ -41,6 +35,81 @@ This package came out of exactly that situation: two production apps, each with
41
35
  - **Not a lowest common denominator.** Providers differ, and some features don't exist everywhere. Adapters declare what they support, and you can always reach the provider's own SDK for anything the shared interface doesn't cover.
42
36
  - **Not a UI.** It's the server-side layer an admin screen, a background job or a seed script would call.
43
37
 
38
+ ## Quick start
39
+
40
+ ```sh
41
+ npm install idp-admin
42
+ npm install @aws-sdk/client-cognito-identity-provider # only for Cognito
43
+ ```
44
+
45
+ Wrap the adapter for your provider in `IdpAdmin`. Each adapter's setup (credentials, permissions, realm or pool settings) is in its doc: [Cognito](docs/cognito.md), [Keycloak](docs/keycloak.md), [fake](docs/fake.md).
46
+
47
+ ```ts
48
+ import { IdpAdmin } from "idp-admin"
49
+ import { CognitoAdapter } from "idp-admin/cognito"
50
+ import { KeycloakAdapter } from "idp-admin/keycloak"
51
+
52
+ // Cognito in production, Keycloak in a container locally: the rest of the app doesn't know which
53
+ const adapter = process.env.COGNITO_USER_POOL_ID
54
+ ? new CognitoAdapter({
55
+ userPoolId: process.env.COGNITO_USER_POOL_ID,
56
+ region: "us-east-1",
57
+ attributes: { company: "custom:companyName" },
58
+ })
59
+ : new KeycloakAdapter({
60
+ issuer: "http://localhost:8080/realms/acme",
61
+ clientId: "acme-admin",
62
+ clientSecret: process.env.KEYCLOAK_ADMIN_SECRET!,
63
+ attributes: { company: "companyName" },
64
+ })
65
+
66
+ const admin = new IdpAdmin(adapter, {
67
+ // runs whenever someone loses access, so their sessions in your app end too
68
+ onAccessReduced: ({ userId }) => revokeAllSessionsFor(userId),
69
+ })
70
+ ```
71
+
72
+ `attributes` maps the names your app uses to the provider's own, and only those names compile.
73
+
74
+ ```ts
75
+ import { UserExistsError } from "idp-admin"
76
+
77
+ // create a user who must set their own password at first sign-in; send them the temporary one yourself
78
+ try{
79
+ const user = await admin.createUser({
80
+ email: "Dana@Example.com", // stored and matched as dana@example.com
81
+ name: "Dana Lee",
82
+ attributes: { company: "Acme" },
83
+ temporaryPassword: "Temp-pass-1!",
84
+ })
85
+ await admin.ensureGroup("admins")
86
+ await admin.addToGroup(user.id, "admins")
87
+ }catch(error){
88
+ if(error instanceof UserExistsError) console.log("already invited")
89
+ else throw error
90
+ }
91
+
92
+ // take access away: disabled at the provider, signed out there, then onAccessReduced
93
+ const dana = await admin.findUserByEmail("dana@example.com")
94
+ if(dana) await admin.disableUser(dana.id)
95
+
96
+ // everyone, fetched a page at a time behind the scenes
97
+ for await (const user of admin.listUsers()) console.log(user.email, user.status, user.enabled)
98
+ ```
99
+
100
+ A user's `id` is the `sub` in their sign-in tokens, so it matches what your auth library gives you, and it's what `onAccessReduced` receives. `findUserByEmail` returns `null` while `getUser` throws `UserNotFoundError`. If ending sessions fails after the provider change, the method throws `AccessReductionIncompleteError`, and calling it again is safe. The full list of methods, types and errors is in [docs/index.md](docs/index.md).
101
+
102
+ In tests, use the fake instead. It keeps everything in memory and records what a real provider would have done:
103
+
104
+ ```ts
105
+ import { FakeAdapter } from "idp-admin/fake"
106
+
107
+ const fake = new FakeAdapter({ attributes: ["company"] })
108
+ const admin = new IdpAdmin(fake, { onAccessReduced })
109
+ // ...run the code under test with `admin`, then:
110
+ expect(fake.signOuts).toContain(userId)
111
+ ```
112
+
44
113
  ## Providers
45
114
 
46
115
  Cognito, Keycloak and the fake provider are included today: the ones we run in production and can test properly.
@@ -0,0 +1,83 @@
1
+ import type { AdapterCreateUserInput, AdapterInviteUserInput, IdpAdapter } from "./adapter";
2
+ import { type AccessReductionAction } from "./errors";
3
+ import type { IdpUser, UserAttributes } from "./types";
4
+ /** What the access-reduced hook is told: whose access was reduced, and how. */
5
+ export type AccessReducedEvent = {
6
+ userId: string;
7
+ action: Exclude<AccessReductionAction, "removed-from-group">;
8
+ } | {
9
+ userId: string;
10
+ action: "removed-from-group";
11
+ group: string;
12
+ };
13
+ export type IdpAdminOptions = {
14
+ onAccessReduced?: (event: AccessReducedEvent) => void | Promise<void>;
15
+ };
16
+ /** The optional adapter methods, and whether the adapter in use has each. */
17
+ export type IdpAdminSupports = {
18
+ createUserWithInvite: boolean;
19
+ resendInvite: boolean;
20
+ };
21
+ /**
22
+ * What apps call to manage users in their identity provider, through one adapter. Holds the rules every
23
+ * provider shares: emails are lowercased and trimmed, and taking access away (deleting, disabling,
24
+ * removing from a group) always ends the user's sessions, by signing them out at the provider and
25
+ * calling `onAccessReduced`.
26
+ */
27
+ export declare class IdpAdmin<TAttr extends string = never> {
28
+ #private;
29
+ /** The adapter, for its `native` client and anything the shared methods don't cover. */
30
+ readonly adapter: IdpAdapter<TAttr>;
31
+ constructor(adapter: IdpAdapter<TAttr>, options?: IdpAdminOptions);
32
+ get supports(): IdpAdminSupports;
33
+ /** Creates a user who must replace `temporaryPassword` at first sign-in. The provider sends no message. @throws {UserExistsError} */
34
+ createUser(input: AdapterCreateUserInput<TAttr>): Promise<IdpUser<TAttr>>;
35
+ /** Creates a user the provider generates a password for and sends its own invitation. @throws {UserExistsError} @throws {NotSupportedError} */
36
+ createUserWithInvite(input: AdapterInviteUserInput<TAttr>): Promise<IdpUser<TAttr>>;
37
+ /** @throws {UserNotFoundError} */
38
+ getUser(userId: string): Promise<IdpUser<TAttr>>;
39
+ /** The user with this email, whatever its case, or null. */
40
+ findUserByEmail(email: string): Promise<IdpUser<TAttr> | null>;
41
+ /** Every user, fetched a page at a time as the loop needs them. */
42
+ listUsers(): AsyncGenerator<IdpUser<TAttr>>;
43
+ /** A temporary password must be replaced at next sign-in. @throws {UserNotFoundError} */
44
+ setPassword(userId: string, password: string, options: {
45
+ temporary: boolean;
46
+ }): Promise<void>;
47
+ /** Sets the given attributes and leaves the rest unchanged. @throws {UserNotFoundError} */
48
+ updateAttributes(userId: string, attributes: UserAttributes<TAttr>): Promise<void>;
49
+ /** Sends the provider's invitation again. @throws {UserNotFoundError} @throws {NotSupportedError} */
50
+ resendInvite(userId: string): Promise<void>;
51
+ /** Revokes the user's tokens at the provider. Doesn't call `onAccessReduced`. @throws {UserNotFoundError} */
52
+ signOutEverywhere(userId: string): Promise<void>;
53
+ enableUser(userId: string): Promise<void>;
54
+ /**
55
+ * Disables the user, signs them out at the provider, then calls `onAccessReduced`. Succeeds for a user
56
+ * who is already disabled, and repeats every step.
57
+ * @throws {UserNotFoundError} @throws {AccessReductionIncompleteError} the user is disabled, but a later step failed
58
+ */
59
+ disableUser(userId: string): Promise<void>;
60
+ /**
61
+ * Deletes the user, then calls `onAccessReduced`, also when the user was already gone. Deleting ends
62
+ * their tokens at the provider, so there is no separate sign-out. True if a user was deleted, false
63
+ * if there was none.
64
+ * @throws {AccessReductionIncompleteError} the user is deleted, but the hook failed
65
+ */
66
+ deleteUser(userId: string): Promise<boolean>;
67
+ /** Succeeds if they're already a member. @throws {UserNotFoundError} @throws {GroupNotFoundError} */
68
+ addToGroup(userId: string, group: string): Promise<void>;
69
+ /**
70
+ * Removes the user from the group, signs them out at the provider, then calls `onAccessReduced`.
71
+ * Succeeds if they aren't a member, or the group doesn't exist, and repeats every step.
72
+ * @throws {UserNotFoundError} @throws {AccessReductionIncompleteError} the user is removed, but a later step failed
73
+ */
74
+ removeFromGroup(userId: string, group: string): Promise<void>;
75
+ /** The names of every group the user is in. @throws {UserNotFoundError} */
76
+ listUserGroups(userId: string): AsyncGenerator<string>;
77
+ /** Every member of the group. @throws {GroupNotFoundError} */
78
+ listGroupMembers(group: string): AsyncGenerator<IdpUser<TAttr>>;
79
+ /** Creates the group, or does nothing if it exists. */
80
+ ensureGroup(group: string): Promise<void>;
81
+ /** Deletes the group, removing every member from it first. @throws {GroupNotFoundError} */
82
+ deleteGroup(group: string): Promise<void>;
83
+ }
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdpAdmin = void 0;
4
+ const errors_1 = require("./errors");
5
+ /**
6
+ * What apps call to manage users in their identity provider, through one adapter. Holds the rules every
7
+ * provider shares: emails are lowercased and trimmed, and taking access away (deleting, disabling,
8
+ * removing from a group) always ends the user's sessions, by signing them out at the provider and
9
+ * calling `onAccessReduced`.
10
+ */
11
+ class IdpAdmin {
12
+ #onAccessReduced;
13
+ /** The adapter, for its `native` client and anything the shared methods don't cover. */
14
+ adapter;
15
+ constructor(adapter, options = {}) {
16
+ this.adapter = adapter;
17
+ this.#onAccessReduced = options.onAccessReduced;
18
+ }
19
+ get supports() {
20
+ return {
21
+ createUserWithInvite: typeof this.adapter.createUserWithInvite === "function",
22
+ resendInvite: typeof this.adapter.resendInvite === "function",
23
+ };
24
+ }
25
+ // ---- users
26
+ /** Creates a user who must replace `temporaryPassword` at first sign-in. The provider sends no message. @throws {UserExistsError} */
27
+ createUser(input) {
28
+ return this.adapter.createUser({ ...input, email: normalizeEmail(input.email) });
29
+ }
30
+ /** Creates a user the provider generates a password for and sends its own invitation. @throws {UserExistsError} @throws {NotSupportedError} */
31
+ async createUserWithInvite(input) {
32
+ if (!this.adapter.createUserWithInvite)
33
+ throw new errors_1.NotSupportedError("createUserWithInvite");
34
+ return this.adapter.createUserWithInvite({ ...input, email: normalizeEmail(input.email) });
35
+ }
36
+ /** @throws {UserNotFoundError} */
37
+ getUser(userId) {
38
+ return this.adapter.getUser(userId);
39
+ }
40
+ /** The user with this email, whatever its case, or null. */
41
+ findUserByEmail(email) {
42
+ return this.adapter.findUserByEmail(normalizeEmail(email));
43
+ }
44
+ /** Every user, fetched a page at a time as the loop needs them. */
45
+ listUsers() {
46
+ return all((cursor) => this.adapter.listUsersPage(cursor));
47
+ }
48
+ /** A temporary password must be replaced at next sign-in. @throws {UserNotFoundError} */
49
+ setPassword(userId, password, options) {
50
+ return this.adapter.setPassword(userId, password, options);
51
+ }
52
+ /** Sets the given attributes and leaves the rest unchanged. @throws {UserNotFoundError} */
53
+ updateAttributes(userId, attributes) {
54
+ return this.adapter.updateAttributes(userId, attributes);
55
+ }
56
+ /** Sends the provider's invitation again. @throws {UserNotFoundError} @throws {NotSupportedError} */
57
+ async resendInvite(userId) {
58
+ if (!this.adapter.resendInvite)
59
+ throw new errors_1.NotSupportedError("resendInvite");
60
+ await this.adapter.resendInvite(userId);
61
+ }
62
+ /** Revokes the user's tokens at the provider. Doesn't call `onAccessReduced`. @throws {UserNotFoundError} */
63
+ signOutEverywhere(userId) {
64
+ return this.adapter.signOutEverywhere(userId);
65
+ }
66
+ async enableUser(userId) {
67
+ await this.adapter.setEnabled(userId, true);
68
+ }
69
+ /**
70
+ * Disables the user, signs them out at the provider, then calls `onAccessReduced`. Succeeds for a user
71
+ * who is already disabled, and repeats every step.
72
+ * @throws {UserNotFoundError} @throws {AccessReductionIncompleteError} the user is disabled, but a later step failed
73
+ */
74
+ async disableUser(userId) {
75
+ await this.adapter.setEnabled(userId, false);
76
+ await this.#signOut(userId, "disabled");
77
+ await this.#accessReduced({ userId, action: "disabled" });
78
+ }
79
+ /**
80
+ * Deletes the user, then calls `onAccessReduced`, also when the user was already gone. Deleting ends
81
+ * their tokens at the provider, so there is no separate sign-out. True if a user was deleted, false
82
+ * if there was none.
83
+ * @throws {AccessReductionIncompleteError} the user is deleted, but the hook failed
84
+ */
85
+ async deleteUser(userId) {
86
+ let deleted = true;
87
+ try {
88
+ await this.adapter.deleteUser(userId);
89
+ }
90
+ catch (error) {
91
+ if (!(error instanceof errors_1.UserNotFoundError))
92
+ throw error;
93
+ deleted = false;
94
+ }
95
+ await this.#accessReduced({ userId, action: "deleted" });
96
+ return deleted;
97
+ }
98
+ // ---- groups
99
+ /** Succeeds if they're already a member. @throws {UserNotFoundError} @throws {GroupNotFoundError} */
100
+ addToGroup(userId, group) {
101
+ return this.adapter.addToGroup(userId, group);
102
+ }
103
+ /**
104
+ * Removes the user from the group, signs them out at the provider, then calls `onAccessReduced`.
105
+ * Succeeds if they aren't a member, or the group doesn't exist, and repeats every step.
106
+ * @throws {UserNotFoundError} @throws {AccessReductionIncompleteError} the user is removed, but a later step failed
107
+ */
108
+ async removeFromGroup(userId, group) {
109
+ try {
110
+ await this.adapter.removeFromGroup(userId, group);
111
+ }
112
+ catch (error) {
113
+ if (!(error instanceof errors_1.GroupNotFoundError))
114
+ throw error;
115
+ // nobody is in a group that doesn't exist, but the user must still exist
116
+ await this.adapter.getUser(userId);
117
+ }
118
+ await this.#signOut(userId, "removed-from-group");
119
+ await this.#accessReduced({ userId, action: "removed-from-group", group });
120
+ }
121
+ /** The names of every group the user is in. @throws {UserNotFoundError} */
122
+ listUserGroups(userId) {
123
+ return all((cursor) => this.adapter.listUserGroupsPage(userId, cursor));
124
+ }
125
+ /** Every member of the group. @throws {GroupNotFoundError} */
126
+ listGroupMembers(group) {
127
+ return all((cursor) => this.adapter.listGroupMembersPage(group, cursor));
128
+ }
129
+ /** Creates the group, or does nothing if it exists. */
130
+ async ensureGroup(group) {
131
+ if (await this.adapter.groupExists(group))
132
+ return;
133
+ try {
134
+ await this.adapter.createGroup(group);
135
+ }
136
+ catch (error) {
137
+ // created by someone else since the check
138
+ if (!(error instanceof errors_1.GroupExistsError))
139
+ throw error;
140
+ }
141
+ }
142
+ /** Deletes the group, removing every member from it first. @throws {GroupNotFoundError} */
143
+ deleteGroup(group) {
144
+ return this.adapter.deleteGroup(group);
145
+ }
146
+ // ---- access reduction
147
+ async #signOut(userId, action) {
148
+ try {
149
+ await this.adapter.signOutEverywhere(userId);
150
+ }
151
+ catch (cause) {
152
+ throw new errors_1.AccessReductionIncompleteError({ userId, action, failedStep: "provider-sign-out", cause });
153
+ }
154
+ }
155
+ async #accessReduced(event) {
156
+ try {
157
+ await this.#onAccessReduced?.(event);
158
+ }
159
+ catch (cause) {
160
+ throw new errors_1.AccessReductionIncompleteError({ userId: event.userId, action: event.action, failedStep: "hook", cause });
161
+ }
162
+ }
163
+ }
164
+ exports.IdpAdmin = IdpAdmin;
165
+ function normalizeEmail(email) {
166
+ return email.trim().toLowerCase();
167
+ }
168
+ async function* all(page) {
169
+ let cursor = null;
170
+ do {
171
+ const result = await page(cursor);
172
+ yield* result.items;
173
+ cursor = result.nextCursor;
174
+ } while (cursor !== null);
175
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export type { AdapterCreateUserInput, AdapterInviteUserInput, IdpAdapter } from "./adapter";
2
2
  export { AccessReductionIncompleteError, GroupExistsError, GroupNotFoundError, IdpAdminError, NotSupportedError, UserExistsError, UserNotFoundError, type AccessReductionAction, type AccessReductionStep, type AnyIdpAdminError, } from "./errors";
3
+ export { IdpAdmin, type AccessReducedEvent, type IdpAdminOptions, type IdpAdminSupports } from "./idp-admin";
3
4
  export type { IdpUser, Page, UserAttributes, UserStatus } from "./types";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.UserNotFoundError = exports.UserExistsError = exports.NotSupportedError = exports.IdpAdminError = exports.GroupNotFoundError = exports.GroupExistsError = exports.AccessReductionIncompleteError = void 0;
3
+ exports.IdpAdmin = exports.UserNotFoundError = exports.UserExistsError = exports.NotSupportedError = exports.IdpAdminError = exports.GroupNotFoundError = exports.GroupExistsError = exports.AccessReductionIncompleteError = void 0;
4
4
  var errors_1 = require("./errors");
5
5
  Object.defineProperty(exports, "AccessReductionIncompleteError", { enumerable: true, get: function () { return errors_1.AccessReductionIncompleteError; } });
6
6
  Object.defineProperty(exports, "GroupExistsError", { enumerable: true, get: function () { return errors_1.GroupExistsError; } });
@@ -9,3 +9,5 @@ Object.defineProperty(exports, "IdpAdminError", { enumerable: true, get: functio
9
9
  Object.defineProperty(exports, "NotSupportedError", { enumerable: true, get: function () { return errors_1.NotSupportedError; } });
10
10
  Object.defineProperty(exports, "UserExistsError", { enumerable: true, get: function () { return errors_1.UserExistsError; } });
11
11
  Object.defineProperty(exports, "UserNotFoundError", { enumerable: true, get: function () { return errors_1.UserNotFoundError; } });
12
+ var idp_admin_1 = require("./idp-admin");
13
+ Object.defineProperty(exports, "IdpAdmin", { enumerable: true, get: function () { return idp_admin_1.IdpAdmin; } });
package/docs/fake.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `idp-admin/fake`: an identity provider that keeps users and groups in memory. [Index](index.md)
4
4
 
5
- It's the reference implementation every other adapter is tested against, and a provider for tests that need no container or cloud account. It implements every method of [`IdpAdapter`](index.md#idpadaptertattr), including the optional ones, and adds a few helpers only a fake can offer.
5
+ It's the reference implementation every other adapter is tested against, and a provider for tests that need no container or cloud account: wrap it in `IdpAdmin` like any other adapter, and use the helpers below to check what happened. It implements every method of [`IdpAdapter`](index.md#idpadaptertattr), including the optional ones, and adds a few helpers only a fake can offer.
6
6
 
7
7
  Not for production: nothing is shared between instances or kept after the process exits.
8
8
 
@@ -22,7 +22,7 @@ const fake = new FakeAdapter({ attributes: ["company"] })
22
22
  ## Behaviour
23
23
 
24
24
  - **Ids** are UUID v4s from `crypto.randomUUID()`.
25
- - **Emails** are matched exactly. The adapter contract expects them already lowercased and trimmed, so the fake doesn't lowercase again; pass them that way when calling it directly.
25
+ - **Emails** are matched exactly. They arrive already lowercased and trimmed (`IdpAdmin` normalises them), so the fake doesn't do it again.
26
26
  - **Status:**
27
27
  - a user from `createUser` or `createUserWithInvite` starts as `password_change_required`
28
28
  - `setPassword` with `temporary: true` sets `password_change_required`, and with `temporary: false` sets `active`
package/docs/index.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # idp-admin reference
2
2
 
3
- One file per published entry point. This file covers the core, `idp-admin`: the types every adapter shares, the errors, and the adapter contract.
3
+ One file per published entry point. This file covers the core, `idp-admin`: `IdpAdmin`, the types every adapter shares, the errors, and the adapter contract.
4
4
 
5
5
  | Entry point | Doc |
6
6
  |---|---|
@@ -9,7 +9,75 @@ One file per published entry point. This file covers the core, `idp-admin`: the
9
9
  | `idp-admin/cognito` | [cognito.md](cognito.md) |
10
10
  | `idp-admin/keycloak` | [keycloak.md](keycloak.md) |
11
11
 
12
- `IdpAdmin`, the class apps call, is not built yet. Until it is, the adapters are usable directly, but without the shared rules (email normalisation, the access-reduced hook, provider sign-out, paging, idempotent removal) that `IdpAdmin` adds.
12
+ ## `IdpAdmin`
13
+
14
+ What apps call. It wraps one adapter and adds the rules every provider shares.
15
+
16
+ ```ts
17
+ import { IdpAdmin } from "idp-admin"
18
+ import { CognitoAdapter } from "idp-admin/cognito"
19
+
20
+ const admin = new IdpAdmin(new CognitoAdapter({ userPoolId, region: "us-east-1" }), {
21
+ onAccessReduced: ({ userId }) => revokeAllSessionsFor(userId),
22
+ })
23
+ ```
24
+
25
+ `TAttr`, the attribute names, comes from the adapter, so `admin.createUser` accepts exactly the attributes that adapter declares.
26
+
27
+ ### Options
28
+
29
+ | Option | Default | Meaning |
30
+ |---|---|---|
31
+ | `onAccessReduced` | none | Called after a user is deleted, disabled or removed from a group, with an `AccessReducedEvent`. Use it to end the app's own sessions for that user. It may be sync or async. It can run more than once for the same change, so it must be safe to repeat. |
32
+
33
+ ```ts
34
+ type AccessReducedEvent =
35
+ | { userId: string; action: "deleted" | "disabled" }
36
+ | { userId: string; action: "removed-from-group"; group: string }
37
+ ```
38
+
39
+ ### Shared rules
40
+
41
+ - **Emails** are trimmed and lowercased before they reach the adapter, in `createUser`, `createUserWithInvite` and `findUserByEmail`. `Dana@Example.com` and `dana@example.com` are the same user.
42
+ - **Taking access away** (`deleteUser`, `disableUser`, `removeFromGroup`) runs these steps in order:
43
+ 1. the change at the provider
44
+ 2. provider sign-out (`signOutEverywhere`), for `disableUser` and `removeFromGroup`. Deleting already ends the user's tokens at the provider, so `deleteUser` skips it
45
+ 3. `onAccessReduced`
46
+
47
+ If step 2 or 3 fails, the method throws `AccessReductionIncompleteError` and the provider change stands. All three methods are safe to repeat and run every step each time, so calling the same method again is the recovery.
48
+ - **Provider sign-out revokes refresh tokens.** Access and ID tokens already issued stay valid until they expire (about an hour by default on Cognito). Ending the app's sessions in `onAccessReduced` is what takes effect immediately.
49
+ - **Lists** are async iterators over the adapter's pages. The next page is fetched only when the loop reaches it, and `break` stops fetching.
50
+ - **Optional features:** a method the adapter lacks throws `NotSupportedError`. `supports` tells you beforehand.
51
+
52
+ ### Methods
53
+
54
+ | Method | Returns | Behaviour |
55
+ |---|---|---|
56
+ | `createUser({ email, name?, attributes?, temporaryPassword })` | `IdpUser` | The user must replace `temporaryPassword` at first sign-in. The provider sends no message, so send your own. Throws `UserExistsError`. |
57
+ | `createUserWithInvite({ email, name?, attributes? })` | `IdpUser` | The provider generates a password and emails its own invitation. Throws `UserExistsError`, or `NotSupportedError` if the adapter can't. |
58
+ | `getUser(userId)` | `IdpUser` | Throws `UserNotFoundError`. |
59
+ | `findUserByEmail(email)` | `IdpUser \| null` | `null` when nobody has that email. |
60
+ | `listUsers()` | `AsyncGenerator<IdpUser>` | Every user. |
61
+ | `setPassword(userId, password, { temporary })` | `void` | A temporary password must be replaced at next sign-in. |
62
+ | `updateAttributes(userId, attributes)` | `void` | Sets the given attributes and leaves the rest unchanged. |
63
+ | `resendInvite(userId)` | `void` | Sends the provider's invitation again. Throws `NotSupportedError` if the adapter can't. |
64
+ | `enableUser(userId)` | `void` | |
65
+ | `disableUser(userId)` | `void` | Then provider sign-out, then `onAccessReduced`. Succeeds if already disabled. |
66
+ | `deleteUser(userId)` | `boolean` | Then `onAccessReduced`. `true` if a user was deleted, `false` if they were already gone; the hook runs either way. |
67
+ | `signOutEverywhere(userId)` | `void` | Revokes the user's tokens at the provider. Doesn't call `onAccessReduced`. |
68
+ | `addToGroup(userId, group)` | `void` | Succeeds if they're already a member. Throws `GroupNotFoundError`. |
69
+ | `removeFromGroup(userId, group)` | `void` | Then provider sign-out, then `onAccessReduced`. Succeeds if they aren't a member or the group doesn't exist. |
70
+ | `listUserGroups(userId)` | `AsyncGenerator<string>` | The names of the groups a user is in. |
71
+ | `listGroupMembers(group)` | `AsyncGenerator<IdpUser>` | Throws `GroupNotFoundError`. |
72
+ | `ensureGroup(group)` | `void` | Creates the group, or does nothing if it exists, including when another caller creates it at the same moment. |
73
+ | `deleteGroup(group)` | `void` | Removes every member first. Doesn't call `onAccessReduced`; to end members' sessions, call `removeFromGroup` for each before deleting it. |
74
+
75
+ Unless a row says otherwise, a method addressing a missing user throws `UserNotFoundError`.
76
+
77
+ | Property | Meaning |
78
+ |---|---|
79
+ | `adapter` | The adapter passed in, for its `native` client and anything the shared methods don't cover. Calls made on it directly skip the shared rules. |
80
+ | `supports` | `{ createUserWithInvite: boolean; resendInvite: boolean }`: which optional features the adapter has. |
13
81
 
14
82
  ## Types
15
83
 
@@ -87,7 +155,7 @@ Every error idp-admin throws on purpose extends `IdpAdminError`, so `instanceof
87
155
 
88
156
  ## `IdpAdapter<TAttr>`
89
157
 
90
- The contract every adapter implements: plain translation to one provider's admin API. Shared rules live in `IdpAdmin`, so an adapter can't get them wrong. Apps don't normally call adapters directly.
158
+ The contract every adapter implements: plain translation to one provider's admin API. Shared rules live in `IdpAdmin`, so an adapter can't get them wrong. Apps call `IdpAdmin`, not adapters; this section is for writing an adapter.
91
159
 
92
160
  Every user is addressed by `userId`, the provider's permanent id. A method that addresses a user who doesn't exist throws `UserNotFoundError`; one that addresses a group that doesn't exist throws `GroupNotFoundError`. Emails passed in are already lowercased and trimmed.
93
161
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idp-admin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "One user-management API for any identity provider.",
5
5
  "keywords": [
6
6
  "identity-provider",