Tutorial - Web3 login for RedwoodJS

Don't roll your own crypto. Let users bring their own.

December 27, 2020

The goal of this tutorial is to enable your users to bring their own cryptography. Let’s explore together 🥾

There are already many tutorials about using web3 wallets to perform authentication. This is an updated guide, adapted to the latest tools available, primarily RedwoodJS. You may also find this tutorial helpful if you plan to implement your own custom redwood auth solution.

A simple login page, showing a metamask prompt to sign a message

Advantages to web3 login

  • Self-hosted - no dependence on 3rd-party providers
  • Completely free & scalable
  • Private & anonymous for users

Disadvantages

  • You’re managing auth, so your production setup better be secure
  • You must to design and code the UX/UI yourself

Prerequisites

I won’t cover the advantages of RedwoodJS, or how to get started here. For that you should read my previous post Using RedwoodJS to create an Ethereum app.

We’ll be using the following FOSS tools. Please consider supporting them via #help-wanted contributions.

  • @redwoodjs/core 0.21.0 link Bringing full-stack to the Jamstack
  • ethereumjs-util 3.0.0 link A collection of utility functions for Ethereum
  • eth-sig-util 7.0.7 link A collection of functions for signing and verifying data with Ethereum keys.
  • ethers.js 5.0.17 link Complete Ethereum library and wallet implementation in JavaScript.

Here is the Final example code https://github.com/pi0neerpat/redwood-web3-login-demo and the Demo site https://redwood-web3-login-demo.vercel.app/

🐉 Please keep in mind that RedwoodJS is under heavy development. Things might have changed since publishing.

Overview

Here is the general flow for authentication we will implement.

  1. Create the challenge message for the user

Mutation authChallenge (defined in SDL auth.sdl.js) accepts the incoming request from the client.

The corresponding function authChallenge() is called in our Service auth.js. The challenge message is returned.

  1. User signs the message with their wallet client-side
  2. Validate the signature and return an auth token

Mutation authVerify (defined in SDL auth.sdl.js) accepts the incoming request from the client.

The corresponding function authVerify() is called in our Service auth.js.

  1. Token is stored and passed with subsequent requests client-side
  2. Token is validated for authenticated requests

Create the auth service

Since the first step to authenticate is a graphql query, we will start by defining our prisma models.

model User {
  id    String     @id @default(uuid())
  address String  @unique
  ...
  authDetail AuthDetail
}

model AuthDetail {
  id    String     @id @default(uuid())
  nonce String
  timestamp DateTime @default(now())
}

Now lets have redwood auto-generate the necessary sdl files and services.

yarn rw scaffold User

yarn rw generate sdl AuthDetail

Tip: Use “—crud” flag if you want to include additional boilerplate functions when using the generate command.

Good work. Now we need to write our own custom services to handle authentication. It’s probably easiest to start by writing the sdl first, since its a simple input/output “recipe” for our service. Go ahead and create graphql/auth.sdl.js

export const schema = gql`
  type Mutation {
    authChallenge(input: AuthChallengeInput!): AuthChallengeResult
    authVerify(input: AuthVerifyInput!): AuthVerifyResult
  }

  input AuthChallengeInput {
    address: String!
  }

  type AuthChallengeResult {
    message: String!
  }

  input AuthVerifyInput {
    signature: String!
    address: String!
  }

  type AuthVerifyResult {
    token: String!
  }
`

Next we’ll write the service itself in services/auth/auth.js. This is where the “business logic” will be to handle authentication.

yarn workspace api add ethereumjs-util eth-sig-util jsonwebtoken
import { AuthenticationError } from '@redwoodjs/api'

import { bufferToHex } from 'ethereumjs-util'
import { recoverPersonalSignature } from 'eth-sig-util'
import jwt from 'jsonwebtoken'

import { db } from 'src/lib/db'

const NONCE_MESSAGE =
  'Please prove you control this wallet by signing this random text: '

const getNonceMessage = (nonce) => NONCE_MESSAGE + nonce

export const authChallenge = async ({ input: { address: addressRaw } }) => {
  const nonce = Math.floor(Math.random() * 1000000).toString()
  const address = addressRaw.toLowerCase()
  await db.user.upsert({
    where: { address },
    update: {
      authDetail: {
        update: {
          nonce,
          timestamp: new Date(),
        },
      },
    },
    create: {
      address,
      authDetail: {
        create: {
          nonce,
        },
      },
    },
  })

  return { message: getNonceMessage(nonce) }
}

export const authVerify = async ({
  input: { signature, address: addressRaw },
}) => {
  try {
    const address = addressRaw.toLowerCase()
    const authDetails = await db.user
      .findOne({
        where: { address },
      })
      .authDetail()
    if (!authDetails) throw new Error('No authentication started')

    const { nonce, timestamp } = authDetails
    const startTime = new Date(timestamp)
    if (new Date() - startTime > 5 * 60 * 1000)
      throw new Error(
        'The challenge must have been generated within the last 5 minutes'
      )
    const signerAddress = recoverPersonalSignature({
      data: bufferToHex(Buffer.from(getNonceMessage(nonce), 'utf8')),
      sig: signature,
    })
    if (address !== signerAddress.toLowerCase())
      throw new Error('invalid signature')

    return { token: 'dummyToken' }
  } catch (e) {
    throw new Error(e)
  }
}

In authChallenge we lookup the user by their address, and create a new user if it doesn’t exist. Then we store the nonce, and send the auth challenge string back to the client.

In authVerify we verify the signature, and return a token. For now we’ll use “dummyToken”. Later we’ll implement an actual token here.

Test the auth service

Navigate to http://localhost:8911/graphql and let’s play around with our two new endpoints.

mutation {
  authChallenge(input: { address: "223" }) {
    message
  }
}

Should return a message like “Please prove you control this wallet by signing this random text: 950678”

mutation {
  authVerify(input: { address: "223", signature: "123" }) {
    token
  }
}

Should return “Error: Invalid signature length”. This is because we aren’t sending a valid signature. We’ll test this out in the next section.

Now that you’re working with queries, you’ll want a nice admin dashboard to inspect/edit the database. Run this command to start the amazing Prisma Studio.

yarn rw db studio

Tip: In case you need a ☢ nuclear option, delete dev.db which holds the sqlite data. Start a new database withyarn rw db save && yarn rw db up.

Add the AuthProvider to the web app

Redwood ships with a nice useAuth hook from the @redwoodjs/auth package. We’ll use it to do fancy things like this:

const { hasRole } = useAuth()

...

{hasRole('admin') && (
  <Link to={routes.admin()}>Admin</Link>
)}

In order to use these hooks, we need to add the redwood AuthProvider at the root of our app. We also need to install Ethers which we’ll use to access the web3 wallet.

yarn workspace web add @redwoodjs/auth @ethersproject/providers
// file: web/src/index.js
import { AuthProvider } from '@redwoodjs/auth'
import customAuthClient from 'src/auth/client'

ReactDOM.render(
  <FatalErrorBoundary page={FatalErrorPage}>
    <AuthProvider client={customAuthClient} type="custom">
      <RedwoodProvider>
        <Routes />
      </RedwoodProvider>
    </AuthProvider>
  </FatalErrorBoundary>,
  document.getElementById('redwood-app')
)

Normally, we would specify a 3rd-party client here like Magic Link, Auth0, Firebase, etc. (full list). Instead we pass our own custom client.

Let’s create the client now in web/src/auth/client.js.

import { Web3Provider } from '@ethersproject/providers'
import { createGraphQLClient } from '@redwoodjs/web'

const LOCAL_TOKEN_KEY = 'wallet_auth_token'

const graphQLClient = createGraphQLClient()

const AUTH_CHALLENGE_MUTATION = gql`
  mutation AuthChallengeMutation($input: AuthChallengeInput!) {
    authChallenge(input: $input) {
      message
    }
  }
`
const AUTH_VERIFY_MUTATION = gql`
  mutation AuthVerifyMutation($input: AuthVerifyInput!) {
    authVerify(input: $input) {
      token
    }
  }
`
export const getErrorResponse = (error, functionName) => {
  const errorText = typeof error === 'string' ? error : error.message
  const res = {
    /* eslint-disable-nextline i18next/no-literal-string */
    message: `Error web3.${functionName}(): ${errorText}`,
  }
  const ABORTED = 'aborted'
  const EXCEPTION = 'exception'
  const UNKOWN = 'unknown error type'
  if (error.code) {
    res.code = error.code
    switch (error.code) {
      case 4001:
        res.txErrorType = ABORTED
        break
      case -32016:
        res.txErrorType = EXCEPTION
        break
      default:
        res.txErrorType = UNKOWN
    }
  }
  return { error: res }
}

export const isWeb3EnabledBrowser = () =>
  typeof window !== 'undefined' && typeof window.ethereum !== 'undefined'

export const unlockBrowser = async ({ debug }) => {
  try {
    if (!isWeb3EnabledBrowser()) {
      return { hasWallet: false, isUnlocked: false }
    }
    window.ethereum.autoRefreshOnNetworkChange = false

    const walletAddress = await window.ethereum.request({
      method: 'eth_requestAccounts',
      params: [
        {
          eth_accounts: {},
        },
      ],
    })

    const walletProvider = new Web3Provider(window.ethereum)

    const network = await walletProvider.getNetwork()
    if (debug)
      /* eslint-disable-next-line no-console */
      console.log(
        'Web3Browser wallet loaded: ',
        JSON.stringify({ walletAddress, network })
      )
    return {
      hasWallet: true,
      walletAddress: walletAddress[0],
      walletProvider,
    }
  } catch (error) {
    if (isWeb3EnabledBrowser()) {
      if (debug)
        /* eslint-disable-next-line no-console */
        console.log('Web3 detected in browser, but wallet unlock failed')
      return {
        hasWallet: true,
        isUnlocked: false,
        ...getErrorResponse(error, 'unlockBrowser'),
      }
    }
    return {
      hasWallet: false,
      isUnlocked: false,
      ...getErrorResponse(error, 'unlockBrowser'),
    }
  }
}

export const signMessage = async ({ walletProvider, message }) => {
  try {
    const signature = await walletProvider.getSigner(0).signMessage(message)
    return { signature }
  } catch (error) {
    return getErrorResponse(error, 'signMessage')
  }
}

const login = async () => {
  const {
    walletAddress,
    walletProvider,
    error: errorUnlocking,
    hasWallet,
  } = await unlockBrowser({ debug: true })

  const {
    data: {
      authChallenge: { message },
    },
  } = await graphQLClient.mutate({
    mutation: AUTH_CHALLENGE_MUTATION,
    variables: { input: { address: walletAddress } },
  })

  const { signature } = await signMessage({
    walletProvider,
    message,
  })

  const {
    data: {
      authVerify: { token },
    },
  } = await graphQLClient.mutate({
    mutation: AUTH_VERIFY_MUTATION,
    variables: { input: { address: walletAddress, signature } },
  })
  localStorage.setItem(LOCAL_TOKEN_KEY, token)
}

const getToken = async () => localStorage.getItem(LOCAL_TOKEN_KEY)

export default { login, getToken }

Our custom client must adhere to minimum requirements for the Redwood useAuth API (see docs and code). This will enable redwood to automatically inject our auth token into all request headers.

Add the login button

Earlier we generated pages, cells, and components for our User when we ran the scaffold command. Let’s clean things up before moving forward. Go ahead and delete the “new user” page and the associated components and routes.

Now let’s create a login page.

yarn rw generate page login

Wire up a button to call the login function using the useAuth hook.

import { Link, routes, navigate } from '@redwoodjs/router'
import { useAuth } from '@redwoodjs/auth'
import { useParams } from '@redwoodjs/router'

const LoginPage = () => {
  const { logIn } = useAuth()
  const { redirectTo } = useParams()

  const onLogin = async () => {
    await logIn()
    navigate(redirectTo || routes.home())
  }

  return (
    <>
      <h1>LoginPage</h1>
      <button onClick={onLogin}> Log In </button>
    </>
  )
}

export default LoginPage

Testing the login

When you click the login button, you should see two prompts in your wallet. The first will unlock your wallet, so the redwood app can read your address and fetch the challenge message. The second will ask you to sign the challenge message. After signing, the authentication flow will be completed, and you will be given an auth token. In your localStorage, the item wallet_auth_token should now have a value of “dummyToken”.

Great! We can now successfully prove the user owns their wallet. From now on, the client will add the auth token to all subsequent requests. Let’s pop back over to the server so we can implement a real token.

Implement json web token (jwt)

Back in our auth service we need to pass a real token in place of “dummyToken”. Replace the return statement in authVerify() with the following:

const token = jwt.sign({ address }, process.env.JWT_SECRET, {
  expiresIn: '5h',
})
return { token }

Now if you login, you should receive a real jwt. Great! Now that we have a real jwt, which gets passed in every request, the server needs to check whether it is valid or not.

To do this, create a new library file in api/src/lib called auth.js

import { AuthenticationError } from '@redwoodjs/api'
import jwt from 'jsonwebtoken'

import { db } from 'src/lib/db'

const verifyToken = (token) => {
  try {
    // Returns if the token is both valid and not expired
    const data = jwt.verify(token, process.env.JWT_SECRET)
    return { valid: true, expired: false, data }
  } catch (err) {
    // Returns if the token is valid but expired
    if (err && err.name === 'TokenExpiredError')
      return {
        valid: true,
        expired: true,
        data: jwt.decode(token, process.env.JWT_SECRET),
      }
    // Returns if the token is not valid
    return { valid: false, expired: false, data: {} }
  }
}

export const getCurrentUser = async (_decoded, { token }) => {
  const { valid, expired, data } = verifyToken(token)
  if (!valid) throw new AuthenticationError('Invalid Token Provided')
  return db.user.findOne({ where: { address: data.address } })
}

export const requireAuth = () => {
  if (!context.currentUser) {
    throw new AuthenticationError("You don't have permission to do that.")
  }
}

Here getCurrentUser() gets the user from the token, and requireAuth() ensures the user’s token is valid. If necessary, this is where we can define more complex rules such as role-based authentication.

Now we need to tell Apollo Server to use our new getCurrentUser() function, so that the user details are injected into context. Let’s add this over in api/functions/graphql.js.

import { getCurrentUser } from 'src/lib/auth'

export const handler = createGraphQLHandler({
  getCurrentUser,
  schema: makeMergedSchema({

Thanks to the magic of @redwoodjs/api, when a request is made the header token is decoded according to one of the built-in decoders. The decoded token is then be passed to our getCurrentUser() function and the user details are added to context. You’ll notice that in our case, we implemented our own decoding step, in order to have more control over this process.

Just fyi, the following headers are being used. The Auth-Provider field is how the appropriate decoder is selected.

Authorization: Bearer TOKEN
Auth-Provider: custom

Restrict services

We are ready to begin closing off our api to only authenticated users. In a test scenario, let’s only allow authenticated users to get user data. In services/users/users.js we can add the requireAuth() function.

import { requireAuth } from 'src/lib/auth'

export const users = () => {
  requireAuth()
  return db.user.findMany()
}

Now test it out in the graphql playground!

query {
  users{
    id
    createdAt
    updatedAt
  }
}

You should receive an error “You don’t have permission to do that.”

Restrict web routes

Back in the web app, lets continue the example scenario. In our Routes.js, we will redirect a user if they are not authenticated.

import { Router, Route, Private } from '@redwoodjs/router'

const Routes = () => {
...
<Private unauthenticated="login">
  <Route path="/users/{id}" page={UserPage} name="user" />
  <Route path="/users" page={UsersPage} name="users" />
</Private>

I’ll leave it up to you to explore more, such as restricting in-page content. Go check out the redwood authorization docs.

Additional Resources

Custom auth examples

Useful docs

Closing Remarks

Hey thanks for reading, I hope you learned something. I’ll be implementing this in a production app soon, so stay tuned. No go forth and build an amazing redwood app with a web3 login!