@_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
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `/end_session` endpoint logic — OIDC RP-Initiated Logout 1.0.
3
+ *
4
+ * The OP validates the optional `id_token_hint`, validates the optional
5
+ * `post_logout_redirect_uri` against the client's registered list,
6
+ * revokes the subject's refresh tokens (when a subject is identified),
7
+ * and either redirects the user-agent back to the RP with `state` echoed
8
+ * or returns a generic "logged out" acknowledgement.
9
+ *
10
+ * Per §2:
11
+ * - `id_token_hint` (RECOMMENDED): a previously-issued id_token. The
12
+ * OP MUST verify the signature; expiry MAY be tolerated because
13
+ * logout commonly follows token expiry. We accept expired hints but
14
+ * enforce signature + issuer.
15
+ * - `client_id` (OPTIONAL): when both this and `id_token_hint` are
16
+ * present, the OP MUST verify the client_id matches the hint's `aud`.
17
+ * - `post_logout_redirect_uri` (OPTIONAL): MUST be exact-match against
18
+ * the resolved client's `postLogoutRedirectUris`. If the URI is not
19
+ * registered, the OP MUST NOT redirect — surface a non-redirecting
20
+ * error instead, otherwise the endpoint becomes an open redirector.
21
+ * - `state` (OPTIONAL): echoed back in the redirect.
22
+ *
23
+ * Front-channel / back-channel logout (RP notification of OP-initiated
24
+ * logout) is out of scope for this minimal endpoint. Hosts that need it
25
+ * can layer their own notification fan-out on top of `session_logout`
26
+ * audit events.
27
+ */
28
+ import type { AuditLog } from "../ports/audit-log"
29
+ import type { ConfigStore } from "../ports/config-store"
30
+ import type { KeyStore } from "../ports/key-store"
31
+ import type { TokenStore } from "../ports/token-store"
32
+ import { authError, type AuthError } from "../types/error"
33
+ import type { Result } from "../types/result"
34
+ import { err, isErr, ok } from "../types/result"
35
+ import type { ClientConfig, TenantContext } from "../types/tenant"
36
+
37
+ import { safeAudit } from "./audit"
38
+ import { verifyIdToken } from "./jwt"
39
+ import { revokeAllForSubject } from "./revoke"
40
+
41
+ export type EndSessionRequest = {
42
+ idTokenHint?: string
43
+ clientId?: string
44
+ postLogoutRedirectUri?: string
45
+ state?: string
46
+ }
47
+
48
+ export type EndSessionDeps = {
49
+ configStore: ConfigStore
50
+ tokenStore: TokenStore
51
+ keyStore: KeyStore
52
+ auditLog?: AuditLog
53
+ issuerUrl: string
54
+ clock: () => number
55
+ }
56
+
57
+ export type EndSessionOutput =
58
+ /** Redirect the user-agent back to the RP. */
59
+ | { kind: "redirect"; url: string }
60
+ /**
61
+ * No `post_logout_redirect_uri` was supplied (or it wasn't registered).
62
+ * Caller should render a minimal "logged out" page or return 200.
63
+ */
64
+ | { kind: "ok"; subjectId?: string }
65
+
66
+ /**
67
+ * Process an `/end_session` request and return an outcome the HTTP
68
+ * adapter can translate into a `Response`. Pure domain — no Hono types,
69
+ * no `Response` construction here.
70
+ */
71
+ export async function endSession(
72
+ req: EndSessionRequest,
73
+ tenant: TenantContext,
74
+ deps: EndSessionDeps,
75
+ ): Promise<Result<EndSessionOutput, AuthError>> {
76
+ // 1. Verify the id_token_hint (if present). Tolerate expiry per §2.
77
+ let hintSubject: string | undefined
78
+ let hintAud: string | undefined
79
+ if (req.idTokenHint) {
80
+ const keysRes = await deps.keyStore.signingKeys()
81
+ if (isErr(keysRes)) return err(keysRes.error)
82
+ try {
83
+ const claims = await verifyIdToken(req.idTokenHint, keysRes.value, {
84
+ issuer: deps.issuerUrl,
85
+ acceptExpired: true,
86
+ })
87
+ hintSubject = claims.sub
88
+ hintAud = claims.aud
89
+ } catch (e) {
90
+ const reason = e instanceof Error ? e.message : String(e)
91
+ return err(
92
+ authError.invalidRequest(
93
+ `id_token_hint failed signature/issuer verification: ${reason}`,
94
+ "id_token_hint",
95
+ ),
96
+ )
97
+ }
98
+ }
99
+
100
+ // 2. If both client_id and id_token_hint are present, they MUST agree
101
+ // (§2). Disagreement is `invalid_request` — never a redirect.
102
+ if (req.clientId && hintAud && req.clientId !== hintAud) {
103
+ return err(
104
+ authError.invalidRequest(
105
+ "client_id does not match id_token_hint.aud",
106
+ "client_id",
107
+ ),
108
+ )
109
+ }
110
+
111
+ // 3. Resolve the client for redirect-URI validation. Prefer explicit
112
+ // client_id, fall back to the hint's aud.
113
+ const resolvedClientId = req.clientId ?? hintAud
114
+ let client: ClientConfig | undefined
115
+ if (resolvedClientId) {
116
+ client = tenant.config.clients.find((c) => c.id === resolvedClientId)
117
+ if (!client) {
118
+ return err(
119
+ authError.invalidRequest(
120
+ `unknown client "${resolvedClientId}"`,
121
+ "client_id",
122
+ ),
123
+ )
124
+ }
125
+ }
126
+
127
+ // 4. Validate post_logout_redirect_uri. MUST be exact-match against
128
+ // the client's registered list (§2). No client → no validatable URI.
129
+ if (req.postLogoutRedirectUri) {
130
+ if (!client) {
131
+ return err(
132
+ authError.invalidRequest(
133
+ "post_logout_redirect_uri requires identifying the client via client_id or id_token_hint",
134
+ "post_logout_redirect_uri",
135
+ ),
136
+ )
137
+ }
138
+ const registered = client.postLogoutRedirectUris ?? []
139
+ if (!registered.includes(req.postLogoutRedirectUri)) {
140
+ return err(
141
+ authError.invalidRequest(
142
+ "post_logout_redirect_uri is not registered for this client",
143
+ "post_logout_redirect_uri",
144
+ ),
145
+ )
146
+ }
147
+ }
148
+
149
+ // 5. Revoke the subject's tokens. Only possible when an id_token_hint
150
+ // identified a subject. Without a hint, /end_session is essentially
151
+ // a session-cookie clear (which the framework doesn't yet own); we
152
+ // still return a successful redirect/ok so RPs can complete UX.
153
+ if (hintSubject) {
154
+ const revoked = await revokeAllForSubject(tenant.id, hintSubject, {
155
+ tokenStore: deps.tokenStore,
156
+ ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
157
+ clock: deps.clock,
158
+ })
159
+ if (isErr(revoked)) return err(revoked.error)
160
+ }
161
+
162
+ await safeAudit(deps, {
163
+ kind: "session_logout",
164
+ tenantId: tenant.id,
165
+ ...(resolvedClientId ? { clientId: resolvedClientId } : {}),
166
+ ...(hintSubject ? { subjectId: hintSubject } : {}),
167
+ timestamp: deps.clock(),
168
+ })
169
+
170
+ // 6. Build the redirect or signal "ok" for the caller to render.
171
+ if (req.postLogoutRedirectUri) {
172
+ const url = new URL(req.postLogoutRedirectUri)
173
+ if (req.state !== undefined) url.searchParams.set("state", req.state)
174
+ return ok({ kind: "redirect", url: url.toString() })
175
+ }
176
+ return ok({
177
+ kind: "ok",
178
+ ...(hintSubject ? { subjectId: hintSubject } : {}),
179
+ })
180
+ }
@@ -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 =
@@ -166,6 +174,11 @@ async function translate(
166
174
  context: final.context ?? null,
167
175
  providerSubject: result.providerSubject,
168
176
  properties: result.properties,
177
+ ...(final.appNonce !== undefined ? { appNonce: final.appNonce } : {}),
178
+ ...(final.claimsRequest !== undefined
179
+ ? { claimsRequest: final.claimsRequest }
180
+ : {}),
181
+ authTime: Math.floor(now / 1000),
169
182
  expiresAt: now + AUTH_CODE_TTL_MS,
170
183
  },
171
184
  AUTH_CODE_TTL_MS,
@@ -206,3 +219,184 @@ async function translate(
206
219
  return err(result.error)
207
220
  }
208
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
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pushed Authorization Requests (RFC 9126).
3
+ *
4
+ * The RP submits the full `/authorize` parameter set to `POST /par` along
5
+ * with its client credentials. The OP authenticates the client, stores
6
+ * the payload under an opaque `request_uri`, and returns it with an
7
+ * `expires_in`. The RP then redirects the user-agent to
8
+ * `GET /authorize?client_id=...&request_uri=...` and the OP rehydrates
9
+ * the original parameter set.
10
+ *
11
+ * Per RFC 9126 §3:
12
+ * - Response is HTTP 201 with `application/json` body
13
+ * `{request_uri, expires_in}`.
14
+ * - `request_uri` MUST be of the form
15
+ * `urn:ietf:params:oauth:request_uri:<opaque>` (§2.2).
16
+ * - Default TTL is 60 seconds — same as auth-code TTL.
17
+ *
18
+ * Per RFC 9126 §4:
19
+ * - At `/authorize`, `request_uri` is one-shot — consumed and discarded
20
+ * on first use.
21
+ * - Parameters in the PAR record take precedence; additional `/authorize`
22
+ * parameters MAY be merged but MUST NOT contradict (we strictly use
23
+ * the PAR'd set and ignore any additional ones except `client_id`).
24
+ *
25
+ * Client authentication (§2):
26
+ * - Public clients: `client_id` only.
27
+ * - Confidential clients: `client_id` + `client_secret` (Basic or form).
28
+ */
29
+ import type { AuditLog } from "../ports/audit-log"
30
+ import type { ConfigStore } from "../ports/config-store"
31
+ import type { SessionStore } from "../ports/session-store"
32
+ import { authError, type AuthError } from "../types/error"
33
+ import type { Result } from "../types/result"
34
+ import { err, isErr, ok } from "../types/result"
35
+ import type { TenantContext } from "../types/tenant"
36
+
37
+ import { verifyClientCredentials } from "./client-auth"
38
+ import { randomToken } from "./crypto"
39
+
40
+ export const PAR_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"
41
+ export const DEFAULT_PAR_TTL_MS = 60 * 1000
42
+
43
+ export type ParRequest = {
44
+ clientId: string
45
+ /** Confidential-client secret. */
46
+ clientSecret?: string
47
+ /** Raw `/authorize` parameter record, posted by the RP. */
48
+ params: Record<string, string>
49
+ }
50
+
51
+ export type ParResponse = {
52
+ request_uri: string
53
+ expires_in: number
54
+ }
55
+
56
+ export type ParDeps = {
57
+ configStore: ConfigStore
58
+ sessionStore: SessionStore
59
+ auditLog?: AuditLog
60
+ clock: () => number
61
+ /** Test override. */
62
+ newRequestUriSuffix?: () => string
63
+ ttlMs?: number
64
+ }
65
+
66
+ /**
67
+ * Process a `POST /par` request. Authenticates the client, validates the
68
+ * `client_id` consistency, and persists the parameter set.
69
+ */
70
+ export async function pushAuthorizationRequest(
71
+ req: ParRequest,
72
+ tenant: TenantContext,
73
+ deps: ParDeps,
74
+ ): Promise<Result<ParResponse, AuthError>> {
75
+ if (!deps.sessionStore.savePar || !deps.sessionStore.consumePar) {
76
+ return err(
77
+ authError.invalidRequest(
78
+ "session adapter does not implement PAR storage",
79
+ ),
80
+ )
81
+ }
82
+
83
+ // 1. Client authentication. The `client_id` in the body must match the
84
+ // authenticating client.
85
+ const client = tenant.config.clients.find((c) => c.id === req.clientId)
86
+ if (!client) {
87
+ return err(authError.invalidClient(`unknown client "${req.clientId}"`))
88
+ }
89
+ if (client.type === "confidential") {
90
+ const authErr = await verifyClientCredentials(client, req.clientSecret)
91
+ if (authErr) return err(authErr)
92
+ } else if (req.clientSecret !== undefined) {
93
+ return err(
94
+ authError.invalidClient("public client must not present client_secret"),
95
+ )
96
+ }
97
+
98
+ // 2. Sanity-check the body: client_id in body must match — the RFC
99
+ // requires it (§2). RP shouldn't be authenticating one client and
100
+ // pushing params for a different one.
101
+ if (req.params.client_id && req.params.client_id !== req.clientId) {
102
+ return err(
103
+ authError.invalidRequest(
104
+ "client_id in body conflicts with authenticated client",
105
+ "client_id",
106
+ ),
107
+ )
108
+ }
109
+ // RFC 9126 §2.1: `request_uri` MUST NOT appear in a PAR request body.
110
+ if ("request_uri" in req.params) {
111
+ return err(
112
+ authError.invalidRequest(
113
+ "request_uri MUST NOT be present in a PAR request",
114
+ "request_uri",
115
+ ),
116
+ )
117
+ }
118
+
119
+ // 3. Mint request_uri + persist.
120
+ const suffix = (deps.newRequestUriSuffix ?? randomToken)()
121
+ const requestUri = PAR_URI_PREFIX + suffix
122
+ const ttl = deps.ttlMs ?? DEFAULT_PAR_TTL_MS
123
+ const now = deps.clock()
124
+ const saved = await deps.sessionStore.savePar(
125
+ requestUri,
126
+ {
127
+ requestUri,
128
+ params: { ...req.params, client_id: req.clientId },
129
+ clientId: req.clientId,
130
+ issuedAt: now,
131
+ expiresAt: now + ttl,
132
+ },
133
+ ttl,
134
+ )
135
+ if (isErr(saved)) return err(saved.error)
136
+
137
+ return ok({
138
+ request_uri: requestUri,
139
+ expires_in: Math.floor(ttl / 1000),
140
+ })
141
+ }
@@ -34,6 +34,13 @@ export type RefreshGrantRequest = {
34
34
  /** Confidential clients authenticate at /token. */
35
35
  clientId?: string
36
36
  clientSecret?: string
37
+ /**
38
+ * RFC 9449 §6 — JWK thumbprint of the DPoP proof presented on this
39
+ * refresh request. MUST match `RefreshTokenPayload.dpopJkt` when the
40
+ * original grant was DPoP-bound; presence is REQUIRED for bound
41
+ * tokens, OPTIONAL otherwise.
42
+ */
43
+ dpopJkt?: string
37
44
  }
38
45
 
39
46
  export type RefreshTokensDeps = {
@@ -46,6 +53,8 @@ export type RefreshTokensDeps = {
46
53
  /** Reuse-detection window (ms). Default 60 s. */
47
54
  reuseWindowMs?: number
48
55
  newRefreshToken?: () => string
56
+ /** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
57
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
49
58
  }
50
59
 
51
60
  export async function refreshTokens(
@@ -93,6 +102,27 @@ export async function refreshTokens(
93
102
  return err(authError.invalidGrant(INVALID_REFRESH_DESC))
94
103
  }
95
104
 
105
+ // RFC 9449 §5 — when the refresh token is DPoP-bound, verify the
106
+ // presented DPoP thumbprint matches BEFORE consuming. Consuming first
107
+ // would burn the token on a wrong-key probe AND trip reuse-detection
108
+ // on a subsequent legitimate retry with the right key.
109
+ if (peekedPayload.dpopJkt !== undefined) {
110
+ if (req.dpopJkt === undefined) {
111
+ return err(
112
+ authError.invalidDpopProof(
113
+ "refresh token is DPoP-bound; request is missing a DPoP proof",
114
+ ),
115
+ )
116
+ }
117
+ if (req.dpopJkt !== peekedPayload.dpopJkt) {
118
+ return err(
119
+ authError.invalidDpopProof(
120
+ "refresh token DPoP jkt does not match the original binding",
121
+ ),
122
+ )
123
+ }
124
+ }
125
+
96
126
  const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
97
127
  reuseWindowMs: deps.reuseWindowMs,
98
128
  })
@@ -150,6 +180,17 @@ export async function refreshTokens(
150
180
  methodKind: payload.methodKind,
151
181
  scopes: requestedScopes,
152
182
  audience: payload.audience,
183
+ // `auth_time` is stable across refresh rotations per OIDC Core §12;
184
+ // refresh does not re-authenticate the user. `nonce` is deliberately
185
+ // NOT carried forward — the nonce was bound to the original
186
+ // `/authorize` request and should not reappear on rotated id_tokens.
187
+ authTime: payload.authTime,
188
+ // Preserve DPoP binding across refresh rotation (§6.1).
189
+ ...(payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {}),
190
+ // Preserve OIDC §5.5 claims-parameter intent across refresh (§12).
191
+ ...(payload.claimsRequest !== undefined
192
+ ? { claimsRequest: payload.claimsRequest }
193
+ : {}),
153
194
  },
154
195
  family: payload.family,
155
196
  deps,