@_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
@@ -33,6 +33,11 @@ export type DispatchInput = {
33
33
  flow: FlowRecord | null
34
34
  cookies: ReadonlyMap<string, string>
35
35
  sessionStore: SessionStore
36
+ /**
37
+ * Issuer URL of this IdP. Always required — unlike `dispatch`, methods
38
+ * need it on every route to emit mount-prefixed URLs of their own.
39
+ */
40
+ issuerUrl: string
36
41
  /** Populated at `GET /authorize`; null on callbacks. */
37
42
  dispatch: MethodDispatchData | null
38
43
  }
@@ -57,6 +62,7 @@ export async function dispatchMethod(
57
62
 
58
63
  const ctx: MethodContext<unknown> = {
59
64
  request: input.request,
65
+ issuerUrl: input.issuerUrl,
60
66
  subPath: input.subPath,
61
67
  tenant: input.tenant,
62
68
  flow: input.flow,
@@ -79,6 +79,12 @@ export type HandleMethodRouteInput = {
79
79
  /** flowId from the `idp.flow` cookie. */
80
80
  flowId: string
81
81
  cookies: ReadonlyMap<string, string>
82
+ /**
83
+ * Issuer URL — passed to the method so it can emit mount-prefixed URLs
84
+ * (a re-rendered form action, say). The inbound request URL cannot
85
+ * supply this: a proxy has already stripped the mount prefix from it.
86
+ */
87
+ issuerUrl: string
82
88
  }
83
89
 
84
90
  export type HandleMethodRouteDeps = {
@@ -139,6 +145,7 @@ export async function handleMethodRoute(
139
145
  flow,
140
146
  cookies: input.cookies,
141
147
  sessionStore: deps.sessionStore,
148
+ issuerUrl: input.issuerUrl,
142
149
  dispatch: null,
143
150
  })
144
151
  if (isErr(dispatched)) return err(dispatched.error)
@@ -185,6 +192,16 @@ async function translate(
185
192
  { keyStore: deps.keyStore, tokenStore: deps.tokenStore },
186
193
  )
187
194
  if (isErr(saved)) return err(saved.error)
195
+ await safeAudit(deps, {
196
+ kind: "authorize_succeeded",
197
+ tenantId: final.tenantId,
198
+ clientId: final.clientId,
199
+ methodId: final.methodId,
200
+ methodKind: final.methodKind,
201
+ flowId: final.flowId,
202
+ providerSubject: result.providerSubject,
203
+ timestamp: now,
204
+ })
188
205
  return ok({
189
206
  kind: "issue-code",
190
207
  code,
@@ -295,6 +312,7 @@ export async function handlePublicMethodRoute(
295
312
  flow: null,
296
313
  cookies: new Map(),
297
314
  sessionStore: deps.sessionStore,
315
+ issuerUrl: input.dispatch.issuerUrl,
298
316
  dispatch: input.dispatch,
299
317
  })
300
318
  if (isErr(dispatched)) return err(dispatched.error)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Mount prefix — where this IdP sits under its origin.
3
+ *
4
+ * The library serves its routes at its own root (`/authorize`, `/m/*`,
5
+ * `/cb/*`); a deployment may sit behind a reverse proxy that strips a path
6
+ * prefix before forwarding. Routing is unaffected — but every URL the
7
+ * library *emits* (form actions, upstream `redirect_uri`s, SAML metadata)
8
+ * is consumed on the public side of that proxy and must carry the prefix.
9
+ *
10
+ * `issuerUrl` already carries it. It is the single source of truth: there
11
+ * is deliberately no `basePath` option, because two sources could disagree
12
+ * and the `iss` claim would then contradict the URLs we hand out.
13
+ *
14
+ * All emitted-URL construction goes through this module. `metadata.test.ts`
15
+ * is an anti-drift test asserting the SAML entityID/ACS equal what
16
+ * `buildAuthnRequestRedirect` derives; funnelling every site through
17
+ * `callbackTarget` is what keeps that true.
18
+ */
19
+
20
+ /**
21
+ * The normalised mount prefix of `issuerUrl`: `""` for a root-mounted
22
+ * deployment, otherwise a leading-slash, no-trailing-slash path.
23
+ *
24
+ * https://example.com -> ""
25
+ * https://example.com/ -> ""
26
+ * https://example.com/idp -> "/idp"
27
+ * https://example.com/idp/ -> "/idp"
28
+ * https://example.com//idp// -> "/idp"
29
+ *
30
+ * A root-mounted issuer must yield `""` and not `"/"`, so that existing
31
+ * deployments emit byte-identical URLs to before this existed.
32
+ *
33
+ * An unparseable `issuerUrl` yields `""` rather than throwing: emitting a
34
+ * root-relative URL degrades to the pre-existing behaviour, whereas
35
+ * throwing would turn a misconfiguration into a 500 from inside form
36
+ * rendering. Misconfiguration still fails loudly at `/authorize`, which
37
+ * parses `issuerUrl` for the callback host.
38
+ */
39
+ export function mountPath(issuerUrl: string): string {
40
+ let pathname: string
41
+ try {
42
+ pathname = new URL(issuerUrl).pathname
43
+ } catch {
44
+ return ""
45
+ }
46
+ const segments = pathname.split("/").filter(Boolean)
47
+ return segments.length === 0 ? "" : `/${segments.join("/")}`
48
+ }
49
+
50
+ /**
51
+ * A path-absolute URL for one of the library's own routes, carrying the
52
+ * deployment's mount prefix. `path` is the route as the service itself
53
+ * serves it, with a leading slash.
54
+ *
55
+ * mountedPath("https://x/idp", "/m/code/send") -> "/idp/m/code/send"
56
+ * mountedPath("https://x", "/m/code/send") -> "/m/code/send"
57
+ */
58
+ export function mountedPath(issuerUrl: string, path: string): string {
59
+ return `${mountPath(issuerUrl)}${path}`
60
+ }
61
+
62
+ /**
63
+ * Where a method's callback lives, in the two forms the framework needs.
64
+ *
65
+ * The distinction matters under a path-mounted deployment and is the
66
+ * reason these are derived together rather than one from the other:
67
+ * `url` is public (registered with upstream providers, advertised in SAML
68
+ * metadata) and carries the mount prefix, while `path` is what the
69
+ * inbound request actually looks like after the proxy has stripped that
70
+ * prefix, and so must not.
71
+ */
72
+ export type CallbackTarget = {
73
+ /** Public, fully-qualified callback URL. Carries the mount prefix. */
74
+ url: string
75
+ /** Host the callback is expected to arrive on. */
76
+ host: string
77
+ /**
78
+ * Expected pathname of the inbound request — this service's own route,
79
+ * *without* the mount prefix, because the proxy has already stripped it.
80
+ * Persisted on `FlowRecord.callbackPath` and matched in `domain/callback.ts`.
81
+ */
82
+ path: string
83
+ }
84
+
85
+ /**
86
+ * The single derivation point for a method's callback URL.
87
+ *
88
+ * `callbackHost` is the optional per-tenant override from
89
+ * `IdPOptions.callbackHostFor`. **The issuer's mount prefix applies even
90
+ * when that override is in effect.** `callbackHostFor` exists to partition
91
+ * callbacks across hostnames so a tenant is recoverable from the `Host`
92
+ * header before the state envelope is verified (tenant-recovery mechanism
93
+ * #2) — it varies the authority of the *same* deployment, not the
94
+ * deployment itself. Those hosts are served by this service behind the
95
+ * same proxy, so they share its mount. A deployment needing partitioned
96
+ * hosts mounted differently from the issuer is describing two mounts,
97
+ * which one `issuerUrl` cannot express and a second option must not be
98
+ * introduced to paper over.
99
+ */
100
+ export function callbackTarget(input: {
101
+ issuerUrl: string
102
+ methodId: string
103
+ callbackHost?: string | undefined
104
+ }): CallbackTarget {
105
+ const issuer = new URL(input.issuerUrl)
106
+ const host = input.callbackHost ?? issuer.host
107
+ const path = `/cb/${input.methodId}`
108
+ return {
109
+ host,
110
+ path,
111
+ url: `${issuer.protocol}//${host}${mountPath(input.issuerUrl)}${path}`,
112
+ }
113
+ }
@@ -57,6 +57,26 @@ export type RefreshTokensDeps = {
57
57
  customScopeClaims?: Record<string, ReadonlyArray<string>>
58
58
  }
59
59
 
60
+ /**
61
+ * Requested scopes must be a subset of what the grant carries (RFC 6749
62
+ * §6). Returns the error rather than throwing so both call sites -- the
63
+ * pre-consume gate and the post-consume re-check -- read identically.
64
+ */
65
+ function validateRequestedScopes(
66
+ requested: string | undefined,
67
+ granted: string[],
68
+ ): AuthError | null {
69
+ if (!requested) return null
70
+ for (const s of requested.split(" ").filter(Boolean)) {
71
+ if (!granted.includes(s)) {
72
+ return authError.invalidScope(
73
+ `requested scope "${s}" not granted by original refresh token`,
74
+ )
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
60
80
  export async function refreshTokens(
61
81
  req: RefreshGrantRequest,
62
82
  deps: RefreshTokensDeps,
@@ -123,6 +143,16 @@ export async function refreshTokens(
123
143
  }
124
144
  }
125
145
 
146
+ // Scope narrowing is validated against the *peeked* grant, before the
147
+ // token is consumed. Same reasoning as the client-auth and DPoP gates
148
+ // above: a request the grant cannot satisfy must not burn the token,
149
+ // or a client typo makes the next legitimate refresh look like theft
150
+ // and can revoke the whole family. `consumeRefresh` stays the
151
+ // authoritative atomic gate immediately below (TokenStore port:
152
+ // "callers race peekRefresh then consumeRefresh").
153
+ const scopeErr = validateRequestedScopes(req.scope, peekedPayload.scopes)
154
+ if (scopeErr) return err(scopeErr)
155
+
126
156
  const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
127
157
  reuseWindowMs: deps.reuseWindowMs,
128
158
  })
@@ -147,19 +177,18 @@ export async function refreshTokens(
147
177
  }
148
178
  const payload = consumed.value
149
179
 
150
- // Narrow scope if requested (must be subset of original).
180
+ // Re-check against the consumed payload, which the port declares
181
+ // authoritative. In practice this cannot diverge from the peeked
182
+ // grant -- rotation preserves scopes -- so this is defence in depth,
183
+ // not the user-facing gate; that already ran above without burning.
184
+ const authoritativeScopeErr = validateRequestedScopes(
185
+ req.scope,
186
+ payload.scopes,
187
+ )
188
+ if (authoritativeScopeErr) return err(authoritativeScopeErr)
151
189
  const requestedScopes = req.scope
152
190
  ? req.scope.split(" ").filter(Boolean)
153
191
  : payload.scopes
154
- for (const s of requestedScopes) {
155
- if (!payload.scopes.includes(s)) {
156
- return err(
157
- authError.invalidScope(
158
- `requested scope "${s}" not granted by original refresh token`,
159
- ),
160
- )
161
- }
162
- }
163
192
 
164
193
  const tenant: TenantContext = {
165
194
  id: payload.tenantId,
@@ -136,17 +136,36 @@ export async function registerNewClient(
136
136
  const hookResult = await deps.registerClient({
137
137
  tenant,
138
138
  request,
139
+ client: clientConfig,
140
+ ...(secret !== undefined ? { secret } : {}),
139
141
  })
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
142
  if (isErr(hookResult)) return err(hookResult.error)
143
+
144
+ // The host returns what it actually persisted — normally `clientConfig`
145
+ // unchanged, sometimes adjusted (narrowed scopes, host-owned id). That
146
+ // return is authoritative; the framework does not second-guess it.
148
147
  const persisted = hookResult.value.client
149
- const persistedSecret = hookResult.value.secret
148
+ // The plaintext may only be reused when the host persisted *our* hash;
149
+ // if it substituted its own, our secret would not verify and the RP
150
+ // would receive a credential that silently never works.
151
+ const keptOurSecret =
152
+ persisted.type === "confidential" &&
153
+ clientConfig.type === "confidential" &&
154
+ persisted.secretHash === clientConfig.secretHash
155
+ const persistedSecret =
156
+ hookResult.value.secret ?? (keptOurSecret ? secret : undefined)
157
+
158
+ // RFC 7591 §3.2.1 — a confidential client must leave registration with
159
+ // a usable secret. Better to fail loudly than to hand back a client
160
+ // that cannot authenticate.
161
+ if (persisted.type === "confidential" && persistedSecret === undefined) {
162
+ return err(
163
+ authError.serverError(
164
+ "registerClient persisted a confidential client with a substituted " +
165
+ "secretHash but returned no matching plaintext secret",
166
+ ),
167
+ )
168
+ }
150
169
 
151
170
  const issuedAt = Math.floor(deps.clock() / 1000)
152
171
  return ok({
@@ -140,3 +140,43 @@ function isEnvelopeShape(value: unknown): value is EnvelopeShape {
140
140
  typeof v.kid === "string"
141
141
  )
142
142
  }
143
+
144
+ /**
145
+ * Pull the MAC state envelope off an inbound callback request.
146
+ *
147
+ * The envelope normally rides `?state=` on the upstream redirect.
148
+ * POST-binding callbacks carry it in the form body instead: OAuth
149
+ * `response_mode=form_post` uses `state`, SAML's HTTP-POST binding uses
150
+ * `RelayState`. Read the query first (cheap, the common case) and fall
151
+ * back to a **cloned** body read so the downstream method handler still
152
+ * gets an unconsumed request body (it needs it for `code` /
153
+ * `SAMLResponse`).
154
+ *
155
+ * Any body-parse failure degrades to "no state" — identical to the
156
+ * behaviour when the query param is simply absent.
157
+ *
158
+ * **This is the single extraction point**, shared by the tenant
159
+ * middleware and the callback domain. They ran near-identical copies
160
+ * until 0.14.0, and the middleware copy — which runs *first* — read only
161
+ * `state`. SP-initiated SAML callbacks therefore fell through to
162
+ * `resolveTenant`, breaking the documented guarantee that callbacks
163
+ * recover the tenant without it. Keep exactly one of these.
164
+ */
165
+ export async function extractCallbackState(
166
+ req: Request,
167
+ ): Promise<string | null> {
168
+ const fromQuery = new URL(req.url).searchParams.get("state")
169
+ if (fromQuery) return fromQuery
170
+ if (req.method !== "POST") return null
171
+ const ct = req.headers.get("content-type") ?? ""
172
+ if (!ct.toLowerCase().includes("application/x-www-form-urlencoded")) {
173
+ return null
174
+ }
175
+ try {
176
+ const form = new URLSearchParams(await req.clone().text())
177
+ const v = form.get("state") ?? form.get("RelayState")
178
+ return v && v.length > 0 ? v : null
179
+ } catch {
180
+ return null
181
+ }
182
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Subject-claim validation at issuance.
3
+ *
4
+ * `IdPOptions.subjects` is a required option, and until 0.14.0 nothing on
5
+ * the server read it: `createIdP` accepted the schema and signed whatever
6
+ * `success()` returned. Two things went wrong as a result.
7
+ *
8
+ * First, the failure surfaced in the wrong place. `client.verify()` does
9
+ * validate against the same schema, so a malformed claim was caught by the
10
+ * relying party — after the token had been signed, written into the refresh
11
+ * payload, and handed to anything else holding it. Validating here turns a
12
+ * late failure in another service into an immediate local one.
13
+ *
14
+ * Second, the types were unsound. `SubjectPayload` declares
15
+ * `properties: v1.InferOutput<T[type]>` — the schema's *parsed output* —
16
+ * but with no parse anywhere the runtime value was the raw input. Any
17
+ * schema carrying a transform or a default had a declared type that lied.
18
+ * Returning the validated value is what makes that declaration true, and
19
+ * it makes the issued token agree with what `client.verify()` will hand
20
+ * back to the RP.
21
+ *
22
+ * Standard Schema keeps this validator spec-neutral (zod, valibot,
23
+ * arktype) and adds no dependency the public surface didn't already have.
24
+ */
25
+ import type { AuthError } from "../types/error"
26
+ import { authError } from "../types/error"
27
+ import type { Result } from "../types/result"
28
+ import { err, ok } from "../types/result"
29
+ import type { SubjectClaim, SubjectSchema } from "../types/subject"
30
+
31
+ /** Why a claim was rejected — enough for an operator, never the values. */
32
+ export type SubjectClaimRejection = {
33
+ reason: "unknown-type" | "invalid-properties"
34
+ /** The offending `claim.type`. Host-declared, safe to log. */
35
+ subjectType: string
36
+ /** Standard Schema issue path, or the list of declared types. */
37
+ detail: string
38
+ }
39
+
40
+ /**
41
+ * Validate the host's `SubjectClaim` against its own declared schema.
42
+ *
43
+ * On success the returned claim carries the **parsed** properties, which
44
+ * is what gets signed and persisted. On failure the caller emits an
45
+ * `invalid_subject_claim` audit event and returns a server error: the
46
+ * host's callback broke its own contract, which is a deployment fault,
47
+ * not something the relying party did wrong.
48
+ */
49
+ export async function validateSubjectClaim(
50
+ subjects: SubjectSchema,
51
+ claim: SubjectClaim,
52
+ ): Promise<
53
+ Result<SubjectClaim, AuthError & { rejection: SubjectClaimRejection }>
54
+ > {
55
+ const declared = Object.keys(subjects)
56
+ const subjectType = typeof claim?.type === "string" ? claim.type : ""
57
+
58
+ const schema = subjectType ? subjects[subjectType] : undefined
59
+ if (!schema) {
60
+ return err(
61
+ Object.assign(
62
+ authError.serverError(
63
+ `success() returned subject type "${subjectType}", which is not declared in \`subjects\``,
64
+ ),
65
+ {
66
+ rejection: {
67
+ reason: "unknown-type" as const,
68
+ subjectType,
69
+ detail: `declared: ${declared.join(", ") || "(none)"}`,
70
+ },
71
+ },
72
+ ),
73
+ )
74
+ }
75
+
76
+ const validated = await schema["~standard"].validate(claim.properties)
77
+ if (validated.issues) {
78
+ // Paths only — the values themselves may be personal data.
79
+ const detail =
80
+ validated.issues
81
+ .map((i) => (i.path ?? []).map(String).join(".") || "(root)")
82
+ .join(", ") || "(root)"
83
+ return err(
84
+ Object.assign(
85
+ authError.serverError(
86
+ `success() returned properties that violate the "${subjectType}" schema`,
87
+ ),
88
+ {
89
+ rejection: {
90
+ reason: "invalid-properties" as const,
91
+ subjectType,
92
+ detail,
93
+ },
94
+ },
95
+ ),
96
+ )
97
+ }
98
+
99
+ return ok({
100
+ type: subjectType,
101
+ properties: validated.value,
102
+ } as SubjectClaim)
103
+ }
@@ -27,7 +27,7 @@ import { authError } from "../types/error"
27
27
  import type { PersistUpstreamTokens, SuccessMapInput } from "../types/idp"
28
28
  import type { Result } from "../types/result"
29
29
  import { err, isErr, ok } from "../types/result"
30
- import type { SubjectClaim } from "../types/subject"
30
+ import type { SubjectClaim, SubjectSchema } from "../types/subject"
31
31
  import type { TenantContext } from "../types/tenant"
32
32
  import type {
33
33
  AccessTokenClaims,
@@ -50,6 +50,7 @@ import {
50
50
  import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
51
51
  import { signAccessToken, signIdToken } from "./jwt"
52
52
  import { validatePkce } from "./pkce"
53
+ import { validateSubjectClaim } from "./subject"
53
54
 
54
55
  /**
55
56
  * Encrypt a `CodePayload` with the active `KeyStore` encryption key and
@@ -104,6 +105,12 @@ export type ExchangeCodeDeps = {
104
105
  keyStore: KeyStore
105
106
  auditLog?: AuditLog
106
107
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
108
+ /**
109
+ * The host's declared subject schemas. The claim `success()` returns is
110
+ * validated against these before anything is signed — see
111
+ * `domain/subject.ts` for why that is the library's job.
112
+ */
113
+ subjects: SubjectSchema
107
114
  persistUpstreamTokens?: PersistUpstreamTokens
108
115
  issuerUrl: string
109
116
  clock: () => number
@@ -204,6 +211,23 @@ export async function exchangeCode(
204
211
  return err(authError.serverError("success callback threw", e))
205
212
  }
206
213
 
214
+ const checked = await validateSubjectClaim(deps.subjects, claim)
215
+ if (isErr(checked)) {
216
+ await safeAudit(deps, {
217
+ kind: "invalid_subject_claim",
218
+ tenantId: payload.tenantId,
219
+ clientId: payload.clientId,
220
+ subjectType: checked.error.rejection.subjectType,
221
+ reason: checked.error.rejection.reason,
222
+ detail: checked.error.rejection.detail,
223
+ timestamp: deps.clock(),
224
+ })
225
+ return err(checked.error)
226
+ }
227
+ // Parsed value from here on, so the token matches what the schema
228
+ // declares and what `client.verify()` returns to the RP.
229
+ claim = checked.value
230
+
207
231
  // 7. Optional upstream-tokens hook (runs after success, before
208
232
  // mint — failed mints below should NOT roll back this hook because
209
233
  // by contract the hook itself decides whether to persist).
@@ -16,6 +16,7 @@ import type { SessionStore } from "../ports/session-store"
16
16
  import type { TokenStore } from "../ports/token-store"
17
17
  import type { MethodCache } from "../domain/method-cache"
18
18
  import type { AuthError } from "../types/error"
19
+ import type { SubjectSchema } from "../types/subject"
19
20
  import type {
20
21
  ExchangeAudience,
21
22
  LogoutEventInput,
@@ -52,6 +53,8 @@ export type HttpDeps = {
52
53
  resolveIssuer: (req: Request) => string
53
54
  /** Optional partitioned-host helper (recovery #2). */
54
55
  callbackHostFor?: (tenantId: TenantId) => string
56
+ /** Host-declared subject schemas, validated at issuance. */
57
+ subjects: SubjectSchema
55
58
  resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
56
59
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
57
60
  /** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
@@ -12,6 +12,7 @@ import {
12
12
  handlePublicMethodRoute,
13
13
  } from "../../domain/method-route"
14
14
  import type { RouteKey } from "../../domain/method-dispatch"
15
+ import { callbackTarget } from "../../domain/mount"
15
16
  import { authError } from "../../types/error"
16
17
  import { isErr } from "../../types/result"
17
18
 
@@ -51,13 +52,15 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
51
52
  resolved.value.publicRoutes?.includes(routeKey)
52
53
  ) {
53
54
  const issuerUrl = c.get("issuerUrl")
54
- const callbackHost =
55
- deps.callbackHostFor?.(tenant.id) ?? new URL(issuerUrl).host
56
- // Mirrors the ACS URL derivation in domain/authorize.ts
57
- // (callbackHost/path/url). Source of truth is there; the
55
+ // Same derivation as domain/authorize.ts — both call
56
+ // `callbackTarget`, which is the single source of truth. The
58
57
  // metadata.test.ts anti-drift test asserts the emitted
59
58
  // entityID/ACS equal what buildAuthnRequestRedirect derives.
60
- const callbackUrl = `${new URL(issuerUrl).protocol}//${callbackHost}/cb/${methodId}`
59
+ const { url: callbackUrl } = callbackTarget({
60
+ issuerUrl,
61
+ methodId,
62
+ callbackHost: deps.callbackHostFor?.(tenant.id),
63
+ })
61
64
  const pub = await handlePublicMethodRoute(
62
65
  {
63
66
  rawRequest: c.req.raw,
@@ -122,6 +125,7 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
122
125
  httpMethod,
123
126
  flowId,
124
127
  cookies,
128
+ issuerUrl: c.get("issuerUrl"),
125
129
  },
126
130
  {
127
131
  sessionStore: deps.sessionStore,
@@ -127,6 +127,7 @@ export function makeTokenHandler(deps: HttpDeps) {
127
127
  keyStore: deps.keyStore,
128
128
  ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
129
129
  success: deps.success,
130
+ subjects: deps.subjects,
130
131
  ...(deps.persistUpstreamTokens
131
132
  ? { persistUpstreamTokens: deps.persistUpstreamTokens }
132
133
  : {}),
@@ -174,6 +175,7 @@ export function makeTokenHandler(deps: HttpDeps) {
174
175
  ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
175
176
  methodCache: deps.methodCache,
176
177
  success: deps.success,
178
+ subjects: deps.subjects,
177
179
  ...(deps.persistUpstreamTokens
178
180
  ? { persistUpstreamTokens: deps.persistUpstreamTokens }
179
181
  : {}),
@@ -17,7 +17,10 @@
17
17
  */
18
18
  import type { MiddlewareHandler } from "hono"
19
19
 
20
- import { verifyStateEnvelope } from "../../domain/state-envelope"
20
+ import {
21
+ extractCallbackState,
22
+ verifyStateEnvelope,
23
+ } from "../../domain/state-envelope"
21
24
  import { isErr } from "../../types/result"
22
25
  import type {
23
26
  TenantConfig,
@@ -174,7 +177,7 @@ async function runCallbackRecovery(
174
177
  req: Request,
175
178
  deps: HttpDeps,
176
179
  ): Promise<TenantRecovery> {
177
- const state = await extractStateParam(req)
180
+ const state = await extractCallbackState(req)
178
181
  if (state) {
179
182
  const env = await verifyStateEnvelope(state, deps.stateKeys)
180
183
  if (env.ok) {
@@ -187,25 +190,3 @@ async function runCallbackRecovery(
187
190
  }
188
191
  return { kind: "fresh-request" }
189
192
  }
190
-
191
- /**
192
- * Pull `state` from the query for GET callbacks, or from a form body for
193
- * POST callbacks (Apple's `response_mode=form_post`). The body is cloned
194
- * so the handler can still read it.
195
- */
196
- async function extractStateParam(req: Request): Promise<string | null> {
197
- const url = new URL(req.url)
198
- const fromQuery = url.searchParams.get("state")
199
- if (fromQuery) return fromQuery
200
- if (req.method !== "POST") return null
201
- const ct = req.headers.get("content-type") ?? ""
202
- if (!ct.toLowerCase().startsWith("application/x-www-form-urlencoded")) {
203
- return null
204
- }
205
- try {
206
- const text = await req.clone().text()
207
- return new URLSearchParams(text).get("state")
208
- } catch {
209
- return null
210
- }
211
- }
package/src/index.ts CHANGED
@@ -73,7 +73,6 @@ export type {
73
73
 
74
74
  export type {
75
75
  ExchangeAudience,
76
- FailureEvent,
77
76
  IdP,
78
77
  IdPOptions,
79
78
  LogoutEventInput,
@@ -83,7 +82,6 @@ export type {
83
82
  RegisterClientRequest,
84
83
  RegisterClientResponse,
85
84
  RenderPicker,
86
- SuccessEvent,
87
85
  SuccessMapInput,
88
86
  } from "./types/idp"
89
87
 
@@ -133,6 +131,23 @@ export type { TokenStore } from "./ports/token-store"
133
131
  */
134
132
  export { revokeAllForSubject } from "./domain/revoke"
135
133
 
134
+ /**
135
+ * Build a path-absolute URL for one of the library's own routes, carrying
136
+ * the mount prefix from `issuerUrl`.
137
+ *
138
+ * Custom methods that render their own URLs — a form `action`, say — must
139
+ * use this rather than a path-absolute literal, or the URL will 404 under
140
+ * a deployment mounted behind a proxy at a path prefix. Root-mounted
141
+ * issuers yield the literal unchanged.
142
+ *
143
+ * mountedPath(ctx.issuerUrl, `/m/${id}/send`)
144
+ * // "https://x" -> "/m/mycode/send"
145
+ * // "https://x/idp" -> "/idp/m/mycode/send"
146
+ *
147
+ * See INTEGRATION.md § 8.
148
+ */
149
+ export { mountedPath, mountPath } from "./domain/mount"
150
+
136
151
  // Phase 4 — credential + WebAuthn method factories.
137
152
  export { passwordMethod } from "./methods/password"
138
153
  export type {
@@ -268,6 +283,7 @@ export function createIdP(opts: IdPOptions): IdP {
268
283
  ...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
269
284
  resolveTenant: opts.resolveTenant,
270
285
  success: opts.success,
286
+ subjects: opts.subjects,
271
287
  ...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
272
288
  ...(opts.persistUpstreamTokens
273
289
  ? { persistUpstreamTokens: opts.persistUpstreamTokens }