Users and Auth
Just about any application on the public internet needs user authentication. Convex integrates with identity providers implementing the OpenID Connect protocol. Developers pass an ID token to the Convex client in order to reference the user in their queries and mutations.
Authentication in Convex is checked at the query or mutation function level, so new or signed-out users of an app can still subscribe to queries and even make mutations if you let them! Most apps require authentication to query or mutate data and present only a simple login page to logged-out users. That's that pattern we'll use here, requiring users log in use our chat app.
This page builds on the code in the previous section from the
2-filters-and-references
directory in the
Convex Demos repo. The
completed demo code is in the 3-users-and-auth
directory.
Identity providers
Developers can choose any OpenID-compliant authentication provider (e.g. Google, Github, Facebook) for use with their Convex app. For the purposes of this tutorial we'll be using Auth0, which provides "universal login" across a number of supported providers. Auth0 has a free tier which will be more than enough for our purposes.
Getting started with Auth0
You'll need to start by creating a free account with Auth0. Next, create a new single-page app from your Auth0 dashboard:
From here, we'll roughly be following Auth0's React Login guide, shown after creating your app.
In your application settings, we need to tell Auth0 what URL origins can log in
and log out. For local development, you can just use http://localhost:3000
for
the Callback URL, Logout URL, and Allowed Web Origin fields.
At the top of the page you'll also see your Client ID and Domain, which you'll need for the next step.
Setting up Convex with your chosen identity provider
Your Convex deployment needs to be configured with your chosen identity provider(s) to ensure that only valid identity tokens are accepted.
In your terminal, run the following command to configure a new identity provider:
npx convex auth add
You'll be prompted first for the identity provider's domain and then for your Client ID, both of which are shown in your Auth0 Application Settings.
After adding the identity provider, run npx convex push
to apply your changes.
Setting up the app
Our Convex deployment is now prepared to accept ID Tokens from Auth0, but we need to make a few changes to our React app to fetch the tokens from Auth0 and pass them to Convex. We'll use Auth0's React SDK for this.
Install the Auth0 React SDK
In your project directory, run the following command:
npm install @auth0/auth0-react
Set up the authed Convex provider component
In order to ensure our application code only runs when it can access an authed ConvexReactClient, we'll use ConvexProviderWithAuth0 to show a login or loading component until the user has logged in.
tip
If you need to customize the login flow or use another auth provider, you can use the whatever client-side authentication method your auth provider suggests and call ConvexReactClient.setAuth() with the user's identity token once they've completed the login flow.
This will be easiest to do from src/main.tsx
, where the main App
component
gets rendered. Replace the ConvexProvider
component with the
ConvexProviderWithAuth0
component, filling in
authInfo
from convex.json
.
import { StrictMode } from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App, { Login } from "./App";
import { ConvexProviderWithAuth0 } from "convex/react-auth0";
import { ConvexReactClient } from "convex/react";
import convexConfig from "../convex.json";
const convex = new ConvexReactClient(convexConfig.origin);
const authInfo = convexConfig.authInfo[0];
ReactDOM.render(
<StrictMode>
<ConvexProviderWithAuth0
client={convex}
authInfo={authInfo}
loggedOut={<Login />}
>
<App />
</ConvexProviderWithAuth0>
</StrictMode>,
document.getElementById("root")
);
This wrapper component renders a login view until the user has logged in, not
rendering its child components until then. Once the user has logged in, all
components inside the main App
component can access authentication state
information through the
useAuth0
hook.
Add login and logout buttons
Notice the addition of loggedOut=<Login />
in the above code:
ConvexProviderWithAuth0 will show a simple login
button by default, but we can customize that view by passing in a JSX expression
that contains our own login button as the loggedOut
prop.
The values provided by useAuth0
make it easy to create login and logout
buttons: the logout
and loginWithRedirect
values are functions that can be
called from event handlers.
export function Login() {
const { isLoading, loginWithRedirect } = useAuth0();
if (isLoading) {
return <button className="btn btn-primary">Loading...</button>;
}
return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<div className="text-center">
<span>
<button className="btn btn-primary" onClick={loginWithRedirect}>
Log in
</button>
</span>
</div>
</main>
);
}
Let's make a logout button too:
function Logout() {
const { logout, user } = useAuth0();
return (
<div>
{/* We know this component only renders if the user is logged in. */}
<p>Logged in{user!.name ? ` as ${user!.name}` : ""}</p>
<button
className="btn btn-primary"
onClick={() => logout({ returnTo: window.location.origin })}
>
Log out
</button>
</div>
);
}
You can add this at the top of the rendered application, replacing the old randomly-generated user string. Like so:
<div className="text-center">
<span>
<Logout />
</span>
</div>
Updating our app
Next, let's start changing the core application logic to be aware of authentication state.
We'll give an overview of the steps, then present a code example that puts them together.
Store the user in the Convex deployment
ConvexProviderWithAuth0
provides our application with a Convex client that has
an ID token set. We can access information about the authenticated user in our
queries and mutations through the
Auth.getUserIdentity
method of
the auth
property of the QueryCtx
or
MutationCtx
instance passed as first
arguments to every Convex function invocation. The returned
UserIdentity contains a tokenIdentifier
string which can be used as a unique identifier for the user object in a table.
If this call doesn't return null
, we know the user is authenticated. But just
having access to the user's identity inside a function is only half of what we
need. Most applications will want to persist the user so they can be referenced
as a foreign key from other documents (in our case, from the messages
table).
We'll add a storeUser
mutation that runs after the user has logged in to add
the user to a users
table, updating if the entry already exists.
Using the getUserIdentity
method and the db
methods from previous examples,
you should end up with a mutation function that looks like this:
import { Document, Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";
// Insert or update the user in a Convex table then return the document's Id.
//
// The `UserIdentity` returned from `auth.getUserIdentity` is just an ephemeral
// object representing the identity of the authenticated user; most applications
// will want to store this in a `users` table to reference it in their other
// tables.
//
// The `UserIdentity.tokenIdentifier` string is a stable and unique value we use
// to look up identities, but inserting the value into a table also gives us an
// `_id` field.
//
// Keep in mind that `UserIdentity` has a number of optional fields, the
// presence of which depends on the identity provider chosen. It's up to the
// application developer to determine which ones are available and to decide
// which of those need to be persisted.
export default mutation(async ({ db, auth }): Promise<Id<"users">> => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Called storeUser without authentication present");
}
// Check if we've already stored this identity before.
const user: Document<"users"> | null = await db
.table("users")
.filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.first();
if (user !== null) {
// If we've seen this identity before but the name has changed, patch the value.
if (user.name != identity.name) {
db.patch(user._id, { name: identity.name! });
}
return user._id;
}
// If it's a new identity, create a new `User`.
return db.insert("users", {
name: identity.name!,
tokenIdentifier: identity.tokenIdentifier,
// The `_id` field will be assigned by the backend.
});
});
We'll also need to update our schema in convex/schema.ts
to reflect this new
users
table:
users: defineTable({
name: s.string(),
tokenIdentifier: s.string(),
}),
We're switching back to the UI, so update the generated client with
npx convex codegen
. Now, call this mutation whenever the application mounts by
placing it in a useEffect
, which should look like this (at the top of your
App
component):
const [userId, setUserId] = useState<Id<"users"> | null>(null);
const storeUser = useMutation("storeUser");
const addChannel = useMutation("addChannel");
// Call the `storeUser` mutation function to store
// the current user in the `users` table and return the `Id` value.
useEffect(() => {
// Store the user in the database.
// Recall that `storeUser` gets the user information via the `auth`
// object on the server. You don't need to pass anything manually here.
async function createUser() {
const id = await storeUser();
setUserId(id);
}
createUser();
return () => setUserId(null);
}, [storeUser]);
Once userId
is non-null we'll know that the user has been persisted to the
users table in Convex.
Reference the user when sending messages
Now that the user is stored in the deployment, we can update our listMessages
and sendMessage
functions.
Starting with sendMessage
, we'll first change the mutation to raise an error
if called without authentication. We'll also want to update the UI to prevent
this situation from occurring naturally, but it's important to enforce this in
the mutation itself.
With authentication, sendMessage
no longer needs an author
parameter.
Instead, we can store the user's Id
and access
the user's name when listing messages.
The updated sendMessage
mutation should look like this:
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";
// Send a message to the given chat channel.
export default mutation(
async ({ db, auth }, channel: Id<"channels">, body: string) => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthenticated call to sendMessage");
}
const user = await db
.table("users")
.filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.unique();
const message = {
channel,
body,
user: user._id,
};
db.insert("messages", message);
}
);
Remember to clear your "messages" table
If you have existing messages in your database from the previous tutorial, go to the dashboard and hit the "clear table" button now.
This is necessary because the old messages won't have a user
field on them.
Look up users when listing messages
The messages
table no longer has a populated author
field, so we'll need to
look up the user
reference when listing messages to find the display name.
Our query will mostly look the same, but now we'll process the messages before
returning to fetch the users who sent them. The new MessageWithAuthor
type
represents documents from the messages table that we've augmented with an
author
field. The result should look like this:
import { Document, Id } from "./_generated/dataModel";
import { query } from "./_generated/server";
export type MessageWithAuthor = Document<"messages"> & {
author: string;
};
// List all chat messages in the given channel.
export default query(
async ({ db }, channel: Id<"channels">): Promise<MessageWithAuthor[]> => {
const messages = await db
.table("messages")
.filter(q => q.eq(q.field("channel"), channel))
.collect();
return Promise.all(
messages.map(async message => {
// For each message in this channel, fetch the `User` who wrote it and
// insert their name into the `author` field.
const user = await db.get(message.user);
return {
author: user!.name,
...message,
};
})
);
}
);
Protect channel creation
Although channels won't have any reference to the user who created them, we
still want to ensure that the addChannel
mutation requires the request to be
authenticated. This is the simplest way to use auth: throw an error if we don't
find an identity.
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";
// Create a new chat channel.
export default mutation(
async ({ db, auth }, name: string): Promise<Id<"channels">> => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthenticated call to addChannel");
}
return db.insert("channels", { name });
}
);
Final UI touches
We've already added a login/logout button above that shows the user's name when signed in, so all that remains is to restrict sending messages and creating channels to signed-in users in the UI.
Earlier, in the authentication useEffect
hook, we also set up a
user
state hook representing the currently-authenticated user. We can update
our submit buttons to have the disabled
property when the user is null
, for
that brief period between the body of the chat app appearing and the storeUser
mutation returning.
Application code
Putting it all together, you should end up with a authenticated chat application that looks similar to this:
import { useState, FormEvent, useEffect } from "react";
import { useAuth0 } from "@auth0/auth0-react";
import { useMutation, useQuery } from "../convex/_generated/react";
import type { MessageWithAuthor } from "../convex/listMessages";
import { Id } from "../convex/_generated/dataModel";
// Render a chat message.
function MessageView(props: { message: MessageWithAuthor }) {
const message = props.message;
return (
<div>
<strong>{message.author}:</strong> {message.body}
</div>
);
}
function ChatBox(props: { channelId: Id<"channels"> }) {
// Dynamically update `messages` in response to the output of
// `listMessages.ts`.
const messages = useQuery("listMessages", props.channelId) || [];
const sendMessage = useMutation("sendMessage");
// Run `sendMessage.ts` as a mutation to record a chat message when
// `handleSendMessage` triggered.
const [newMessageText, setNewMessageText] = useState("");
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
setNewMessageText(""); // reset text entry box
await sendMessage(props.channelId, newMessageText);
}
return (
<div className="chat-box">
<ul className="list-group shadow-sm my-3">
{messages.slice(-10).map(message => (
<li
key={message._id.toString()}
className="list-group-item d-flex justify-content-between"
>
<MessageView message={message} />
<div className="ml-auto text-secondary text-nowrap">
{new Date(message._creationTime).toLocaleTimeString()}
</div>
</li>
))}
</ul>
<form
onSubmit={handleSendMessage}
className="d-flex justify-content-center"
>
<input
value={newMessageText}
onChange={event => setNewMessageText(event.target.value)}
className="form-control w-50"
placeholder="Write a message…"
/>
<input
type="submit"
value="Send"
disabled={!newMessageText}
className="ms-2 btn btn-primary"
/>
</form>
</div>
);
}
function Logout() {
const { logout, user } = useAuth0();
return (
<div>
{/* We know this component only renders if the user is logged in. */}
<p>Logged in{user!.name ? ` as ${user!.name}` : ""}</p>
<button
className="btn btn-primary"
onClick={() => logout({ returnTo: window.location.origin })}
>
Log out
</button>
</div>
);
}
export function Login() {
const { isLoading, loginWithRedirect } = useAuth0();
if (isLoading) {
return <button className="btn btn-primary">Loading...</button>;
}
return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<div className="text-center">
<span>
<button className="btn btn-primary" onClick={loginWithRedirect}>
Log in
</button>
</span>
</div>
</main>
);
}
export default function App() {
const [userId, setUserId] = useState<Id<"users"> | null>(null);
const storeUser = useMutation("storeUser");
const addChannel = useMutation("addChannel");
// Call the `storeUser` mutation function to store
// the current user in the `users` table and return the `Id` value.
useEffect(() => {
// Store the user in the database.
// Recall that `storeUser` gets the user information via the `auth`
// object on the server. You don't need to pass anything manually here.
async function createUser() {
const id = await storeUser();
setUserId(id);
}
createUser();
return () => setUserId(null);
}, [storeUser]);
// Dynamically update `channels` in response to the output of
// `listChannels.ts`.
const channels = useQuery("listChannels") || [];
// Records the Convex document ID for the currently selected channel.
const [channelId, setChannelId] = useState<Id<"channels"> | null>(null);
// Run `addChannel.ts` as a mutation to create a new channel when
// `handleAddChannel` is triggered.
const [newChannelName, setNewChannelName] = useState("");
async function handleAddChannel(event: FormEvent) {
event.preventDefault();
setNewChannelName("");
const id = await addChannel(newChannelName);
setChannelId(id);
}
return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<div className="text-center">
<span>
<Logout />
</span>
</div>
<br />
<div className="main-content">
<div className="channel-box">
<div className="list-group shadow-sm my-3">
{channels.map(channel => (
<a
key={channel._id.toString()}
className="list-group-item channel-item d-flex justify-content-between"
style={{
display: "block",
fontWeight: channel._id.equals(channelId) ? "bold" : "normal",
}}
onClick={() => setChannelId(channel._id)}
>
{channel.name}
</a>
))}
</div>
<form
onSubmit={handleAddChannel}
className="d-flex justify-content-center"
>
<input
value={newChannelName}
onChange={event => setNewChannelName(event.target.value)}
className="form-control w-50"
placeholder="Add a channel..."
disabled={userId === null}
/>
<input
type="submit"
value="Add"
className="ms-2 btn btn-primary"
disabled={!newChannelName || userId === null}
/>
</form>
</div>
{channelId ? <ChatBox channelId={channelId} /> : null}
</div>
</main>
);
}
Try it out
Push your Convex functions to the cloud with npx convex push
, run the UI
locally with npm run dev
, then navigate your browser to localhost:3000
to
try it out.