idp-admin 0.0.0-stage → 0.1.1

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/docs/fake.md ADDED
@@ -0,0 +1,59 @@
1
+ # fake
2
+
3
+ `idp-admin/fake`: an identity provider that keeps users and groups in memory. [Index](index.md)
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.
6
+
7
+ Not for production: nothing is shared between instances or kept after the process exits.
8
+
9
+ ## `new FakeAdapter(options?)`
10
+
11
+ ```ts
12
+ import { FakeAdapter } from "idp-admin/fake"
13
+
14
+ const fake = new FakeAdapter({ attributes: ["company"] })
15
+ ```
16
+
17
+ | Option | Default | Meaning |
18
+ |---|---|---|
19
+ | `attributes` | none | Custom attribute names users may have. They become the adapter's attribute type, so only these compile, and anything else is also rejected at runtime with a `TypeError`, as a real provider would. |
20
+ | `pageSize` | `60` | Most items per page from the list methods. `60` is Cognito's largest page; set it low to test paging. |
21
+
22
+ ## Behaviour
23
+
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.
26
+ - **Status:**
27
+ - a user from `createUser` or `createUserWithInvite` starts as `password_change_required`
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
30
+ - **Disabling** changes `enabled` only; the status is kept.
31
+ - **Lists** return items in creation order. A cursor is the offset of the next page's first item.
32
+ - **Copies:** every user returned is a copy, so changing it doesn't change the stored user.
33
+ - **Deleting** a user also frees their email and removes them from every group. Deleting a group removes it from its members.
34
+
35
+ ## Fake-only helpers
36
+
37
+ ### `addFederatedUser(input)`
38
+
39
+ ```ts
40
+ const admin = fake.addFederatedUser({ email: "admin@mc3.co", name: "An Admin", attributes: { company: "MC3" } })
41
+ ```
42
+
43
+ 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
+ ### `invitations`
46
+
47
+ ```ts
48
+ fake.invitations // readonly { userId: string; email: string }[]
49
+ ```
50
+
51
+ Every provider invitation the fake would have sent, oldest first: one per `createUserWithInvite` and one per `resendInvite`. `createUser` sends none, because the app sends its own email.
52
+
53
+ ### `signOuts`
54
+
55
+ ```ts
56
+ fake.signOuts // readonly string[]
57
+ ```
58
+
59
+ The id of every user signed out at the provider, oldest first, once per `signOutEverywhere` call.
package/docs/index.md ADDED
@@ -0,0 +1,115 @@
1
+ # idp-admin reference
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.
4
+
5
+ | Entry point | Doc |
6
+ |---|---|
7
+ | `idp-admin` | This file |
8
+ | `idp-admin/fake` | [fake.md](fake.md) |
9
+ | `idp-admin/cognito` | [cognito.md](cognito.md) |
10
+ | `idp-admin/keycloak` | [keycloak.md](keycloak.md) |
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.
13
+
14
+ ## Types
15
+
16
+ ### `IdpUser<TAttr>`
17
+
18
+ ```ts
19
+ type IdpUser<TAttr extends string = never> = {
20
+ id: string
21
+ email: string
22
+ name: string | null
23
+ status: UserStatus
24
+ enabled: boolean
25
+ attributes: UserAttributes<TAttr>
26
+ }
27
+ ```
28
+
29
+ | Field | Meaning |
30
+ |---|---|
31
+ | `id` | The provider's permanent id for the user: the one their sign-in tokens carry as `sub`. Cognito: the user's `sub`. Keycloak: the user id. Fake: a UUID v4. |
32
+ | `email` | Always lowercase. |
33
+ | `name` | `null` when the provider has none. |
34
+ | `status` | Where they are in their account lifecycle. See `UserStatus`. |
35
+ | `enabled` | `false` once disabled. A disabled user can't sign in, whatever their `status`. |
36
+ | `attributes` | Custom attributes, keyed by the names declared in the adapter's config. |
37
+
38
+ ### `UserStatus`
39
+
40
+ ```ts
41
+ type UserStatus = "active" | "password_change_required" | "federated" | "unconfirmed"
42
+ ```
43
+
44
+ | Value | Meaning |
45
+ |---|---|
46
+ | `active` | Has a permanent password, or otherwise needs nothing before signing in. |
47
+ | `password_change_required` | Has a temporary password and must set their own at next sign-in. |
48
+ | `federated` | Signs in through another identity provider (SSO), so has no password here. |
49
+ | `unconfirmed` | Signed themselves up and hasn't confirmed their account yet. |
50
+
51
+ Whether a user is disabled is `enabled`, not a status: a user disabled before setting a password is still `password_change_required`.
52
+
53
+ ### `UserAttributes<TAttr>`
54
+
55
+ ```ts
56
+ type UserAttributes<TAttr extends string> = [TAttr] extends [never] ? Record<string, never> : Partial<Record<TAttr, string>>
57
+ ```
58
+
59
+ `TAttr` is the attribute names declared in the adapter's config, inferred from it; no type parameter is written by hand. Only declared names compile, and with none declared no attribute compiles. Values are strings, because that is what providers store.
60
+
61
+ ### `Page<T>`
62
+
63
+ ```ts
64
+ type Page<T> = { items: T[]; nextCursor: string | null }
65
+ ```
66
+
67
+ One page of a list. Pass `nextCursor` back to get the next page; it is `null` on the last page. Cursors are opaque: don't build or change them.
68
+
69
+ ## Errors
70
+
71
+ Every error idp-admin throws on purpose extends `IdpAdminError`, so `instanceof IdpAdminError` separates them from provider and network failures. Each has a fixed `code`, typed as a literal, and `name` set to its class name. `AnyIdpAdminError` is the union of all of them, so `switch(error.code)` narrows to the matching class.
72
+
73
+ | Class | `code` | Extra fields | Thrown when |
74
+ |---|---|---|---|
75
+ | `UserNotFoundError` | `USER_NOT_FOUND` | `userId` | The user doesn't exist, or no longer does. |
76
+ | `UserExistsError` | `USER_EXISTS` | `email` | Creating a user whose email is taken. |
77
+ | `GroupNotFoundError` | `GROUP_NOT_FOUND` | `group` | The group doesn't exist. |
78
+ | `GroupExistsError` | `GROUP_EXISTS` | `group` | Creating a group that exists. |
79
+ | `NotSupportedError` | `NOT_SUPPORTED` | `feature` | The adapter can't do what was asked; `feature` names the method or option. |
80
+ | `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. |
81
+
82
+ `AccessReductionIncompleteError`:
83
+ - `action` is `"deleted"`, `"disabled"` or `"removed-from-group"`: what already happened at the provider. It is not undone.
84
+ - `failedStep` is `"provider-sign-out"` or `"hook"`: the step after it that failed.
85
+ - `cause` is the original error.
86
+ - Calling the same method again is safe and repeats every step.
87
+
88
+ ## `IdpAdapter<TAttr>`
89
+
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.
91
+
92
+ 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
+
94
+ | Method | Returns | Behaviour |
95
+ |---|---|---|
96
+ | `createUser(input)` | `IdpUser` | Creates a user with `input.temporaryPassword`, who must replace it at first sign-in. The provider sends no message. Throws `UserExistsError`. |
97
+ | `createUserWithInvite?(input)` | `IdpUser` | Optional. The provider generates a password and sends its own invitation. Throws `UserExistsError`. |
98
+ | `getUser(userId)` | `IdpUser` | |
99
+ | `findUserByEmail(email)` | `IdpUser \| null` | `null` when nobody has that email. |
100
+ | `listUsersPage(cursor)` | `Page<IdpUser>` | One page of every user. Pass `null` for the first page. |
101
+ | `setPassword(userId, password, { temporary })` | `void` | A temporary password must be replaced at next sign-in. |
102
+ | `setEnabled(userId, enabled)` | `void` | Succeeds if the user is already in that state. |
103
+ | `deleteUser(userId)` | `void` | Also removes them from every group. |
104
+ | `updateAttributes(userId, attributes)` | `void` | Sets the given attributes and leaves the rest unchanged. |
105
+ | `resendInvite?(userId)` | `void` | Optional. Sends the provider's invitation again. |
106
+ | `signOutEverywhere(userId)` | `void` | Revokes the user's tokens at the provider. |
107
+ | `addToGroup(userId, group)` | `void` | Succeeds if they're already a member. |
108
+ | `removeFromGroup(userId, group)` | `void` | Succeeds if they aren't a member. |
109
+ | `listUserGroupsPage(userId, cursor)` | `Page<string>` | One page of the names of the groups a user is in. |
110
+ | `groupExists(group)` | `boolean` | |
111
+ | `createGroup(group)` | `void` | Throws `GroupExistsError`. |
112
+ | `deleteGroup(group)` | `void` | Removes every member from it first. |
113
+ | `listGroupMembersPage(group, cursor)` | `Page<IdpUser>` | One page of a group's members. |
114
+
115
+ `AdapterCreateUserInput<TAttr>` is `{ email, name?, attributes?, temporaryPassword }`. `AdapterInviteUserInput<TAttr>` is the same without `temporaryPassword`.
@@ -0,0 +1,71 @@
1
+ # keycloak
2
+
3
+ `idp-admin/keycloak`: Keycloak, through its admin REST API. [Index](index.md)
4
+
5
+ Tested against Keycloak 26. It uses the global `fetch` and has no dependencies.
6
+
7
+ ## `new KeycloakAdapter(options)`
8
+
9
+ ```ts
10
+ import { KeycloakAdapter } from "idp-admin/keycloak"
11
+
12
+ const adapter = new KeycloakAdapter({
13
+ issuer: "https://auth.example.com/realms/frcs",
14
+ clientId: "frcs-admin",
15
+ clientSecret: process.env.KEYCLOAK_ADMIN_SECRET!,
16
+ attributes: { company: "companyName" },
17
+ })
18
+ ```
19
+
20
+ | Option | Default | Meaning |
21
+ |---|---|---|
22
+ | `issuer` | required | The realm's URL, `<host>/realms/<realm>`: the same value the app uses as its OIDC issuer. A trailing slash is fine. Anything else throws a `TypeError`. |
23
+ | `clientId`, `clientSecret` | required | A confidential client whose service account the adapter acts as. See [Realm setup](#realm-setup). |
24
+ | `attributes` | none | App attribute name → Keycloak attribute name. The app names become the adapter's attribute type, so only they compile, and any other name is rejected at runtime with a `TypeError`. |
25
+ | `pageSize` | `50` | Most items per page from the list methods, a whole number from 1 to 99. Anything else throws a `RangeError`. |
26
+
27
+ ## Realm setup
28
+
29
+ The adapter needs three things from the realm:
30
+
31
+ 1. **A confidential client with a service account.** Client authentication on, Service account roles on. Nothing else about the client matters to the adapter.
32
+ 2. **These realm-management roles on the service account,** exported as `KEYCLOAK_ADMIN_ROLES`: `manage-users`, `view-users`, `query-users`, `query-groups`, and `manage-realm` (for creating and deleting groups). The tests run with exactly these. If a call is refused, the error names them.
33
+ 3. **Somewhere for custom attributes to go,** if `attributes` is set. Keycloak's user profile **silently drops** an attribute it doesn't declare unless unmanaged attributes are enabled. Either declare each Keycloak attribute name in Realm settings → User profile, or set Unmanaged attributes to Enabled. Before writing attributes, the adapter reads the realm's user profile and throws, without creating or changing anyone, if Keycloak would drop one.
34
+
35
+ Also worth knowing: by default Keycloak requires a first and last name before anyone can sign in ("Account is not fully set up"). A user created without a name, or with a one-word name, can't sign in until the realm's user profile makes those fields optional or the user fills them in.
36
+
37
+ ## Behaviour
38
+
39
+ - **Ids** are Keycloak user ids, which Keycloak puts in the `sub` of the user's tokens.
40
+ - **Emails** are the username too. Keycloak stores them lowercase and rejects emails it considers invalid, including ones containing `"`. `findUserByEmail` matches exactly.
41
+ - **Names:** Keycloak keeps a first and a last name. The first word of `name` becomes the first name and the rest the last name. Reading joins them back with a space.
42
+ - **Status:**
43
+ - `federated`: the user is linked to an identity provider (they signed in through SSO). This takes precedence over everything else.
44
+ - `password_change_required`: the user has Keycloak's `UPDATE_PASSWORD` required action, which a temporary password sets.
45
+ - `unconfirmed`: the user has the `VERIFY_EMAIL` required action.
46
+ - `active`: anything else.
47
+ - **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
+ - **Groups** are top-level realm groups, addressed by exact name. Subgroups aren't supported.
49
+ - **Sign-out:** `signOutEverywhere` ends every session the user has, so their refresh tokens stop working. Access tokens already issued stay valid until they expire.
50
+ - **Not supported:** `createUserWithInvite` and `resendInvite`. Keycloak can only send invitations through required-action emails, which need SMTP on the realm. Send your own email with a temporary password instead.
51
+ - **Lists** ask Keycloak for one item more than a page, so the last page is known without an extra request. Detecting federated users takes one extra request per user listed.
52
+ - **Tokens:** the adapter gets a client-credentials token and reuses it until 30 seconds before it expires. Concurrent calls share one token request.
53
+
54
+ ## Errors
55
+
56
+ Besides the idp-admin errors in the [index](index.md#errors):
57
+
58
+ | Error | When |
59
+ |---|---|
60
+ | `KeycloakRequestError` | Keycloak answered with a status the adapter doesn't turn into an idp-admin error. It has `method`, `path` and `status`. A rejected client secret and a refused call (403) both explain what to fix. |
61
+ | `TypeError` | An unknown attribute name, or an issuer that isn't `<host>/realms/<realm>`. |
62
+ | `Error` | The realm would silently drop an attribute. See [Realm setup](#realm-setup). |
63
+
64
+ ## `native`
65
+
66
+ ```ts
67
+ const response = await adapter.native.request("GET", "/users/count")
68
+ adapter.native.adminUrl // https://auth.example.com/admin/realms/frcs
69
+ ```
70
+
71
+ The admin API, authenticated as the service account, for anything the shared interface doesn't cover. Paths are relative to `/admin/realms/<realm>`. `request` returns the raw `Response` and doesn't throw on error statuses.
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
1
  {
2
2
  "name": "idp-admin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "One user-management API for any identity provider.",
5
+ "keywords": [
6
+ "identity-provider",
7
+ "idp",
8
+ "user-management",
9
+ "users",
10
+ "groups",
11
+ "admin",
12
+ "iam",
13
+ "oidc",
14
+ "cognito",
15
+ "keycloak"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Leighton Miguel",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/shortxmas/idp-admin.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/shortxmas/idp-admin/issues"
25
+ },
26
+ "homepage": "https://github.com/shortxmas/idp-admin#readme",
27
+ "type": "commonjs",
28
+ "main": "dist/index.js",
29
+ "types": "dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./cognito": {
36
+ "types": "./dist/cognito/index.d.ts",
37
+ "default": "./dist/cognito/index.js"
38
+ },
39
+ "./keycloak": {
40
+ "types": "./dist/keycloak/index.d.ts",
41
+ "default": "./dist/keycloak/index.js"
42
+ },
43
+ "./fake": {
44
+ "types": "./dist/fake/index.d.ts",
45
+ "default": "./dist/fake/index.js"
46
+ },
47
+ "./package.json": "./package.json"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "docs"
52
+ ],
53
+ "engines": {
54
+ "node": ">=22"
55
+ },
56
+ "scripts": {
57
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
58
+ "prepublishOnly": "npm run build",
59
+ "test": "jest",
60
+ "typecheck": "tsc --noEmit"
61
+ },
62
+ "peerDependencies": {
63
+ "@aws-sdk/client-cognito-identity-provider": "^3"
64
+ },
65
+ "peerDependenciesMeta": {
66
+ "@aws-sdk/client-cognito-identity-provider": {
67
+ "optional": true
68
+ }
69
+ },
70
+ "devDependencies": {
71
+ "@aws-sdk/client-cognito-identity-provider": "^3.1151.0",
72
+ "@swc/core": "^1.16.13",
73
+ "@swc/jest": "^0.2.39",
74
+ "@types/jest": "^30.0.0",
75
+ "@types/node": "^26.6.4",
76
+ "jest": "^30.5.2",
77
+ "tsx": "^4.23.15",
78
+ "typescript": "5.9.3"
79
+ }
80
+ }