Replace entire codebase with homarr-labs/homarr
This commit is contained in:
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"] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user