Password
WIPEmail and password sign-in, the strength policy, the Argon2id and scrypt hashers, and rehashing as people sign in
import { passwords } from '@gentleduck/auth/providers/passwords'
const auth = createAuth({
// ...
providers: [passwords()],
})
Registering it also gives you auth.passwords, which sets and checks passwords outside a sign-in.
Signing in
const { sid, intents } = await auth.flows.signIn({
providerId: 'password',
input: { email: 'ada@example.com', password: 'correct-horse-battery' },
})
- A wrong password and an unknown email both throw
AUTH_INVALID_CREDENTIALS, and both run the hasher, so neither answer nor timing tells them apart. - Every attempt, right or wrong, spends one from the limiter under
signin:password:<email>, with the email case-folded. Once it refuses, the attempt throwsAUTH_RATE_LIMITEDand emitslockout. - A successful sign-in opens a session at AAL 1. A second factor is a step-up.
The provider has no begin step, and it never creates an account.
Setting a password
const ada = await auth.identities.create({ profile: { email: 'ada@example.com', username: 'ada' } })
await auth.passwords.set(ada.id, 'correct-horse-battery', auth.cfg.stores.credentials)
set checks the policy, hashes, and replaces any password the identity had. A password reset goes through auth.flows, which calls it for you.
Options
| Option | Default | Effect |
|---|---|---|
minLength | 8 | Shorter is refused. |
maxLength | 1024 | Longer is refused before it reaches the hasher. |
rejectCommon | true | Refuses a short built-in list of the most obvious passwords, such as password1 and 12345678, compared case-insensitively. |
hasher | Argon2id | See below. |
autoRehash | true | Rehashes on a successful sign-in when the stored hash used weaker parameters. |
limiterKeyPrefix | 'signin:password:' | The limiter bucket prefix. |
compliance | none | Raises minLength to the preset's floor: 12 for hipaa, 14 for fips. |
A password failing the policy throws AUTH_INVALID_CREDENTIALS without naming the rule, so check length in your form for a friendlier message.
Hashers
| Hasher | Notes |
|---|---|
argon2idHasher(params?) | The default. Needs the @node-rs/argon2 peer, loaded on first use. ARGON2ID_DEFAULTS is the OWASP minimum; ARGON2ID_COMPLIANCE satisfies the fips check. |
scryptHasher(params?) | Node's built-in scrypt, no dependency. Defaults N: 2 ** 17, r: 8, p: 1, keylen: 64. |
Both refuse nonsense parameters at construction, and strict({ env: 'production' }) refuses parameters below scrypt N 2^14 or Argon2id's OWASP minimum. Test suites can use cheap parameters freely.
When you raise the parameters, autoRehash moves each stored hash to them the next time its owner signs in.
Bringing hashes from another system
A hasher is { id, hash, verify, needsRehash }. To move off bcrypt without a reset, import the old hashes as they are and verify both kinds; every bcrypt hash reports needsRehash, so each is replaced at its owner's next sign-in.
import bcrypt from 'bcryptjs'
import { type Hasher, scryptHasher } from '@gentleduck/auth/providers/passwords'
const scrypt = scryptHasher()
const migrating: Hasher.Me = {
id: 'scrypt',
hash: (plain) => scrypt.hash(plain),
verify: (plain, stored) => (stored.startsWith('$2') ? bcrypt.compare(plain, stored) : scrypt.verify(plain, stored)),
needsRehash: (stored) => stored.startsWith('$2') || scrypt.needsRehash(stored),
}
passwords({ hasher: migrating })
verify must compare in constant time and answer false, not throw, for a hash it cannot read.