Skip to content
Udibo
On this page
View Markdown

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 and prompt/max_age/acr_values validationyesblocksnull → 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
consentHandleConsentFn (handleAuthorizeRequest arg)GET /authorize, when the requested scope needs consent or the request has prompt=consentno (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-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 callyesblocksundefined → invalid_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 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)nofire-and-forgetIsolated: caught and logged, never rethrown, so a throw can't change the invalid_grant a replayed token yields
rotated-out-revocationonRotatedOutRevocation (AuthorizationServerOptions)POST /revoke revokes the family of a rotated-out refresh token issued to the authenticated client, after revokeFamilynofire-and-forgetIsolated: caught and logged, never rethrown, so a throw can't change the 200 the endpoint answers
code-reuseonCodeReuse (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 codenofire-and-forgetIsolated: caught and logged, never rethrown, so a throw can't change the invalid_grant a replayed code 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)blocksfalse → invalid_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 grantblocksundefined → invalid_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 tokenyesblocksundefined → invalid_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 the only pre-registration veto (see Known 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 enforce → rate_limited; a limiter that throws → rate_limited under enforce (fail closed), logged-and-allowed under log-only
rate-limit-storeRateLimitStoreBacks the built-in RateLimiteryes, for the built-in RateLimiterblocksPropagates 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 AccountLockoutyes, for the built-in AccountLockoutblocksPropagates through the AccountLockoutLike call sites
token-flow-storeTokenFlowStore (via tokens)All request* / verify* / reset* / unlock* / consume* flowsrequired for those flowsblocksPropagates 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-storeOtpStore (otp.store)requestSignInCode / verifySignInCoderequired for the code flowblocksPropagates and aborts the flow — except while minting in requestSignInCode, trapped as for token-flow-store (credential_mint.failed)
session-revocationRevocableSessionService (sessions)resetPassword, after the new credential is set (after the resetCredential transaction commits, when the user store implements it)noblocks (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-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 return → provider_error; a mapper that throws surfaces raw
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 and confirmEnrollment (check); reset on successnoblocksA 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
eventIdentityEventHook (onEvent)mfa.* outcomesnofire-and-forgetSwallowed and logged

Hono BFF — @udibo/oauth2/hono/bff

Trigger pointSymbolFiresRequiredControlOn throw
session-storeSessionStore (sessionStore)Callback, refresh, session read, logoutyesblocksIn /auth/callback → invalid_grant; in refresh/logout/session paths → propagates to Hono
backchannel-logout-storedestroyByLogout (SessionStore capability)POST /auth/backchannelwith backchannelLogoutblocksPropagates to Hono (500)
refresh-lock-storewithRefreshLock (SessionStore capability)Each token refresh, once per session per instancenoblocks502 temporarily_unavailable, also when a store call inside the lock throws; the session is kept
refresh-leaseRefreshLease (withRefreshLock argument)Storing a refreshed session, or ending it after the authorization server refuses the refresh tokennoblocksA 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-userresolveUser (HonoBffOptions)/auth/callback, enriching session user claimsnoblocksRelabeled as invalid_grant "code exchange failed" even though it runs post-exchange (see Failure behavior)
callback-erroronCallbackError (HonoBffOptions)/auth/callback cannot completeno (default 400 JSON)blocksInvoked with no guard — its own throw propagates to Hono (500) (see Failure behavior)
resolve-originresolveOrigin (HonoBffOptions)Login/callback/logout, to pick the trusted originnoblocksLogin/logout → propagates to Hono; callback → server_error before the iss check (RFC 9207)
auth-request-storageauthRequestStorage (HonoBffOptions)/auth/login and /auth/callback, per requestyesblocksLogin → propagates; callback → invalid_grant
logout-token-verifierverifyLogoutToken (backchannelLogout)POST /auth/backchannel, verifying the JWTwith backchannelLogoutblocksThrow or null → 400 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/clearin a browser no (defaults to memory); elsewhere yes for a sign-in or device flow, which throw TypeError without itblocksPropagates and aborts the calling method
auth-request-storageAuthRequestStorage (authRequestStorage)DirectClient.login / exchangeAuthorizationCodein a browser no (defaults to SessionStorageAuthRequestStorage); elsewhere yes, on the client or per call, or the call throws TypeErrorblocksPropagates 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 throws are misclassified. A failure in this post-exchange app hook is relabeled invalid_grant "code exchange failed" because it sits inside the exchange try/catch.

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