From 6dafbaae48f17ff519723fe33fc89034b10e8f1b Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 9 Aug 2024 15:59:00 +0200 Subject: [PATCH] feat: add session expiry (#951) --- .../[locale]/_client-providers/session.tsx | 15 ++++++++++- packages/auth/callbacks.ts | 9 ++++--- packages/auth/configuration.ts | 5 ++-- packages/auth/env.mjs | 25 +++++++++++++++++++ packages/auth/session.ts | 1 - packages/auth/test/callbacks.spec.ts | 9 ++++++- turbo.json | 1 + 7 files changed, 56 insertions(+), 9 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/session.tsx b/apps/nextjs/src/app/[locale]/_client-providers/session.tsx index d4e0538db..a4622de94 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/session.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/session.tsx @@ -1,14 +1,27 @@ "use client"; import type { PropsWithChildren } from "react"; +import { useEffect } from "react"; +import dayjs from "dayjs"; import type { Session } from "@homarr/auth"; -import { SessionProvider } from "@homarr/auth/client"; +import { SessionProvider, signIn } from "@homarr/auth/client"; interface AuthProviderProps { session: Session | null; } export const AuthProvider = ({ children, session }: PropsWithChildren) => { + useLoginRedirectOnSessionExpiry(session); return {children}; }; + +const useLoginRedirectOnSessionExpiry = (session: Session | null) => { + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + if (!session) return () => {}; + //setTimeout doesn't allow for a number higher than 2147483647 (2³¹-1 , or roughly 24 days) + const timeout = setTimeout(() => void signIn(), Math.min(dayjs(session.expires).diff(), 2147483647)); + return () => clearTimeout(timeout); + }, [session]); +}; diff --git a/packages/auth/callbacks.ts b/packages/auth/callbacks.ts index b082e896a..90242b836 100644 --- a/packages/auth/callbacks.ts +++ b/packages/auth/callbacks.ts @@ -7,7 +7,8 @@ import { eq, inArray } from "@homarr/db"; import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite"; import { getPermissionsWithChildren } from "@homarr/definitions"; -import { expireDateAfter, generateSessionToken, sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session"; +import { env } from "./env.mjs"; +import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session"; export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => { const dbGroupMembers = await db.query.groupMembers.findMany({ @@ -53,18 +54,18 @@ export const createSignInCallback = } const sessionToken = generateSessionToken(); - const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds); + const sessionExpires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME); await adapter.createSession({ sessionToken, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion userId: user.id!, - expires: sessionExpiry, + expires: sessionExpires, }); cookies().set(sessionTokenCookieName, sessionToken, { path: "/", - expires: sessionExpiry, + expires: sessionExpires, httpOnly: true, sameSite: "lax", secure: true, diff --git a/packages/auth/configuration.ts b/packages/auth/configuration.ts index 0ee8ab302..32b8f4f26 100644 --- a/packages/auth/configuration.ts +++ b/packages/auth/configuration.ts @@ -7,12 +7,13 @@ import { db } from "@homarr/db"; import { adapter } from "./adapter"; import { createSessionCallback, createSignInCallback } from "./callbacks"; +import { env } from "./env.mjs"; import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider"; import { EmptyNextAuthProvider } from "./providers/empty/empty-provider"; import { filterProviders } from "./providers/filter-providers"; import { OidcProvider } from "./providers/oidc/oidc-provider"; import { createRedirectUri } from "./redirect"; -import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session"; +import { sessionTokenCookieName } from "./session"; export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) => NextAuth({ @@ -43,7 +44,7 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read secret: "secret-is-not-defined-yet", // TODO: This should be added later session: { strategy: "database", - maxAge: sessionMaxAgeInSeconds, + maxAge: env.AUTH_SESSION_EXPIRY_TIME, }, pages: { signIn: "/auth/login", diff --git a/packages/auth/env.mjs b/packages/auth/env.mjs index 72f8fa396..1a15dd811 100644 --- a/packages/auth/env.mjs +++ b/packages/auth/env.mjs @@ -23,6 +23,29 @@ const authProvidersSchema = z ) .default("credentials"); +const createDurationSchema = (defaultValue) => + z + .string() + .regex(/^\d+[smhd]?$/) + .default(defaultValue) + .transform((duration) => { + const lastChar = duration[duration.length - 1]; + if (!isNaN(Number(lastChar))) { + return Number(defaultValue); + } + + const multipliers = { + s: 1, + m: 60, + h: 60 * 60, + d: 60 * 60 * 24, + }; + const numberDuration = Number(duration.slice(0, -1)); + const multiplier = multipliers[lastChar]; + + return numberDuration * multiplier; + }); + const booleanSchema = z .string() .default("false") @@ -39,6 +62,7 @@ const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.en export const env = createEnv({ server: { + AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"), AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(), AUTH_PROVIDERS: authProvidersSchema, ...(authProviders.includes("oidc") @@ -70,6 +94,7 @@ export const env = createEnv({ }, client: {}, runtimeEnv: { + AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME, AUTH_SECRET: process.env.AUTH_SECRET, AUTH_PROVIDERS: process.env.AUTH_PROVIDERS, AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE, diff --git a/packages/auth/session.ts b/packages/auth/session.ts index 249c3b1e1..48701ef0b 100644 --- a/packages/auth/session.ts +++ b/packages/auth/session.ts @@ -5,7 +5,6 @@ import type { Database } from "@homarr/db"; import { getCurrentUserPermissionsAsync } from "./callbacks"; -export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days export const sessionTokenCookieName = "next-auth.session-token"; export const expireDateAfter = (seconds: number) => { diff --git a/packages/auth/test/callbacks.spec.ts b/packages/auth/test/callbacks.spec.ts index db439fbc4..755b56fe8 100644 --- a/packages/auth/test/callbacks.spec.ts +++ b/packages/auth/test/callbacks.spec.ts @@ -132,6 +132,13 @@ const createAdapter = () => { type SessionExport = typeof import("../session"); const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5"; const mockSessionExpiry = new Date("2023-07-01"); +vi.mock("../env.mjs", () => { + return { + env: { + AUTH_SESSION_EXPIRY_TIME: 60 * 60 * 24 * 7, + }, + }; +}); vi.mock("../session", async (importOriginal) => { const mod = await importOriginal(); @@ -185,7 +192,7 @@ describe("createSignInCallback", () => { expect(result).toBe(false); }); - it("should call adapter.createSession with correct input", async () => { + test("should call adapter.createSession with correct input", async () => { const adapter = createAdapter(); const isCredentialsRequest = true; const signInCallback = createSignInCallback(adapter, isCredentialsRequest); diff --git a/turbo.json b/turbo.json index 9be4b2792..9528e6d00 100644 --- a/turbo.json +++ b/turbo.json @@ -24,6 +24,7 @@ "AUTH_OIDC_AUTO_LOGIN", "AUTH_PROVIDERS", "AUTH_SECRET", + "AUTH_SESSION_EXPIRY_TIME", "CI", "DISABLE_REDIS_LOGS", "DB_URL",