Authentication
An Admin Section
Having the admin screens at /admin
is a reasonable thing to do. Let's update the routes to make that happen by updating the four routes where the URL begins with /posts
to start with /admin/posts
instead:
- JavaScript
- TypeScript
import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'
const Routes = () => {
return (
<Router>
<Set wrap={PostsLayout}>
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
export default Routes
import { Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'
const Routes = () => {
return (
<Router>
<Set wrap={PostsLayout}>
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
export default Routes
Head to http://localhost:8910/admin/posts and our generated scaffold page should come up. Thanks to named routes we don't have to update any of the <Link>
s that were generated by the scaffolds since the name
s of the pages didn't change!
Having the admin at a different path is great, but nothing is stopping someone from just browsing to that new path and messing with our blog posts. How do we keep prying eyes away?
Authentication
"Authentication" is a blanket term for all of the stuff that goes into making sure that a user, often identified with an email address and password, is allowed to access something. Authentication can be famously fickle to do right both from a technical and developer-happiness standpoint.
"Credentials" are the pieces of information a user provides to prove they are who they say they are: commonly a username (usually email) and password.
Redwood includes two authentication paths out of the box:
- Self-hosted, where user credentials are stored in your own database
- Third-party hosted, where user credentials are stored with the third party
In both cases you end up with an authenticated user that you can access in both the web and api sides of your app.
Redwood includes integrations for several of the most popular third-party auth providers:
- Auth0
- Clerk
- Netlify Identity
- Netlify GoTrue-JS
- Magic
- Nhost
- Firebase's GoogleAuthProvider
- Supabase
- SuperTokens
- WalletConnect
As for our blog, we're going to use self-hosted authentication (named dbAuth in Redwood) since it's the simplest to get started with and doesn't involve any third party signups.
There are two terms which contain a lot of letters, starting with an "A" and ending in "ation" (which means you could rhyme them if you wanted to) that become involved in most discussions about login:
- Authentication
- Authorization
Here is how Redwood uses these terms:
- Authentication deals with determining whether someone is who they say they are, generally by "logging in" with an email and password, or a third party provider like Auth0.
- Authorization is whether a user (who has usually already been authenticated) is allowed to do something they want to do. This generally involves some combination of roles and permission checking before allowing access to a URL or feature of your site.
This section of the tutorial focuses on Authentication only. See chapter 7 of the tutorial to learn about Authorization in Redwood.
Auth Setup
As you probably have guessed, Redwood has a couple of generators to get you going. One installs the backend components needed for dbAuth, the other creates login, signup and forgot password pages.
Run this setup command to get the internals of dbAuth added to our app:
yarn rw setup auth dbAuth
When asked if you want to override the existing file /api/src/lib/auth.ts
say yes. The shell auth.ts
that's created in a new app makes sure things like the @requireAuth
directive work, but now we'll replace it with a real implementation.
You'll see that the process creates several files and includes some post-install instructions for the last couple of customizations you'll need to make. Let's go through them now.
Create a User Model
First we'll need to add a couple of fields to our User
model. We don't even have a User
model yet, so we'll create one along with the required fields at the same time.
Open up schema.prisma
and add:
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = "native"
}
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
}
model Contact {
id Int @id @default(autoincrement())
name String
email String
message String
createdAt DateTime @default(now())
}
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
}
This gives us a user with a name and email, as well as four fields that dbAuth will control:
- hashedPassword: stores the result of combining the user's password with a
salt
and then hashed - salt: a unique string that combines with the hashedPassword to prevent rainbow table attacks
- resetToken: if the user forgets their password, dbAuth inserts a token in here that must be present when the user returns to reset their password
- resetTokenExpiresAt: a timestamp after which the
resetToken
will be considered expired and no longer valid (the user will need to fill out the forgot password form again)
Let's create the user model by migrating the database, naming it something like "create user":
yarn rw prisma migrate dev
That's it for the database setup!
Private Routes
Try reloading the Posts admin and we'll see something that's 50% correct:
Going to the admin section now prevents a non-logged in user from seeing posts, great! This is the result of the @requireAuth
directive in api/src/graphql/posts.sdl.ts
: you're not authenticated so GraphQL will not respond to your request for data. But, ideally they wouldn't be able to see the admin pages themselves. Let's fix that with a new component in the Routes file, <Private>
:
- JavaScript
- TypeScript
import { Private, Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'
const Routes = () => {
return (
<Router>
<Private unauthenticated="home">
<Set wrap={PostsLayout}>
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
export default Routes
import { Private, Router, Route, Set } from '@redwoodjs/router'
import PostsLayout from 'src/layouts/PostsLayout'
import BlogLayout from 'src/layouts/BlogLayout'
const Routes = () => {
return (
<Router>
<Private unauthenticated="home">
<Set wrap={PostsLayout}>
<Route path="/admin/posts/new" page={PostNewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPostPage} name="post" />
<Route path="/admin/posts" page={PostPostsPage} name="posts" />
</Set>
</Private>
<Set wrap={BlogLayout}>
<Route path="/article/{id:Int}" page={ArticlePage} name="article" />
<Route path="/contact" page={ContactPage} name="contact" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
</Set>
<Route notfound page={NotFoundPage} />
</Router>
)
}
export default Routes
We wrap the routes we want to be private (that is, only accessible when logged in) in the <Private>
component, and tell our app where to send them if they are unauthenticated. In this case they should go to the home
route.
Try going back to http://localhost:8910/admin/posts now and—yikes!
Well, we couldn't get to the admin pages, but we also can't see our blog posts any more. Do you know why we're seeing the same message here that we saw in the posts admin page?
It's because the posts
query in posts.sdl.ts
is used by both the homepage and the posts admin page. Since it has the @requireAuth
directive, it's locked down and can only be accessed when logged in. But we do want people that aren't logged in to be able to view the posts on the homepage!
Now that our admin pages are behind a <Private>
route, what if we set the posts
query to be @skipAuth
instead? Let's try:
- JavaScript
- TypeScript
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
}
type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @requireAuth
}
input CreatePostInput {
title: String!
body: String!
}
input UpdatePostInput {
title: String
body: String
}
type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
}
type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @requireAuth
}
input CreatePostInput {
title: String!
body: String!
}
input UpdatePostInput {
title: String
body: String
}
type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`
Reload the homepage and:
They're back! Let's just check that if we click on one of our posts that we can see it...UGH:
This page shows a single post, using the post
query, not posts
! So, we need to @skipAuth
on that one as well:
- JavaScript
- TypeScript
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
}
type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @skipAuth
}
input CreatePostInput {
title: String!
body: String!
}
input UpdatePostInput {
title: String
body: String
}
type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`
export const schema = gql`
type Post {
id: Int!
title: String!
body: String!
createdAt: DateTime!
}
type Query {
posts: [Post!]! @skipAuth
post(id: Int!): Post @skipAuth
}
input CreatePostInput {
title: String!
body: String!
}
input UpdatePostInput {
title: String
body: String
}
type Mutation {
createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! @requireAuth
}
`
Cross your fingers and reload!
We're back in business! Once you add authentication into your app you'll probably run into several situations like this where you need to go back and forth, re-allowing access to some pages or queries that inadvertently got locked down by default. Remember, Redwood is secure by default—we'd rather you accidentally expose too little of your app than too much!
Now that our pages are behind login, let's actually create a login page so that we can see them again.
posts
and post
feels bad somehow...Ahh, good eye. While posts don't currently expose any particularly secret information, what if we eventually add a field like publishStatus
where you could mark a post as draft
so that it doesn't show on the homepage. But, if you knew enough about GraphQL, you could easily request all posts in the database and be able to read all the drafts!
It would be more future-proof to create a new endpoint for public display of posts, something like publicPosts
and publicPost
that will have built-in logic to only ever return a minimal amount of data and leave the default posts
and post
queries returning all the data for a post, something that only the admin will have access to. (Or do the opposite: keep posts
and post
as public and create new adminPosts
and adminPost
endpoints that can contain sensitive information.)
Login & Signup Pages
Yet another generator is here for you, this time one that will create pages for login, signup and forgot password pages:
yarn rw g dbAuth
Again several pages will be created and some post-install instructions will describe next steps. But for now, try going to http://localhost:8910/login:
That was easy! We don't have a user to login with, so try going to the signup page instead (there's a link under the Login button, or just head to http://localhost:8910/signup):
dbAuth defaults to the generic "Username" for the first field, but in our case the username will be an email address (we can change that label in a moment). Create yourself a user with email and password:
And after clicking "Signup" you should end up back on the homepage, where everything looks the same! Yay? But now try going to http://localhost:8910/admin/posts:
Awesome! Signing up will automatically log you in (although this behavior can be changed) and if you look in the code for the SignupPage
you'll see where the redirect to the homepage takes place (hint: check out line 21).
Add a Logout Link
Now that we're logged in, how do we log out? Let's add a link to the BlogLayout
so that it's present on all pages, and also include an indicator of who you're actually logged in as.
Redwood provides a hook useAuth
which we can use in our components to determine the state of the user's login-ness, get their user info, and more. In BlogLayout
we want to destructure the isAuthenticated
, currentUser
and logOut
properties from useAuth()
:
- JavaScript
- TypeScript
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'
const BlogLayout = ({ children }) => {
const { isAuthenticated, currentUser, logOut } = useAuth()
return (
<>
<header>
<h1>
<Link to={routes.home()}>Redwood Blog</Link>
</h1>
<nav>
<ul>
<li>
<Link to={routes.home()}>Home</Link>
</li>
<li>
<Link to={routes.about()}>About</Link>
</li>
<li>
<Link to={routes.contact()}>Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</>
)
}
export default BlogLayout
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'
type BlogLayoutProps = {
children?: React.ReactNode
}
const BlogLayout = ({ children }: BlogLayoutProps) => {
const { isAuthenticated, currentUser, logOut } = useAuth()
return (
<>
<header>
<h1>
<Link to={routes.home()}>Redwood Blog</Link>
</h1>
<nav>
<ul>
<li>
<Link to={routes.home()}>Home</Link>
</li>
<li>
<Link to={routes.about()}>About</Link>
</li>
<li>
<Link to={routes.contact()}>Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</>
)
}
export default BlogLayout
As you can probably tell by the names:
- isAuthenticated: a boolean as to whether or not a user is logged in
- currentUser: any details the app has on that user (more on this in a moment)
- logOut: removes the user's session and logs them out
At the top right of the page, let's show the email address of the user (if they're logged in) as well as a link to log out. If they're not logged in, let's show a link to do just that:
- JavaScript
- TypeScript
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'
const BlogLayout = ({ children }) => {
const { isAuthenticated, currentUser, logOut } = useAuth()
return (
<>
<header>
<div className="flex-between">
<h1>
<Link to={routes.home()}>Redwood Blog</Link>
</h1>
{isAuthenticated ? (
<div>
<span>Logged in as {currentUser.email}</span>{' '}
<button type="button" onClick={logOut}>
Logout
</button>
</div>
) : (
<Link to={routes.login()}>Login</Link>
)}
</div>
<nav>
<ul>
<li>
<Link to={routes.home()}>Home</Link>
</li>
<li>
<Link to={routes.about()}>About</Link>
</li>
<li>
<Link to={routes.contact()}>Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</>
)
}
export default BlogLayout
import { useAuth } from '@redwoodjs/auth'
import { Link, routes } from '@redwoodjs/router'
type BlogLayoutProps = {
children?: React.ReactNode
}
const BlogLayout = ({ children }: BlogLayoutProps) => {
const { isAuthenticated, currentUser, logOut } = useAuth()
return (
<>
<header>
<div className="flex-between">
<h1>
<Link to={routes.home()}>Redwood Blog</Link>
</h1>
{isAuthenticated ? (
<div>
<span>Logged in as {currentUser.email}</span>{' '}
<button type="button" onClick={logOut}>
Logout
</button>
</div>
) : (
<Link to={routes.login()}>Login</Link>
)}
</div>
<nav>
<ul>
<li>
<Link to={routes.home()}>Home</Link>
</li>
<li>
<Link to={routes.about()}>About</Link>
</li>
<li>
<Link to={routes.contact()}>Contact</Link>
</li>
</ul>
</nav>
</header>
<main>{children}</main>
</>
)
}
export default BlogLayout
Well, it's almost right! Where's our email address? By default, the function that determines what's in currentUser
only returns that user's id
field for security reasons (better to expose too little than too much, remember!). To add email to that list, check out api/src/lib/auth.ts
:
- JavaScript
- TypeScript
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
import { db } from './db'
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true },
})
}
export const isAuthenticated = () => {
return !!context.currentUser
}
export const hasRole = ({ roles }) => {
if (!isAuthenticated()) {
return false
}
const currentUserRoles = context.currentUser?.roles
if (typeof roles === 'string') {
if (typeof currentUserRoles === 'string') {
// roles to check is a string, currentUser.roles is a string
return currentUserRoles === roles
} else if (Array.isArray(currentUserRoles)) {
// roles to check is a string, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) => roles === allowedRole)
}
}
if (Array.isArray(roles)) {
if (Array.isArray(currentUserRoles)) {
// roles to check is an array, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) =>
roles.includes(allowedRole)
)
} else if (typeof context.currentUser.roles === 'string') {
// roles to check is an array, currentUser.roles is a string
return roles.some(
(allowedRole) => context.currentUser?.roles === allowedRole
)
}
}
// roles not found
return false
}
export const requireAuth = ({ roles }) => {
if (!isAuthenticated()) {
throw new AuthenticationError("You don't have permission to do that.")
}
if (roles && !hasRole(roles)) {
throw new ForbiddenError("You don't have access to do that.")
}
}
At this point of the tutorial we have not added roles to our user model yet, therefore you can ignore the hasRole
method in api/src/lib/auth.js
for now.
If this bothers you, feel free to peek into the tutorial chapter about Authorization and add the missing field as described there.
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
import { db } from './db'
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true },
})
}
export const isAuthenticated = (): boolean => {
return !!context.currentUser
}
type AllowedRoles = string | string[] | undefined
export const hasRole = ({ roles }): boolean => {
if (!isAuthenticated()) {
return false
}
const currentUserRoles = context.currentUser?.roles
if (typeof roles === 'string') {
if (typeof currentUserRoles === 'string') {
// roles to check is a string, currentUser.roles is a string
return currentUserRoles === roles
} else if (Array.isArray(currentUserRoles)) {
// roles to check is a string, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) => roles === allowedRole)
}
}
if (Array.isArray(roles)) {
if (Array.isArray(currentUserRoles)) {
// roles to check is an array, currentUser.roles is an array
return currentUserRoles?.some((allowedRole) =>
roles.includes(allowedRole)
)
} else if (typeof context.currentUser.roles === 'string') {
// roles to check is an array, currentUser.roles is a string
return roles.some(
(allowedRole) => context.currentUser?.roles === allowedRole
)
}
}
// roles not found
return false
}
export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => {
if (!isAuthenticated()) {
throw new AuthenticationError("You don't have permission to do that.")
}
if (roles && !hasRole(roles)) {
throw new ForbiddenError("You don't have access to do that.")
}
}
At this point of the tutorial we have not added roles to our user model yet, therefore you can ignore the following error:
Property 'roles' does not exist on type '{ id: number; email: string; }'.
in the hasRole
method in api/src/lib/auth.ts
for now.
If this bothers you, feel free to peek into the tutorial chapter about Authorization and add the missing field as described there.
The getCurrentUser()
function is where the magic happens: whatever is returned by this function is the content of currentUser
, in both the web and api sides! In the case of dbAuth, the single argument passed in, session
, contains the id
of the user that's logged in. It then looks up the user in the database with Prisma, selecting just the id
. Let's add email
to this list:
- JavaScript
- TypeScript
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true, email: true},
})
}
export const getCurrentUser = async (session) => {
return await db.user.findUnique({
where: { id: session.id },
select: { id: true, email: true},
})
}
Now our email should be present at the upper right on the homepage:
Before we leave this file, take a look at requireAuth()
. Remember when we talked about the @requireAuth
directive and how when we first installed authentication we saw the message "You don't have permission to do that."? This is where that came from!
Session Secret
After the initial setup
command, which installed dbAuth, you may have noticed that an edit was made to the .env
file in the root of your project. The setup
script appended a new ENV var called SESSION_SECRET
along with a big random string of numbers and letters. This is the encryption key for the cookies that are stored in the user's browser when they log in. This secret should never be shared, never checked into your repo, and should be re-generated for each environment you deploy to.
You can generate a new value with the yarn rw g secret
command. It only outputs it to the terminal, you'll need to copy/paste to your .env
file. Note that if you change this secret in a production environment, all users will be logged out on their next request because the cookie they currently have cannot be decrypted with the new key! They'll need to log in again to a new cookie encrypted with the new key.
Wrapping Up
Believe it or not, that's pretty much it for authentication! You can use the combination of @requireAuth
and @skipAuth
directives to lock down access to GraphQL query/mutations, and the <Private>
component to restrict access to entire pages of your app. If you only want to restrict access to certain components, or certain parts of a component, you can always get isAuthenticated
from the useAuth()
hook and then render one thing or another.
Head over to the Redwood docs to read more about self-hosted authentication and third-party authentication.
One More Thing
Remember the GraphQL Playground exercise at the end of Creating a Contact? Try to run that again now that authentication is in place and you should get that error we've been talking about because of the @requireAuth
directive! But, creating a new contact should still work just fine (because we're using @skipAuth
on that mutation).
However, simulating a logged-in user through the GraphQL Playground is no picnic. But, we're working on improving the experience!