Skip to content

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 pointSymbolFiresRequiredControlOn throw
introspection-authorizationcanIntrospectToken (AuthorizationServerOptions)After client authentication and live-token lookup, before claims enrichmentno (omit → all admitted clients may inspect any token)blocksfalse → inactive response; throw → error response
services-resolverresolve (AuthorizationServerOptions)Top of every endpoint, to resolve per-request servicesyesblocksConverted to an error response on token/authorize/revoke/introspect/device, and on the metadata and JWKS discovery endpoints
authenticateAuthenticateUserFn (handleAuthorizeRequest arg)GET /authorize, after client/PKCE validationyesblocksnullaccess_denied; a Response short-circuits; an OAuth2 error redirects to the client, other throws → direct error response
consentHandleConsentFn (handleAuthorizeRequest arg)GET /authorize, only when the requested scope needs consentno (omit → auto-grant)blocks{ approved: false } denies; same redirect/error conversion as authenticate
token-claimsuserClaims (AuthorizationServerOptions)Assembling id_token and UserInfo claims (never the access token)noblocksConverted to a token-endpoint / UserInfo error response
subjectsubjectOf (AuthorizationServerOptions)Assembling the sub for id_token and UserInfono (defaults to user.id)blocksConverted to a token-endpoint / UserInfo error response
signing-keySigningKeyProvider (signingKeys)mintIdToken (getSigningKey) and GET /jwks (getPublicJwks)no (unset → OIDC surface off)blocksgetSigningKey throw → token-endpoint server_error; getPublicJwks throw → JWKS-endpoint error response
redirect-guardIsPublicSuffix (isPublicSuffix)GET /authorize, validating a wildcard redirect_urino (unset → wildcards refused)blocksCaught before a redirect URI is chosen → direct error response (bundled impl never throws). Must be sync, no I/O
challenge-methodChallengeMethods (challengeMethods)PKCE verification during the code→token exchangeno (defaults to S256)blocksConverted to a token-endpoint error response
client-storeClientServiceInterface (clientService)Client lookup/auth on every token/revoke/introspect/device callyesblocksundefinedinvalid_client; other throws converted to an error response
token-storeTokenServiceInterface (tokenService)Token issuance, refresh, revoke, introspectyesblocksConverted to a token-endpoint error response
token-reuseonTokenReuse (RefreshTokenGrantOptions)A rotated-out refresh token is replayed, after the family is revokednofire-and-forgetIsolated: caught and logged, never rethrown, so a throw can't change the invalid_grant a replayed token yields
refresh-caprefreshTokenFamilyExpiresAt (TokenServiceInterface)Refresh-token issuance and rotationno (absent → uncapped)blocksA past cap → invalid_grant; other throws → token-endpoint error response
accepted-scopeacceptedScope (TokenServiceInterface)Scope narrowing during issuanceyes (on the interface)blocksfalseinvalid_scope; other throws converted
authorization-code-storeAuthorizationCodeServiceInterface (authorizationCodeService)Authorize (mint code) and code→token exchangewith the code grantblocksMissing/expired → invalid_grant; other throws converted
device-code-storeDeviceAuthorizationServiceInterface (deviceAuthorizationService)Device-authorization request and pollingwith the device grantblocksRFC 8628 codes (authorization_pending, slow_down, expired_token, …); other throws converted
user-authenticatorUserServiceInterface (userService)PasswordGrant.token — the only MFA-enforcement point for the (deprecated) password grantwith the password grantblocksundefinedinvalid_grant; a thrown OAuth2 error surfaces verbatim, other throws → server_error
scope-constructorScopeConstructor (Scope)Parsing/comparing scope stringsno (defaults to BasicScope)blocksFollows 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 pointSymbolFiresRequiredControlOn throw
services-resolverresolve (ResourceServerOptions)Top of authenticate(), after the bearer is extractedyesblocksPropagates to the handler boundary; a non-OAuth2 throw → server_error
token-readerTokenReaderInterface (tokenService)authenticate() validates the access tokenyesblocksundefinedinvalid_token; a reader may throw temporarily_unavailable/server_error
token-ownergetClient / getUser (both readers' options)After a token validates, to resolve client/usergetClient yes, getUser noblocksNot wrapped — a throw propagates out of getToken raw (surfaces as server_error)
reader-fetchfetch (both readers' options)Introspection POST / JWKS + discovery fetchnoblocksWrapped: 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 pointSymbolFiresRequiredControlOn throw
user-storeIdentityUserStore<User> (users)Every flow (create in signUp, findByIdentifier/getCredential in signIn, …)yesblocksPropagates and aborts the flow. Throwing from create is today's only pre-registration veto (see Gaps). markEmailVerified runs before the token is consumed, so a throw leaves the verification link retryable
credential-importLegacyPasswordVerifier (legacyVerifiers)signIn, when a user has an imported hash but no native credentialnoblocksFail-closed: canVerify/verify throws are caught → verifier skipped / returns false; never authenticates, never aborts
deliveryDeliveryHooks (delivery)After a verify/reset/unlock/sign-in token or code is mintedno (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
eventIdentityEventHook (onEvent)After every flow outcomenofire-and-forgetCaught and logged, never rethrown — cannot break the flow (deliberate)
password-policyPasswordPolicy.validators (passwordPolicy)signUp and resetPassword, before hashing (defaults apply when unset)noblocksReturn an issue string to reject (→ weak_password); a validator that throws is trapped to weak_password too
rate-limiterRateLimiterLike (rateLimiter / rateLimiters)Start of each throttled flow (check); reset on successno (opt-in)blocksA limit hit under enforcerate_limited; a limiter that throwsrate_limited under enforce (fail closed), logged-and-allowed under log-only
rate-limit-storeRateLimitStoreBacks the built-in RateLimiternoblocksPropagates through RateLimiter.check/reset
lockoutAccountLockoutLike (lockout)signIn (status/recordFailure), reset on success/reset/unlockno (opt-in)blocksPropagates and aborts the flow; enforcement itself is uniform-null (no lockout oracle)
lockout-storeLockoutStoreBacks the built-in AccountLockoutnoblocksPropagates through the AccountLockoutLike call sites
token-flow-storeTokenFlowStore (via tokens)All request* / verify* / reset* / unlock* / consume* flowsrequired for those flowsblocksPropagates and aborts the flow
otp-storeOtpStore (otp.store)requestSignInCode / verifySignInCoderequired for the code flowblocksPropagates and aborts the flow
session-revocationRevocableSessionService (sessions)resetPassword, after the new credential is setnoblocks (side-effect)A throw (after the password change) emits password_reset.failed (session_revocation_failed), suppresses password_reset.completed, and rethrows — the reset is reported failed/retryable, not silently half-complete
session-listingListableSessionServiceApp-driven (a "where you're signed in" screen)non/a (app calls it)Propagates to your caller
captchaCaptchaProvider (via verifyCaptcha)App-driven, in your route before signUp / signIn / the email-sending requestsno (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-lookupIdentifierLookups<User> (createIdentifierResolver)When you call the resolver to classify + look up an identifiereach kind optionalblocksPropagates to your caller
otp-deliverRequestOtpOptions.onDeliver (EmailOtpService.request)After the OTP hash is stored, to hand the raw code to transportrequired for direct EmailOtpService useblocks (side-effect)Propagates out of request, after the code is stored
breach-checkbreachedPasswordValidator (fetch / onEvent)The HIBP range lookup a passwordPolicy validator runsboth optionalblocks / fire-and-forgetfetch 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 pointSymbolFiresRequiredControlOn throw
external-providerExternalProvider (provider)External start (buildAuthorizationUrl) and callback (fetchProfile)yesblocksPropagates and aborts start/callback; built-ins throw ExternalAuthError
profile-mapperOAuth2ProfileMapper (mapProfile)External callback, mapping the raw profilewith oauth2ProviderblocksA missing/empty subject in the returnprovider_error; a mapper that throws surfaces raw (see Findings)
client-secretAppleClientSecretFactory (clientSecret)Apple callback, signing a fresh client-secret JWTno (defaults to the built-in factory)blocksNot wrapped — a custom factory's throw propagates raw; the default throws configuration
provider-fetchfetch (every connector config)Discovery, token exchange, UserInfo, JWKSno (defaults to globalThis.fetch)blocksWrapped → 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 pointSymbolFiresRequiredControlOn throw
mfa-storeMfaStore (store)Enroll, verify, disable, regenerateyesblocksPropagates; confirmEnrollment specially rolls back (clearTotp) and rethrows if recovery-code persistence fails
rate-limiterRateLimiterLike (rateLimiter)Start of each verify (check); reset on successnoblocksA limit hit under enforcerate_limited; a limiter that throwsrate_limited under enforce (fail closed), logged-and-allowed under log-only
eventIdentityEventHook (onEvent)mfa.* outcomesnofire-and-forgetSwallowed and logged

Hono BFF — @udibo/oauth2/hono/bff

Trigger pointSymbolFiresRequiredControlOn throw
session-storeSessionStore (sessionStore)Callback, refresh, session read, logoutno (defaults to memory)blocksIn /auth/callbackinvalid_grant; in refresh/logout/session paths → propagates to Hono
backchannel-logout-storedestroyByLogout (SessionStore capability)POST /auth/backchannelwith backchannelLogoutblocksPropagates to Hono (500)
resolve-userresolveUser (HonoBffOptions)/auth/callback, enriching session user claimsnoblocksRelabeled as invalid_grant "code exchange failed" even though it runs post-exchange (see Findings)
callback-erroronCallbackError (HonoBffOptions)/auth/callback cannot completeno (default 400 JSON)blocksInvoked with no guard — its own throw propagates to Hono (500) (see Findings)
resolve-originresolveOrigin (HonoBffOptions)Login/callback/logout, to pick the trusted originnoblocksLogin/logout → propagates to Hono; callback → relabeled as invalid_grant "code exchange failed"
auth-request-storageauthRequestStorage (HonoBffOptions)/auth/login and /auth/callback, per requestnoblocksLogin → propagates; callback → invalid_grant
logout-token-verifierverifyLogoutToken (backchannelLogout)POST /auth/backchannel, verifying the JWTwith backchannelLogoutblocksThrow or null400 invalid_request (deliberate "throw-or-null to reject" contract)
proxy-fetchfetch (HonoBffProxyOptions)The upstream call in bff.proxynoblocksWrapped → 502 temporarily_unavailable

Hono adapters — authorization server & identity

Trigger pointSymbolFiresRequiredControlOn throw
authenticateHonoAuthenticateUserFn (authenticateUser)GET /authorize (Hono adapter)yesblocksPassed through to the core server, which converts it (same contract as the core seam)
consentHonoHandleConsentFn (handleConsent)GET /authorize after auth (Hono adapter)no (omit → auto-grant)blocksPassed through to the core server
post-authenticationonAuthenticated (HonoIdentityOptions)After signUp/signIn, to mint your sessionno, but required to mount the /signup + /signin routesblocksA thrown IdentityError → JSON error response; any other throw propagates to Hono (500)

Client — @udibo/oauth2/client

Trigger pointSymbolFiresRequiredControlOn throw
event-listenerOAuth2ClientEventListener (subscribe)Every client state changenofire-and-forgetSwallowed — a synchronous listener throw is isolated so other subscribers still receive the event (deliberate)
token-storageTokenStorage (tokenStorage)Token read/persist/clearno (defaults to memory)blocksPropagates and aborts the calling method
refresh-token-storageRefreshTokenStorage (refreshTokenStorage)Refresh-token read/persist/clearno (defaults to memory)blocksPropagates and aborts the calling method
auth-request-storageAuthRequestStorage (authRequestStorage)DirectClient.login / exchangeAuthorizationCodeno (defaults to SessionStorageAuthRequestStorage in a browser document, memory elsewhere)blocksPropagates and aborts the calling method
discovery-cacheDiscoveryCache (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 lookupnoblocksPropagates to discover(); a rejected load is deliberately not cached
client-fetchfetch (BaseOptions)Every outbound HTTP callno (defaults to globalThis.fetch)blocksMostly 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 pointSymbolFiresRequiredControlOn throw
form-submitAuthSubmitHandler (onSubmit)Form submit, after validate passesyesblocksCaught → form-level error (status: "error"); does not propagate
form-validatevalidate (UseAuthFormOptions)Synchronously at the start of submitnoblocksCaught → form-level error (status: "error"), like onSubmit
form-successonSuccess (UseAuthFormOptions)After a submit with no errorsnofire-and-forgetCaught → console.error; deliberately isolated
social-selectonSocialSelect / socialHref (SocialProvidersProps)A social button is clicked / renderednoblocksSurfaces 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 a passwordPolicy validator). There is no beforeCreate/allowlist/domain-restriction seam.

  • No service-layer post-authentication hook. After signIn verifies 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 userClaims reaches ID tokens and UserInfo. Use createJwtAccessTokenGenerator and its claims options when your app issues JWT access tokens.

  • No MFA-challenge seam in IdentityService. IdentityService has zero MFA integration; MFA is the separate @udibo/oauth2/identity/mfa module, 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.save runs before mintIdToken / JWT-access-token generation, so a signing-key outage returns server_error for a token that already exists.

  • Inconsistent legacy-credential contract. A getLegacyCredential throw aborts sign-in, but a clearLegacyCredential throw is swallowed — even though the docs tell implementers to provide both together.

  • BFF resolveUser / resolveOrigin throws are misclassified. Failures in these post-exchange app hooks are relabeled invalid_grant "code exchange failed" because they sit inside the exchange try/catch.

  • BFF onCallbackError is unguarded. A throw from the error handler itself propagates to Hono (500).