Password
Sign up and sign in with an email and a password, reset a forgotten one, and verify or change the 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
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: '[email protected]',
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 [email protected] signs in as
[email protected]. The user model's email field holds it: the user and
Master read it, other callers never do, and only Master writes it.
Send the emails
sendEmail receives each email to send: its kind, the address to and
the userId. A 'reset', 'verify' or 'emailChange' email has a
token; an 'emailChanged' one, sent to the old address, has the
newEmail. The application writes the text and a link to its own page,
which sends the token back:
const pages = {
reset: 'reset-password',
verify: 'verify-email',
emailChange: 'confirm-email',
}
export async function sendEmail(email: Email): Promise<void> {
if (email.kind === 'emailChanged') {
// To the old address, which may not have asked for the change.
const text = `Your email is now ${email.newEmail}. Not you? Contact us.`
outbox.push({ ...email, text })
return
}
// The link opens the application's page, which sends the token to
// resetPassword, verifyEmail or confirmEmailChange.
const link = `https://example.com/${pages[email.kind]}?token=${email.token}`
// An application sends the text to `email.to` with its email provider.
outbox.push({ ...email, text: `Open ${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 six 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: '[email protected]' },
),
).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: '[email protected]', 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. Closing the account
deletes the password and the links sent, and sets emailVerified back to
false.
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.
Change the email
requestEmailChange takes the new email and the current password, and
emails a link, valid one hour, to the new address. confirmEmailChange
takes its token: the email changes and is verified, sessions stay open, and
the old address is told:
// The password again, so a stolen session cannot take the account.
await request(
`mutation ($input: RequestEmailChangeInput!) {
requestEmailChange(input: $input)
}`,
{ newEmail: '[email protected]', currentPassword: 'correct horse' },
{ authorization: `Bearer ${session}` },
)
// The link goes to the new address; its page sends the token back.
const token = await emailed('emailChange')
expect(
await request(
`mutation ($input: ConfirmEmailChangeInput!) {
confirmEmailChange(input: $input)
}`,
{ token },
),
).toEqual({ data: { confirmEmailChange: true } })
expect(
await User.signIn({ email: '[email protected]', password: 'correct horse' }),
).toMatchObject({ user: { email: '[email protected]', emailVerified: true } })
// The old address is told.
await vi.waitFor(() => {
expect(outbox.at(-1)).toMatchObject({
kind: 'emailChanged',
to: '[email protected]',
})
})An application without emails writes the field as Master. The account
follows it, emailVerified goes back to false unless the write sets it,
and the links already sent stop working:
// As Master, without any email: the account follows the field.
const updated = await User.update(
{ where: { id: ada.id }, data: { email: '[email protected]' } },
{ as: master },
)
expect(updated).toMatchObject({
email: '[email protected]',
emailVerified: false,
})
await expect(
User.signIn({ email: '[email protected]', password: 'correct horse' }),
).resolves.toMatchObject({ user: { name: 'Ada' } })The field never goes back to null while the user has a password:
closing the account clears it. A user
stored before the field reads null until a Master update writes its
account's email.
Check the password again
Before a sensitive action, such as closing the account, a function asks for
the password again, so a stolen session is not enough.
User.checkPassword({ id, password }, { as, client }) throws
Wrong password. for a wrong one, and opens no session:
// In a function's run, as the caller, with the client it receives.
const as = { kind: 'user', user: ada } as const
await User.checkPassword({ id: ada.id, password: 'correct horse' }, { as })
await expect(
User.checkPassword({ id: ada.id, password: 'wrong password' }, { as }),
).rejects.toMatchObject({
code: 'INVALID_CREDENTIALS',
message: 'Wrong password.',
})It runs as Master or as the user, and is always counted: with the client,
as changePassword; without it, the 11th failure on the user within 15
minutes is refused.
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 emails; without it, the email functions do not exist. |
It requires authPlugin(). password() in the user model's use adds the
fields email (unique, null without a password) and emailVerified
(false by default), and the commands below. A user model with its own
email field stops startup:
User.email: password adds a field User already has.
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 |
requestEmailChange | newEmail, currentPassword | true | sendEmail, a signed-in user |
confirmEmailChange | token | true | sendEmail |
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. |
User.checkPassword({ id, password }, { as, client }) | Checks the current password; no session. Master or the user, always counted. |
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 and email change: 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 any kind with a link |
| hashing | one per CPU at once, 64 waiting, then The server is busy: try again. |
An IPv6 address counts as its /64 network. changePassword and
User.checkPassword count the failed current passwords of each user the
same way, together; User.checkPassword without a client refuses the 11th
on the user, from anywhere.
Errors
| Code | Message |
|---|---|
INVALID_CREDENTIALS | Wrong email or password. |
INVALID_CREDENTIALS | Wrong password., from changePassword, requestEmailChange and User.checkPassword |
INVALID_CREDENTIALS | This link is invalid or has expired. |
UNAUTHENTICATED | changePassword needs a signed-in user., the same for sendVerificationEmail and requestEmailChange |
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., email: closeAccount removes the email. |
UNIQUE_VIOLATION | A sign-up or an email change to an email that has an account: This email has an account. from requestEmailChange |
ACCESS_DENIED | Sign-up is closed. for User.signUp without Master, when signUp: false; User.setPassword runs as Master or as the user., and the same for User.checkPassword |
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.