Custom github JWT Auth with Redwood Auth [Advice needed]

Hi All,

First of all thanks for the amazing work so far, we’re trying to build a simple service with Redwood, and it looks very promising so far.

So for our project we wanted to use custom auth built within redwood, and we’ve managed to hack it together, leveraging as much of the auth system introduced in 0.7.0 as possible. I stress on the word hack, and would love some advice on how we could contribute it back to redwood source in a cleaner way. Please note that this code is very WIP e.g. using both axios and got, but I’m hoping to start a discussion on the best way to make this part of redwood source, particularly the server side part.

Here’s how I set it up:

Step 1: Create a custom functions for github auth

No need to integrate this directly into redwood, I think - this feels like custom functionality

A. Authorize function

This function just redirects to github (note that adding a param to the function to redirect to another provider is easy peasy), but on the server side. Doing this on the backend would allow us to use the state param later if we choose.

src/functions/authorize

import got from 'got'

const { GITHUB_CLIENT_ID } = process.env

export const handler = async (event, context) => {
  const res = await got.get('https://github.com/login/oauth/authorize', {
    searchParams: {
      client_id: GITHUB_CLIENT_ID,
      scope: 'user:email',
      // redirect_uri: 'http://localhost:8910/auth/github/callback',
      // state: 'blahblah', // add when redis
    },
    followRedirect: false,
  })

  return {
    statusCode: res.statusCode,
    headers: { ...res.headers },
  }
}

B. The callback page

Once the user accepts the login prompt, github redirects back to http://localhost:8910/auth/github/callback

So our callback function in src/services/github.js

import axios from 'axios'
import got from 'got'
import jwt from 'jsonwebtoken'

import { findOrCreateUserByEmail } from '../users/users'

export const githubAccessToken = async ({ code }) => {
  const params = {
    code,
    client_id: process.env.GITHUB_CLIENT_ID,
    client_secret: process.env.GITHUB_CLIENT_SECRET,
  }

  const { data } = await axios.post(
    'https://github.com/login/oauth/access_token',
    params,
    {
      headers: { Accept: 'application/json' },
    }
  )

  // STEP 1: get user details using access token
  const { body: githubUserData } = await got.get(
    'https://api.github.com/user',
    {
      headers: {
        Authorization: `token ${data.access_token}`,
      },
      responseType: 'json',
    }
  )

  const { body: emailData } = await got.get(
    'https://api.github.com/user/emails',
    {
      headers: {
        Authorization: `token ${data.access_token}`,
        Accept: 'application/json',
      },
      responseType: 'json',
    }
  )

  // STEP 2: create or find user,
  const primaryEmail = emailData.find((email) => email.primary)
  const tapeUser = await findOrCreateUserByEmail({
    input: { email: primaryEmail.email, name: githubUserData.name },
  })

  // STEP 3:  generate JWT and return as accessToken
  const tapeToken = jwt.sign(tapeUser, `XXX_JWT_SECRET_KEY`, {
    algorithm: 'RS256',
  })

  return { tapeToken }
}

and our page:

In web/src/pages/Auth/GithubPage/GithubPage.js

import AuthCallbackCell from 'src/components/AuthCallbackCell'

const GithubPage = (params) => {
  return (
    <div>
      <h1>GithubPage</h1>
      <p>Params: {JSON.stringify(params)}</p>
      <AuthCallbackCell provider="github" code={params.code} />
    </div>
  )
}

export default GithubPage

And our Auth cell

import { useEffect } from 'react'

export const QUERY = gql`
  query($code: String!) {
    githubAccessToken(code: $code) {
      tapeToken
    }
  }
`

export const Loading = () => <div>Loading...</div>

export const Empty = () => <div>Empty</div>

export const Failure = ({ error }) => <div>Error: {error.message}</div>

export const Success = ({ githubAccessToken: { tapeToken } }) => {
  useEffect(() => {
    localStorage.setItem('tapeAuthToken', tapeToken)
    window.location = '/dashboard'
  }, [tapeToken])
  // console.log(response)
  return <p>Tape Access Token: {tapeToken}</p>
}

Ofcourse we setup the github.sdl and mapping to graphql as suggested in the docs. Note the hard redirect to dashboard, rather than using redwood’s navigate so the auth state gets picked up.

Step 2: Custom Auth Provider and Auth Client

Integration into redwood: easy | Client side mainly
The reason we created new components is because of some of the case statements within the redwood library, and thought composing a custom provider made more sense.

This is for client side primarily and follows some of the documentation available.

In index.js

    <CustomAuthProvider type="jwt">
      <RedwoodProvider>
        <Routes />
      </RedwoodProvider>
    </CustomAuthProvider>

CustomAuthProvider

import React from 'react'

import { createAuthClient } from './customAuthClient'

export const AuthContext = React.createContext({})

export class CustomAuthProvider extends React.Component {
  state = {
    loading: true,
    isAuthenticated: false,
    currentUser: null,
  }

  constructor(props) {
    super(props)
    this.rwClient = createAuthClient()
  }

  async componentDidMount() {
    await this.rwClient.restoreAuthState?.()
    const currentUser = await this.rwClient.currentUser()
    this.setState({
      currentUser,
      isAuthenticated: currentUser !== null,
      loading: false,
    })
  }

  logIn = async (options) => {
    const currentUser = await this.rwClient.login(options)
    this.setState({ currentUser, isAuthenticated: currentUser !== null })
  }

  logOut = async () => {
    await this.rwClient.logout()
    this.setState({ currentUser: null, isAuthenticated: false })
  }

  render() {
    const { client, type, children } = this.props

    return (
      <AuthContext.Provider
        value={{
          ...this.state,
          logIn: this.logIn,
          logOut: this.logOut,
          getToken: this.rwClient.getToken,
          // client: client,
          type: type,
        }}
      >
        {children}
      </AuthContext.Provider>
    )
  }
}

export default CustomAuthProvider

customAuthClient.js

import jwt from 'jsonwebtoken'

import { useCustomAuth } from 'src/components/useCustomAuth'

export const createAuthClient = () => {
  return {
    login: () => {
      console.warn('Not implemented yet')
    },
    logout: () => {
      console.warn('Not implemented yet')
    },
    restoreAuthState: async () => {
      // alert(
      //   'Restore auth state called. Not sure when this is used. Perhaps to refresh?'
      // )
      // !WARN: not sure why this variable isn't set automatically... they dont do it for auth0
      // Its used by the router when using <Private>
      window.__REDWOOD__USE_AUTH = useCustomAuth
    },
    getToken: () => {
      return localStorage.getItem('tapeAuthToken')
    },
    currentUser: () => {
      const token = localStorage.getItem('tapeAuthToken')

      const tokenDeets = jwt.decode(token)
      console.log('My token', token)
      console.log('Decoded', tokenDeets)
      return token ? tokenDeets : null
    },
  }
}

As you can see, there’s no need to modify redwood lib source for this necessarily, only that the requests to graphql will be rejected because auth-provider of type jwt isn’t recognised on the server side.

So next, I patched the backend code

Step 3: Patch authHeaders in @redwood/api/auth

Integration into redwood: Easy, but would need advice how to cleanly put it into the source

In node_modules/@redwoodjs/api/dist/auth/authHeaders.js, add a new case to the switch statement so auth-provider jwt is allowed

.
.
.
    case 'auth0':
      {
        decoded = await (0, _verifyAuth0Token.verifyAuth0Token)(token);
        break;
      }

    case 'jwt':
    {
      console.info('Entered custom jwt authHandler with public key', `XXX_JWT_PUBLIC_KEY`);
      decoded = _jsonwebtoken.default.verify(token, `XXX_JWT_PUBLIC_KEY`);
    }
    break;

    default:
      throw new Error(`Auth-Provider of type "${type}" is not supported.`);
.
.
.

So far so good, it all works (ofcourse if you replace the placeholder values for the keys). I just use a script to replace the values both in the github service and in the redwood authHeaders file

Now for the caveats, and would love some thoughts from the community here:

  1. Passing in the keys through env
    If we used process.env.JWT_SECRET_KEY and process.env.JWT_PRIVATE_KEY - it works quite well and saves me the trouble of patching the redwood auth code (beyond adding the case statement). But netlify fails to deploy this due to the 4096 byte limitation on AWS lambda environment variables. This is why I had to resort to the find-and-replace script, that changes the placeholder values.

Any advice here on what we could do?

  1. Reading the JWT public key in redwood
    In step 3, we use jwt.verify to check the JWT token that gets produced by the custom github function. I’d love to have used an env variable here, but that has issues as described above in (1).

So whats the best way to pass in the public key here without modifying redwood’s source (assuming the case statement exists). Is there some way we could pass it in when calling createGraphQLHandler ?

  1. In custom auth client, I had to add window.__REDWOOD__USE_AUTH = useCustomAuth
    This gets called in componentDidMount of the authProvider, but I don’t see why I had to do this considering this is set in the useCustomAuth, and is done exactly the same way as auth0 provider is setup.

Looking forward to your thoughts!

4 Likes

Please note I understand I should be making the changes in TypeScript and raising a PR (instead of patching compiled js), but I’m trying to workout the best way forward first :slight_smile:

@danny One approach to consider: enable Github via Auth0.

Auth0 supports many identity providers … Salesforce, Bitbucket, LinkedIn … about 50 in all. See https://auth0.com/docs/connections for a comprehensive list.

For a provider that’s supported by Auth0, an auth stack like Redwood --> Auth0 --> Github may be easier than Redwood --> Github. I prefer using Auth0 this way because of all the other things it can do – auth rules, event hooks, email cycle, monitoring etc. – things beyond “just” identity.

Thanks for the reply Chris - yes I’m aware of the integrations with Auth0, GoTrue and Netlify auth. Github is just an example here, but if we could get JWT handling built in to redwood, it could be of value to some people to not integrate, pay for and rely on an external service.

It’s the same reason express has passport, and rails has omniauth - even though you could easily integrate with Auth0. You can imagine it also has a lot of value when looking to migrate existing services which could use an already setup auth server.

Just to add that the example I posted above does work - I’m just looking for advice on how to add it to the redwood source so other people can benefit from it, rather than have to hack around redwood like I have!

2 Likes

@thedavid - any chance I could get your thoughts on this? Keen to contribute back to redwood!

2 Likes

Thanks for pinging me. Just confirming I saw this and will spend time soon replying with some thoughts/possibilities – just can’t get to it right this moment. But appreciate all the legwork on this so far! And offer to help with contrib == :rocket:

1 Like

@danny ok, lots to parse here. My goal is two-fold: give you some straight-up opinions and help towards a manageable next step. But the caveat is that I’m most definitely not our resident expert at Auth.

But let me start with this --> woah, this is awesome. Lots of moving pieces against a young framework and you pulled it off. Way. To. Go. :rocket:

Opinions

I’ve built a lot of things. Can’t say I’m necessarily great at it, but something I seem to do increasingly over time is utilize existing solutions at the beginning of a project – especially when my team is small and cognitive overhead is a precious resource. Not knowing your specific circumstances/needs, I have to +1 the suggestion about Auth0 --> it’s what I would use. You get 7,000 users on the free tier. 7,000! :astonished:

@chris-hailstorm (who’s one of our resident AuthMasters™) is a big advocate of not rolling your own Auth because of all the downstream capabilities you end up needing to address that can add a lot of overhead: 2 factor, Multiple identity providers, logging and auditing, security, etc. I agree with him.

But, to be clear, you don’t have to agree. So consider this a Fwiw…

Env Var Workarounds

You mention one of the primary blocking issues you’re facing is maxing out the size of env var (that definitely sucks – do you know if that’s Lamdba restriction or something on Netlify’s sides? Also, what’s the diff between size limit and your jwt secret?)

As a master of hacking things together, what I’d attempt to do is store the JWT secret in something like AWS Secrets Manager (or similar) and use the Lambda Env Var to access the Secrets Manager. That might be terrible and/or flawed due to serverless structure. But, well, you asked :wink:

RW Support for JWT + Roll-your-own Auth

Regarding " Step 3: Patch authHeaders in @redwood/api/auth", this could be of interest to support on the framework side.

@peterp (our resident, resident AuthMaster™) is currently working on some additions and restructuring of the Auth package. See #637. So the timing is right to discuss if something like this is possible, specifically this comment from above:

In node_modules/@redwoodjs/api/dist/auth/authHeaders.js, add a new case to the switch statement so auth-provider jwt is allowed

Also, I need Peter’s help clarifying these two questions:

So whats the best way to pass in the public key here without modifying redwood’s source (assuming the case statement exists). Is there some way we could pass it in when calling createGraphQLHandler?

^^ I don’t believe this is possible

In custom auth client, I had to add window.__REDWOOD__USE_AUTH = useCustomAuth
This gets called in componentDidMount of the authProvider, but I don’t see why I had to do this considering this is set in the useCustomAuth, and is done exactly the same way as auth0 provider is setup.

^^ unclear to me

1 Like

Thanks for this, this approach clarifies a lot of questions that I had for adding a “custom auth provider”, I think the approach for you have here is totally reasonable and something that should be available in Redwood.

On the API side

I think the way that you’ve done it is perfect, but instead of calling it jwt I would set the type as custom, and I would pass through the raw token to the getCurrentUser. (We’re doing that for magic.link and firebase).

We could add a jwt custom type, and provide a hook or place to store the key for verification, but I’m not sure that it would make things easier than what I’ve suggested above.

On the client side
I would love the API to look something like this:

import { MyAuthClient } from 'src/lib/myAuthClient'

const myAuthClient = new MyAuthClient(/** opts **/)
//...
<AuthProvider client={myAuthClient} type="custom">

As long as MyAuthClient returns an object that works within these bounds:

Then it should support the client side.

Regarding your callback once you’ve authenticated, Auth0 adds that logic in something like this:

  1. Hmpf - This is a tough one, I know that a lot of Rails developers are using encrypted secrets, and here’s a Netlify plugin that looks like it could do the something similair: https://github.com/sw-yx/netlify-plugin-encrypted-files. But maybe Redwood should look into providing a solution for this.

  2. Auth0 has a client that allows you to download and cache the public key: https://github.com/redwoodjs/redwood/blob/4f219b0cc0a63b9116946d18500e441c452a7a72/packages/api/src/auth/verifyAuth0Token.ts#L37-L40 - but you could add it to your repo and read it from disk.

  3. So the reason why that exists is because both RedwoodProvider and Router have a useAuth prop, but default to window.__REDWOOD__USE_AUTH. This allow us to have a zero config setup.

Again, I would just like to thank you for this, it’s discussions like these that are making Redwood amazing. Should we create an issue on GitHub and carve out a way forward for making this happen?

1 Like

@rob and myself were just talking and we’re wondering if we could replace those env vars during the build step - we’re trying to figure out the security implications, but this might be a much easier option.

Hi @peterp, thanks for the indepth reply.

As it happens, I just figured this out today and the code is a lot cleaner. I started this auth work before firebase and magic link were PRd, so glad that I made decisions along the same path. I’ll post the code as a reply shortly.

That’s exactly what I’m doing right now. I have a file in api/src/config/keys.js that looks like

export const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY || `REPLACE_ME_JWT_SECRET_KEY`
export const JWT_PUBLIC_KEY = process.env.JWT_PUBLIC_KEY || `REPLACE_ME_JWT_PUBLIC_KEY`

The build script simply replaces the JWT secret and public keys, which means that we could potentially use AWS Secrets Manager, and pull the values at build time. No keys in the repo that way :), and no complicated logic at run time.

Thanks @thedavid too, normally I would agree with you and Chris 110% about “reinventing the wheel”. However, we’re anticipating (read: hoping) there will be lot of users, but most of them will probably be using the free tier which might make the likes of Auth0 prohibitively expensive based on the plans they have. (e.g. If we have 8000 users, 6000 free - we start to lose out - and we really want there to be a free tier)

The decision to not use an external provider is largely therefore a non-technical one - but we also wanted to support “access key” based auth - think SSH keys or Github apitokens that can be revoked. This is covered in the code in my next post.

Show and tell!
So managed to make the code a lot cleaner today, and also implemented “access key” style auth alongside jwt. This is so that we can support the web client (redwood) and a cli. We needed a way to revoke this access key, and had no need for a transparent token, which we generate in one of the services and save to DB.

Only a couple of changes:

node_modules/@redwoodjs/api/dist/auth/authHeaders.js
Modify the default case statement to simply return an unrecognised auth provider’s token, instead of throwing an error. We can just as easily use “custom” as the key here, but I wanted both JWT and an opaque token.

.
.
.
    case 'auth0':
      {
        decoded = await (0, _verifyAuth0Token.verifyAuth0Token)(token);
        break;
      }

    default:
      decoded = {
        type,
        token
      }
      break;
.
.
.

src/lib/auth.js
Moved the logic for validating the token into getCurrentUser. Note the case statement to check the type of token

.
.
`// This function gets called on every auth required call
// to populate context.currentUser
// NOTE: I've patched package to return the undecoded token as TokenHeader

// TokenHeader = {
// type: 'cli' | 'jwt',
// token: 'xxxyyyzzz'
// }

export const getCurrentUser = async (tokenHeader) => {
  let user
  let decodedJwt

  const { token, type } = tokenHeader

  switch (type) {
    case 'jwt':
      decodedJwt = jwt.verify(token, JWT_PUBLIC_KEY)
      user = await findUser({
        email: decodedJwt.email,
      })
      break

    case 'cli':
      user = await CliToken.userFromToken({
        token,
      })
      break

    default:
      throw new AuthenticationError()
  }

  return user
}
.
.

Et viola!


More than happy to jump in an a conversation on Github if you’d like Peter - very happy to be contributing back. I can also clean this up, and create a public repo if it’s easier to follow the code that way.