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:

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 reset and verification 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: '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:

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

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

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

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

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 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

FunctionInputReturnsNeeds
signUpemail, password, the signUp fieldsSignedIn
signInemail, passwordSignedIn
changePasswordcurrentPassword, newPasswordSignedIna signed-in user
requestPasswordResetemailtruesendEmail
resetPasswordtoken, passwordtruesendEmail
verifyEmailtokentruesendEmail
sendVerificationEmailnonetruesendEmail, 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

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.

Values

ValueRule
emailtrimmed, lowercased, NFC; at most 254 characters, with one @
password8 to 256 characters after NFKC normalization; no other rule
tokensingle use; reset: 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 either kind
hashingone 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

CodeMessage
INVALID_CREDENTIALSWrong email or password.
INVALID_CREDENTIALSThis link is invalid or has expired.
UNAUTHENTICATEDchangePassword needs a signed-in user., sendVerificationEmail needs a signed-in user.
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.
UNIQUE_VIOLATIONA sign-up with an email that has an account.
ACCESS_DENIEDSign-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.

On this page