Replace entire codebase with homarr-labs/homarr

This commit is contained in:
Thomas Camlong
2026-01-15 21:54:44 +01:00
parent c5bc3b1559
commit 4fdd1fe351
4666 changed files with 409577 additions and 147434 deletions

45
packages/auth/adapter.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { Adapter } from "@auth/core/adapters";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { accounts, sessions, users } from "@homarr/db/schema";
import type { SupportedAuthProvider } from "@homarr/definitions";
export const createAdapter = (db: Database, provider: SupportedAuthProvider | "unknown"): Adapter => {
const drizzleAdapter = DrizzleAdapter(db, { usersTable: users, sessionsTable: sessions, accountsTable: accounts });
return {
...drizzleAdapter,
// We override the default implementation as we want to have a provider
// flag in the user instead of the account to not intermingle users from different providers
// eslint-disable-next-line no-restricted-syntax
getUserByEmail: async (email) => {
if (provider === "unknown") {
throw new Error("Unable to get user by email for unknown provider");
}
const user = await db.query.users.findFirst({
where: and(eq(users.email, email), eq(users.provider, provider)),
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
image: true,
},
});
if (!user) {
return null;
}
return {
...user,
// We allow null as email for credentials provider
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
email: user.email!,
};
},
};
};

View File

@@ -0,0 +1 @@
export const API_KEY_HEADER_NAME = "ApiKey";

View File

@@ -0,0 +1,76 @@
import type { Session } from "next-auth";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
import { hashPasswordAsync } from "../security";
import { createSessionAsync } from "../server";
const logger = createLogger({ module: "apiKeyAuth" });
/**
* Validate an API key from the request header and return a session if valid.
*
* @param db - The database instance
* @param apiKeyHeaderValue - The value of the ApiKey header (format: "id.token")
* @param ipAddress - The IP address of the request (for logging)
* @param userAgent - The user agent of the request (for logging)
* @returns A session if the API key is valid, null otherwise
*/
export const getSessionFromApiKeyAsync = async (
db: Database,
apiKeyHeaderValue: string | null,
ipAddress: string | null,
userAgent: string,
): Promise<Session | null> => {
if (apiKeyHeaderValue === null) {
return null;
}
const [apiKeyId, apiKey] = apiKeyHeaderValue.split(".");
if (!apiKeyId || !apiKey) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_INVALID_FORMAT" });
return null;
}
const apiKeyFromDb = await db.query.apiKeys.findFirst({
where: eq(apiKeys.id, apiKeyId),
columns: {
id: true,
apiKey: true,
salt: true,
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
emailVerified: true,
},
},
},
});
if (!apiKeyFromDb) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_NOT_FOUND" });
return null;
}
const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt);
if (apiKeyFromDb.apiKey !== hashedApiKey) {
logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_MISMATCH" });
return null;
}
logger.info("Successfully authenticated with api-key", {
name: apiKeyFromDb.user.name,
id: apiKeyFromDb.user.id,
});
return await createSessionAsync(db, apiKeyFromDb.user);
};

View File

@@ -0,0 +1,2 @@
export { getSessionFromApiKeyAsync } from "./get-api-key-session";
export { API_KEY_HEADER_NAME } from "./constants";

View File

@@ -0,0 +1,133 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import { apiKeys, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createSaltAsync, hashPasswordAsync } from "../../security";
import { getSessionFromApiKeyAsync } from "../get-api-key-session";
// Mock the logger to avoid console output during tests
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
warn: vi.fn(),
info: vi.fn(),
}),
}));
const defaultUserId = createId();
const defaultUsername = "testuser";
const defaultApiKeyId = createId();
const defaultIpAddress = "127.0.0.1";
const defaultUserAgent = "test-agent";
const defaultLogParams = [defaultIpAddress, defaultUserAgent] as const;
describe("getSessionFromApiKeyAsync", () => {
test("should return null when api key header is null", async () => {
// Arrange
const { db } = await setupAsync();
const apiKey = null;
// Act
const result = await getSessionFromApiKeyAsync(db, apiKey, ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test.each([
["invalidformat", "no dot"],
["keyid.", "missing token"],
[".token", "missing id"],
])("should return null when api key format is invalid key=%s reason=%s", async (apiKey) => {
// Arrange
const { db } = await setupAsync();
// Act
const result = await getSessionFromApiKeyAsync(db, apiKey, ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test("should return null when api key is not found in database", async () => {
// Arrange
const { db } = await setupAsync();
// Act
const result = await getSessionFromApiKeyAsync(db, "nonexistent.token", ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test("should return null when api key token does not match", async () => {
// Arrange
const { db } = await setupAsync({ token: "correcttoken" });
// Act
const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.wrongtoken`, ...defaultLogParams);
// Assert
expect(result).toBeNull();
});
test("should return session when api key is valid", async () => {
// Arrange
const token = "validtesttoken123";
const { db } = await setupAsync({ token });
// Act
const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.${token}`, ...defaultLogParams);
// Assert
expect(result).not.toBeNull();
expect(result!.user.id).toEqual(defaultUserId);
expect(result!.user.name).toEqual(defaultUsername);
});
test("should work with null ip address", async () => {
// Arrange
const token = "validtesttoken456";
const { db } = await setupAsync({ token });
// Act
const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.${token}`, null, defaultUserAgent);
// Assert
expect(result).not.toBeNull();
expect(result!.user.id).toEqual(defaultUserId);
});
});
interface SetupOptions {
/**
* If provided, inserts an API key into the database for testing.
*/
token?: string;
}
const setupAsync = async (options?: SetupOptions) => {
const db = createDb();
await db.insert(users).values({
id: defaultUserId,
name: defaultUsername,
email: "test@example.com",
});
if (options?.token) {
const salt = await createSaltAsync();
await db.insert(apiKeys).values({
id: defaultApiKeyId,
apiKey: await hashPasswordAsync(options.token, salt),
salt,
userId: defaultUserId,
});
}
return {
db,
};
};

View File

@@ -0,0 +1,70 @@
import dayjs from "dayjs";
import type { NextAuthConfig } from "next-auth";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, groupPermissions, users } from "@homarr/db/schema";
import { getPermissionsWithChildren } from "@homarr/definitions";
export const getCurrentUserPermissionsAsync = async (db: Database, userId: string) => {
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const groupIds = dbGroupMembers.map((groupMember) => groupMember.groupId);
if (groupIds.length === 0) return [];
const dbGroupPermissions = await db
.selectDistinct({
permission: groupPermissions.permission,
})
.from(groupPermissions)
.where(inArray(groupPermissions.groupId, groupIds));
const permissionKeys = dbGroupPermissions.map(({ permission }) => permission);
return getPermissionsWithChildren(permissionKeys);
};
export const createSessionAsync = async (
db: Database,
user: { id: string; email: string | null },
): Promise<Session> => {
return {
expires: dayjs().add(1, "day").toISOString(),
user: {
...user,
email: user.email ?? "",
permissions: await getCurrentUserPermissionsAsync(db, user.id),
colorScheme: "dark",
},
} as Session;
};
export const createSessionCallback = (db: Database): NextAuthCallbackOf<"session"> => {
return async ({ session, user }) => {
const additionalProperties = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
colorScheme: true,
},
});
return {
...session,
user: {
...session.user,
...additionalProperties,
id: user.id,
name: user.name,
permissions: await getCurrentUserPermissionsAsync(db, user.id),
},
};
};
};
type NextAuthCallbackRecord = Exclude<NextAuthConfig["callbacks"], undefined>;
export type NextAuthCallbackOf<TKey extends keyof NextAuthCallbackRecord> = Exclude<
NextAuthCallbackRecord[TKey],
undefined
>;

2
packages/auth/client.ts Normal file
View File

@@ -0,0 +1,2 @@
export { signIn, signOut, useSession, SessionProvider } from "next-auth/react";
export * from "./permissions/integration-provider";

View File

@@ -0,0 +1,116 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { cookies } from "next/headers";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { createAdapter } from "./adapter";
import { createSessionCallback } from "./callbacks";
import { env } from "./env";
import { createSignInEventHandler } from "./events";
import { createCredentialsConfiguration, createLdapConfiguration } 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 { expireDateAfter, generateSessionToken, sessionTokenCookieName } from "./session";
const logger = createLogger({ module: "authConfiguration" });
// See why it's unknown in the [...nextauth]/route.ts file
export const createConfiguration = (
provider: SupportedAuthProvider | "unknown",
headers: ReadonlyHeaders | null,
useSecureCookies: boolean,
) => {
const adapter = createAdapter(db, provider);
return NextAuth({
logger: {
error: (error) => {
// Remove the big error message for failed login attempts
// as it is not useful for the user.
if (error.name === "CredentialsSignin") {
logger.warn("The login attempt of a user was not successful.");
return;
}
logger.error(error);
},
},
trustHost: true,
cookies: {
sessionToken: {
name: sessionTokenCookieName,
},
},
adapter,
providers: filterProviders([
Credentials(createCredentialsConfiguration(db)),
Credentials(createLdapConfiguration(db)),
EmptyNextAuthProvider(),
OidcProvider(headers),
]),
callbacks: {
session: createSessionCallback(db),
// eslint-disable-next-line no-restricted-syntax
signIn: async ({ user }) => {
/**
* For credentials provider only jwt is supported by default
* so we have to create the session and set the cookie manually.
*/
if (provider !== "credentials" && provider !== "ldap") {
return true;
}
if (!adapter.createSession || !user.id) {
return false;
}
const expires = expireDateAfter(env.AUTH_SESSION_EXPIRY_TIME);
const sessionToken = generateSessionToken();
await adapter.createSession({
sessionToken,
expires,
userId: user.id,
});
(await cookies()).set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: expires,
httpOnly: true,
sameSite: "lax",
secure: useSecureCookies,
});
return true;
},
},
events: {
signIn: createSignInEventHandler(db),
},
redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
session: {
strategy: "database",
maxAge: env.AUTH_SESSION_EXPIRY_TIME,
generateSessionToken,
},
pages: {
signIn: "/auth/login",
error: "/auth/login",
},
jwt: {
// eslint-disable-next-line no-restricted-syntax
async encode() {
const cookie = (await cookies()).get(sessionTokenCookieName)?.value;
return cookie ?? "";
},
decode() {
return null;
},
},
});
};

63
packages/auth/env.ts Normal file
View File

@@ -0,0 +1,63 @@
import { z } from "zod/v4";
import { createBooleanSchema, createDurationSchema, createEnv } from "@homarr/core/infrastructure/env";
import { supportedAuthProviders } from "@homarr/definitions";
const authProvidersSchema = z
.string()
.min(1)
.transform((providers) =>
providers
.replaceAll(" ", "")
.toLowerCase()
.split(",")
.filter((provider) => {
if (supportedAuthProviders.some((supportedProvider) => supportedProvider === provider)) return true;
else if (!provider)
console.log("One or more of the entries for AUTH_PROVIDER could not be parsed and/or returned null.");
else console.log(`The value entered for AUTH_PROVIDER "${provider}" is incorrect.`);
return false;
}),
)
.default(["credentials"]);
const authProviders = authProvidersSchema.safeParse(process.env.AUTH_PROVIDERS).data ?? [];
export const env = createEnv({
server: {
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
AUTH_PROVIDERS: authProvidersSchema,
...(authProviders.includes("oidc")
? {
AUTH_OIDC_ISSUER: z.string().url(),
AUTH_OIDC_CLIENT_ID: z.string().min(1),
AUTH_OIDC_CLIENT_SECRET: z.string().min(1),
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
}
: {}),
...(authProviders.includes("ldap")
? {
AUTH_LDAP_URI: z.string().url(),
AUTH_LDAP_BIND_DN: z.string(),
AUTH_LDAP_BIND_PASSWORD: z.string(),
AUTH_LDAP_BASE: z.string(),
AUTH_LDAP_SEARCH_SCOPE: z.enum(["base", "one", "sub"]).default("base"),
AUTH_LDAP_USERNAME_ATTRIBUTE: z.string().default("uid"),
AUTH_LDAP_USER_MAIL_ATTRIBUTE: z.string().default("mail"),
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: z.string().optional(),
AUTH_LDAP_GROUP_CLASS: z.string().default("groupOfUniqueNames"),
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: z.string().default("member"),
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: z.string().default("dn"),
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: z.string().optional(),
}
: {}),
},
experimental__runtimeEnv: process.env,
});

View File

@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

189
packages/auth/events.ts Normal file
View File

@@ -0,0 +1,189 @@
import { cookies } from "next/headers";
import dayjs from "dayjs";
import type { NextAuthConfig } from "next-auth";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { and, eq, inArray } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { env } from "./env";
import { extractProfileName } from "./providers/oidc/oidc-provider";
const logger = createLogger({ module: "authEvents" });
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
return async ({ user, profile }) => {
logger.debug(`SignIn EventHandler for user: ${JSON.stringify(user)} . profile: ${JSON.stringify(profile)}`);
if (!user.id) throw new Error("User ID is missing");
const dbUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
columns: {
name: true,
image: true,
colorScheme: true,
},
});
if (!dbUser) throw new Error("User not found");
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
// Groups from oidc provider are provided from the profile, it's not typed.
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
}
// In ldap-authroization we return the groups from ldap, it's not typed.
if ("groups" in user && Array.isArray(user.groups)) {
logger.debug(`Using profile groups: ${JSON.stringify(user.groups)}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, user.groups as string[]);
}
await addUserToEveryoneGroupIfNotMemberAsync(db, user.id);
if (dbUser.name !== user.name) {
await db.update(users).set({ name: user.name }).where(eq(users.id, user.id));
logger.info("Username for user of credentials provider has changed.", {
userId: user.id,
oldName: dbUser.name,
newName: user.name,
});
}
if (profile) {
const profileUsername = extractProfileName(profile);
if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
}
if (dbUser.name !== profileUsername) {
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
logger.info("Username for user of oidc provider has changed.", {
userId: user.id,
oldName: dbUser.name,
newName: profileUsername,
});
}
if (
typeof profile.picture === "string" &&
dbUser.image !== profile.picture &&
!dbUser.image?.startsWith("data:")
) {
await db.update(users).set({ image: profile.picture }).where(eq(users.id, user.id));
logger.info("Profile picture for user of oidc provider has changed.", {
userId: user.id,
});
}
}
logger.info("User logged in", { userId: user.id, userName: dbUser.name, timestamp: dayjs().format() });
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
(await cookies()).set(colorSchemeCookieKey, dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});
};
};
const addUserToEveryoneGroupIfNotMemberAsync = async (db: Database, userId: string) => {
const dbEveryoneGroup = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
with: {
members: {
where: eq(groupMembers.userId, userId),
},
},
});
if (dbEveryoneGroup?.members.length === 0) {
await db.insert(groupMembers).values({
userId,
groupId: dbEveryoneGroup.id,
});
logger.info("Added user to everyone group.", { userId });
}
};
const synchronizeGroupsWithExternalForUserAsync = async (db: Database, userId: string, externalGroups: string[]) => {
const ignoredGroups = [everyoneGroup];
const dbGroupMembers = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
with: {
group: { columns: { name: true } },
},
});
/**
* The below groups are those groups the user is part of in the external system, but not in Homarr.
* So he has to be added to those groups.
*/
const missingExternalGroupsForUser = externalGroups.filter(
(externalGroup) => !dbGroupMembers.some(({ group }) => group.name === externalGroup),
);
if (missingExternalGroupsForUser.length > 0) {
logger.debug("Homarr does not have the user in certain groups.", {
user: userId,
count: missingExternalGroupsForUser.length,
});
const groupIds = await db.query.groups.findMany({
columns: {
id: true,
},
where: inArray(groups.name, missingExternalGroupsForUser),
});
logger.debug("Homarr has found groups in the database user is not in.", {
user: userId,
count: groupIds.length,
});
if (groupIds.length > 0) {
await db.insert(groupMembers).values(
groupIds.map((group) => ({
userId,
groupId: group.id,
})),
);
logger.info("Added user to groups successfully.", { user: userId, count: groupIds.length });
} else {
logger.debug("User is already in all groups of Homarr.", { user: userId });
}
}
/**
* The below groups are those groups the user is part of in Homarr, but not in the external system and not ignored.
* So he has to be removed from those groups.
*/
const groupsUserIsNoLongerMemberOfExternally = dbGroupMembers.filter(
({ group }) => !externalGroups.concat(ignoredGroups).includes(group.name),
);
if (groupsUserIsNoLongerMemberOfExternally.length > 0) {
logger.debug("Homarr has the user in certain groups that LDAP does not have.", {
user: userId,
count: groupsUserIsNoLongerMemberOfExternally.length,
});
await db.delete(groupMembers).where(
and(
eq(groupMembers.userId, userId),
inArray(
groupMembers.groupId,
groupsUserIsNoLongerMemberOfExternally.map(({ groupId }) => groupId),
),
),
);
logger.info("Removed user from groups successfully.", {
user: userId,
count: groupsUserIsNoLongerMemberOfExternally.length,
});
}
};

26
packages/auth/index.ts Normal file
View File

@@ -0,0 +1,26 @@
import { headers } from "next/headers";
import type { DefaultSession } from "@auth/core/types";
import type { ColorScheme, GroupPermissionKey, SupportedAuthProvider } from "@homarr/definitions";
import { createConfiguration } from "./configuration";
export type { Session } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
permissions: GroupPermissionKey[];
colorScheme: ColorScheme;
} & DefaultSession["user"];
}
}
export * from "./security";
// See why it's unknown in the [...nextauth]/route.ts file
export const createHandlersAsync = async (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
createConfiguration(provider, await headers(), useSecureCookies);
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";

11
packages/auth/next.ts Normal file
View File

@@ -0,0 +1,11 @@
import { cache } from "react";
import { createConfiguration } from "./configuration";
const { auth: defaultAuth } = createConfiguration("unknown", null, false);
/**
* This is the main way to get session data for your RSCs.
* This will de-duplicate all calls to next-auth's default `auth()` function and only call it once per request
*/
export const auth = cache(defaultAuth);

View File

@@ -0,0 +1,53 @@
{
"name": "@homarr/auth",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./index.ts",
"./next": "./next.ts",
"./security": "./security.ts",
"./api-key": "./api-key/index.ts",
"./client": "./client.ts",
"./server": "./server.ts",
"./shared": "./shared.ts",
"./env": "./env.ts"
},
"main": "./index.ts",
"types": "./index.ts",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@auth/core": "^0.41.1",
"@auth/drizzle-adapter": "^1.11.1",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "^6.0.0",
"cookies": "^0.9.1",
"ldapts": "8.1.2",
"next": "16.1.1",
"next-auth": "5.0.0-beta.30",
"react": "19.2.3",
"react-dom": "19.2.3",
"zod": "^4.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "6.0.0",
"@types/cookies": "0.9.2",
"eslint": "^9.39.2",
"prettier": "^3.7.4",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,47 @@
import type { Session } from "next-auth";
import type { BoardPermission } from "@homarr/definitions";
export type BoardPermissionsProps = (
| {
creator: {
id: string;
} | null;
}
| {
creatorId: string | null;
}
) & {
userPermissions: {
permission: BoardPermission;
}[];
groupPermissions: {
permission: BoardPermission;
}[];
isPublic: boolean;
};
export const constructBoardPermissions = (board: BoardPermissionsProps, session: Session | null) => {
const creatorId = "creator" in board ? board.creator?.id : board.creatorId;
const isCreator = session !== null && session.user.id === creatorId;
return {
hasFullAccess:
isCreator ||
board.userPermissions.some(({ permission }) => permission === "full") ||
board.groupPermissions.some(({ permission }) => permission === "full") ||
(session?.user.permissions.includes("board-full-all") ?? false),
hasChangeAccess:
isCreator ||
board.userPermissions.some(({ permission }) => permission === "modify" || permission === "full") ||
board.groupPermissions.some(({ permission }) => permission === "modify" || permission === "full") ||
(session?.user.permissions.includes("board-modify-all") ?? false) ||
(session?.user.permissions.includes("board-full-all") ?? false),
hasViewAccess:
isCreator ||
board.userPermissions.length >= 1 ||
board.groupPermissions.length >= 1 ||
board.isPublic ||
(session?.user.permissions.includes("board-view-all") ?? false),
};
};

View File

@@ -0,0 +1,2 @@
export * from "./board-permissions";
export * from "./integration-permissions";

View File

@@ -0,0 +1,28 @@
import type { Session } from "next-auth";
import type { IntegrationPermission } from "@homarr/definitions";
export interface IntegrationPermissionsProps {
userPermissions: {
permission: IntegrationPermission;
}[];
groupPermissions: {
permission: IntegrationPermission;
}[];
}
export const constructIntegrationPermissions = (integration: IntegrationPermissionsProps, session: Session | null) => {
const permissions = integration.userPermissions
.concat(integration.groupPermissions)
.map(({ permission }) => permission);
return {
hasFullAccess:
(session?.user.permissions.includes("integration-full-all") ?? false) || permissions.includes("full"),
hasInteractAccess:
permissions.includes("full") ||
permissions.includes("interact") ||
(session?.user.permissions.includes("integration-interact-all") ?? false),
hasUseAccess: permissions.length >= 1 || (session?.user.permissions.includes("integration-use-all") ?? false),
};
};

View File

@@ -0,0 +1,56 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationContextProps {
integrations: {
id: string;
name: string;
url: string;
kind: IntegrationKind;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;
hasUseAccess: boolean;
};
}[];
}
const IntegrationContext = createContext<IntegrationContextProps | null>(null);
export const IntegrationProvider = ({ integrations, children }: PropsWithChildren<IntegrationContextProps>) => {
return <IntegrationContext.Provider value={{ integrations }}>{children}</IntegrationContext.Provider>;
};
export const useIntegrationsWithUseAccess = () => {
const context = useContext(IntegrationContext);
if (!context) {
throw new Error("useIntegrationsWithUseAccess must be used within an IntegrationProvider");
}
return context.integrations.filter((integration) => integration.permissions.hasUseAccess);
};
export const useIntegrationsWithInteractAccess = () => {
const context = useContext(IntegrationContext);
if (!context) {
throw new Error("useIntegrationsWithInteractAccess must be used within an IntegrationProvider");
}
return context.integrations.filter((integration) => integration.permissions.hasInteractAccess);
};
export const useIntegrationsWithFullAccess = () => {
const context = useContext(IntegrationContext);
if (!context) {
throw new Error("useIntegrationsWithFullAccess must be used within an IntegrationProvider");
}
return context.integrations.filter((integration) => integration.permissions.hasFullAccess);
};

View File

@@ -0,0 +1,122 @@
import type { Session } from "next-auth";
import type { Database } from "@homarr/db";
import { and, eq, inArray, or } from "@homarr/db";
import { boards, boardUserPermissions, groupMembers } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
import { constructIntegrationPermissions } from "./integration-permissions";
interface Integration {
id: string;
items: {
item: {
boardId: string;
};
}[];
userPermissions: {
permission: IntegrationPermission;
}[];
groupPermissions: {
permission: IntegrationPermission;
}[];
}
export const hasQueryAccessToIntegrationsAsync = async (
db: Database,
integrations: Integration[],
session: Session | null,
) => {
// If the user has board-view-all and every integration has at least one item that is placed on a board he has access.
if (
session?.user.permissions.includes("board-view-all") &&
integrations.every((integration) => integration.items.length >= 1)
) {
return true;
}
const integrationsWithUseAccess = integrations.filter(
(integration) => constructIntegrationPermissions(integration, session).hasUseAccess,
);
// If the user has use access to all integrations, he has access.
if (integrationsWithUseAccess.length === integrations.length) {
return true;
}
const integrationsWithoutUseAccessAndWithoutBoardViewAllAccess = integrations
.filter((integration) => !integrationsWithUseAccess.includes(integration))
.filter((integration) => !(session?.user.permissions.includes("board-view-all") && integration.items.length >= 1));
if (integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.length === 0) {
return true;
}
const integrationsWithBoardIds = integrationsWithoutUseAccessAndWithoutBoardViewAllAccess.map((integration) => ({
id: integration.id,
anyOfBoardIds: integration.items.map(({ item }) => item.boardId),
}));
const permissionsOfCurrentUserWhenPresent = await db.query.boardUserPermissions.findMany({
where: eq(boardUserPermissions.userId, session?.user.id ?? ""),
});
// If for each integration the user has access to at least of of it's present boards, he has access.
if (
checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess(
integrationsWithBoardIds,
permissionsOfCurrentUserWhenPresent.map(({ boardId }) => boardId),
)
) {
return true;
}
const permissionsOfCurrentUserGroupsWhenPresent = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
with: {
group: {
with: {
boardPermissions: {},
},
},
},
});
const boardIdsWithPermission = permissionsOfCurrentUserWhenPresent
.map((permission) => permission.boardId)
.concat(
permissionsOfCurrentUserGroupsWhenPresent
.map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
.flat(),
);
// If for each integration the user has access to at least of of it's present boards, he has access.
if (
checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess(integrationsWithBoardIds, boardIdsWithPermission)
) {
return true;
}
const relevantBoardIds = [...new Set(integrationsWithBoardIds.map(({ anyOfBoardIds }) => anyOfBoardIds).flat())];
const publicBoardsOrBoardsWhereCurrentUserIsOwner = await db.query.boards.findMany({
where: and(
or(eq(boards.isPublic, true), eq(boards.creatorId, session?.user.id ?? "")),
inArray(boards.id, relevantBoardIds),
),
});
const boardsWithAccess = boardIdsWithPermission.concat(
publicBoardsOrBoardsWhereCurrentUserIsOwner.map(({ id }) => id),
);
// If for each integration the user has access to at least of of it's present boards, he has access.
return checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess(integrationsWithBoardIds, boardsWithAccess);
};
const checkEveryIntegrationContainsSomeBoardIdIncludedInBoardsWithAccess = (
integration: { id: string; anyOfBoardIds: string[] }[],
boardIdsWithAccess: string[],
) => {
return integration.every(({ anyOfBoardIds }) =>
anyOfBoardIds.some((boardId) => boardIdsWithAccess.includes(boardId)),
);
};

View File

@@ -0,0 +1,36 @@
import type { Session } from "next-auth";
import { db, eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { constructIntegrationPermissions } from "./integration-permissions";
export const getIntegrationsWithPermissionsAsync = async (session: Session | null) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrations = await db.query.integrations.findMany({
columns: {
id: true,
name: true,
url: true,
kind: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
});
return integrations.map(({ userPermissions, groupPermissions, ...integration }) => ({
...integration,
permissions: constructIntegrationPermissions({ userPermissions, groupPermissions }, session),
}));
};

View File

@@ -0,0 +1,307 @@
import type { Session } from "next-auth";
import { describe, expect, test } from "vitest";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { constructBoardPermissions } from "../board-permissions";
describe("constructBoardPermissions", () => {
test("should return all board permissions as true when session user id is equal to creator id", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "1",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(true);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasFullAccess as true when session permissions include board-full-all", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["board-full-all"]),
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(true);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasChangeAccess as true when session permissions include board-modify-all", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["board-modify-all"]),
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test('should return hasChangeAccess as true when board user permissions include "modify"', () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [{ permission: "modify" as const }],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasChangeAccess as true when board group permissions include modify", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [{ permission: "modify" as const }],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(true);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when session permissions include board-view-all", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["board-view-all"]),
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board user permissions length is greater than or equal to 1", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [{ permission: "view" as const }],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return hasViewAccess as true when board group permissions length is greater than or equal to 1", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [{ permission: "view" as const }],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return all false when board is not public and session user id is not equal to creator id and no permissions", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(false);
});
test("should return hasViewAccess as true when board is public", () => {
// Arrange
const board = {
creator: {
id: "1",
},
userPermissions: [],
groupPermissions: [],
isPublic: true,
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(true);
});
test("should return all false when creator is null and session is null", () => {
// Arrange
const board = {
creator: null,
userPermissions: [],
groupPermissions: [],
isPublic: false,
};
const session = null;
// Act
const result = constructBoardPermissions(board, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasChangeAccess).toBe(false);
expect(result.hasViewAccess).toBe(false);
});
});

View File

@@ -0,0 +1,200 @@
import type { Session } from "next-auth";
import { describe, expect, test } from "vitest";
import { getPermissionsWithChildren } from "@homarr/definitions";
import { constructIntegrationPermissions } from "../integration-permissions";
describe("constructIntegrationPermissions", () => {
test("should return hasFullAccess as true when session permissions include integration-full-all", () => {
// Arrange
const integration = {
userPermissions: [],
groupPermissions: [],
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["integration-full-all"]),
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(true);
expect(result.hasInteractAccess).toBe(true);
expect(result.hasUseAccess).toBe(true);
});
test("should return hasInteractAccess as true when session permissions include integration-interact-all", () => {
// Arrange
const integration = {
userPermissions: [],
groupPermissions: [],
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["integration-interact-all"]),
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(true);
expect(result.hasUseAccess).toBe(true);
});
test('should return hasInteractAccess as true when integration user permissions include "interact"', () => {
// Arrange
const integration = {
userPermissions: [{ permission: "interact" as const }],
groupPermissions: [],
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(true);
expect(result.hasUseAccess).toBe(true);
});
test("should return hasInteractAccess as true when integration group permissions include interact", () => {
// Arrange
const integration = {
userPermissions: [],
groupPermissions: [{ permission: "interact" as const }],
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(true);
expect(result.hasUseAccess).toBe(true);
});
test("should return hasUseAccess as true when session permissions include integration-use-all", () => {
// Arrange
const integration = {
userPermissions: [],
groupPermissions: [],
};
const session = {
user: {
id: "2",
permissions: getPermissionsWithChildren(["integration-use-all"]),
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(false);
expect(result.hasUseAccess).toBe(true);
});
test("should return hasUseAccess as true when integration user permissions length is greater than or equal to 1", () => {
// Arrange
const integration = {
userPermissions: [{ permission: "use" as const }],
groupPermissions: [],
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(false);
expect(result.hasUseAccess).toBe(true);
});
test("should return hasUseAccess as true when integration group permissions length is greater than or equal to 1", () => {
// Arrange
const integration = {
userPermissions: [],
groupPermissions: [{ permission: "use" as const }],
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(false);
expect(result.hasUseAccess).toBe(true);
});
test("should return all false when integration no permissions", () => {
// Arrange
const integration = {
userPermissions: [],
groupPermissions: [],
};
const session = {
user: {
id: "2",
permissions: [],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
// Act
const result = constructIntegrationPermissions(integration, session);
// Assert
expect(result.hasFullAccess).toBe(false);
expect(result.hasInteractAccess).toBe(false);
expect(result.hasUseAccess).toBe(false);
});
});

View File

@@ -0,0 +1,583 @@
import type { Session } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import type { InferInsertModel } from "@homarr/db";
import { boardGroupPermissions, boards, boardUserPermissions, groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import * as integrationPermissions from "../integration-permissions";
import { hasQueryAccessToIntegrationsAsync } from "../integration-query-permissions";
const createSession = (user: Partial<Session["user"]>): Session => ({
user: {
id: "1",
permissions: [],
colorScheme: "light",
...user,
},
expires: new Date().toISOString(),
});
describe("hasQueryAccessToIntegrationsAsync should check if the user has query access to the specified integrations", () => {
test("should return true if the user has the board-view-all permission and the integrations are used anywhere", async () => {
// Arrange
const db = createDb();
const session = createSession({
permissions: ["board-view-all"],
});
const integrations = [
{
id: "1",
items: [{ item: { boardId: "1" } }],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [{ item: { boardId: "2" } }],
userPermissions: [],
groupPermissions: [],
},
];
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return true if the user has the board-view-all permission, the first integration is used and the second one he has use access", async () => {
// Arrange
const db = createDb();
const session = createSession({
permissions: ["board-view-all"],
});
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: true,
});
const integrations = [
{
id: "1",
items: [{ item: { boardId: "1" } }],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [],
userPermissions: [],
groupPermissions: [],
},
];
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return true if the user has use access to all integrations", async () => {
// Arrange
const db = createDb();
const session = createSession({});
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: true,
});
const integrations = [
{
id: "1",
items: [],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [],
userPermissions: [],
groupPermissions: [],
},
];
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return true if the user has user permission to access to at least one board of each integration", async () => {
// Arrange
const db = createDb();
const session = createSession({});
await db.insert(users).values({ id: session.user.id });
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "1",
},
},
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return false if the user has user permission to access board of first integration but not of second one", async () => {
// Arrange
const db = createDb();
const session = createSession({});
await db.insert(users).values({ id: session.user.id });
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(false);
});
test("should return true if the user has group permission to access to at least one board of each integration", async () => {
// Arrange
const db = createDb();
const session = createSession({});
await db.insert(users).values({ id: session.user.id });
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "1",
},
},
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return false if the user has group permission to access board of first integration but not of second one", async () => {
// Arrange
const db = createDb();
const session = createSession({});
await db.insert(users).values({ id: session.user.id });
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "1", permission: "view" });
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(false);
});
test("should return true if the user has user permission to access first board and group permission to access second one", async () => {
// Arrange
const db = createDb();
const session = createSession({});
await db.insert(users).values({ id: session.user.id });
const spy = vi.spyOn(integrationPermissions, "constructIntegrationPermissions");
spy.mockReturnValue({
hasFullAccess: false,
hasInteractAccess: false,
hasUseAccess: false,
});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1" }));
await db.insert(boards).values(createMockBoard({ id: "2" }));
await db.insert(groups).values({ id: "1", name: "", position: 1 });
await db.insert(groupMembers).values({ userId: session.user.id, groupId: "1" });
await db.insert(boardGroupPermissions).values({ groupId: "1", boardId: "2", permission: "view" });
await db.insert(boardUserPermissions).values({ userId: session.user.id, boardId: "1", permission: "view" });
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return true if one of the boards the integration is used is public", async () => {
// Arrange
const db = createDb();
const session = createSession({});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1", isPublic: true }));
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return true if the user is creator of the board the integration is used", async () => {
// Arrange
const db = createDb();
const session = createSession({});
await db.insert(users).values({ id: session.user.id });
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1", creatorId: session.user.id }));
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(true);
});
test("should return false if the user has no access to any of the integrations", async () => {
// Arrange
const db = createDb();
const session = createSession({});
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
// Assert
expect(result).toBe(false);
});
test("should return false if the user is anonymous and the board is not public", async () => {
// Arrange
const db = createDb();
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1" }));
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, null);
// Assert
expect(result).toBe(false);
});
test("should return true if the user is anonymous and the board is public", async () => {
// Arrange
const db = createDb();
const integrations = [
{
id: "1",
items: [
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
{
id: "2",
items: [
{
item: {
boardId: "2",
},
},
{
item: {
boardId: "1",
},
},
],
userPermissions: [],
groupPermissions: [],
},
];
await db.insert(boards).values(createMockBoard({ id: "1", isPublic: true }));
// Act
const result = await hasQueryAccessToIntegrationsAsync(db, integrations, null);
// Assert
expect(result).toBe(true);
});
});
const createMockBoard = (board: Partial<InferInsertModel<typeof boards>>): InferInsertModel<typeof boards> => ({
id: createId(),
name: board.id ?? createId(),
...board,
});

View File

@@ -0,0 +1,9 @@
import type { SupportedAuthProvider } from "@homarr/definitions";
import { env } from "../env";
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
// The question mark is placed there because isProviderEnabled is called during static build of about page
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return env.AUTH_PROVIDERS?.includes(provider);
};

View File

@@ -0,0 +1,39 @@
import bcrypt from "bcrypt";
import type { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
import type { userSignInSchema } from "@homarr/validation/user";
const logger = createLogger({ module: "basicAuthorization" });
export const authorizeWithBasicCredentialsAsync = async (
db: Database,
credentials: z.infer<typeof userSignInSchema>,
) => {
const user = await db.query.users.findFirst({
where: and(eq(users.name, credentials.name.toLowerCase()), eq(users.provider, "credentials")),
});
if (!user?.password) {
logger.info("User not found", { userName: credentials.name });
return null;
}
logger.info("User is trying to log in. Checking password...", { userName: user.name });
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
if (!isValidPassword) {
logger.warn("Password for user was incorrect", { userName: user.name });
return null;
}
logger.info("User successfully authorized", { userName: user.name });
return {
id: user.id,
name: user.name,
};
};

View File

@@ -0,0 +1,149 @@
import { CredentialsSignin } from "@auth/core/errors";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
import type { ldapSignInSchema } from "@homarr/validation/user";
import { env } from "../../../env";
import { LdapClient } from "../ldap-client";
const logger = createLogger({ module: "ldapAuthorization" });
export const authorizeWithLdapCredentialsAsync = async (
db: Database,
credentials: z.infer<typeof ldapSignInSchema>,
) => {
logger.info("User is trying to log in using LDAP. Connecting to LDAP server...", { userName: credentials.name });
const client = new LdapClient();
await client
.bindAsync({
distinguishedName: env.AUTH_LDAP_BIND_DN,
password: env.AUTH_LDAP_BIND_PASSWORD,
})
.catch((error) => {
throw new CredentialsSignin("Failed to connect to LDAP server", { cause: error });
});
logger.info("Connected to LDAP server. Searching for user...");
const ldapUser = await client
.searchAsync({
base: env.AUTH_LDAP_BASE,
options: {
filter: createLdapUserFilter(credentials.name),
scope: env.AUTH_LDAP_SEARCH_SCOPE,
attributes: [env.AUTH_LDAP_USERNAME_ATTRIBUTE, env.AUTH_LDAP_USER_MAIL_ATTRIBUTE],
},
})
.then((entries) => {
if (entries.length > 1) {
logger.warn(`Multiple LDAP users found for ${credentials.name}, expected only one.`);
throw new CredentialsSignin();
}
return entries.at(0);
});
if (!ldapUser) {
throw new CredentialsSignin(`User not found in LDAP username="${credentials.name}"`);
}
// Validate email
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
if (!mailResult.success) {
logger.error("User found in LDAP but with invalid or non-existing Email", {
userName: credentials.name,
emailValue: ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE],
});
throw new CredentialsSignin("User found in LDAP but with invalid or non-existing Email");
}
logger.info("User found in LDAP. Logging in...", { userName: credentials.name });
// Bind with user credentials to check if the password is correct
const userClient = new LdapClient();
await userClient
.bindAsync({
distinguishedName: ldapUser.dn,
password: credentials.password,
})
.catch(() => {
logger.warn("Wrong credentials for user", { userName: credentials.name });
throw new CredentialsSignin();
});
await userClient.disconnectAsync();
logger.info("User credentials are correct. Retrieving user groups...", { userName: credentials.name });
const userGroups = await client
.searchAsync({
base: env.AUTH_LDAP_BASE,
options: {
// For example, if the user is doejohn, the filter will be (&(objectClass=group)(uid=doejohn)) or (&(objectClass=group)(uid=doejohn)(sAMAccountType=1234))
filter: `(&(objectClass=${env.AUTH_LDAP_GROUP_CLASS})(${
env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE
}=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE]})${env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG ?? ""})`,
scope: env.AUTH_LDAP_SEARCH_SCOPE,
attributes: ["cn"],
},
})
.then((entries) => entries.map((entry) => entry.cn).filter((group): group is string => group !== undefined));
logger.info("User groups retrieved", { userName: credentials.name, groups: userGroups.length });
await client.disconnectAsync();
// Create or update user in the database
let user = await db.query.users.findFirst({
columns: {
id: true,
name: true,
image: true,
email: true,
emailVerified: true,
provider: true,
},
where: and(eq(users.email, mailResult.data), eq(users.provider, "ldap")),
});
if (!user) {
logger.info("User not found in the database. Creating...", { userName: credentials.name });
const insertUser = {
id: createId(),
name: credentials.name,
email: mailResult.data,
emailVerified: new Date(), // assume email is verified
image: null,
provider: "ldap",
} satisfies InferInsertModel<typeof users>;
await db.insert(users).values(insertUser);
user = insertUser;
logger.info("User created successfully", { userName: credentials.name });
}
return {
id: user.id,
name: credentials.name,
// Groups is used in events.ts to synchronize groups with external systems
groups: userGroups,
};
};
const createLdapUserFilter = (username: string) => {
if (env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG) {
// For example, if the username is doejohn and the extra arg is (sAMAccountType=1234), the filter will be (&(uid=doejohn)(sAMAccountType=1234))
return `(&(${env.AUTH_LDAP_USERNAME_ATTRIBUTE}=${username})${env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG})`;
}
// For example, if the username is doejohn, the filter will be (uid=doejohn)
return `(${env.AUTH_LDAP_USERNAME_ATTRIBUTE}=${username})`;
};

View File

@@ -0,0 +1,34 @@
import type Credentials from "@auth/core/providers/credentials";
import type { Database } from "@homarr/db";
import { ldapSignInSchema, userSignInSchema } from "@homarr/validation/user";
import { authorizeWithBasicCredentialsAsync } from "./authorization/basic-authorization";
import { authorizeWithLdapCredentialsAsync } from "./authorization/ldap-authorization";
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
export const createCredentialsConfiguration = (db: Database) =>
({
id: "credentials",
type: "credentials",
name: "Credentials",
// eslint-disable-next-line no-restricted-syntax
async authorize(credentials) {
const data = await userSignInSchema.parseAsync(credentials);
return await authorizeWithBasicCredentialsAsync(db, data);
},
}) satisfies CredentialsConfiguration;
export const createLdapConfiguration = (db: Database) =>
({
id: "ldap",
type: "credentials",
name: "Ldap",
// eslint-disable-next-line no-restricted-syntax
async authorize(credentials) {
const data = await ldapSignInSchema.parseAsync(credentials);
return await authorizeWithLdapCredentialsAsync(db, data).catch(() => null);
},
}) satisfies CredentialsConfiguration;

View File

@@ -0,0 +1,90 @@
import type { Entry, SearchOptions as LdapSearchOptions } from "ldapts";
import { Client } from "ldapts";
import { objectEntries } from "@homarr/common";
import { env } from "../../env";
export interface BindOptions {
distinguishedName: string;
password: string;
}
interface SearchOptions {
base: string;
options: LdapSearchOptions;
}
export class LdapClient {
private client: Client;
constructor() {
this.client = new Client({
url: env.AUTH_LDAP_URI,
});
}
/**
* Binds to the LDAP server with the provided distinguishedName and password.
* @param distinguishedName distinguishedName to bind to
* @param password password to bind with
* @returns void
*/
public async bindAsync({ distinguishedName, password }: BindOptions) {
return await this.client.bind(distinguishedName, password);
}
/**
* Search for entries in the LDAP server.
* @param base base DN to start the search
* @param options search options
* @returns list of search results
*/
public async searchAsync({ base, options }: SearchOptions) {
const { searchEntries } = await this.client.search(base, options);
return searchEntries.map((entry) => {
return {
...objectEntries(entry)
.map(([key, value]) => [key, LdapClient.convertEntryPropertyToString(value)] as const)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as Record<string, string>),
dn: LdapClient.getEntryDn(entry),
} as {
[key: string]: string;
dn: string;
};
});
}
private static convertEntryPropertyToString(value: Entry[string]) {
const firstValue = Array.isArray(value) ? (value[0] ?? "") : value;
if (typeof firstValue === "string") {
return firstValue;
}
return firstValue.toString("utf8");
}
/**
* dn is the only attribute returned with special characters formatted in UTF-8 (Bad for any letters with an accent)
* Regex replaces any backslash followed by 2 hex characters with a percentage unless said backslash is preceded by another backslash.
* That can then be processed by decodeURIComponent which will turn back characters to normal.
* @param entry search entry from ldap
* @returns normalized distinguishedName
*/
private static getEntryDn(entry: Entry) {
try {
return decodeURIComponent(entry.dn.replace(/(?<!\\)\\([0-9a-fA-F]{2})/g, "%$1"));
} catch {
throw new Error(`Cannot resolve distinguishedName for the entry ${entry.dn}`);
}
}
/**
* Disconnects the client from the LDAP server.
*/
public async disconnectAsync() {
await this.client.unbind();
}
}

View File

@@ -0,0 +1,16 @@
import type { OAuthConfig } from "next-auth/providers";
export function EmptyNextAuthProvider(): OAuthConfig<unknown> {
return {
id: "empty",
name: "Empty",
type: "oauth",
profile: () => {
throw new Error(
"EmptyNextAuthProvider can not be used and is only a placeholder because credentials authentication can not be used as session authentication without additional providers.",
);
},
issuer: "empty",
authorization: new URL("https://example.empty"),
};
}

View File

@@ -0,0 +1,24 @@
import type { Provider } from "next-auth/providers";
import { env } from "../env";
export const filterProviders = (providers: Exclude<Provider, () => unknown>[]) => {
// During build this will be undefined, so we default to an empty array
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const authProviders = env.AUTH_PROVIDERS ?? [];
return providers.filter((provider) => {
if (provider.id === "empty") {
return true;
}
if (
provider.id === "credentials" &&
["ldap", "credentials"].some((credentialType) => authProviders.includes(credentialType))
) {
return true;
}
return authProviders.includes(provider.id);
});
};

View File

@@ -0,0 +1,81 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { customFetch } from "next-auth";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { env } from "../../env";
import { createRedirectUri } from "../../redirect";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
id: "oidc",
name: env.AUTH_OIDC_CLIENT_NAME,
type: "oidc",
clientId: env.AUTH_OIDC_CLIENT_ID,
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
issuer: env.AUTH_OIDC_ISSUER,
allowDangerousEmailAccountLinking: env.AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING,
authorization: {
params: {
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
// We fallback to https as generally oidc providers require https
redirect_uri: createRedirectUri(headers, "/api/auth/callback/oidc", "https"),
},
},
token: {
// Providers like fusionauth may return www-authenticate which results in an error
// https://github.com/nextauthjs/next-auth/issues/8745
// https://github.com/homarr-labs/homarr/issues/2690
conform: (response: Response) => {
if (response.status === 401) return response;
const newHeaders = Array.from(response.headers.entries())
.filter(([key]) => key.toLowerCase() !== "www-authenticate")
.reduce((headers, [key, value]) => {
headers.append(key, value);
return headers;
}, new Headers());
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
},
},
// idToken false forces the use of the userinfo endpoint
// Userinfo endpoint is required for authelia since v4.39
// See https://github.com/homarr-labs/homarr/issues/2635
idToken: !env.AUTH_OIDC_FORCE_USERINFO,
profile(profile) {
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
}
const name = extractProfileName(profile);
if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
}
return {
id: profile.sub,
name,
email: profile.email,
image: typeof profile.picture === "string" ? profile.picture : null,
provider: "oidc",
};
},
// The type for fetch is not identical, but for what we need it it's okay to not be an 1:1 match
// See documentation https://authjs.dev/guides/corporate-proxy?framework=next-js
// @ts-expect-error `undici` has a `duplex` option
[customFetch]: fetchWithTrustedCertificatesAsync,
});
export const extractProfileName = (profile: Profile) => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username;
}
return profile[env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE as keyof typeof profile] as string;
};

View File

@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import { createId } from "@homarr/common";
import { users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createSaltAsync, hashPasswordAsync } from "../../security";
import { authorizeWithBasicCredentialsAsync } from "../credentials/authorization/basic-authorization";
const defaultUserId = createId();
describe("authorizeWithBasicCredentials", () => {
test("should authorize user with correct credentials", async () => {
// Arrange
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: defaultUserId,
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
});
// Act
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "test",
password: "test",
});
// Assert
expect(result).toEqual({ id: defaultUserId, name: "test" });
});
test("should not authorize user with incorrect credentials", async () => {
// Arrange
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: defaultUserId,
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
});
// Act
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "test",
password: "wrong",
});
// Assert
expect(result).toBeNull();
});
test("should not authorize user with incorrect username", async () => {
// Arrange
const db = createDb();
const salt = await createSaltAsync();
await db.insert(users).values({
id: defaultUserId,
name: "test",
salt,
password: await hashPasswordAsync("test", salt),
});
// Act
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "wrong",
password: "test",
});
// Assert
expect(result).toBeNull();
});
test("should not authorize user when password is not set", async () => {
// Arrange
const db = createDb();
await db.insert(users).values({
id: defaultUserId,
name: "test",
});
// Act
const result = await authorizeWithBasicCredentialsAsync(db, {
name: "test",
password: "test",
});
// Assert
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,309 @@
import { CredentialsSignin } from "@auth/core/errors";
import { describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
import * as ldapClient from "../credentials/ldap-client";
vi.mock("../../env", () => ({
env: {
AUTH_LDAP_BIND_DN: "bind_dn",
AUTH_LDAP_BIND_PASSWORD: "bind_password",
AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail",
AUTH_LDAP_GROUP_CLASS: "group",
},
}));
describe("authorizeWithLdapCredentials", () => {
test("should fail when wrong ldap base credentials", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.reject(new Error("bindAsync"))),
} as unknown as ldapClient.LdapClient;
});
// Act
const act = () =>
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
});
// Assert
await expect(act()).rejects.toThrow(CredentialsSignin);
});
test("should fail when user not found", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn(() => Promise.resolve([])),
} as unknown as ldapClient.LdapClient;
});
// Act
const act = () =>
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
});
// Assert
await expect(act()).rejects.toThrow(CredentialsSignin);
});
test("should fail when user has invalid email", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn(() =>
Promise.resolve([
{
dn: "test",
mail: "test",
},
]),
),
} as unknown as ldapClient.LdapClient;
});
// Act
const act = () =>
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
});
// Assert
await expect(act()).rejects.toThrow(CredentialsSignin);
});
test("should fail when user password is incorrect", async () => {
// Arrange
const searchSpy = vi.fn(() =>
Promise.resolve([
{
dn: "test",
mail: "test@gmail.com",
},
]),
);
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn((props: ldapClient.BindOptions) =>
props.distinguishedName === "test" ? Promise.reject(new Error("bindAsync")) : Promise.resolve(),
),
searchAsync: searchSpy,
} as unknown as ldapClient.LdapClient;
});
// Act
const act = () =>
authorizeWithLdapCredentialsAsync(null as unknown as Database, {
name: "test",
password: "test",
});
// Assert
await expect(act()).rejects.toThrow(CredentialsSignin);
expect(searchSpy).toHaveBeenCalledTimes(1);
});
test("should authorize user with correct credentials and create user", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn(() =>
Promise.resolve([
{
dn: "test",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
} as unknown as ldapClient.LdapClient;
});
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
});
// Assert
expect(result.name).toBe("test");
expect(result.groups).toHaveLength(0); // Groups are needed in signIn events callback
const dbUser = await db.query.users.findFirst({
where: eq(users.name, "test"),
});
expect(dbUser).toBeDefined();
expect(dbUser?.id).toBe(result.id);
expect(dbUser?.email).toBe("test@gmail.com");
expect(dbUser?.emailVerified).not.toBeNull();
expect(dbUser?.provider).toBe("ldap");
});
test("should authorize user with correct credentials and create user with same email when credentials user already exists", async () => {
// Arrange
const db = createDb();
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn(() =>
Promise.resolve([
{
dn: "test",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
} as unknown as ldapClient.LdapClient;
});
await db.insert(users).values({
id: createId(),
name: "test",
email: "test@gmail.com",
provider: "credentials",
});
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
});
// Assert
expect(result.name).toBe("test");
expect(result.groups).toHaveLength(0); // Groups are needed in signIn events callback
const dbUser = await db.query.users.findFirst({
where: and(eq(users.name, "test"), eq(users.provider, "ldap")),
});
expect(dbUser).toBeDefined();
expect(dbUser?.id).toBe(result.id);
expect(dbUser?.email).toBe("test@gmail.com");
expect(dbUser?.emailVerified).not.toBeNull();
expect(dbUser?.provider).toBe("ldap");
const credentialsUser = await db.query.users.findFirst({
where: and(eq(users.name, "test"), eq(users.provider, "credentials")),
});
expect(credentialsUser).toBeDefined();
expect(credentialsUser?.id).not.toBe(result.id);
});
// The name update occurs in the signIn event callback
test("should authorize user with correct credentials and return updated name", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn(() =>
Promise.resolve([
{
dn: "test55",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
} as unknown as ldapClient.LdapClient;
});
const userId = createId();
const db = createDb();
await db.insert(users).values({
id: userId,
name: "test-old",
email: "test@gmail.com",
provider: "ldap",
});
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
});
// Assert
expect(result).toEqual({ id: userId, name: "test", groups: [] });
const dbUser = await db.query.users.findFirst({
where: eq(users.id, userId),
});
expect(dbUser).toBeDefined();
expect(dbUser?.id).toBe(userId);
expect(dbUser?.name).toBe("test-old");
expect(dbUser?.email).toBe("test@gmail.com");
expect(dbUser?.provider).toBe("ldap");
});
test("should authorize user with correct credentials and return his groups", async () => {
// Arrange
const spy = vi.spyOn(ldapClient, "LdapClient");
spy.mockImplementation(function () {
return {
bindAsync: vi.fn(() => Promise.resolve()),
searchAsync: vi.fn((argument: { options: { filter: string } }) =>
argument.options.filter.includes("group")
? Promise.resolve([
{
cn: "homarr_example",
},
])
: Promise.resolve([
{
dn: "test55",
mail: "test@gmail.com",
},
]),
),
disconnectAsync: vi.fn(),
} as unknown as ldapClient.LdapClient;
});
const db = createDb();
const userId = createId();
await db.insert(users).values({
id: userId,
name: "test",
email: "test@gmail.com",
provider: "ldap",
});
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "homarr_example",
position: 1,
});
// Act
const result = await authorizeWithLdapCredentialsAsync(db, {
name: "test",
password: "test",
});
// Assert
expect(result).toEqual({ id: userId, name: "test", groups: ["homarr_example"] });
});
});

25
packages/auth/redirect.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { extractBaseUrlFromHeaders } from "@homarr/common";
/**
* The redirect_uri is constructed to work behind a reverse proxy. It is constructed from the headers x-forwarded-proto and x-forwarded-host.
* @param headers
* @param pathname
* @returns
*/
export const createRedirectUri = (
headers: ReadonlyHeaders | null,
pathname: string,
fallbackProtocol: "http" | "https" = "http",
) => {
if (!headers) {
return pathname;
}
const baseUrl = extractBaseUrlFromHeaders(headers, fallbackProtocol);
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
return `${baseUrl}${path}`;
};

View File

@@ -0,0 +1,9 @@
import bcrypt from "bcrypt";
export const createSaltAsync = async () => {
return bcrypt.genSalt(10);
};
export const hashPasswordAsync = async (password: string, salt: string) => {
return bcrypt.hash(password, salt);
};

4
packages/auth/server.ts Normal file
View File

@@ -0,0 +1,4 @@
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
export { isProviderEnabled } from "./providers/check-provider";
export { createSessionCallback, createSessionAsync } from "./callbacks";

53
packages/auth/session.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { Session } from "next-auth";
import { generateSecureRandomToken } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { getCurrentUserPermissionsAsync } from "./callbacks";
// Default of authjs
export const sessionTokenCookieName = "authjs.session-token";
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);
};
export const generateSessionToken = () => {
return generateSecureRandomToken(48);
};
export const getSessionFromTokenAsync = async (db: Database, token: string | undefined): Promise<Session | null> => {
if (!token) {
return null;
}
const session = await db.query.sessions.findFirst({
where: ({ sessionToken }, { eq }) => eq(sessionToken, token),
columns: {
expires: true,
},
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
colorScheme: true,
},
},
},
});
if (!session) {
return null;
}
return {
user: {
...session.user,
permissions: await getCurrentUserPermissionsAsync(db, session.user.id),
},
expires: session.expires.toISOString(),
};
};

1
packages/auth/shared.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./permissions";

View File

@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import { users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { createAdapter } from "../adapter";
describe("createAdapter should create drizzle adapter", () => {
test.each([["credentials" as const], ["ldap" as const], ["oidc" as const]])(
"createAdapter getUserByEmail should return user for provider %s when this provider provided",
async (provider) => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, provider);
const email = "test@example.com";
await db.insert(users).values({ id: "1", name: "test", email, provider });
// Act
const user = await adapter.getUserByEmail?.(email);
// Assert
expect(user).toEqual({
id: "1",
name: "test",
email,
emailVerified: null,
image: null,
});
},
);
test.each([
["credentials", ["ldap", "oidc"]],
["ldap", ["credentials", "oidc"]],
["oidc", ["credentials", "ldap"]],
] as const)(
"createAdapter getUserByEmail should return null if only for other providers than %s exist",
async (requestedProvider, existingProviders) => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, requestedProvider);
const email = "test@example.com";
for (const provider of existingProviders) {
await db.insert(users).values({ id: provider, name: `test-${provider}`, email, provider });
}
// Act
const user = await adapter.getUserByEmail?.(email);
// Assert
expect(user).toBeNull();
},
);
test("createAdapter getUserByEmail should throw error if provider is unknown", async () => {
// Arrange
const db = createDb();
const adapter = createAdapter(db, "unknown");
const email = "test@example.com";
// Act
const actAsync = async () => await adapter.getUserByEmail?.(email);
// Assert
await expect(actAsync()).rejects.toThrow("Unable to get user by email for unknown provider");
});
});

View File

@@ -0,0 +1,144 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { AdapterUser } from "@auth/core/adapters";
import type { JWT } from "next-auth/jwt";
import { describe, expect, test, vi } from "vitest";
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import * as definitions from "@homarr/definitions";
import { createSessionCallback, getCurrentUserPermissionsAsync } from "../callbacks";
// This one is placed here because it's used in multiple tests and needs to be the same reference
const setCookies = vi.fn();
vi.mock("next/headers", () => ({
cookies: () => ({
set: setCookies,
}),
}));
describe("getCurrentUserPermissions", () => {
test("should return empty permissions when non existing user requested", async () => {
// Arrange
const db = createDb();
await db.insert(groups).values({
id: "2",
name: "test",
position: 1,
});
await db.insert(groupPermissions).values({
groupId: "2",
permission: "admin",
});
await db.insert(users).values({
id: "2",
});
const userId = "1";
// Act
const result = await getCurrentUserPermissionsAsync(db, userId);
// Assert
expect(result).toEqual([]);
});
test("should return empty permissions when user has no groups", async () => {
// Arrange
const db = createDb();
const userId = "1";
await db.insert(groups).values({
id: "2",
name: "test",
position: 1,
});
await db.insert(groupPermissions).values({
groupId: "2",
permission: "admin",
});
await db.insert(users).values({
id: userId,
});
// Act
const result = await getCurrentUserPermissionsAsync(db, userId);
// Assert
expect(result).toEqual([]);
});
test("should return permissions for user", async () => {
// Arrange
const db = createDb();
const getPermissionsWithChildrenMock = vi
.spyOn(definitions, "getPermissionsWithChildren")
.mockReturnValue(["board-create"]);
const mockId = "1";
await db.insert(users).values({
id: mockId,
});
await db.insert(groups).values({
id: mockId,
name: "test",
position: 1,
});
await db.insert(groupMembers).values({
userId: mockId,
groupId: mockId,
});
await db.insert(groupPermissions).values({
groupId: mockId,
permission: "admin",
});
// Act
const result = await getCurrentUserPermissionsAsync(db, mockId);
// Assert
expect(result).toEqual(["board-create"]);
expect(getPermissionsWithChildrenMock).toHaveBeenCalledWith(["admin"]);
});
});
describe("session callback", () => {
test("should add id and name to session user", async () => {
// Arrange
const user: AdapterUser = {
id: "id",
name: "name",
email: "email",
emailVerified: new Date("2023-01-13"),
};
const token: JWT = {};
const db = createDb();
const callback = createSessionCallback(db);
// Act
const result = await callback({
session: {
user: {
id: "no-id",
email: "no-email",
emailVerified: new Date("2023-01-13"),
permissions: [],
colorScheme: "dark",
},
expires: "2023-01-13" as Date & string,
sessionToken: "token",
userId: "no-id",
},
user,
token,
trigger: "update",
newSession: {},
});
// Assert
expect(result.user).toBeDefined();
expect(result.user!.id).toEqual(user.id);
expect(result.user!.name).toEqual(user.name);
});
});

View File

@@ -0,0 +1,264 @@
import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { cookies } from "next/headers";
import { describe, expect, test, vi } from "vitest";
import { eq } from "@homarr/db";
import type { Database } from "@homarr/db";
import { groupMembers, groups, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { createSignInEventHandler } from "../events";
vi.mock("next-auth", () => ({}));
vi.mock("../env", () => {
return {
env: {
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
},
};
});
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
type HeadersExport = typeof import("next/headers");
vi.mock("next/headers", async (importOriginal) => {
const mod = await importOriginal<HeadersExport>();
const result = {
set: (name: string, value: string, options: Partial<ResponseCookie>) => options as ResponseCookie,
} as unknown as ReadonlyRequestCookies;
vi.spyOn(result, "set");
const cookies = () => Promise.resolve(result);
return { ...mod, cookies } satisfies HeadersExport;
});
describe("createSignInEventHandler should create signInEventHandler", () => {
describe("signInEventHandler should add users to everyone group", () => {
test("should add user to everyone group if he isn't already", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
describe("signInEventHandler should synchronize ldap groups", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: ["test"] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: [] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers).toBeUndefined();
});
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test", groups: [] } as never,
profile: undefined,
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
describe("signInEventHandler should synchronize oidc groups", () => {
test("should add missing group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: ["test"] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: [] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers).toBeUndefined();
});
test("should not remove group membership for everyone group", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
await createGroupAsync(db, everyoneGroup);
await db.insert(groupMembers).values({
userId: "1",
groupId: "1",
});
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: { preferred_username: "test", someRandomGroupsKey: [] },
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
});
test.each([
["ldap" as const, { name: "test-new" }, undefined],
["oidc" as const, { name: "test" }, { preferred_username: "test-new" }],
["oidc" as const, { name: "test" }, { preferred_username: "test@example.com", name: "test-new" }],
])("signInEventHandler should update username for %s provider", async (_provider, user, profile) => {
// Arrange
const db = createDb();
await createUserAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", ...user },
profile,
account: null,
});
// Assert
const dbUser = await db.query.users.findFirst({
where: eq(users.id, "1"),
columns: {
name: true,
},
});
expect(dbUser?.name).toBe("test-new");
});
test("signInEventHandler should set color-scheme cookie", async () => {
// Arrange
const db = createDb();
await createUserAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: undefined,
account: null,
});
// Assert
expect((await cookies()).set).toHaveBeenCalledWith(
colorSchemeCookieKey,
"dark",
expect.objectContaining({
path: "/",
}),
);
});
});
const createUserAsync = async (db: Database) =>
await db.insert(users).values({
id: "1",
name: "test",
colorScheme: "dark",
});
const createGroupAsync = async (db: Database, name = "test") =>
await db.insert(groups).values({
id: "1",
name,
position: 1,
});

View File

@@ -0,0 +1,59 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import { describe, expect, test } from "vitest";
import { createRedirectUri } from "../redirect";
describe("redirect", () => {
test("Callback should return http url when not defining protocol", () => {
// Arrange
const headers = new Map<string, string>([["x-forwarded-host", "localhost:3000"]]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("http://localhost:3000/api/auth/callback/oidc");
});
test("Callback should return https url when defining protocol", () => {
// Arrange
const headers = new Map<string, string>([
["x-forwarded-proto", "https"],
["x-forwarded-host", "localhost:3000"],
]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("https://localhost:3000/api/auth/callback/oidc");
});
test("Callback should return https url when defining protocol and host", () => {
// Arrange
const headers = new Map<string, string>([
["x-forwarded-proto", "https"],
["host", "something.else"],
]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("https://something.else/api/auth/callback/oidc");
});
test("Callback should return https url when defining protocol as http,https and host", () => {
// Arrange
const headers = new Map<string, string>([
["x-forwarded-proto", "http,https"],
["x-forwarded-host", "hello.world"],
]) as unknown as ReadonlyHeaders;
// Act
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
// Assert
expect(result).toBe("https://hello.world/api/auth/callback/oidc");
});
});

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { createSaltAsync, hashPasswordAsync } from "../security";
describe("createSalt should return a salt", () => {
it("should return a salt", async () => {
const result = await createSaltAsync();
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(25);
});
it("should return a different salt each time", async () => {
const result1 = await createSaltAsync();
const result2 = await createSaltAsync();
expect(result1).not.toEqual(result2);
});
});
describe("hashPassword should return a hash", () => {
it("should return a hash", async () => {
const password = "password";
const salt = await createSaltAsync();
const result = await hashPasswordAsync(password, salt);
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(55);
expect(result).not.toEqual(password);
});
it("should return a different hash each time", async () => {
const password = "password";
const password2 = "another password";
const salt = await createSaltAsync();
const result1 = await hashPasswordAsync(password, salt);
const result2 = await hashPasswordAsync(password2, salt);
expect(result1).not.toEqual(result2);
});
it("should return a different hash for the same password with different salts", async () => {
const password = "password";
const salt1 = await createSaltAsync();
const salt2 = await createSaltAsync();
const result1 = await hashPasswordAsync(password, salt1);
const result2 = await hashPasswordAsync(password, salt2);
expect(result1).not.toEqual(result2);
});
});

View File

@@ -0,0 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod/v4";
import { expireDateAfter, generateSessionToken } from "../session";
describe("expireDateAfter should calculate date after specified seconds", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it.each([
["2023-07-01T00:00:00Z", 60, "2023-07-01T00:01:00Z"], // 1 minute
["2023-07-01T00:00:00Z", 60 * 60, "2023-07-01T01:00:00Z"], // 1 hour
["2023-07-01T00:00:00Z", 60 * 60 * 24, "2023-07-02T00:00:00Z"], // 1 day
["2023-07-01T00:00:00Z", 60 * 60 * 24 * 30, "2023-07-31T00:00:00Z"], // 30 days
["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365, "2024-06-30T00:00:00Z"], // 1 year
["2023-07-01T00:00:00Z", 60 * 60 * 24 * 365 * 10, "2033-06-28T00:00:00Z"], // 10 years
])("should calculate date %s and after %i seconds to equal %s", (initialDate, seconds, expectedDate) => {
vi.setSystemTime(new Date(initialDate));
const result = expireDateAfter(seconds);
expect(result).toEqual(new Date(expectedDate));
});
});
describe("generateSessionToken should return a random UUID", () => {
it("should return a random UUID", () => {
const result = generateSessionToken();
expect(
z
.string()
.regex(/^[a-f0-9]+$/)
.safeParse(result).success,
).toBe(true);
});
it("should return a different token each time", () => {
const result1 = generateSessionToken();
const result2 = generateSessionToken();
expect(result1).not.toEqual(result2);
});
});

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["."],
"exclude": ["node_modules"]
}