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:
- Passing in the keys through env
If we usedprocess.env.JWT_SECRET_KEY
andprocess.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?
- Reading the JWT public key in redwood
In step 3, we usejwt.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
?
- 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!