Password
Sign up and sign in with an email and a password, reset a forgotten one, and verify an email.
@ultimabase/password is a sign-in method for
Authentication: a user signs up and signs in with an
email and a password. password() goes in the user model's use:
import {
currentUser,
defineUser,
isAuthenticated,
isOwner,
isSelf,
signedIn,
} from '@ultimabase/auth'
import { graphql } from '@ultimabase/graphql'
import { password } from '@ultimabase/password'
import { allow, anyOf, defineModel } from 'ultimabase'
export const User = defineUser({
name: 'User',
use: [graphql(), password()],
fields: (f) => ({
name: f.string(),
// Only the server makes an editor: no client writes this field.
editor: f.boolean({ default: false }).with({ access: allow.readOnly }),
posts: f.many(() => Post, { inverse: 'author' }),
}),
}).with({
// Signed-in users read each other's names; each user changes their own.
access: { read: isAuthenticated, update: isSelf },
})
// Every rule, hook and function now reads `caller.user` as a `User`.
declare module 'ultimabase' {
interface Register {
readonly user: typeof User
}
}The full example, with its tests: examples/password-sign-in.
and passwordPlugin() after authPlugin(). signUp lists the user fields
a sign-up takes besides the email and the password; sendEmail sends the
reset and verification emails:
plugins: [
// Listens on PORT, or 3000.
httpPlugin(),
graphqlPlugin(),
// Sessions, and the caller of each request.
authPlugin(),
// A user signs up with an email, a password and a name, and receives
// the reset and verification emails sendEmail sends.
passwordPlugin({ signUp: ['name'], sendEmail }),
],A client signs up, then signs in, with the functions the plugin adds; each
returns the session's token, its expiresAt and the user:
const response = await post(
`mutation ($input: SignUpInput!) {
signUp(input: $input) { token expiresAt user { name } }
}`,
{
input: {
email: 'ada@example.com',
password: 'correct horse',
name: 'Ada',
},
},
)
const { token } = signedUp.parse(await response.json()).data.signUp
const bearer = { authorization: `Bearer ${token}` }
expect(await viewer(bearer)).toEqual({ data: { viewer: { name: 'Ada' } } })
expect(await viewer({})).toEqual({ data: { viewer: null } })Authentication shows how the token and the cookie carry
the session. Passwords are hashed with Argon2id; the email is stored
trimmed and lowercased, so Ada@Example.com signs in as
ada@example.com.
Send the emails
sendEmail receives each email to send: its kind, 'reset' or
'verify', the address to, the userId and a token. The application
writes the text and a link to its own page, which sends the token back:
export async function sendEmail(email: Email): Promise<void> {
// The link opens the application's page, which sends the token to
// resetPassword or verifyEmail.
const page = email.kind === 'reset' ? 'reset-password' : 'verify-email'
const link = `https://example.com/${page}?token=${email.token}`
// An application sends `link` to `email.to` with its email provider.
outbox.push({ ...email, link })
}The emails go out after the function answers: a slow provider never slows a
sign-up, and a request cannot tell from the delay whether an email has an
account. Without sendEmail, the four email functions do not exist.
Reset a forgotten password
requestPasswordReset emails a reset link, valid one hour, and answers
true whether or not the email has an account. resetPassword takes the
token and the new password, ends every session of the user, and marks the
email verified, since the user received the email:
// The same answer whether the email has an account.
expect(
await request(
`mutation ($input: RequestPasswordResetInput!) {
requestPasswordReset(input: $input)
}`,
{ email: 'ada@example.com' },
),
).toEqual({ data: { requestPasswordReset: true } })
// The page the link opens sends its token and the new password.
const token = await emailed('reset')
const reset = `mutation ($input: ResetPasswordInput!) { resetPassword(input: $input) }`
expect(await request(reset, { token, password: 'battery staple' })).toEqual({
data: { resetPassword: true },
})
expect(await signIn('battery staple')).toEqual({
data: { signIn: { user: { name: 'Ada' } } },
})
// A link works once.
expect(
await request(reset, { token, password: 'another one' }),
).toMatchObject({
errors: [
{
message: 'This link is invalid or has expired.',
extensions: { code: 'INVALID_CREDENTIALS' },
},
],
})Verify the email
The signUp function sends a verification link, valid 24 hours; the
emailVerified field, which the plugin adds to the user model, is false
until verifyEmail receives its token. sendVerificationEmail sends the
link again to the signed-in user.
// The signUp function sends the verification email.
const signedUp = await request(
`mutation ($input: SignUpInput!) { signUp(input: $input) { token } }`,
{ email: 'ada@example.com', password: 'correct horse', name: 'Ada' },
)
const session = z
.object({ data: z.object({ signUp: z.object({ token: z.string() }) }) })
.parse(signedUp).data.signUp.token
const bearer = { authorization: `Bearer ${session}` }
const verified = () =>
request('{ viewer { emailVerified } }', undefined, bearer)
expect(await verified()).toEqual({
data: { viewer: { emailVerified: false } },
})
const token = await emailed('verify')
await request(
`mutation ($input: VerifyEmailInput!) { verifyEmail(input: $input) }`,
{ token },
)
expect(await verified()).toEqual({
data: { viewer: { emailVerified: true } },
})Nothing requires a verified email: a rule that needs one reads
emailVerified on the user's row.
Change the password
changePassword takes the current password and the new one. It ends every
session of the user and opens a new one, so a stolen session stops working:
const changed = await request(
`mutation ($input: ChangePasswordInput!) {
changePassword(input: $input) { token }
}`,
{ currentPassword: 'correct horse', newPassword: 'battery staple' },
{ authorization: `Bearer ${token}` },
)
// A new session; every other one, this old token's too, has ended.
expect(changed).toMatchObject({
data: { changePassword: { token: aString } },
})
expect(await signIn('correct horse')).toMatchObject({
errors: [{ message: 'Wrong email or password.' }],
})From server code, User.setPassword({ id, password }, { as }) replaces it,
as Master or as the user, and ends every session too. It asks no current
password: check the user's intent first.
Failed sign-ins
Within 15 minutes, the 11th failed sign-in from one address on one account
is refused, even with the right password, until enough failures are 15
minutes old. details.retryAfter gives the seconds to wait; over REST it is
also the Retry-After header.
for (let failed = 0; failed < 10; failed += 1) {
expect(await signIn('wrong horse')).toMatchObject({
errors: [{ extensions: { code: 'INVALID_CREDENTIALS' } }],
})
}
// Refused before the password is checked, even the right one.
expect(await signIn('correct horse')).toMatchObject({
errors: [
{
message: 'Too many attempts: try again later.',
extensions: {
code: 'TOO_MANY_ATTEMPTS',
details: { retryAfter: aNumber },
},
},
],
})A successful sign-in clears its address's failures. A wrong email and a wrong password give the same answer, in the same time.
Reference
passwordPlugin(options)
| Option | Type | Default | Effect |
|---|---|---|---|
signUp | user field names, or false | [] | The fields signUp takes besides email and password; false removes signUp. |
sendEmail | (email: Email) => Promise<void> | none | Sends the reset and verification emails; without it, the email functions do not exist. |
It requires authPlugin(). password() in the user model's use adds the
field emailVerified (false by default) and the commands below.
Functions
| Function | Input | Returns | Needs |
|---|---|---|---|
signUp | email, password, the signUp fields | SignedIn | |
signIn | email, password | SignedIn | |
changePassword | currentPassword, newPassword | SignedIn | a signed-in user |
requestPasswordReset | email | true | sendEmail |
resetPassword | token, password | true | sendEmail |
verifyEmail | token | true | sendEmail |
sendVerificationEmail | none | true | sendEmail, a signed-in user |
Each is public. In GraphQL, a function's input is one input argument of
type SignUpInput, SignInInput… SignedIn is { token, expiresAt, user };
over HTTP, signUp, signIn and changePassword also set the cookie.
Commands
| Command | Effect |
|---|---|
User.signUp({ email, password, data }, { as }) | Creates the user; opens no session and sends no email. Master writes any field. |
User.signIn({ email, password }) | Opens a session; SignedIn. Not limited without a client. |
User.setPassword({ id, password }, { as }) | Replaces the password and ends every session; Master or the user. |
Values
| Value | Rule |
|---|---|
trimmed, lowercased, NFC; at most 254 characters, with one @ | |
| password | 8 to 256 characters after NFKC normalization; no other rule |
| token | single use; reset: one hour, verification: 24 hours; stored as its SHA-256 |
Limits
| What | Limit |
|---|---|
| failed sign-ins | 10 per account and address, 100 per account, 100 per address, in 15 minutes |
signUp | 10 per address and hour |
requestPasswordReset | 10 per address and hour |
sendVerificationEmail | 10 per address and hour |
| emails | 3 per account and hour, of either kind |
| hashing | one per CPU at once, 64 waiting, then The server is busy: try again. |
An IPv6 address counts as its /64 network. changePassword counts the
failed current passwords of each user the same way.
Errors
| Code | Message |
|---|---|
INVALID_CREDENTIALS | Wrong email or password. |
INVALID_CREDENTIALS | This link is invalid or has expired. |
UNAUTHENTICATED | changePassword needs a signed-in user., sendVerificationEmail needs a signed-in user. |
TOO_MANY_ATTEMPTS | Too many attempts: try again later., with details.retryAfter in seconds |
VALIDATION_FAILED | password: A password has 8 to 256 characters., Sign-up takes the listed fields only. |
UNIQUE_VIOLATION | A sign-up with an email that has an account. |
ACCESS_DENIED | Sign-up is closed. for User.signUp without Master, when signUp: false; User.setPassword runs as Master or as the user. |
At start, signUp naming a field the user model lacks throws
passwordPlugin({ signUp }): User.nickname is not a field sign-up may take.,
and leaving out a required field throws passwordPlugin({ signUp }): User needs name to be created; list them.
A sign-up with a taken email answers UNIQUE_VIOLATION, which tells that
the email has an account. A hundred failures on one account lock it for
everyone for 15 minutes.