@_mustachio/openauth 0.11.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.
Files changed (208) hide show
  1. package/dist/esm/adapters/d1/index.js +18 -9
  2. package/dist/esm/adapters/d1/migrations.js +7 -2
  3. package/dist/esm/adapters/d1/session-store.js +39 -0
  4. package/dist/esm/adapters/d1/session.js +2 -2
  5. package/dist/esm/adapters/durable-object/index.js +4 -0
  6. package/dist/esm/adapters/durable-object/session-store.js +42 -0
  7. package/dist/esm/adapters/dynamo/index.js +24 -7
  8. package/dist/esm/adapters/dynamo/session-store.js +49 -0
  9. package/dist/esm/adapters/kms/index.js +6 -2
  10. package/dist/esm/adapters/kv/index.js +6 -2
  11. package/dist/esm/adapters/memory/index.js +24 -7
  12. package/dist/esm/adapters/memory/session-store.js +23 -0
  13. package/dist/esm/adapters/postgres/executor.js +2 -2
  14. package/dist/esm/adapters/postgres/index.js +33 -10
  15. package/dist/esm/adapters/postgres/migrations.js +8 -2
  16. package/dist/esm/adapters/postgres/session-store.js +42 -0
  17. package/dist/esm/client.js +27 -27
  18. package/dist/esm/domain/authorize.js +2 -2
  19. package/dist/esm/domain/callback.js +121 -7
  20. package/dist/esm/domain/client-auth.js +2 -2
  21. package/dist/esm/domain/crypto.js +14 -14
  22. package/dist/esm/domain/discovery.js +2 -2
  23. package/dist/esm/domain/dpop.js +4 -4
  24. package/dist/esm/domain/id-token.js +5 -5
  25. package/dist/esm/domain/jwt.js +4 -4
  26. package/dist/esm/domain/method-dispatch.js +27 -1
  27. package/dist/esm/domain/method-route.js +80 -1
  28. package/dist/esm/domain/par.js +2 -2
  29. package/dist/esm/domain/pkce.js +2 -2
  30. package/dist/esm/domain/revoke.js +2 -2
  31. package/dist/esm/domain/scim/discovery.js +206 -0
  32. package/dist/esm/domain/scim/filter.js +177 -0
  33. package/dist/esm/domain/scim/handle.js +338 -0
  34. package/dist/esm/domain/scim/patch.js +568 -0
  35. package/dist/esm/domain/scim/resource.js +256 -0
  36. package/dist/esm/domain/state-envelope.js +2 -2
  37. package/dist/esm/domain/token.js +5 -5
  38. package/dist/esm/error.js +8 -8
  39. package/dist/esm/http/cookies.js +19 -2
  40. package/dist/esm/http/errors.js +4 -4
  41. package/dist/esm/http/handlers/authorize.js +3 -19
  42. package/dist/esm/http/handlers/callback.js +7 -1
  43. package/dist/esm/http/handlers/metadata.js +2 -2
  44. package/dist/esm/http/handlers/method-route.js +47 -2
  45. package/dist/esm/http/handlers/revocation.js +2 -2
  46. package/dist/esm/http/handlers/scim.js +59 -0
  47. package/dist/esm/http/middleware/tenant.js +31 -6
  48. package/dist/esm/http/router.js +3 -0
  49. package/dist/esm/http/schemas/authorize.js +2 -2
  50. package/dist/esm/http/schemas/revocation.js +2 -2
  51. package/dist/esm/http/schemas/token.js +7 -7
  52. package/dist/esm/index.js +34 -32
  53. package/dist/esm/methods/oauth2-factory.js +2 -2
  54. package/dist/esm/methods/password-hash.js +2 -2
  55. package/dist/esm/methods/providers/index.js +30 -14
  56. package/dist/esm/methods/saml-sp/acs.js +232 -0
  57. package/dist/esm/methods/saml-sp/attributes.js +77 -0
  58. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  59. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  60. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  61. package/dist/esm/methods/saml-sp/factory.js +81 -0
  62. package/dist/esm/methods/saml-sp/index.js +7 -0
  63. package/dist/esm/methods/saml-sp/metadata.js +71 -0
  64. package/dist/esm/methods/saml-sp/method.js +29 -0
  65. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  66. package/dist/esm/methods/saml-sp/saml-instance.js +59 -0
  67. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  68. package/dist/esm/methods/saml-sp/sls.js +154 -0
  69. package/dist/esm/methods/saml-sp/types.js +0 -0
  70. package/dist/esm/ports/scim-directory.js +0 -0
  71. package/dist/esm/types/error.js +5 -0
  72. package/dist/esm/types/result.js +3 -3
  73. package/dist/esm/types/scim.js +0 -0
  74. package/dist/esm/ui/forms.js +3 -3
  75. package/dist/esm/ui/index.js +8 -5
  76. package/dist/esm/ui/picker.js +2 -2
  77. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  79. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  80. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  81. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  82. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  83. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  84. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  85. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  86. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  87. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  88. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  89. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  90. package/dist/types/domain/callback.d.ts +16 -1
  91. package/dist/types/domain/callback.d.ts.map +1 -1
  92. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  93. package/dist/types/domain/method-route.d.ts +57 -1
  94. package/dist/types/domain/method-route.d.ts.map +1 -1
  95. package/dist/types/domain/scim/discovery.d.ts +11 -0
  96. package/dist/types/domain/scim/discovery.d.ts.map +1 -0
  97. package/dist/types/domain/scim/filter.d.ts +51 -0
  98. package/dist/types/domain/scim/filter.d.ts.map +1 -0
  99. package/dist/types/domain/scim/handle.d.ts +35 -0
  100. package/dist/types/domain/scim/handle.d.ts.map +1 -0
  101. package/dist/types/domain/scim/patch.d.ts +73 -0
  102. package/dist/types/domain/scim/patch.d.ts.map +1 -0
  103. package/dist/types/domain/scim/resource.d.ts +56 -0
  104. package/dist/types/domain/scim/resource.d.ts.map +1 -0
  105. package/dist/types/http/context.d.ts +6 -1
  106. package/dist/types/http/context.d.ts.map +1 -1
  107. package/dist/types/http/cookies.d.ts +9 -1
  108. package/dist/types/http/cookies.d.ts.map +1 -1
  109. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  110. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  111. package/dist/types/http/handlers/scim.d.ts +3 -0
  112. package/dist/types/http/handlers/scim.d.ts.map +1 -0
  113. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  114. package/dist/types/http/router.d.ts.map +1 -1
  115. package/dist/types/index.d.ts +3 -1
  116. package/dist/types/index.d.ts.map +1 -1
  117. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/attributes.d.ts +45 -0
  120. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  121. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  122. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  123. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  124. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  125. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  126. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  127. package/dist/types/methods/saml-sp/factory.d.ts +920 -0
  128. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  129. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  130. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  131. package/dist/types/methods/saml-sp/metadata.d.ts +50 -0
  132. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  133. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  134. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  135. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  136. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  137. package/dist/types/methods/saml-sp/saml-instance.d.ts +93 -0
  138. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  139. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  140. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  141. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  142. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  143. package/dist/types/methods/saml-sp/types.d.ts +299 -0
  144. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  145. package/dist/types/ports/audit-log.d.ts +18 -5
  146. package/dist/types/ports/audit-log.d.ts.map +1 -1
  147. package/dist/types/ports/scim-directory.d.ts +104 -0
  148. package/dist/types/ports/scim-directory.d.ts.map +1 -0
  149. package/dist/types/ports/session-store.d.ts +23 -0
  150. package/dist/types/ports/session-store.d.ts.map +1 -1
  151. package/dist/types/types/error.d.ts +5 -0
  152. package/dist/types/types/error.d.ts.map +1 -1
  153. package/dist/types/types/idp.d.ts +83 -0
  154. package/dist/types/types/idp.d.ts.map +1 -1
  155. package/dist/types/types/method.d.ts +109 -0
  156. package/dist/types/types/method.d.ts.map +1 -1
  157. package/dist/types/types/scim.d.ts +269 -0
  158. package/dist/types/types/scim.d.ts.map +1 -0
  159. package/dist/types/types/tenant.d.ts +10 -0
  160. package/dist/types/types/tenant.d.ts.map +1 -1
  161. package/package.json +9 -1
  162. package/src/adapters/d1/migrations.ts +5 -0
  163. package/src/adapters/d1/session-store.ts +68 -0
  164. package/src/adapters/durable-object/session-store.ts +61 -0
  165. package/src/adapters/dynamo/session-store.ts +63 -0
  166. package/src/adapters/memory/session-store.ts +39 -0
  167. package/src/adapters/postgres/migrations.ts +6 -0
  168. package/src/adapters/postgres/session-store.ts +69 -0
  169. package/src/domain/callback.ts +215 -7
  170. package/src/domain/method-dispatch.ts +53 -1
  171. package/src/domain/method-route.ts +190 -1
  172. package/src/domain/scim/discovery.ts +262 -0
  173. package/src/domain/scim/filter.ts +295 -0
  174. package/src/domain/scim/handle.ts +597 -0
  175. package/src/domain/scim/patch.ts +807 -0
  176. package/src/domain/scim/resource.ts +358 -0
  177. package/src/http/context.ts +9 -0
  178. package/src/http/cookies.ts +19 -1
  179. package/src/http/handlers/authorize.ts +2 -22
  180. package/src/http/handlers/callback.ts +9 -1
  181. package/src/http/handlers/method-route.ts +72 -2
  182. package/src/http/handlers/scim.ts +107 -0
  183. package/src/http/middleware/tenant.ts +47 -1
  184. package/src/http/router.ts +9 -0
  185. package/src/index.ts +23 -0
  186. package/src/methods/saml-sp/acs.ts +449 -0
  187. package/src/methods/saml-sp/attributes.ts +146 -0
  188. package/src/methods/saml-sp/authnrequest.ts +129 -0
  189. package/src/methods/saml-sp/cache-provider.ts +76 -0
  190. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  191. package/src/methods/saml-sp/factory.ts +139 -0
  192. package/src/methods/saml-sp/index.ts +34 -0
  193. package/src/methods/saml-sp/metadata.ts +215 -0
  194. package/src/methods/saml-sp/method.ts +65 -0
  195. package/src/methods/saml-sp/parse-idp-metadata.ts +158 -0
  196. package/src/methods/saml-sp/saml-instance.ts +210 -0
  197. package/src/methods/saml-sp/slo-initiate.ts +180 -0
  198. package/src/methods/saml-sp/sls.ts +307 -0
  199. package/src/methods/saml-sp/types.ts +298 -0
  200. package/src/ports/CONSISTENCY.md +52 -24
  201. package/src/ports/audit-log.ts +18 -5
  202. package/src/ports/scim-directory.ts +166 -0
  203. package/src/ports/session-store.ts +24 -0
  204. package/src/types/error.ts +11 -0
  205. package/src/types/idp.ts +86 -0
  206. package/src/types/method.ts +110 -0
  207. package/src/types/scim.ts +280 -0
  208. package/src/types/tenant.ts +10 -0
@@ -101,6 +101,30 @@ export type SessionStore = {
101
101
  ttl: number,
102
102
  ): Promise<Result<void>>
103
103
  consumePar?(requestUri: string): Promise<Result<ParRecord>>
104
+
105
+ /**
106
+ * Optional: per-method-instance scratch storage. Survives across flows
107
+ * (unlike `methodState`, which is per-flow). Use cases include cross-flow
108
+ * deduplication state — e.g., a SAML SP method remembering recently-seen
109
+ * assertion IDs for replay protection.
110
+ *
111
+ * The framework scopes keys per `(tenantId, methodId)` before calling
112
+ * these — adapters see opaque, already-namespaced keys and store the
113
+ * UTF-8 string value verbatim. Methods JSON-encode if they want object
114
+ * state.
115
+ *
116
+ * Strong consistency + TTL respect (same semantics as flow records).
117
+ * `readScratch` returns `unknown_state` if the key is missing or
118
+ * expired. `deleteScratch` is idempotent.
119
+ *
120
+ * Adapters without these methods cannot host methods that depend on
121
+ * scratch; the framework surfaces a clear `unsupported` error through
122
+ * `MethodContext.methodScratch` at call time. Implement all three
123
+ * methods together — partial implementations are not supported.
124
+ */
125
+ saveScratch?(key: string, value: string, ttlMs: number): Promise<Result<void>>
126
+ readScratch?(key: string): Promise<Result<string>>
127
+ deleteScratch?(key: string): Promise<Result<void>>
104
128
  }
105
129
 
106
130
  /**
@@ -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
 
@@ -69,6 +70,56 @@ export type FailureEvent = {
69
70
  error: AuthError
70
71
  }
71
72
 
73
+ /**
74
+ * Input to the optional `IdPOptions.onLogout` hook.
75
+ *
76
+ * Fires when an upstream provider notifies this IdP that a federated
77
+ * session has ended — today, a SAML front-channel `LogoutRequest`
78
+ * delivered to the SP's SLS endpoint. By the time this runs the library
79
+ * has already cryptographically verified the upstream logout message
80
+ * (XML-DSig via the SAML method), so the hook is purely the host's
81
+ * teardown point.
82
+ *
83
+ * The library deliberately does **not** know which OIDC `subject` an
84
+ * upstream identifier (`nameId`) maps to — that mapping lives in the
85
+ * host's `success` callback, not the library. So the host clears its
86
+ * own session and returns the subject (if any) whose library-issued
87
+ * tokens should be revoked; the library then runs the same
88
+ * `revokeAllForSubject` primitive `/end_session` uses. Returning
89
+ * nothing skips library-side revocation (the host handled everything,
90
+ * or there is nothing to revoke).
91
+ *
92
+ * Method-agnostic on purpose: any federation method that can verify an
93
+ * upstream logout signal reuses this. SAML SLO is the first caller;
94
+ * OIDC back-channel logout would be the next.
95
+ */
96
+ export type LogoutEventInput = {
97
+ tenant: TenantContext
98
+ methodId: string
99
+ methodKind: string
100
+ /** What kind of upstream logout this is. Extensible discriminant. */
101
+ reason: "upstream_slo"
102
+ /**
103
+ * Upstream subject identifier from the verified logout message
104
+ * (SAML `LogoutRequest/NameID`). Absent if the message omitted it.
105
+ */
106
+ nameId?: string
107
+ /**
108
+ * Upstream session index from the verified logout message
109
+ * (SAML `SessionIndex`), when present. Lets a host that tracks
110
+ * per-session state scope its teardown.
111
+ */
112
+ sessionIndex?: string
113
+ }
114
+
115
+ /**
116
+ * Return of `IdPOptions.onLogout`. `revokeSubject` names the OIDC
117
+ * subject whose library-issued refresh tokens the library should
118
+ * revoke (the host resolves it from `nameId` — only the host has that
119
+ * map). Omit / return nothing to skip library-side revocation.
120
+ */
121
+ export type LogoutHookResult = { revokeSubject?: string } | void
122
+
72
123
  /**
73
124
  * Optional hook called at `/token` time, after PKCE has succeeded and
74
125
  * after the `success` callback has produced a `SubjectClaim`, but
@@ -230,6 +281,17 @@ export type IdPOptions = {
230
281
  auditLog?: AuditLog
231
282
  /** Optional — falls back to `ConfigStore` for `MethodConfig` lookups. */
232
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
233
295
 
234
296
  /** Issuer URL. Function form lets multi-tenant deployments derive it per request. */
235
297
  issuerUrl: string | ((req: Request) => string)
@@ -259,6 +321,30 @@ export type IdPOptions = {
259
321
  onFailure?: (event: FailureEvent) => Promise<void>
260
322
  }
261
323
 
324
+ /**
325
+ * Optional hook fired when an upstream provider signals that a
326
+ * federated session ended — SAML front-channel Single Logout today.
327
+ * The library has already verified the signed logout message; this
328
+ * hook is where the host tears down its own session and names the
329
+ * OIDC subject (if any) whose library-issued tokens to revoke. See
330
+ * the `LogoutEventInput` / `LogoutHookResult` type docs.
331
+ *
332
+ * Unlike `hooks.onSuccess`/`onFailure` (observation only) this hook
333
+ * **influences** library behaviour — its return drives token
334
+ * revocation — so it sits at the top level alongside `success`.
335
+ *
336
+ * Absent ⇒ the library still verifies the logout, emits a
337
+ * `session_logout` audit event, and returns the protocol
338
+ * `LogoutResponse`, but performs no token revocation (it cannot map
339
+ * the upstream id to a subject without the host).
340
+ *
341
+ * If it throws, the SLS endpoint fails closed with an internal error
342
+ * rather than acknowledging a logout it could not fully process.
343
+ */
344
+ onLogout?: (
345
+ input: LogoutEventInput,
346
+ ) => Promise<LogoutHookResult> | LogoutHookResult
347
+
262
348
  /**
263
349
  * Optional escape hatch for high-sensitivity deployments — see the
264
350
  * `PersistUpstreamTokens` type doc.
@@ -56,6 +56,37 @@ export type AuthMethod<P = unknown, S = unknown> = {
56
56
  * same factory get distinct URL spaces).
57
57
  */
58
58
  routes: Record<string, MethodHandler<P, S>>
59
+ /**
60
+ * Opt-in allowlist of route keys (same `"GET /metadata"` string form
61
+ * as `routes` keys) that the framework dispatches **without** a flow
62
+ * cookie or flow record — anonymous, unauthenticated GETs. The sole
63
+ * intended use is publishing static, per-instance descriptive
64
+ * documents (SAML SP metadata XML). The handler receives
65
+ * `ctx.flow === null` and `ctx.methodState === null` and MUST be a
66
+ * pure function of `ctx.tenant` + `ctx.dispatch` + its captured
67
+ * config — it must not assume an authenticated principal.
68
+ *
69
+ * Absent (the default for every method) ⇒ behaviour is unchanged:
70
+ * every `/m/<id>/*` request requires the `idp.flow` cookie. The
71
+ * cookie gate is skipped *only* for a route key a method explicitly
72
+ * lists here — fail-closed by construction.
73
+ */
74
+ publicRoutes?: ReadonlyArray<string>
75
+ /**
76
+ * Opt-in: this method instance handles **unsolicited** upstream
77
+ * callbacks — a `POST /cb/<methodId>` carrying a provider assertion
78
+ * with no framework state envelope and no prior flow (SAML
79
+ * IdP-initiated SSO). When `true`, the framework, instead of
80
+ * rejecting a stateless callback, dispatches `GET /callback` with
81
+ * `flow === null` and a derived `dispatch` (issuer/ACS); the handler
82
+ * verifies the assertion and returns `success` **with
83
+ * `unsolicitedBinding`**. Absent (every method's default) ⇒ a
84
+ * stateless callback stays an `invalid_request` (the conservative
85
+ * default — many deployments do not want IdP-initiated). General
86
+ * capability, not SAML-specific; set per instance (a SAML instance
87
+ * sets it only when its config enables IdP-initiated).
88
+ */
89
+ unsolicitedCallback?: boolean
59
90
  /**
60
91
  * Token-exchange function for the `/token` endpoint when the method
61
92
  * participates in client-credentials-style flows (e.g. `m2m`). Most
@@ -102,6 +133,47 @@ export type MethodContext<S = unknown> = {
102
133
  * and the relevant data is on `flow`).
103
134
  */
104
135
  dispatch: MethodDispatchData | null
136
+ /**
137
+ * Per-method-instance scratch storage scoped to
138
+ * `(tenant.id, method.id)`. Survives across flows — distinct from
139
+ * `methodState`, which is per-flow.
140
+ *
141
+ * Most methods do NOT need this. It exists for cross-flow
142
+ * deduplication patterns such as SAML SP assertion-ID replay
143
+ * protection.
144
+ *
145
+ * Backed by `SessionStore.{saveScratch,readScratch,deleteScratch}`
146
+ * when those optional methods are implemented. Against adapters that
147
+ * don't implement them, every call returns
148
+ * `{ ok: false, error: unsupported }` — the method should surface a
149
+ * `MethodResult.error` with a clear message, not silently degrade.
150
+ */
151
+ methodScratch: MethodScratch
152
+ }
153
+
154
+ /**
155
+ * Caller-facing API for per-method-instance scratch. The framework
156
+ * scopes user-supplied keys with a `(tenantId, methodId)` prefix before
157
+ * delegating to `SessionStore` — adapters never see raw method keys.
158
+ *
159
+ * Values are UTF-8 strings; methods JSON-encode if they want to stash
160
+ * objects. Keeping the port-level type narrow simplifies adapter
161
+ * implementations (one TEXT column, one Dynamo `S` attribute, etc.).
162
+ */
163
+ export type MethodScratch = {
164
+ /**
165
+ * Persist `value` under `key` with the given TTL. Overwrites prior
166
+ * value for the same key. `ttlMs` must be positive.
167
+ */
168
+ put(key: string, value: string, ttlMs: number): Promise<Result<void>>
169
+ /**
170
+ * Read the value previously stored at `key`. Returns `unknown_state`
171
+ * if the key is missing or expired (the underlying adapter MAY
172
+ * lazily evict expired entries on read).
173
+ */
174
+ get(key: string): Promise<Result<string>>
175
+ /** Idempotent. Resolves `ok` whether the key existed or not. */
176
+ delete(key: string): Promise<Result<void>>
105
177
  }
106
178
 
107
179
  /** Framework-supplied data available to the method at `/authorize` time. */
@@ -143,6 +215,27 @@ export type MethodResult<P = unknown, S = unknown> =
143
215
  saveMethodState?: S
144
216
  /** Serialized into a `Cache-Control` header by the framework. */
145
217
  cache?: CachePolicy
218
+ /**
219
+ * Verified upstream-logout notification. Set **only** by a
220
+ * flowless **public** logout route (e.g. SAML front-channel SLS)
221
+ * once it has cryptographically verified the inbound logout
222
+ * message. The method stays port-free — it proves authenticity
223
+ * and builds the protocol response (`response` = the signed
224
+ * `LogoutResponse` redirect / ack); the privileged side effect
225
+ * runs in the framework.
226
+ *
227
+ * When present on a public route the framework fires
228
+ * `IdPOptions.onLogout` and, if that returns `{ revokeSubject }`,
229
+ * runs `revokeAllForSubject` — then returns `response`. Ignored
230
+ * on non-public (flow-bearing) routes: an authenticated method
231
+ * route never logs anyone out. Mirrors the Phase 2 V′ pattern
232
+ * (`success.unsolicitedBinding`): an optional field on an
233
+ * existing variant, not a new `MethodResult` kind.
234
+ */
235
+ logout?: {
236
+ nameId?: string
237
+ sessionIndex?: string
238
+ }
146
239
  }
147
240
  /**
148
241
  * Authentication succeeded. The HTTP layer hands `providerSubject` +
@@ -155,6 +248,23 @@ export type MethodResult<P = unknown, S = unknown> =
155
248
  providerSubject: string
156
249
  properties: P
157
250
  setCookies?: SetCookie[]
251
+ /**
252
+ * RP binding for a **flowless** (unsolicited / IdP-initiated)
253
+ * authentication. Consulted **only** when the method ran with no
254
+ * `flow` (e.g. an unsolicited SAML Response — see
255
+ * `AuthMethod.unsolicitedCallback`). On the normal SP-initiated
256
+ * path a `flow` exists and this is ignored; on the flowless path
257
+ * it is **required** (the framework has no pending RP request to
258
+ * read `client_id` / `redirect_uri` / `scope` from, so the method
259
+ * supplies the operator-configured defaults). The framework still
260
+ * validates `clientId` / `redirectUri` against the tenant's
261
+ * registered client before issuing a code.
262
+ */
263
+ unsolicitedBinding?: {
264
+ clientId: string
265
+ redirectUri: string
266
+ scopes: string[]
267
+ }
158
268
  }
159
269
  /** User refused / failed auth in a non-error way (e.g. consent declined). */
160
270
  | { kind: "denied"; reason: string; setCookies?: SetCookie[] }
@@ -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
+ }
@@ -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
  /**