Skip to content

Instantly share code, notes, and snippets.

@defrex
Last active June 27, 2020 22:39
Show Gist options
  • Save defrex/fbc01bb3ecaedb40091a211f28852efd to your computer and use it in GitHub Desktop.
Save defrex/fbc01bb3ecaedb40091a211f28852efd to your computer and use it in GitHub Desktop.
Nhost Auth & Apollo integrated with Next.js
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')
}
}
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
}
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)
}
}
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>
)
})
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)
}
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