Replace entire codebase with homarr-labs/homarr
This commit is contained in:
45
packages/auth/adapter.ts
Normal file
45
packages/auth/adapter.ts
Normal 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!,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
1
packages/auth/api-key/constants.ts
Normal file
1
packages/auth/api-key/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_KEY_HEADER_NAME = "ApiKey";
|
||||
76
packages/auth/api-key/get-api-key-session.ts
Normal file
76
packages/auth/api-key/get-api-key-session.ts
Normal 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);
|
||||
};
|
||||
2
packages/auth/api-key/index.ts
Normal file
2
packages/auth/api-key/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { getSessionFromApiKeyAsync } from "./get-api-key-session";
|
||||
export { API_KEY_HEADER_NAME } from "./constants";
|
||||
133
packages/auth/api-key/test/get-api-key-session.spec.ts
Normal file
133
packages/auth/api-key/test/get-api-key-session.spec.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
70
packages/auth/callbacks.ts
Normal file
70
packages/auth/callbacks.ts
Normal 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
2
packages/auth/client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { signIn, signOut, useSession, SessionProvider } from "next-auth/react";
|
||||
export * from "./permissions/integration-provider";
|
||||
116
packages/auth/configuration.ts
Normal file
116
packages/auth/configuration.ts
Normal 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
63
packages/auth/env.ts
Normal 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,
|
||||
});
|
||||
4
packages/auth/eslint.config.js
Normal file
4
packages/auth/eslint.config.js
Normal 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
189
packages/auth/events.ts
Normal 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
26
packages/auth/index.ts
Normal 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
11
packages/auth/next.ts
Normal 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);
|
||||
53
packages/auth/package.json
Normal file
53
packages/auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
packages/auth/permissions/board-permissions.ts
Normal file
47
packages/auth/permissions/board-permissions.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
2
packages/auth/permissions/index.ts
Normal file
2
packages/auth/permissions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./board-permissions";
|
||||
export * from "./integration-permissions";
|
||||
28
packages/auth/permissions/integration-permissions.ts
Normal file
28
packages/auth/permissions/integration-permissions.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
56
packages/auth/permissions/integration-provider.tsx
Normal file
56
packages/auth/permissions/integration-provider.tsx
Normal 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);
|
||||
};
|
||||
122
packages/auth/permissions/integration-query-permissions.ts
Normal file
122
packages/auth/permissions/integration-query-permissions.ts
Normal 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)),
|
||||
);
|
||||
};
|
||||
36
packages/auth/permissions/integrations-with-permissions.ts
Normal file
36
packages/auth/permissions/integrations-with-permissions.ts
Normal 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),
|
||||
}));
|
||||
};
|
||||
307
packages/auth/permissions/test/board-permissions.spec.ts
Normal file
307
packages/auth/permissions/test/board-permissions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
200
packages/auth/permissions/test/integration-permissions.spec.ts
Normal file
200
packages/auth/permissions/test/integration-permissions.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
9
packages/auth/providers/check-provider.ts
Normal file
9
packages/auth/providers/check-provider.ts
Normal 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);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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})`;
|
||||
};
|
||||
34
packages/auth/providers/credentials/credentials-provider.ts
Normal file
34
packages/auth/providers/credentials/credentials-provider.ts
Normal 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;
|
||||
90
packages/auth/providers/credentials/ldap-client.ts
Normal file
90
packages/auth/providers/credentials/ldap-client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
packages/auth/providers/empty/empty-provider.ts
Normal file
16
packages/auth/providers/empty/empty-provider.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
24
packages/auth/providers/filter-providers.ts
Normal file
24
packages/auth/providers/filter-providers.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
81
packages/auth/providers/oidc/oidc-provider.ts
Normal file
81
packages/auth/providers/oidc/oidc-provider.ts
Normal 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;
|
||||
};
|
||||
93
packages/auth/providers/test/basic-authorization.spec.ts
Normal file
93
packages/auth/providers/test/basic-authorization.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
309
packages/auth/providers/test/ldap-authorization.spec.ts
Normal file
309
packages/auth/providers/test/ldap-authorization.spec.ts
Normal 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
25
packages/auth/redirect.ts
Normal 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}`;
|
||||
};
|
||||
9
packages/auth/security.ts
Normal file
9
packages/auth/security.ts
Normal 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
4
packages/auth/server.ts
Normal 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
53
packages/auth/session.ts
Normal 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
1
packages/auth/shared.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./permissions";
|
||||
67
packages/auth/test/adapter.spec.ts
Normal file
67
packages/auth/test/adapter.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
144
packages/auth/test/callbacks.spec.ts
Normal file
144
packages/auth/test/callbacks.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
264
packages/auth/test/events.spec.ts
Normal file
264
packages/auth/test/events.spec.ts
Normal 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,
|
||||
});
|
||||
59
packages/auth/test/redirect.spec.ts
Normal file
59
packages/auth/test/redirect.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
47
packages/auth/test/security.spec.ts
Normal file
47
packages/auth/test/security.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
packages/auth/test/session.spec.ts
Normal file
44
packages/auth/test/session.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
8
packages/auth/tsconfig.json
Normal file
8
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user