@_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,168 @@
1
+ /**
2
+ * Dynamic Client Registration (RFC 7591).
3
+ *
4
+ * The host owns persistence — this module validates the wire request,
5
+ * mints a fresh `client_id` (and `client_secret` for confidential
6
+ * clients), invokes the host's `registerClient` hook with a structured
7
+ * `ClientConfig`, and builds the §3.2.1 response.
8
+ *
9
+ * Per RFC 7591:
10
+ * - §2: registration request body is JSON, fields include
11
+ * `client_name`, `redirect_uris`, `grant_types`, `response_types`,
12
+ * `token_endpoint_auth_method`, `scope`, etc.
13
+ * - §3.2.1: response is JSON with `client_id`, optional
14
+ * `client_secret`, `client_id_issued_at`, optional
15
+ * `client_secret_expires_at`, and the registered metadata.
16
+ * - §3.2.2: structural problems → `invalid_client_metadata` /
17
+ * `invalid_redirect_uri`. We collapse to `invalid_request` for
18
+ * consistency with the rest of the library's error mapping.
19
+ */
20
+ import { authError, type AuthError } from "../types/error"
21
+ import type {
22
+ RegisterClient,
23
+ RegisterClientRequest,
24
+ RegisterClientResponse,
25
+ } from "../types/idp"
26
+ import type { Result } from "../types/result"
27
+ import { err, isErr, ok } from "../types/result"
28
+ import type { TenantContext } from "../types/tenant"
29
+
30
+ import { randomId, randomToken } from "./crypto"
31
+ import { hashClientSecret } from "./token"
32
+
33
+ export type RegisterDeps = {
34
+ registerClient?: RegisterClient
35
+ clock: () => number
36
+ /** Test override for deterministic client_id. */
37
+ newClientId?: () => string
38
+ /** Test override for deterministic client_secret. */
39
+ newClientSecret?: () => string
40
+ }
41
+
42
+ export async function registerNewClient(
43
+ request: RegisterClientRequest,
44
+ tenant: TenantContext,
45
+ deps: RegisterDeps,
46
+ ): Promise<Result<RegisterClientResponse, AuthError>> {
47
+ if (!deps.registerClient) {
48
+ return err(
49
+ authError.invalidRequest(
50
+ "dynamic client registration is not enabled on this deployment",
51
+ ),
52
+ )
53
+ }
54
+
55
+ // RFC 7591 §2 — redirect_uris is REQUIRED for any client that uses a
56
+ // redirect-based grant. We accept the field as required at the wire
57
+ // level (schema enforces) and validate non-empty here.
58
+ if (
59
+ !Array.isArray(request.redirect_uris) ||
60
+ request.redirect_uris.length === 0
61
+ ) {
62
+ return err(
63
+ authError.invalidRequest(
64
+ "redirect_uris must be a non-empty array",
65
+ "redirect_uris",
66
+ ),
67
+ )
68
+ }
69
+ for (const uri of request.redirect_uris) {
70
+ try {
71
+ // Reject relative / opaque URIs eagerly so the host gets a clean
72
+ // structural rejection instead of a runtime surprise at /authorize.
73
+ // RFC 7591 §2 leaves URI scheme to grant-type semantics; we follow
74
+ // the OAuth 2.1 BCP and require absolute URIs.
75
+ void new URL(uri)
76
+ } catch {
77
+ return err(
78
+ authError.invalidRequest(
79
+ `redirect_uri "${uri}" is not a valid absolute URI`,
80
+ "redirect_uris",
81
+ ),
82
+ )
83
+ }
84
+ }
85
+
86
+ const authMethod = request.token_endpoint_auth_method ?? "client_secret_basic"
87
+ const isPublic = authMethod === "none"
88
+ const clientId = (deps.newClientId ?? randomId)()
89
+ const grantTypes = request.grant_types ?? ["authorization_code"]
90
+ const responseTypes = request.response_types ?? ["code"]
91
+ const scopes = request.scope
92
+ ? request.scope.split(/\s+/).filter(Boolean)
93
+ : ["openid"]
94
+
95
+ let secret: string | undefined
96
+ let secretHash: string | undefined
97
+ if (!isPublic) {
98
+ secret = (deps.newClientSecret ?? randomToken)()
99
+ secretHash = await hashClientSecret(secret)
100
+ }
101
+
102
+ const clientPartial = {
103
+ id: clientId,
104
+ name: request.client_name ?? clientId,
105
+ redirectUris: request.redirect_uris,
106
+ // Default scopes overlap with what the IdP advertises; hosts may
107
+ // override during the hook call before persisting.
108
+ scopes,
109
+ grantTypes: grantTypes.filter(
110
+ (g): g is "authorization_code" | "refresh_token" | "client_credentials" =>
111
+ g === "authorization_code" ||
112
+ g === "refresh_token" ||
113
+ g === "client_credentials",
114
+ ),
115
+ ...(request.post_logout_redirect_uris !== undefined
116
+ ? { postLogoutRedirectUris: request.post_logout_redirect_uris }
117
+ : {}),
118
+ ...(request.sector_identifier_uri !== undefined
119
+ ? { sectorIdentifier: request.sector_identifier_uri }
120
+ : {}),
121
+ }
122
+
123
+ const clientConfig = isPublic
124
+ ? {
125
+ ...clientPartial,
126
+ type: "public" as const,
127
+ pkceRequired: true as const,
128
+ }
129
+ : {
130
+ ...clientPartial,
131
+ type: "confidential" as const,
132
+ secretHash: secretHash!,
133
+ pkceRequired: true,
134
+ }
135
+
136
+ const hookResult = await deps.registerClient({
137
+ tenant,
138
+ request,
139
+ })
140
+ // The hook may either:
141
+ // - persist `clientConfig` as-is (most common) and return it,
142
+ // - synthesize its own (host-owned id generation, custom scopes) and
143
+ // return that — in which case the secret we minted is moot.
144
+ // We trust the hook's returned client + secret as authoritative.
145
+ void clientConfig
146
+
147
+ if (isErr(hookResult)) return err(hookResult.error)
148
+ const persisted = hookResult.value.client
149
+ const persistedSecret = hookResult.value.secret
150
+
151
+ const issuedAt = Math.floor(deps.clock() / 1000)
152
+ return ok({
153
+ client_id: persisted.id,
154
+ ...(persistedSecret !== undefined
155
+ ? { client_secret: persistedSecret }
156
+ : {}),
157
+ client_id_issued_at: issuedAt,
158
+ // RFC 7591 §3.2.1: `0` = no expiry.
159
+ client_secret_expires_at: 0,
160
+ redirect_uris: persisted.redirectUris,
161
+ grant_types: persisted.grantTypes,
162
+ response_types: responseTypes,
163
+ token_endpoint_auth_method: authMethod,
164
+ ...(request.client_name !== undefined
165
+ ? { client_name: request.client_name }
166
+ : {}),
167
+ })
168
+ }
@@ -74,6 +74,8 @@ export type ExchangeTokenDeps = {
74
74
  issuerUrl: string
75
75
  clock: () => number
76
76
  newRefreshFamily?: () => string
77
+ /** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
78
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
77
79
  }
78
80
 
79
81
  export async function exchangeToken(
@@ -203,6 +205,15 @@ export async function exchangeToken(
203
205
  methodId: subjectClaims.mid ?? "token_exchange",
204
206
  methodKind: subjectClaims.mkind ?? "token_exchange",
205
207
  scopes: requestedScopes,
208
+ // Carry the original end-user auth_time across the audience swap.
209
+ // Per OIDC Core §12, switching audience is NOT a re-authentication
210
+ // — the user logged in once, and downstream id_token consumers rely
211
+ // on auth_time being stable to detect actual re-auth events. When
212
+ // absent (subject was a client_credentials / m2m token), mintTokens
213
+ // correctly skips id_token issuance.
214
+ ...(subjectClaims.auth_time !== undefined
215
+ ? { authTime: subjectClaims.auth_time }
216
+ : {}),
206
217
  ...(subjectClaims.aud !== undefined &&
207
218
  subjectClaims.aud !== callerClient.id
208
219
  ? { audience: subjectClaims.aud }
@@ -47,7 +47,8 @@ import {
47
47
  sha256,
48
48
  utf8,
49
49
  } from "./crypto"
50
- import { signAccessToken } from "./jwt"
50
+ import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
51
+ import { signAccessToken, signIdToken } from "./jwt"
51
52
  import { validatePkce } from "./pkce"
52
53
 
53
54
  /**
@@ -88,6 +89,13 @@ export type TokenAuthCodeRequest = {
88
89
  clientSecret?: string
89
90
  /** Required if the original `/authorize` request had a `code_challenge`. */
90
91
  codeVerifier?: string
92
+ /**
93
+ * RFC 9449 §6 — the JWK thumbprint of the presented DPoP proof. Set by
94
+ * the HTTP layer after verifying the `DPoP:` header against this
95
+ * request's actual method + URI. The domain binds the issued access
96
+ * token to this thumbprint via `cnf.jkt`.
97
+ */
98
+ dpopJkt?: string
91
99
  }
92
100
 
93
101
  export type ExchangeCodeDeps = {
@@ -102,6 +110,11 @@ export type ExchangeCodeDeps = {
102
110
  newRefreshToken?: () => string
103
111
  /** Test override. */
104
112
  newRefreshFamily?: () => string
113
+ /**
114
+ * Host-supplied vendor scope → claim-names map. Forwarded to `mintTokens`
115
+ * for id_token + /userinfo scope-gating. See `IdPOptions.customScopeClaims`.
116
+ */
117
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
105
118
  }
106
119
 
107
120
  export async function exchangeCode(
@@ -141,6 +154,17 @@ export async function exchangeCode(
141
154
  const authResult = await verifyClientCredentials(client, req.clientSecret)
142
155
  if (authResult) return err(authResult)
143
156
 
157
+ // RFC 9449 §5.2 — if the client is configured to require DPoP, a
158
+ // bearer-only request (no DPoP header → no `dpopJkt` threaded in) is
159
+ // refused with `invalid_dpop_proof` before any token is minted.
160
+ if (client.dpopRequired && req.dpopJkt === undefined) {
161
+ return err(
162
+ authError.invalidDpopProof(
163
+ `client "${client.id}" requires DPoP-bound tokens`,
164
+ ),
165
+ )
166
+ }
167
+
144
168
  // 4. Redirect URI binding.
145
169
  if (payload.appRedirectUri !== req.redirectUri) {
146
170
  return err(authError.invalidGrant("redirect_uri mismatch with auth code"))
@@ -198,11 +222,14 @@ export async function exchangeCode(
198
222
  }
199
223
  }
200
224
 
201
- // 8. Mint access + refresh.
225
+ // 8. Mint access + refresh (+ id_token if `openid` scope was granted).
202
226
  const minted = await mintTokens({
203
227
  tenant,
204
228
  claim,
205
- payload,
229
+ payload: {
230
+ ...payload,
231
+ ...(req.dpopJkt !== undefined ? { dpopJkt: req.dpopJkt } : {}),
232
+ },
206
233
  deps,
207
234
  family: (deps.newRefreshFamily ?? randomId)(),
208
235
  })
@@ -219,7 +246,30 @@ export async function mintTokens(args: {
219
246
  payload: Pick<
220
247
  CodePayload,
221
248
  "tenantId" | "clientId" | "methodId" | "methodKind" | "scopes" | "audience"
222
- >
249
+ > & {
250
+ /**
251
+ * Present on `authorization_code` and `refresh_token` grants where an
252
+ * end-user actually authenticated. Absent on `client_credentials`
253
+ * (no end-user) — and absent means no `id_token` is emitted even if
254
+ * the requested scopes nominally include `openid`.
255
+ */
256
+ authTime?: number
257
+ /** RP's OIDC `nonce` from `/authorize`, when present. */
258
+ appNonce?: string
259
+ /**
260
+ * DPoP key thumbprint (RFC 9449 §6.1). When present, the access
261
+ * token's `cnf.jkt` claim is set and `token_type` flips to `"DPoP"`;
262
+ * the saved refresh-token payload's `dpopJkt` is set so refresh
263
+ * rotation re-enforces sender constraint.
264
+ */
265
+ dpopJkt?: string
266
+ /**
267
+ * OIDC Core §5.5 — RP-requested claims from `/authorize`. Carried
268
+ * into the id_token and forward across refresh rotations (§12) so
269
+ * later /userinfo calls keep returning the requested fields.
270
+ */
271
+ claimsRequest?: import("../types/authorization").ClaimsRequest
272
+ }
223
273
  family: string
224
274
  /**
225
275
  * `client_credentials` grants (RFC 6749 §4.4.3) and other paths where a
@@ -236,6 +286,11 @@ export async function mintTokens(args: {
236
286
  issuerUrl: string
237
287
  clock: () => number
238
288
  newRefreshToken?: () => string
289
+ /**
290
+ * Host-supplied vendor scope → claim-names map merged into the
291
+ * id_token + /userinfo scope-gating. See `IdPOptions.customScopeClaims`.
292
+ */
293
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
239
294
  }
240
295
  }): Promise<Result<TokenResponse, AuthError>> {
241
296
  const { tenant, claim, payload, deps, family, skipRefresh } = args
@@ -248,12 +303,22 @@ export async function mintTokens(args: {
248
303
  ? tenant.config.refreshTtl * 1000
249
304
  : DEFAULT_REFRESH_TTL_MS
250
305
  const now = deps.clock()
251
- const subjectId = await deriveSubjectId(claim)
306
+ // OIDC Core §8.1 — `sectorIdentifier` from the receiving client drives
307
+ // pairwise vs public subject derivation. Look it up off the tenant
308
+ // config (already loaded by the grant flow). Absent = public.
309
+ const receivingClient = tenant.config.clients.find(
310
+ (c) => c.id === payload.clientId,
311
+ )
312
+ const subjectId = await deriveSubjectId(
313
+ claim,
314
+ receivingClient?.sectorIdentifier,
315
+ )
252
316
 
253
317
  const keyRes = await deps.keyStore.currentSigningKey()
254
318
  if (isErr(keyRes)) return err(keyRes.error)
255
319
  const signingKey = keyRes.value
256
320
 
321
+ const userinfoClaimNames = Object.keys(payload.claimsRequest?.userinfo ?? {})
257
322
  const claims: AccessTokenClaims = {
258
323
  iss: deps.issuerUrl,
259
324
  sub: subjectId,
@@ -265,6 +330,9 @@ export async function mintTokens(args: {
265
330
  mkind: payload.methodKind,
266
331
  scope: payload.scopes.join(" "),
267
332
  claim,
333
+ ...(payload.authTime !== undefined ? { auth_time: payload.authTime } : {}),
334
+ ...(payload.dpopJkt !== undefined ? { cnf: { jkt: payload.dpopJkt } } : {}),
335
+ ...(userinfoClaimNames.length > 0 ? { uic: userinfoClaimNames } : {}),
268
336
  }
269
337
 
270
338
  let accessToken: string
@@ -295,6 +363,14 @@ export async function mintTokens(args: {
295
363
  family,
296
364
  methodId: payload.methodId,
297
365
  methodKind: payload.methodKind,
366
+ // Carry the original end-user auth time forward. Refresh-grant
367
+ // reissue uses this verbatim so `id_token.auth_time` is stable per
368
+ // OIDC Core §12 (refresh does not re-authenticate the user).
369
+ authTime: payload.authTime ?? Math.floor(now / 1000),
370
+ ...(payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {}),
371
+ ...(payload.claimsRequest !== undefined
372
+ ? { claimsRequest: payload.claimsRequest }
373
+ : {}),
298
374
  issuedAt: now,
299
375
  expiresAt: now + refreshTtl,
300
376
  }
@@ -302,6 +378,41 @@ export async function mintTokens(args: {
302
378
  if (isErr(saved)) return err(saved.error)
303
379
  }
304
380
 
381
+ // OIDC id_token issuance — only when the grant carried an end-user
382
+ // (`authTime` present) AND `openid` scope was granted. Client-credentials
383
+ // never satisfies the first condition; refresh + code do.
384
+ let idToken: string | undefined
385
+ if (payload.authTime !== undefined && shouldIssueIdToken(payload.scopes)) {
386
+ const idClaims = await buildIdTokenClaims({
387
+ issuerUrl: deps.issuerUrl,
388
+ audience: payload.clientId,
389
+ subjectId,
390
+ claim,
391
+ scopes: payload.scopes,
392
+ authTime: payload.authTime,
393
+ ...(payload.appNonce !== undefined ? { appNonce: payload.appNonce } : {}),
394
+ now,
395
+ methodKind: payload.methodKind,
396
+ accessToken,
397
+ ...(payload.claimsRequest !== undefined
398
+ ? { claimsRequest: payload.claimsRequest }
399
+ : {}),
400
+ ...(deps.customScopeClaims !== undefined
401
+ ? { customScopeClaims: deps.customScopeClaims }
402
+ : {}),
403
+ })
404
+ try {
405
+ idToken = await signIdToken(
406
+ idClaims,
407
+ signingKey.privateKeyRef as Parameters<typeof signIdToken>[1],
408
+ signingKey.alg,
409
+ signingKey.kid,
410
+ )
411
+ } catch (e) {
412
+ return err(authError.serverError("id_token sign failed", e))
413
+ }
414
+ }
415
+
305
416
  await safeAudit(deps, {
306
417
  kind: "token_issued",
307
418
  tenantId: payload.tenantId,
@@ -310,14 +421,17 @@ export async function mintTokens(args: {
310
421
  methodKind: payload.methodKind,
311
422
  subjectId,
312
423
  refreshTokenIdHash: refresh ? await hashTokenForAudit(refresh) : "",
424
+ ...(idToken !== undefined ? { idTokenIssued: true } : {}),
425
+ ...(payload.dpopJkt !== undefined ? { dpopBound: true } : {}),
313
426
  timestamp: now,
314
427
  })
315
428
 
316
429
  return ok({
317
430
  access_token: accessToken,
318
- token_type: "Bearer",
431
+ token_type: payload.dpopJkt !== undefined ? "DPoP" : "Bearer",
319
432
  expires_in: Math.floor(accessTtl / 1000),
320
433
  ...(refresh !== undefined ? { refresh_token: refresh } : {}),
434
+ ...(idToken !== undefined ? { id_token: idToken } : {}),
321
435
  scope: payload.scopes.join(" "),
322
436
  })
323
437
  }
@@ -336,11 +450,23 @@ export async function hashClientSecret(plain: string): Promise<string> {
336
450
  /**
337
451
  * Derive a stable subject id from the issued `SubjectClaim`. Hash inputs
338
452
  * are canonicalized so reordered `properties` keys hash identically.
453
+ *
454
+ * OIDC Core §8.1 — when `sectorIdentifier` is supplied, the derivation
455
+ * mixes it in so the resulting `sub` is **pairwise**: identical across
456
+ * clients sharing that sector, distinct across sectors. Absent =
457
+ * **public** subject (same `sub` for every RP).
339
458
  */
340
- async function deriveSubjectId(claim: SubjectClaim): Promise<string> {
459
+ async function deriveSubjectId(
460
+ claim: SubjectClaim,
461
+ sectorIdentifier?: string,
462
+ ): Promise<string> {
341
463
  const c = claim as { type: string; properties: Record<string, unknown> }
342
464
  const ordered = canonicalize(c.properties)
343
- return base64url.encode(await sha256(`${c.type}\0${ordered}`)).slice(0, 22)
465
+ const seed =
466
+ sectorIdentifier !== undefined
467
+ ? `${sectorIdentifier}\0${c.type}\0${ordered}`
468
+ : `${c.type}\0${ordered}`
469
+ return base64url.encode(await sha256(seed)).slice(0, 22)
344
470
  }
345
471
 
346
472
  function canonicalize(value: unknown): string {
@@ -1,7 +1,23 @@
1
1
  /**
2
- * `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the bearer access
3
- * token and returns the inlined `SubjectClaim` claims.
2
+ * `/userinfo` endpoint logic (OIDC Core §5.3). Verifies the presented
3
+ * access token, optionally enforces a DPoP proof binding (RFC 9449 §7),
4
+ * and returns the inlined `SubjectClaim` claims, scope-gated per OIDC
5
+ * Core §5.4.
6
+ *
7
+ * Profile / email / phone / address claims appear in the response only
8
+ * when (a) the access token's `scope` includes the granting scope and
9
+ * (b) the value exists on `SubjectClaim.properties`. Other properties
10
+ * (host-specific subject fields like `userId`, `roles`, …) are returned
11
+ * under `properties` and not subject to OIDC scope gating — they're
12
+ * outside §5.4's universe.
13
+ *
14
+ * DPoP at the resource server (RFC 9449 §7):
15
+ * - If the access token has a `cnf.jkt` claim, the request MUST carry a
16
+ * matching `DPoP:` proof whose `ath` equals SHA-256(access_token).
17
+ * - If no `cnf.jkt`, the access token is plain Bearer and any DPoP
18
+ * proof is silently ignored at this layer (host can still enforce).
4
19
  */
20
+ import type { TokenStore } from "../ports/token-store"
5
21
  import type { KeyStore } from "../ports/key-store"
6
22
  import { authError, type AuthError } from "../types/error"
7
23
  import type { Result } from "../types/result"
@@ -9,13 +25,16 @@ import { err, isErr, ok } from "../types/result"
9
25
  import type { SubjectClaim } from "../types/subject"
10
26
  import type { AccessTokenClaims } from "../types/token"
11
27
 
28
+ import { computeAth, verifyDpopProof } from "./dpop"
29
+ import { pickScopedClaims } from "./id-token"
12
30
  import { verifyAccessToken } from "./jwt"
31
+ import type { ScopedProfileClaims } from "../types/token"
13
32
 
14
- export type UserinfoResponse = {
33
+ export type UserinfoResponse = ScopedProfileClaims & {
15
34
  sub: string
16
35
  /** Subject type discriminator (e.g. `"user"` / `"admin"`). */
17
36
  subject_type: string
18
- /** Inlined claim properties. */
37
+ /** Inlined claim properties, host-specific and not gated by OIDC scope. */
19
38
  properties: Record<string, unknown>
20
39
  /** Scope granted to this access token. */
21
40
  scope?: string
@@ -23,11 +42,34 @@ export type UserinfoResponse = {
23
42
 
24
43
  export type UserinfoDeps = {
25
44
  keyStore: KeyStore
45
+ /** Required when DPoP-bound tokens are accepted. */
46
+ tokenStore?: TokenStore
26
47
  issuerUrl?: string
48
+ /**
49
+ * Host-supplied vendor scope → claim-names map. Merged into the
50
+ * scope-gated /userinfo response just as it is for id_token. See
51
+ * `IdPOptions.customScopeClaims`.
52
+ */
53
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
54
+ }
55
+
56
+ export type UserinfoInput = {
57
+ /** Access token from `Authorization: Bearer ...` or `Authorization: DPoP ...`. */
58
+ accessToken: string
59
+ /** `Authorization`-scheme as presented. Defaults to `"Bearer"`. */
60
+ scheme?: "Bearer" | "DPoP"
61
+ /** Raw `DPoP:` header from the request, if any. */
62
+ dpopProof?: string
63
+ /** Canonical request URI for DPoP `htu` check (scheme + host + path). */
64
+ htu?: string
65
+ /** Request method for DPoP `htm` check, uppercase. */
66
+ htm?: string
67
+ /** Wall clock seconds for DPoP `iat` window. */
68
+ nowSec?: number
27
69
  }
28
70
 
29
71
  export async function userinfo(
30
- bearerToken: string,
72
+ input: UserinfoInput,
31
73
  deps: UserinfoDeps,
32
74
  ): Promise<Result<UserinfoResponse, AuthError>> {
33
75
  const keysRes = await deps.keyStore.signingKeys()
@@ -35,22 +77,95 @@ export async function userinfo(
35
77
 
36
78
  let claims: AccessTokenClaims
37
79
  try {
38
- claims = await verifyAccessToken(bearerToken, keysRes.value, {
80
+ claims = await verifyAccessToken(input.accessToken, keysRes.value, {
39
81
  ...(deps.issuerUrl ? { issuer: deps.issuerUrl } : {}),
40
82
  })
41
83
  } catch {
42
84
  return err(authError.invalidGrant("access token invalid or expired"))
43
85
  }
44
86
 
87
+ // RFC 9449 §7 — DPoP-bound tokens require a matching proof at the RS.
88
+ const boundJkt = claims.cnf?.jkt
89
+ if (boundJkt !== undefined) {
90
+ if (input.scheme !== "DPoP") {
91
+ return err(
92
+ authError.invalidDpopProof(
93
+ 'access token is DPoP-bound; Authorization scheme must be "DPoP"',
94
+ ),
95
+ )
96
+ }
97
+ if (!input.dpopProof) {
98
+ return err(
99
+ authError.invalidDpopProof(
100
+ "access token is DPoP-bound; request is missing a DPoP proof",
101
+ ),
102
+ )
103
+ }
104
+ if (!input.htu || !input.htm || input.nowSec === undefined) {
105
+ return err(
106
+ authError.invalidDpopProof(
107
+ "userinfo dpop verification requires htu, htm, and nowSec",
108
+ ),
109
+ )
110
+ }
111
+ if (!deps.tokenStore) {
112
+ return err(
113
+ authError.invalidDpopProof(
114
+ "userinfo dpop verification requires a token-store (jti replay protection)",
115
+ ),
116
+ )
117
+ }
118
+ const expectedAth = await computeAth(input.accessToken)
119
+ const dpopRes = await verifyDpopProof(
120
+ {
121
+ proofJwt: input.dpopProof,
122
+ htu: input.htu,
123
+ htm: input.htm,
124
+ nowSec: input.nowSec,
125
+ expectedAth,
126
+ },
127
+ { tokenStore: deps.tokenStore },
128
+ )
129
+ if (isErr(dpopRes)) return err(dpopRes.error)
130
+ if (dpopRes.value.jkt !== boundJkt) {
131
+ return err(
132
+ authError.invalidDpopProof(
133
+ "dpop proof key does not match access token cnf.jkt",
134
+ ),
135
+ )
136
+ }
137
+ } else if (input.scheme === "DPoP") {
138
+ // Token isn't bound but caller used the DPoP scheme — reject so a
139
+ // confused-deputy can't accidentally rely on RS-side enforcement.
140
+ return err(
141
+ authError.invalidGrant(
142
+ "access token is not DPoP-bound; use Authorization: Bearer",
143
+ ),
144
+ )
145
+ }
146
+
45
147
  const claim = claims.claim as SubjectClaim & {
46
148
  type: string
47
149
  properties: Record<string, unknown>
48
150
  }
49
151
 
152
+ const scopes = claims.scope ? claims.scope.split(" ").filter(Boolean) : []
153
+ // OIDC Core §5.5 — claims the RP requested via `claims=...userinfo:...`
154
+ // bypass scope gating. Names were captured into `uic` at mint time so
155
+ // the resource server doesn't need to re-resolve the original /authorize
156
+ // request.
157
+ const scopedClaims = pickScopedClaims(
158
+ claim,
159
+ scopes,
160
+ claims.uic ?? [],
161
+ deps.customScopeClaims ?? {},
162
+ )
163
+
50
164
  return ok({
51
165
  sub: claims.sub,
52
166
  subject_type: claim.type,
53
167
  properties: claim.properties,
54
168
  scope: claims.scope,
169
+ ...scopedClaims,
55
170
  })
56
171
  }
@@ -17,7 +17,10 @@ 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,
23
+ RegisterClient,
21
24
  RenderPicker,
22
25
  SuccessMapInput,
23
26
  } from "../types/idp"
@@ -48,15 +51,22 @@ export type HttpDeps = {
48
51
  callbackHostFor?: (tenantId: TenantId) => string
49
52
  resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
50
53
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
54
+ /** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
55
+ onLogout?: (
56
+ input: LogoutEventInput,
57
+ ) => Promise<LogoutHookResult> | LogoutHookResult
51
58
  persistUpstreamTokens?: PersistUpstreamTokens
52
59
  exchangeAudience?: ExchangeAudience
53
60
  renderPicker?: RenderPicker
61
+ registerClient?: RegisterClient
54
62
  /** Builds `TenantContext.request.custom` for every request. See `IdPOptions.buildCustomContext`. */
55
63
  buildCustomContext?: (
56
64
  req: Request,
57
65
  ) => Record<string, unknown> | Promise<Record<string, unknown>>
58
66
  clock: () => number
59
67
  cookieDefaults: CookieDefaults
68
+ /** See `IdPOptions.customScopeClaims`. */
69
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
60
70
  }
61
71
 
62
72
  /** Per-request variables populated by middleware. */
@@ -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