Custom github JWT Auth with Redwood Auth

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

2 Likes

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: GitHub - swyxio/netlify-plugin-encrypted-files: Netlify Build Plugin to obscure files in git repos! (both filenames and their contents) This enables you to partially open source your site, while still being able to work as normal on your local machine and in your Netlify builds.. 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: redwood/packages/api/src/auth/verifyAuth0Token.ts at 4f219b0cc0a63b9116946d18500e441c452a7a72 · redwoodjs/redwood · GitHub - 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.

This topic has come up a few times, so want to summarise where it stands at the moment:

  • As of RW 0.12, custom provider support works pretty well (so all the patches for RW I have above are not necessary anymore). Mainly, if you use provider type custom RW will just return the token and the type to you, which you then need to handle in src/lib/auth.js
  • You still need to write the code customAuthProvider, customAuthClient and the callback functions - this is custom auth afterall!
2 Likes

Hey @danny, I don’t mean to reopen this dialog, but I was wondering if there is a more integrated way to roll this now that Redwood is quite a few versions beyond when this was last updated.

I’m looking to use LinkedIn as an auth and would like to integrate it as much as possible with RW auth features. It seems like it might function similar to what you did with GitHub and Tape. I’ve set it up with Auth0 but would like to get away from that for all the reasons you mentioned above.

Would love to hear your thoughts!

1 Like

@morganmspencer Someone just posted a very thorough walkthrough for using a Local JWT Auth Implementation that may be of interest to you.

@ajcwebdev I just saw that and it helped a ton, thanks for sending it along!

1 Like

Hey, I would say the best method still to implement custom auth is to use the “custom” auth provider. The only thing you will need to implement really are functions to retrieve the user (getCurrentUser) in src/lib/auth, and some way to sign in to linkedin/github/facebook/your own system.

While the solution linked above looks like a lot of work was put into it, and top marks for sharing it with the community, there are a few things I don’t quite agree with there that I can see such as, use of cookies, some potential security risks and not using the existing auth pattern that Redwood has built in - which means you don’t get a tonne of the new features that RW offers e.g. RBAC.

I wrote up a little bit more about implementing custom auth on this post:

1 Like

Has anyone ever tried to pen-test a Redwood app? Would be interesting to actually test some of these different methods.

Looks like we’re not able to generate custom providers anymore?

2 Likes

I believe you have to start by generating one of the default ones so it sets up the necessary auth files first. Then, you change the AuthProvider type to custom and pass your own custom client.

I also ran into this but there is no custom generator.

1 Like

Hi, @edjiang, as @morganmspencer noted, there isn’t a custom generator even though the RedwoodJS docs unfortunately do seem to indicate that there is one:

I looked at the auth generator code and it checks a directory of auth templates (auth0, supabase, etc … ie, the ones you note in your command line message) and even looking at the history of the template directory, I do not see that custom every existed.

So, perhaps the generator never could actually generate the custom setup at all – and the docs need to be updated.

You can as @morganmspencer said, setup with another provider and that will get you the updates to:

  • index.js

You need something like:

const customClient = <your client>
...

    <AuthProvider client={customClient} type="custom">
      <RedwoodProvider>
        <Routes />
      </RedwoodProvider>
    </AuthProvider>

and then the changes to graphql and then the auth.js lib.

I’ll raise an issue since the generator really should work with “custom” as while it is somewhat easy to setup the web side, the api side needs the modifications done.

2 Likes

@morganmspencer and @edjiang FYI I’ve opened an issue:

and referenced this post.

Thanks again for pointing this out!

2 Likes

Thanks for the help! Was code reading and it seems like the interface for a Client is straightforward especially due to the type annotations. But definitely was confused at that step :slight_smile:

1 Like

Got a basic prototype working. Only feedback on documentation was that it was not intuitive that you had to add an auth-provider: custom header in the HTTP request in order for Redwood’s stack to even check the access token and call my handlers. Such a requirement is unintuitive for non-Redwood callers like mobile apps (although not a huge deal).

1 Like

@edjiang Great to hear! Also, would you be up for improving the documentation based on your experience? And/or creating a related issue with your notes and suggestions for improvement would be great as well.

No pressure at all but 100% welcome:

There’s a certain amount of polish needed to write docs on GH, but what I can do to help is write a summary here and add a link in the GH

Build auth endpoint into the api project:

Write the SDL

In my case I was building phone auth stored in the local database. Design your SDL and put it in /api/src/graphql/auth.sdl.js

# Passwordless example
type Mutation {
  authChallenge(input: AuthChallengeInput!): AuthChallengeResult
  authVerify(input: AuthVerifyInput!): AuthVerifyResult # Should return a token
}

# Username/password example
type Mutation {
  authRegister(input: AuthRegisterInput!): AuthRegisterResult
  authLogin(input: AuthLoginInput!): AuthLoginResult # Should return a token
  authVerify(input: AuthVerifyInput!): AuthVerifyResult
  authForgotPassword(input: AuthForgotPasswordInput!): AuthForgotPasswordResult
}

Write the Service

In /api/src/services/auth/auth.js

export const authChallenge = async (input) => { return { success: true } }
// ...etc

Write a token validator and user resolver

In /api/src/lib/auth.js:

import { AuthenticationError } from '@redwoodjs/api'

export const getCurrentUser = async (token) => {
  // Resolve and return user record
}

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

Add the user resolver to the GraphQL endpoint

In /api/src/functions/graphql.js:

import { getCurrentUser } from 'src/lib/auth' // Add this line

export const handler = createGraphQLHandler({
  getCurrentUser, // Add this line
  schema: makeMergedSchema({
    schemas,
    services: makeServices({ services }),
  }),
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})

Authenticate via HTTP Headers!

Add Authorization: Bearer TOKEN and replace TOKEN with your token value
Add Auth-Provider: custom

Viola! You should be able to authenticate using a custom provider now.

Implement Frontend Client

I haven’t implemented this, but you’ll also need to implement the UI to hit your backend endpoints.

3 Likes