@_mustachio/openauth 0.13.2 → 0.14.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 (88) hide show
  1. package/dist/esm/client.js +61 -62
  2. package/dist/esm/domain/authorize.js +21 -3
  3. package/dist/esm/domain/callback.js +30 -21
  4. package/dist/esm/domain/client-credentials.js +16 -0
  5. package/dist/esm/domain/method-dispatch.js +1 -0
  6. package/dist/esm/domain/method-route.js +12 -0
  7. package/dist/esm/domain/mount.js +29 -0
  8. package/dist/esm/domain/refresh.js +16 -5
  9. package/dist/esm/domain/register.js +8 -2
  10. package/dist/esm/domain/state-envelope.js +19 -0
  11. package/dist/esm/domain/subject.js +35 -0
  12. package/dist/esm/domain/token.js +15 -0
  13. package/dist/esm/http/handlers/method-route.js +8 -3
  14. package/dist/esm/http/handlers/token.js +2 -0
  15. package/dist/esm/http/middleware/tenant.js +5 -20
  16. package/dist/esm/index.js +4 -0
  17. package/dist/esm/methods/code.js +9 -8
  18. package/dist/esm/methods/oauth2-generic.js +2 -1
  19. package/dist/esm/methods/passkey.js +3 -2
  20. package/dist/esm/methods/password.js +9 -8
  21. package/dist/esm/methods/saml-sp/metadata.js +2 -1
  22. package/dist/types/client.d.ts +61 -40
  23. package/dist/types/client.d.ts.map +1 -1
  24. package/dist/types/domain/authorize.d.ts.map +1 -1
  25. package/dist/types/domain/callback.d.ts +6 -5
  26. package/dist/types/domain/callback.d.ts.map +1 -1
  27. package/dist/types/domain/client-credentials.d.ts +3 -1
  28. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  29. package/dist/types/domain/method-dispatch.d.ts +5 -0
  30. package/dist/types/domain/method-dispatch.d.ts.map +1 -1
  31. package/dist/types/domain/method-route.d.ts +6 -0
  32. package/dist/types/domain/method-route.d.ts.map +1 -1
  33. package/dist/types/domain/mount.d.ts +90 -0
  34. package/dist/types/domain/mount.d.ts.map +1 -0
  35. package/dist/types/domain/refresh.d.ts.map +1 -1
  36. package/dist/types/domain/register.d.ts.map +1 -1
  37. package/dist/types/domain/state-envelope.d.ts +22 -0
  38. package/dist/types/domain/state-envelope.d.ts.map +1 -1
  39. package/dist/types/domain/subject.d.ts +48 -0
  40. package/dist/types/domain/subject.d.ts.map +1 -0
  41. package/dist/types/domain/token.d.ts +7 -1
  42. package/dist/types/domain/token.d.ts.map +1 -1
  43. package/dist/types/http/context.d.ts +3 -0
  44. package/dist/types/http/context.d.ts.map +1 -1
  45. package/dist/types/http/handlers/method-route.d.ts.map +1 -1
  46. package/dist/types/http/handlers/token.d.ts.map +1 -1
  47. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  48. package/dist/types/http/schemas/revocation.d.ts +4 -4
  49. package/dist/types/http/schemas/token.d.ts +12 -12
  50. package/dist/types/index.d.ts +17 -1
  51. package/dist/types/index.d.ts.map +1 -1
  52. package/dist/types/methods/code.d.ts.map +1 -1
  53. package/dist/types/methods/oauth2-generic.d.ts.map +1 -1
  54. package/dist/types/methods/passkey.d.ts.map +1 -1
  55. package/dist/types/methods/password.d.ts.map +1 -1
  56. package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
  57. package/dist/types/ports/audit-log.d.ts +25 -1
  58. package/dist/types/ports/audit-log.d.ts.map +1 -1
  59. package/dist/types/types/idp.d.ts +49 -39
  60. package/dist/types/types/idp.d.ts.map +1 -1
  61. package/dist/types/types/method.d.ts +11 -0
  62. package/dist/types/types/method.d.ts.map +1 -1
  63. package/package.json +2 -1
  64. package/src/client.ts +145 -129
  65. package/src/domain/authorize.ts +25 -4
  66. package/src/domain/callback.ts +40 -36
  67. package/src/domain/client-credentials.ts +20 -1
  68. package/src/domain/method-dispatch.ts +6 -0
  69. package/src/domain/method-route.ts +18 -0
  70. package/src/domain/mount.ts +113 -0
  71. package/src/domain/refresh.ts +39 -10
  72. package/src/domain/register.ts +27 -8
  73. package/src/domain/state-envelope.ts +40 -0
  74. package/src/domain/subject.ts +103 -0
  75. package/src/domain/token.ts +25 -1
  76. package/src/http/context.ts +3 -0
  77. package/src/http/handlers/method-route.ts +9 -5
  78. package/src/http/handlers/token.ts +2 -0
  79. package/src/http/middleware/tenant.ts +5 -24
  80. package/src/index.ts +18 -2
  81. package/src/methods/code.ts +15 -6
  82. package/src/methods/oauth2-generic.ts +6 -1
  83. package/src/methods/passkey.ts +6 -2
  84. package/src/methods/password.ts +19 -4
  85. package/src/methods/saml-sp/metadata.ts +5 -1
  86. package/src/ports/audit-log.ts +26 -1
  87. package/src/types/idp.ts +49 -42
  88. package/src/types/method.ts +11 -0
@@ -37,59 +37,59 @@ function createClient(input) {
37
37
  jwksCache.set(issuer, result);
38
38
  return result;
39
39
  }
40
+ function applyClientAuth(headers, body) {
41
+ const secret = input.clientSecret;
42
+ if (secret === undefined)
43
+ return;
44
+ if ((input.tokenEndpointAuthMethod ?? "client_secret_basic") === "client_secret_post") {
45
+ body.set("client_secret", secret);
46
+ return;
47
+ }
48
+ const cred = `${encodeURIComponent(input.clientID)}:${encodeURIComponent(secret)}`;
49
+ headers["authorization"] = `Basic ${btoa(cred)}`;
50
+ }
40
51
  const result = {
41
- async authorize(redirectURI, response, opts) {
42
- const result = new URL(issuer + "/authorize");
52
+ async authorize(redirectURI, opts) {
53
+ const wk = await getIssuer();
54
+ const url = new URL(wk.authorization_endpoint);
55
+ const pkce = await generatePKCE();
43
56
  const challenge = {
44
- state: crypto.randomUUID()
57
+ state: crypto.randomUUID(),
58
+ verifier: pkce.verifier
45
59
  };
46
- result.searchParams.set("client_id", input.clientID);
47
- result.searchParams.set("redirect_uri", redirectURI);
48
- result.searchParams.set("response_type", response);
49
- result.searchParams.set("state", challenge.state);
60
+ url.searchParams.set("client_id", input.clientID);
61
+ url.searchParams.set("redirect_uri", redirectURI);
62
+ url.searchParams.set("response_type", "code");
63
+ url.searchParams.set("state", challenge.state);
64
+ url.searchParams.set("code_challenge_method", "S256");
65
+ url.searchParams.set("code_challenge", pkce.challenge);
50
66
  if (opts?.provider)
51
- result.searchParams.set("provider", opts.provider);
67
+ url.searchParams.set("provider", opts.provider);
52
68
  if (opts?.scope !== undefined) {
53
69
  const scope = Array.isArray(opts.scope) ? opts.scope.join(" ") : opts.scope;
54
70
  if (scope)
55
- result.searchParams.set("scope", scope);
56
- }
57
- if (opts?.pkce && response === "code") {
58
- const pkce = await generatePKCE();
59
- result.searchParams.set("code_challenge_method", "S256");
60
- result.searchParams.set("code_challenge", pkce.challenge);
61
- challenge.verifier = pkce.verifier;
71
+ url.searchParams.set("scope", scope);
62
72
  }
63
- return {
64
- challenge,
65
- url: result.toString()
66
- };
67
- },
68
- async pkce(redirectURI, opts) {
69
- const result = new URL(issuer + "/authorize");
70
- if (opts?.provider)
71
- result.searchParams.set("provider", opts.provider);
72
- result.searchParams.set("client_id", input.clientID);
73
- result.searchParams.set("redirect_uri", redirectURI);
74
- result.searchParams.set("response_type", "code");
75
- const pkce = await generatePKCE();
76
- result.searchParams.set("code_challenge_method", "S256");
77
- result.searchParams.set("code_challenge", pkce.challenge);
78
- return [pkce.verifier, result.toString()];
73
+ return { challenge, url: url.toString() };
79
74
  },
80
75
  async exchange(code, redirectURI, verifier) {
81
- const tokens = await f(issuer + "/token", {
76
+ const wk = await getIssuer();
77
+ const body = new URLSearchParams({
78
+ code,
79
+ redirect_uri: redirectURI,
80
+ grant_type: "authorization_code",
81
+ client_id: input.clientID
82
+ });
83
+ if (verifier)
84
+ body.set("code_verifier", verifier);
85
+ const headers = {
86
+ "Content-Type": "application/x-www-form-urlencoded"
87
+ };
88
+ applyClientAuth(headers, body);
89
+ const tokens = await f(wk.token_endpoint, {
82
90
  method: "POST",
83
- headers: {
84
- "Content-Type": "application/x-www-form-urlencoded"
85
- },
86
- body: new URLSearchParams({
87
- code,
88
- redirect_uri: redirectURI,
89
- grant_type: "authorization_code",
90
- client_id: input.clientID,
91
- code_verifier: verifier || ""
92
- }).toString()
91
+ headers,
92
+ body: body.toString()
93
93
  });
94
94
  const json = await tokens.json();
95
95
  if (!tokens.ok) {
@@ -121,15 +121,20 @@ function createClient(input) {
121
121
  };
122
122
  }
123
123
  }
124
- const tokens = await f(issuer + "/token", {
124
+ const wk = await getIssuer();
125
+ const body = new URLSearchParams({
126
+ grant_type: "refresh_token",
127
+ refresh_token: refresh,
128
+ client_id: input.clientID
129
+ });
130
+ const headers = {
131
+ "Content-Type": "application/x-www-form-urlencoded"
132
+ };
133
+ applyClientAuth(headers, body);
134
+ const tokens = await f(wk.token_endpoint, {
125
135
  method: "POST",
126
- headers: {
127
- "Content-Type": "application/x-www-form-urlencoded"
128
- },
129
- body: new URLSearchParams({
130
- grant_type: "refresh_token",
131
- refresh_token: refresh
132
- }).toString()
136
+ headers,
137
+ body: body.toString()
133
138
  });
134
139
  const json = await tokens.json();
135
140
  if (!tokens.ok) {
@@ -151,30 +156,24 @@ function createClient(input) {
151
156
  const jwks = await getJWKS();
152
157
  try {
153
158
  const result = await jwtVerify(token, jwks, {
154
- issuer
159
+ issuer,
160
+ audience: options?.audience ?? input.clientID
155
161
  });
156
162
  const claim = result.payload.claim;
157
- let subjectType;
158
- let subjectProperties;
159
- if (claim && typeof claim.type === "string") {
160
- subjectType = claim.type;
161
- subjectProperties = claim.properties;
162
- } else if (result.payload.mode === "access" && typeof result.payload.type === "string") {
163
- subjectType = result.payload.type;
164
- subjectProperties = result.payload.properties;
165
- }
166
- if (subjectType === undefined) {
163
+ if (!claim || typeof claim.type !== "string") {
167
164
  return { err: new InvalidSubjectError };
168
165
  }
166
+ const subjectType = claim.type;
169
167
  const schema = subjects[subjectType];
170
168
  if (!schema) {
171
169
  return { err: new InvalidSubjectError };
172
170
  }
173
- const validated = await schema["~standard"].validate(subjectProperties);
171
+ const validated = await schema["~standard"].validate(claim.properties);
174
172
  if (validated.issues) {
175
173
  return { err: new InvalidSubjectError };
176
174
  }
177
175
  return {
176
+ err: false,
178
177
  aud: result.payload.aud,
179
178
  subject: {
180
179
  type: subjectType,
@@ -4,6 +4,7 @@ import { err, isErr, ok } from "../types/result";
4
4
  import { safeAudit } from "./audit";
5
5
  import { randomId, randomToken } from "./crypto";
6
6
  import { dispatchMethod } from "./method-dispatch";
7
+ import { callbackTarget } from "./mount";
7
8
  import { mintStateEnvelope } from "./state-envelope";
8
9
  import { saveEncryptedCode } from "./token";
9
10
  var DEFAULT_FLOW_TTL_MS = 10 * 60 * 1000;
@@ -54,9 +55,15 @@ async function startAuthorize(input, deps) {
54
55
  const nonce = (deps.newNonce ?? randomToken)();
55
56
  const flowTtl = deps.flowTtlMs ?? DEFAULT_FLOW_TTL_MS;
56
57
  const now = deps.clock();
57
- const callbackHost = deps.callbackHostFor?.(tenant.id) ?? new URL(deps.issuerUrl).host;
58
- const callbackPath = `/cb/${methodId}`;
59
- const callbackUrl = `${new URL(deps.issuerUrl).protocol}//${callbackHost}${callbackPath}`;
58
+ const {
59
+ host: callbackHost,
60
+ path: callbackPath,
61
+ url: callbackUrl
62
+ } = callbackTarget({
63
+ issuerUrl: deps.issuerUrl,
64
+ methodId,
65
+ callbackHost: deps.callbackHostFor?.(tenant.id)
66
+ });
60
67
  const record = {
61
68
  flowId,
62
69
  tenantId: tenant.id,
@@ -103,6 +110,7 @@ async function startAuthorize(input, deps) {
103
110
  flow: record,
104
111
  cookies: input.cookies,
105
112
  sessionStore: deps.sessionStore,
113
+ issuerUrl: deps.issuerUrl,
106
114
  dispatch: {
107
115
  state: stateEnvelope,
108
116
  callbackUrl,
@@ -203,6 +211,16 @@ async function issueCodeFromInlineSuccess(result, record, deps) {
203
211
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
204
212
  if (isErr(saved))
205
213
  return err(saved.error);
214
+ await safeAudit(deps, {
215
+ kind: "authorize_succeeded",
216
+ tenantId: flow.tenantId,
217
+ clientId: flow.clientId,
218
+ methodId: flow.methodId,
219
+ methodKind: flow.methodKind,
220
+ flowId: flow.flowId,
221
+ providerSubject: result.providerSubject,
222
+ timestamp: now
223
+ });
206
224
  return ok({
207
225
  kind: "issue-code",
208
226
  code,
@@ -4,27 +4,10 @@ import { err, isErr, ok } from "../types/result";
4
4
  import { safeAudit } from "./audit";
5
5
  import { randomToken } from "./crypto";
6
6
  import { dispatchMethod } from "./method-dispatch";
7
+ import { callbackTarget } from "./mount";
7
8
  import { saveEncryptedCode } from "./token";
8
- import { verifyStateEnvelope } from "./state-envelope";
9
+ import { extractCallbackState, verifyStateEnvelope } from "./state-envelope";
9
10
  import { AUTH_CODE_TTL_MS } from "./authorize";
10
- async function extractCallbackState(req) {
11
- const fromQuery = new URL(req.url).searchParams.get("state");
12
- if (fromQuery)
13
- return fromQuery;
14
- if (req.method !== "POST")
15
- return null;
16
- const ct = req.headers.get("content-type") ?? "";
17
- if (!ct.includes("application/x-www-form-urlencoded"))
18
- return null;
19
- try {
20
- const body = await req.clone().text();
21
- const form = new URLSearchParams(body);
22
- const v = form.get("state") ?? form.get("RelayState");
23
- return v && v.length > 0 ? v : null;
24
- } catch {
25
- return null;
26
- }
27
- }
28
11
  async function handleCallback(input, deps) {
29
12
  const url = new URL(input.rawRequest.url);
30
13
  const state = await extractCallbackState(input.rawRequest);
@@ -114,6 +97,7 @@ async function handleCallback(input, deps) {
114
97
  flow,
115
98
  cookies: input.cookies,
116
99
  sessionStore: deps.sessionStore,
100
+ issuerUrl: input.issuerUrl,
117
101
  dispatch: null
118
102
  });
119
103
  if (isErr(dispatched))
@@ -145,6 +129,16 @@ async function translate(result, flow, deps) {
145
129
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
146
130
  if (isErr(saved))
147
131
  return err(saved.error);
132
+ await safeAudit(deps, {
133
+ kind: "authorize_succeeded",
134
+ tenantId: flow.tenantId,
135
+ clientId: flow.clientId,
136
+ methodId: flow.methodId,
137
+ methodKind: flow.methodKind,
138
+ flowId: flow.flowId,
139
+ providerSubject: result.providerSubject,
140
+ timestamp: now
141
+ });
148
142
  return ok({
149
143
  kind: "issue-code",
150
144
  code,
@@ -192,7 +186,7 @@ async function translate(result, flow, deps) {
192
186
  async function tryIdpInitiated(input, deps, url) {
193
187
  if (input.rawRequest.method !== "POST")
194
188
  return null;
195
- if (!input.tenant || !input.issuerUrl)
189
+ if (!input.tenant)
196
190
  return null;
197
191
  const segments = url.pathname.split("/").filter(Boolean);
198
192
  if (segments.length < 2 || segments[0] !== "cb")
@@ -204,7 +198,11 @@ async function tryIdpInitiated(input, deps, url) {
204
198
  const method = methodRes.value;
205
199
  if (method.unsolicitedCallback !== true)
206
200
  return null;
207
- const callbackUrl = `${url.protocol}//${url.host}/cb/${methodId}`;
201
+ const { url: callbackUrl } = callbackTarget({
202
+ issuerUrl: input.issuerUrl,
203
+ methodId,
204
+ callbackHost: url.host
205
+ });
208
206
  const dispatched = await dispatchMethod({
209
207
  method,
210
208
  route: "GET /callback",
@@ -214,6 +212,7 @@ async function tryIdpInitiated(input, deps, url) {
214
212
  flow: null,
215
213
  cookies: input.cookies,
216
214
  sessionStore: deps.sessionStore,
215
+ issuerUrl: input.issuerUrl,
217
216
  dispatch: { state: "", callbackUrl, issuerUrl: input.issuerUrl }
218
217
  });
219
218
  if (isErr(dispatched))
@@ -275,6 +274,16 @@ async function tryIdpInitiated(input, deps, url) {
275
274
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
276
275
  if (isErr(saved))
277
276
  return err(saved.error);
277
+ await safeAudit(deps, {
278
+ kind: "authorize_succeeded",
279
+ tenantId: input.tenant.id,
280
+ clientId: binding.clientId,
281
+ methodId,
282
+ methodKind: method.kind,
283
+ flowId: "",
284
+ providerSubject: result.providerSubject,
285
+ timestamp: now
286
+ });
278
287
  return ok({
279
288
  kind: "issue-code",
280
289
  code,
@@ -4,6 +4,8 @@ import { err, isErr, ok } from "../types/result";
4
4
  import { verifyClientCredentials } from "./client-auth";
5
5
  import { randomId } from "./crypto";
6
6
  import { mintTokens } from "./token";
7
+ import { safeAudit } from "./audit";
8
+ import { validateSubjectClaim } from "./subject";
7
9
  async function clientCredentialsGrant(req, deps, tenantId) {
8
10
  const tenantCfg = await deps.configStore.getTenantConfig(tenantId);
9
11
  if (isErr(tenantCfg))
@@ -67,6 +69,20 @@ async function clientCredentialsGrant(req, deps, tenantId) {
67
69
  } catch (e) {
68
70
  return err(authError.serverError("success callback threw", e));
69
71
  }
72
+ const checked = await validateSubjectClaim(deps.subjects, claim);
73
+ if (isErr(checked)) {
74
+ await safeAudit(deps, {
75
+ kind: "invalid_subject_claim",
76
+ tenantId: tenant.id,
77
+ clientId: client.id,
78
+ subjectType: checked.error.rejection.subjectType,
79
+ reason: checked.error.rejection.reason,
80
+ detail: checked.error.rejection.detail,
81
+ timestamp: deps.clock()
82
+ });
83
+ return err(checked.error);
84
+ }
85
+ claim = checked.value;
70
86
  const minted = await mintTokens({
71
87
  tenant,
72
88
  claim,
@@ -8,6 +8,7 @@ async function dispatchMethod(input) {
8
8
  }
9
9
  const ctx = {
10
10
  request: input.request,
11
+ issuerUrl: input.issuerUrl,
11
12
  subPath: input.subPath,
12
13
  tenant: input.tenant,
13
14
  flow: input.flow,
@@ -38,6 +38,7 @@ async function handleMethodRoute(input, deps) {
38
38
  flow,
39
39
  cookies: input.cookies,
40
40
  sessionStore: deps.sessionStore,
41
+ issuerUrl: input.issuerUrl,
41
42
  dispatch: null
42
43
  });
43
44
  if (isErr(dispatched))
@@ -73,6 +74,16 @@ async function translate(result, flow, deps) {
73
74
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
74
75
  if (isErr(saved))
75
76
  return err(saved.error);
77
+ await safeAudit(deps, {
78
+ kind: "authorize_succeeded",
79
+ tenantId: final.tenantId,
80
+ clientId: final.clientId,
81
+ methodId: final.methodId,
82
+ methodKind: final.methodKind,
83
+ flowId: final.flowId,
84
+ providerSubject: result.providerSubject,
85
+ timestamp: now
86
+ });
76
87
  return ok({
77
88
  kind: "issue-code",
78
89
  code,
@@ -120,6 +131,7 @@ async function handlePublicMethodRoute(input, deps) {
120
131
  flow: null,
121
132
  cookies: new Map,
122
133
  sessionStore: deps.sessionStore,
134
+ issuerUrl: input.dispatch.issuerUrl,
123
135
  dispatch: input.dispatch
124
136
  });
125
137
  if (isErr(dispatched))
@@ -0,0 +1,29 @@
1
+ // src/domain/mount.ts
2
+ function mountPath(issuerUrl) {
3
+ let pathname;
4
+ try {
5
+ pathname = new URL(issuerUrl).pathname;
6
+ } catch {
7
+ return "";
8
+ }
9
+ const segments = pathname.split("/").filter(Boolean);
10
+ return segments.length === 0 ? "" : `/${segments.join("/")}`;
11
+ }
12
+ function mountedPath(issuerUrl, path) {
13
+ return `${mountPath(issuerUrl)}${path}`;
14
+ }
15
+ function callbackTarget(input) {
16
+ const issuer = new URL(input.issuerUrl);
17
+ const host = input.callbackHost ?? issuer.host;
18
+ const path = `/cb/${input.methodId}`;
19
+ return {
20
+ host,
21
+ path,
22
+ url: `${issuer.protocol}//${host}${mountPath(input.issuerUrl)}${path}`
23
+ };
24
+ }
25
+ export {
26
+ callbackTarget,
27
+ mountPath,
28
+ mountedPath
29
+ };
@@ -4,6 +4,16 @@ import { err, isErr } from "../types/result";
4
4
  import { safeAudit } from "./audit";
5
5
  import { verifyClientCredentials } from "./client-auth";
6
6
  import { mintTokens } from "./token";
7
+ function validateRequestedScopes(requested, granted) {
8
+ if (!requested)
9
+ return null;
10
+ for (const s of requested.split(" ").filter(Boolean)) {
11
+ if (!granted.includes(s)) {
12
+ return authError.invalidScope(`requested scope "${s}" not granted by original refresh token`);
13
+ }
14
+ }
15
+ return null;
16
+ }
7
17
  async function refreshTokens(req, deps) {
8
18
  const peek = await deps.tokenStore.peekRefresh(req.refreshToken);
9
19
  if (isErr(peek))
@@ -39,6 +49,9 @@ async function refreshTokens(req, deps) {
39
49
  return err(authError.invalidDpopProof("refresh token DPoP jkt does not match the original binding"));
40
50
  }
41
51
  }
52
+ const scopeErr = validateRequestedScopes(req.scope, peekedPayload.scopes);
53
+ if (scopeErr)
54
+ return err(scopeErr);
42
55
  const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
43
56
  reuseWindowMs: deps.reuseWindowMs
44
57
  });
@@ -56,12 +69,10 @@ async function refreshTokens(req, deps) {
56
69
  return err(consumed.error);
57
70
  }
58
71
  const payload = consumed.value;
72
+ const authoritativeScopeErr = validateRequestedScopes(req.scope, payload.scopes);
73
+ if (authoritativeScopeErr)
74
+ return err(authoritativeScopeErr);
59
75
  const requestedScopes = req.scope ? req.scope.split(" ").filter(Boolean) : payload.scopes;
60
- for (const s of requestedScopes) {
61
- if (!payload.scopes.includes(s)) {
62
- return err(authError.invalidScope(`requested scope "${s}" not granted by original refresh token`));
63
- }
64
- }
65
76
  const tenant = {
66
77
  id: payload.tenantId,
67
78
  config: tenantCfg.value,
@@ -50,12 +50,18 @@ async function registerNewClient(request, tenant, deps) {
50
50
  };
51
51
  const hookResult = await deps.registerClient({
52
52
  tenant,
53
- request
53
+ request,
54
+ client: clientConfig,
55
+ ...secret !== undefined ? { secret } : {}
54
56
  });
55
57
  if (isErr(hookResult))
56
58
  return err(hookResult.error);
57
59
  const persisted = hookResult.value.client;
58
- const persistedSecret = hookResult.value.secret;
60
+ const keptOurSecret = persisted.type === "confidential" && clientConfig.type === "confidential" && persisted.secretHash === clientConfig.secretHash;
61
+ const persistedSecret = hookResult.value.secret ?? (keptOurSecret ? secret : undefined);
62
+ if (persisted.type === "confidential" && persistedSecret === undefined) {
63
+ return err(authError.serverError("registerClient persisted a confidential client with a substituted " + "secretHash but returned no matching plaintext secret"));
64
+ }
59
65
  const issuedAt = Math.floor(deps.clock() / 1000);
60
66
  return ok({
61
67
  client_id: persisted.id,
@@ -74,7 +74,26 @@ function isEnvelopeShape(value) {
74
74
  const v = value;
75
75
  return typeof v.tenantId === "string" && typeof v.flowId === "string" && typeof v.nonce === "string" && typeof v.kid === "string";
76
76
  }
77
+ async function extractCallbackState(req) {
78
+ const fromQuery = new URL(req.url).searchParams.get("state");
79
+ if (fromQuery)
80
+ return fromQuery;
81
+ if (req.method !== "POST")
82
+ return null;
83
+ const ct = req.headers.get("content-type") ?? "";
84
+ if (!ct.toLowerCase().includes("application/x-www-form-urlencoded")) {
85
+ return null;
86
+ }
87
+ try {
88
+ const form = new URLSearchParams(await req.clone().text());
89
+ const v = form.get("state") ?? form.get("RelayState");
90
+ return v && v.length > 0 ? v : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
77
95
  export {
96
+ extractCallbackState,
78
97
  mintStateEnvelope,
79
98
  verifyStateEnvelope
80
99
  };
@@ -0,0 +1,35 @@
1
+ // src/domain/subject.ts
2
+ import { authError } from "../types/error";
3
+ import { err, ok } from "../types/result";
4
+ async function validateSubjectClaim(subjects, claim) {
5
+ const declared = Object.keys(subjects);
6
+ const subjectType = typeof claim?.type === "string" ? claim.type : "";
7
+ const schema = subjectType ? subjects[subjectType] : undefined;
8
+ if (!schema) {
9
+ return err(Object.assign(authError.serverError(`success() returned subject type "${subjectType}", which is not declared in \`subjects\``), {
10
+ rejection: {
11
+ reason: "unknown-type",
12
+ subjectType,
13
+ detail: `declared: ${declared.join(", ") || "(none)"}`
14
+ }
15
+ }));
16
+ }
17
+ const validated = await schema["~standard"].validate(claim.properties);
18
+ if (validated.issues) {
19
+ const detail = validated.issues.map((i) => (i.path ?? []).map(String).join(".") || "(root)").join(", ") || "(root)";
20
+ return err(Object.assign(authError.serverError(`success() returned properties that violate the "${subjectType}" schema`), {
21
+ rejection: {
22
+ reason: "invalid-properties",
23
+ subjectType,
24
+ detail
25
+ }
26
+ }));
27
+ }
28
+ return ok({
29
+ type: subjectType,
30
+ properties: validated.value
31
+ });
32
+ }
33
+ export {
34
+ validateSubjectClaim
35
+ };
@@ -15,6 +15,7 @@ import {
15
15
  import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token";
16
16
  import { signAccessToken, signIdToken } from "./jwt";
17
17
  import { validatePkce } from "./pkce";
18
+ import { validateSubjectClaim } from "./subject";
18
19
  async function saveEncryptedCode(code, payload, ttl, deps) {
19
20
  const keyResult = await deps.keyStore.currentEncryptionKey();
20
21
  if (isErr(keyResult))
@@ -91,6 +92,20 @@ async function exchangeCode(req, deps) {
91
92
  } catch (e) {
92
93
  return err(authError.serverError("success callback threw", e));
93
94
  }
95
+ const checked = await validateSubjectClaim(deps.subjects, claim);
96
+ if (isErr(checked)) {
97
+ await safeAudit(deps, {
98
+ kind: "invalid_subject_claim",
99
+ tenantId: payload.tenantId,
100
+ clientId: payload.clientId,
101
+ subjectType: checked.error.rejection.subjectType,
102
+ reason: checked.error.rejection.reason,
103
+ detail: checked.error.rejection.detail,
104
+ timestamp: deps.clock()
105
+ });
106
+ return err(checked.error);
107
+ }
108
+ claim = checked.value;
94
109
  if (deps.persistUpstreamTokens) {
95
110
  try {
96
111
  await deps.persistUpstreamTokens({
@@ -3,6 +3,7 @@ import {
3
3
  handleMethodRoute,
4
4
  handlePublicMethodRoute
5
5
  } from "../../domain/method-route";
6
+ import { callbackTarget } from "../../domain/mount";
6
7
  import { authError } from "../../types/error";
7
8
  import { isErr } from "../../types/result";
8
9
  import { applyResponsePolicy, cacheControlHeader } from "../cookies";
@@ -24,8 +25,11 @@ function makeMethodRouteHandler(deps) {
24
25
  const resolved = await deps.methodCache.resolve(tenant.config, methodId);
25
26
  if (!isErr(resolved) && resolved.value.publicRoutes?.includes(routeKey)) {
26
27
  const issuerUrl = c.get("issuerUrl");
27
- const callbackHost = deps.callbackHostFor?.(tenant.id) ?? new URL(issuerUrl).host;
28
- const callbackUrl = `${new URL(issuerUrl).protocol}//${callbackHost}/cb/${methodId}`;
28
+ const { url: callbackUrl } = callbackTarget({
29
+ issuerUrl,
30
+ methodId,
31
+ callbackHost: deps.callbackHostFor?.(tenant.id)
32
+ });
29
33
  const pub = await handlePublicMethodRoute({
30
34
  rawRequest: c.req.raw,
31
35
  tenant,
@@ -75,7 +79,8 @@ function makeMethodRouteHandler(deps) {
75
79
  subPath,
76
80
  httpMethod,
77
81
  flowId,
78
- cookies
82
+ cookies,
83
+ issuerUrl: c.get("issuerUrl")
79
84
  }, {
80
85
  sessionStore: deps.sessionStore,
81
86
  tokenStore: deps.tokenStore,
@@ -67,6 +67,7 @@ function makeTokenHandler(deps) {
67
67
  keyStore: deps.keyStore,
68
68
  ...deps.auditLog ? { auditLog: deps.auditLog } : {},
69
69
  success: deps.success,
70
+ subjects: deps.subjects,
70
71
  ...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
71
72
  issuerUrl: c.get("issuerUrl"),
72
73
  clock: deps.clock,
@@ -99,6 +100,7 @@ function makeTokenHandler(deps) {
99
100
  ...deps.auditLog ? { auditLog: deps.auditLog } : {},
100
101
  methodCache: deps.methodCache,
101
102
  success: deps.success,
103
+ subjects: deps.subjects,
102
104
  ...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
103
105
  issuerUrl: c.get("issuerUrl"),
104
106
  clock: deps.clock,
@@ -1,5 +1,8 @@
1
1
  // src/http/middleware/tenant.ts
2
- import { verifyStateEnvelope } from "../../domain/state-envelope";
2
+ import {
3
+ extractCallbackState,
4
+ verifyStateEnvelope
5
+ } from "../../domain/state-envelope";
3
6
  import { isErr } from "../../types/result";
4
7
  import { parseCookieHeader } from "../cookies";
5
8
  import {
@@ -90,7 +93,7 @@ function buildTenantContext(req, id, config, custom) {
90
93
  };
91
94
  }
92
95
  async function runCallbackRecovery(req, deps) {
93
- const state = await extractStateParam(req);
96
+ const state = await extractCallbackState(req);
94
97
  if (state) {
95
98
  const env = await verifyStateEnvelope(state, deps.stateKeys);
96
99
  if (env.ok) {
@@ -103,24 +106,6 @@ async function runCallbackRecovery(req, deps) {
103
106
  }
104
107
  return { kind: "fresh-request" };
105
108
  }
106
- async function extractStateParam(req) {
107
- const url = new URL(req.url);
108
- const fromQuery = url.searchParams.get("state");
109
- if (fromQuery)
110
- return fromQuery;
111
- if (req.method !== "POST")
112
- return null;
113
- const ct = req.headers.get("content-type") ?? "";
114
- if (!ct.toLowerCase().startsWith("application/x-www-form-urlencoded")) {
115
- return null;
116
- }
117
- try {
118
- const text = await req.clone().text();
119
- return new URLSearchParams(text).get("state");
120
- } catch {
121
- return null;
122
- }
123
- }
124
109
  export {
125
110
  bootstrapMiddleware,
126
111
  tenantMiddleware