@_mustachio/openauth 0.11.0 → 0.12.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 (164) 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/state-envelope.js +2 -2
  32. package/dist/esm/domain/token.js +5 -5
  33. package/dist/esm/error.js +8 -8
  34. package/dist/esm/http/cookies.js +19 -2
  35. package/dist/esm/http/errors.js +4 -4
  36. package/dist/esm/http/handlers/authorize.js +3 -19
  37. package/dist/esm/http/handlers/callback.js +7 -1
  38. package/dist/esm/http/handlers/metadata.js +2 -2
  39. package/dist/esm/http/handlers/method-route.js +47 -2
  40. package/dist/esm/http/handlers/revocation.js +2 -2
  41. package/dist/esm/http/middleware/tenant.js +6 -6
  42. package/dist/esm/http/schemas/authorize.js +2 -2
  43. package/dist/esm/http/schemas/revocation.js +2 -2
  44. package/dist/esm/http/schemas/token.js +7 -7
  45. package/dist/esm/index.js +33 -32
  46. package/dist/esm/methods/oauth2-factory.js +2 -2
  47. package/dist/esm/methods/password-hash.js +2 -2
  48. package/dist/esm/methods/providers/index.js +30 -14
  49. package/dist/esm/methods/saml-sp/acs.js +208 -0
  50. package/dist/esm/methods/saml-sp/attributes.js +75 -0
  51. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  52. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  53. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  54. package/dist/esm/methods/saml-sp/factory.js +70 -0
  55. package/dist/esm/methods/saml-sp/index.js +7 -0
  56. package/dist/esm/methods/saml-sp/metadata.js +69 -0
  57. package/dist/esm/methods/saml-sp/method.js +29 -0
  58. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  59. package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
  60. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  61. package/dist/esm/methods/saml-sp/sls.js +154 -0
  62. package/dist/esm/methods/saml-sp/types.js +0 -0
  63. package/dist/esm/types/result.js +3 -3
  64. package/dist/esm/ui/forms.js +3 -3
  65. package/dist/esm/ui/index.js +8 -5
  66. package/dist/esm/ui/picker.js +2 -2
  67. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  68. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  69. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  70. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  71. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  72. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  73. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  74. package/dist/types/adapters/memory/session-store.d.ts +3 -0
  75. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  76. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  77. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  78. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  79. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  80. package/dist/types/domain/callback.d.ts +16 -1
  81. package/dist/types/domain/callback.d.ts.map +1 -1
  82. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  83. package/dist/types/domain/method-route.d.ts +57 -1
  84. package/dist/types/domain/method-route.d.ts.map +1 -1
  85. package/dist/types/http/context.d.ts +3 -1
  86. package/dist/types/http/context.d.ts.map +1 -1
  87. package/dist/types/http/cookies.d.ts +9 -1
  88. package/dist/types/http/cookies.d.ts.map +1 -1
  89. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  90. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  91. package/dist/types/index.d.ts +1 -1
  92. package/dist/types/index.d.ts.map +1 -1
  93. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  94. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  95. package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
  96. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  97. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  98. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  99. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  100. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  101. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  102. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  103. package/dist/types/methods/saml-sp/factory.d.ts +704 -0
  104. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  105. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  106. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  107. package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
  108. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  109. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  110. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  111. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  112. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  113. package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
  114. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  115. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  116. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  117. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  118. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  119. package/dist/types/methods/saml-sp/types.d.ts +204 -0
  120. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  121. package/dist/types/ports/audit-log.d.ts +18 -5
  122. package/dist/types/ports/audit-log.d.ts.map +1 -1
  123. package/dist/types/ports/session-store.d.ts +23 -0
  124. package/dist/types/ports/session-store.d.ts.map +1 -1
  125. package/dist/types/types/idp.d.ts +71 -0
  126. package/dist/types/types/idp.d.ts.map +1 -1
  127. package/dist/types/types/method.d.ts +109 -0
  128. package/dist/types/types/method.d.ts.map +1 -1
  129. package/package.json +9 -1
  130. package/src/adapters/d1/migrations.ts +5 -0
  131. package/src/adapters/d1/session-store.ts +68 -0
  132. package/src/adapters/durable-object/session-store.ts +61 -0
  133. package/src/adapters/dynamo/session-store.ts +63 -0
  134. package/src/adapters/memory/session-store.ts +39 -0
  135. package/src/adapters/postgres/migrations.ts +6 -0
  136. package/src/adapters/postgres/session-store.ts +69 -0
  137. package/src/domain/callback.ts +215 -7
  138. package/src/domain/method-dispatch.ts +53 -1
  139. package/src/domain/method-route.ts +190 -1
  140. package/src/http/context.ts +6 -0
  141. package/src/http/cookies.ts +19 -1
  142. package/src/http/handlers/authorize.ts +2 -21
  143. package/src/http/handlers/callback.ts +9 -1
  144. package/src/http/handlers/method-route.ts +75 -2
  145. package/src/index.ts +3 -0
  146. package/src/methods/saml-sp/acs.ts +399 -0
  147. package/src/methods/saml-sp/attributes.ts +137 -0
  148. package/src/methods/saml-sp/authnrequest.ts +128 -0
  149. package/src/methods/saml-sp/cache-provider.ts +76 -0
  150. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  151. package/src/methods/saml-sp/factory.ts +118 -0
  152. package/src/methods/saml-sp/index.ts +34 -0
  153. package/src/methods/saml-sp/metadata.ts +204 -0
  154. package/src/methods/saml-sp/method.ts +65 -0
  155. package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
  156. package/src/methods/saml-sp/saml-instance.ts +173 -0
  157. package/src/methods/saml-sp/slo-initiate.ts +177 -0
  158. package/src/methods/saml-sp/sls.ts +306 -0
  159. package/src/methods/saml-sp/types.ts +203 -0
  160. package/src/ports/CONSISTENCY.md +25 -0
  161. package/src/ports/audit-log.ts +18 -5
  162. package/src/ports/session-store.ts +28 -0
  163. package/src/types/idp.ts +74 -0
  164. package/src/types/method.ts +110 -0
@@ -54,6 +54,37 @@ export type AuthMethod<P = unknown, S = unknown> = {
54
54
  * same factory get distinct URL spaces).
55
55
  */
56
56
  routes: Record<string, MethodHandler<P, S>>;
57
+ /**
58
+ * Opt-in allowlist of route keys (same `"GET /metadata"` string form
59
+ * as `routes` keys) that the framework dispatches **without** a flow
60
+ * cookie or flow record — anonymous, unauthenticated GETs. The sole
61
+ * intended use is publishing static, per-instance descriptive
62
+ * documents (SAML SP metadata XML). The handler receives
63
+ * `ctx.flow === null` and `ctx.methodState === null` and MUST be a
64
+ * pure function of `ctx.tenant` + `ctx.dispatch` + its captured
65
+ * config — it must not assume an authenticated principal.
66
+ *
67
+ * Absent (the default for every method) ⇒ behaviour is unchanged:
68
+ * every `/m/<id>/*` request requires the `idp.flow` cookie. The
69
+ * cookie gate is skipped *only* for a route key a method explicitly
70
+ * lists here — fail-closed by construction.
71
+ */
72
+ publicRoutes?: ReadonlyArray<string>;
73
+ /**
74
+ * Opt-in: this method instance handles **unsolicited** upstream
75
+ * callbacks — a `POST /cb/<methodId>` carrying a provider assertion
76
+ * with no framework state envelope and no prior flow (SAML
77
+ * IdP-initiated SSO). When `true`, the framework, instead of
78
+ * rejecting a stateless callback, dispatches `GET /callback` with
79
+ * `flow === null` and a derived `dispatch` (issuer/ACS); the handler
80
+ * verifies the assertion and returns `success` **with
81
+ * `unsolicitedBinding`**. Absent (every method's default) ⇒ a
82
+ * stateless callback stays an `invalid_request` (the conservative
83
+ * default — many deployments do not want IdP-initiated). General
84
+ * capability, not SAML-specific; set per instance (a SAML instance
85
+ * sets it only when its config enables IdP-initiated).
86
+ */
87
+ unsolicitedCallback?: boolean;
57
88
  /**
58
89
  * Token-exchange function for the `/token` endpoint when the method
59
90
  * participates in client-credentials-style flows (e.g. `m2m`). Most
@@ -96,6 +127,46 @@ export type MethodContext<S = unknown> = {
96
127
  * and the relevant data is on `flow`).
97
128
  */
98
129
  dispatch: MethodDispatchData | null;
130
+ /**
131
+ * Per-method-instance scratch storage scoped to
132
+ * `(tenant.id, method.id)`. Survives across flows — distinct from
133
+ * `methodState`, which is per-flow.
134
+ *
135
+ * Most methods do NOT need this. It exists for cross-flow
136
+ * deduplication patterns such as SAML SP assertion-ID replay
137
+ * protection.
138
+ *
139
+ * Backed by `SessionStore.{saveScratch,readScratch,deleteScratch}`
140
+ * when those optional methods are implemented. Against adapters that
141
+ * don't implement them, every call returns
142
+ * `{ ok: false, error: unsupported }` — the method should surface a
143
+ * `MethodResult.error` with a clear message, not silently degrade.
144
+ */
145
+ methodScratch: MethodScratch;
146
+ };
147
+ /**
148
+ * Caller-facing API for per-method-instance scratch. The framework
149
+ * scopes user-supplied keys with a `(tenantId, methodId)` prefix before
150
+ * delegating to `SessionStore` — adapters never see raw method keys.
151
+ *
152
+ * Values are UTF-8 strings; methods JSON-encode if they want to stash
153
+ * objects. Keeping the port-level type narrow simplifies adapter
154
+ * implementations (one TEXT column, one Dynamo `S` attribute, etc.).
155
+ */
156
+ export type MethodScratch = {
157
+ /**
158
+ * Persist `value` under `key` with the given TTL. Overwrites prior
159
+ * value for the same key. `ttlMs` must be positive.
160
+ */
161
+ put(key: string, value: string, ttlMs: number): Promise<Result<void>>;
162
+ /**
163
+ * Read the value previously stored at `key`. Returns `unknown_state`
164
+ * if the key is missing or expired (the underlying adapter MAY
165
+ * lazily evict expired entries on read).
166
+ */
167
+ get(key: string): Promise<Result<string>>;
168
+ /** Idempotent. Resolves `ok` whether the key existed or not. */
169
+ delete(key: string): Promise<Result<void>>;
99
170
  };
100
171
  /** Framework-supplied data available to the method at `/authorize` time. */
101
172
  export type MethodDispatchData = {
@@ -135,6 +206,27 @@ export type MethodResult<P = unknown, S = unknown> =
135
206
  saveMethodState?: S;
136
207
  /** Serialized into a `Cache-Control` header by the framework. */
137
208
  cache?: CachePolicy;
209
+ /**
210
+ * Verified upstream-logout notification. Set **only** by a
211
+ * flowless **public** logout route (e.g. SAML front-channel SLS)
212
+ * once it has cryptographically verified the inbound logout
213
+ * message. The method stays port-free — it proves authenticity
214
+ * and builds the protocol response (`response` = the signed
215
+ * `LogoutResponse` redirect / ack); the privileged side effect
216
+ * runs in the framework.
217
+ *
218
+ * When present on a public route the framework fires
219
+ * `IdPOptions.onLogout` and, if that returns `{ revokeSubject }`,
220
+ * runs `revokeAllForSubject` — then returns `response`. Ignored
221
+ * on non-public (flow-bearing) routes: an authenticated method
222
+ * route never logs anyone out. Mirrors the Phase 2 V′ pattern
223
+ * (`success.unsolicitedBinding`): an optional field on an
224
+ * existing variant, not a new `MethodResult` kind.
225
+ */
226
+ logout?: {
227
+ nameId?: string;
228
+ sessionIndex?: string;
229
+ };
138
230
  }
139
231
  /**
140
232
  * Authentication succeeded. The HTTP layer hands `providerSubject` +
@@ -147,6 +239,23 @@ export type MethodResult<P = unknown, S = unknown> =
147
239
  providerSubject: string;
148
240
  properties: P;
149
241
  setCookies?: SetCookie[];
242
+ /**
243
+ * RP binding for a **flowless** (unsolicited / IdP-initiated)
244
+ * authentication. Consulted **only** when the method ran with no
245
+ * `flow` (e.g. an unsolicited SAML Response — see
246
+ * `AuthMethod.unsolicitedCallback`). On the normal SP-initiated
247
+ * path a `flow` exists and this is ignored; on the flowless path
248
+ * it is **required** (the framework has no pending RP request to
249
+ * read `client_id` / `redirect_uri` / `scope` from, so the method
250
+ * supplies the operator-configured defaults). The framework still
251
+ * validates `clientId` / `redirectUri` against the tenant's
252
+ * registered client before issuing a code.
253
+ */
254
+ unsolicitedBinding?: {
255
+ clientId: string;
256
+ redirectUri: string;
257
+ scopes: string[];
258
+ };
150
259
  }
151
260
  /** User refused / failed auth in a non-error way (e.g. consent declined). */
152
261
  | {
@@ -1 +1 @@
1
- {"version":3,"file":"method.d.ts","sourceRoot":"","sources":["../../../src/types/method.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,uBAAuB,CAAA;AAE/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACxC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAA;AACxC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAA;AAEnE;;;;;;;;;GASG;AACH,MAAM,MAAM,UAAU,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO,IAAI;IACjD;;;OAGG;IACH,EAAE,EAAE,MAAM,CAAA;IACV;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,UAAU,CAAA;IAChB;;;;;OAKG;IACH,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC3C;;;;OAIG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAA;CACrB,CAAA;AAED,MAAM,MAAM,aAAa,CAAC,CAAC,EAAE,CAAC,IAAI,CAChC,GAAG,EAAE,aAAa,CAAC,CAAC,CAAC,KAClB,OAAO,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;AAEhC;;;;;;;;;;GAUG;AACH,MAAM,MAAM,aAAa,CAAC,CAAC,GAAG,OAAO,IAAI;IACvC,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAA;IAChB,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,EAAE,aAAa,CAAA;IACrB,2EAA2E;IAC3E,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IACvB;;;OAGG;IACH,WAAW,EAAE,CAAC,GAAG,IAAI,CAAA;IACrB,6DAA6D;IAC7D,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACpC;;;;;;OAMG;IACH,QAAQ,EAAE,kBAAkB,GAAG,IAAI,CAAA;CACpC,CAAA;AAED,4EAA4E;AAC5E,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAA;IACnB,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,YAAY,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO;AAC/C;;;;;;;;GAQG;AACD;IACE,IAAI,EAAE,WAAW,CAAA;IACjB,wEAAwE;IACxE,QAAQ,EAAE,QAAQ,CAAA;IAClB,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB,eAAe,CAAC,EAAE,CAAC,CAAA;IACnB,iEAAiE;IACjE,KAAK,CAAC,EAAE,WAAW,CAAA;CACpB;AACH;;;;GAIG;GACD;IACE,IAAI,EAAE,SAAS,CAAA;IACf,2EAA2E;IAC3E,eAAe,EAAE,MAAM,CAAA;IACvB,UAAU,EAAE,CAAC,CAAA;IACb,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;CACzB;AACH,6EAA6E;GAC3E;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;CAAE;AAC9D,kFAAkF;GAChF;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAA;AAEvC;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,8BAA8B;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qCAAqC;IACrC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,8BAA8B;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAA;IACZ,8CAA8C;IAC9C,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAA;CACrC,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,KAAK,EAAE;IAChC,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC/B,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,CAAA;AAEnC;;;;;;;;;;;;;;GAcG;AAMH,MAAM,MAAM,oBAAoB,GAAG,iBAAiB,CAClD,GAAG,EAAE,6CAA6C;AAClD,GAAG,EAAE,2BAA2B;AAChC,GAAG,CACJ,CAAA;AAED,MAAM,MAAM,iBAAiB,CAAC,CAAC,GAAG,OAAO,EAAE,CAAC,GAAG,OAAO,EAAE,GAAG,GAAG,OAAO,IAAI;IACvE,2FAA2F;IAC3F,IAAI,EAAE,MAAM,CAAA;IAEZ;;;;;;;;;;;;OAYG;IACH,YAAY,EAAE,EAAE,CAAC,cAAc,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;IAE7C;;;;;OAKG;IACH,KAAK,EAAE,CAAC,KAAK,EAAE;QACb,6EAA6E;QAC7E,EAAE,EAAE,MAAM,CAAA;QACV,mEAAmE;QACnE,IAAI,EAAE,MAAM,CAAA;QACZ,QAAQ,EAAE,QAAQ,CAAA;QAClB,wCAAwC;QACxC,MAAM,EAAE,GAAG,CAAA;KACZ,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;CAChC,CAAA"}
1
+ {"version":3,"file":"method.d.ts","sourceRoot":"","sources":["../../../src/types/method.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@_mustachio/openauth",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,12 @@
37
37
  "import": "./dist/esm/adapters/*/index.js",
38
38
  "types": "./dist/types/adapters/*/index.d.ts"
39
39
  },
40
+ "./methods/saml-sp": {
41
+ "node": {
42
+ "import": "./dist/esm/methods/saml-sp/index.js",
43
+ "types": "./dist/types/methods/saml-sp/index.d.ts"
44
+ }
45
+ },
40
46
  "./*": {
41
47
  "import": "./dist/esm/*.js",
42
48
  "types": "./dist/types/*.d.ts"
@@ -73,8 +79,10 @@
73
79
  },
74
80
  "dependencies": {
75
81
  "@noble/hashes": "2.2.0",
82
+ "@node-saml/node-saml": "5.1.0",
76
83
  "@simplewebauthn/server": "13.1.1",
77
84
  "@standard-schema/spec": "1.0.0-beta.3",
85
+ "@xmldom/xmldom": "0.8.13",
78
86
  "aws4fetch": "1.0.20",
79
87
  "jose": "5.9.6",
80
88
  "oauth4webapi": "3.8.6",
@@ -67,6 +67,11 @@ export const INITIAL_SCHEMA_SQL = [
67
67
  payload TEXT NOT NULL,
68
68
  expires_at INTEGER NOT NULL
69
69
  )`,
70
+ `CREATE TABLE IF NOT EXISTS openauth_scratch (
71
+ scratch_key TEXT PRIMARY KEY,
72
+ value TEXT NOT NULL,
73
+ expires_at INTEGER NOT NULL
74
+ )`,
70
75
  `CREATE TABLE IF NOT EXISTS openauth_audit_events (
71
76
  id INTEGER PRIMARY KEY AUTOINCREMENT,
72
77
  ts INTEGER NOT NULL,
@@ -227,4 +227,72 @@ export class D1SessionStore implements SessionStore {
227
227
  }
228
228
  return ok(undefined)
229
229
  }
230
+
231
+ async saveScratch(
232
+ key: string,
233
+ value: string,
234
+ ttlMs: number,
235
+ ): Promise<Result<void>> {
236
+ if (ttlMs <= 0) {
237
+ return err(
238
+ authError.internalError(
239
+ `saveScratch: ttlMs must be positive, got ${ttlMs}`,
240
+ ),
241
+ )
242
+ }
243
+ const expiresAt = this.#clock() + ttlMs
244
+ try {
245
+ // Pinned to the primary — scratch backs InResponseTo replay
246
+ // protection, which is read-after-write sensitive at the ACS.
247
+ await primarySession(this.#db)
248
+ .prepare(
249
+ `INSERT INTO openauth_scratch (scratch_key, value, expires_at) VALUES (?1, ?2, ?3)
250
+ ON CONFLICT(scratch_key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`,
251
+ )
252
+ .bind(key, value, expiresAt)
253
+ .run()
254
+ } catch (e) {
255
+ return err(authError.internalError("saveScratch: upsert failed", e))
256
+ }
257
+ return ok(undefined)
258
+ }
259
+
260
+ async readScratch(key: string): Promise<Result<string>> {
261
+ let row: { value: string; expires_at: number } | null
262
+ try {
263
+ row = await primarySession(this.#db)
264
+ .prepare(
265
+ `SELECT value, expires_at FROM openauth_scratch WHERE scratch_key = ?1`,
266
+ )
267
+ .bind(key)
268
+ .first<{ value: string; expires_at: number }>()
269
+ } catch (e) {
270
+ return err(authError.internalError("readScratch: query failed", e))
271
+ }
272
+ if (!row) {
273
+ return err(authError.unknownState(`scratch "${key}" unknown`))
274
+ }
275
+ if (this.#clock() >= Number(row.expires_at)) {
276
+ try {
277
+ await primarySession(this.#db)
278
+ .prepare(`DELETE FROM openauth_scratch WHERE scratch_key = ?1`)
279
+ .bind(key)
280
+ .run()
281
+ } catch {}
282
+ return err(authError.unknownState(`scratch "${key}" expired`))
283
+ }
284
+ return ok(row.value)
285
+ }
286
+
287
+ async deleteScratch(key: string): Promise<Result<void>> {
288
+ try {
289
+ await primarySession(this.#db)
290
+ .prepare(`DELETE FROM openauth_scratch WHERE scratch_key = ?1`)
291
+ .bind(key)
292
+ .run()
293
+ } catch (e) {
294
+ return err(authError.internalError("deleteScratch: delete failed", e))
295
+ }
296
+ return ok(undefined)
297
+ }
230
298
  }
@@ -51,6 +51,11 @@ type StoredSession = {
51
51
  expiresAt: number
52
52
  }
53
53
 
54
+ type StoredScratch = {
55
+ value: string
56
+ expiresAt: number
57
+ }
58
+
54
59
  export type DurableObjectSessionStoreOptions = {
55
60
  storage: DurableObjectStorageLike
56
61
  clock?: () => number
@@ -218,10 +223,66 @@ export class DurableObjectSessionStore implements SessionStore {
218
223
  }
219
224
  return ok(undefined)
220
225
  }
226
+
227
+ async saveScratch(
228
+ key: string,
229
+ value: string,
230
+ ttlMs: number,
231
+ ): Promise<Result<void>> {
232
+ if (ttlMs <= 0) {
233
+ return err(
234
+ authError.internalError(
235
+ `saveScratch: ttlMs must be positive, got ${ttlMs}`,
236
+ ),
237
+ )
238
+ }
239
+ const stored: StoredScratch = {
240
+ value,
241
+ expiresAt: this.#clock() + ttlMs,
242
+ }
243
+ try {
244
+ // Plain put — scratch overwrites any prior value for the key. No
245
+ // transaction needed: it is a single write, and the DO serializes
246
+ // all operations on this instance.
247
+ await this.#storage.put(scratchKey(key), stored)
248
+ } catch (e) {
249
+ return err(authError.internalError("saveScratch: put failed", e))
250
+ }
251
+ return ok(undefined)
252
+ }
253
+
254
+ async readScratch(key: string): Promise<Result<string>> {
255
+ let stored: StoredScratch | undefined
256
+ try {
257
+ stored = await this.#storage.get<StoredScratch>(scratchKey(key))
258
+ } catch (e) {
259
+ return err(authError.internalError("readScratch: get failed", e))
260
+ }
261
+ if (!stored) {
262
+ return err(authError.unknownState(`scratch "${key}" unknown`))
263
+ }
264
+ if (this.#clock() >= stored.expiresAt) {
265
+ try {
266
+ await this.#storage.delete(scratchKey(key))
267
+ } catch {}
268
+ return err(authError.unknownState(`scratch "${key}" expired`))
269
+ }
270
+ return ok(stored.value)
271
+ }
272
+
273
+ async deleteScratch(key: string): Promise<Result<void>> {
274
+ try {
275
+ await this.#storage.delete(scratchKey(key))
276
+ } catch (e) {
277
+ return err(authError.internalError("deleteScratch: delete failed", e))
278
+ }
279
+ return ok(undefined)
280
+ }
221
281
  }
222
282
 
223
283
  const flowKey = (flowId: string) => `flow:${flowId}`
224
284
  const sessionKey = (sessionId: string) => `session:${sessionId}`
285
+ const scratchKey = (key: string) => `scratch:${key}`
225
286
 
226
287
  // Re-exported for adapter users wiring `storage.transaction` callbacks.
227
288
  export type { DurableObjectStorageLike, DurableObjectTransactionLike }
@@ -211,6 +211,69 @@ export class DynamoSessionStore implements SessionStore {
211
211
  }
212
212
  return ok(undefined)
213
213
  }
214
+
215
+ async saveScratch(
216
+ key: string,
217
+ value: string,
218
+ ttlMs: number,
219
+ ): Promise<Result<void>> {
220
+ if (ttlMs <= 0) {
221
+ return err(
222
+ authError.internalError(
223
+ `saveScratch: ttlMs must be positive, got ${ttlMs}`,
224
+ ),
225
+ )
226
+ }
227
+ const expiresAtMs = this.#clock() + ttlMs
228
+ try {
229
+ // Unconditional put — scratch overwrites any prior value for the key.
230
+ await this.#exec.put({
231
+ item: {
232
+ pk: "scratch",
233
+ sk: key,
234
+ value,
235
+ expires_at: expiresAtMs,
236
+ ttl: Math.floor(expiresAtMs / 1000),
237
+ },
238
+ })
239
+ } catch (e) {
240
+ return err(authError.internalError("saveScratch: put failed", e))
241
+ }
242
+ return ok(undefined)
243
+ }
244
+
245
+ async readScratch(key: string): Promise<Result<string>> {
246
+ let row: Record<string, unknown> | undefined
247
+ try {
248
+ row = await this.#exec.get({
249
+ key: { pk: "scratch", sk: key },
250
+ consistentRead: true,
251
+ })
252
+ } catch (e) {
253
+ return err(authError.internalError("readScratch: get failed", e))
254
+ }
255
+ if (!row) {
256
+ return err(authError.unknownState(`scratch "${key}" unknown`))
257
+ }
258
+ // Native DynamoDB TTL eviction is best-effort (can lag up to ~48h), so
259
+ // filter on the adapter clock for correctness.
260
+ if (this.#clock() >= Number(row.expires_at)) {
261
+ try {
262
+ await this.#exec.delete({ key: { pk: "scratch", sk: key } })
263
+ } catch {}
264
+ return err(authError.unknownState(`scratch "${key}" expired`))
265
+ }
266
+ return ok(String(row.value))
267
+ }
268
+
269
+ async deleteScratch(key: string): Promise<Result<void>> {
270
+ try {
271
+ await this.#exec.delete({ key: { pk: "scratch", sk: key } })
272
+ } catch (e) {
273
+ return err(authError.internalError("deleteScratch: delete failed", e))
274
+ }
275
+ return ok(undefined)
276
+ }
214
277
  }
215
278
 
216
279
  function parseFlowPayload(raw: unknown): FlowRecord {
@@ -28,6 +28,11 @@ type StoredPar = {
28
28
  expiresAt: number
29
29
  }
30
30
 
31
+ type StoredScratch = {
32
+ value: string
33
+ expiresAt: number
34
+ }
35
+
31
36
  export type MemorySessionStoreOptions = {
32
37
  clock?: Clock
33
38
  }
@@ -37,6 +42,7 @@ export class MemorySessionStore implements SessionStore {
37
42
  #flows = new Map<string, StoredFlow>()
38
43
  #sessions = new Map<string, SessionRecord>()
39
44
  #par = new Map<string, StoredPar>()
45
+ #scratch = new Map<string, StoredScratch>()
40
46
 
41
47
  constructor(opts: MemorySessionStoreOptions = {}) {
42
48
  this.#clock = opts.clock ?? realClock
@@ -168,4 +174,37 @@ export class MemorySessionStore implements SessionStore {
168
174
  }
169
175
  return ok(stored.record)
170
176
  }
177
+
178
+ async saveScratch(
179
+ key: string,
180
+ value: string,
181
+ ttlMs: number,
182
+ ): Promise<Result<void>> {
183
+ if (ttlMs <= 0) {
184
+ return err(
185
+ authError.internalError(
186
+ `saveScratch: ttlMs must be positive, got ${ttlMs}`,
187
+ ),
188
+ )
189
+ }
190
+ this.#scratch.set(key, { value, expiresAt: this.#clock() + ttlMs })
191
+ return ok(undefined)
192
+ }
193
+
194
+ async readScratch(key: string): Promise<Result<string>> {
195
+ const stored = this.#scratch.get(key)
196
+ if (!stored) {
197
+ return err(authError.unknownState(`scratch "${key}" unknown`))
198
+ }
199
+ if (this.#clock() >= stored.expiresAt) {
200
+ this.#scratch.delete(key)
201
+ return err(authError.unknownState(`scratch "${key}" expired`))
202
+ }
203
+ return ok(stored.value)
204
+ }
205
+
206
+ async deleteScratch(key: string): Promise<Result<void>> {
207
+ this.#scratch.delete(key)
208
+ return ok(undefined)
209
+ }
171
210
  }
@@ -89,6 +89,12 @@ CREATE TABLE IF NOT EXISTS openauth_sessions (
89
89
  expires_at bigint NOT NULL
90
90
  );
91
91
 
92
+ CREATE TABLE IF NOT EXISTS openauth_scratch (
93
+ scratch_key text PRIMARY KEY,
94
+ value text NOT NULL,
95
+ expires_at bigint NOT NULL
96
+ );
97
+
92
98
  CREATE TABLE IF NOT EXISTS openauth_audit_events (
93
99
  id bigserial PRIMARY KEY,
94
100
  ts bigint NOT NULL,
@@ -203,6 +203,75 @@ export class PostgresSessionStore implements SessionStore {
203
203
  }
204
204
  return ok(undefined)
205
205
  }
206
+
207
+ async saveScratch(
208
+ key: string,
209
+ value: string,
210
+ ttlMs: number,
211
+ ): Promise<Result<void>> {
212
+ if (ttlMs <= 0) {
213
+ return err(
214
+ authError.internalError(
215
+ `saveScratch: ttlMs must be positive, got ${ttlMs}`,
216
+ ),
217
+ )
218
+ }
219
+ const expiresAt = this.#clock() + ttlMs
220
+ try {
221
+ await this.#exec.query(
222
+ `INSERT INTO openauth_scratch (scratch_key, value, expires_at)
223
+ VALUES ($1, $2, $3)
224
+ ON CONFLICT (scratch_key)
225
+ DO UPDATE SET value = EXCLUDED.value, expires_at = EXCLUDED.expires_at`,
226
+ [key, value, expiresAt],
227
+ )
228
+ } catch (e) {
229
+ return err(authError.internalError("saveScratch: upsert failed", e))
230
+ }
231
+ return ok(undefined)
232
+ }
233
+
234
+ async readScratch(key: string): Promise<Result<string>> {
235
+ let row: { value: string; expires_at: string | number } | undefined
236
+ try {
237
+ const result = await this.#exec.query<{
238
+ value: string
239
+ expires_at: string | number
240
+ }>(
241
+ `SELECT value, expires_at FROM openauth_scratch WHERE scratch_key = $1`,
242
+ [key],
243
+ )
244
+ row = result.rows[0]
245
+ } catch (e) {
246
+ return err(authError.internalError("readScratch: query failed", e))
247
+ }
248
+ if (!row) {
249
+ return err(authError.unknownState(`scratch "${key}" unknown`))
250
+ }
251
+ if (this.#clock() >= Number(row.expires_at)) {
252
+ // Lazy GC — best-effort, don't fail the read if the delete races.
253
+ try {
254
+ await this.#exec.query(
255
+ `DELETE FROM openauth_scratch WHERE scratch_key = $1`,
256
+ [key],
257
+ )
258
+ } catch {}
259
+ return err(authError.unknownState(`scratch "${key}" expired`))
260
+ }
261
+ return ok(row.value)
262
+ }
263
+
264
+ async deleteScratch(key: string): Promise<Result<void>> {
265
+ try {
266
+ await this.#exec.query(
267
+ `DELETE FROM openauth_scratch WHERE scratch_key = $1`,
268
+ [key],
269
+ )
270
+ } catch (e) {
271
+ return err(authError.internalError("deleteScratch: delete failed", e))
272
+ }
273
+ return ok(undefined)
274
+ }
206
275
  }
207
276
 
208
277
  function parseFlowRecord(raw: unknown): FlowRecord {