Skip to main content

Password provider

WIP

Email + password sign-in with per-email rate limiting and auto-rehash on parameter drift.

Setup

import { password } from '@gentleduck/auth/providers/password'

export const auth = new AuthEngine({
  // ...
  providers: [
    password({
      findIdentityByEmail: async (email, tenantId) => {
        return auth.identities.findByEmail(email, tenantId)
      },
      passwords: auth.passwords,           // the shared facet
      limiterKeyPrefix: 'signin:password:', // default
      autoRehash: true,                    // default
    }),
  ],
})

What it does

  • begin is a no-op. The client just calls complete directly with { email, password }.
  • complete:
    1. Resolves the identity by email + optional tenantId.
    2. Trips the rate limiter under the per-email bucket (limiterKeyPrefix + email).
    3. Verifies the password against the stored hash (constant-time).
    4. If autoRehash is true and passwords.needsRehash() says yes, re-hashes the plaintext with current params and updates the row.
    5. Emits a startSession intent at aal: 'aal1', amr: ['pwd'].

The whole verify path runs in constant time regardless of whether the identity exists - username enumeration is defeated. The email field is included in the failed-attempt rate-limit bucket, so an attacker spraying random emails still pays the rate-limit cost.

Wire shape

// Client: vanilla
await client.signIn({
  providerId: 'password',
  input: { email: 'ada@example.com', password: 'correct horse battery staple' },
})
// -> 200 { session, identity }

// Server route
POST /auth/signin
Content-Type: application/json

{ "providerId": "password", "input": { "email": "...", "password": "..." } }

Sign-up

The provider itself doesn't sign people up - use auth.flows.signUp for the state-machine version, or write a thin handler:

app.post('/auth/signup', async (req, res) => {
  const { email, password } = req.body

  const identity = await auth.identities.create({
    email,
    emailVerified: false,
    profile: { displayName: email.split('@')[0] },
  })

  const credentialId = await auth.passwords.create({
    identityId: identity.id,
    secret: password,
  })

  // Send verification email via your channel
  await auth.flows.sendEmailVerification({ identityId: identity.id })

  res.status(201).json({ id: identity.id })
})

Password reset

// 1. Initiate
app.post('/auth/forgot', async (req, res) => {
  await auth.flows.beginPasswordReset({ email: req.body.email })
  // Always returns 200 so existence isn't leaked
  res.json({ ok: true })
})

// 2. Complete
app.post('/auth/reset', async (req, res) => {
  await auth.flows.completePasswordReset({
    token: req.body.token,
    newPassword: req.body.password,
  })
  res.json({ ok: true })
})

flows.beginPasswordReset mints a single-use token (hashed at rest), delivers it via the configured email channel, and does not reveal whether the email exists. completePasswordReset verifies the token, rotates the password, and revokes every session of the identity.

Errors

CodeWhen
AUTH/INVALID_CREDENTIALSWrong email or wrong password - same code for both.
AUTH/RATE_LIMITEDLimiter tripped (default 5 attempts / 60 s per email).
AUTH/EMAIL_NOT_VERIFIEDEmail-verification gate enforced and identity has not verified.
AUTH/RECOVERY_TOKEN_INVALIDReset token unknown or already consumed.
AUTH/RECOVERY_TOKEN_EXPIREDReset token past TTL.