feat: add session expiry (#951)
This commit is contained in:
@@ -1,14 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { SessionProvider } from "@homarr/auth/client";
|
import { SessionProvider, signIn } from "@homarr/auth/client";
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
export const AuthProvider = ({ children, session }: PropsWithChildren<AuthProviderProps>) => {
|
||||||
|
useLoginRedirectOnSessionExpiry(session);
|
||||||
return <SessionProvider session={session}>{children}</SessionProvider>;
|
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]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { eq, inArray } from "@homarr/db";
|
|||||||
import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite";
|
import { groupMembers, groupPermissions } from "@homarr/db/schema/sqlite";
|
||||||
import { getPermissionsWithChildren } from "@homarr/definitions";
|
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) => {
|
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
|
||||||
const dbGroupMembers = await db.query.groupMembers.findMany({
|
const dbGroupMembers = await db.query.groupMembers.findMany({
|
||||||
@@ -53,18 +54,18 @@ export const createSignInCallback =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = generateSessionToken();
|
const sessionToken = generateSessionToken();
|
||||||
const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
|
const sessionExpires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
|
||||||
|
|
||||||
await adapter.createSession({
|
await adapter.createSession({
|
||||||
sessionToken,
|
sessionToken,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
expires: sessionExpiry,
|
expires: sessionExpires,
|
||||||
});
|
});
|
||||||
|
|
||||||
cookies().set(sessionTokenCookieName, sessionToken, {
|
cookies().set(sessionTokenCookieName, sessionToken, {
|
||||||
path: "/",
|
path: "/",
|
||||||
expires: sessionExpiry,
|
expires: sessionExpires,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: true,
|
secure: true,
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { db } from "@homarr/db";
|
|||||||
|
|
||||||
import { adapter } from "./adapter";
|
import { adapter } from "./adapter";
|
||||||
import { createSessionCallback, createSignInCallback } from "./callbacks";
|
import { createSessionCallback, createSignInCallback } from "./callbacks";
|
||||||
|
import { env } from "./env.mjs";
|
||||||
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
|
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
|
||||||
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
||||||
import { filterProviders } from "./providers/filter-providers";
|
import { filterProviders } from "./providers/filter-providers";
|
||||||
import { OidcProvider } from "./providers/oidc/oidc-provider";
|
import { OidcProvider } from "./providers/oidc/oidc-provider";
|
||||||
import { createRedirectUri } from "./redirect";
|
import { createRedirectUri } from "./redirect";
|
||||||
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
|
import { sessionTokenCookieName } from "./session";
|
||||||
|
|
||||||
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
|
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
|
||||||
NextAuth({
|
NextAuth({
|
||||||
@@ -43,7 +44,7 @@ export const createConfiguration = (isCredentialsRequest: boolean, headers: Read
|
|||||||
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
||||||
session: {
|
session: {
|
||||||
strategy: "database",
|
strategy: "database",
|
||||||
maxAge: sessionMaxAgeInSeconds,
|
maxAge: env.AUTH_SESSION_EXPIRY_TIME,
|
||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/auth/login",
|
signIn: "/auth/login",
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ const authProvidersSchema = z
|
|||||||
)
|
)
|
||||||
.default("credentials");
|
.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
|
const booleanSchema = z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.default("false")
|
||||||
@@ -39,6 +62,7 @@ const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.en
|
|||||||
|
|
||||||
export const env = createEnv({
|
export const env = createEnv({
|
||||||
server: {
|
server: {
|
||||||
|
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
|
||||||
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
|
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
|
||||||
AUTH_PROVIDERS: authProvidersSchema,
|
AUTH_PROVIDERS: authProvidersSchema,
|
||||||
...(authProviders.includes("oidc")
|
...(authProviders.includes("oidc")
|
||||||
@@ -70,6 +94,7 @@ export const env = createEnv({
|
|||||||
},
|
},
|
||||||
client: {},
|
client: {},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
|
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
|
||||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { Database } from "@homarr/db";
|
|||||||
|
|
||||||
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
import { getCurrentUserPermissionsAsync } from "./callbacks";
|
||||||
|
|
||||||
export const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
|
|
||||||
export const sessionTokenCookieName = "next-auth.session-token";
|
export const sessionTokenCookieName = "next-auth.session-token";
|
||||||
|
|
||||||
export const expireDateAfter = (seconds: number) => {
|
export const expireDateAfter = (seconds: number) => {
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ const createAdapter = () => {
|
|||||||
type SessionExport = typeof import("../session");
|
type SessionExport = typeof import("../session");
|
||||||
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
|
const mockSessionToken = "e9ef3010-6981-4a81-b9d6-8495d09cf3b5";
|
||||||
const mockSessionExpiry = new Date("2023-07-01");
|
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) => {
|
vi.mock("../session", async (importOriginal) => {
|
||||||
const mod = await importOriginal<SessionExport>();
|
const mod = await importOriginal<SessionExport>();
|
||||||
|
|
||||||
@@ -185,7 +192,7 @@ describe("createSignInCallback", () => {
|
|||||||
expect(result).toBe(false);
|
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 adapter = createAdapter();
|
||||||
const isCredentialsRequest = true;
|
const isCredentialsRequest = true;
|
||||||
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
|
const signInCallback = createSignInCallback(adapter, isCredentialsRequest);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"AUTH_OIDC_AUTO_LOGIN",
|
"AUTH_OIDC_AUTO_LOGIN",
|
||||||
"AUTH_PROVIDERS",
|
"AUTH_PROVIDERS",
|
||||||
"AUTH_SECRET",
|
"AUTH_SECRET",
|
||||||
|
"AUTH_SESSION_EXPIRY_TIME",
|
||||||
"CI",
|
"CI",
|
||||||
"DISABLE_REDIS_LOGS",
|
"DISABLE_REDIS_LOGS",
|
||||||
"DB_URL",
|
"DB_URL",
|
||||||
|
|||||||
Reference in New Issue
Block a user