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:

src/models.ts
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:

src/app.ts
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:

src/sessions.test.ts
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; a 'passwordChanged' one, sent once the password changed, has neither. The application writes the text and a link to its own page, which sends the token back:

src/mail.ts
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
	}
	if (email.kind === 'passwordChanged') {
		const text = 'Your password was changed. Not you? Reset it.'
		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:

src/password.test.ts
// 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.

src/password.test.ts
// 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:

src/password.test.ts
const changed = await request(
	`mutation ($input: ChangePasswordInput!) {
		changePassword(input: $input) { token user { passwordSetAt } }
	}`,
	{ currentPassword: 'correct horse', newPassword: 'battery staple' },
	{ authorization: `Bearer ${token}` },
)
// A new session; every other one, this old token's too, has ended.
// passwordSetAt, which the user alone reads, is now.
expect(changed).toMatchObject({
	data: {
		changePassword: { token: aString, user: { passwordSetAt: aString } },
	},
})
expect(await signIn('correct horse')).toMatchObject({
	errors: [{ message: 'Wrong email or password.' }],
})
// The account is told.
await vi.waitFor(() => {
	expect(outbox.at(-1)).toMatchObject({
		kind: 'passwordChanged',
		to: '[email protected]',
	})
})

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.

The user model's passwordSetAt field holds when the password was last set: by signUp, changePassword, resetPassword or User.setPassword. The user and Master read it, as an account page that shows "password changed on …"; closing the account sets it to null, and a rehash on sign-in keeps it. With sendEmail, each change but the sign-up's also sends a 'passwordChanged' email to the account, once it is saved. A Master write of passwordSetAt itself, as an import from Parse, sends none.

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:

src/password.test.ts
// 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:

src/password.test.ts
// 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:

src/password.test.ts
// 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.

src/password.test.ts
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)

OptionTypeDefaultEffect
signUpuser field names, or false[]The fields signUp takes besides email and password; false removes signUp.
sendEmail(email: Email) => Promise<void>noneSends 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), emailVerified (false by default) and passwordSetAt (null without a password), and the commands below. A user model with its own email or passwordSetAt field stops startup: User.email: password adds a field User already has.

Functions

FunctionInputReturnsNeeds
signUpemail, password, the signUp fieldsSignedIn
signInemail, passwordSignedIn
changePasswordcurrentPassword, newPasswordSignedIna signed-in user
requestPasswordResetemailtruesendEmail
resetPasswordtoken, passwordtruesendEmail
verifyEmailtokentruesendEmail
sendVerificationEmailnonetruesendEmail, a signed-in user
requestEmailChangenewEmail, currentPasswordtruesendEmail, a signed-in user
confirmEmailChangetokentruesendEmail

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

CommandEffect
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

ValueRule
emailtrimmed, lowercased, NFC; at most 254 characters, with one @
password8 to 256 characters after NFKC normalization; no other rule
tokensingle use; reset and email change: one hour, verification: 24 hours; stored as its SHA-256

Limits

WhatLimit
failed sign-ins10 per account and address, 100 per account, 100 per address, in 15 minutes
signUp10 per address and hour
requestPasswordReset10 per address and hour
sendVerificationEmail10 per address and hour
emails3 per account and hour, of any kind with a link
hashingone 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

CodeMessage
INVALID_CREDENTIALSWrong email or password.
INVALID_CREDENTIALSWrong password., from changePassword, requestEmailChange and User.checkPassword
INVALID_CREDENTIALSThis link is invalid or has expired.
UNAUTHENTICATEDchangePassword needs a signed-in user., the same for sendVerificationEmail and requestEmailChange
TOO_MANY_ATTEMPTSToo many attempts: try again later., with details.retryAfter in seconds
VALIDATION_FAILEDpassword: A password has 8 to 256 characters., Sign-up takes the listed fields only., email: closeAccount removes the email.
UNIQUE_VIOLATIONA sign-up or an email change to an email that has an account: This email has an account. from requestEmailChange
ACCESS_DENIEDSign-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.

On this page