Skip to main content

Flows

WIP

High-level flows - signIn, signOut, signUp state machine, password reset, email change, impersonate.

What flows give you

The auth.flows facet sits above providers, sessions, identities, and channels - it composes them into the multi-step journeys your app actually runs.

await auth.flows.signIn({ providerId: 'password', input: { email, password } })
await auth.flows.signOut(ctx)
await auth.flows.beginPasswordReset({ email })
await auth.flows.completePasswordReset({ token, newPassword })
await auth.flows.beginEmailChange({ identityId, newEmail })
await auth.flows.completeEmailChange({ token })
await auth.flows.impersonate({ actorId, subjectId, reason })
await auth.flows.releaseImpersonation(ctx)

Each method returns Intent[] so the framework adapter can issue the right wire effects (cookies, redirects, JSON).

Sign-up state machine

For sign-ups that require multiple steps (collect email -> send verification -> user clicks link -> choose password -> MFA enrolment), use the state machine API:

const state = await auth.flows.signUp.start({ email })
// -> { stateId, nextStep: 'verify-email' }

// User clicks the verification link
const stepped = await auth.flows.signUp.advance({
  stateId: state.stateId,
  step: 'verify-email',
  input: { token },
})
// -> { nextStep: 'choose-password' }

// User chooses a password
await auth.flows.signUp.advance({
  stateId: state.stateId,
  step: 'choose-password',
  input: { password },
})
// -> { nextStep: 'enrol-mfa' | 'complete' }

// MFA enrolment (if required by your config)
await auth.flows.signUp.advance({
  stateId: state.stateId,
  step: 'enrol-mfa',
  input: { factor: 'totp', code },
})
// -> { nextStep: 'complete', identityId }

The state lives in your credentials store (with TTL), so a user can abandon the sign-up at any step and pick up where they left off when they click the verification link again.

Password reset

// Step 1: user enters their email on /forgot
await auth.flows.beginPasswordReset({ email })
// Always returns successfully - existence not leaked.
// Internally: if the email exists, send a single-use token via the
// configured email channel.

// Step 2: user clicks the link and enters a new password
await auth.flows.completePasswordReset({
  token,
  newPassword,
})
// -> throws AUTH/RECOVERY_TOKEN_INVALID, AUTH/RECOVERY_TOKEN_EXPIRED,
//   or AUTH/RECOVERY_REQUIRES_MFA if MFA is enrolled.
// -> on success: rotates the password, revokes every session, mints a
//   fresh session for the user.

For high-assurance identities (MFA enrolled), the reset path itself requires MFA - preventing email-only account takeover.

Email change

// Step 1: user requests email change
await auth.flows.beginEmailChange({
  identityId,
  newEmail,
})
// -> sends a confirmation link to both old and new email.

// Step 2: user clicks the link in the new email inbox
await auth.flows.completeEmailChange({
  token,
})
// -> updates the identity's email, revokes other sessions, optionally
//   sends a "your email changed" notice to the old address.

The two-step flow (confirm via new inbox) is required - otherwise an attacker who already has a session could swap the email to a domain they control and lock the legitimate owner out.

Impersonation

For admin / support workflows where one identity needs to act as another:

// Start impersonation
const intents = await auth.flows.impersonate({
  actorId: ctx.identity.id,           // the admin
  subjectId: 'identity-to-impersonate',
  reason: 'support-ticket-12345',
  durationMs: 30 * 60 * 1000,         // 30 minutes
})

// The runtime mints a special session with:
// - session.actorId = the admin's id (audit trail)
// - session.subjectId = the impersonated user
// - session.kind = 'impersonate'
// - session.aal capped at the admin's original AAL

// Release impersonation
await auth.flows.releaseImpersonation(ctx)
// -> revokes the impersonation session, restores actor's prior session.

Impersonation events emit:

session.impersonate-start  { actorId, subjectId, reason }
session.impersonate-release { actorId, subjectId, durationMs }

Wire these to your audit log - every impersonation should be visible in the audit trail.

The admin's iam permission to impersonate is not duck-auth's concern; gate it with auth.iam or via your application's own role check before calling flows.impersonate.

Errors

CodeWhen
AUTH/SIGNUP_INCOMPLETEflows.signUp.advance called for a state past its terminal step.
AUTH/SIGNUP_TOKEN_INVALIDVerification link token is unknown or consumed.
AUTH/RECOVERY_TOKEN_INVALIDReset token unknown or revoked.
AUTH/RECOVERY_TOKEN_EXPIREDReset token past TTL (default 1 hour).
AUTH/RECOVERY_REQUIRES_MFAIdentity has MFA enrolled; reset requires MFA verify.
AUTH/EMAIL_TAKENbeginEmailChange target already belongs to another identity.
AUTH/EMAIL_CHANGE_PENDINGbeginEmailChange while a prior request is still active.
AUTH/IMPERSONATE_FORBIDDENCaller lacks the actor permission.
AUTH/IMPERSONATE_EXPIREDImpersonation session past its absolute TTL.