@_mustachio/openauth 0.10.0 → 0.11.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 (151) hide show
  1. package/dist/esm/adapters/memory/session-store.js +22 -0
  2. package/dist/esm/adapters/memory/token-store.js +13 -0
  3. package/dist/esm/client.js +9 -2
  4. package/dist/esm/domain/authorize.js +5 -0
  5. package/dist/esm/domain/callback.js +3 -0
  6. package/dist/esm/domain/discovery.js +44 -2
  7. package/dist/esm/domain/dpop.js +122 -0
  8. package/dist/esm/domain/id-token.js +94 -0
  9. package/dist/esm/domain/introspect.js +5 -1
  10. package/dist/esm/domain/jwt.js +28 -0
  11. package/dist/esm/domain/logout.js +75 -0
  12. package/dist/esm/domain/method-route.js +3 -0
  13. package/dist/esm/domain/par.js +51 -0
  14. package/dist/esm/domain/refresh.js +12 -1
  15. package/dist/esm/domain/register.js +74 -0
  16. package/dist/esm/domain/token-exchange.js +1 -0
  17. package/dist/esm/domain/token.js +48 -7
  18. package/dist/esm/domain/userinfo.js +38 -3
  19. package/dist/esm/http/handlers/authorize.js +90 -3
  20. package/dist/esm/http/handlers/end-session.js +64 -0
  21. package/dist/esm/http/handlers/metadata.js +5 -2
  22. package/dist/esm/http/handlers/par.js +53 -0
  23. package/dist/esm/http/handlers/register.js +46 -0
  24. package/dist/esm/http/handlers/token.js +36 -6
  25. package/dist/esm/http/handlers/userinfo.js +35 -29
  26. package/dist/esm/http/router.js +10 -0
  27. package/dist/esm/http/schemas/authorize.js +7 -1
  28. package/dist/esm/http/schemas/end-session.js +13 -0
  29. package/dist/esm/http/schemas/par.js +9 -0
  30. package/dist/esm/http/schemas/register.js +16 -0
  31. package/dist/esm/index.js +7 -2
  32. package/dist/esm/types/error.js +5 -0
  33. package/dist/types/adapters/memory/session-store.d.ts +3 -1
  34. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  35. package/dist/types/adapters/memory/token-store.d.ts +1 -0
  36. package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
  37. package/dist/types/client.d.ts +19 -0
  38. package/dist/types/client.d.ts.map +1 -1
  39. package/dist/types/domain/authorize.d.ts.map +1 -1
  40. package/dist/types/domain/client-credentials.d.ts +2 -0
  41. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  42. package/dist/types/domain/discovery.d.ts +37 -0
  43. package/dist/types/domain/discovery.d.ts.map +1 -1
  44. package/dist/types/domain/dpop.d.ts +61 -0
  45. package/dist/types/domain/dpop.d.ts.map +1 -0
  46. package/dist/types/domain/id-token.d.ts +83 -0
  47. package/dist/types/domain/id-token.d.ts.map +1 -0
  48. package/dist/types/domain/introspect.d.ts +8 -0
  49. package/dist/types/domain/introspect.d.ts.map +1 -1
  50. package/dist/types/domain/jwt.d.ts +27 -1
  51. package/dist/types/domain/jwt.d.ts.map +1 -1
  52. package/dist/types/domain/logout.d.ts +69 -0
  53. package/dist/types/domain/logout.d.ts.map +1 -0
  54. package/dist/types/domain/par.d.ts +62 -0
  55. package/dist/types/domain/par.d.ts.map +1 -0
  56. package/dist/types/domain/refresh.d.ts +9 -0
  57. package/dist/types/domain/refresh.d.ts.map +1 -1
  58. package/dist/types/domain/register.d.ts +33 -0
  59. package/dist/types/domain/register.d.ts.map +1 -0
  60. package/dist/types/domain/token-exchange.d.ts +2 -0
  61. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  62. package/dist/types/domain/token.d.ts +41 -1
  63. package/dist/types/domain/token.d.ts.map +1 -1
  64. package/dist/types/domain/userinfo.d.ts +44 -5
  65. package/dist/types/domain/userinfo.d.ts.map +1 -1
  66. package/dist/types/http/context.d.ts +4 -1
  67. package/dist/types/http/context.d.ts.map +1 -1
  68. package/dist/types/http/handlers/authorize.d.ts.map +1 -1
  69. package/dist/types/http/handlers/end-session.d.ts +3 -0
  70. package/dist/types/http/handlers/end-session.d.ts.map +1 -0
  71. package/dist/types/http/handlers/metadata.d.ts +1 -1
  72. package/dist/types/http/handlers/metadata.d.ts.map +1 -1
  73. package/dist/types/http/handlers/par.d.ts +3 -0
  74. package/dist/types/http/handlers/par.d.ts.map +1 -0
  75. package/dist/types/http/handlers/register.d.ts +3 -0
  76. package/dist/types/http/handlers/register.d.ts.map +1 -0
  77. package/dist/types/http/handlers/token.d.ts.map +1 -1
  78. package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
  79. package/dist/types/http/router.d.ts.map +1 -1
  80. package/dist/types/http/schemas/authorize.d.ts +40 -0
  81. package/dist/types/http/schemas/authorize.d.ts.map +1 -1
  82. package/dist/types/http/schemas/end-session.d.ts +31 -0
  83. package/dist/types/http/schemas/end-session.d.ts.map +1 -0
  84. package/dist/types/http/schemas/par.d.ts +20 -0
  85. package/dist/types/http/schemas/par.d.ts.map +1 -0
  86. package/dist/types/http/schemas/register.d.ts +42 -0
  87. package/dist/types/http/schemas/register.d.ts.map +1 -0
  88. package/dist/types/index.d.ts +3 -3
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/types/ports/audit-log.d.ts +27 -0
  91. package/dist/types/ports/audit-log.d.ts.map +1 -1
  92. package/dist/types/ports/session-store.d.ts +27 -0
  93. package/dist/types/ports/session-store.d.ts.map +1 -1
  94. package/dist/types/ports/token-store.d.ts +12 -0
  95. package/dist/types/ports/token-store.d.ts.map +1 -1
  96. package/dist/types/types/authorization.d.ts +23 -0
  97. package/dist/types/types/authorization.d.ts.map +1 -1
  98. package/dist/types/types/error.d.ts +9 -0
  99. package/dist/types/types/error.d.ts.map +1 -1
  100. package/dist/types/types/flow.d.ts +10 -0
  101. package/dist/types/types/flow.d.ts.map +1 -1
  102. package/dist/types/types/idp.d.ts +91 -1
  103. package/dist/types/types/idp.d.ts.map +1 -1
  104. package/dist/types/types/tenant.d.ts +37 -0
  105. package/dist/types/types/tenant.d.ts.map +1 -1
  106. package/dist/types/types/token.d.ts +128 -0
  107. package/dist/types/types/token.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/src/adapters/memory/session-store.ts +44 -1
  110. package/src/adapters/memory/token-store.ts +16 -0
  111. package/src/client.ts +32 -0
  112. package/src/domain/authorize.ts +9 -0
  113. package/src/domain/callback.ts +5 -0
  114. package/src/domain/client-credentials.ts +2 -0
  115. package/src/domain/discovery.ts +80 -2
  116. package/src/domain/dpop.ts +293 -0
  117. package/src/domain/id-token.ts +213 -0
  118. package/src/domain/introspect.ts +10 -0
  119. package/src/domain/jwt.ts +80 -1
  120. package/src/domain/logout.ts +180 -0
  121. package/src/domain/method-route.ts +5 -0
  122. package/src/domain/par.ts +141 -0
  123. package/src/domain/refresh.ts +41 -0
  124. package/src/domain/register.ts +168 -0
  125. package/src/domain/token-exchange.ts +11 -0
  126. package/src/domain/token.ts +134 -8
  127. package/src/domain/userinfo.ts +121 -6
  128. package/src/http/context.ts +4 -0
  129. package/src/http/handlers/authorize.ts +144 -3
  130. package/src/http/handlers/end-session.ts +106 -0
  131. package/src/http/handlers/metadata.ts +7 -2
  132. package/src/http/handlers/par.ts +84 -0
  133. package/src/http/handlers/register.ts +70 -0
  134. package/src/http/handlers/token.ts +56 -0
  135. package/src/http/handlers/userinfo.ts +62 -38
  136. package/src/http/router.ts +19 -0
  137. package/src/http/schemas/authorize.ts +27 -0
  138. package/src/http/schemas/end-session.ts +23 -0
  139. package/src/http/schemas/par.ts +23 -0
  140. package/src/http/schemas/register.ts +29 -0
  141. package/src/index.ts +18 -1
  142. package/src/ports/CONSISTENCY.md +52 -21
  143. package/src/ports/audit-log.ts +29 -0
  144. package/src/ports/session-store.ts +33 -0
  145. package/src/ports/token-store.ts +13 -0
  146. package/src/types/authorization.ts +25 -0
  147. package/src/types/error.ts +23 -0
  148. package/src/types/flow.ts +10 -0
  149. package/src/types/idp.ts +96 -0
  150. package/src/types/tenant.ts +37 -0
  151. package/src/types/token.ts +135 -0
@@ -0,0 +1,74 @@
1
+ // src/domain/register.ts
2
+ import { authError } from "../types/error";
3
+ import { err, isErr, ok } from "../types/result";
4
+ import { randomId, randomToken } from "./crypto";
5
+ import { hashClientSecret } from "./token";
6
+ async function registerNewClient(request, tenant, deps) {
7
+ if (!deps.registerClient) {
8
+ return err(authError.invalidRequest("dynamic client registration is not enabled on this deployment"));
9
+ }
10
+ if (!Array.isArray(request.redirect_uris) || request.redirect_uris.length === 0) {
11
+ return err(authError.invalidRequest("redirect_uris must be a non-empty array", "redirect_uris"));
12
+ }
13
+ for (const uri of request.redirect_uris) {
14
+ try {
15
+ new URL(uri);
16
+ } catch {
17
+ return err(authError.invalidRequest(`redirect_uri "${uri}" is not a valid absolute URI`, "redirect_uris"));
18
+ }
19
+ }
20
+ const authMethod = request.token_endpoint_auth_method ?? "client_secret_basic";
21
+ const isPublic = authMethod === "none";
22
+ const clientId = (deps.newClientId ?? randomId)();
23
+ const grantTypes = request.grant_types ?? ["authorization_code"];
24
+ const responseTypes = request.response_types ?? ["code"];
25
+ const scopes = request.scope ? request.scope.split(/\s+/).filter(Boolean) : ["openid"];
26
+ let secret;
27
+ let secretHash;
28
+ if (!isPublic) {
29
+ secret = (deps.newClientSecret ?? randomToken)();
30
+ secretHash = await hashClientSecret(secret);
31
+ }
32
+ const clientPartial = {
33
+ id: clientId,
34
+ name: request.client_name ?? clientId,
35
+ redirectUris: request.redirect_uris,
36
+ scopes,
37
+ grantTypes: grantTypes.filter((g) => g === "authorization_code" || g === "refresh_token" || g === "client_credentials"),
38
+ ...request.post_logout_redirect_uris !== undefined ? { postLogoutRedirectUris: request.post_logout_redirect_uris } : {},
39
+ ...request.sector_identifier_uri !== undefined ? { sectorIdentifier: request.sector_identifier_uri } : {}
40
+ };
41
+ const clientConfig = isPublic ? {
42
+ ...clientPartial,
43
+ type: "public",
44
+ pkceRequired: true
45
+ } : {
46
+ ...clientPartial,
47
+ type: "confidential",
48
+ secretHash,
49
+ pkceRequired: true
50
+ };
51
+ const hookResult = await deps.registerClient({
52
+ tenant,
53
+ request
54
+ });
55
+ if (isErr(hookResult))
56
+ return err(hookResult.error);
57
+ const persisted = hookResult.value.client;
58
+ const persistedSecret = hookResult.value.secret;
59
+ const issuedAt = Math.floor(deps.clock() / 1000);
60
+ return ok({
61
+ client_id: persisted.id,
62
+ ...persistedSecret !== undefined ? { client_secret: persistedSecret } : {},
63
+ client_id_issued_at: issuedAt,
64
+ client_secret_expires_at: 0,
65
+ redirect_uris: persisted.redirectUris,
66
+ grant_types: persisted.grantTypes,
67
+ response_types: responseTypes,
68
+ token_endpoint_auth_method: authMethod,
69
+ ...request.client_name !== undefined ? { client_name: request.client_name } : {}
70
+ });
71
+ }
72
+ export {
73
+ registerNewClient
74
+ };
@@ -75,6 +75,7 @@ async function exchangeToken(req, deps) {
75
75
  methodId: subjectClaims.mid ?? "token_exchange",
76
76
  methodKind: subjectClaims.mkind ?? "token_exchange",
77
77
  scopes: requestedScopes,
78
+ ...subjectClaims.auth_time !== undefined ? { authTime: subjectClaims.auth_time } : {},
78
79
  ...subjectClaims.aud !== undefined && subjectClaims.aud !== callerClient.id ? { audience: subjectClaims.aud } : {}
79
80
  },
80
81
  family: (deps.newRefreshFamily ?? randomId)(),
@@ -12,7 +12,8 @@ import {
12
12
  sha256,
13
13
  utf8
14
14
  } from "./crypto";
15
- import { signAccessToken } from "./jwt";
15
+ import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token";
16
+ import { signAccessToken, signIdToken } from "./jwt";
16
17
  import { validatePkce } from "./pkce";
17
18
  async function saveEncryptedCode(code, payload, ttl, deps) {
18
19
  const keyResult = await deps.keyStore.currentEncryptionKey();
@@ -57,6 +58,9 @@ async function exchangeCode(req, deps) {
57
58
  const authResult = await verifyClientCredentials(client, req.clientSecret);
58
59
  if (authResult)
59
60
  return err(authResult);
61
+ if (client.dpopRequired && req.dpopJkt === undefined) {
62
+ return err(authError.invalidDpopProof(`client "${client.id}" requires DPoP-bound tokens`));
63
+ }
60
64
  if (payload.appRedirectUri !== req.redirectUri) {
61
65
  return err(authError.invalidGrant("redirect_uri mismatch with auth code"));
62
66
  }
@@ -104,7 +108,10 @@ async function exchangeCode(req, deps) {
104
108
  const minted = await mintTokens({
105
109
  tenant,
106
110
  claim,
107
- payload,
111
+ payload: {
112
+ ...payload,
113
+ ...req.dpopJkt !== undefined ? { dpopJkt: req.dpopJkt } : {}
114
+ },
108
115
  deps,
109
116
  family: (deps.newRefreshFamily ?? randomId)()
110
117
  });
@@ -115,11 +122,13 @@ async function mintTokens(args) {
115
122
  const accessTtl = tenant.config.accessTtl !== undefined ? tenant.config.accessTtl * 1000 : DEFAULT_ACCESS_TTL_MS;
116
123
  const refreshTtl = tenant.config.refreshTtl !== undefined ? tenant.config.refreshTtl * 1000 : DEFAULT_REFRESH_TTL_MS;
117
124
  const now = deps.clock();
118
- const subjectId = await deriveSubjectId(claim);
125
+ const receivingClient = tenant.config.clients.find((c) => c.id === payload.clientId);
126
+ const subjectId = await deriveSubjectId(claim, receivingClient?.sectorIdentifier);
119
127
  const keyRes = await deps.keyStore.currentSigningKey();
120
128
  if (isErr(keyRes))
121
129
  return err(keyRes.error);
122
130
  const signingKey = keyRes.value;
131
+ const userinfoClaimNames = Object.keys(payload.claimsRequest?.userinfo ?? {});
123
132
  const claims = {
124
133
  iss: deps.issuerUrl,
125
134
  sub: subjectId,
@@ -130,7 +139,10 @@ async function mintTokens(args) {
130
139
  mid: payload.methodId,
131
140
  mkind: payload.methodKind,
132
141
  scope: payload.scopes.join(" "),
133
- claim
142
+ claim,
143
+ ...payload.authTime !== undefined ? { auth_time: payload.authTime } : {},
144
+ ...payload.dpopJkt !== undefined ? { cnf: { jkt: payload.dpopJkt } } : {},
145
+ ...userinfoClaimNames.length > 0 ? { uic: userinfoClaimNames } : {}
134
146
  };
135
147
  let accessToken;
136
148
  try {
@@ -151,6 +163,9 @@ async function mintTokens(args) {
151
163
  family,
152
164
  methodId: payload.methodId,
153
165
  methodKind: payload.methodKind,
166
+ authTime: payload.authTime ?? Math.floor(now / 1000),
167
+ ...payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {},
168
+ ...payload.claimsRequest !== undefined ? { claimsRequest: payload.claimsRequest } : {},
154
169
  issuedAt: now,
155
170
  expiresAt: now + refreshTtl
156
171
  };
@@ -158,6 +173,28 @@ async function mintTokens(args) {
158
173
  if (isErr(saved))
159
174
  return err(saved.error);
160
175
  }
176
+ let idToken;
177
+ if (payload.authTime !== undefined && shouldIssueIdToken(payload.scopes)) {
178
+ const idClaims = await buildIdTokenClaims({
179
+ issuerUrl: deps.issuerUrl,
180
+ audience: payload.clientId,
181
+ subjectId,
182
+ claim,
183
+ scopes: payload.scopes,
184
+ authTime: payload.authTime,
185
+ ...payload.appNonce !== undefined ? { appNonce: payload.appNonce } : {},
186
+ now,
187
+ methodKind: payload.methodKind,
188
+ accessToken,
189
+ ...payload.claimsRequest !== undefined ? { claimsRequest: payload.claimsRequest } : {},
190
+ ...deps.customScopeClaims !== undefined ? { customScopeClaims: deps.customScopeClaims } : {}
191
+ });
192
+ try {
193
+ idToken = await signIdToken(idClaims, signingKey.privateKeyRef, signingKey.alg, signingKey.kid);
194
+ } catch (e) {
195
+ return err(authError.serverError("id_token sign failed", e));
196
+ }
197
+ }
161
198
  await safeAudit(deps, {
162
199
  kind: "token_issued",
163
200
  tenantId: payload.tenantId,
@@ -166,23 +203,27 @@ async function mintTokens(args) {
166
203
  methodKind: payload.methodKind,
167
204
  subjectId,
168
205
  refreshTokenIdHash: refresh ? await hashTokenForAudit(refresh) : "",
206
+ ...idToken !== undefined ? { idTokenIssued: true } : {},
207
+ ...payload.dpopJkt !== undefined ? { dpopBound: true } : {},
169
208
  timestamp: now
170
209
  });
171
210
  return ok({
172
211
  access_token: accessToken,
173
- token_type: "Bearer",
212
+ token_type: payload.dpopJkt !== undefined ? "DPoP" : "Bearer",
174
213
  expires_in: Math.floor(accessTtl / 1000),
175
214
  ...refresh !== undefined ? { refresh_token: refresh } : {},
215
+ ...idToken !== undefined ? { id_token: idToken } : {},
176
216
  scope: payload.scopes.join(" ")
177
217
  });
178
218
  }
179
219
  async function hashClientSecret(plain) {
180
220
  return base64url.encode(await sha256(utf8.encode(plain)));
181
221
  }
182
- async function deriveSubjectId(claim) {
222
+ async function deriveSubjectId(claim, sectorIdentifier) {
183
223
  const c = claim;
184
224
  const ordered = canonicalize(c.properties);
185
- return base64url.encode(await sha256(`${c.type}\x00${ordered}`)).slice(0, 22);
225
+ const seed = sectorIdentifier !== undefined ? `${sectorIdentifier}\x00${c.type}\x00${ordered}` : `${c.type}\x00${ordered}`;
226
+ return base64url.encode(await sha256(seed)).slice(0, 22);
186
227
  }
187
228
  function canonicalize(value) {
188
229
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -1,25 +1,60 @@
1
1
  // src/domain/userinfo.ts
2
2
  import { authError } from "../types/error";
3
3
  import { err, isErr, ok } from "../types/result";
4
+ import { computeAth, verifyDpopProof } from "./dpop";
5
+ import { pickScopedClaims } from "./id-token";
4
6
  import { verifyAccessToken } from "./jwt";
5
- async function userinfo(bearerToken, deps) {
7
+ async function userinfo(input, deps) {
6
8
  const keysRes = await deps.keyStore.signingKeys();
7
9
  if (isErr(keysRes))
8
10
  return err(keysRes.error);
9
11
  let claims;
10
12
  try {
11
- claims = await verifyAccessToken(bearerToken, keysRes.value, {
13
+ claims = await verifyAccessToken(input.accessToken, keysRes.value, {
12
14
  ...deps.issuerUrl ? { issuer: deps.issuerUrl } : {}
13
15
  });
14
16
  } catch {
15
17
  return err(authError.invalidGrant("access token invalid or expired"));
16
18
  }
19
+ const boundJkt = claims.cnf?.jkt;
20
+ if (boundJkt !== undefined) {
21
+ if (input.scheme !== "DPoP") {
22
+ return err(authError.invalidDpopProof('access token is DPoP-bound; Authorization scheme must be "DPoP"'));
23
+ }
24
+ if (!input.dpopProof) {
25
+ return err(authError.invalidDpopProof("access token is DPoP-bound; request is missing a DPoP proof"));
26
+ }
27
+ if (!input.htu || !input.htm || input.nowSec === undefined) {
28
+ return err(authError.invalidDpopProof("userinfo dpop verification requires htu, htm, and nowSec"));
29
+ }
30
+ if (!deps.tokenStore) {
31
+ return err(authError.invalidDpopProof("userinfo dpop verification requires a token-store (jti replay protection)"));
32
+ }
33
+ const expectedAth = await computeAth(input.accessToken);
34
+ const dpopRes = await verifyDpopProof({
35
+ proofJwt: input.dpopProof,
36
+ htu: input.htu,
37
+ htm: input.htm,
38
+ nowSec: input.nowSec,
39
+ expectedAth
40
+ }, { tokenStore: deps.tokenStore });
41
+ if (isErr(dpopRes))
42
+ return err(dpopRes.error);
43
+ if (dpopRes.value.jkt !== boundJkt) {
44
+ return err(authError.invalidDpopProof("dpop proof key does not match access token cnf.jkt"));
45
+ }
46
+ } else if (input.scheme === "DPoP") {
47
+ return err(authError.invalidGrant("access token is not DPoP-bound; use Authorization: Bearer"));
48
+ }
17
49
  const claim = claims.claim;
50
+ const scopes = claims.scope ? claims.scope.split(" ").filter(Boolean) : [];
51
+ const scopedClaims = pickScopedClaims(claim, scopes, claims.uic ?? [], deps.customScopeClaims ?? {});
18
52
  return ok({
19
53
  sub: claims.sub,
20
54
  subject_type: claim.type,
21
55
  properties: claim.properties,
22
- scope: claims.scope
56
+ scope: claims.scope,
57
+ ...scopedClaims
23
58
  });
24
59
  }
25
60
  export {
@@ -10,7 +10,10 @@ import {
10
10
  authorizeRedirectErrorResponse,
11
11
  isNonRecoverable
12
12
  } from "../errors";
13
- import { authorizeQuerySchema } from "../schemas/authorize";
13
+ import {
14
+ authorizeQuerySchema,
15
+ authorizeRequestUriQuerySchema
16
+ } from "../schemas/authorize";
14
17
  function makeAuthorizeHandler(deps) {
15
18
  return async (c) => {
16
19
  const tenant = c.get("tenant");
@@ -19,13 +22,37 @@ function makeAuthorizeHandler(deps) {
19
22
  }
20
23
  const url = new URL(c.req.url);
21
24
  const raw = Object.fromEntries(url.searchParams.entries());
22
- const parsed = authorizeQuerySchema.safeParse(raw);
25
+ let workingRaw = raw;
26
+ if (raw.request_uri !== undefined) {
27
+ const parParsed = authorizeRequestUriQuerySchema.safeParse(raw);
28
+ if (!parParsed.success) {
29
+ return authorizeDirectErrorResponse(authError.invalidRequest(parParsed.error.issues[0]?.message ?? "invalid request", parParsed.error.issues[0]?.path[0]?.toString() ?? "request"));
30
+ }
31
+ if (!deps.sessionStore.consumePar) {
32
+ return authorizeDirectErrorResponse(authError.invalidRequest("session adapter does not support PAR", "request_uri"));
33
+ }
34
+ const par = await deps.sessionStore.consumePar(parParsed.data.request_uri);
35
+ if (isErr(par)) {
36
+ return authorizeDirectErrorResponse(authError.invalidRequest("unknown or expired request_uri", "request_uri"));
37
+ }
38
+ if (par.value.clientId !== parParsed.data.client_id) {
39
+ return authorizeDirectErrorResponse(authError.invalidRequest("request_uri belongs to a different client", "request_uri"));
40
+ }
41
+ workingRaw = par.value.params;
42
+ }
43
+ const parsed = authorizeQuerySchema.safeParse(workingRaw);
23
44
  if (!parsed.success) {
24
45
  const first = parsed.error.issues[0];
25
46
  const field = first?.path[0]?.toString() ?? "request";
26
47
  return authorizeDirectErrorResponse(authError.invalidRequest(first?.message ?? "invalid request", field));
27
48
  }
28
49
  const q = parsed.data;
50
+ if (raw.request_uri === undefined) {
51
+ const client = tenant.config.clients.find((c2) => c2.id === q.client_id);
52
+ if (client?.requirePushedAuthorizationRequests) {
53
+ return authorizeDirectErrorResponse(authError.invalidRequest("this client requires Pushed Authorization Requests (RFC 9126)", "request_uri"));
54
+ }
55
+ }
29
56
  if (q.response_type !== "code") {
30
57
  return authorizeDirectErrorResponse({
31
58
  code: "invalid_request",
@@ -33,6 +60,14 @@ function makeAuthorizeHandler(deps) {
33
60
  field: "response_type"
34
61
  });
35
62
  }
63
+ let claimsRequest;
64
+ if (q.claims !== undefined) {
65
+ const parsedClaims = parseClaimsParameter(q.claims);
66
+ if (!parsedClaims.ok) {
67
+ return authorizeDirectErrorResponse(authError.invalidRequest(parsedClaims.error, "claims"));
68
+ }
69
+ claimsRequest = parsedClaims.value;
70
+ }
36
71
  const request = {
37
72
  tenantId: asTenantId(tenant.id),
38
73
  clientId: q.client_id,
@@ -46,7 +81,8 @@ function makeAuthorizeHandler(deps) {
46
81
  ...q.code_challenge_method !== undefined ? { codeChallengeMethod: q.code_challenge_method } : {},
47
82
  ...q.prompt !== undefined ? { prompt: q.prompt } : {},
48
83
  ...q.ui_locales !== undefined ? { uiLocales: q.ui_locales } : {},
49
- ...q.nonce !== undefined ? { nonce: q.nonce } : {}
84
+ ...q.nonce !== undefined ? { nonce: q.nonce } : {},
85
+ ...claimsRequest !== undefined ? { claimsRequest } : {}
50
86
  };
51
87
  const result = await startAuthorize({
52
88
  request,
@@ -132,6 +168,57 @@ function makeAuthorizeHandler(deps) {
132
168
  }
133
169
  };
134
170
  }
171
+ function parseClaimsParameter(raw) {
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(raw);
175
+ } catch (e) {
176
+ return {
177
+ ok: false,
178
+ error: `claims parameter is not valid JSON: ${e instanceof Error ? e.message : String(e)}`
179
+ };
180
+ }
181
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
182
+ return { ok: false, error: "claims parameter must be a JSON object" };
183
+ }
184
+ const obj = parsed;
185
+ const result = {};
186
+ for (const section of ["userinfo", "id_token"]) {
187
+ const v = obj[section];
188
+ if (v === undefined)
189
+ continue;
190
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
191
+ return {
192
+ ok: false,
193
+ error: `claims.${section} must be a JSON object`
194
+ };
195
+ }
196
+ const sectionMap = {};
197
+ for (const [name, entry] of Object.entries(v)) {
198
+ if (entry === null) {
199
+ sectionMap[name] = null;
200
+ continue;
201
+ }
202
+ if (typeof entry !== "object" || Array.isArray(entry)) {
203
+ return {
204
+ ok: false,
205
+ error: `claims.${section}.${name} must be null or a JSON object`
206
+ };
207
+ }
208
+ const e = entry;
209
+ const normalized = {};
210
+ if (typeof e.essential === "boolean")
211
+ normalized.essential = e.essential;
212
+ if ("value" in e)
213
+ normalized.value = e.value;
214
+ if (Array.isArray(e.values))
215
+ normalized.values = e.values;
216
+ sectionMap[name] = normalized;
217
+ }
218
+ result[section] = sectionMap;
219
+ }
220
+ return { ok: true, value: result };
221
+ }
135
222
  function clearFlowCookie() {
136
223
  return {
137
224
  name: "idp.flow",
@@ -0,0 +1,64 @@
1
+ // src/http/handlers/end-session.ts
2
+ import { endSession } from "../../domain/logout";
3
+ import { authError } from "../../types/error";
4
+ import { isErr } from "../../types/result";
5
+ import { applyResponsePolicy } from "../cookies";
6
+ import { tokenEndpointErrorResponse } from "../errors";
7
+ import { endSessionParamsSchema } from "../schemas/end-session";
8
+ function makeEndSessionHandler(deps) {
9
+ return async (c) => {
10
+ const tenant = c.get("tenant");
11
+ if (!tenant) {
12
+ return tokenEndpointErrorResponse(authError.invalidRequest("tenant unresolved"));
13
+ }
14
+ let raw;
15
+ const httpMethod = c.req.method.toUpperCase();
16
+ if (httpMethod === "GET") {
17
+ raw = Object.fromEntries(new URL(c.req.url).searchParams.entries());
18
+ } else if (httpMethod === "POST") {
19
+ const ct = (c.req.header("content-type") ?? "").toLowerCase();
20
+ if (!ct.startsWith("application/x-www-form-urlencoded")) {
21
+ return tokenEndpointErrorResponse(authError.invalidRequest("POST /end_session requires application/x-www-form-urlencoded body"));
22
+ }
23
+ raw = Object.fromEntries(new URLSearchParams(await c.req.raw.text()));
24
+ } else {
25
+ return tokenEndpointErrorResponse(authError.invalidRequest(`unsupported method "${httpMethod}"`));
26
+ }
27
+ const parsed = endSessionParamsSchema.safeParse(raw);
28
+ if (!parsed.success) {
29
+ return tokenEndpointErrorResponse(authError.invalidRequest(parsed.error.issues[0]?.message ?? "invalid request"));
30
+ }
31
+ const q = parsed.data;
32
+ const res = await endSession({
33
+ ...q.id_token_hint !== undefined ? { idTokenHint: q.id_token_hint } : {},
34
+ ...q.client_id !== undefined ? { clientId: q.client_id } : {},
35
+ ...q.post_logout_redirect_uri !== undefined ? { postLogoutRedirectUri: q.post_logout_redirect_uri } : {},
36
+ ...q.state !== undefined ? { state: q.state } : {}
37
+ }, tenant, {
38
+ configStore: deps.configStore,
39
+ tokenStore: deps.tokenStore,
40
+ keyStore: deps.keyStore,
41
+ ...deps.auditLog ? { auditLog: deps.auditLog } : {},
42
+ issuerUrl: c.get("issuerUrl"),
43
+ clock: deps.clock
44
+ });
45
+ if (isErr(res))
46
+ return tokenEndpointErrorResponse(res.error);
47
+ if (res.value.kind === "redirect") {
48
+ return applyResponsePolicy(new Response(null, {
49
+ status: 302,
50
+ headers: { location: res.value.url }
51
+ }), { setCookies: [], cookieDefaults: deps.cookieDefaults });
52
+ }
53
+ return new Response("Logged out.", {
54
+ status: 200,
55
+ headers: {
56
+ "content-type": "text/plain; charset=utf-8",
57
+ "cache-control": "no-store"
58
+ }
59
+ });
60
+ };
61
+ }
62
+ export {
63
+ makeEndSessionHandler
64
+ };
@@ -2,9 +2,12 @@
2
2
  import { buildDiscoveryDocument, buildJwks } from "../../domain/discovery";
3
3
  import { isErr } from "../../types/result";
4
4
  import { tokenEndpointErrorResponse } from "../errors";
5
- function makeDiscoveryHandler(_deps) {
5
+ function makeDiscoveryHandler(deps) {
6
6
  return async (c) => {
7
- const doc = buildDiscoveryDocument({ issuerUrl: c.get("issuerUrl") });
7
+ const doc = buildDiscoveryDocument({
8
+ issuerUrl: c.get("issuerUrl"),
9
+ ...deps.customScopeClaims !== undefined ? { customScopeClaims: deps.customScopeClaims } : {}
10
+ });
8
11
  return new Response(JSON.stringify(doc), {
9
12
  status: 200,
10
13
  headers: {
@@ -0,0 +1,53 @@
1
+ // src/http/handlers/par.ts
2
+ import { resolveClientCredentials } from "../../domain/client-auth";
3
+ import { pushAuthorizationRequest } from "../../domain/par";
4
+ import { authError } from "../../types/error";
5
+ import { isErr } from "../../types/result";
6
+ import { tokenEndpointErrorResponse } from "../errors";
7
+ import { parBodySchema } from "../schemas/par";
8
+ function makeParHandler(deps) {
9
+ return async (c) => {
10
+ const tenant = c.get("tenant");
11
+ if (!tenant) {
12
+ return tokenEndpointErrorResponse(authError.invalidRequest("tenant unresolved"));
13
+ }
14
+ const ct = (c.req.header("content-type") ?? "").toLowerCase();
15
+ if (!ct.startsWith("application/x-www-form-urlencoded")) {
16
+ return tokenEndpointErrorResponse(authError.invalidRequest("body must be application/x-www-form-urlencoded"));
17
+ }
18
+ const raw = Object.fromEntries(new URLSearchParams(await c.req.raw.text()).entries());
19
+ const parsed = parBodySchema.safeParse(raw);
20
+ if (!parsed.success) {
21
+ return tokenEndpointErrorResponse(authError.invalidRequest(parsed.error.issues[0]?.message ?? "invalid request"));
22
+ }
23
+ const creds = resolveClientCredentials({
24
+ authorizationHeader: c.req.header("authorization") ?? null,
25
+ bodyClientId: parsed.data.client_id,
26
+ bodyClientSecret: parsed.data.client_secret
27
+ });
28
+ const clientId = creds?.clientId ?? parsed.data.client_id;
29
+ const { client_secret: _strip, ...paramsForAuthorize } = parsed.data;
30
+ const res = await pushAuthorizationRequest({
31
+ clientId,
32
+ ...creds?.clientSecret !== undefined ? { clientSecret: creds.clientSecret } : {},
33
+ params: paramsForAuthorize
34
+ }, tenant, {
35
+ configStore: deps.configStore,
36
+ sessionStore: deps.sessionStore,
37
+ ...deps.auditLog ? { auditLog: deps.auditLog } : {},
38
+ clock: deps.clock
39
+ });
40
+ if (isErr(res))
41
+ return tokenEndpointErrorResponse(res.error);
42
+ return new Response(JSON.stringify(res.value), {
43
+ status: 201,
44
+ headers: {
45
+ "content-type": "application/json",
46
+ "cache-control": "no-store"
47
+ }
48
+ });
49
+ };
50
+ }
51
+ export {
52
+ makeParHandler
53
+ };
@@ -0,0 +1,46 @@
1
+ // src/http/handlers/register.ts
2
+ import { registerNewClient } from "../../domain/register";
3
+ import { authError } from "../../types/error";
4
+ import { isErr } from "../../types/result";
5
+ import { tokenEndpointErrorResponse } from "../errors";
6
+ import { registerBodySchema } from "../schemas/register";
7
+ function makeRegisterHandler(deps) {
8
+ return async (c) => {
9
+ const tenant = c.get("tenant");
10
+ if (!tenant) {
11
+ return tokenEndpointErrorResponse(authError.invalidRequest("tenant unresolved"));
12
+ }
13
+ const ct = (c.req.header("content-type") ?? "").toLowerCase();
14
+ if (!ct.startsWith("application/json")) {
15
+ return tokenEndpointErrorResponse(authError.invalidRequest("body must be application/json"));
16
+ }
17
+ let raw;
18
+ try {
19
+ raw = await c.req.raw.json();
20
+ } catch (e) {
21
+ const reason = e instanceof Error ? e.message : String(e);
22
+ return tokenEndpointErrorResponse(authError.invalidRequest(`malformed JSON: ${reason}`));
23
+ }
24
+ const parsed = registerBodySchema.safeParse(raw);
25
+ if (!parsed.success) {
26
+ return tokenEndpointErrorResponse(authError.invalidRequest(parsed.error.issues[0]?.message ?? "invalid request"));
27
+ }
28
+ const request = parsed.data;
29
+ const res = await registerNewClient(request, tenant, {
30
+ ...deps.registerClient ? { registerClient: deps.registerClient } : {},
31
+ clock: deps.clock
32
+ });
33
+ if (isErr(res))
34
+ return tokenEndpointErrorResponse(res.error);
35
+ return new Response(JSON.stringify(res.value), {
36
+ status: 201,
37
+ headers: {
38
+ "content-type": "application/json",
39
+ "cache-control": "no-store"
40
+ }
41
+ });
42
+ };
43
+ }
44
+ export {
45
+ makeRegisterHandler
46
+ };