From 452304b4714e337c61ebf8d0fb3d1d9a20b5a82a Mon Sep 17 00:00:00 2001 From: SeDemal Date: Tue, 7 May 2024 00:06:07 +0200 Subject: [PATCH] Add logout callback URL and session expiration environment variables (#2023) Co-authored-by: @Meierschlumpf --- src/components/layout/header/AvatarMenu.tsx | 11 ++++-- src/env.js | 13 +++++- src/hooks/custom-session-provider.tsx | 44 +++++++++++++++++++++ src/pages/_app.tsx | 14 +++++-- src/pages/auth/login.tsx | 1 + src/server/auth.ts | 5 ++- src/tools/client/parseDuration.ts | 29 ++++++++++++++ 7 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/hooks/custom-session-provider.tsx diff --git a/src/components/layout/header/AvatarMenu.tsx b/src/components/layout/header/AvatarMenu.tsx index 1ddcfd67f..485c9bb35 100644 --- a/src/components/layout/header/AvatarMenu.tsx +++ b/src/components/layout/header/AvatarMenu.tsx @@ -14,6 +14,7 @@ import { signOut, useSession } from 'next-auth/react'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; import { forwardRef } from 'react'; +import { useLogoutUrl } from '~/hooks/custom-session-provider'; import { useColorScheme } from '~/hooks/use-colorscheme'; import { useBoardLink } from '../Templates/BoardLayout'; @@ -26,6 +27,8 @@ export const AvatarMenu = () => { const Icon = colorScheme === 'dark' ? IconSun : IconMoonStars; const defaultBoardHref = useBoardLink('/board'); + const logoutUrl = useLogoutUrl(); + return ( @@ -64,11 +67,13 @@ export const AvatarMenu = () => { } color="red" - onClick={() => + onClick={() => { signOut({ redirect: false, - }).then(() => window.location.reload()) - } + }).then(() => + logoutUrl ? window.location.assign(logoutUrl) : window.location.reload() + ); + }} > {t('actions.avatar.logout', { username: sessionData.user.name, diff --git a/src/env.js b/src/env.js index 9e7aeb50e..e0080ca9d 100644 --- a/src/env.js +++ b/src/env.js @@ -16,7 +16,7 @@ const numberSchema = z .string() .regex(/\d*/) .transform((value) => (value === undefined ? undefined : Number(value))) - .optional() + .optional(); const portSchema = z .string() @@ -49,6 +49,12 @@ const env = createEnv({ DEMO_MODE: z.string().optional(), HOSTNAME: z.string().optional(), + //regex allows number with extra letter as time multiplier, applied with secondsFromTimeString + AUTH_SESSION_EXPIRY_TIME: z + .string() + .regex(/^\d+[smhd]?$/) + .optional(), + // Authentication AUTH_PROVIDER: z .string() @@ -96,7 +102,7 @@ const env = createEnv({ AUTH_OIDC_OWNER_GROUP: z.string().default('admin'), AUTH_OIDC_AUTO_LOGIN: zodParsedBoolean(), AUTH_OIDC_SCOPE_OVERWRITE: z.string().default('openid email profile groups'), - AUTH_OIDC_TIMEOUT: numberSchema.default(3500) + AUTH_OIDC_TIMEOUT: numberSchema.default('3500'), } : {}), }, @@ -118,6 +124,7 @@ const env = createEnv({ .optional() .default('light'), NEXT_PUBLIC_DOCKER_HOST: z.string().optional(), + AUTH_LOGOUT_REDIRECT_URL: z.string().optional(), }, /** * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g. @@ -157,6 +164,8 @@ const env = createEnv({ AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN, AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE, AUTH_OIDC_TIMEOUT: process.env.AUTH_OIDC_TIMEOUT, + AUTH_LOGOUT_REDIRECT_URL: process.env.AUTH_LOGOUT_REDIRECT_URL, + AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME, DEMO_MODE: process.env.DEMO_MODE, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, diff --git a/src/hooks/custom-session-provider.tsx b/src/hooks/custom-session-provider.tsx new file mode 100644 index 000000000..0bd1cc5b5 --- /dev/null +++ b/src/hooks/custom-session-provider.tsx @@ -0,0 +1,44 @@ +import dayjs from 'dayjs'; +import { Session } from 'next-auth'; +import { SessionProvider, signIn } from 'next-auth/react'; +import { createContext, useContext, useEffect } from 'react'; + +interface CustomSessionProviderProps { + session: Session; + children: React.ReactNode; + logoutUrl?: string; +} + +export const CustomSessionProvider = ({ + session, + children, + logoutUrl, +}: CustomSessionProviderProps) => { + //Automatically redirect to the login page after a session expires or after 24 days + useEffect(() => { + if (!session) return () => {}; + //setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days) + const timeout = setTimeout(signIn, Math.min(dayjs(session.expires).diff(), 2147483647)); + return () => clearTimeout(timeout); + }, [session]); + + return ( + + {children} + + ); +}; + +interface SessionContextProps { + logoutUrl?: string; +} + +const SessionContext = createContext(null); + +export function useLogoutUrl() { + const context = useContext(SessionContext); + if (!context) { + throw new Error('You cannot use logoutUrl outside of session context.'); + } + return context.logoutUrl; +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 1795ac8e1..d3296d752 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -10,7 +10,7 @@ import utc from 'dayjs/plugin/utc'; import 'flag-icons/css/flag-icons.min.css'; import { GetServerSidePropsContext } from 'next'; import { Session } from 'next-auth'; -import { getSession, SessionProvider } from 'next-auth/react'; +import { getSession } from 'next-auth/react'; import { appWithTranslation } from 'next-i18next'; import { AppProps } from 'next/app'; import Script from 'next/script'; @@ -19,12 +19,16 @@ import 'video.js/dist/video-js.css'; import { CommonHead } from '~/components/layout/Meta/CommonHead'; import { ConfigProvider } from '~/config/provider'; import { env } from '~/env.js'; +import { CustomSessionProvider } from '~/hooks/custom-session-provider'; import { ColorSchemeProvider } from '~/hooks/use-colorscheme'; import { modals } from '~/modals'; import { usePackageAttributesStore } from '~/tools/client/zustands/usePackageAttributesStore'; import { ColorTheme } from '~/tools/color'; import { getLanguageByCode } from '~/tools/language'; -import { getServiceSidePackageAttributes, ServerSidePackageAttributesType } from '~/tools/server/getPackageVersion'; +import { + ServerSidePackageAttributesType, + getServiceSidePackageAttributes, +} from '~/tools/server/getPackageVersion'; import { theme } from '~/tools/server/theme/theme'; import { ConfigType } from '~/types/config'; import { api } from '~/utils/api'; @@ -44,6 +48,7 @@ function App( environmentColorScheme: MantineColorScheme; packageAttributes: ServerSidePackageAttributesType; editModeEnabled: boolean; + logoutUrl?: string; analyticsEnabled: boolean; config?: ConfigType; primaryColor?: MantineTheme['primaryColor']; @@ -111,7 +116,7 @@ function App( strategy="lazyOnload" /> )} - + {(colorScheme) => ( @@ -151,7 +156,7 @@ function App( )} - + ); } @@ -177,6 +182,7 @@ App.getInitialProps = async ({ ctx }: { ctx: GetServerSidePropsContext }) => { pageProps: { ...getActiveColorScheme(session, ctx), packageAttributes: getServiceSidePackageAttributes(), + logoutUrl: env.AUTH_LOGOUT_REDIRECT_URL, analyticsEnabled, session, locale: ctx.locale ?? 'en', diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index 19b8442f2..8bc5c0d9f 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -49,6 +49,7 @@ export default function LoginPage({ validateInputOnChange: true, validateInputOnBlur: true, validate: i18nZodResolver(signInSchemaWithProvider), + initialValues: { name: '', password: '', provider: '' }, }); const handleSubmit = (values: z.infer) => { diff --git a/src/server/auth.ts b/src/server/auth.ts index 380940378..5f56c5c66 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -4,6 +4,8 @@ import { type GetServerSidePropsContext, type NextApiRequest, type NextApiRespon import { type NextAuthOptions, getServerSession } from 'next-auth'; import { Adapter } from 'next-auth/adapters'; import { decode, encode } from 'next-auth/jwt'; +import { env } from '~/env'; +import { secondsFromTimeString } from '~/tools/client/parseDuration'; import { adapter, getProviders, onCreateUser } from '~/utils/auth'; import { createRedirectUri } from '~/utils/auth/oidc'; import EmptyNextAuthProvider from '~/utils/empty-provider'; @@ -13,7 +15,8 @@ import { colorSchemeParser } from '~/validations/user'; import { db } from './db'; import { users } from './db/schema'; -const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days +const sessionMaxAgeInSeconds = + secondsFromTimeString(env.AUTH_SESSION_EXPIRY_TIME) ?? 30 * 24 * 60 * 60; // 30 days /** * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. diff --git a/src/tools/client/parseDuration.ts b/src/tools/client/parseDuration.ts index d59de519a..4c0171e89 100644 --- a/src/tools/client/parseDuration.ts +++ b/src/tools/client/parseDuration.ts @@ -18,3 +18,32 @@ export const parseDuration = (time: number, t: TFunction): string => { return eta; }; + +export const secondsFromTimeString = (time: string | undefined): number | undefined => { + if (!time) { + return undefined; + } + const lastChar = time[time.length - 1]; + if (!isNaN(+lastChar)) { + return Number(time); + } + + const numTime = +time.substring(0, time.length - 1); + switch (lastChar.toLowerCase()) { + case 's': { + return numTime; + } + case 'm': { + return numTime * 60; + } + case 'h': { + return numTime * 60 * 60; + } + case 'd': { + return numTime * 24 * 60 * 60; + } + default: { + return undefined; + } + } +};