DEV Community

tmns
tmns

Posted on • Edited on

Adding Authentication to RedwoodJS (the hard way)

What?

RedwoodJS is a new (still in alpha) JAMstack-inspired framework that helps you build React-GraphQL
web applications quickly and enjoyably. It is unabashedly opinionated, with the goal of making the structural (read: boring) decisions for you, in turn allowing you to dive straight into the creative (read: fun) parts.

Over the past few weeks I have been building a small project with RedwoodJS and overall have been very pleased with the developer experience, as well as the official docs. However, when I eventually reached the point of adding authentication / authorization to the project, I realized that this hadn't been implemented yet by RedwoodJS the team.

Naturally, they are aware that such an important feature is missing and it is actually currently a main priority of theirs. Considering they're also super capable, it could even be implemented by the time I finish writing this - making the text obsolete - but in the case it isn't - read on :)

How?

It's worth noting up-front that this isn't the simplest solution out there. You could for example use something like Netlify Identity or Magic Link if it suits your use case. However, my use case was a little particular in that I wanted to allow logging in but exclude registration (done manually via a side-channel), which led me to Firebase.

To get started, you will first need to create a new project in Firebase. You will then have to "add an app" to this new project, which you can find the option for in Settings -> General (at the time of writing). This process can be a little confusing if you've never worked with the Firebase console before but there are plenty of guides out there if you get lost navigating all its buttons and tabs.

Once you've added an app to your project, Firebase should present you with some configuration values, looking something like the following:

  // Your web app's Firebase configuration
  var firebaseConfig = {
    apiKey: "example",
    authDomain: "example.firebaseapp.com",
    databaseURL: "example",
    projectId: "example",
    storageBucket: "example.appspot.com",
    messagingSenderId: "example",
    appId: "example"
  };
Enter fullscreen mode Exit fullscreen mode

Assuming you already have a RedwoodJS app set up, you will want to locate and save these values in its .env file (by default ignored by git), like so:

REACT_APP_API_KEY="example"
REACT_APP_AUTH_DOMAIN="example.firebaseapp.com"
REACT_APP_DATABASE_URL="example"
REACT_APP_PROJECT_ID="example"
REACT_APP_STORAGE_BUCKET="example.appspot.com"
REACT_APP_MESSAGING_SENDER_ID="example"
REACT_APP_APP_ID="example"
Enter fullscreen mode Exit fullscreen mode

Now that we have our configuration values, let's begin building out our authentication hook, which will take advantage of React's useContext. Luckily enough, a great example of such a hook (along with its application) already exists for us to use, which you can see in its entirety over at useHooks.

Let's first import all the necessary dependencies, including some of Redwood's router methods, and initialize our Firebase app with the above configuration (this can be done in a new file, e.g. src/context/use-auth.js):

import { useState, useEffect, useContext, createContext } from 'react'
import { navigate, routes } from '@redwoodjs/router'
import * as firebase from 'firebase/app'
import 'firebase/auth'

firebase.initializeApp({
  apiKey: process.env.REACT_APP_API_KEY,
  authDomain: process.env.REACT_APP_AUTH_DOMAIN,
  databaseURL: process.env.REACT_APP_DATABASE_URL,
  projectId: process.env.REACT_APP_PROJECT_ID,
  storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
  messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
})
Enter fullscreen mode Exit fullscreen mode

Now, we can continue on in the same file - exporting an authentication context provider and a corresponding useAuth hook:

const authContext = createContext()

export function ProvideAuth({ children }) {
  const auth = useProvideAuth()
  return <authContext.Provider value={auth}>{children}</authContext.Provider>
}

export const useAuth = () => useContext(authContext)
Enter fullscreen mode Exit fullscreen mode

But where does this useProvideAuth come from?? We'll build it now! It will be in this function that we will implement our signin and signout functions (as well as registration, reset-password, and any other authentication logic you want). Also, we will utilize useEffect to watch the user's authentication status and keep it in sync with a user reference we will export along with our authentication functions:

function useProvideAuth() {
  const [user, setUser] = useState(null)
  const [error, setError] = useState(null)

  const signin = (email, password) => {
    return firebase
      .auth()
      .signInWithEmailAndPassword(email, password)
      .then((response) => {
        setUser(response.user)
        navigate(routes.orders())
      })
      .catch((error) => {
        setError(error)
      })
  }

  const signout = () => {
    return firebase
      .auth()
      .signOut()
      .then(() => {
        setUser(false)
        navigate(routes.home())
      })
      .catch((error) => {
        setError(error)
      })
  }

  useEffect(() => {
    const unsubscribe = firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        setUser(user)
      } else {
        setUser(false)
      }

      return () => unsubscribe()
    })
  }, [])

  return {
    user,
    error,
    signin,
    signout,
  }
Enter fullscreen mode Exit fullscreen mode

Note that in my example above, on successful login I call navigate(routes.orders()), which is Redwood's way of programmatically navigating the user to a specific route. Here, I navigate the user to an "/orders" route that exists in my application. Of course, for your own project you would change this to your own desired route, or even remove it completely if it doesn't suit your needs.

Great! So now we have our authentication context provider and hook - but how do we use them? The most direct way I found was first adding the provider to the main web/src/index.js file, like so:

import ReactDOM from 'react-dom'
import { RedwoodProvider, FatalErrorBoundary } from '@redwoodjs/web'
import FatalErrorPage from 'src/pages/FatalErrorPage'

import Routes from './Routes'
import { ProvideAuth } from './context/use-auth'

import './scaffold.css'
import './index.css'

ReactDOM.render(
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider>
      <ProvideAuth>
        <Routes />
      </ProvideAuth>
    </RedwoodProvider>
  </FatalErrorBoundary>,
  document.getElementById('redwood-app')
)
Enter fullscreen mode Exit fullscreen mode

Then, if for example we have a login page, we can use our hook like so:

// all our other imports
import { useAuth } from 'src/context/use-auth'

[...]

const LoginForm = () => {
  const { signin, error } = useAuth()

  const onSubmit = ({ email, password }) => {
    signin(email, password)
  }

  return (
    <FormContainer>
      <FormStyled
        onSubmit={onSubmit}
        validation={{ mode: 'onBlur' }}
      >
        <Heading style={{ color: '#3273dc' }}>Login</Heading>
        <SubmitError>
          {error && 'Incorrect username or password'}
        </SubmitError>
[...]
Enter fullscreen mode Exit fullscreen mode

Above we're using destructuring to assign the returned object's values of useAuth directly to signin and error, but you could also return the whole object to a variable (e.g. auth) and use it that way (e.g. auth.signin and auth.error).

Similarly, if we wanted to render a component based on the authentication status of the user, say a login button if they're logged out and a logout button if they're logged in, we could do something like so:

const Navbar = () => {
  const { user, signout } = useAuth()

  const LoginButton = () => (
    <Link to={routes.login()}>
      <Button color="info" outlined>
        Login
      </Button>
    </Link>
  )

  const LogoutButton = () => (
    <Button color="info" outlined onClick={() => signout()}>
      Logout
    </Button>
  )

  return (
[...]
        <Navbar.Container position="end">
          <Navbar.Item renderAs="div">
            {user ? <LogoutButton /> : <LoginButton />}
          </Navbar.Item>
        </Navbar.Container>
      </Navbar.Menu>
    </NavbarStyled>
  )
Enter fullscreen mode Exit fullscreen mode

Okay, so we now have authentication working and can even render components depending on the authentication status of our user - but what about conditionally rendering routes? How do we protect routes that we don't want unauthenticated users to access?

Attempting to answer this question led me down a dark path of severe trial and error. I will keep it short and sweet here, focusing on the solution I came up with rather than all the failures. If you want to hear me rant though, feel free to reach out! ^_^

Let's first create a separate App component, which will use React Suspense to lazy load versions of our routes, based on the authentication status of our user:

// web/src/app.js
import { lazy, useEffect, Suspense } from 'react'
import { css } from '@emotion/core'
import ClipLoader from 'react-spinners/ClipLoader'

import { useAuth } from 'src/context/use-auth'

const loadAuthRoutes = () => import('./AuthRoutes.js')
const AuthRoutes = lazy(loadAuthRoutes)
const Routes = lazy(() => import('./Routes.js'))

const override = css`
  display: block;
  margin: 3em auto 0 auto;
  font-size: 4em;
  border-color: #3273dc;
`

function App() {
  const { user } = useAuth()

  useEffect(() => {
    loadAuthRoutes()
  }, [])

  return (
    <Suspense fallback={<ClipLoader css={override} />}>
      {user ? <AuthRoutes /> : <Routes />}
    </Suspense>
  )
}

export default App

// and slightly modify web/src/index.js to use App instead of Routes
import App from './app'
import { ProvideAuth } from './context/use-auth'

import './scaffold.css'
import './index.css'

ReactDOM.render(
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider>
      <ProvideAuth>
        <App />
      </ProvideAuth>
    </RedwoodProvider>
  </FatalErrorBoundary>,
  document.getElementById('redwood-app')
)
Enter fullscreen mode Exit fullscreen mode

This is quite similar to what's suggested by Kent C. Dodds in this post of his on authentication. The main difference here is that were solely importing different routes. Actually, the routes themselves technically are the same, we just have to add an extra attribute to our protected routes:

// web/src/Routes.js
import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
  return (
    <Router>
      <Route path="/login" page={LoginPage} name="login" />
      <Route 
        path="/orders" 
        page={OrdersPage} 
        name="orders" 
        redirect="/login" />
      <Route
        path="/orders/{id:Int}"
        page={OrderPage}
        name="order"
        redirect="/login"
      />
      <Route
        path="/orders/{id:Int}/edit"
        page={EditOrderPage}
        name="editOrder"
        redirect="/login"
      />
      <Route
        path="/orders/new"
        page={NewOrderPage}
        name="newOrder"
        redirect="/login"
      />
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

// web/src/AuthRoutes.js
import { Router, Route } from '@redwoodjs/router'
import LoginPage from 'src/pages/LoginPage'
import HomePage from 'src/pages/HomePage'
import NotFoundPage from 'src/pages/NotFoundPage'
import OrdersPage from 'src/pages/OrdersPage'
import OrderPage from 'src/pages/OrderPage'
import EditOrderPage from 'src/pages/EditOrderPage'
import NewOrderPage from 'src/pages/NewOrderPage'

const AuthRoutes = () => {
  return (
    <Router>
      <Route path="/login" page={LoginPage} name="login" redirect="/orders" />
      <Route path="/orders" page={OrdersPage} name="orders" />
      <Route path="/orders/{id:Int}" page={OrderPage} name="order" />
      <Route path="/orders/{id:Int}/edit" page={EditOrderPage} name="editOrder" />
      <Route path="/orders/new" page={NewOrderPage} name="newOrder" />
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default AuthRoutes
Enter fullscreen mode Exit fullscreen mode

So, what we essentially do is add a redirect option to each of the routes we want protected in Routes.js, which is the default set of routes loaded when a user is authenticated. This ensures the user is redirected to the login screen (in my example) whenever they attempt to access such a route.

Then, we create another routes file (AuthRoutes.js), which imports and defines all of the same routes, but without the redirect attribute on our protected routes, letting the user access them normally upon authentication.

Note, at the time of writing the redirect attribute isn't noted in the Redwood docs, I found out about it by looking at the source of the router itself:

  const routes = React.Children.toArray(children)
[...]
  for (let route of routes) {
    const { path, page: Page, redirect, notfound } = route.props
[...]
      if (redirect) {
        const newPath = replaceParams(redirect, pathParams)
        navigate(newPath)
        return (
          <RouterImpl pathname={newPath} search={search}>
            {children}
          </RouterImpl>
        )
      }
[...]
Enter fullscreen mode Exit fullscreen mode

Also, you may notice something interesting when looking at the source and how it handles the children of the Router component. It takes in all children, regardless of the component name, and then performs a set of operations on them. This means for example you could write something like this and you would still end up with a perfectly valid router / working app:

import { Router, Route } from '@redwoodjs/router'
const Ping = () => console.log("pong")

const Routes = () => {
  return (
    <Router>
      <Route path="/login" page={LoginPage} name="login" />
      <Ping path="/orders" page={OrdersPage} name="orders" />
      <Ping path="/orders/{id:Int}" page={OrderPage} name="order" />
      <Ping path="/orders/{id:Int}/edit" page={EditOrderPage} name="editOrder" />
      <Ping path="/orders/new" page={NewOrderPage} name="newOrder" />
      <Route path="/" page={HomePage} name="home" />
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}
Enter fullscreen mode Exit fullscreen mode

In fact, the function ping will never be called and you won't see any "pong" logged to the console. This may be obvious to people experienced in how routers are implemented but it was a shock to me! Further, this made it impossible for me to implement my first idea - the traditional ProtectedRoute component, as the ProtectedRoute logic would simply never be executed. However, I'm not too disappointed here, as the solution I ended up with is also pretty straight-forward to manage (in my super humble opinion).

And that's all there is to it! At this point you should have both your authentication implemented and your sensitive Redwood routes protected. If there are any pro Redwood users already out there that have some corrections / best practices to add to this discussion - please reach out / comment!

But most importantly - stay safe and healthy everyone!!

Top comments (0)