Password provider
WIPEmail + 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
beginis a no-op. The client just callscompletedirectly with{ email, password }.complete:- Resolves the identity by
email+ optionaltenantId. - Trips the rate limiter under the per-email bucket
(
limiterKeyPrefix + email). - Verifies the password against the stored hash (constant-time).
- If
autoRehashistrueandpasswords.needsRehash()says yes, re-hashes the plaintext with current params and updates the row. - Emits a
startSessionintent ataal: 'aal1',amr: ['pwd'].
- Resolves the identity by
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
| Code | When |
|---|---|
AUTH/INVALID_CREDENTIALS | Wrong email or wrong password - same code for both. |
AUTH/RATE_LIMITED | Limiter tripped (default 5 attempts / 60 s per email). |
AUTH/EMAIL_NOT_VERIFIED | Email-verification gate enforced and identity has not verified. |
AUTH/RECOVERY_TOKEN_INVALID | Reset token unknown or already consumed. |
AUTH/RECOVERY_TOKEN_EXPIRED | Reset token past TTL. |