Skip to content

Commit

Permalink
Fixing broken user session (#1007)
Browse files Browse the repository at this point in the history
* added fetch session in all axios calls

* switch off sentry in development mode

* reduced accessTokenExpires with 10secs and replaced getSession with getServerSession as per next-auth recommendation

* fixed lint errors

* restored original value of refetch

* formatting

* restored QueryClient defaultOptions to have queryFn

* updated getSession with getServerSession for the remaining getServerSideProps

Co-authored-by: quantum-grit <quantum-grit@gmail.com>
  • Loading branch information
quantum-grit and quantum-grit authored Aug 31, 2022
1 parent c84abb6 commit c5572da
Show file tree
Hide file tree
Showing 12 changed files with 134 additions and 84 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/check-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ jobs:

run-playwright:
name: Run Playwright
uses: ./.github/workflows/playwright.yml
uses: ./.github/workflows/playwright.yml
1 change: 1 addition & 0 deletions sentry.client.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN
Sentry.init({
dsn: SENTRY_DSN || 'https://e25f62860a394934878c2e21306a6b66@o540074.ingest.sentry.io/5657969',
blacklistUrls: [/localhost/, /127.0.0.1/],
enabled: process.env.NODE_ENV !== 'development',
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
Expand Down
1 change: 1 addition & 0 deletions sentry.server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN

Sentry.init({
dsn: SENTRY_DSN || 'https://e25f62860a394934878c2e21306a6b66@o540074.ingest.sentry.io/5657969',
enabled: process.env.NODE_ENV !== 'development',
// Note: if you want to override the automatic release value, do not set a
// `release` value here - use the environment variable `SENTRY_RELEASE`, so
// that it will also get attached to your source maps
Expand Down
30 changes: 30 additions & 0 deletions src/gql/next-auth.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import NextAuth from 'next-auth'

/**
* Declaring the Session and User type as per docs here:
* https://next-auth.js.org/getting-started/typescript#main-module
**/

declare module 'next-auth' {
// export interface Profile {}
// export interface Account {}

/**
* Session object available everywhere
*/
export interface Session {
accessToken: string
refreshToken: string
user: ServerUser | null
}

/**
* Login and SignUp response
*/
export interface User {
expires: number
accessToken: string
refreshToken: string
picture: string
}
}
25 changes: 16 additions & 9 deletions src/middleware/auth/securedProps.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Session } from 'next-auth'
import { getSession } from 'next-auth/react'
import { unstable_getServerSession, Session } from 'next-auth'
import { authOptions } from '../../pages/api/auth/[...nextauth]'
import { dehydrate, QueryClient } from 'react-query'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
Expand All @@ -10,11 +10,17 @@ import { authQueryFnFactory } from 'service/restRequests'
export const securedProps: (
ctx: GetServerSidePropsContext,
returnUrl?: string,
) => Promise<GetServerSidePropsResult<Session>> = async (ctx, returnUrl?: string) => {
const session = await getSession(ctx)
) => Promise<GetServerSidePropsResult<{ session: Session }>> = async (ctx, returnUrl?: string) => {
//For getting session on server side the docs recommend using unstable_getServerSession as per
//here: https://next-auth.js.org/getting-started/introduction#server-side
//the docs say there is noting unstable, it just may change in next versions
const session = await unstable_getServerSession(ctx.req, ctx.res, authOptions)

let url = returnUrl ?? ctx.req.url ?? ''
if (url.startsWith('/_next') || url.startsWith('/_error')) url = '/'

if (!session) {
console.log('no server side session, login required')
return {
redirect: {
destination: `${routes.login}?callbackUrl=${encodeURIComponent(url)}`,
Expand All @@ -24,14 +30,14 @@ export const securedProps: (
}

return {
props: session,
props: { session },
}
}

export const securedPropsWithTranslation: (
namespaces?: string[],
returnUrl?: string,
) => GetServerSideProps<Session> =
) => GetServerSideProps<{ session: Session }> =
(namespaces = ['common', 'auth', 'validation'], returnUrl) =>
async (ctx) => {
const response = await securedProps(ctx, returnUrl)
Expand All @@ -49,14 +55,15 @@ export const securedPropsWithTranslation: (
export const securedAdminProps: (
namespaces?: string[],
resolveEndpoint?: (ctx: GetServerSidePropsContext) => string,
) => GetServerSideProps<Session> = (namespaces, resolveEndpoint) => async (ctx) => {
) => GetServerSideProps<{ session: Session }> = (namespaces, resolveEndpoint) => async (ctx) => {
const result = securedPropsWithTranslation(namespaces)
const response = await result(ctx)
if ('props' in response) {
const client = new QueryClient()
const { session } = await response.props

if (resolveEndpoint) {
const { accessToken } = await response.props
await client.prefetchQuery(resolveEndpoint(ctx), authQueryFnFactory(accessToken))
await client.prefetchQuery(resolveEndpoint(ctx), authQueryFnFactory(session.accessToken))
}
return {
props: {
Expand Down
45 changes: 15 additions & 30 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@ import { useRouter } from 'next/router'
import { EmotionCache } from '@emotion/cache'
import { CacheProvider } from '@emotion/react'
import { SessionProvider } from 'next-auth/react'
import React, { useEffect, useCallback } from 'react'
import React, { useEffect, useState } from 'react'
import { appWithTranslation, useTranslation } from 'next-i18next'
import { CssBaseline, ThemeProvider, Theme } from '@mui/material'
import { Hydrate, QueryClient, QueryClientProvider } from 'react-query'

import theme from 'common/theme'
import useGTM from 'common/util/useGTM'
import { routes } from 'common/routes'
import { queryFn } from 'service/restRequests'
import { isAxiosError } from 'service/apiErrors'
import createEmotionCache from 'common/createEmotionCache'

import 'styles/global.scss'
import { queryFn } from 'service/restRequests'

// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache()
Expand All @@ -37,31 +35,6 @@ function CustomApp({
const router = useRouter()
const { i18n } = useTranslation()
const { initialize, trackEvent } = useGTM()
const onError = useCallback((error: unknown) => {
if (error && isAxiosError(error)) {
// Redirect to login
if (error.response?.status === 401) {
router.push({
pathname: routes.login,
query: { callbackUrl: router.asPath },
})
}
}
}, [])

const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
queryFn,
onError,
staleTime: 25000,
},
mutations: { onError },
},
}),
)

useEffect(() => {
// Init GTM
Expand Down Expand Up @@ -91,6 +64,18 @@ function CustomApp({
return () => router.events.off('routeChangeComplete', onRouteChange)
}, [i18n.language])

const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
queryFn,
staleTime: 25000,
},
},
}),
)

return (
<CacheProvider value={emotionCache}>
<Head>
Expand All @@ -100,7 +85,7 @@ function CustomApp({
<ThemeProvider theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<SessionProvider session={session} refetchInterval={60}>
<SessionProvider session={session} refetchInterval={60} refetchOnWindowFocus={true}>
<QueryClientProvider client={queryClient}>
<Hydrate state={dehydratedState}>
<Component {...pageProps} />
Expand Down
43 changes: 13 additions & 30 deletions src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,28 +26,6 @@ declare module 'next-auth/jwt' {
expires?: number
}
}
declare module 'next-auth' {
// export interface Profile {}
// export interface Account {}

/**
* Session object available everywhere
*/
export interface Session {
accessToken: string
user: ServerUser | null
}

/**
* Login and SignUp response
*/
export interface User {
expires: number
accessToken: string
refreshToken: string
picture: string
}
}

const onCreate: EventCallbacks['createUser'] = async ({ user }) => {
const { email } = user
Expand All @@ -58,7 +36,7 @@ const onCreate: EventCallbacks['createUser'] = async ({ user }) => {
console.log(`❌ Unable to send welcome email to user (${email})`)
}
}
export const options: NextAuthOptions = {
export const authOptions: NextAuthOptions = {
debug: process.env.APP_ENV !== 'production',
pages: {
signIn: '/login',
Expand All @@ -68,11 +46,11 @@ export const options: NextAuthOptions = {
},
session: {
strategy: 'jwt',
maxAge: 60 * 60 * 10,
updateAge: 60 * 60 * 24,
maxAge: 60 * 60 * 1, //1 hours
updateAge: 60 * 10, // 10 minutes
},
jwt: {
maxAge: 300,
maxAge: 60 * 60 * 1, // 1 hour
},
providers: [
CredentialsProvider({
Expand Down Expand Up @@ -119,6 +97,9 @@ export const options: NextAuthOptions = {
async session({ session, token }): Promise<Session> {
session.user = jwtDecode<ServerUser>(token.accessToken)
session.accessToken = token.accessToken
session.refreshToken = token.refreshToken

console.log('Returning session from api/auth')

return session
},
Expand All @@ -131,7 +112,7 @@ export const options: NextAuthOptions = {
return {
accessToken: user.accessToken,
// This is called the first time only here expires always exists and that calculates the timestamp that the token would actually expire in
accessTokenExpires: Date.now() + Number(user.expires) * 1000,
accessTokenExpires: Date.now() + Number(user.expires) * 1000 - 10000,
refreshToken: user.refreshToken,
user: jwtDecode<ServerUser>(user.accessToken),
}
Expand All @@ -144,8 +125,10 @@ export const options: NextAuthOptions = {
)
return {
accessToken: keycloakToken.accessToken,
// This is called the first time only here expires always exists and that calculates the timestamp that the token would actually expire in
accessTokenExpires: Date.now() + Number(keycloakToken.expires) * 1000,
// This is called the first time only
// here expires value always exists and it contains the time interval that the token would actually expire in milliseconds
// we decrease it with 10sec for the refresh to run before the actual expiration
accessTokenExpires: Date.now() + Number(keycloakToken.expires) * 1000 - 10000,
refreshToken: keycloakToken.refreshToken,
user: jwtDecode<ServerUser>(keycloakToken.accessToken),
}
Expand All @@ -162,4 +145,4 @@ export const options: NextAuthOptions = {
},
events: { createUser: onCreate },
}
export default NextAuth(options)
export default NextAuth(authOptions)
14 changes: 11 additions & 3 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { Session } from 'next-auth'
import { Session, unstable_getServerSession } from 'next-auth'
import { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/react'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'

import IndexPage from 'components/index/IndexPage'
import { authOptions } from './api/auth/[...nextauth]'
import { QueryClient } from 'react-query'
import { queryFn } from 'service/restRequests'

export const getServerSideProps: GetServerSideProps<{
session: Session | null
}> = async (ctx) => {
const session = await getSession(ctx)
const client = new QueryClient()
await client.prefetchQuery('/campaign/list', queryFn)

//For getting session on server side the docs recommend using unstable_getServerSession as per
//here: https://next-auth.js.org/getting-started/introduction#server-side
//the docs say there is noting unstable, it just may change in next versions
const session = await unstable_getServerSession(ctx.req, ctx.res, authOptions)
return {
props: {
...(await serverSideTranslations(ctx.locale ?? 'bg', ['common', 'index', 'campaigns'])),
Expand Down
9 changes: 7 additions & 2 deletions src/pages/login.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/react'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'

import { routes } from 'common/routes'
import LoginPage from 'components/auth/login/LoginPage'
import { unstable_getServerSession } from 'next-auth'
import { authOptions } from './api/auth/[...nextauth]'

export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getSession(ctx)
//For getting session on server side the docs recommend using unstable_getServerSession as per
//here: https://next-auth.js.org/getting-started/introduction#server-side
//the docs say there is noting unstable, it just may change in next versions
const session = await unstable_getServerSession(ctx.req, ctx.res, authOptions)

if (session) {
return {
redirect: {
Expand Down
10 changes: 8 additions & 2 deletions src/pages/register.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { GetServerSideProps } from 'next'
import { getProviders, getSession } from 'next-auth/react'
import { getProviders } from 'next-auth/react'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'

import { routes } from 'common/routes'
import RegisterPage from 'components/auth/register/RegisterPage'
import { unstable_getServerSession } from 'next-auth'
import { authOptions } from './api/auth/[...nextauth]'

export type RegisterPageProps = {
providers: Awaited<ReturnType<typeof getProviders>>
}

export const getServerSideProps: GetServerSideProps<RegisterPageProps> = async (ctx) => {
const session = await getSession(ctx)
//For getting session on server side the docs recommend using unstable_getServerSession as per
//here: https://next-auth.js.org/getting-started/introduction#server-side
//the docs say there is noting unstable, it just may change in next versions
const session = await unstable_getServerSession(ctx.req, ctx.res, authOptions)

if (session) {
return {
redirect: {
Expand Down
10 changes: 6 additions & 4 deletions src/service/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function useRegister() {
}

export async function refreshAccessToken(token: string): Promise<JWT> {
console.log(token)
console.log('Refreshing token: ' + token)
try {
const response = await apiClient.post<AuthResponse>(
endpoints.auth.refresh.url,
Expand All @@ -116,10 +116,11 @@ export async function refreshAccessToken(token: string): Promise<JWT> {
return {
...authRes,
user: jwtDecode<ServerUser>(authRes.accessToken),
accessTokenExpires: Date.now() + authRes.expires * 1000,
// we decrease it with 10sec for the refresh to run before the actual expiration
accessTokenExpires: Date.now() + authRes.expires * 1000 - 10000,
}
} catch (error) {
console.log(error)
console.log("Couldn't refresh token. Error: ", error)
throw new Error('RefreshAccessTokenError')
}
}
Expand Down Expand Up @@ -147,7 +148,8 @@ export async function getAccessTokenFromProvider(
return {
...authRes,
user: jwtDecode<ServerUser>(authRes.accessToken),
accessTokenExpires: Date.now() + authRes.expires * 1000,
// we decrease it with 10sec for the refresh to run before the actual expiration
accessTokenExpires: Date.now() + authRes.expires * 1000 - 10000,
}
} catch (error) {
console.log(error)
Expand Down
Loading

0 comments on commit c5572da

Please sign in to comment.