Skip to main content

Delivery

WIP

How duck-auth hands every magic link, reset link and verification link to your own mailer through one deliver function

duck-auth mints tokens but never sends them. Every outbound message goes through one function you pass as deliver, so the transport (SMTP, Resend, SES, an SMS gateway, a queue) is whatever your app already uses.

The contract

import type { Deliver } from '@gentleduck/auth/core'

const deliver: Deliver = async ({ kind, identity, vars, tenant }) => {
  // kind:     which message this is
  // identity: the recipient's whole identity row
  // vars:     what the message needs, narrowed by kind
  // tenant:   the tenant the flow ran under
}

vars.url is the full link, already carrying its token, built as baseUrl + callbackPath + '?token=...'. vars.ttlMin is how long it stays good.

kindSent byExtra varsDefault path, TTL
magic-linkThe magic-link provider's beginnone/auth/magic-link/callback, 10 min
password-resetflows.requestPasswordResetrequiresMfa/auth/reset-password, 30 min
email-verificationflows.requestEmailVerificationnone/auth/verify-email, 30 min
account-deletionflows.requestAccountDeletionnone/auth/delete-account, 30 min
account-deletion-cancelflows.completeAccountDeletion with sendUndoLink: truerestorableUntil (epoch ms)/auth/cancel-deletion, the grace window

Each flow takes a callbackPath and a ttlMs to override the default; the magic-link provider takes them as options. The path must be same-origin (it starts with / and has no //): a flow replaces one that is not with its default, and the magic-link provider refuses one at construction.

requiresMfa is true when the account has TOTP or WebAuthn MFA enrolled. Completing that reset then needs a stepped-up session of the same account, so the mail can say so.

Wiring it

Pass deliver to createAuth. The flows read it from there. The magic-link provider takes its own deliver, so register it as a thunk to receive the engine's:

import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth, type Deliver, orNull } from '@gentleduck/auth/core'
import { magicLink } from '@gentleduck/auth/providers/magic-link'

const stores = new MemoryAdapter()

const deliver: Deliver = async (message) => {
  switch (message.kind) {
    case 'password-reset':
      return sendMail(message.identity.profile.email, 'Reset your password', message.vars.url)
    case 'magic-link':
      return sendMail(message.identity.profile.email, 'Your sign-in link', message.vars.url)
    default:
      return sendMail(message.identity.profile.email, 'Confirm it is you', message.vars.url)
  }
}

const auth = createAuth({
  baseUrl: 'https://app.example.com',
  deliver,
  stores,
  providers: [
    (_engine, send) =>
      magicLink({ deliver: send, findIdentityByEmail: (email) => orNull(stores.identities.find({ email })) }),
  ],
})

declare function sendMail(to: string, subject: string, url: string): Promise<void>

Failure and timing

  • Reset and magic-link requests do not wait for deliver. Both take an email address from a stranger, and awaiting the mailer would make the answer slower for an address that exists. The answer is { ok: true } either way and the message goes out behind it. The verification and deletion flows run for a known identity and await it.
  • A throw is reported, not raised. duck-auth emits signin.failed with reason: 'deliver threw' and the message kind as providerId. It never reads the thrown error, since a mailer's error text tends to carry the recipient and the link.
  • No deliver is a misconfiguration. A flow that needs one throws AUTH_MISCONFIGURED, and under strict({ env: 'production' }) a magic-link provider without one fails at boot.
  • An unknown address sends nothing. requestPasswordReset and the magic-link begin answer the same for a stranger as for an account, and do the same store work, so neither the body nor the timing tells them apart.

Testing

authTestDeliver() from @gentleduck/auth/test records messages instead of sending them:

import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth } from '@gentleduck/auth/core'
import { passwords } from '@gentleduck/auth/providers/passwords'
import { authTestDeliver } from '@gentleduck/auth/test'

const { deliver, outbox } = authTestDeliver()
const auth = createAuth({ baseUrl: 'http://localhost', deliver, stores: new MemoryAdapter(), providers: [passwords()] })

const ada = await auth.identities.create({ profile: { email: 'ada@example.com', username: 'ada' } })
await auth.flows.requestEmailVerification({ identityId: ada.id })
outbox[0]?.vars.url // 'http://localhost/auth/verify-email?token=...'

See Testing.