@_mustachio/openauth 0.12.0 → 0.13.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/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +25 -0
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/methods/saml-sp/acs.js +44 -20
- package/dist/esm/methods/saml-sp/attributes.js +2 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
- package/dist/esm/methods/saml-sp/factory.js +12 -1
- package/dist/esm/methods/saml-sp/metadata.js +5 -3
- package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
- package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
- package/dist/esm/methods/saml-sp/sls.js +2 -2
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/scim.js +0 -0
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +3 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/factory.d.ts +217 -1
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/types.d.ts +95 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +12 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +3 -0
- package/src/http/handlers/authorize.ts +0 -1
- package/src/http/handlers/method-route.ts +1 -4
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +20 -0
- package/src/methods/saml-sp/acs.ts +99 -49
- package/src/methods/saml-sp/attributes.ts +12 -3
- package/src/methods/saml-sp/authnrequest.ts +3 -2
- package/src/methods/saml-sp/factory.ts +24 -3
- package/src/methods/saml-sp/metadata.ts +19 -8
- package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
- package/src/methods/saml-sp/saml-instance.ts +42 -5
- package/src/methods/saml-sp/slo-initiate.ts +6 -3
- package/src/methods/saml-sp/sls.ts +3 -2
- package/src/methods/saml-sp/types.ts +95 -0
- package/src/ports/CONSISTENCY.md +28 -25
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +1 -5
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +12 -0
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +10 -0
package/src/ports/CONSISTENCY.md
CHANGED
|
@@ -15,31 +15,34 @@ overspecifies adapters that don't need that on the read-eventual paths
|
|
|
15
15
|
|
|
16
16
|
## Per-method consistency contracts
|
|
17
17
|
|
|
18
|
-
| Port
|
|
19
|
-
|
|
|
20
|
-
| `TokenStore`
|
|
21
|
-
| `TokenStore`
|
|
22
|
-
| `TokenStore`
|
|
23
|
-
| `TokenStore`
|
|
24
|
-
| `TokenStore`
|
|
25
|
-
| `TokenStore`
|
|
26
|
-
| `TokenStore`
|
|
27
|
-
| `SessionStore`
|
|
28
|
-
| `SessionStore`
|
|
29
|
-
| `SessionStore`
|
|
30
|
-
| `SessionStore`
|
|
31
|
-
| `SessionStore`
|
|
32
|
-
| `SessionStore`
|
|
33
|
-
| `SessionStore`
|
|
34
|
-
| `TokenStore`
|
|
35
|
-
| `KeyStore`
|
|
36
|
-
| `KeyStore`
|
|
37
|
-
| `KeyStore`
|
|
38
|
-
| `ConfigStore`
|
|
39
|
-
| `ConfigStore`
|
|
40
|
-
| `MethodStore`
|
|
41
|
-
| `MethodStore`
|
|
42
|
-
| `AuditLog`
|
|
18
|
+
| Port | Method | Required | Why |
|
|
19
|
+
| --------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
20
|
+
| `TokenStore` | `saveCode(code, payload, ttl)` | **Strong, atomic.** Payload **encrypted at rest** with a key from `KeyStore`; `ttl ≤ 60 s` (framework refuses larger). | Code must be created exactly once and visible to the next `consumeCode` on any node. Payload may carry upstream tokens — see _Code payload confidentiality_ in plan. |
|
|
21
|
+
| `TokenStore` | `consumeCode(code)` | **Strong, CAS.** Returns the **decrypted** payload on success. | Single-use; second consumption must fail deterministically. Race with itself must resolve to one winner. |
|
|
22
|
+
| `TokenStore` | `saveRefresh(refresh, payload)` | **Strong, atomic.** | New refresh tokens issued during rotation must be immediately retrievable on the next request. |
|
|
23
|
+
| `TokenStore` | `consumeRefresh(refresh, options)` | **Strong, CAS with reuse-detection window** (default 60 s). | Core of refresh-token rotation security. Concurrent presentations resolve to one winner; reuse within the window triggers the revoke chain. |
|
|
24
|
+
| `TokenStore` | `peekRefresh(refresh)` | **Eventual acceptable.** Non-destructive read; must never mark the token consumed. | Used by `/revoke` for token-to-client binding (RFC 7009 §2.2) and by the refresh-grant for client auth + audit fields. Race losers fall through to `consumeRefresh`, which is the strong gate, so a stale peek can only delay — not bypass — single-use enforcement. |
|
|
25
|
+
| `TokenStore` | `revokeFamily(family)` | Strong preferred; eventual acceptable with documented SLA. | Reuse-detection must invalidate the whole chain. Brief lag is tolerable. |
|
|
26
|
+
| `TokenStore` | `revokeBySubject(tenant, subject)` | Strong preferred; eventual acceptable with documented SLA. | Revocation must propagate quickly. A few seconds of lag is tolerable. |
|
|
27
|
+
| `SessionStore` | `saveFlow(flowId, payload, ttl)` | **Strong, atomic.** `ttl == expiresAt - createdAt`. | Flow record must be visible on the callback request to `consumeFlow`. |
|
|
28
|
+
| `SessionStore` | `updateFlowMethodState(flowId, state)` | **Strong, atomic.** Resolves **before** the user-agent redirect is sent. | Upstream PKCE verifier / nonce must be durably persisted before the redirect, otherwise the callback cannot validate. |
|
|
29
|
+
| `SessionStore` | `consumeFlow(flowId)` | **Strong, atomic delete-on-read** that **returns the full `FlowRecord`** (`CAS` or `DELETE … RETURNING`). | Single-use; concurrent consumption resolves to one winner. The record is returned so the framework can snapshot fields into the auth-code payload before disposal. |
|
|
30
|
+
| `SessionStore` | `savePar(uri, payload, ttl)` (optional) | **Strong, atomic.** `ttl` default 60 s. | RFC 9126 §3 — the PAR record must be visible to the next `consumePar` on any node. One-shot read. |
|
|
31
|
+
| `SessionStore` | `consumePar(uri)` (optional) | **Strong, atomic delete-on-read.** | RFC 9126 §4 — `request_uri` is single-use; concurrent presentations of the same uri resolve to one winner. Same semantics as `consumeFlow`. |
|
|
32
|
+
| `SessionStore` | `createSession / readSession / revokeSession` (optional long-lived) | **Strong.** | Session creation must be immediately readable on the next request. |
|
|
33
|
+
| `SessionStore` | `saveScratch / readScratch / deleteScratch` (optional) | **Strong, TTL-respecting.** Implement the trio together; partial implementations are not supported. | Backs `MethodContext.methodScratch` for methods that need cross-flow per-instance state (e.g. SAML SP assertion-ID replay protection). Keys arrive already namespaced by `(tenantId, methodId)` — the adapter stores opaque key/value pairs. |
|
|
34
|
+
| `TokenStore` | `recordDpopJti(jti, ttlMs)` (optional) | **Strong, atomic record-or-fail.** | RFC 9449 §11.1 — replay protection requires single-use enforcement on the jti within the TTL window. A re-presentation must return `invalid_grant` deterministically. Adapters without this method cannot satisfy DPoP and the verifier surfaces `invalid_dpop_proof`. |
|
|
35
|
+
| `KeyStore` | `currentSigningKey()` / `currentEncryptionKey()` | Strong. | Active key must be unambiguous. |
|
|
36
|
+
| `KeyStore` | `signingKeys()` (JWKS) | Eventual OK (with TTL). | Verifiers tolerate brief JWKS lag during rotation. |
|
|
37
|
+
| `KeyStore` | `getEncryptionKey(kid)` | Strong. | Required to decrypt code payloads encrypted under non-current keys during the overlap window. |
|
|
38
|
+
| `ConfigStore` | `getTenantConfig(id)` | **Eventual + bounded staleness (TTL ≤ 60 s).** Invalidation hook fires on update. | Read-heavy, write-rare. Aggressive caching is desirable. |
|
|
39
|
+
| `ConfigStore` | `putTenantConfig(config)` | **Strong write + immediate invalidation** of cached entries for the affected `TenantId`. | Subsequent `getTenantConfig` must reflect the new value once the write resolves. |
|
|
40
|
+
| `MethodStore` | `getMethodConfig` / `listMethods` | Same as `ConfigStore.getTenantConfig`. | Subset of tenant config. |
|
|
41
|
+
| `MethodStore` | `putMethodConfig` / `deleteMethodConfig` | Strong write + immediate invalidation. | Console-driven mutations must take effect on the next request. |
|
|
42
|
+
| `AuditLog` | `log(event)` | Append-only, durable. Cross-instance ordering **not** required (consumers sort by `timestamp` + `actor`). | Loss = audit gap; ordering is a UI / SIEM concern. |
|
|
43
|
+
| `ScimDirectory` | `createUser` / `replaceUser` / `patchUser` / `deleteUser` | **Strong, read-your-writes.** | A SCIM client issues `GET /Users?filter=userName eq "…"` immediately after a create to confirm it; an eventually-consistent read there makes the client create the user a second time. Duplicate users are the classic SCIM failure and are painful to unpick. |
|
|
44
|
+
| `ScimDirectory` | `getUser` / `findUsers` | **Read-your-writes for the tenant's own recent writes**; otherwise eventual is acceptable. | Same reason. Cross-tenant staleness is harmless — SCIM reads are always tenant-scoped. |
|
|
45
|
+
| `ScimDirectory` | uniqueness of `userName` | **Host-enforced.** Return `authError.conflict(...)` on collision. | The library stores no user rows, so it cannot enforce uniqueness. A host that checks-then-writes without a constraint will race under Okta's initial import, which is heavily concurrent. |
|
|
43
46
|
|
|
44
47
|
## Implications for adapter choice
|
|
45
48
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ScimDirectory` — the host's user directory, as SCIM needs to see it.
|
|
3
|
+
*
|
|
4
|
+
* This is the boundary from `SCIM-AD2`: the library owns the SCIM
|
|
5
|
+
* protocol (routing, bearer auth, schema validation, PATCH
|
|
6
|
+
* normalization, error envelope, pagination, discovery docs) and the
|
|
7
|
+
* host owns every byte of persistence. The library stores no user data.
|
|
8
|
+
*
|
|
9
|
+
* That split is not a compromise — it is the same protocol-over-
|
|
10
|
+
* host-owned-state shape as `ConfigStore`, `MethodStore` and the
|
|
11
|
+
* `success` callback. The host's Users table is the host's.
|
|
12
|
+
*
|
|
13
|
+
* **Consistency:** read-your-writes is required. A SCIM client
|
|
14
|
+
* (notably Okta) will `GET /Users?filter=userName eq "…"` immediately
|
|
15
|
+
* after a `POST /Users` to confirm the create; an eventually-consistent
|
|
16
|
+
* read there produces duplicate users. See `ports/CONSISTENCY.md`.
|
|
17
|
+
*
|
|
18
|
+
* **Errors.** SCIM clients retry `5xx` and give up on `4xx`, so which
|
|
19
|
+
* error you return decides whether a failure is surfaced to an admin or
|
|
20
|
+
* retried forever:
|
|
21
|
+
*
|
|
22
|
+
* - `authError.conflict(...)` → `409 uniqueness`. A collision only you
|
|
23
|
+
* can detect, because only you store the rows.
|
|
24
|
+
* - `authError.invalidRequest(...)` → `400 invalidValue`. A
|
|
25
|
+
* **permanent** rejection — you understood the request and will
|
|
26
|
+
* never accept it. Use it when `addMembers` names a user you do not
|
|
27
|
+
* have (an IdP's group push can reference a member its user push
|
|
28
|
+
* filtered out, or one deleted between operations), or when the data
|
|
29
|
+
* violates a constraint you cannot satisfy.
|
|
30
|
+
* - anything else → `500`, which the client retries. Correct for a
|
|
31
|
+
* transient fault, and far better than reporting a success you did
|
|
32
|
+
* not perform — but returning it for a permanent problem means the
|
|
33
|
+
* IdP retries the same doomed request indefinitely.
|
|
34
|
+
*
|
|
35
|
+
* Your message is passed through on both `4xx` paths; it is what an IdP
|
|
36
|
+
* admin reads in the provisioning log, so make it specific.
|
|
37
|
+
*/
|
|
38
|
+
import type { Result } from "../types/result"
|
|
39
|
+
import type {
|
|
40
|
+
ScimGroupPatch,
|
|
41
|
+
ScimGroupQuery,
|
|
42
|
+
ScimGroupRecord,
|
|
43
|
+
ScimGroupWrite,
|
|
44
|
+
ScimPage,
|
|
45
|
+
ScimUserPatch,
|
|
46
|
+
ScimUserQuery,
|
|
47
|
+
ScimUserRecord,
|
|
48
|
+
ScimUserWrite,
|
|
49
|
+
} from "../types/scim"
|
|
50
|
+
import type { TenantId } from "../types/tenant"
|
|
51
|
+
|
|
52
|
+
export type ScimDirectory = {
|
|
53
|
+
/** `null` (not an error) when no user has that id in this tenant. */
|
|
54
|
+
getUser(
|
|
55
|
+
tenantId: TenantId,
|
|
56
|
+
id: string,
|
|
57
|
+
): Promise<Result<ScimUserRecord | null>>
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Query users. `query.startIndex` is 1-based and `query.count` is
|
|
61
|
+
* already clamped; `query.totalResults` in the response must be the
|
|
62
|
+
* full match count, not the page length.
|
|
63
|
+
*/
|
|
64
|
+
findUsers(
|
|
65
|
+
tenantId: TenantId,
|
|
66
|
+
query: ScimUserQuery,
|
|
67
|
+
): Promise<Result<ScimPage<ScimUserRecord>>>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a user. Return `conflict` if `userName` (or another
|
|
71
|
+
* uniqueness constraint of yours) is already taken — the library
|
|
72
|
+
* cannot check that for you.
|
|
73
|
+
*/
|
|
74
|
+
createUser(
|
|
75
|
+
tenantId: TenantId,
|
|
76
|
+
user: ScimUserWrite,
|
|
77
|
+
): Promise<Result<ScimUserRecord>>
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Full replace (`PUT`). Attributes absent from `user` are cleared, per
|
|
81
|
+
* RFC 7644 §3.5.1 — this is a replace, not a merge. Use `patchUser`
|
|
82
|
+
* for partial updates.
|
|
83
|
+
*/
|
|
84
|
+
replaceUser(
|
|
85
|
+
tenantId: TenantId,
|
|
86
|
+
id: string,
|
|
87
|
+
user: ScimUserWrite,
|
|
88
|
+
): Promise<Result<ScimUserRecord>>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Apply a normalized delta. Present key ⇒ set; absent ⇒ leave alone;
|
|
92
|
+
* `null` ⇒ clear. The library has already resolved SCIM path
|
|
93
|
+
* expressions, so no parsing is needed here.
|
|
94
|
+
*
|
|
95
|
+
* `{ active: false }` is the ordinary deprovisioning signal and is
|
|
96
|
+
* usually the single most important operation to implement correctly.
|
|
97
|
+
*/
|
|
98
|
+
patchUser(
|
|
99
|
+
tenantId: TenantId,
|
|
100
|
+
id: string,
|
|
101
|
+
patch: ScimUserPatch,
|
|
102
|
+
): Promise<Result<ScimUserRecord>>
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Hard delete (`DELETE`). Distinct from deactivation on purpose
|
|
106
|
+
* (`SCIM-AD8`): the library will not quietly turn a destructive
|
|
107
|
+
* request into a soft one. Hosts that do not want cascading deletes
|
|
108
|
+
* should implement this as a tombstone and say so in their runbook —
|
|
109
|
+
* but that decision is theirs to make explicitly, not the library's to
|
|
110
|
+
* make silently.
|
|
111
|
+
*/
|
|
112
|
+
deleteUser(tenantId: TenantId, id: string): Promise<Result<void>>
|
|
113
|
+
|
|
114
|
+
// ─── Groups ───
|
|
115
|
+
//
|
|
116
|
+
// Optional as a set: a host that only needs user provisioning can omit
|
|
117
|
+
// all six, and `/scim/v2/Groups` then answers 501 instead of failing
|
|
118
|
+
// at runtime. Implement all of them or none — a half-implemented
|
|
119
|
+
// Groups surface fails an IdP's group push in confusing ways.
|
|
120
|
+
|
|
121
|
+
getGroup?(
|
|
122
|
+
tenantId: TenantId,
|
|
123
|
+
id: string,
|
|
124
|
+
): Promise<Result<ScimGroupRecord | null>>
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* `query.excludeMembers` is set when the client asked for
|
|
128
|
+
* `excludedAttributes=members`. Honour it by not loading membership —
|
|
129
|
+
* Okta sets it while enumerating groups, and ignoring it turns a cheap
|
|
130
|
+
* listing into a fan-out read per group.
|
|
131
|
+
*/
|
|
132
|
+
findGroups?(
|
|
133
|
+
tenantId: TenantId,
|
|
134
|
+
query: ScimGroupQuery,
|
|
135
|
+
): Promise<Result<ScimPage<ScimGroupRecord>>>
|
|
136
|
+
|
|
137
|
+
createGroup?(
|
|
138
|
+
tenantId: TenantId,
|
|
139
|
+
group: ScimGroupWrite,
|
|
140
|
+
): Promise<Result<ScimGroupRecord>>
|
|
141
|
+
|
|
142
|
+
/** Full replace, including membership. */
|
|
143
|
+
replaceGroup?(
|
|
144
|
+
tenantId: TenantId,
|
|
145
|
+
id: string,
|
|
146
|
+
group: ScimGroupWrite,
|
|
147
|
+
): Promise<Result<ScimGroupRecord>>
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Apply a normalized group delta.
|
|
151
|
+
*
|
|
152
|
+
* Exactly one membership shape arrives at a time: `members` (replace
|
|
153
|
+
* everything) or `addMembers` / `removeMembers` (incremental). The
|
|
154
|
+
* incremental form exists so a 20,000-member group does not have to be
|
|
155
|
+
* read and rewritten to add one person — see `SCIM-AD9`. Adding an
|
|
156
|
+
* existing member or removing an absent one must succeed quietly;
|
|
157
|
+
* IdPs retry and expect idempotence.
|
|
158
|
+
*/
|
|
159
|
+
patchGroup?(
|
|
160
|
+
tenantId: TenantId,
|
|
161
|
+
id: string,
|
|
162
|
+
patch: ScimGroupPatch,
|
|
163
|
+
): Promise<Result<ScimGroupRecord>>
|
|
164
|
+
|
|
165
|
+
deleteGroup?(tenantId: TenantId, id: string): Promise<Result<void>>
|
|
166
|
+
}
|
|
@@ -122,11 +122,7 @@ export type SessionStore = {
|
|
|
122
122
|
* `MethodContext.methodScratch` at call time. Implement all three
|
|
123
123
|
* methods together — partial implementations are not supported.
|
|
124
124
|
*/
|
|
125
|
-
saveScratch?(
|
|
126
|
-
key: string,
|
|
127
|
-
value: string,
|
|
128
|
-
ttlMs: number,
|
|
129
|
-
): Promise<Result<void>>
|
|
125
|
+
saveScratch?(key: string, value: string, ttlMs: number): Promise<Result<void>>
|
|
130
126
|
readScratch?(key: string): Promise<Result<string>>
|
|
131
127
|
deleteScratch?(key: string): Promise<Result<void>>
|
|
132
128
|
}
|
package/src/types/error.ts
CHANGED
|
@@ -50,6 +50,12 @@ export type AuthError =
|
|
|
50
50
|
// endpoints. Use for control-flow signalling that should never escape to a
|
|
51
51
|
// standards-compliant client.
|
|
52
52
|
| { code: "internal_error"; description: string; cause?: unknown }
|
|
53
|
+
// Uniqueness / state conflict. Raised by hosts from `ScimDirectory`
|
|
54
|
+
// when a create or update collides with an existing record — only the
|
|
55
|
+
// host can know, since only the host stores the rows. The SCIM layer
|
|
56
|
+
// renders it as `409` with `scimType: "uniqueness"`. It has no OAuth
|
|
57
|
+
// endpoint mapping and should never reach one.
|
|
58
|
+
| { code: "conflict"; description: string; attribute?: string }
|
|
53
59
|
// RFC 9449 §5.2 — DPoP proof verification failed (bad signature, htm/htu
|
|
54
60
|
// mismatch, iat outside window, replayed jti, missing/mismatched cnf.jkt).
|
|
55
61
|
// Returned as a 400 with `error="invalid_dpop_proof"` on form-body
|
|
@@ -130,6 +136,11 @@ export const authError = {
|
|
|
130
136
|
code: "invalid_target",
|
|
131
137
|
description,
|
|
132
138
|
}),
|
|
139
|
+
conflict: (description: string, attribute?: string): AuthError => ({
|
|
140
|
+
code: "conflict",
|
|
141
|
+
description,
|
|
142
|
+
...(attribute !== undefined ? { attribute } : {}),
|
|
143
|
+
}),
|
|
133
144
|
internalError: (description: string, cause?: unknown): AuthError => ({
|
|
134
145
|
code: "internal_error",
|
|
135
146
|
description,
|
package/src/types/idp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { AuditLog } from "../ports/audit-log"
|
|
|
7
7
|
import type { ConfigStore } from "../ports/config-store"
|
|
8
8
|
import type { KeyStore } from "../ports/key-store"
|
|
9
9
|
import type { MethodStore } from "../ports/method-store"
|
|
10
|
+
import type { ScimDirectory } from "../ports/scim-directory"
|
|
10
11
|
import type { SessionStore } from "../ports/session-store"
|
|
11
12
|
import type { TokenStore } from "../ports/token-store"
|
|
12
13
|
|
|
@@ -280,6 +281,17 @@ export type IdPOptions = {
|
|
|
280
281
|
auditLog?: AuditLog
|
|
281
282
|
/** Optional — falls back to `ConfigStore` for `MethodConfig` lookups. */
|
|
282
283
|
methodStore?: MethodStore
|
|
284
|
+
/**
|
|
285
|
+
* The host's user directory, as SCIM needs to see it. Supply it to
|
|
286
|
+
* serve `/scim/v2/*`; omit it and those routes answer 501 regardless
|
|
287
|
+
* of per-tenant config.
|
|
288
|
+
*
|
|
289
|
+
* The library owns the SCIM protocol and stores no user data — every
|
|
290
|
+
* read and write goes through this port to the host's own tables. See
|
|
291
|
+
* `SCIM-AD2` in `docs/plans/claude/scim-plan.md` and
|
|
292
|
+
* `INTEGRATION.md` § SCIM.
|
|
293
|
+
*/
|
|
294
|
+
scimDirectory?: ScimDirectory
|
|
283
295
|
|
|
284
296
|
/** Issuer URL. Function form lets multi-tenant deployments derive it per request. */
|
|
285
297
|
issuerUrl: string | ((req: Request) => string)
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCIM 2.0 — public types.
|
|
3
|
+
*
|
|
4
|
+
* The library is the SCIM **Service Provider** (the system being
|
|
5
|
+
* provisioned into); corporate IdPs are the SCIM clients. Inbound only —
|
|
6
|
+
* we never originate provisioning traffic. See `SCIM-AD1` in
|
|
7
|
+
* `docs/plans/claude/scim-plan.md`.
|
|
8
|
+
*
|
|
9
|
+
* These types are the whole contract between the library's protocol
|
|
10
|
+
* layer and the host's data layer. No SCIM JSON shape, path expression,
|
|
11
|
+
* or filter string reaches the host: it receives validated, normalized
|
|
12
|
+
* values and returns records. See `SCIM-AD2`.
|
|
13
|
+
*
|
|
14
|
+
* Plain TypeScript, no third-party types, no Node APIs — SCIM is JSON
|
|
15
|
+
* over HTTP, so unlike SAML this surface lives on the root entry and
|
|
16
|
+
* stays edge-clean.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** RFC 7643 §4.1.2 — a multi-valued attribute entry. */
|
|
20
|
+
export type ScimMultiValue = {
|
|
21
|
+
value: string
|
|
22
|
+
/** e.g. `"work"` / `"home"` / `"mobile"`. */
|
|
23
|
+
type?: string
|
|
24
|
+
/** At most one entry per attribute should be primary. */
|
|
25
|
+
primary?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** RFC 7643 §4.1.1 — the `name` complex attribute. */
|
|
29
|
+
export type ScimName = {
|
|
30
|
+
formatted?: string
|
|
31
|
+
familyName?: string
|
|
32
|
+
givenName?: string
|
|
33
|
+
middleName?: string
|
|
34
|
+
honorificPrefix?: string
|
|
35
|
+
honorificSuffix?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* RFC 7643 §4.3 — the enterprise user extension
|
|
40
|
+
* (`urn:ietf:params:scim:schemas:extension:enterprise:2.0:User`).
|
|
41
|
+
* Okta and Entra both populate parts of this; `department` and
|
|
42
|
+
* `manager` are the ones hosts usually care about.
|
|
43
|
+
*/
|
|
44
|
+
export type ScimEnterpriseUser = {
|
|
45
|
+
employeeNumber?: string
|
|
46
|
+
costCenter?: string
|
|
47
|
+
organization?: string
|
|
48
|
+
division?: string
|
|
49
|
+
department?: string
|
|
50
|
+
/** `manager.value` is the manager's SCIM `id`. */
|
|
51
|
+
manager?: { value?: string; displayName?: string }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A user as the **host** stores it. Returned from every `ScimDirectory`
|
|
56
|
+
* read and write.
|
|
57
|
+
*
|
|
58
|
+
* `id` is the host's stable identifier and becomes the SCIM resource id
|
|
59
|
+
* in URLs — it must be opaque, stable, and URL-safe.
|
|
60
|
+
*
|
|
61
|
+
* `externalId` is the **IdP's** identifier for the same person. It is
|
|
62
|
+
* what reconciliation depends on in practice (a user can change
|
|
63
|
+
* `userName`; `externalId` survives), so hosts should persist and index
|
|
64
|
+
* it even though SCIM marks it optional.
|
|
65
|
+
*/
|
|
66
|
+
export type ScimUserRecord = {
|
|
67
|
+
id: string
|
|
68
|
+
externalId?: string
|
|
69
|
+
userName: string
|
|
70
|
+
/** `false` is the normal deprovisioning signal — see `SCIM-AD8`. */
|
|
71
|
+
active: boolean
|
|
72
|
+
name?: ScimName
|
|
73
|
+
displayName?: string
|
|
74
|
+
emails?: ScimMultiValue[]
|
|
75
|
+
phoneNumbers?: ScimMultiValue[]
|
|
76
|
+
enterprise?: ScimEnterpriseUser
|
|
77
|
+
/** Unix ms. Surfaced as `meta.created` when present. */
|
|
78
|
+
createdAt?: number
|
|
79
|
+
/** Unix ms. Surfaced as `meta.lastModified` when present. */
|
|
80
|
+
updatedAt?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A validated create / replace payload. Same shape as a record minus
|
|
85
|
+
* the host-assigned `id` and timestamps.
|
|
86
|
+
*
|
|
87
|
+
* `active` is always present: SCIM defaults it to `true` on create, and
|
|
88
|
+
* resolving that default in the library rather than the host keeps the
|
|
89
|
+
* semantics in one place.
|
|
90
|
+
*
|
|
91
|
+
* Note there is no `password` field, deliberately — see `SCIM-AD1`
|
|
92
|
+
* non-goals. A `password` in the payload is refused, not silently
|
|
93
|
+
* dropped.
|
|
94
|
+
*/
|
|
95
|
+
export type ScimUserWrite = {
|
|
96
|
+
externalId?: string
|
|
97
|
+
userName: string
|
|
98
|
+
active: boolean
|
|
99
|
+
name?: ScimName
|
|
100
|
+
displayName?: string
|
|
101
|
+
emails?: ScimMultiValue[]
|
|
102
|
+
phoneNumbers?: ScimMultiValue[]
|
|
103
|
+
enterprise?: ScimEnterpriseUser
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A **normalized** PATCH delta (`SCIM-AD6`).
|
|
108
|
+
*
|
|
109
|
+
* The library resolves `urn:ietf:params:scim:api:messages:2.0:PatchOp`
|
|
110
|
+
* operations — including path expressions like
|
|
111
|
+
* `emails[type eq "work"].value` and the different shapes Okta and Entra
|
|
112
|
+
* emit for the same intent — into this flat delta. The host never parses
|
|
113
|
+
* a SCIM path.
|
|
114
|
+
*
|
|
115
|
+
* Present key ⇒ set to that value. Absent key ⇒ leave untouched.
|
|
116
|
+
* `null` ⇒ the attribute was removed and should be cleared.
|
|
117
|
+
*/
|
|
118
|
+
export type ScimUserPatch = {
|
|
119
|
+
externalId?: string | null
|
|
120
|
+
userName?: string
|
|
121
|
+
active?: boolean
|
|
122
|
+
name?: ScimName | null
|
|
123
|
+
displayName?: string | null
|
|
124
|
+
emails?: ScimMultiValue[] | null
|
|
125
|
+
phoneNumbers?: ScimMultiValue[] | null
|
|
126
|
+
enterprise?: ScimEnterpriseUser | null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Attributes the filter subset may reference (`SCIM-AD3`).
|
|
131
|
+
*
|
|
132
|
+
* `emails.value` is the normalized form of the complex path Entra emits
|
|
133
|
+
* (`emails[type eq "work"].value`) — the host matches on any email
|
|
134
|
+
* value and need not model the `type` qualifier.
|
|
135
|
+
*/
|
|
136
|
+
export type ScimFilterAttribute =
|
|
137
|
+
| "id"
|
|
138
|
+
| "userName"
|
|
139
|
+
| "externalId"
|
|
140
|
+
| "active"
|
|
141
|
+
| "emails.value"
|
|
142
|
+
/** Groups only. */
|
|
143
|
+
| "displayName"
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The parsed filter, as a small typed tree. The host never receives the
|
|
147
|
+
* raw filter string — parsing is the library's job, and an expression
|
|
148
|
+
* outside the supported subset is rejected with `400 invalidFilter`
|
|
149
|
+
* before the port is called.
|
|
150
|
+
*/
|
|
151
|
+
export type ScimFilter =
|
|
152
|
+
| { op: "eq"; attribute: ScimFilterAttribute; value: string | boolean }
|
|
153
|
+
| { op: "and"; left: ScimFilter; right: ScimFilter }
|
|
154
|
+
|
|
155
|
+
/** Input to `ScimDirectory.findUsers`. */
|
|
156
|
+
export type ScimUserQuery = {
|
|
157
|
+
/** Absent ⇒ unfiltered list. */
|
|
158
|
+
filter?: ScimFilter
|
|
159
|
+
/**
|
|
160
|
+
* **1-based**, per RFC 7644 §3.4.2.4 — not zero. Already clamped to
|
|
161
|
+
* `>= 1` by the library.
|
|
162
|
+
*/
|
|
163
|
+
startIndex: number
|
|
164
|
+
/** Page size, already clamped to the connection's configured maximum. */
|
|
165
|
+
count: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One page of results. `totalResults` is the count of everything
|
|
170
|
+
* matching the filter, not the size of `resources` — SCIM clients use it
|
|
171
|
+
* to drive pagination, so a host that returns the page size here will
|
|
172
|
+
* make Okta loop or stop early.
|
|
173
|
+
*/
|
|
174
|
+
export type ScimPage<T> = {
|
|
175
|
+
resources: T[]
|
|
176
|
+
totalResults: number
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* One group membership entry. `value` is the member's SCIM `id` — for
|
|
181
|
+
* us always a User id, since we do not support nested groups.
|
|
182
|
+
*/
|
|
183
|
+
export type ScimGroupMember = {
|
|
184
|
+
value: string
|
|
185
|
+
/** Human label the IdP supplied. Advisory; not authoritative. */
|
|
186
|
+
display?: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A group as the **host** stores it. */
|
|
190
|
+
export type ScimGroupRecord = {
|
|
191
|
+
id: string
|
|
192
|
+
externalId?: string
|
|
193
|
+
displayName: string
|
|
194
|
+
/**
|
|
195
|
+
* Omit when the caller asked for `excludedAttributes=members`, which
|
|
196
|
+
* Okta does while enumerating groups. Distinguish "not requested"
|
|
197
|
+
* (omit) from "no members" (`[]`) — returning `[]` for the former
|
|
198
|
+
* tells the client the group was emptied.
|
|
199
|
+
*/
|
|
200
|
+
members?: ScimGroupMember[]
|
|
201
|
+
/** Unix ms → `meta.created`. */
|
|
202
|
+
createdAt?: number
|
|
203
|
+
/** Unix ms → `meta.lastModified`. */
|
|
204
|
+
updatedAt?: number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A validated group create / replace payload. */
|
|
208
|
+
export type ScimGroupWrite = {
|
|
209
|
+
externalId?: string
|
|
210
|
+
displayName: string
|
|
211
|
+
/** Full membership for a create or replace. */
|
|
212
|
+
members?: ScimGroupMember[]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A normalized group PATCH.
|
|
217
|
+
*
|
|
218
|
+
* Membership is handled differently from every user attribute, and
|
|
219
|
+
* deliberately so (`SCIM-AD9`). For a user, the library resolves a
|
|
220
|
+
* targeted patch into the complete new value because the lists involved
|
|
221
|
+
* are small and bounded. Group membership is neither: resolving
|
|
222
|
+
* "add one member" against a 20,000-member group would mean reading all
|
|
223
|
+
* 20,000 rows and handing them back on every single change.
|
|
224
|
+
*
|
|
225
|
+
* So the client's *intent* is preserved instead. `addMembers` /
|
|
226
|
+
* `removeMembers` are incremental and let the host issue one insert or
|
|
227
|
+
* delete; `members` is a full replacement. They are mutually exclusive
|
|
228
|
+
* — the library never emits `members` alongside either incremental
|
|
229
|
+
* field, so a host can branch on which is present without ordering
|
|
230
|
+
* concerns.
|
|
231
|
+
*/
|
|
232
|
+
export type ScimGroupPatch = {
|
|
233
|
+
displayName?: string
|
|
234
|
+
externalId?: string | null
|
|
235
|
+
/** Replace the entire membership with exactly these members. */
|
|
236
|
+
members?: ScimGroupMember[]
|
|
237
|
+
/** Add these members, leaving existing ones alone. Idempotent. */
|
|
238
|
+
addMembers?: ScimGroupMember[]
|
|
239
|
+
/** Remove these member ids. Removing a non-member is not an error. */
|
|
240
|
+
removeMembers?: string[]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Input to `ScimDirectory.findGroups`. */
|
|
244
|
+
export type ScimGroupQuery = {
|
|
245
|
+
filter?: ScimFilter
|
|
246
|
+
/** 1-based. */
|
|
247
|
+
startIndex: number
|
|
248
|
+
count: number
|
|
249
|
+
/**
|
|
250
|
+
* `true` when the client sent `excludedAttributes=members`. The host
|
|
251
|
+
* may skip loading membership entirely — Okta sets this while
|
|
252
|
+
* enumerating groups, and honouring it is the difference between a
|
|
253
|
+
* cheap listing and a fan-out read per group.
|
|
254
|
+
*/
|
|
255
|
+
excludeMembers: boolean
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Per-tenant SCIM connection config, carried on `TenantConfig.scim`.
|
|
260
|
+
*
|
|
261
|
+
* SCIM is not an `AuthMethod` — no `/authorize`, no flow, no user agent
|
|
262
|
+
* — so it is tenant-level config rather than a `MethodConfig`
|
|
263
|
+
* (`SCIM-AD5`).
|
|
264
|
+
*/
|
|
265
|
+
export type ScimConfig = {
|
|
266
|
+
/** `false` (or absent config) ⇒ `/scim/v2/*` responds 403 for this tenant. */
|
|
267
|
+
enabled: boolean
|
|
268
|
+
/**
|
|
269
|
+
* Hash of the bearer token issued to the IdP, produced with
|
|
270
|
+
* `hashClientSecret` — the same treatment as
|
|
271
|
+
* `ClientConfig.secretHash`. Never store the raw token.
|
|
272
|
+
*/
|
|
273
|
+
tokenHash: string
|
|
274
|
+
/**
|
|
275
|
+
* Largest page this connection will return, whatever the client asks
|
|
276
|
+
* for. Defaults to 100; hard-capped at 500 so a hostile or
|
|
277
|
+
* misconfigured `count` cannot ask the host for an unbounded read.
|
|
278
|
+
*/
|
|
279
|
+
maxPageSize?: number
|
|
280
|
+
}
|
package/src/types/tenant.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* providers carries only `{ tenantId, flowId, nonce, kid }`. Everything else
|
|
12
12
|
* lives in the server-side `FlowRecord` (see `./flow.ts`).
|
|
13
13
|
*/
|
|
14
|
+
import type { ScimConfig } from "./scim"
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Branded tenant id — **opaque to the framework**.
|
|
@@ -199,6 +200,15 @@ export type TenantConfig = {
|
|
|
199
200
|
refreshTtl?: number
|
|
200
201
|
/** Override default access-token TTL (seconds). */
|
|
201
202
|
accessTtl?: number
|
|
203
|
+
/**
|
|
204
|
+
* SCIM 2.0 provisioning for this tenant. Absent or `enabled: false` ⇒
|
|
205
|
+
* `/scim/v2/*` answers 403 for this tenant.
|
|
206
|
+
*
|
|
207
|
+
* Tenant-level rather than a `MethodConfig` because SCIM is not an
|
|
208
|
+
* auth method — no `/authorize`, no flow, no user agent. See
|
|
209
|
+
* `SCIM-AD5` in `docs/plans/claude/scim-plan.md`.
|
|
210
|
+
*/
|
|
211
|
+
scim?: ScimConfig
|
|
202
212
|
}
|
|
203
213
|
|
|
204
214
|
/**
|