@_mustachio/openauth 0.13.3 → 0.15.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 (61) hide show
  1. package/dist/esm/client.js +61 -62
  2. package/dist/esm/domain/authorize.js +10 -0
  3. package/dist/esm/domain/callback.js +21 -19
  4. package/dist/esm/domain/client-credentials.js +16 -0
  5. package/dist/esm/domain/method-route.js +10 -0
  6. package/dist/esm/domain/refresh.js +16 -5
  7. package/dist/esm/domain/register.js +8 -2
  8. package/dist/esm/domain/state-envelope.js +19 -0
  9. package/dist/esm/domain/subject.js +35 -0
  10. package/dist/esm/domain/token.js +28 -0
  11. package/dist/esm/http/handlers/token.js +6 -0
  12. package/dist/esm/http/middleware/tenant.js +5 -20
  13. package/dist/esm/index.js +2 -0
  14. package/dist/types/client.d.ts +61 -40
  15. package/dist/types/client.d.ts.map +1 -1
  16. package/dist/types/domain/authorize.d.ts.map +1 -1
  17. package/dist/types/domain/callback.d.ts.map +1 -1
  18. package/dist/types/domain/client-credentials.d.ts +6 -2
  19. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  20. package/dist/types/domain/method-route.d.ts.map +1 -1
  21. package/dist/types/domain/refresh.d.ts +3 -0
  22. package/dist/types/domain/refresh.d.ts.map +1 -1
  23. package/dist/types/domain/register.d.ts.map +1 -1
  24. package/dist/types/domain/state-envelope.d.ts +22 -0
  25. package/dist/types/domain/state-envelope.d.ts.map +1 -1
  26. package/dist/types/domain/subject.d.ts +48 -0
  27. package/dist/types/domain/subject.d.ts.map +1 -0
  28. package/dist/types/domain/token-exchange.d.ts +3 -1
  29. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  30. package/dist/types/domain/token.d.ts +11 -2
  31. package/dist/types/domain/token.d.ts.map +1 -1
  32. package/dist/types/http/context.d.ts +6 -0
  33. package/dist/types/http/context.d.ts.map +1 -1
  34. package/dist/types/http/handlers/token.d.ts.map +1 -1
  35. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  36. package/dist/types/http/schemas/revocation.d.ts +4 -4
  37. package/dist/types/http/schemas/token.d.ts +12 -12
  38. package/dist/types/index.d.ts +1 -1
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/ports/audit-log.d.ts +25 -1
  41. package/dist/types/ports/audit-log.d.ts.map +1 -1
  42. package/dist/types/types/idp.d.ts +105 -38
  43. package/dist/types/types/idp.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/client.ts +145 -129
  46. package/src/domain/authorize.ts +10 -0
  47. package/src/domain/callback.ts +22 -29
  48. package/src/domain/client-credentials.ts +27 -2
  49. package/src/domain/method-route.ts +10 -0
  50. package/src/domain/refresh.ts +42 -10
  51. package/src/domain/register.ts +27 -8
  52. package/src/domain/state-envelope.ts +40 -0
  53. package/src/domain/subject.ts +103 -0
  54. package/src/domain/token-exchange.ts +3 -1
  55. package/src/domain/token.ts +54 -2
  56. package/src/http/context.ts +6 -0
  57. package/src/http/handlers/token.ts +6 -0
  58. package/src/http/middleware/tenant.ts +5 -24
  59. package/src/index.ts +3 -2
  60. package/src/ports/audit-log.ts +26 -1
  61. package/src/types/idp.ts +107 -41
@@ -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
+ }
@@ -36,7 +36,7 @@ import type { ConfigStore } from "../ports/config-store"
36
36
  import type { KeyStore } from "../ports/key-store"
37
37
  import type { TokenStore } from "../ports/token-store"
38
38
  import { authError, type AuthError } from "../types/error"
39
- import type { ExchangeAudience } from "../types/idp"
39
+ import type { ExchangeAudience, OnTokenIssued } from "../types/idp"
40
40
  import type { Result } from "../types/result"
41
41
  import { err, isErr, ok } from "../types/result"
42
42
  import type { SubjectClaim } from "../types/subject"
@@ -73,6 +73,8 @@ export type ExchangeTokenDeps = {
73
73
  exchangeAudience?: ExchangeAudience
74
74
  issuerUrl: string
75
75
  clock: () => number
76
+ /** See `IdPOptions.onTokenIssued`. Threaded to `mintTokens`. */
77
+ onTokenIssued?: OnTokenIssued
76
78
  newRefreshFamily?: () => string
77
79
  /** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
78
80
  customScopeClaims?: Record<string, ReadonlyArray<string>>
@@ -24,10 +24,14 @@ import type { TokenStore } from "../ports/token-store"
24
24
  import type { ConfigStore } from "../ports/config-store"
25
25
  import type { AuthError } from "../types/error"
26
26
  import { authError } from "../types/error"
27
- import type { PersistUpstreamTokens, SuccessMapInput } from "../types/idp"
27
+ import type {
28
+ OnTokenIssued,
29
+ PersistUpstreamTokens,
30
+ SuccessMapInput,
31
+ } from "../types/idp"
28
32
  import type { Result } from "../types/result"
29
33
  import { err, isErr, ok } from "../types/result"
30
- import type { SubjectClaim } from "../types/subject"
34
+ import type { SubjectClaim, SubjectSchema } from "../types/subject"
31
35
  import type { TenantContext } from "../types/tenant"
32
36
  import type {
33
37
  AccessTokenClaims,
@@ -50,6 +54,7 @@ import {
50
54
  import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
51
55
  import { signAccessToken, signIdToken } from "./jwt"
52
56
  import { validatePkce } from "./pkce"
57
+ import { validateSubjectClaim } from "./subject"
53
58
 
54
59
  /**
55
60
  * Encrypt a `CodePayload` with the active `KeyStore` encryption key and
@@ -104,9 +109,17 @@ export type ExchangeCodeDeps = {
104
109
  keyStore: KeyStore
105
110
  auditLog?: AuditLog
106
111
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
112
+ /**
113
+ * The host's declared subject schemas. The claim `success()` returns is
114
+ * validated against these before anything is signed — see
115
+ * `domain/subject.ts` for why that is the library's job.
116
+ */
117
+ subjects: SubjectSchema
107
118
  persistUpstreamTokens?: PersistUpstreamTokens
108
119
  issuerUrl: string
109
120
  clock: () => number
121
+ /** See `IdPOptions.onTokenIssued`. Threaded to `mintTokens`. */
122
+ onTokenIssued?: OnTokenIssued
110
123
  newRefreshToken?: () => string
111
124
  /** Test override. */
112
125
  newRefreshFamily?: () => string
@@ -204,6 +217,23 @@ export async function exchangeCode(
204
217
  return err(authError.serverError("success callback threw", e))
205
218
  }
206
219
 
220
+ const checked = await validateSubjectClaim(deps.subjects, claim)
221
+ if (isErr(checked)) {
222
+ await safeAudit(deps, {
223
+ kind: "invalid_subject_claim",
224
+ tenantId: payload.tenantId,
225
+ clientId: payload.clientId,
226
+ subjectType: checked.error.rejection.subjectType,
227
+ reason: checked.error.rejection.reason,
228
+ detail: checked.error.rejection.detail,
229
+ timestamp: deps.clock(),
230
+ })
231
+ return err(checked.error)
232
+ }
233
+ // Parsed value from here on, so the token matches what the schema
234
+ // declares and what `client.verify()` returns to the RP.
235
+ claim = checked.value
236
+
207
237
  // 7. Optional upstream-tokens hook (runs after success, before
208
238
  // mint — failed mints below should NOT roll back this hook because
209
239
  // by contract the hook itself decides whether to persist).
@@ -285,6 +315,7 @@ export async function mintTokens(args: {
285
315
  auditLog?: AuditLog
286
316
  issuerUrl: string
287
317
  clock: () => number
318
+ onTokenIssued?: OnTokenIssued
288
319
  newRefreshToken?: () => string
289
320
  /**
290
321
  * Host-supplied vendor scope → claim-names map merged into the
@@ -314,6 +345,27 @@ export async function mintTokens(args: {
314
345
  receivingClient?.sectorIdentifier,
315
346
  )
316
347
 
348
+ // Hand the host the derived subject id *before* anything durable is
349
+ // written. `subjectId` exists nowhere else — it is signed as `sub` and
350
+ // is the key `revokeBySubject` takes — so a host that misses it holds a
351
+ // token it can never revoke. Running after `saveRefresh` and aborting
352
+ // on failure would leave precisely that: a live chain with no record.
353
+ if (deps.onTokenIssued) {
354
+ try {
355
+ await deps.onTokenIssued({
356
+ tenant,
357
+ clientId: payload.clientId,
358
+ subjectId,
359
+ claim,
360
+ // No refresh row is written when `skipRefresh` is set, so there
361
+ // is no chain for the host to revoke by family.
362
+ ...(skipRefresh ? {} : { family }),
363
+ })
364
+ } catch (e) {
365
+ return err(authError.serverError("onTokenIssued hook threw", e))
366
+ }
367
+ }
368
+
317
369
  const keyRes = await deps.keyStore.currentSigningKey()
318
370
  if (isErr(keyRes)) return err(keyRes.error)
319
371
  const signingKey = keyRes.value
@@ -16,6 +16,8 @@ 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 { OnTokenIssued } from "../types/idp"
20
+ import type { SubjectSchema } from "../types/subject"
19
21
  import type {
20
22
  ExchangeAudience,
21
23
  LogoutEventInput,
@@ -52,6 +54,10 @@ export type HttpDeps = {
52
54
  resolveIssuer: (req: Request) => string
53
55
  /** Optional partitioned-host helper (recovery #2). */
54
56
  callbackHostFor?: (tenantId: TenantId) => string
57
+ /** Host-declared subject schemas, validated at issuance. */
58
+ subjects: SubjectSchema
59
+ /** See `IdPOptions.onTokenIssued`. */
60
+ onTokenIssued?: OnTokenIssued
55
61
  resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
56
62
  success: (input: SuccessMapInput) => Promise<SubjectClaim>
57
63
  /** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
@@ -127,11 +127,13 @@ 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
  : {}),
133
134
  issuerUrl: c.get("issuerUrl"),
134
135
  clock: deps.clock,
136
+ ...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
135
137
  ...(deps.customScopeClaims !== undefined
136
138
  ? { customScopeClaims: deps.customScopeClaims }
137
139
  : {}),
@@ -174,11 +176,13 @@ export function makeTokenHandler(deps: HttpDeps) {
174
176
  ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
175
177
  methodCache: deps.methodCache,
176
178
  success: deps.success,
179
+ subjects: deps.subjects,
177
180
  ...(deps.persistUpstreamTokens
178
181
  ? { persistUpstreamTokens: deps.persistUpstreamTokens }
179
182
  : {}),
180
183
  issuerUrl: c.get("issuerUrl"),
181
184
  clock: deps.clock,
185
+ ...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
182
186
  ...(deps.customScopeClaims !== undefined
183
187
  ? { customScopeClaims: deps.customScopeClaims }
184
188
  : {}),
@@ -208,6 +212,7 @@ export function makeTokenHandler(deps: HttpDeps) {
208
212
  ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
209
213
  issuerUrl: c.get("issuerUrl"),
210
214
  clock: deps.clock,
215
+ ...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
211
216
  ...(deps.customScopeClaims !== undefined
212
217
  ? { customScopeClaims: deps.customScopeClaims }
213
218
  : {}),
@@ -248,6 +253,7 @@ export function makeTokenHandler(deps: HttpDeps) {
248
253
  : {}),
249
254
  issuerUrl: c.get("issuerUrl"),
250
255
  clock: deps.clock,
256
+ ...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
251
257
  ...(deps.customScopeClaims !== undefined
252
258
  ? { customScopeClaims: deps.customScopeClaims }
253
259
  : {}),
@@ -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,17 +73,16 @@ export type {
73
73
 
74
74
  export type {
75
75
  ExchangeAudience,
76
- FailureEvent,
77
76
  IdP,
78
77
  IdPOptions,
79
78
  LogoutEventInput,
80
79
  LogoutHookResult,
80
+ OnTokenIssued,
81
81
  PersistUpstreamTokens,
82
82
  RegisterClient,
83
83
  RegisterClientRequest,
84
84
  RegisterClientResponse,
85
85
  RenderPicker,
86
- SuccessEvent,
87
86
  SuccessMapInput,
88
87
  } from "./types/idp"
89
88
 
@@ -285,6 +284,8 @@ export function createIdP(opts: IdPOptions): IdP {
285
284
  ...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
286
285
  resolveTenant: opts.resolveTenant,
287
286
  success: opts.success,
287
+ subjects: opts.subjects,
288
+ ...(opts.onTokenIssued ? { onTokenIssued: opts.onTokenIssued } : {}),
288
289
  ...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
289
290
  ...(opts.persistUpstreamTokens
290
291
  ? { persistUpstreamTokens: opts.persistUpstreamTokens }
@@ -28,13 +28,24 @@ export type AuditEvent =
28
28
  flowId: string
29
29
  }
30
30
  | {
31
+ /**
32
+ * A method authenticated the end user and an authorization code was
33
+ * minted. Completes the `authorize_started` / `authorize_failed`
34
+ * pair, which shipped without it.
35
+ */
31
36
  kind: "authorize_succeeded"
32
37
  tenantId: TenantId
33
38
  clientId: string
34
39
  methodId: string
35
40
  methodKind: string
36
41
  flowId: string
37
- subjectId: string
42
+ /**
43
+ * The **upstream** identifier the method returned. Deliberately not
44
+ * `subjectId`: the OIDC subject is derived from the host's
45
+ * `success()` claim at `/token`, which has not run yet. Correlate
46
+ * with `token_issued.subjectId` via `clientId` + `flowId`.
47
+ */
48
+ providerSubject: string
38
49
  }
39
50
  | {
40
51
  kind: "authorize_failed"
@@ -162,6 +173,20 @@ export type AuditEvent =
162
173
  /** Zod error path string. Never the raw config blob. */
163
174
  errorPath: string
164
175
  }
176
+ | {
177
+ /**
178
+ * `IdPOptions.success` returned a claim that violates the host's
179
+ * own `subjects` schema. A deployment fault, not RP behaviour —
180
+ * token issuance is refused. Carries paths, never values.
181
+ */
182
+ kind: "invalid_subject_claim"
183
+ tenantId: TenantId
184
+ clientId: string
185
+ subjectType: string
186
+ reason: "unknown-type" | "invalid-properties"
187
+ /** Standard Schema issue path, or the declared type list. */
188
+ detail: string
189
+ }
165
190
  | {
166
191
  kind: "unknown_method_kind"
167
192
  tenantId: TenantId