idp-admin 0.2.0 → 0.4.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
@@ -72,7 +72,7 @@ const admin = new IdpAdmin(adapter, {
72
72
  `attributes` maps the names your app uses to the provider's own, and only those names compile.
73
73
 
74
74
  ```ts
75
- import { UserExistsError } from "idp-admin"
75
+ import { UserExistsError, generateTemporaryPassword } from "idp-admin"
76
76
 
77
77
  // create a user who must set their own password at first sign-in; send them the temporary one yourself
78
78
  try{
@@ -80,7 +80,7 @@ try{
80
80
  email: "Dana@Example.com", // stored and matched as dana@example.com
81
81
  name: "Dana Lee",
82
82
  attributes: { company: "Acme" },
83
- temporaryPassword: "Temp-pass-1!",
83
+ temporaryPassword: generateTemporaryPassword(),
84
84
  })
85
85
  await admin.ensureGroup("admins")
86
86
  await admin.addToGroup(user.id, "admins")
@@ -6,9 +6,10 @@ const errors_1 = require("../errors");
6
6
  const STATUSES = {
7
7
  CONFIRMED: "active",
8
8
  FORCE_CHANGE_PASSWORD: "password_change_required",
9
- RESET_REQUIRED: "password_change_required",
10
- // Cognito suspects the password leaked, so the user must set a new one
11
- COMPROMISED: "password_change_required",
9
+ // an admin reset the password; the user must use the forgot-password code to set a new one
10
+ RESET_REQUIRED: "password_reset_required",
11
+ // Cognito suspects the password leaked, so the user must reset it the same way
12
+ COMPROMISED: "password_reset_required",
12
13
  UNCONFIRMED: "unconfirmed",
13
14
  EXTERNAL_PROVIDER: "federated",
14
15
  };
package/dist/errors.d.ts CHANGED
@@ -40,18 +40,22 @@ export type AccessReductionStep = "provider-sign-out" | "hook";
40
40
  /**
41
41
  * The provider change happened, but a step after it didn't, so the user may still have live sessions
42
42
  * or provider tokens. The provider change is not undone. Calling the same method again is safe: it
43
- * repeats every step.
43
+ * repeats every step. The exception is `deleteGroup`, which stops at the member whose step failed and
44
+ * leaves the group in place: call `removeFromGroup` for that member and group, then `deleteGroup` again.
44
45
  */
45
46
  export declare class AccessReductionIncompleteError extends IdpAdminError {
46
47
  readonly code = "ACCESS_REDUCTION_INCOMPLETE";
47
48
  readonly userId: string;
48
49
  readonly action: AccessReductionAction;
50
+ readonly group: string | undefined;
49
51
  readonly failedStep: AccessReductionStep;
50
52
  constructor(details: {
51
53
  userId: string;
52
54
  action: AccessReductionAction;
55
+ group?: string;
53
56
  failedStep: AccessReductionStep;
54
57
  cause: unknown;
58
+ deletingGroup?: boolean;
55
59
  });
56
60
  }
57
61
  /** Every idp-admin error, as a union, so `switch(error.code)` narrows to the matching class. */
package/dist/errors.js CHANGED
@@ -71,19 +71,28 @@ const STEP_TEXT = {
71
71
  /**
72
72
  * The provider change happened, but a step after it didn't, so the user may still have live sessions
73
73
  * or provider tokens. The provider change is not undone. Calling the same method again is safe: it
74
- * repeats every step.
74
+ * repeats every step. The exception is `deleteGroup`, which stops at the member whose step failed and
75
+ * leaves the group in place: call `removeFromGroup` for that member and group, then `deleteGroup` again.
75
76
  */
76
77
  class AccessReductionIncompleteError extends IdpAdminError {
77
78
  code = "ACCESS_REDUCTION_INCOMPLETE";
78
79
  userId;
79
80
  action;
81
+ // the group the user was removed from, for `removed-from-group`
82
+ group;
80
83
  failedStep;
81
84
  constructor(details) {
82
85
  const reason = details.cause instanceof Error ? details.cause.message : String(details.cause);
83
- super(`User ${details.userId} was ${ACTION_TEXT[details.action]} at the provider, but ${STEP_TEXT[details.failedStep]}: ${reason}. ` +
84
- "Calling the same method again is safe and retries it.", { cause: details.cause });
86
+ const action = details.action === "removed-from-group" && details.group !== undefined
87
+ ? `removed from group ${details.group}`
88
+ : ACTION_TEXT[details.action];
89
+ const recovery = details.deletingGroup
90
+ ? "The group wasn't deleted. Call removeFromGroup for this user and group, then deleteGroup again."
91
+ : "Calling the same method again is safe and retries it.";
92
+ super(`User ${details.userId} was ${action} at the provider, but ${STEP_TEXT[details.failedStep]}: ${reason}. ${recovery}`, { cause: details.cause });
85
93
  this.userId = details.userId;
86
94
  this.action = details.action;
95
+ this.group = details.group;
87
96
  this.failedStep = details.failedStep;
88
97
  }
89
98
  }
@@ -27,6 +27,12 @@ export declare class FakeAdapter<const TAttr extends string = never> implements
27
27
  name?: string;
28
28
  attributes?: UserAttributes<TAttr>;
29
29
  }): IdpUser<TAttr>;
30
+ /**
31
+ * Requires the user to reset their password, as an admin reset at a real provider does, so their
32
+ * status becomes `password_reset_required` until a password is set. Only the fake can do this
33
+ * directly. A federated user is unaffected. Synchronous. @throws {UserNotFoundError}
34
+ */
35
+ requirePasswordReset(userId: string): void;
30
36
  createUser(input: AdapterCreateUserInput<TAttr>): Promise<IdpUser<TAttr>>;
31
37
  createUserWithInvite(input: AdapterInviteUserInput<TAttr>): Promise<IdpUser<TAttr>>;
32
38
  getUser(userId: string): Promise<IdpUser<TAttr>>;
@@ -33,6 +33,16 @@ class FakeAdapter {
33
33
  addFederatedUser(input) {
34
34
  return copy(this.#add(input, "federated"));
35
35
  }
36
+ /**
37
+ * Requires the user to reset their password, as an admin reset at a real provider does, so their
38
+ * status becomes `password_reset_required` until a password is set. Only the fake can do this
39
+ * directly. A federated user is unaffected. Synchronous. @throws {UserNotFoundError}
40
+ */
41
+ requirePasswordReset(userId) {
42
+ const user = this.#user(userId);
43
+ if (user.status !== "federated")
44
+ user.status = "password_reset_required";
45
+ }
36
46
  async createUser(input) {
37
47
  return copy(this.#add(input, "password_change_required"));
38
48
  }
@@ -78,6 +78,11 @@ export declare class IdpAdmin<TAttr extends string = never> {
78
78
  listGroupMembers(group: string): AsyncGenerator<IdpUser<TAttr>>;
79
79
  /** Creates the group, or does nothing if it exists. */
80
80
  ensureGroup(group: string): Promise<void>;
81
- /** Deletes the group, removing every member from it first. @throws {GroupNotFoundError} */
81
+ /**
82
+ * Removes every member from the group the way `removeFromGroup` does (signed out at the provider, then
83
+ * `onAccessReduced`), then deletes the group. If a member's step fails it stops there and leaves the
84
+ * group in place: call `removeFromGroup` for that member, then `deleteGroup` again.
85
+ * @throws {GroupNotFoundError} @throws {AccessReductionIncompleteError} a member is removed, but a later step failed
86
+ */
82
87
  deleteGroup(group: string): Promise<void>;
83
88
  }
package/dist/idp-admin.js CHANGED
@@ -73,8 +73,7 @@ class IdpAdmin {
73
73
  */
74
74
  async disableUser(userId) {
75
75
  await this.adapter.setEnabled(userId, false);
76
- await this.#signOut(userId, "disabled");
77
- await this.#accessReduced({ userId, action: "disabled" });
76
+ await this.#reduced({ userId, action: "disabled" }, { signOut: true });
78
77
  }
79
78
  /**
80
79
  * Deletes the user, then calls `onAccessReduced`, also when the user was already gone. Deleting ends
@@ -92,7 +91,7 @@ class IdpAdmin {
92
91
  throw error;
93
92
  deleted = false;
94
93
  }
95
- await this.#accessReduced({ userId, action: "deleted" });
94
+ await this.#reduced({ userId, action: "deleted" }, { signOut: false });
96
95
  return deleted;
97
96
  }
98
97
  // ---- groups
@@ -115,8 +114,7 @@ class IdpAdmin {
115
114
  // nobody is in a group that doesn't exist, but the user must still exist
116
115
  await this.adapter.getUser(userId);
117
116
  }
118
- await this.#signOut(userId, "removed-from-group");
119
- await this.#accessReduced({ userId, action: "removed-from-group", group });
117
+ await this.#reduced({ userId, action: "removed-from-group", group }, { signOut: true });
120
118
  }
121
119
  /** The names of every group the user is in. @throws {UserNotFoundError} */
122
120
  listUserGroups(userId) {
@@ -139,25 +137,47 @@ class IdpAdmin {
139
137
  throw error;
140
138
  }
141
139
  }
142
- /** Deletes the group, removing every member from it first. @throws {GroupNotFoundError} */
143
- deleteGroup(group) {
144
- return this.adapter.deleteGroup(group);
140
+ /**
141
+ * Removes every member from the group the way `removeFromGroup` does (signed out at the provider, then
142
+ * `onAccessReduced`), then deletes the group. If a member's step fails it stops there and leaves the
143
+ * group in place: call `removeFromGroup` for that member, then `deleteGroup` again.
144
+ * @throws {GroupNotFoundError} @throws {AccessReductionIncompleteError} a member is removed, but a later step failed
145
+ */
146
+ async deleteGroup(group) {
147
+ // collected first, because removing members while paging through them would skip some
148
+ const members = [];
149
+ for await (const member of this.listGroupMembers(group))
150
+ members.push(member.id);
151
+ for (const userId of members) {
152
+ await this.adapter.removeFromGroup(userId, group);
153
+ await this.#reduced({ userId, action: "removed-from-group", group }, { signOut: true, deletingGroup: true });
154
+ }
155
+ await this.adapter.deleteGroup(group);
145
156
  }
146
157
  // ---- 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 });
158
+ // the steps after a provider change that reduced someone's access: provider sign-out, then the hook
159
+ async #reduced(event, options) {
160
+ const incomplete = (failedStep, cause) => new errors_1.AccessReductionIncompleteError({
161
+ userId: event.userId,
162
+ action: event.action,
163
+ group: event.action === "removed-from-group" ? event.group : undefined,
164
+ failedStep,
165
+ cause,
166
+ deletingGroup: options.deletingGroup,
167
+ });
168
+ if (options.signOut) {
169
+ try {
170
+ await this.adapter.signOutEverywhere(event.userId);
171
+ }
172
+ catch (cause) {
173
+ throw incomplete("provider-sign-out", cause);
174
+ }
153
175
  }
154
- }
155
- async #accessReduced(event) {
156
176
  try {
157
177
  await this.#onAccessReduced?.(event);
158
178
  }
159
179
  catch (cause) {
160
- throw new errors_1.AccessReductionIncompleteError({ userId: event.userId, action: event.action, failedStep: "hook", cause });
180
+ throw incomplete("hook", cause);
161
181
  }
162
182
  }
163
183
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
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
3
  export { IdpAdmin, type AccessReducedEvent, type IdpAdminOptions, type IdpAdminSupports } from "./idp-admin";
4
+ export { generateTemporaryPassword, type TemporaryPasswordOptions } from "./passwords";
4
5
  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.IdpAdmin = exports.UserNotFoundError = exports.UserExistsError = exports.NotSupportedError = exports.IdpAdminError = exports.GroupNotFoundError = exports.GroupExistsError = exports.AccessReductionIncompleteError = void 0;
3
+ exports.generateTemporaryPassword = 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; } });
@@ -11,3 +11,5 @@ Object.defineProperty(exports, "UserExistsError", { enumerable: true, get: funct
11
11
  Object.defineProperty(exports, "UserNotFoundError", { enumerable: true, get: function () { return errors_1.UserNotFoundError; } });
12
12
  var idp_admin_1 = require("./idp-admin");
13
13
  Object.defineProperty(exports, "IdpAdmin", { enumerable: true, get: function () { return idp_admin_1.IdpAdmin; } });
14
+ var passwords_1 = require("./passwords");
15
+ Object.defineProperty(exports, "generateTemporaryPassword", { enumerable: true, get: function () { return passwords_1.generateTemporaryPassword; } });
@@ -0,0 +1,10 @@
1
+ export type TemporaryPasswordOptions = {
2
+ length?: number;
3
+ };
4
+ /**
5
+ * A random temporary password for `createUser` or `setPassword`, from Node's cryptographic random
6
+ * source. It always has at least one uppercase letter, lowercase letter, digit and symbol, so default
7
+ * password policies accept it, and is 16 characters unless `length` says otherwise. A provider policy
8
+ * stricter than that (a longer minimum, say) needs a matching `length`.
9
+ */
10
+ export declare function generateTemporaryPassword(options?: TemporaryPasswordOptions): string;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.generateTemporaryPassword = generateTemporaryPassword;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
6
+ const LOWER = "abcdefghijklmnopqrstuvwxyz";
7
+ const DIGITS = "0123456789";
8
+ // a subset every provider accepts, and that needs no escaping in an email or a URL
9
+ const SYMBOLS = "!@#$%^&*";
10
+ const ALL = UPPER + LOWER + DIGITS + SYMBOLS;
11
+ /**
12
+ * A random temporary password for `createUser` or `setPassword`, from Node's cryptographic random
13
+ * source. It always has at least one uppercase letter, lowercase letter, digit and symbol, so default
14
+ * password policies accept it, and is 16 characters unless `length` says otherwise. A provider policy
15
+ * stricter than that (a longer minimum, say) needs a matching `length`.
16
+ */
17
+ function generateTemporaryPassword(options = {}) {
18
+ const length = options.length ?? 16;
19
+ if (!Number.isInteger(length) || length < 8 || length > 256) {
20
+ throw new RangeError(`A temporary password's length must be a whole number from 8 to 256, not ${length}`);
21
+ }
22
+ const characters = [UPPER, LOWER, DIGITS, SYMBOLS].map(pick);
23
+ while (characters.length < length)
24
+ characters.push(pick(ALL));
25
+ // shuffle, so the guaranteed characters aren't always first
26
+ for (let i = characters.length - 1; i > 0; i--) {
27
+ const j = (0, node_crypto_1.randomInt)(i + 1);
28
+ const swapped = characters[i];
29
+ characters[i] = characters[j];
30
+ characters[j] = swapped;
31
+ }
32
+ return characters.join("");
33
+ }
34
+ function pick(from) {
35
+ return from[(0, node_crypto_1.randomInt)(from.length)];
36
+ }
package/dist/types.d.ts CHANGED
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * - `active`: has a permanent password, or otherwise needs nothing before signing in
5
5
  * - `password_change_required`: has a temporary password and must set their own at next sign-in
6
+ * - `password_reset_required`: must reset their password through the provider's forgot-password flow;
7
+ * a temporary password won't sign them in
6
8
  * - `federated`: signs in through another identity provider (SSO), so has no password here
7
9
  * - `unconfirmed`: signed themselves up and hasn't confirmed their account yet
8
10
  */
9
- export type UserStatus = "active" | "password_change_required" | "federated" | "unconfirmed";
11
+ export type UserStatus = "active" | "password_change_required" | "password_reset_required" | "federated" | "unconfirmed";
10
12
  /**
11
13
  * Custom attributes, keyed by the names the app declared in its adapter's config. Values are strings,
12
14
  * because that is what providers store; convert other types yourself. With none declared, no key is
package/docs/cognito.md CHANGED
@@ -47,7 +47,8 @@ Every one of them can be limited to the pool's ARN, `arn:aws:cognito-idp:<region
47
47
  | Cognito `UserStatus` | `status` |
48
48
  |---|---|
49
49
  | `CONFIRMED` | `active` |
50
- | `FORCE_CHANGE_PASSWORD`, `RESET_REQUIRED`, `COMPROMISED` | `password_change_required` |
50
+ | `FORCE_CHANGE_PASSWORD` | `password_change_required` |
51
+ | `RESET_REQUIRED`, `COMPROMISED` | `password_reset_required` |
51
52
  | `UNCONFIRMED` | `unconfirmed` |
52
53
  | `EXTERNAL_PROVIDER` | `federated` |
53
54
  | anything else | `active` |
package/docs/fake.md CHANGED
@@ -26,7 +26,8 @@ const fake = new FakeAdapter({ attributes: ["company"] })
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`
29
- - a federated user stays `federated` whatever `setPassword` does, because they sign in elsewhere
29
+ - `requirePasswordReset` sets `password_reset_required`, until a password is set
30
+ - a federated user stays `federated` whatever `setPassword` or `requirePasswordReset` does, because they sign in elsewhere
30
31
  - **Disabling** changes `enabled` only; the status is kept.
31
32
  - **Lists** return items in creation order. A cursor is the offset of the next page's first item.
32
33
  - **Copies:** every user returned is a copy, so changing it doesn't change the stored user.
@@ -42,6 +43,14 @@ const admin = fake.addFederatedUser({ email: "admin@mc3.co", name: "An Admin", a
42
43
 
43
44
  Adds a user who signs in through another identity provider (SSO), with status `federated`. A real provider creates these when someone first signs in through SSO, so only the fake can add one directly. Synchronous. Throws `UserExistsError` if the email is taken.
44
45
 
46
+ ### `requirePasswordReset(userId)`
47
+
48
+ ```ts
49
+ fake.requirePasswordReset(user.id)
50
+ ```
51
+
52
+ Puts the user into `password_reset_required`, as an admin reset at a real provider does, so tests can cover that state. It lasts until a password is set with `setPassword`. A federated user is unaffected. Synchronous. Throws `UserNotFoundError` for an unknown id.
53
+
45
54
  ### `invitations`
46
55
 
47
56
  ```ts
package/docs/index.md CHANGED
@@ -39,12 +39,12 @@ type AccessReducedEvent =
39
39
  ### Shared rules
40
40
 
41
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:
42
+ - **Taking access away** (`deleteUser`, `disableUser`, `removeFromGroup`, and `deleteGroup` for each member) runs these steps in order:
43
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
44
+ 2. provider sign-out (`signOutEverywhere`), for everything but `deleteUser`. Deleting already ends the user's tokens at the provider, so `deleteUser` skips it
45
45
  3. `onAccessReduced`
46
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.
47
+ If step 2 or 3 fails, the method throws `AccessReductionIncompleteError` and the provider change stands. `deleteUser`, `disableUser` and `removeFromGroup` are safe to repeat and run every step each time, so calling the same method again is the recovery. `deleteGroup` stops at the member whose step failed and leaves the group in place; the member is already out of it, so calling `deleteGroup` again wouldn't reach them. Call `removeFromGroup(error.userId, group)`, then `deleteGroup` again. The error's message says so.
48
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
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
50
  - **Optional features:** a method the adapter lacks throws `NotSupportedError`. `supports` tells you beforehand.
@@ -70,7 +70,7 @@ type AccessReducedEvent =
70
70
  | `listUserGroups(userId)` | `AsyncGenerator<string>` | The names of the groups a user is in. |
71
71
  | `listGroupMembers(group)` | `AsyncGenerator<IdpUser>` | Throws `GroupNotFoundError`. |
72
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. |
73
+ | `deleteGroup(group)` | `void` | Removes every member as `removeFromGroup` does (provider sign-out, then `onAccessReduced` for each), then deletes the group. Throws `GroupNotFoundError`. See **Taking access away** for recovering from a failure part way. |
74
74
 
75
75
  Unless a row says otherwise, a method addressing a missing user throws `UserNotFoundError`.
76
76
 
@@ -79,6 +79,22 @@ Unless a row says otherwise, a method addressing a missing user throws `UserNotF
79
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
80
  | `supports` | `{ createUserWithInvite: boolean; resendInvite: boolean }`: which optional features the adapter has. |
81
81
 
82
+ ## `generateTemporaryPassword(options?)`
83
+
84
+ ```ts
85
+ import { generateTemporaryPassword } from "idp-admin"
86
+
87
+ await admin.createUser({ email, temporaryPassword: generateTemporaryPassword() })
88
+ ```
89
+
90
+ A random password for `createUser` or a temporary `setPassword`, from Node's cryptographic random source (`crypto.randomInt`).
91
+
92
+ | Option | Default | Meaning |
93
+ |---|---|---|
94
+ | `length` | `16` | A whole number from 8 to 256. Anything else throws a `RangeError`. |
95
+
96
+ It always contains at least one uppercase letter, lowercase letter, digit and symbol, in random positions, so the default password policies of Cognito and Keycloak accept it. Symbols are from `!@#$%^&*`, which every provider accepts and which need no escaping in an email or a URL. If your pool or realm requires more than 16 characters, pass a matching `length`.
97
+
82
98
  ## Types
83
99
 
84
100
  ### `IdpUser<TAttr>`
@@ -106,13 +122,14 @@ type IdpUser<TAttr extends string = never> = {
106
122
  ### `UserStatus`
107
123
 
108
124
  ```ts
109
- type UserStatus = "active" | "password_change_required" | "federated" | "unconfirmed"
125
+ type UserStatus = "active" | "password_change_required" | "password_reset_required" | "federated" | "unconfirmed"
110
126
  ```
111
127
 
112
128
  | Value | Meaning |
113
129
  |---|---|
114
130
  | `active` | Has a permanent password, or otherwise needs nothing before signing in. |
115
131
  | `password_change_required` | Has a temporary password and must set their own at next sign-in. |
132
+ | `password_reset_required` | Must reset their password through the provider's forgot-password flow. A temporary password won't sign them in. Only some providers report it. |
116
133
  | `federated` | Signs in through another identity provider (SSO), so has no password here. |
117
134
  | `unconfirmed` | Signed themselves up and hasn't confirmed their account yet. |
118
135
 
@@ -145,13 +162,14 @@ Every error idp-admin throws on purpose extends `IdpAdminError`, so `instanceof
145
162
  | `GroupNotFoundError` | `GROUP_NOT_FOUND` | `group` | The group doesn't exist. |
146
163
  | `GroupExistsError` | `GROUP_EXISTS` | `group` | Creating a group that exists. |
147
164
  | `NotSupportedError` | `NOT_SUPPORTED` | `feature` | The adapter can't do what was asked; `feature` names the method or option. |
148
- | `AccessReductionIncompleteError` | `ACCESS_REDUCTION_INCOMPLETE` | `userId`, `action`, `failedStep`, `cause` | A user was deleted, disabled or removed from a group at the provider, but a later step failed. |
165
+ | `AccessReductionIncompleteError` | `ACCESS_REDUCTION_INCOMPLETE` | `userId`, `action`, `group`, `failedStep`, `cause` | A user was deleted, disabled or removed from a group at the provider, but a later step failed. |
149
166
 
150
167
  `AccessReductionIncompleteError`:
151
168
  - `action` is `"deleted"`, `"disabled"` or `"removed-from-group"`: what already happened at the provider. It is not undone.
169
+ - `group` is the group they were removed from, for `"removed-from-group"`; otherwise `undefined`.
152
170
  - `failedStep` is `"provider-sign-out"` or `"hook"`: the step after it that failed.
153
171
  - `cause` is the original error.
154
- - Calling the same method again is safe and repeats every step.
172
+ - Calling the same method again is safe and repeats every step, except after `deleteGroup`: call `removeFromGroup(userId, group)`, then `deleteGroup` again.
155
173
 
156
174
  ## `IdpAdapter<TAttr>`
157
175
 
package/docs/keycloak.md CHANGED
@@ -43,6 +43,7 @@ Also worth knowing: by default Keycloak requires a first and last name before an
43
43
  - `federated`: the user is linked to an identity provider (they signed in through SSO). This takes precedence over everything else.
44
44
  - `password_change_required`: the user has Keycloak's `UPDATE_PASSWORD` required action, which a temporary password sets.
45
45
  - `unconfirmed`: the user has the `VERIFY_EMAIL` required action.
46
+ - never `password_reset_required`: an admin reset in Keycloak sets the same `UPDATE_PASSWORD` action as a temporary password, so it reports `password_change_required`.
46
47
  - `active`: anything else.
47
48
  - **Attributes** are stored as single-value Keycloak attributes under their Keycloak names. `updateAttributes` keeps every attribute the app didn't declare, including ones set by other apps.
48
49
  - **Groups** are top-level realm groups, addressed by exact name. Subgroups aren't supported.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idp-admin",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "One user-management API for any identity provider.",
5
5
  "keywords": [
6
6
  "identity-provider",
@@ -76,5 +76,10 @@
76
76
  "jest": "^30.5.2",
77
77
  "tsx": "^4.23.15",
78
78
  "typescript": "5.9.3"
79
+ },
80
+ "overrides": {
81
+ "@istanbuljs/load-nyc-config": {
82
+ "js-yaml": "^4.1.0"
83
+ }
79
84
  }
80
85
  }