idp-admin 0.1.2 → 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.
- package/README.md +21 -23
- package/dist/cognito/index.js +4 -3
- package/dist/fake/index.d.ts +6 -0
- package/dist/fake/index.js +10 -0
- package/dist/idp-admin.d.ts +83 -0
- package/dist/idp-admin.js +175 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/types.d.ts +3 -1
- package/docs/cognito.md +2 -1
- package/docs/fake.md +12 -3
- package/docs/index.md +73 -4
- package/docs/keycloak.md +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +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.
|
|
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).
|
|
10
10
|
|
|
11
11
|
## Why it exists
|
|
12
12
|
|
|
@@ -42,15 +42,15 @@ npm install idp-admin
|
|
|
42
42
|
npm install @aws-sdk/client-cognito-identity-provider # only for Cognito
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
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
46
|
|
|
47
47
|
```ts
|
|
48
|
-
import
|
|
48
|
+
import { IdpAdmin } from "idp-admin"
|
|
49
49
|
import { CognitoAdapter } from "idp-admin/cognito"
|
|
50
50
|
import { KeycloakAdapter } from "idp-admin/keycloak"
|
|
51
51
|
|
|
52
52
|
// Cognito in production, Keycloak in a container locally: the rest of the app doesn't know which
|
|
53
|
-
const
|
|
53
|
+
const adapter = process.env.COGNITO_USER_POOL_ID
|
|
54
54
|
? new CognitoAdapter({
|
|
55
55
|
userPoolId: process.env.COGNITO_USER_POOL_ID,
|
|
56
56
|
region: "us-east-1",
|
|
@@ -62,6 +62,11 @@ const idp: IdpAdapter<"company"> = process.env.COGNITO_USER_POOL_ID
|
|
|
62
62
|
clientSecret: process.env.KEYCLOAK_ADMIN_SECRET!,
|
|
63
63
|
attributes: { company: "companyName" },
|
|
64
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
|
+
})
|
|
65
70
|
```
|
|
66
71
|
|
|
67
72
|
`attributes` maps the names your app uses to the provider's own, and only those names compile.
|
|
@@ -71,36 +76,28 @@ import { UserExistsError } from "idp-admin"
|
|
|
71
76
|
|
|
72
77
|
// create a user who must set their own password at first sign-in; send them the temporary one yourself
|
|
73
78
|
try{
|
|
74
|
-
const user = await
|
|
75
|
-
email: "dana@example.com
|
|
79
|
+
const user = await admin.createUser({
|
|
80
|
+
email: "Dana@Example.com", // stored and matched as dana@example.com
|
|
76
81
|
name: "Dana Lee",
|
|
77
82
|
attributes: { company: "Acme" },
|
|
78
83
|
temporaryPassword: "Temp-pass-1!",
|
|
79
84
|
})
|
|
80
|
-
|
|
81
|
-
await
|
|
85
|
+
await admin.ensureGroup("admins")
|
|
86
|
+
await admin.addToGroup(user.id, "admins")
|
|
82
87
|
}catch(error){
|
|
83
88
|
if(error instanceof UserExistsError) console.log("already invited")
|
|
84
89
|
else throw error
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
// take access away
|
|
88
|
-
const dana = await
|
|
89
|
-
if(dana)
|
|
90
|
-
await idp.setEnabled(dana.id, false)
|
|
91
|
-
await idp.signOutEverywhere(dana.id)
|
|
92
|
-
}
|
|
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)
|
|
93
95
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
do{
|
|
97
|
-
const page = await idp.listUsersPage(cursor)
|
|
98
|
-
for(const user of page.items) console.log(user.email, user.status, user.enabled)
|
|
99
|
-
cursor = page.nextCursor
|
|
100
|
-
}while(cursor)
|
|
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)
|
|
101
98
|
```
|
|
102
99
|
|
|
103
|
-
A user's `id` is the `sub` in their sign-in tokens, so it matches what your auth library gives you
|
|
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).
|
|
104
101
|
|
|
105
102
|
In tests, use the fake instead. It keeps everything in memory and records what a real provider would have done:
|
|
106
103
|
|
|
@@ -108,7 +105,8 @@ In tests, use the fake instead. It keeps everything in memory and records what a
|
|
|
108
105
|
import { FakeAdapter } from "idp-admin/fake"
|
|
109
106
|
|
|
110
107
|
const fake = new FakeAdapter({ attributes: ["company"] })
|
|
111
|
-
|
|
108
|
+
const admin = new IdpAdmin(fake, { onAccessReduced })
|
|
109
|
+
// ...run the code under test with `admin`, then:
|
|
112
110
|
expect(fake.signOuts).toContain(userId)
|
|
113
111
|
```
|
|
114
112
|
|
package/dist/cognito/index.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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/fake/index.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/fake/index.js
CHANGED
|
@@ -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
|
}
|
|
@@ -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/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
|
|
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
|
@@ -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,11 +22,12 @@ 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.
|
|
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`
|
|
29
|
-
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
|
@@ -38,13 +106,14 @@ type IdpUser<TAttr extends string = never> = {
|
|
|
38
106
|
### `UserStatus`
|
|
39
107
|
|
|
40
108
|
```ts
|
|
41
|
-
type UserStatus = "active" | "password_change_required" | "federated" | "unconfirmed"
|
|
109
|
+
type UserStatus = "active" | "password_change_required" | "password_reset_required" | "federated" | "unconfirmed"
|
|
42
110
|
```
|
|
43
111
|
|
|
44
112
|
| Value | Meaning |
|
|
45
113
|
|---|---|
|
|
46
114
|
| `active` | Has a permanent password, or otherwise needs nothing before signing in. |
|
|
47
115
|
| `password_change_required` | Has a temporary password and must set their own at next sign-in. |
|
|
116
|
+
| `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. |
|
|
48
117
|
| `federated` | Signs in through another identity provider (SSO), so has no password here. |
|
|
49
118
|
| `unconfirmed` | Signed themselves up and hasn't confirmed their account yet. |
|
|
50
119
|
|
|
@@ -87,7 +156,7 @@ Every error idp-admin throws on purpose extends `IdpAdminError`, so `instanceof
|
|
|
87
156
|
|
|
88
157
|
## `IdpAdapter<TAttr>`
|
|
89
158
|
|
|
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
|
|
159
|
+
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
160
|
|
|
92
161
|
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
162
|
|
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.
|