Extension reference: callbacks and storage contracts
Use this reference when adapting the package to your application's data and policy. It lists constructor callbacks and storage interfaces, when the package calls them, and whether their return value or failure can stop a flow. The exported symbol is the stable API name; the descriptive label is only a navigation aid.
How to read the reference
Two properties decide how a trigger point behaves, and every row states both.
Control — what the seam can do to the flow:
blocks — the seam runs inside the flow and can stop it. It denies by return value (
null,false,{ approved: false }, an issue string) or by throwing; either way the flow does not continue as if nothing happened.fire-and-forget — the seam is for a side effect (audit, notification, re-render). Its throw is caught and logged, never rethrown, so it cannot affect the outcome. Use it when you need observability, not a gate.
On throw — what a thrown error actually does at the call site, traced to the code, because this is where accidental and deliberate behaviour diverge. A blocks seam usually surfaces the throw as a specific protocol error; a fire-and-forget seam swallows it. Rows call out the cases where a throw does something surprising.
Stability. A trigger-point's export name is public API under stability.md — renaming one is a breaking change, and its throw contract is part of the wire behaviour the servers promise. The Symbol column names the exact export; that name, not the informal trigger-point label, is the stable identifier.
Authorization server — @udibo/oauth2/server
Seams on AuthorizationServer and its grants. Unless noted, a throw from a seam
reached through an endpoint is caught by that endpoint and converted to an RFC
6749 §5.2 error response (or, on the authorize path once the redirect URI is
validated, a redirect to the client with error=…).
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| introspection-authorization | canIntrospectToken (AuthorizationServerOptions) | After client authentication and live-token lookup, before claims enrichment | no (omit → all admitted clients may inspect any token) | blocks | false → inactive response; throw → error response |
| services-resolver | resolve (AuthorizationServerOptions) | Top of every endpoint, to resolve per-request services | yes | blocks | Converted to an error response on token/authorize/revoke/introspect/device, and on the metadata and JWKS discovery endpoints |
| authenticate | AuthenticateUserFn (handleAuthorizeRequest arg) | GET /authorize, after client/PKCE and prompt/max_age/acr_values validation | yes | blocks | null → access_denied; a Response short-circuits (login_required under prompt=none); a user failing prompt=login/max_age → login_required; an OAuth2 error redirects to the client, other throws → direct error response |
| consent | HandleConsentFn (handleAuthorizeRequest arg) | GET /authorize, when the requested scope needs consent or the request has prompt=consent | no (omit → auto-grant; prompt=consent → consent_required) | blocks | { approved: false } denies; a Response under prompt=none → consent_required; same redirect/error conversion as authenticate |
| token-claims | userClaims (AuthorizationServerOptions) | Assembling id_token and UserInfo claims (never the access token) | no | blocks | Converted to a token-endpoint / UserInfo error response |
| subject | subjectOf (AuthorizationServerOptions) | Assembling the sub for id_token and UserInfo | no (defaults to user.id) | blocks | Converted to a token-endpoint / UserInfo error response |
| signing-key | SigningKeyProvider (signingKeys) | mintIdToken (getSigningKey) and GET /jwks (getPublicJwks) | no (unset → OIDC surface off) | blocks | getSigningKey throw → token-endpoint server_error; getPublicJwks throw → JWKS-endpoint error response |
| redirect-guard | IsPublicSuffix (isPublicSuffix) | GET /authorize, validating a wildcard redirect_uri | no (unset → wildcards refused) | blocks | Caught before a redirect URI is chosen → direct error response (bundled impl never throws). Must be sync, no I/O |
| challenge-method | ChallengeMethods (challengeMethods) | PKCE verification during the code→token exchange | no (defaults to S256) | blocks | Converted to a token-endpoint error response |
| client-store | ClientServiceInterface (clientService) | Client lookup/auth on every token/revoke/introspect/device call | yes | blocks | undefined → invalid_client; other throws converted to an error response |
| token-store | TokenServiceInterface (tokenService) | Token issuance, refresh, revoke, introspect | yes | blocks | Converted to a token-endpoint error response |
| token-reuse | onTokenReuse (RefreshTokenGrantOptions) | A rotated-out refresh token is replayed to the token endpoint, after the family is revoked; not inside replayGraceSeconds, nor when /revoke revokes a rotated-out token's family (see rotated-out-revocation) | no | fire-and-forget | Isolated: caught and logged, never rethrown, so a throw can't change the invalid_grant a replayed token yields |
| rotated-out-revocation | onRotatedOutRevocation (AuthorizationServerOptions) | POST /revoke revokes the family of a rotated-out refresh token issued to the authenticated client, after revokeFamily | no | fire-and-forget | Isolated: caught and logged, never rethrown, so a throw can't change the 200 the endpoint answers |
| code-reuse | onCodeReuse (AuthorizationCodeGrantOptions) | A claimed authorization code is presented again and revokeCode revoked the tokens issued from it; not for another client's replay when revokeCode honors client, nor for a replay by a client that did not authenticate with its secret unless it presents the code_verifier that redeemed the code | no | fire-and-forget | Isolated: caught and logged, never rethrown, so a throw can't change the invalid_grant a replayed code yields |
| refresh-cap | refreshTokenFamilyExpiresAt (TokenServiceInterface) | Refresh-token issuance and rotation | no (absent → uncapped) | blocks | A past cap → invalid_grant; other throws → token-endpoint error response |
| accepted-scope | acceptedScope (TokenServiceInterface) | Scope narrowing during issuance | yes (on the interface) | blocks | false → invalid_scope; other throws converted |
| authorization-code-store | AuthorizationCodeServiceInterface (authorizationCodeService) | Authorize (mint code) and code→token exchange | with the code grant | blocks | Missing/expired → invalid_grant; other throws converted |
| device-code-store | DeviceAuthorizationServiceInterface (deviceAuthorizationService) | Device-authorization request and polling | with the device grant | blocks | RFC 8628 codes (authorization_pending, slow_down, expired_token, …); other throws converted |
| user-authenticator | UserServiceInterface (userService) | PasswordGrant.token — the only MFA-enforcement point for the (deprecated) password grant | with the password grant | blocks | undefined → invalid_grant; a thrown OAuth2 error surfaces verbatim, other throws → server_error |
| scope-constructor | ScopeConstructor (Scope) | Parsing/comparing scope strings | no (defaults to BasicScope) | blocks | Follows its host flow's conversion path |
Resource server — @udibo/oauth2/server/resource
authenticate() does not try/catch its seams; the adapter boundary
(handleAuthError) converts what they throw. The readers deliberately keep a
down issuer (temporarily_unavailable) distinct from an invalid token
(invalid_token).
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| services-resolver | resolve (ResourceServerOptions) | Top of authenticate(), after the bearer is extracted | yes | blocks | Propagates to the handler boundary; a non-OAuth2 throw → server_error |
| token-reader | TokenReaderInterface (tokenService) | authenticate() validates the access token | yes | blocks | undefined → invalid_token; a reader may throw temporarily_unavailable/server_error |
| token-owner | getClient / getUser (both readers' options) | After a token validates, to resolve client/user | getClient yes, getUser no | blocks | Not wrapped — a throw propagates out of getToken raw (surfaces as server_error) |
| reader-fetch | fetch (both readers' options) | Introspection POST / JWKS + discovery fetch | no | blocks | Wrapped: transport/5xx → temporarily_unavailable, 4xx / bad body → server_error |
Identity — @udibo/oauth2/identity
The identity protections are opt-in: rateLimiter and lockout do nothing
unless you pass them. passwordPolicy is not — the policy check runs on every
signUp and resetPassword, at its 8–256 character defaults when you pass
nothing; the option only tightens it. A throw from a store or hook here
propagates out of the IdentityService method you called and up to your route,
unless the row says otherwise.
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| user-store | IdentityUserStore<User> (users) | Every flow (create in signUp, findByIdentifier/getCredential in signIn, …) | yes | blocks | Propagates and aborts the flow. Throwing from create is the only pre-registration veto (see Known gaps). markEmailVerified runs before the token is consumed, so a throw leaves the verification link retryable |
| credential-import | LegacyPasswordVerifier (legacyVerifiers) | signIn, when a user has an imported hash but no native credential | no | blocks | Fail-closed: canVerify/verify throws are caught → verifier skipped / returns false; never authenticates, never aborts |
| delivery | DeliveryHooks (delivery) | After a verify/reset/unlock/sign-in token or code is minted | no (omit a hook → that message disabled) | blocks (side-effect) | A throw is caught for every hook, code included: the minted token or code is invalidated (that exact one) and the flow still resolves with its uniform result — no orphaned credential, no mailer-outage enumeration oracle. Emits delivery.failed; invalidated: false means the cleanup failed too and the credential is still live |
| event | IdentityEventHook (onEvent) | After every flow outcome | no | fire-and-forget | Caught and logged, never rethrown — cannot break the flow (deliberate) |
| password-policy | PasswordPolicy.validators (passwordPolicy) | signUp and resetPassword, before hashing (defaults apply when unset) | no | blocks | Return an issue string to reject (→ weak_password); a validator that throws is trapped to weak_password too |
| rate-limiter | RateLimiterLike (rateLimiter / rateLimiters) | Start of each throttled flow (check); reset on success | no (opt-in) | blocks | A limit hit under enforce → rate_limited; a limiter that throws → rate_limited under enforce (fail closed), logged-and-allowed under log-only |
| rate-limit-store | RateLimitStore | Backs the built-in RateLimiter | yes, for the built-in RateLimiter | blocks | Propagates through RateLimiter.check/reset |
| lockout | AccountLockoutLike (lockout) | signIn (status/recordFailure), reset on success/reset/unlock | no (opt-in) | blocks | Propagates and aborts the flow; enforcement itself is uniform-null (no lockout oracle) |
| lockout-store | LockoutStore | Backs the built-in AccountLockout | yes, for the built-in AccountLockout | blocks | Propagates through the AccountLockoutLike call sites |
| token-flow-store | TokenFlowStore (via tokens) | All request* / verify* / reset* / unlock* / consume* flows | required for those flows | blocks | Propagates and aborts the flow — except while minting in requestPasswordReset / requestSignInLink, where a throw is trapped like a delivery failure: the call resolves void, nothing is sent, and credential_mint.failed is the only signal (a store outage is not an enumeration oracle) |
| otp-store | OtpStore (otp.store) | requestSignInCode / verifySignInCode | required for the code flow | blocks | Propagates and aborts the flow — except while minting in requestSignInCode, trapped as for token-flow-store (credential_mint.failed) |
| session-revocation | RevocableSessionService (sessions) | resetPassword, after the new credential is set (after the resetCredential transaction commits, when the user store implements it) | no | blocks (side-effect) | A throw (after the password change; with resetCredential, after the stored sessions were revoked too) emits password_reset.failed (session_revocation_failed, with userId), suppresses password_reset.completed, and rethrows — the reset is reported failed, not silently half-complete. The token is already spent, so re-run revokeAllByUser(userId) from the event. See partial-failure recovery |
| session-listing | ListableSessionService | App-driven (a "where you're signed in" screen) | no | n/a (app calls it) | Propagates to your caller |
| captcha | CaptchaProvider (via verifyCaptcha) | App-driven, in your route before signUp / signIn / the email-sending requests | no (no provider → unchallenged pass) | blocks (you enforce it) | Never propagates. verifyCaptcha traps a provider throw and returns a decision: failOpen: true (default) → "pass", false → "fail", both with degraded: true. A { success: false } return is a real rejection ("fail", degraded: false); reject it with IdentityError captcha_failed (403) |
| identifier-lookup | IdentifierLookups<User> (createIdentifierResolver) | When you call the resolver to classify + look up an identifier | each kind optional | blocks | Propagates to your caller |
| otp-deliver | RequestOtpOptions.onDeliver (EmailOtpService.request) | After the OTP hash is stored, to hand the raw code to transport | required for direct EmailOtpService use | blocks (side-effect) | Propagates out of request, after the code is stored |
| breach-check | breachedPasswordValidator (fetch / onEvent) | The HIBP range lookup a passwordPolicy validator runs | both optional | blocks / fire-and-forget | fetch failure caught → fail-open (default) or fail-closed; onEvent swallowed. Safe under the no-catch validator call site |
External providers — @udibo/oauth2/identity/external
ExternalAuthFlow is deliberately storage-free and has no callback hooks of
its own: finish() returns a verified ExternalProfile and your app does
find-or-create-user, session start, and rejection outside the flow, in its own
try/catch. The seams below live on the provider connectors.
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| external-provider | ExternalProvider (provider) | External start (buildAuthorizationUrl) and callback (fetchProfile) | yes | blocks | Propagates and aborts start/callback; built-ins throw ExternalAuthError |
| profile-mapper | OAuth2ProfileMapper (mapProfile) | External callback, mapping the raw profile | with oauth2Provider | blocks | A missing/empty subject in the return → provider_error; a mapper that throws surfaces raw |
| client-secret | AppleClientSecretFactory (clientSecret) | Apple callback, signing a fresh client-secret JWT | no (defaults to the built-in factory) | blocks | Not wrapped — a custom factory's throw propagates raw; the default throws configuration |
| provider-fetch | fetch (every connector config) | Discovery, token exchange, UserInfo, JWKS | no (defaults to globalThis.fetch) | blocks | Wrapped → provider_error; OIDC UserInfo failure is non-fatal (swallowed → null) |
MFA — @udibo/oauth2/identity/mfa
MfaService decides nothing about when to demand a factor — that stays your
app's sign-in policy. It exposes read methods (enrollmentStatus, isEnrolled)
and a verify you call; there is no app-supplied challenge hook to veto (see
Gaps). The app implements one required interface plus the two shared optional
seams.
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| mfa-store | MfaStore (store) | Enroll, verify, disable, regenerate | yes | blocks | Propagates; confirmEnrollment specially rolls back (clearTotp) and rethrows if recovery-code persistence fails |
| rate-limiter | RateLimiterLike (rateLimiter) | Start of each verify and confirmEnrollment (check); reset on success | no | blocks | A check that throws → rate_limited under enforce (fail closed), logged-and-allowed under log-only; a limit hit under enforce → rate_limited. A reset that throws is logged and never fails the verification — the code is already spent by then |
| event | IdentityEventHook (onEvent) | mfa.* outcomes | no | fire-and-forget | Swallowed and logged |
Hono BFF — @udibo/oauth2/hono/bff
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| session-store | SessionStore (sessionStore) | Callback, refresh, session read, logout | yes | blocks | In /auth/callback → invalid_grant; in refresh/logout/session paths → propagates to Hono |
| backchannel-logout-store | destroyByLogout (SessionStore capability) | POST /auth/backchannel | with backchannelLogout | blocks | Propagates to Hono (500) |
| refresh-lock-store | withRefreshLock (SessionStore capability) | Each token refresh, once per session per instance | no | blocks | 502 temporarily_unavailable, also when a store call inside the lock throws; the session is kept |
| refresh-lease | RefreshLease (withRefreshLock argument) | Storing a refreshed session, or ending it after the authorization server refuses the refresh token | no | blocks | A refused update or destroy → the session is re-read: gone → signed out; a peer's tokens → used; otherwise 502 and the session is kept. Any other throw → 502, session kept |
| resolve-user | resolveUser (HonoBffOptions) | /auth/callback, enriching session user claims | no | blocks | Relabeled as invalid_grant "code exchange failed" even though it runs post-exchange (see Failure behavior) |
| callback-error | onCallbackError (HonoBffOptions) | /auth/callback cannot complete | no (default 400 JSON) | blocks | Invoked with no guard — its own throw propagates to Hono (500) (see Failure behavior) |
| resolve-origin | resolveOrigin (HonoBffOptions) | Login/callback/logout, to pick the trusted origin | no | blocks | Login/logout → propagates to Hono; callback → server_error before the iss check (RFC 9207) |
| auth-request-storage | authRequestStorage (HonoBffOptions) | /auth/login and /auth/callback, per request | yes | blocks | Login → propagates; callback → invalid_grant |
| logout-token-verifier | verifyLogoutToken (backchannelLogout) | POST /auth/backchannel, verifying the JWT | with backchannelLogout | blocks | Throw or null → 400 invalid_request (deliberate "throw-or-null to reject" contract) |
| proxy-fetch | fetch (HonoBffProxyOptions) | The upstream call in bff.proxy | no | blocks | Wrapped → 502 temporarily_unavailable |
Hono adapters — authorization server & identity
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| authenticate | HonoAuthenticateUserFn (authenticateUser) | GET /authorize (Hono adapter) | yes | blocks | Passed through to the core server, which converts it (same contract as the core seam) |
| consent | HonoHandleConsentFn (handleConsent) | GET /authorize after auth (Hono adapter) | no (omit → auto-grant) | blocks | Passed through to the core server |
| post-authentication | onAuthenticated (HonoIdentityOptions) | After signUp/signIn, to mint your session | no, but required to mount the /signup + /signin routes | blocks | A thrown IdentityError → JSON error response; any other throw propagates to Hono (500) |
Client — @udibo/oauth2/client
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| event-listener | OAuth2ClientEventListener (subscribe) | Every client state change | no | fire-and-forget | Swallowed — a synchronous listener throw is isolated so other subscribers still receive the event (deliberate) |
| token-storage | TokenStorage (tokenStorage) | Token read/persist/clear | no (defaults to memory) | blocks | Propagates and aborts the calling method |
| refresh-token-storage | RefreshTokenStorage (refreshTokenStorage) | Refresh-token read/persist/clear | in a browser no (defaults to memory); elsewhere yes for a sign-in or device flow, which throw TypeError without it | blocks | Propagates and aborts the calling method |
| auth-request-storage | AuthRequestStorage (authRequestStorage) | DirectClient.login / exchangeAuthorizationCode | in a browser no (defaults to SessionStorageAuthRequestStorage); elsewhere yes, on the client or per call, or the call throws TypeError | blocks | Propagates and aborts the calling method |
| discovery-cache | DiscoveryCache (discoveryCache) | Every endpoint use of an issuer client whose held copy has expired. The client keeps a copy either way: until the expiresAt its resolve reports when a cache is supplied, or DEFAULT_DISCOVERY_TTL_MS (1 h) when one is not — so a cache is read once per entry lifetime, not once per lookup | no | blocks | Propagates to discover(); a rejected load is deliberately not cached |
| client-fetch | fetch (BaseOptions) | Every outbound HTTP call | no (defaults to globalThis.fetch) | blocks | Mostly propagates; #fetchMetadata retries both well-known paths before rethrowing |
React auth forms — @udibo/oauth2/react/components
Seams on the headless useAuthForm hook and the prebuilt components.
| Trigger point | Symbol | Fires | Required | Control | On throw |
|---|---|---|---|---|---|
| form-submit | AuthSubmitHandler (onSubmit) | Form submit, after validate passes | yes | blocks | Caught → form-level error (status: "error"); does not propagate |
| form-validate | validate (UseAuthFormOptions) | Synchronously at the start of submit | no | blocks | Caught → form-level error (status: "error"), like onSubmit |
| form-success | onSuccess (UseAuthFormOptions) | After a submit with no errors | no | fire-and-forget | Caught → console.error; deliberately isolated |
| social-select | onSocialSelect / socialHref (SocialProvidersProps) | A social button is clicked / rendered | no | blocks | Surfaces through React's event/render path |
Known gaps
These responsibilities stay in the application or use a separate API.
No dedicated pre-registration veto. The only way to reject a sign-up on something other than the password is to throw from
IdentityUserStore.create(or reject the password with apasswordPolicyvalidator). There is nobeforeCreate/allowlist/domain-restriction seam.No service-layer post-authentication hook. After
signInverifies a credential, nothing app-supplied can inspect or veto the success (e.g. force MFA, reject a disabled user) — that gating lives in your route.Access-token claims use a separate API. The authorization server's
userClaimsreaches ID tokens and UserInfo. UsecreateJwtAccessTokenGeneratorand its claims options when your app issues JWT access tokens.No MFA-challenge seam in
IdentityService.IdentityServicehas zero MFA integration; MFA is the separate@udibo/oauth2/identity/mfamodule, and when to demand a factor is your app's decision.
Failure behavior to account for
A signing failure surfaces after the token row is persisted.
tokenService.saveruns beforemintIdToken/ JWT-access-token generation, so a signing-key outage returnsserver_errorfor a token that already exists.Inconsistent legacy-credential contract. A
getLegacyCredentialthrow aborts sign-in, but aclearLegacyCredentialthrow is swallowed — even though the docs tell implementers to provide both together.BFF
resolveUserthrows are misclassified. A failure in this post-exchange app hook is relabeledinvalid_grant"code exchange failed" because it sits inside the exchangetry/catch.BFF
onCallbackErroris unguarded. A throw from the error handler itself propagates to Hono (500).

