@_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
@@ -58,6 +58,21 @@ export type HandleCallbackInput = {
58
58
  /** The raw `Request` for the upstream provider's redirect. */
59
59
  rawRequest: Request
60
60
  cookies: ReadonlyMap<string, string>
61
+ /**
62
+ * Resolved tenant for this request. The SP-initiated path derives
63
+ * the tenant from the consumed flow and ignores this. The
64
+ * **IdP-initiated** path (unsolicited POST, no flow) has nothing to
65
+ * derive from, so it uses this — populated by the HTTP layer from
66
+ * the tenant middleware. Absent ⇒ IdP-initiated is not attempted.
67
+ */
68
+ tenant?: TenantContext
69
+ /**
70
+ * Per-request issuer URL (HTTP layer). Used only by the
71
+ * IdP-initiated path to derive the SP entityID / ACS — the same
72
+ * derivation the AuthnRequest and metadata paths use, so the values
73
+ * cannot drift.
74
+ */
75
+ issuerUrl?: string
61
76
  }
62
77
 
63
78
  export type HandleCallbackDeps = {
@@ -81,24 +96,73 @@ export type HandleCallbackDeps = {
81
96
  ) => Record<string, unknown> | Promise<Record<string, unknown>>
82
97
  }
83
98
 
99
+ /**
100
+ * The framework's MAC state envelope normally rides `?state=` on the
101
+ * upstream redirect. POST-binding callbacks carry it in the form body
102
+ * instead: OAuth `response_mode=form_post` uses `state`, SAML's
103
+ * HTTP-POST binding uses `RelayState`. Read the query first (cheap,
104
+ * the common case) and fall back to a **cloned** body read so the
105
+ * downstream method handler still gets an unconsumed request body
106
+ * (it needs it for `code` / `SAMLResponse`).
107
+ *
108
+ * Any body-parse failure degrades to "no state" — identical to the
109
+ * pre-existing behaviour when the query param is absent.
110
+ */
111
+ async function extractCallbackState(req: Request): Promise<string | null> {
112
+ const fromQuery = new URL(req.url).searchParams.get("state")
113
+ if (fromQuery) return fromQuery
114
+ if (req.method !== "POST") return null
115
+ const ct = req.headers.get("content-type") ?? ""
116
+ if (!ct.includes("application/x-www-form-urlencoded")) return null
117
+ try {
118
+ const body = await req.clone().text()
119
+ const form = new URLSearchParams(body)
120
+ const v = form.get("state") ?? form.get("RelayState")
121
+ return v && v.length > 0 ? v : null
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
84
127
  export async function handleCallback(
85
128
  input: HandleCallbackInput,
86
129
  deps: HandleCallbackDeps,
87
130
  ): Promise<Result<CallbackOutput, AuthError>> {
88
131
  const url = new URL(input.rawRequest.url)
89
- const state = url.searchParams.get("state")
90
- if (!state) {
91
- return err(authError.invalidRequest("missing state parameter", "state"))
92
- }
93
- const envelopeRes = await verifyStateEnvelope(state, deps.stateKeys)
94
- if (isErr(envelopeRes)) {
132
+ const state = await extractCallbackState(input.rawRequest)
133
+ // A SAML IdP-initiated POST may legitimately carry a `RelayState`
134
+ // (Okta/Entra deep-link tokens), so "RelayState present" does NOT
135
+ // imply "framework state envelope present". The envelope is only
136
+ // real if it MAC-verifies. When there is no verifiable envelope —
137
+ // none at all, or a value that fails verification — first try
138
+ // IdP-initiated (the only flowless path). Only if that is not a
139
+ // clean candidate do we emit the original error/audit, unchanged
140
+ // for every method that did not opt into `unsolicitedCallback`.
141
+ const envelopeRes = state
142
+ ? await verifyStateEnvelope(state, deps.stateKeys)
143
+ : null
144
+ if (envelopeRes === null || isErr(envelopeRes)) {
145
+ const idp = await tryIdpInitiated(input, deps, url)
146
+ if (idp) return idp
147
+ if (!state) {
148
+ return err(authError.invalidRequest("missing state parameter", "state"))
149
+ }
150
+ // State present but no valid envelope and not an IdP-init
151
+ // candidate — the original tampered/expired-envelope behaviour.
95
152
  await safeAudit(deps, {
96
153
  kind: "flow_replay_attempt",
97
154
  tenantId: null,
98
155
  flowId: "unknown",
99
156
  timestamp: deps.clock(),
100
157
  })
101
- return err(authError.invalidRequest(envelopeRes.error.description, "state"))
158
+ return err(
159
+ authError.invalidRequest(
160
+ envelopeRes && isErr(envelopeRes)
161
+ ? envelopeRes.error.description
162
+ : "invalid state",
163
+ "state",
164
+ ),
165
+ )
102
166
  }
103
167
  const envelope = envelopeRes.value
104
168
 
@@ -263,6 +327,150 @@ async function translate(
263
327
  }
264
328
  }
265
329
 
330
+ /**
331
+ * IdP-initiated (unsolicited) SAML SSO — SAML-AD7, the one carve-out
332
+ * vs. OAuth/OIDC methods. There is no AuthnRequest, no MAC state
333
+ * envelope, and no flow record, so none of `handleCallback`'s gates
334
+ * apply. Returns `null` when this is **not** a clean IdP-init candidate
335
+ * (so the caller emits the normal "missing state" error — the
336
+ * conservative default); otherwise a final `CallbackOutput`.
337
+ *
338
+ * Trust chain (no flow to lean on):
339
+ * - the assertion itself is signature/issuer/audience/conditions
340
+ * verified by the method (`consumeAssertion`, IdP-init mode) plus
341
+ * explicit assertion-ID replay dedup;
342
+ * - the RP binding is NOT attacker-influenced — it comes from the
343
+ * method's `unsolicitedBinding` (operator config), and is then
344
+ * re-validated here against the tenant's registered client +
345
+ * redirect URIs (open-redirect defence-in-depth). `RelayState` is
346
+ * never interpreted as a redirect.
347
+ */
348
+ async function tryIdpInitiated(
349
+ input: HandleCallbackInput,
350
+ deps: HandleCallbackDeps,
351
+ url: URL,
352
+ ): Promise<Result<CallbackOutput, AuthError> | null> {
353
+ if (input.rawRequest.method !== "POST") return null
354
+ if (!input.tenant || !input.issuerUrl) return null
355
+
356
+ const segments = url.pathname.split("/").filter(Boolean)
357
+ if (segments.length < 2 || segments[0] !== "cb") return null
358
+ const methodId = segments[1]!
359
+
360
+ const methodRes = await deps.methodCache.resolve(
361
+ input.tenant.config,
362
+ methodId,
363
+ )
364
+ if (isErr(methodRes)) return null
365
+ const method = methodRes.value
366
+ if (method.unsolicitedCallback !== true) return null
367
+
368
+ const callbackUrl = `${url.protocol}//${url.host}/cb/${methodId}`
369
+ const dispatched = await dispatchMethod({
370
+ method,
371
+ route: "GET /callback",
372
+ tenant: input.tenant,
373
+ request: input.rawRequest,
374
+ subPath: "/callback",
375
+ flow: null,
376
+ cookies: input.cookies,
377
+ sessionStore: deps.sessionStore,
378
+ dispatch: { state: "", callbackUrl, issuerUrl: input.issuerUrl },
379
+ })
380
+ if (isErr(dispatched)) return err(dispatched.error)
381
+
382
+ const result = dispatched.value
383
+ if (result.kind === "challenge") {
384
+ return ok({
385
+ kind: "challenge",
386
+ response: result.response,
387
+ setCookies: result.setCookies ?? [],
388
+ ...(result.cache !== undefined ? { cache: result.cache } : {}),
389
+ })
390
+ }
391
+ if (result.kind === "denied") {
392
+ await safeAudit(deps, {
393
+ kind: "authorize_failed",
394
+ tenantId: input.tenant.id,
395
+ clientId: "idp-initiated",
396
+ methodId,
397
+ methodKind: method.kind,
398
+ flowId: "idp-initiated",
399
+ reason: result.reason,
400
+ timestamp: deps.clock(),
401
+ })
402
+ return ok({
403
+ kind: "denied",
404
+ reason: result.reason,
405
+ setCookies: result.setCookies ?? [],
406
+ })
407
+ }
408
+ if (result.kind === "error") return err(result.error)
409
+
410
+ // success — must carry the operator-configured RP binding.
411
+ const binding = result.unsolicitedBinding
412
+ if (!binding) {
413
+ return err(
414
+ authError.internalError(
415
+ `method "${methodId}" handled an unsolicited callback but returned ` +
416
+ `no unsolicitedBinding`,
417
+ ),
418
+ )
419
+ }
420
+ // Open-redirect defence-in-depth: the binding is operator config, but
421
+ // still validate it against the tenant's registered client exactly
422
+ // as /authorize validates an RP's redirect_uri.
423
+ const client = input.tenant.config.clients.find(
424
+ (c) => c.id === binding.clientId,
425
+ )
426
+ if (!client) {
427
+ return err(
428
+ authError.invalidRequest(
429
+ `IdP-initiated binding references unknown client "${binding.clientId}"`,
430
+ ),
431
+ )
432
+ }
433
+ if (!client.redirectUris.includes(binding.redirectUri)) {
434
+ return err(
435
+ authError.invalidRequest(
436
+ `IdP-initiated redirect_uri "${binding.redirectUri}" is not ` +
437
+ `registered for client "${binding.clientId}"`,
438
+ ),
439
+ )
440
+ }
441
+
442
+ const code = (deps.newCodeId ?? randomToken)()
443
+ const now = deps.clock()
444
+ const saved = await saveEncryptedCode(
445
+ code,
446
+ {
447
+ tenantId: input.tenant.id,
448
+ clientId: binding.clientId,
449
+ appRedirectUri: binding.redirectUri,
450
+ // No RP-supplied OAuth state in an unsolicited flow; RelayState
451
+ // is opaque and intentionally NOT echoed as `state`.
452
+ appState: null,
453
+ scopes: binding.scopes,
454
+ methodId,
455
+ methodKind: method.kind,
456
+ context: input.tenant.request.custom ?? null,
457
+ providerSubject: result.providerSubject,
458
+ properties: result.properties,
459
+ authTime: Math.floor(now / 1000),
460
+ expiresAt: now + AUTH_CODE_TTL_MS,
461
+ },
462
+ AUTH_CODE_TTL_MS,
463
+ { keyStore: deps.keyStore, tokenStore: deps.tokenStore },
464
+ )
465
+ if (isErr(saved)) return err(saved.error)
466
+ return ok({
467
+ kind: "issue-code",
468
+ code,
469
+ appRedirectUri: binding.redirectUri,
470
+ appState: null,
471
+ })
472
+ }
473
+
266
474
  function normalizePath(p: string): string {
267
475
  return p.replace(/\/+$/, "")
268
476
  }
@@ -16,10 +16,11 @@ import type {
16
16
  MethodContext,
17
17
  MethodDispatchData,
18
18
  MethodResult,
19
+ MethodScratch,
19
20
  } from "../types/method"
20
21
  import type { Result } from "../types/result"
21
22
  import { err, isErr, ok } from "../types/result"
22
- import type { TenantContext } from "../types/tenant"
23
+ import type { TenantContext, TenantId } from "../types/tenant"
23
24
 
24
25
  export type RouteKey = `${"GET" | "POST"} ${string}`
25
26
 
@@ -62,6 +63,11 @@ export async function dispatchMethod(
62
63
  methodState: input.flow?.methodState ?? null,
63
64
  cookies: input.cookies,
64
65
  dispatch: input.dispatch,
66
+ methodScratch: buildMethodScratch(
67
+ input.sessionStore,
68
+ input.tenant.id,
69
+ input.method.id,
70
+ ),
65
71
  }
66
72
 
67
73
  let result: MethodResult<unknown, unknown>
@@ -93,3 +99,49 @@ export async function dispatchMethod(
93
99
 
94
100
  return ok(result)
95
101
  }
102
+
103
+ /**
104
+ * Construct the scoped `MethodScratch` handed to a method handler. Keys
105
+ * are namespaced `scratch:<tenantId>:<methodId>:<userKey>` so a method
106
+ * instance can never read or clobber another instance's data — even
107
+ * across tenants on a shared store.
108
+ *
109
+ * When the underlying `SessionStore` doesn't implement
110
+ * `saveScratch` / `readScratch` / `deleteScratch`, every operation
111
+ * returns `unsupportedAdapter` so methods can surface a clean error
112
+ * rather than silently no-op.
113
+ */
114
+ function buildMethodScratch(
115
+ store: SessionStore,
116
+ tenantId: TenantId,
117
+ methodId: string,
118
+ ): MethodScratch {
119
+ const prefix = `scratch:${tenantId}:${methodId}:`
120
+ const scope = (key: string): string => `${prefix}${key}`
121
+ const unsupported = (op: string): AuthError =>
122
+ authError.internalError(
123
+ `SessionStore does not implement ${op}; methodScratch is unavailable on this adapter`,
124
+ )
125
+
126
+ return {
127
+ put: async (key, value, ttlMs) => {
128
+ if (!store.saveScratch) return err(unsupported("saveScratch"))
129
+ if (ttlMs <= 0) {
130
+ return err(
131
+ authError.internalError(
132
+ `methodScratch.put: ttlMs must be positive, got ${ttlMs}`,
133
+ ),
134
+ )
135
+ }
136
+ return store.saveScratch(scope(key), value, ttlMs)
137
+ },
138
+ get: async (key) => {
139
+ if (!store.readScratch) return err(unsupported("readScratch"))
140
+ return store.readScratch(scope(key))
141
+ },
142
+ delete: async (key) => {
143
+ if (!store.deleteScratch) return err(unsupported("deleteScratch"))
144
+ return store.deleteScratch(scope(key))
145
+ },
146
+ }
147
+ }
@@ -32,7 +32,14 @@ import type { SessionStore } from "../ports/session-store"
32
32
  import type { TokenStore } from "../ports/token-store"
33
33
  import { authError, type AuthError } from "../types/error"
34
34
  import type { FlowRecord } from "../types/flow"
35
- import type { MethodResult, CachePolicy, SetCookie } from "../types/method"
35
+ import type { LogoutEventInput, LogoutHookResult } from "../types/idp"
36
+ import type {
37
+ AuthMethod,
38
+ CachePolicy,
39
+ MethodDispatchData,
40
+ MethodResult,
41
+ SetCookie,
42
+ } from "../types/method"
36
43
  import type { Result } from "../types/result"
37
44
  import { err, isErr, ok } from "../types/result"
38
45
  import type { TenantContext } from "../types/tenant"
@@ -42,6 +49,7 @@ import { AUTH_CODE_TTL_MS } from "./authorize"
42
49
  import { randomToken } from "./crypto"
43
50
  import { MethodCache } from "./method-cache"
44
51
  import { dispatchMethod, type RouteKey } from "./method-dispatch"
52
+ import { revokeAllForSubject } from "./revoke"
45
53
  import { saveEncryptedCode } from "./token"
46
54
 
47
55
  export type MethodRouteOutput =
@@ -211,3 +219,184 @@ async function translate(
211
219
  return err(result.error)
212
220
  }
213
221
  }
222
+
223
+ /**
224
+ * Anonymous public method route — the fourth, deliberately narrow,
225
+ * pipeline. Used only for route keys a method explicitly lists in
226
+ * `AuthMethod.publicRoutes` (today: SAML SP `GET /metadata`). No flow
227
+ * cookie, no flow record, no `SessionStore` read: the handler is a pure
228
+ * function of `tenant` + `dispatch` + captured config.
229
+ *
230
+ * Security: this re-checks `publicRoutes` membership against the
231
+ * resolved method. The HTTP layer also checks before routing here, but
232
+ * a domain function guarding a no-auth path must not trust its caller —
233
+ * the gate is enforced here too, fail-closed.
234
+ *
235
+ * Only `challenge` (the metadata document) and `denied` are sensible
236
+ * outcomes; `success` from a flowless route is a programming error
237
+ * (there is no flow to consume into an auth code) and surfaces as an
238
+ * internal error rather than silently authenticating.
239
+ */
240
+ export type HandlePublicMethodRouteInput = {
241
+ rawRequest: Request
242
+ tenant: TenantContext
243
+ /** Pre-resolved by the HTTP layer (it needed it to detect the public route). */
244
+ method: AuthMethod
245
+ route: RouteKey
246
+ subPath: string
247
+ /** Issuer/callback context so the handler can derive stable URLs. */
248
+ dispatch: MethodDispatchData
249
+ }
250
+
251
+ export type HandlePublicMethodRouteDeps = {
252
+ sessionStore: SessionStore
253
+ /**
254
+ * Required for the upstream-logout path: when a public route returns a
255
+ * `challenge` carrying `logout`, the framework runs
256
+ * `revokeAllForSubject` for the host-named subject. Anonymous read-only
257
+ * public routes (`/metadata`-style) never touch this.
258
+ */
259
+ tokenStore: TokenStore
260
+ clock: () => number
261
+ auditLog?: AuditLog
262
+ /**
263
+ * See `IdPOptions.onLogout`. Absent ⇒ the framework still verifies +
264
+ * acknowledges the logout and audits it, but revokes nothing (it
265
+ * cannot resolve the upstream id to a subject without the host).
266
+ */
267
+ onLogout?: (
268
+ input: LogoutEventInput,
269
+ ) => Promise<LogoutHookResult> | LogoutHookResult
270
+ }
271
+
272
+ export type PublicMethodRouteOutput =
273
+ | { kind: "challenge"; response: Response; cache?: CachePolicy }
274
+ | { kind: "denied"; reason: string }
275
+
276
+ export async function handlePublicMethodRoute(
277
+ input: HandlePublicMethodRouteInput,
278
+ deps: HandlePublicMethodRouteDeps,
279
+ ): Promise<Result<PublicMethodRouteOutput, AuthError>> {
280
+ if (!input.method.publicRoutes?.includes(input.route)) {
281
+ return err(
282
+ authError.invalidRequest(
283
+ `route "${input.route}" is not public on method "${input.method.id}"`,
284
+ "path",
285
+ ),
286
+ )
287
+ }
288
+
289
+ const dispatched = await dispatchMethod({
290
+ method: input.method,
291
+ route: input.route,
292
+ tenant: input.tenant,
293
+ request: input.rawRequest,
294
+ subPath: input.subPath,
295
+ flow: null,
296
+ cookies: new Map(),
297
+ sessionStore: deps.sessionStore,
298
+ dispatch: input.dispatch,
299
+ })
300
+ if (isErr(dispatched)) return err(dispatched.error)
301
+
302
+ const r = dispatched.value
303
+ switch (r.kind) {
304
+ case "challenge": {
305
+ // A verified upstream logout (SAML front-channel SLS): the method
306
+ // proved authenticity and built the `LogoutResponse`; the
307
+ // privileged side effect (host teardown + token revocation) runs
308
+ // here, where the ports live. Fail closed — if the side effect
309
+ // errors we do NOT hand back a `LogoutResponse` that would tell
310
+ // the IdP the user is logged out when they may not be.
311
+ if (r.logout) {
312
+ const sideEffect = await runUpstreamLogout(input, deps, r.logout)
313
+ if (isErr(sideEffect)) return err(sideEffect.error)
314
+ }
315
+ return ok({
316
+ kind: "challenge",
317
+ response: r.response,
318
+ ...(r.cache !== undefined ? { cache: r.cache } : {}),
319
+ })
320
+ }
321
+ case "denied":
322
+ return ok({ kind: "denied", reason: r.reason })
323
+ case "success":
324
+ return err(
325
+ authError.internalError(
326
+ `public route "${input.route}" on method "${input.method.id}" ` +
327
+ `returned success — a flowless route cannot authenticate`,
328
+ ),
329
+ )
330
+ case "error":
331
+ return err(r.error)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Run the privileged side effect for a verified upstream logout: fire
337
+ * the host's `onLogout` hook, then — if the host named a subject —
338
+ * revoke that subject's library-issued tokens via the same
339
+ * `revokeAllForSubject` primitive `/end_session` uses. Always emits a
340
+ * `session_logout` audit event (`via: "upstream_slo"`).
341
+ *
342
+ * The library cannot map the upstream `nameId` to an OIDC subject
343
+ * itself (that mapping lives in the host's `success` callback), so
344
+ * revocation is host-directed by design. A throwing hook or a failed
345
+ * revoke fails closed (`internal_error`) — the caller then withholds
346
+ * the `LogoutResponse`.
347
+ */
348
+ async function runUpstreamLogout(
349
+ input: HandlePublicMethodRouteInput,
350
+ deps: HandlePublicMethodRouteDeps,
351
+ logout: { nameId?: string; sessionIndex?: string },
352
+ ): Promise<Result<void, AuthError>> {
353
+ let hookResult: LogoutHookResult = undefined
354
+ if (deps.onLogout) {
355
+ const evt: LogoutEventInput = {
356
+ tenant: input.tenant,
357
+ methodId: input.method.id,
358
+ methodKind: input.method.kind,
359
+ reason: "upstream_slo",
360
+ ...(logout.nameId !== undefined ? { nameId: logout.nameId } : {}),
361
+ ...(logout.sessionIndex !== undefined
362
+ ? { sessionIndex: logout.sessionIndex }
363
+ : {}),
364
+ }
365
+ try {
366
+ hookResult = await deps.onLogout(evt)
367
+ } catch (e) {
368
+ return err(
369
+ authError.internalError(
370
+ `onLogout hook threw during upstream Single Logout for method ` +
371
+ `"${input.method.id}"`,
372
+ e,
373
+ ),
374
+ )
375
+ }
376
+ }
377
+
378
+ const revokeSubject =
379
+ hookResult && typeof hookResult === "object"
380
+ ? hookResult.revokeSubject
381
+ : undefined
382
+
383
+ if (revokeSubject) {
384
+ const revoked = await revokeAllForSubject(input.tenant.id, revokeSubject, {
385
+ tokenStore: deps.tokenStore,
386
+ ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
387
+ clock: deps.clock,
388
+ })
389
+ if (isErr(revoked)) return err(revoked.error)
390
+ }
391
+
392
+ await safeAudit(deps, {
393
+ kind: "session_logout",
394
+ tenantId: input.tenant.id,
395
+ via: "upstream_slo",
396
+ methodId: input.method.id,
397
+ methodKind: input.method.kind,
398
+ ...(revokeSubject ? { subjectId: revokeSubject } : {}),
399
+ timestamp: deps.clock(),
400
+ })
401
+ return ok(undefined)
402
+ }
@@ -17,6 +17,8 @@ import type { MethodCache } from "../domain/method-cache"
17
17
  import type { AuthError } from "../types/error"
18
18
  import type {
19
19
  ExchangeAudience,
20
+ LogoutEventInput,
21
+ LogoutHookResult,
20
22
  PersistUpstreamTokens,
21
23
  RegisterClient,
22
24
  RenderPicker,
@@ -49,6 +51,10 @@ export type HttpDeps = {
49
51
  callbackHostFor?: (tenantId: TenantId) => string
50
52
  resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
51
53
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
54
+ /** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
55
+ onLogout?: (
56
+ input: LogoutEventInput,
57
+ ) => Promise<LogoutHookResult> | LogoutHookResult
52
58
  persistUpstreamTokens?: PersistUpstreamTokens
53
59
  exchangeAudience?: ExchangeAudience
54
60
  renderPicker?: RenderPicker
@@ -7,7 +7,7 @@
7
7
  * strip method-returned `Set-Cookie` / security / `Cache-Control` headers so
8
8
  * methods cannot bypass policy.
9
9
  */
10
- import type { SetCookie } from "../types/method"
10
+ import type { CachePolicy, SetCookie } from "../types/method"
11
11
 
12
12
  /** Parse an incoming `Cookie:` header into a read-only map. */
13
13
  export function parseCookieHeader(header: string | null): Map<string, string> {
@@ -117,6 +117,24 @@ const STRIPPED_HEADERS = [
117
117
  "cache-control",
118
118
  ] as const
119
119
 
120
+ /**
121
+ * Serialize a `MethodResult.challenge`'s `CachePolicy` into a
122
+ * `Cache-Control` header value. The framework owns this header (methods
123
+ * cannot set it directly — see `STRIPPED_HEADERS`); a method opts into
124
+ * caching via the typed `cache` field and this renders it. Absent /
125
+ * `maxAge: 0` ⇒ `no-store` (the safe default for auth UI).
126
+ */
127
+ export function cacheControlHeader(cache: CachePolicy | undefined): string {
128
+ if (!cache) return "no-store"
129
+ if ((cache.maxAge ?? -1) === 0) return "no-store"
130
+ const parts: string[] = []
131
+ if (cache.isPrivate) parts.push("private")
132
+ if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
133
+ if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
134
+ if (cache.immutable) parts.push("immutable")
135
+ return parts.length ? parts.join(", ") : "no-store"
136
+ }
137
+
120
138
  export type ApplyOptions = {
121
139
  setCookies?: SetCookie[]
122
140
  cookieDefaults?: CookieDefaults
@@ -24,7 +24,7 @@ import type {
24
24
  import type { PickerContext, PickerMethod } from "../../types/picker"
25
25
  import { renderPicker as renderDefaultPicker } from "../../ui/picker"
26
26
 
27
- import { applyResponsePolicy } from "../cookies"
27
+ import { applyResponsePolicy, cacheControlHeader } from "../cookies"
28
28
  import type { HttpContext, HttpDeps } from "../context"
29
29
  import {
30
30
  authorizeDirectErrorResponse,
@@ -209,7 +209,7 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
209
209
  return applyResponsePolicy(out.response, {
210
210
  setCookies: [flowCookie, ...out.setCookies],
211
211
  cookieDefaults: deps.cookieDefaults,
212
- cacheControl: cacheControlFor(out.cache),
212
+ cacheControl: cacheControlHeader(out.cache),
213
213
  })
214
214
  }
215
215
  case "issue-code": {
@@ -341,22 +341,3 @@ function clearFlowCookie() {
341
341
  }
342
342
  }
343
343
 
344
- function cacheControlFor(
345
- cache:
346
- | {
347
- maxAge?: number
348
- sMaxAge?: number
349
- isPrivate?: boolean
350
- immutable?: boolean
351
- }
352
- | undefined,
353
- ): string {
354
- if (!cache) return "no-store"
355
- if ((cache.maxAge ?? -1) === 0) return "no-store"
356
- const parts: string[] = []
357
- if (cache.isPrivate) parts.push("private")
358
- if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
359
- if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
360
- if (cache.immutable) parts.push("immutable")
361
- return parts.length ? parts.join(", ") : "no-store"
362
- }
@@ -16,8 +16,16 @@ import { authorizeDirectErrorResponse } from "../errors"
16
16
 
17
17
  export function makeCallbackHandler(deps: HttpDeps) {
18
18
  return async (c: HttpContext): Promise<Response> => {
19
+ const tenant = c.get("tenant")
19
20
  const result = await handleCallback(
20
- { rawRequest: c.req.raw, cookies: c.get("cookies") },
21
+ {
22
+ rawRequest: c.req.raw,
23
+ cookies: c.get("cookies"),
24
+ // Only the IdP-initiated (no-state) path reads these; the
25
+ // SP-initiated path derives tenant from the consumed flow.
26
+ ...(tenant ? { tenant } : {}),
27
+ issuerUrl: c.get("issuerUrl"),
28
+ },
21
29
  {
22
30
  configStore: deps.configStore,
23
31
  sessionStore: deps.sessionStore,