Last active
June 27, 2020 22:39
-
-
Save defrex/fbc01bb3ecaedb40091a211f28852efd to your computer and use it in GitHub Desktop.
Nhost Auth & Apollo integrated with Next.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import fetch from 'cross-fetch' | |
import jwtDecode from 'jwt-decode' | |
import moment from 'moment' | |
import { NextPageContext } from 'next' | |
import { destroyCookie, parseCookies, setCookie } from 'nookies' | |
export class AuthClient { | |
baseUrl = 'https://backend-[whatev].nhost.app' | |
stateChangeCallbacks: (() => void)[] = [] | |
context?: NextPageContext | |
constructor(context?: NextPageContext) { | |
this.context = context | |
} | |
isAuthenticated(): boolean { | |
return this.getToken() !== undefined | |
} | |
getToken(): string | undefined { | |
const cookies = parseCookies(this.context) | |
return cookies.token | |
} | |
onAuthStateChanged(callback: () => void): () => void { | |
this.stateChangeCallbacks.push(callback) | |
return () => { | |
const index = this.stateChangeCallbacks.indexOf(callback) | |
if (index !== -1) { | |
this.stateChangeCallbacks.splice(index, 1) | |
} | |
} | |
} | |
authStateChanged() { | |
for (const authChangeCallback of this.stateChangeCallbacks) { | |
authChangeCallback() | |
} | |
} | |
async login(username: string, password: string): Promise<void> { | |
try { | |
const response = await fetch(`${this.baseUrl}/auth/local/login`, { | |
method: 'POST', | |
body: JSON.stringify({ | |
username, | |
password, | |
}), | |
headers: { | |
'content-type': 'application/json', | |
accept: 'application/json', | |
}, | |
}) | |
const data = await response.json() | |
this.setTokens(data) | |
} catch (e) { | |
throw e.response | |
} | |
} | |
async logout(allSessions?: boolean): Promise<void> { | |
const { refreshToken } = parseCookies(this.context) | |
destroyCookie(this.context, 'token') | |
destroyCookie(this.context, 'refreshToken') | |
await fetch(`${this.baseUrl}/auth/logout${allSessions ? '-all' : ''}`, { | |
method: 'POST', | |
body: JSON.stringify({ | |
refresh_token: refreshToken, | |
}), | |
headers: { | |
'content-type': 'application/json', | |
accept: 'application/json', | |
}, | |
}) | |
this.authStateChanged() | |
} | |
async refreshToken(): Promise<void> { | |
const cookies = parseCookies(this.context) | |
const { token, refreshToken } = cookies | |
if (typeof refreshToken !== 'string' || refreshToken === '') { | |
return | |
} | |
if (token) { | |
const { exp: expiry } = jwtDecode(token) | |
if (moment.unix(expiry).isAfter()) { | |
return | |
} | |
} | |
try { | |
const response = await fetch(`${this.baseUrl}/auth/refresh-token`, { | |
method: 'post', | |
body: JSON.stringify({ | |
refresh_token: refreshToken, | |
}), | |
headers: { | |
'content-type': 'application/json', | |
accept: 'application/json', | |
}, | |
}) | |
const data = await response.json() | |
this.setTokens(data) | |
} catch (e) { | |
throw e | |
} | |
} | |
setTokens({ | |
jwt_token: token, | |
refresh_token: refreshToken, | |
}: { | |
jwt_token: string | |
refresh_token: string | |
}) { | |
const origToken = this.getToken() | |
if (!token) { | |
throw new Error('invalid token in setTokens') | |
} | |
setCookie(this.context, 'token', token, { | |
sameSite: true, | |
maxAge: 15 * 60, // 15 min | |
}) | |
setCookie(this.context, 'refreshToken', refreshToken, { | |
sameSite: true, | |
maxAge: 365 * 24 * 60 * 60, // 1 year | |
}) | |
if (!origToken) { | |
this.authStateChanged() | |
} | |
} | |
toJSON() { | |
return null | |
} | |
async signup( | |
email: string, | |
username: string, | |
password: string, | |
metadata?: any, | |
): Promise<void> { | |
console.warn('TODO: implement signup') | |
} | |
async activateAccount(secretToken: string): Promise<void> { | |
console.warn('TODO: implement activate account') | |
} | |
async updatePassword( | |
secretToken: string, | |
newPassword: string, | |
): Promise<void> { | |
console.warn('TODO: update password') | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
ApolloClient, | |
from, | |
HttpLink, | |
InMemoryCache, | |
NormalizedCacheObject, | |
} from '@apollo/client' | |
import { setContext } from '@apollo/link-context' | |
import fetch from 'cross-fetch' | |
import { AuthClient } from './AuthClient' | |
let apolloClient: ApolloClient<NormalizedCacheObject> | |
export function getApolloClient( | |
auth?: AuthClient, | |
data?: NormalizedCacheObject, | |
) { | |
const isBrowser = typeof window !== 'undefined' | |
if (isBrowser && apolloClient) { | |
return apolloClient | |
} | |
const httpLink = new HttpLink({ | |
uri: 'https://hasura-[whatev].nhost.app/v1/graphql', | |
fetch, | |
}) | |
const authLink = setContext((a, { headers }) => { | |
const jwt = auth?.getToken() | |
return { | |
headers: { | |
...headers, | |
...(jwt ? { authorization: `Bearer ${jwt}` } : {}), | |
}, | |
} | |
}) | |
const cache = new InMemoryCache() | |
if (data) { | |
cache.restore(data) | |
} | |
apolloClient = new ApolloClient({ | |
connectToDevTools: isBrowser, | |
ssrMode: !isBrowser, | |
cache, | |
link: from([authLink, httpLink]), | |
}) | |
return apolloClient | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AuthClient } from './AuthClient' | |
import { NextPageContext } from 'next' | |
let globalAuth: AuthClient | |
export function getAuthClient(context?: NextPageContext) { | |
const isBrowser = typeof window !== 'undefined' | |
if (isBrowser) { | |
if (!globalAuth) { | |
globalAuth = new AuthClient() | |
} | |
return globalAuth | |
} else { | |
if (!context) { | |
throw new Error('Missing request context in server Auth initialization') | |
} | |
return new AuthClient(context) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { gql, useQuery } from '@apollo/client' | |
import { Page } from '../components/Page' | |
import { withData } from '../lib/withData' | |
export default withData()(function HomePage() { | |
const { isAuthenticated } = useAuth() | |
const { data, loading } = useQuery(gql` | |
{ | |
jobs { | |
id | |
} | |
} | |
`) | |
console.log('query', { loading, data }) | |
return ( | |
<div> | |
{isAuthenticated ? 'You are logged in!' : 'You are not logged in'} | |
<pre>{JSON.stringify(data, null, 2)}</pre> | |
</div> | |
) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
createContext, | |
ReactNode, | |
useContext, | |
useEffect, | |
useState, | |
} from 'react' | |
import { AuthClient } from './AuthClient' | |
interface AuthContextValue { | |
isAuthenticated: boolean | |
authClient?: AuthClient | |
} | |
function getAuthContextValue(authClient?: AuthClient): AuthContextValue { | |
return { | |
isAuthenticated: authClient?.isAuthenticated() || false, | |
authClient, | |
} | |
} | |
const AuthContext = createContext<AuthContextValue>(getAuthContextValue()) | |
interface AuthProviderProps { | |
client?: AuthClient | |
children: ReactNode | |
} | |
export function AuthProvider({ client, children }: AuthProviderProps) { | |
const [contextValue, setContextValue] = useState(getAuthContextValue(client)) | |
useEffect( | |
() => | |
client?.onAuthStateChanged(() => { | |
setContextValue(getAuthContextValue(client)) | |
}), | |
[client, setContextValue], | |
) | |
return <AuthContext.Provider value={contextValue} children={children} /> | |
} | |
export function useAuth() { | |
return useContext(AuthContext) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
ApolloClient, | |
ApolloProvider, | |
NormalizedCacheObject, | |
} from '@apollo/client' | |
import { NextPage, NextPageContext } from 'next' | |
import App, { AppContext as NextAppContext } from 'next/app' | |
import Head from 'next/head' | |
import React from 'react' | |
import { AuthClient } from './AuthClient' | |
import { getApolloClient } from './getApolloClient' | |
import { getAuthClient } from './getAuthClient' | |
import { AuthProvider } from './useAuth' | |
type DataContext = NextPageContext & | |
NextAppContext & { | |
apolloClient?: ApolloClient<NormalizedCacheObject> | |
authClient?: AuthClient | |
apolloState?: NormalizedCacheObject | |
ctx: NextPageContext & { | |
apolloClient?: ApolloClient<NormalizedCacheObject> | |
authClient?: AuthClient | |
apolloState?: NormalizedCacheObject | |
} | |
} | |
export const initContext = async (ctx: DataContext) => { | |
const inAppContext = Boolean(ctx.ctx) | |
if (process.env.NODE_ENV === 'development') { | |
if (inAppContext) { | |
console.warn( | |
'Warning: You have opted-out of Automatic Static Optimization due to `withData` in `pages/_app`.\n' + | |
'Read more: https://err.sh/next.js/opt-out-auto-static-optimization\n', | |
) | |
} | |
} | |
const authClient = | |
ctx.authClient || getAuthClient(inAppContext ? ctx.ctx : ctx) | |
const apolloClient = | |
ctx.apolloClient || getApolloClient(authClient, ctx.apolloState) | |
// We send the Apollo Client as a prop to the component to avoid calling initApollo() twice in the server. | |
// Otherwise, the component would have to call initApollo() again but this | |
// time without the context. Once that happens, the following code will make sure we send | |
// the prop as `null` to the browser. | |
// @ts-ignore - I know, but extending ApolloClient for toJSON is a PITA | |
apolloClient.toJSON = () => null | |
// Add apolloClient to NextPageContext & NextAppContext. | |
// This allows us to consume the apolloClient inside our | |
// custom `getInitialProps({ apolloClient })`. | |
ctx.apolloClient = apolloClient | |
ctx.authClient = authClient | |
if (inAppContext) { | |
ctx.ctx.apolloClient = apolloClient | |
ctx.ctx.authClient = authClient | |
} | |
return ctx | |
} | |
export const withData = ({ ssr = true } = {}) => (PageComponent: NextPage) => { | |
const WithData = ({ | |
apolloClient, | |
apolloState, | |
authClient, | |
...pageProps | |
}: DataContext) => { | |
if (!authClient && typeof window !== 'undefined') { | |
authClient = getAuthClient() | |
} | |
if (!apolloClient) { | |
apolloClient = getApolloClient(authClient) | |
} | |
return ( | |
<AuthProvider client={authClient}> | |
<ApolloProvider client={apolloClient}> | |
<PageComponent {...(pageProps as any)} /> | |
</ApolloProvider> | |
</AuthProvider> | |
) | |
} | |
// Set the correct displayName in development | |
if (process.env.NODE_ENV !== 'production') { | |
const displayName = | |
PageComponent.displayName || PageComponent.name || 'Component' | |
WithData.displayName = `withData(${displayName})` | |
} | |
if (ssr || PageComponent.getInitialProps) { | |
WithData.getInitialProps = async (ctx: DataContext) => { | |
const inAppContext = Boolean(ctx.ctx) | |
const { apolloClient, authClient } = await initContext(ctx) | |
let pageProps = {} | |
if (PageComponent.getInitialProps) { | |
pageProps = await PageComponent.getInitialProps(ctx) | |
} else if (inAppContext) { | |
pageProps = await App.getInitialProps(ctx) | |
} | |
// Only on the server: | |
if (typeof window === 'undefined') { | |
const { AppTree } = ctx | |
// When redirecting, the response is finished. | |
// No point in continuing to render | |
if (ctx.res && ctx.res.finished) { | |
return pageProps | |
} | |
// Only if dataFromTree is enabled | |
if (ssr && AppTree) { | |
try { | |
// Import `@apollo/react-ssr` dynamically. | |
// We don't want to have this in our client bundle. | |
const { getDataFromTree } = await import('@apollo/react-ssr') | |
// Since AppComponents and PageComponents have different context types | |
// we need to modify their props a little. | |
let props | |
if (inAppContext) { | |
props = { ...pageProps, apolloClient, authClient } | |
} else { | |
props = { pageProps: { ...pageProps, apolloClient, authClient } } | |
} | |
// Take the Next.js AppTree, determine which queries are needed to render, | |
// and fetch them. This method can be pretty slow since it renders | |
// your entire AppTree once for every query. Check out apollo fragments | |
// if you want to reduce the number of rerenders. | |
// https://www.apollographql.com/docs/react/data/fragments/ | |
await getDataFromTree(<AppTree {...(props as any)} />) | |
} catch (error) { | |
// Prevent Apollo Client GraphQL errors from crashing SSR. | |
// Handle them in components via the data.error prop: | |
// https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error | |
console.error('Error while running `getDataFromTree`', error) | |
} | |
// getDataFromTree does not call componentWillUnmount | |
// head side effect therefore need to be cleared manually | |
Head.rewind() | |
} | |
} | |
return { | |
...pageProps, | |
apolloState: apolloClient!.cache.extract(), | |
// Provide the clients for ssr. As soon as this payload | |
// gets JSON.stringified they will remove themselves via toJSON. | |
apolloClient: ctx.apolloClient, | |
authClient: ctx.authClient, | |
} | |
} | |
} | |
return WithData | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment