feat: add session expiry (#951)

This commit is contained in:
Meier Lukas
2024-08-09 15:59:00 +02:00
committed by GitHub
parent 25452ff063
commit 6dafbaae48
7 changed files with 56 additions and 9 deletions

View File

@@ -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<AuthProviderProps>) => {
useLoginRedirectOnSessionExpiry(session);
return <SessionProvider session={session}>{children}</SessionProvider>;
};
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]);
};

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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<SessionExport>();
@@ -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);

View File

@@ -24,6 +24,7 @@
"AUTH_OIDC_AUTO_LOGIN",
"AUTH_PROVIDERS",
"AUTH_SECRET",
"AUTH_SESSION_EXPIRY_TIME",
"CI",
"DISABLE_REDIS_LOGS",
"DB_URL",