Skip to content
Udibo
On this page
View Markdown

Recover from partial failures in the identity flows

The identity flows write to several app-owned stores in sequence: a token or code store, your user store, your session store, the lockout store and the rate limiter. A store can fail between two of those writes. This guide lists every state such a failure can leave behind in password reset, email verification, passwordless sign-in and MFA recovery codes. For each state it gives the signal your app receives and the steps that finish or undo the change.

Single-use credentials are spent before the change they authorize, with one exception. Two concurrent submissions of one link or code therefore resolve to exactly one success, and the loser writes nothing. The cost of that ordering is that a failure after the spend leaves the credential used up. The user then asks for a new one. No state below leaves a spent credential usable.

The exception is email verification: markEmailVerified runs before the link is spent. Marking an address verified twice is harmless, while spending the link first would cost the user a new link whenever the write failed. That is why markEmailVerified must be idempotent.

Subscribe to onEvent and alert on the events named here. The flows also log the best-effort failures with console.error, prefixed [@udibo/oauth2].

Password reset

resetPassword spends the reset link, then writes the new password, clears an imported hash, voids pending sign-in links and codes, revokes the user's sessions and clears the failed-attempt lockout. Only the link spend comes first; the rest run in that order.

Failing stepAlready changedSignalRecovery
Hashing or setCredentialLink spentpassword_reset.failed with credential_write_failed and userId, error rethrownNone needed. The old password still works and no session was touched. The user requests a fresh link
clearLegacyCredentialPassword changedLogged; the reset succeedsClear the imported hash for the user. It is ignored while a native credential exists, but it would work again if that credential were removed
TokenFlowStore.deleteBySubjectPassword changedLogged; the reset succeedsPending sign-in links stay redeemable until they expire (15 minutes by default). Delete them from your token store to close that window
OtpStore.invalidatePassword changed, links voidedLogged; the reset succeedsThe pending sign-in code stays usable until it expires (10 minutes by default). Invalidate it in your code store
RevocableSessionService.revokeAllByUserPassword changed, passwordless credentials voidedpassword_reset.failed with session_revocation_failed and userId, error rethrownCall revokeAllByUser(userId) again until it succeeds. Until then, every session the user had before the reset stays signed in
AccountLockoutLike.resetPassword changed, sessions revokedpassword_reset.failed with lockout_reset_failed and userId, error rethrownCall reset(userId) on your lockout, or let the lock lapse. Until then a locked account cannot sign in with the new password

A failed reset never emits password_reset.completed, and the error reaches your route without saying how far the reset got. The new password is already in effect after session_revocation_failed and lockout_reset_failed, but not after credential_write_failed. So the message to the user should say that the password may have changed, and suggest signing in with the new password before requesting a new link. A locked account may still refuse it until the lock lapses.

Make the password write and the revocation one transaction

The session_revocation_failed row is the dangerous one: the user reset the password to lock someone out, and that person's session is still live. When your users and sessions share a database, implement the optional IdentityUserStore.resetCredential. It replaces the credential, drops any imported hash and revokes every session of the user in one transaction, and returns the number of sessions revoked. resetPassword then calls it instead of setCredential and clearLegacyCredential. A failure inside it rolls everything back, so it reports credential_write_failed with the old password and sessions intact. password_reset.completed carries sessionsRevoked, the number of sessions the reset revoked.

TypeScript
import type {
  IdentityUserStore,
  PasswordCredential,
} from "@udibo/oauth2/identity";

interface AppUser {
  id: string;
}
interface Transaction {
  updateCredential(userId: string, credential: PasswordCredential): Promise<void>;
  clearImportedHash(userId: string): Promise<void>;
  revokeSessions(userId: string): Promise<number>;
}
declare const users: IdentityUserStore<AppUser>;
declare function transaction<T>(
  work: (tx: Transaction) => Promise<T>,
): Promise<T>;

const store: IdentityUserStore<AppUser> = {
  ...users,
  resetCredential: (userId, credential) =>
    transaction(async (tx) => {
      await tx.updateCredential(userId, credential);
      await tx.clearImportedHash(userId);
      return await tx.revokeSessions(userId);
    }),
};

Session state that lives outside that database, such as a cache of accepted session ids, is outside the transaction. If you also configure a sessions service, resetPassword calls its revokeAllByUser after the transaction commits, so that state is revoked too. The sessions already revoked in the database are not revoked again, and a failure there reports session_revocation_failed with the password changed and the stored sessions already revoked. Leave sessions unset only when the transaction covers every place a session lives. Verify the implementation with the resetCredential option of runIdentityUserStoreContractTests (Integration testing).

Email verification

verifyEmail marks the address verified before it spends the link, so the link stays usable whenever the flag did not change. markEmailVerified must be idempotent for this to be safe.

Failing stepAlready changedSignalRecovery
markEmailVerifiedNothingError rethrownThe user opens the same link again
Spending the linkAddress verifiedError rethrown; no email_verification.completedThe user opens the same link again, which verifies idempotently and spends it. Left alone, the link expires. Until then it can verify only the address it was issued for, provided the token carries that email and your markEmailVerified filters on it
Spending the link loses a raceAddress verifiedResult invalid for this caller, completed for the winnerNone needed

Passwordless sign-in

The request half never surfaces a store or mailer failure, so that an outage is not an account-enumeration oracle. The events are the only signal.

Failing stepAlready changedSignalRecovery
Minting a link or codeNothingcredential_mint.failedThe user requests again once the store is back
Delivery, then invalidating the minted credentialNothing usabledelivery.failed with invalidated: trueThe user requests again once the mailer is back
Delivery, and invalidation also failsA link or code nobody received is livedelivery.failed with invalidated: falseInvalidate it in your token or code store, or let it expire. Nobody holds it, so this is a window, not a leak
Throttle reset after a verified codeCode spentLogged; the result is still successNone needed. The throttle window stays filled until it lapses
Your session start after successLink or code spentYour own error handlingThe user requests a fresh link or code. The flow cannot replay a spent one

MFA recovery codes

MfaService.verify reads the user's remaining recovery codes before it burns the submitted one. A read failure therefore fails the attempt with the code still unspent, and the reported remainingRecoveryCodes comes from that read.

Failing stepAlready changedSignalRecovery
Reading the remaining codesNothingError rethrownThe user submits the same code again
consumeRecoveryHashNothing, if the store is atomicError rethrownThe user submits the same code again
Throttle reset after a burned codeCode spentLogged; verification succeedsNone needed
setRecoveryHashes in regenerateRecoveryCodesNothing; the old codes still workError rethrownThe user regenerates again. A store must replace the set in one write for this to hold
Recovery codes stored after enrollment activatesActivation rolled backError rethrownThe user enrolls again. If the rollback also fails, MFA stays active without recovery codes: have the user regenerate them
clearTotp in disable, after the codes were clearedRecovery codes removedError rethrownMFA stays on with TOTP only. Call disable again