Skip to main content

Flows

WIP

Sign-in and sign-out, step-up, password reset, email verification, account deletion with undo, staged sign-up, impersonation, and provider linking

auth.flows runs the multi-step operations. Each one composes providers, sessions and identities, and answers the intents your server adapter applies to the response: cookies to set or clear, a redirect, a JSON body.

Signing in and out

const { session, sid, intents } = await auth.flows.signIn({
  providerId: 'password',
  input: { email: 'ada@example.com', password: 'correct-horse-battery' },
  previousSid, // the sid the request arrived with, if any; it is revoked
  ip,
  userAgent,
})

const { intents: cleared } = await auth.flows.signOut(sid)
  • signIn hands input to the provider's complete, mints the session through rotation, and emits signin.success.
  • session is null and sid empty when the provider answered with something other than a session, such as an OAuth redirect.
  • An unknown providerId throws AUTH_PROVIDER_FAILED.
  • signOut revokes the session, ignores one already gone, and answers the intents that clear the transport.

auth.flows.beginProvider(id, input) runs a provider's first leg: the magic-link mail, the OAuth redirect, the passkey challenge.

Step-up

Enrolling a second factor does not change sign-in, which still answers an AAL 1 session. A privileged route asks for more with checkStepUp:

const need = await auth.flows.checkStepUp(session, { aal: 2, methods: ['totp'], freshness: 5 * 60_000 })
if (!need.satisfied) {
  // need.reason: 'mfa-required' | 'fresh-required'; need.methods: what would satisfy it
}

const stepped = await auth.flows.completeStepUp({ currentSid: sid, method: 'totp', code, ip, userAgent })
stepped.session.aal // 2

completeStepUp takes 'totp', 'backup-code', or 'webauthn' with a webauthn response, is rate limited per identity, and rotates to an AAL 2 session. A wrong code throws AUTH_INVALID_CREDENTIALS. See Adding MFA.

Password reset

await auth.flows.requestPasswordReset({
  input: { email },
  findIdentityByEmail: (email) => orNull(auth.identities.getByEmail(email)),
})

await auth.flows.completePasswordReset({ token, newPassword })
  • The request answers { ok: true } whether or not the address exists, and mails a password-reset link through Delivery when it does.
  • Completing it spends the token, revokes every session of the account, then sets the password. A bad token throws AUTH_RECOVERY_TOKEN_INVALID, an old one AUTH_RECOVERY_TOKEN_EXPIRED.
  • When the account has TOTP or WebAuthn MFA, a mailed link alone is not enough: it throws AUTH_RECOVERY_REQUIRES_MFA with the methods, until the call carries currentSid of a fresh AAL 2 session of the same account. That session is rotated rather than revoked, and its replacement comes back in intents.

Email verification

await auth.flows.requestEmailVerification({ identityId: identity.id })
const { identity: verified } = await auth.flows.completeEmailVerification({ token })
verified.emailVerified // true

The request is rate limited per identity and does nothing for an address already verified, answering the same either way.

Account deletion

Deletion is confirmed by mail, lands as a soft delete, and can be undone inside the grace window:

await auth.flows.requestAccountDeletion({ identityId: identity.id, reason: 'user asked' })

const deleted = await auth.flows.completeAccountDeletion({ token, sendUndoLink: true })
// deleted.restorableUntil, deleted.cancellationToken

await auth.flows.cancelAccountDeletion({ token: deleted.cancellationToken })
  • completeAccountDeletion soft-deletes the identity and revokes its sessions. With sendUndoLink it mails an account-deletion-cancel link; without it, deliver cancellationToken yourself or drop it to turn undo off.
  • cancelAccountDeletion takes either the undo token (the user's route) or { identityId, authorize } (an operator's). Both or neither throws AUTH_MISCONFIGURED.
  • Once the window closes, identities.gc() erases the row.

Staged sign-up

For a sign-up that spans several screens, beginSignUp creates an unverified identity and answers a flow token the client keeps:

const { flowToken } = await auth.flows.beginSignUp({
  email: 'lin@example.com',
  required: ['email-verified', 'terms-accepted'], // the default
})

await auth.flows.advanceSignUp({ flowToken, stage: 'email-verified' })
await auth.flows.advanceSignUp({ flowToken, stage: 'terms-accepted', profilePatch: { username: 'lin' } })

const { session, intents } = await auth.flows.completeSignUp({ flowToken })
  • Stages are email-collected, email-verified, profile-completed, mfa-enrolled, terms-accepted and completed. Your code proves each one before advancing it; advanceSignUp records it.
  • completeSignUp throws AUTH_SIGNUP_INCOMPLETE with the missing stages, otherwise merges the collected profile and signs the user in.
  • The flow expires 30 minutes after its last step and 24 hours after it began. A dead or spent token throws AUTH_SIGNUP_TOKEN_INVALID. getSignUpFlow(flowToken) reads the flow, and .orNull() answers null for a dead one.
  • profilePatch cannot change the email the flow began with.

Impersonation

Support staff can act as a user through a separate, audited session:

const acting = await auth.flows.impersonate({
  realSid: operatorSid,
  targetIdentityId: user.id,
  reason: 'ticket 4412',
  authorize: async (operator, targetId) => canImpersonate(operator.identityId, targetId),
})
acting.session.actingAs // { realIdentityId, reason, startedAt, expiresAt }

const back = await auth.flows.releaseImpersonation(acting.sid)
  • authorize is mandatory and decides; with duck-iam it wraps engine.authorize(). Never pass async () => true.
  • The session lives at most an hour, emits identity.impersonated, and cannot impersonate again or target the operator.
  • releaseImpersonation ends it and mints the operator a session of their own, or answers session: null when the operator's account went away meanwhile.

Linking a provider

await auth.flows.linkProvider({
  identityId: identity.id,
  providerId: 'oauth:github',
  providerSub,
  authorize: async ({ identity }) => identity.id === signedInIdentityId,
})

await auth.flows.unlinkProvider({ identityId: identity.id, providerId: 'oauth:github' })

A link is a permanent way to sign in, so authorize must answer false unless providerSub came from a token exchange your own code performed. unlinkProvider refuses the last way in unless allowLockout is set, which is for deletion and admin overrides.