@_mustachio/openauth 0.13.3 → 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 (57) 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 +15 -0
  11. package/dist/esm/http/handlers/token.js +2 -0
  12. package/dist/esm/http/middleware/tenant.js +5 -20
  13. package/dist/esm/index.js +1 -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 +3 -1
  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.map +1 -1
  22. package/dist/types/domain/register.d.ts.map +1 -1
  23. package/dist/types/domain/state-envelope.d.ts +22 -0
  24. package/dist/types/domain/state-envelope.d.ts.map +1 -1
  25. package/dist/types/domain/subject.d.ts +48 -0
  26. package/dist/types/domain/subject.d.ts.map +1 -0
  27. package/dist/types/domain/token.d.ts +7 -1
  28. package/dist/types/domain/token.d.ts.map +1 -1
  29. package/dist/types/http/context.d.ts +3 -0
  30. package/dist/types/http/context.d.ts.map +1 -1
  31. package/dist/types/http/handlers/token.d.ts.map +1 -1
  32. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  33. package/dist/types/http/schemas/revocation.d.ts +4 -4
  34. package/dist/types/http/schemas/token.d.ts +12 -12
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/types/ports/audit-log.d.ts +25 -1
  38. package/dist/types/ports/audit-log.d.ts.map +1 -1
  39. package/dist/types/types/idp.d.ts +33 -38
  40. package/dist/types/types/idp.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client.ts +145 -129
  43. package/src/domain/authorize.ts +10 -0
  44. package/src/domain/callback.ts +22 -29
  45. package/src/domain/client-credentials.ts +20 -1
  46. package/src/domain/method-route.ts +10 -0
  47. package/src/domain/refresh.ts +39 -10
  48. package/src/domain/register.ts +27 -8
  49. package/src/domain/state-envelope.ts +40 -0
  50. package/src/domain/subject.ts +103 -0
  51. package/src/domain/token.ts +25 -1
  52. package/src/http/context.ts +3 -0
  53. package/src/http/handlers/token.ts +2 -0
  54. package/src/http/middleware/tenant.ts +5 -24
  55. package/src/index.ts +1 -2
  56. package/src/ports/audit-log.ts +26 -1
  57. package/src/types/idp.ts +33 -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
+ }
@@ -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. */
@@ -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
 
@@ -285,6 +283,7 @@ export function createIdP(opts: IdPOptions): IdP {
285
283
  ...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
286
284
  resolveTenant: opts.resolveTenant,
287
285
  success: opts.success,
286
+ subjects: opts.subjects,
288
287
  ...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
289
288
  ...(opts.persistUpstreamTokens
290
289
  ? { 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
package/src/types/idp.ts CHANGED
@@ -46,30 +46,6 @@ export type SuccessMapInput = {
46
46
  context: Record<string, unknown> | null
47
47
  }
48
48
 
49
- /**
50
- * Optional observation hook payload — fires after the subject claim has
51
- * already been minted. **Does not** influence the issued subject; use it
52
- * for audit, analytics, side effects only.
53
- */
54
- export type SuccessEvent = SuccessMapInput & {
55
- /** The final subject claim that became the JWT `sub`. */
56
- claim: SubjectClaim
57
- }
58
-
59
- /**
60
- * Optional observation hook payload — fires on a failed auth attempt.
61
- * Carries enough id information for operators to find the offending flow
62
- * / config row without leaking secrets.
63
- */
64
- export type FailureEvent = {
65
- tenantId: TenantId | null
66
- clientId: string | null
67
- methodId?: string
68
- methodKind?: string
69
- flowId?: string
70
- error: AuthError
71
- }
72
-
73
49
  /**
74
50
  * Input to the optional `IdPOptions.onLogout` hook.
75
51
  *
@@ -217,19 +193,41 @@ export type RegisterClientResponse = {
217
193
  /**
218
194
  * Optional Dynamic Client Registration hook. Hosts that want to expose
219
195
  * RFC 7591 client provisioning supply this; the framework validates the
220
- * wire format, then defers persistence to the host. If absent, the
221
- * `/register` endpoint returns `invalid_request` so RPs receive a clear
222
- * "not enabled" signal rather than a 404.
196
+ * wire format and mints credentials, then defers **persistence** to the
197
+ * host. If absent, the `/register` endpoint returns `invalid_request` so
198
+ * RPs receive a clear "not enabled" signal rather than a 404.
223
199
  *
224
- * The hook receives the parsed request, the resolved tenant, and the
225
- * plaintext client secret (if any) the framework minted — hosts hash it
226
- * with `hashClientSecret` before storing on `ClientConfig.secretHash`,
227
- * then return the final `ClientConfig` along with the secret in the
228
- * `RegisterClientResponse` so the RP can record it.
200
+ * The library owns credential generation — entropy, hashing, and the
201
+ * `ClientConfig` discriminated union, which requires `pkceRequired: true`
202
+ * as a literal on public clients and a `secretHash` on confidential ones.
203
+ * Those are protocol and security concerns, and making every host
204
+ * reimplement them is how they get done wrong. The host owns the table:
205
+ * write `client` through your own `ConfigStore` and return it.
206
+ *
207
+ * Before 0.14.0 this hook received only `{ tenant, request }` and the
208
+ * framework discarded what it had generated, so hosts had to mint their
209
+ * own — contradicting both this doc comment and `ARCHITECTURE.md`.
210
+ *
211
+ * Return the config you actually persisted. Adjusting it first is fine
212
+ * (narrowing `scopes`, substituting your own `id`); if you replace `id`
213
+ * or `secretHash`, return the matching plaintext as `secret` so the RP
214
+ * receives something that works.
229
215
  */
230
216
  export type RegisterClient = (input: {
231
217
  tenant: TenantContext
232
218
  request: RegisterClientRequest
219
+ /**
220
+ * Framework-minted `ClientConfig`, ready to persist as-is. Public
221
+ * clients carry `pkceRequired: true`; confidential clients carry
222
+ * `secretHash` for `secret` below.
223
+ */
224
+ client: ClientConfig
225
+ /**
226
+ * Plaintext secret matching `client.secretHash`. Present only for
227
+ * confidential clients. Return it in the result so the RP can record
228
+ * it — this is the only time it exists.
229
+ */
230
+ secret?: string
233
231
  }) => Promise<Result<{ client: ClientConfig; secret?: string }, AuthError>>
234
232
 
235
233
  /**
@@ -328,12 +326,6 @@ export type IdPOptions = {
328
326
 
329
327
  theme?: ThemeConfig
330
328
 
331
- hooks?: {
332
- /** Observation only — does NOT influence the subject. */
333
- onSuccess?: (event: SuccessEvent) => Promise<void>
334
- onFailure?: (event: FailureEvent) => Promise<void>
335
- }
336
-
337
329
  /**
338
330
  * Optional hook fired when an upstream provider signals that a
339
331
  * federated session ended — SAML front-channel Single Logout today.
@@ -342,9 +334,9 @@ export type IdPOptions = {
342
334
  * OIDC subject (if any) whose library-issued tokens to revoke. See
343
335
  * the `LogoutEventInput` / `LogoutHookResult` type docs.
344
336
  *
345
- * Unlike `hooks.onSuccess`/`onFailure` (observation only) this hook
346
- * **influences** library behaviour — its return drives token
347
- * revocation — so it sits at the top level alongside `success`.
337
+ * Unlike `AuditLog` (observation only) this hook **influences**
338
+ * library behaviour — its return drives token revocation — so it sits
339
+ * at the top level alongside `success`.
348
340
  *
349
341
  * Absent ⇒ the library still verifies the logout, emits a
350
342
  * `session_logout` audit event, and returns the protocol