Authentication

Declare the user model, open and end sessions, and write rules that read the signed-in user.

@ultimabase/auth gives each request its caller: it reads the session the request carries and makes the caller that user. It declares no way to sign in: a sign-in plugin does, as password with an email and a password.

npm install @ultimabase/auth @ultimabase/password

The user model is declared with defineUser, and the Register declaration types caller.user as its row in every rule, hook and function:

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.

authPlugin() goes in the application's plugins, after httpPlugin(), which it requires:

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 }),
],

Sessions

A sign-up or a sign-in opens a session and returns its token. A client sends it back in the Authorization header:

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

The requests above go through this helper:

src/sessions.test.ts
/** A GraphQL request, and its response. */
const post = (
	query: string,
	variables: object = {},
	headers: Record<string, string> = {},
): Promise<Response> =>
	fetch(`${running.url}/graphql`, {
		method: 'POST',
		headers: { 'content-type': 'application/json', ...headers },
		body: JSON.stringify({ query, variables }),
	})

A sign-in over HTTP also sets a session cookie, HttpOnly and SameSite=Lax, which a browser sends back by itself: a web application needs no code to keep the token. signOut ends the session and clears the cookie:

src/sessions.test.ts
const response = await post(
	`mutation ($input: SignInInput!) { signIn(input: $input) { user { name } } }`,
	{ input: { email: 'ada@example.com', password: 'correct horse' } },
)
const set = response.headers.get('set-cookie') ?? ''
expect(set).toMatch(
	/^ultimabase_session=.+; Max-Age=\d+; Path=\/; HttpOnly; SameSite=Lax$/,
)
// A browser sends it back by itself.
const cookie = { cookie: set.split(';')[0] ?? '' }
expect(await viewer(cookie)).toEqual({ data: { viewer: { name: 'Ada' } } })

// signOut ends the session and clears the cookie.
const out = await post('mutation { signOut }', {}, cookie)
expect(await out.json()).toEqual({ data: { signOut: true } })
expect(out.headers.get('set-cookie')).toMatch(
	/^ultimabase_session=; Max-Age=0/,
)
// A stale cookie is anonymous, so the browser can sign in again.
expect(await viewer(cookie)).toEqual({ data: { viewer: null } })

A session lasts 30 days after its last use and at most one year after the sign-in; the user then signs in again. A Bearer token that has ended refuses the request with UNAUTHENTICATED, so the client knows to sign in again; a cookie that has ended is anonymous, so a browser can still reach the sign-in page.

In production (NODE_ENV=production), the cookie is Secure and named __Host-ultimabase_session. Outside it, it works on http://localhost; a request from another host logs a warning once: auth serves example.com a session cookie without Secure: set NODE_ENV=production.

From server code

The user model gets commands: User.revokeSessions ends every session of a user, on every device, as when an account is stolen. The sign-in plugin adds its own, as password's User.signUp and User.signIn:

src/sessions.test.ts
const ada = await User.signUp(
	{
		email: 'ada@example.com',
		password: 'correct horse',
		data: { name: 'Ada' },
	},
	{ as: master },
)
// Opens a session, as the signIn function does, without its cookie.
const { token, user } = await User.signIn({
	email: 'ada@example.com',
	password: 'correct horse',
})
expect(user?.name).toBe('Ada')

// Ends every session of the user, on every device.
await User.revokeSessions({ id: ada.id }, { as: master })
// The request is refused before it runs, whatever it asks.
const revoked = await post(
	'{ viewer { name } }',
	{},
	{
		authorization: `Bearer ${token}`,
	},
)
expect(revoked.status).toBe(401)
expect(await revoked.json()).toEqual({
	code: 'UNAUTHENTICATED',
	message: 'The session expired or was revoked: sign in again.',
})

Deleting a user ends its sessions and deletes its linked accounts.

Rules on the signed-in user

auth adds rules to those of Access rules. isAuthenticated lets every signed-in user through; isSelf, on the user model, lets each user write their own row. Above, a visitor sees no names:

src/app.test.ts
const ada = await signUp('ada')
const names = '{ users { edges { node { name } } } }'
expect(await request(names)).toEqual({ data: { users: { edges: [] } } })
expect(await request(names, {}, as(ada.token))).toEqual({
	data: { users: { edges: [{ node: { name: 'ada' } }] } },
})

currentUser is a default: the author of a new post is the user who creates it. isOwner('author') lets through the user the one relation points to, before and after the write, so no one writes a post in another's name. signedIn receives the user row, here to let an editor delete any post:

src/models.ts
export const Post = defineModel({
	name: 'Post',
	use: [graphql()],
	fields: (f) => ({
		title: f.string(),
		published: f.boolean({ default: false }),
		// Whoever creates the post.
		author: f.one(() => User, { default: currentUser }),
	}),
}).with({
	access: {
		// Everyone reads published posts; an author reads their drafts too.
		read: ({ caller, where }) =>
			caller.kind === 'user'
				? where({
						OR: [
							{ published: { equals: true } },
							{ author: { is: { id: { equals: caller.user.id } } } },
						],
					})
				: where({ published: { equals: true } }),
		// A post is written in its author's name only: the caller's own.
		create: isOwner('author'),
		update: isOwner('author'),
		// An author deletes their posts, an editor anyone's.
		delete: anyOf(
			isOwner('author'),
			signedIn(({ user }) => user.editor),
		),
	},
})

A visitor sees the published posts, an author their drafts too, and only the author publishes one:

src/app.test.ts
const { ada, grace, post } = await written()
const hello = [{ node: { title: 'Hello' } }]
expect(await titles()).toEqual([])
expect(await titles(as(grace.token))).toEqual([])
expect(await titles(as(ada.token))).toEqual(hello)
const publish = `mutation ($id: ID!) {
	updatePost(where: { id: $id }, data: { published: true }) { published }
}`
// Grace cannot see the draft, so it does not exist for her.
expect(await request(publish, { id: post }, as(grace.token))).toMatchObject(
	refused('NOT_FOUND'),
)
expect(await request(publish, { id: post }, as(ada.token))).toEqual({
	data: { updatePost: { published: true } },
})
expect(await titles()).toEqual(hello)
// Now she sees it, but it is not hers.
expect(await request(publish, { id: post }, as(grace.token))).toMatchObject(
	refused('ACCESS_DENIED'),
)

Reference

defineUser(definition)

The keys of defineModel: name, fields, use, indexes, description and dbName; its .with takes the same keys too. An application has one user model.

authPlugin()

It takes no option. It requires httpPlugin() and adds:

WhatNameEffect
functionsignOutEnds the caller's session and clears the cookie; true.
queryviewerThe signed-in user's row, read with their rules, or null.
commandrevokeSessionsUser.revokeSessions({ id }, { as }), as Master or as the user.
modelsAuthSessionThe sessions; each token is stored as its SHA-256, never in clear.
AuthAccountThe accounts a user signs in with: a method and its subject, such as an email, unique together.

Both functions are public. The models are Master's only: no transport exposes them.

Rules and default

NameWhereEffect
isAuthenticatedany ruleThe caller is signed in.
signedIn(rule)any rulerule receives the facts and user; false for a visitor. A read rule may return a filter.
isOwner(field)a write ruleThe one relation field points to the caller, in the stored row and in the value.
isSelfthe user model's ruleThe row is the caller's own.
currentUsera one default{ id } of the signed-in user, nothing for a visitor.

Sessions

KeyValue
token32 random bytes in base64url
lifetime30 days after the last use, renewed when less than 15 days are left
longest365 days after the sign-in
BearerAuthorization: Bearer <token>, read before the cookie
cookieultimabase_session; __Host-ultimabase_session and Secure in production
cookie optionsHttpOnly, SameSite=Lax, Path=/

Errors

CodeWhen
UNAUTHENTICATED (401)A Bearer token that has ended: The session expired or was revoked: sign in again.
ACCESS_DENIED (403)revokeSessions for another user: User.revokeSessions runs as Master or as the user.

At start, an application without a user model throws authPlugin() needs a user model: declare it with defineUser., and one with two models lists two models defineUser returned: an application has one user model.

Limits

  • A request carries one session: a Bearer header wins over the cookie.
  • A function sets the cookie only over HTTP; from server code, User.signIn returns the token alone.

On this page