@_mustachio/openauth 0.10.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 (277) 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 +45 -0
  13. package/dist/esm/adapters/memory/token-store.js +13 -0
  14. package/dist/esm/adapters/postgres/executor.js +2 -2
  15. package/dist/esm/adapters/postgres/index.js +33 -10
  16. package/dist/esm/adapters/postgres/migrations.js +8 -2
  17. package/dist/esm/adapters/postgres/session-store.js +42 -0
  18. package/dist/esm/client.js +35 -28
  19. package/dist/esm/domain/authorize.js +7 -2
  20. package/dist/esm/domain/callback.js +124 -7
  21. package/dist/esm/domain/client-auth.js +2 -2
  22. package/dist/esm/domain/crypto.js +14 -14
  23. package/dist/esm/domain/discovery.js +46 -4
  24. package/dist/esm/domain/dpop.js +122 -0
  25. package/dist/esm/domain/id-token.js +94 -0
  26. package/dist/esm/domain/introspect.js +5 -1
  27. package/dist/esm/domain/jwt.js +30 -2
  28. package/dist/esm/domain/logout.js +75 -0
  29. package/dist/esm/domain/method-dispatch.js +27 -1
  30. package/dist/esm/domain/method-route.js +83 -1
  31. package/dist/esm/domain/par.js +51 -0
  32. package/dist/esm/domain/pkce.js +2 -2
  33. package/dist/esm/domain/refresh.js +12 -1
  34. package/dist/esm/domain/register.js +74 -0
  35. package/dist/esm/domain/revoke.js +2 -2
  36. package/dist/esm/domain/state-envelope.js +2 -2
  37. package/dist/esm/domain/token-exchange.js +1 -0
  38. package/dist/esm/domain/token.js +53 -12
  39. package/dist/esm/domain/userinfo.js +38 -3
  40. package/dist/esm/error.js +8 -8
  41. package/dist/esm/http/cookies.js +19 -2
  42. package/dist/esm/http/errors.js +4 -4
  43. package/dist/esm/http/handlers/authorize.js +92 -21
  44. package/dist/esm/http/handlers/callback.js +7 -1
  45. package/dist/esm/http/handlers/end-session.js +64 -0
  46. package/dist/esm/http/handlers/metadata.js +7 -4
  47. package/dist/esm/http/handlers/method-route.js +47 -2
  48. package/dist/esm/http/handlers/par.js +53 -0
  49. package/dist/esm/http/handlers/register.js +46 -0
  50. package/dist/esm/http/handlers/revocation.js +2 -2
  51. package/dist/esm/http/handlers/token.js +36 -6
  52. package/dist/esm/http/handlers/userinfo.js +35 -29
  53. package/dist/esm/http/middleware/tenant.js +6 -6
  54. package/dist/esm/http/router.js +10 -0
  55. package/dist/esm/http/schemas/authorize.js +8 -2
  56. package/dist/esm/http/schemas/end-session.js +13 -0
  57. package/dist/esm/http/schemas/par.js +9 -0
  58. package/dist/esm/http/schemas/register.js +16 -0
  59. package/dist/esm/http/schemas/revocation.js +2 -2
  60. package/dist/esm/http/schemas/token.js +7 -7
  61. package/dist/esm/index.js +40 -34
  62. package/dist/esm/methods/oauth2-factory.js +2 -2
  63. package/dist/esm/methods/password-hash.js +2 -2
  64. package/dist/esm/methods/providers/index.js +30 -14
  65. package/dist/esm/methods/saml-sp/acs.js +208 -0
  66. package/dist/esm/methods/saml-sp/attributes.js +75 -0
  67. package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
  68. package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
  69. package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
  70. package/dist/esm/methods/saml-sp/factory.js +70 -0
  71. package/dist/esm/methods/saml-sp/index.js +7 -0
  72. package/dist/esm/methods/saml-sp/metadata.js +69 -0
  73. package/dist/esm/methods/saml-sp/method.js +29 -0
  74. package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
  75. package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
  76. package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
  77. package/dist/esm/methods/saml-sp/sls.js +154 -0
  78. package/dist/esm/methods/saml-sp/types.js +0 -0
  79. package/dist/esm/types/error.js +5 -0
  80. package/dist/esm/types/result.js +3 -3
  81. package/dist/esm/ui/forms.js +3 -3
  82. package/dist/esm/ui/index.js +8 -5
  83. package/dist/esm/ui/picker.js +2 -2
  84. package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
  85. package/dist/types/adapters/d1/session-store.d.ts +3 -0
  86. package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
  87. package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
  88. package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
  89. package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
  90. package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
  91. package/dist/types/adapters/memory/session-store.d.ts +6 -1
  92. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  93. package/dist/types/adapters/memory/token-store.d.ts +1 -0
  94. package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
  95. package/dist/types/adapters/postgres/migrations.d.ts +1 -1
  96. package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
  97. package/dist/types/adapters/postgres/session-store.d.ts +3 -0
  98. package/dist/types/adapters/postgres/session-store.d.ts.map +1 -1
  99. package/dist/types/client.d.ts +19 -0
  100. package/dist/types/client.d.ts.map +1 -1
  101. package/dist/types/domain/authorize.d.ts.map +1 -1
  102. package/dist/types/domain/callback.d.ts +16 -1
  103. package/dist/types/domain/callback.d.ts.map +1 -1
  104. package/dist/types/domain/client-credentials.d.ts +2 -0
  105. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  106. package/dist/types/domain/discovery.d.ts +37 -0
  107. package/dist/types/domain/discovery.d.ts.map +1 -1
  108. package/dist/types/domain/dpop.d.ts +61 -0
  109. package/dist/types/domain/dpop.d.ts.map +1 -0
  110. package/dist/types/domain/id-token.d.ts +83 -0
  111. package/dist/types/domain/id-token.d.ts.map +1 -0
  112. package/dist/types/domain/introspect.d.ts +8 -0
  113. package/dist/types/domain/introspect.d.ts.map +1 -1
  114. package/dist/types/domain/jwt.d.ts +27 -1
  115. package/dist/types/domain/jwt.d.ts.map +1 -1
  116. package/dist/types/domain/logout.d.ts +69 -0
  117. package/dist/types/domain/logout.d.ts.map +1 -0
  118. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  119. package/dist/types/domain/method-route.d.ts +57 -1
  120. package/dist/types/domain/method-route.d.ts.map +1 -1
  121. package/dist/types/domain/par.d.ts +62 -0
  122. package/dist/types/domain/par.d.ts.map +1 -0
  123. package/dist/types/domain/refresh.d.ts +9 -0
  124. package/dist/types/domain/refresh.d.ts.map +1 -1
  125. package/dist/types/domain/register.d.ts +33 -0
  126. package/dist/types/domain/register.d.ts.map +1 -0
  127. package/dist/types/domain/token-exchange.d.ts +2 -0
  128. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  129. package/dist/types/domain/token.d.ts +41 -1
  130. package/dist/types/domain/token.d.ts.map +1 -1
  131. package/dist/types/domain/userinfo.d.ts +44 -5
  132. package/dist/types/domain/userinfo.d.ts.map +1 -1
  133. package/dist/types/http/context.d.ts +6 -1
  134. package/dist/types/http/context.d.ts.map +1 -1
  135. package/dist/types/http/cookies.d.ts +9 -1
  136. package/dist/types/http/cookies.d.ts.map +1 -1
  137. package/dist/types/http/handlers/authorize.d.ts.map +1 -1
  138. package/dist/types/http/handlers/callback.d.ts.map +1 -1
  139. package/dist/types/http/handlers/end-session.d.ts +3 -0
  140. package/dist/types/http/handlers/end-session.d.ts.map +1 -0
  141. package/dist/types/http/handlers/metadata.d.ts +1 -1
  142. package/dist/types/http/handlers/metadata.d.ts.map +1 -1
  143. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  144. package/dist/types/http/handlers/par.d.ts +3 -0
  145. package/dist/types/http/handlers/par.d.ts.map +1 -0
  146. package/dist/types/http/handlers/register.d.ts +3 -0
  147. package/dist/types/http/handlers/register.d.ts.map +1 -0
  148. package/dist/types/http/handlers/token.d.ts.map +1 -1
  149. package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
  150. package/dist/types/http/router.d.ts.map +1 -1
  151. package/dist/types/http/schemas/authorize.d.ts +40 -0
  152. package/dist/types/http/schemas/authorize.d.ts.map +1 -1
  153. package/dist/types/http/schemas/end-session.d.ts +31 -0
  154. package/dist/types/http/schemas/end-session.d.ts.map +1 -0
  155. package/dist/types/http/schemas/par.d.ts +20 -0
  156. package/dist/types/http/schemas/par.d.ts.map +1 -0
  157. package/dist/types/http/schemas/register.d.ts +42 -0
  158. package/dist/types/http/schemas/register.d.ts.map +1 -0
  159. package/dist/types/index.d.ts +3 -3
  160. package/dist/types/index.d.ts.map +1 -1
  161. package/dist/types/methods/saml-sp/acs.d.ts +4 -0
  162. package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
  163. package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
  164. package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
  165. package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
  166. package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
  167. package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
  168. package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
  169. package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
  170. package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
  171. package/dist/types/methods/saml-sp/factory.d.ts +704 -0
  172. package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
  173. package/dist/types/methods/saml-sp/index.d.ts +24 -0
  174. package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
  175. package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
  176. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
  177. package/dist/types/methods/saml-sp/method.d.ts +17 -0
  178. package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
  179. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
  180. package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
  181. package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
  182. package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
  183. package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
  184. package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
  185. package/dist/types/methods/saml-sp/sls.d.ts +4 -0
  186. package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
  187. package/dist/types/methods/saml-sp/types.d.ts +204 -0
  188. package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
  189. package/dist/types/ports/audit-log.d.ts +40 -0
  190. package/dist/types/ports/audit-log.d.ts.map +1 -1
  191. package/dist/types/ports/session-store.d.ts +50 -0
  192. package/dist/types/ports/session-store.d.ts.map +1 -1
  193. package/dist/types/ports/token-store.d.ts +12 -0
  194. package/dist/types/ports/token-store.d.ts.map +1 -1
  195. package/dist/types/types/authorization.d.ts +23 -0
  196. package/dist/types/types/authorization.d.ts.map +1 -1
  197. package/dist/types/types/error.d.ts +9 -0
  198. package/dist/types/types/error.d.ts.map +1 -1
  199. package/dist/types/types/flow.d.ts +10 -0
  200. package/dist/types/types/flow.d.ts.map +1 -1
  201. package/dist/types/types/idp.d.ts +162 -1
  202. package/dist/types/types/idp.d.ts.map +1 -1
  203. package/dist/types/types/method.d.ts +109 -0
  204. package/dist/types/types/method.d.ts.map +1 -1
  205. package/dist/types/types/tenant.d.ts +37 -0
  206. package/dist/types/types/tenant.d.ts.map +1 -1
  207. package/dist/types/types/token.d.ts +128 -0
  208. package/dist/types/types/token.d.ts.map +1 -1
  209. package/package.json +9 -1
  210. package/src/adapters/d1/migrations.ts +5 -0
  211. package/src/adapters/d1/session-store.ts +68 -0
  212. package/src/adapters/durable-object/session-store.ts +61 -0
  213. package/src/adapters/dynamo/session-store.ts +63 -0
  214. package/src/adapters/memory/session-store.ts +83 -1
  215. package/src/adapters/memory/token-store.ts +16 -0
  216. package/src/adapters/postgres/migrations.ts +6 -0
  217. package/src/adapters/postgres/session-store.ts +69 -0
  218. package/src/client.ts +32 -0
  219. package/src/domain/authorize.ts +9 -0
  220. package/src/domain/callback.ts +220 -7
  221. package/src/domain/client-credentials.ts +2 -0
  222. package/src/domain/discovery.ts +80 -2
  223. package/src/domain/dpop.ts +293 -0
  224. package/src/domain/id-token.ts +213 -0
  225. package/src/domain/introspect.ts +10 -0
  226. package/src/domain/jwt.ts +80 -1
  227. package/src/domain/logout.ts +180 -0
  228. package/src/domain/method-dispatch.ts +53 -1
  229. package/src/domain/method-route.ts +195 -1
  230. package/src/domain/par.ts +141 -0
  231. package/src/domain/refresh.ts +41 -0
  232. package/src/domain/register.ts +168 -0
  233. package/src/domain/token-exchange.ts +11 -0
  234. package/src/domain/token.ts +134 -8
  235. package/src/domain/userinfo.ts +121 -6
  236. package/src/http/context.ts +10 -0
  237. package/src/http/cookies.ts +19 -1
  238. package/src/http/handlers/authorize.ts +146 -24
  239. package/src/http/handlers/callback.ts +9 -1
  240. package/src/http/handlers/end-session.ts +106 -0
  241. package/src/http/handlers/metadata.ts +7 -2
  242. package/src/http/handlers/method-route.ts +75 -2
  243. package/src/http/handlers/par.ts +84 -0
  244. package/src/http/handlers/register.ts +70 -0
  245. package/src/http/handlers/token.ts +56 -0
  246. package/src/http/handlers/userinfo.ts +62 -38
  247. package/src/http/router.ts +19 -0
  248. package/src/http/schemas/authorize.ts +27 -0
  249. package/src/http/schemas/end-session.ts +23 -0
  250. package/src/http/schemas/par.ts +23 -0
  251. package/src/http/schemas/register.ts +29 -0
  252. package/src/index.ts +21 -1
  253. package/src/methods/saml-sp/acs.ts +399 -0
  254. package/src/methods/saml-sp/attributes.ts +137 -0
  255. package/src/methods/saml-sp/authnrequest.ts +128 -0
  256. package/src/methods/saml-sp/cache-provider.ts +76 -0
  257. package/src/methods/saml-sp/cert-rotation.ts +34 -0
  258. package/src/methods/saml-sp/factory.ts +118 -0
  259. package/src/methods/saml-sp/index.ts +34 -0
  260. package/src/methods/saml-sp/metadata.ts +204 -0
  261. package/src/methods/saml-sp/method.ts +65 -0
  262. package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
  263. package/src/methods/saml-sp/saml-instance.ts +173 -0
  264. package/src/methods/saml-sp/slo-initiate.ts +177 -0
  265. package/src/methods/saml-sp/sls.ts +306 -0
  266. package/src/methods/saml-sp/types.ts +203 -0
  267. package/src/ports/CONSISTENCY.md +77 -21
  268. package/src/ports/audit-log.ts +42 -0
  269. package/src/ports/session-store.ts +61 -0
  270. package/src/ports/token-store.ts +13 -0
  271. package/src/types/authorization.ts +25 -0
  272. package/src/types/error.ts +23 -0
  273. package/src/types/flow.ts +10 -0
  274. package/src/types/idp.ts +170 -0
  275. package/src/types/method.ts +110 -0
  276. package/src/types/tenant.ts +37 -0
  277. package/src/types/token.ts +135 -0
package/src/client.ts CHANGED
@@ -91,6 +91,14 @@ export interface Tokens {
91
91
  * The number of seconds until the access token expires.
92
92
  */
93
93
  expiresIn: number
94
+
95
+ /**
96
+ * The OIDC `id_token` (signed JWT), present when `scope=openid` was granted.
97
+ *
98
+ * Refresh-grant rotation reissues this with a stable `auth_time` per
99
+ * OIDC Core §12 and deliberately omits the original `nonce`.
100
+ */
101
+ idToken?: string
94
102
  }
95
103
 
96
104
  interface ResponseLike {
@@ -178,6 +186,18 @@ export interface AuthorizeOptions {
178
186
  * If there's only one provider configured, the user will be redirected to that.
179
187
  */
180
188
  provider?: string
189
+ /**
190
+ * OAuth scopes to request, e.g. `["openid", "email", "profile"]`.
191
+ *
192
+ * Pass `openid` to request an `id_token` at `/token` (OIDC Core §2). Pass
193
+ * `email` / `profile` / `phone` / `address` to opt into the standard §5.4
194
+ * profile claim sets on the id_token and `/userinfo` response.
195
+ *
196
+ * Accepts an array or a pre-joined space-separated string. When omitted,
197
+ * no `scope` is sent — the IdP's default grant applies, which typically
198
+ * does NOT include `openid` and therefore does NOT issue an id_token.
199
+ */
200
+ scope?: string | string[]
181
201
  }
182
202
 
183
203
  export interface AuthorizeResult {
@@ -588,6 +608,12 @@ export function createClient(input: ClientInput): Client {
588
608
  result.searchParams.set("response_type", response)
589
609
  result.searchParams.set("state", challenge.state)
590
610
  if (opts?.provider) result.searchParams.set("provider", opts.provider)
611
+ if (opts?.scope !== undefined) {
612
+ const scope = Array.isArray(opts.scope)
613
+ ? opts.scope.join(" ")
614
+ : opts.scope
615
+ if (scope) result.searchParams.set("scope", scope)
616
+ }
591
617
  if (opts?.pkce && response === "code") {
592
618
  const pkce = await generatePKCE()
593
619
  result.searchParams.set("code_challenge_method", "S256")
@@ -648,6 +674,9 @@ export function createClient(input: ClientInput): Client {
648
674
  access: json.access_token as string,
649
675
  refresh: json.refresh_token as string,
650
676
  expiresIn: json.expires_in as number,
677
+ ...(typeof json.id_token === "string"
678
+ ? { idToken: json.id_token as string }
679
+ : {}),
651
680
  },
652
681
  }
653
682
  },
@@ -691,6 +720,9 @@ export function createClient(input: ClientInput): Client {
691
720
  access: json.access_token as string,
692
721
  refresh: json.refresh_token as string,
693
722
  expiresIn: json.expires_in as number,
723
+ ...(typeof json.id_token === "string"
724
+ ? { idToken: json.id_token as string }
725
+ : {}),
694
726
  },
695
727
  }
696
728
  },
@@ -221,6 +221,10 @@ export async function startAuthorize(
221
221
  prompt: req.prompt,
222
222
  uiLocales: req.uiLocales,
223
223
  nonce,
224
+ ...(req.nonce !== undefined ? { appNonce: req.nonce } : {}),
225
+ ...(req.claimsRequest !== undefined
226
+ ? { claimsRequest: req.claimsRequest }
227
+ : {}),
224
228
  clientPkce: req.codeChallenge
225
229
  ? { challenge: req.codeChallenge, method: "S256" }
226
230
  : undefined,
@@ -387,6 +391,11 @@ async function issueCodeFromInlineSuccess(
387
391
  context: flow.context ?? null,
388
392
  providerSubject: result.providerSubject,
389
393
  properties: result.properties,
394
+ ...(flow.appNonce !== undefined ? { appNonce: flow.appNonce } : {}),
395
+ ...(flow.claimsRequest !== undefined
396
+ ? { claimsRequest: flow.claimsRequest }
397
+ : {}),
398
+ authTime: Math.floor(now / 1000),
390
399
  expiresAt: now + AUTH_CODE_TTL_MS,
391
400
  },
392
401
  AUTH_CODE_TTL_MS,
@@ -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
 
@@ -207,6 +271,11 @@ async function translate(
207
271
  context: flow.context ?? null,
208
272
  providerSubject: result.providerSubject,
209
273
  properties: result.properties,
274
+ ...(flow.appNonce !== undefined ? { appNonce: flow.appNonce } : {}),
275
+ ...(flow.claimsRequest !== undefined
276
+ ? { claimsRequest: flow.claimsRequest }
277
+ : {}),
278
+ authTime: Math.floor(now / 1000),
210
279
  expiresAt: now + AUTH_CODE_TTL_MS,
211
280
  },
212
281
  AUTH_CODE_TTL_MS,
@@ -258,6 +327,150 @@ async function translate(
258
327
  }
259
328
  }
260
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
+
261
474
  function normalizePath(p: string): string {
262
475
  return p.replace(/\/+$/, "")
263
476
  }
@@ -54,6 +54,8 @@ export type ClientCredentialsDeps = {
54
54
  issuerUrl: string
55
55
  clock: () => number
56
56
  newRefreshFamily?: () => string
57
+ /** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
58
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
57
59
  }
58
60
 
59
61
  export async function clientCredentialsGrant(
@@ -19,11 +19,37 @@ export type DiscoveryDocument = {
19
19
  jwks_uri: string
20
20
  revocation_endpoint: string
21
21
  introspection_endpoint: string
22
+ /** OIDC RP-Initiated Logout 1.0 §2. */
23
+ end_session_endpoint: string
24
+ /** RFC 7591 §3.1. */
25
+ registration_endpoint: string
26
+ /** RFC 9126 §5. */
27
+ pushed_authorization_request_endpoint: string
28
+ /**
29
+ * RFC 9126 §5. True iff every client on this tenant is required to use
30
+ * PAR. We advertise `false` at the tenant level — the per-client toggle
31
+ * is read at request time.
32
+ */
33
+ require_pushed_authorization_requests: boolean
34
+ /** RFC 9449 §5.1 — DPoP algorithms accepted on proof JWTs. */
35
+ dpop_signing_alg_values_supported: string[]
22
36
  response_types_supported: string[]
23
37
  grant_types_supported: string[]
24
38
  subject_types_supported: string[]
25
39
  id_token_signing_alg_values_supported: string[]
26
40
  scopes_supported: string[]
41
+ /** OIDC Core §3 — registered claim names the IdP can return. */
42
+ claims_supported: string[]
43
+ /** OIDC Core §5.5 — `claims` request parameter is honored. */
44
+ claims_parameter_supported: boolean
45
+ /** OIDC Core §6.1 — JAR (`request` parameter). Not implemented. */
46
+ request_parameter_supported: boolean
47
+ /** OIDC Core §6.2 — JAR (`request_uri` parameter). Not implemented. */
48
+ request_uri_parameter_supported: boolean
49
+ /** OIDC Core §6.2 — whether `request_uri` values must be pre-registered. */
50
+ require_request_uri_registration: boolean
51
+ /** OIDC Core §3.1.2.1 — UI locales the OP can render. */
52
+ ui_locales_supported?: string[]
27
53
  token_endpoint_auth_methods_supported: string[]
28
54
  code_challenge_methods_supported: string[]
29
55
  }
@@ -38,14 +64,30 @@ export type DiscoveryDeps = {
38
64
  jwks: string
39
65
  revoke: string
40
66
  introspect: string
67
+ endSession: string
68
+ par: string
69
+ register: string
41
70
  }>
42
71
  /** Advertised scopes. Defaults to `["openid", "email", "profile"]`. */
43
72
  scopes?: string[]
73
+ /** Advertised UI locales. Defaults to `["en"]`. */
74
+ uiLocales?: string[]
75
+ /**
76
+ * Host-supplied scope → claim-names map. Each key adds a scope to
77
+ * `scopes_supported`; the union of values augments `claims_supported`.
78
+ * Mirrors `IdPOptions.customScopeClaims`.
79
+ */
80
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
44
81
  }
45
82
 
46
83
  export function buildDiscoveryDocument(deps: DiscoveryDeps): DiscoveryDocument {
47
84
  const base = deps.issuerUrl.replace(/\/+$/, "")
48
85
  const p = deps.paths ?? {}
86
+ const customScopeKeys = Object.keys(deps.customScopeClaims ?? {})
87
+ const customClaimNames = new Set<string>()
88
+ for (const list of Object.values(deps.customScopeClaims ?? {})) {
89
+ for (const name of list) customClaimNames.add(name)
90
+ }
49
91
  return {
50
92
  issuer: deps.issuerUrl,
51
93
  authorization_endpoint: `${base}${p.authorize ?? "/authorize"}`,
@@ -54,15 +96,51 @@ export function buildDiscoveryDocument(deps: DiscoveryDeps): DiscoveryDocument {
54
96
  jwks_uri: `${base}${p.jwks ?? "/.well-known/jwks.json"}`,
55
97
  revocation_endpoint: `${base}${p.revoke ?? "/revoke"}`,
56
98
  introspection_endpoint: `${base}${p.introspect ?? "/introspect"}`,
99
+ end_session_endpoint: `${base}${p.endSession ?? "/end_session"}`,
100
+ registration_endpoint: `${base}${p.register ?? "/register"}`,
101
+ pushed_authorization_request_endpoint: `${base}${p.par ?? "/par"}`,
102
+ require_pushed_authorization_requests: false,
103
+ dpop_signing_alg_values_supported: ["ES256", "EdDSA"],
57
104
  response_types_supported: ["code"],
58
105
  grant_types_supported: [
59
106
  "authorization_code",
60
107
  "refresh_token",
61
108
  "client_credentials",
62
109
  ],
63
- subject_types_supported: ["public"],
110
+ subject_types_supported: ["public", "pairwise"],
64
111
  id_token_signing_alg_values_supported: ["ES256", "EdDSA"],
65
- scopes_supported: deps.scopes ?? ["openid", "email", "profile"],
112
+ scopes_supported: [
113
+ ...(deps.scopes ?? ["openid", "email", "profile"]),
114
+ ...customScopeKeys,
115
+ ],
116
+ claims_supported: [
117
+ "sub",
118
+ "iss",
119
+ "aud",
120
+ "exp",
121
+ "iat",
122
+ "auth_time",
123
+ "nonce",
124
+ "amr",
125
+ "at_hash",
126
+ "email",
127
+ "email_verified",
128
+ "name",
129
+ "given_name",
130
+ "family_name",
131
+ "preferred_username",
132
+ "picture",
133
+ "locale",
134
+ "phone_number",
135
+ "phone_number_verified",
136
+ "address",
137
+ ...customClaimNames,
138
+ ],
139
+ claims_parameter_supported: true,
140
+ request_parameter_supported: false,
141
+ request_uri_parameter_supported: false,
142
+ require_request_uri_registration: false,
143
+ ui_locales_supported: deps.uiLocales ?? ["en"],
66
144
  token_endpoint_auth_methods_supported: [
67
145
  "client_secret_basic",
68
146
  "client_secret_post",