feat: add ldap and oidc sso (#500)
* wip: sso * feat: add ldap client and provider * feat: implement login form * feat: finish sso * fix: lint and format issue * chore: address pull request feedback * fix: build not working * fix: oidc is redirected to internal docker container hostname * fix: build not working * refactor: migrate to ldapts * fix: format and frozen lock file * fix: deepsource issues * fix: unit tests for ldap authorization not working * refactor: remove unnecessary args from dockerfile * chore: address pull request feedback * fix: use console instead of logger in auth env.mjs * fix: default value for auth provider of wrong type * fix: broken lock file * fix: format issue
This commit is contained in:
5
packages/auth/adapter.ts
Normal file
5
packages/auth/adapter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
export const adapter = DrizzleAdapter(db);
|
||||
@@ -1,18 +1,20 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { adapter } from "./adapter";
|
||||
import { createSessionCallback, createSignInCallback } from "./callbacks";
|
||||
import { createCredentialsConfiguration } from "./providers/credentials";
|
||||
import { EmptyNextAuthProvider } from "./providers/empty";
|
||||
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
|
||||
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
||||
import { filterProviders } from "./providers/filter-providers";
|
||||
import { OidcProvider } from "./providers/oidc/oidc-provider";
|
||||
import { createRedirectUri } from "./redirect";
|
||||
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
|
||||
|
||||
const adapter = DrizzleAdapter(db);
|
||||
|
||||
export const createConfiguration = (isCredentialsRequest: boolean) =>
|
||||
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
|
||||
NextAuth({
|
||||
logger: {
|
||||
error: (code, ...message) => {
|
||||
@@ -28,11 +30,16 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
|
||||
},
|
||||
trustHost: true,
|
||||
adapter,
|
||||
providers: [Credentials(createCredentialsConfiguration(db)), EmptyNextAuthProvider()],
|
||||
providers: filterProviders([
|
||||
Credentials(createCredentialsConfiguration(db)),
|
||||
EmptyNextAuthProvider(),
|
||||
OidcProvider(headers),
|
||||
]),
|
||||
callbacks: {
|
||||
session: createSessionCallback(db),
|
||||
signIn: createSignInCallback(adapter, isCredentialsRequest),
|
||||
},
|
||||
redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
|
||||
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
||||
session: {
|
||||
strategy: "database",
|
||||
|
||||
@@ -1,13 +1,95 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const trueStrings = ["1", "yes", "t", "true"];
|
||||
const falseStrings = ["0", "no", "f", "false"];
|
||||
|
||||
const supportedAuthProviders = ["credentials", "oidc", "ldap"];
|
||||
const authProvidersSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((providers) =>
|
||||
providers
|
||||
.replaceAll(" ", "")
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.filter((provider) => {
|
||||
if (supportedAuthProviders.includes(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 booleanSchema = z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((value, ctx) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (trueStrings.includes(normalized)) return true;
|
||||
if (falseStrings.includes(normalized)) return false;
|
||||
|
||||
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
|
||||
});
|
||||
|
||||
const skipValidation = Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION);
|
||||
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
|
||||
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: booleanSchema,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
|
||||
}
|
||||
: {}),
|
||||
...(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(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
client: {},
|
||||
runtimeEnv: {
|
||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
|
||||
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
|
||||
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
|
||||
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG,
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
|
||||
AUTH_LDAP_SEARCH_SCOPE: process.env.AUTH_LDAP_SEARCH_SCOPE,
|
||||
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
|
||||
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
|
||||
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
|
||||
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
|
||||
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
|
||||
},
|
||||
skipValidation: Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION),
|
||||
skipValidation,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DefaultSession } from "next-auth";
|
||||
import { headers } from "next/headers";
|
||||
import type { DefaultSession } from "@auth/core/types";
|
||||
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
@@ -17,6 +18,6 @@ declare module "next-auth" {
|
||||
|
||||
export * from "./security";
|
||||
|
||||
export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest);
|
||||
export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest, headers());
|
||||
|
||||
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cache } from "react";
|
||||
|
||||
import { createConfiguration } from "./configuration";
|
||||
|
||||
const { auth: defaultAuth } = createConfiguration(false);
|
||||
const { auth: defaultAuth } = createConfiguration(false, null);
|
||||
|
||||
/**
|
||||
* This is the main way to get session data for your RSCs.
|
||||
|
||||
@@ -23,11 +23,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@auth/core": "^0.34.1",
|
||||
"@auth/drizzle-adapter": "^1.4.1",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.0.12",
|
||||
"next": "^14.2.5",
|
||||
"next-auth": "5.0.0-beta.19",
|
||||
"react": "^18.3.1",
|
||||
@@ -37,8 +42,6 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.7.0",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type Credentials from "@auth/core/providers/credentials";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
|
||||
|
||||
export const createCredentialsConfiguration = (db: Database) =>
|
||||
({
|
||||
type: "credentials",
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
name: {
|
||||
label: "Username",
|
||||
type: "text",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async authorize(credentials) {
|
||||
const data = await validation.user.signIn.parseAsync(credentials);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.name, data.name),
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
console.log(`user ${data.name} was not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(data.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
console.log(`password for user ${user.name} was incorrect`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`user ${user.name} successfully authorized`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
};
|
||||
},
|
||||
}) satisfies CredentialsConfiguration;
|
||||
@@ -0,0 +1,36 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
|
||||
export const authorizeWithBasicCredentialsAsync = async (
|
||||
db: Database,
|
||||
credentials: z.infer<typeof validation.user.signIn>,
|
||||
) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.name, credentials.name),
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
logger.info(`user ${credentials.name} was not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
logger.warn(`password for user ${user.name} was incorrect`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`user ${user.name} successfully authorized`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Adapter } from "@auth/core/adapters";
|
||||
import { CredentialsSignin } from "@auth/core/errors";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { LdapClient } from "../ldap-client";
|
||||
|
||||
export const authorizeWithLdapCredentialsAsync = async (
|
||||
adapter: Adapter,
|
||||
credentials: z.infer<typeof validation.user.signIn>,
|
||||
) => {
|
||||
logger.info(`user ${credentials.name} is trying to log in using LDAP. Connecting to LDAP server...`);
|
||||
const client = new LdapClient();
|
||||
await client
|
||||
.bindAsync({
|
||||
distinguishedName: env.AUTH_LDAP_BIND_DN,
|
||||
password: env.AUTH_LDAP_BIND_PASSWORD,
|
||||
})
|
||||
.catch(() => {
|
||||
logger.error("Failed to connect to LDAP server");
|
||||
throw new CredentialsSignin();
|
||||
});
|
||||
|
||||
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) => entries.at(0));
|
||||
|
||||
if (!ldapUser) {
|
||||
logger.warn(`User ${credentials.name} not found in LDAP`);
|
||||
throw new CredentialsSignin();
|
||||
}
|
||||
|
||||
// Validate email
|
||||
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
|
||||
|
||||
if (!mailResult.success) {
|
||||
logger.error(
|
||||
`User ${credentials.name} found but with invalid or non-existing Email. Not Supported: "${ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]}"`,
|
||||
);
|
||||
throw new CredentialsSignin();
|
||||
}
|
||||
|
||||
logger.info(`User ${credentials.name} found in LDAP. Logging in...`);
|
||||
|
||||
// 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 ${credentials.name}`);
|
||||
throw new CredentialsSignin();
|
||||
});
|
||||
await userClient.disconnectAsync();
|
||||
|
||||
logger.info(`User ${credentials.name} logged in successfully, retrieving user groups...`);
|
||||
|
||||
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(`Found ${userGroups.length} groups for user ${credentials.name}.`);
|
||||
|
||||
await client.disconnectAsync();
|
||||
|
||||
// Create or update user in the database
|
||||
let user = await adapter.getUserByEmail?.(mailResult.data);
|
||||
|
||||
if (!user) {
|
||||
logger.info(`User ${credentials.name} not found in the database. Creating...`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
user = await adapter.createUser!({
|
||||
id: createId(),
|
||||
name: credentials.name,
|
||||
email: mailResult.data,
|
||||
emailVerified: new Date(), // assume email is verified
|
||||
});
|
||||
|
||||
logger.info(`User ${credentials.name} created successfully.`);
|
||||
}
|
||||
|
||||
if (user.name !== credentials.name) {
|
||||
logger.warn(`User ${credentials.name} found in the database but with different name. Updating...`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
user = await adapter.updateUser!({
|
||||
id: user.id,
|
||||
name: credentials.name,
|
||||
});
|
||||
|
||||
logger.info(`User ${credentials.name} updated successfully.`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
};
|
||||
};
|
||||
|
||||
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})`;
|
||||
};
|
||||
40
packages/auth/providers/credentials/credentials-provider.ts
Normal file
40
packages/auth/providers/credentials/credentials-provider.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type Credentials from "@auth/core/providers/credentials";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { adapter } from "../../adapter";
|
||||
import { authorizeWithBasicCredentialsAsync } from "./authorization/basic-authorization";
|
||||
import { authorizeWithLdapCredentialsAsync } from "./authorization/ldap-authorization";
|
||||
|
||||
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
|
||||
|
||||
export const createCredentialsConfiguration = (db: Database) =>
|
||||
({
|
||||
type: "credentials",
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
name: {
|
||||
label: "Username",
|
||||
type: "text",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
},
|
||||
isLdap: {
|
||||
label: "LDAP",
|
||||
type: "checkbox",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async authorize(credentials) {
|
||||
const data = await validation.user.signIn.parseAsync(credentials);
|
||||
|
||||
if (data.credentialType === "ldap") {
|
||||
return await authorizeWithLdapCredentialsAsync(adapter, data).catch(() => null);
|
||||
}
|
||||
|
||||
return await authorizeWithBasicCredentialsAsync(db, data);
|
||||
},
|
||||
}) satisfies CredentialsConfiguration;
|
||||
89
packages/auth/providers/credentials/ldap-client.ts
Normal file
89
packages/auth/providers/credentials/ldap-client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Entry, SearchOptions as LdapSearchOptions } from "ldapts";
|
||||
import { Client } from "ldapts";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import { env } from "../../env.mjs";
|
||||
|
||||
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 (firstValue instanceof Buffer) {
|
||||
return firstValue.toString("utf8");
|
||||
}
|
||||
return firstValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
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.mjs";
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
37
packages/auth/providers/oidc/oidc-provider.ts
Normal file
37
packages/auth/providers/oidc/oidc-provider.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import type { OIDCConfig } from "next-auth/providers";
|
||||
|
||||
import { env } from "../../env.mjs";
|
||||
import { createRedirectUri } from "../../redirect";
|
||||
|
||||
interface Profile {
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
preferred_username: string;
|
||||
email_verified: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
redirect_uri: createRedirectUri(headers, "/api/auth/callback/oidc"),
|
||||
},
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
// Use the name as the username if the preferred_username is an email address
|
||||
name: profile.preferred_username.includes("@") ? profile.name : profile.preferred_username,
|
||||
email: profile.email,
|
||||
};
|
||||
},
|
||||
});
|
||||
97
packages/auth/providers/test/basic-authorization.spec.ts
Normal file
97
packages/auth/providers/test/basic-authorization.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
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",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// 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",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// 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",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// 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",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { createCredentialsConfiguration } from "../credentials";
|
||||
|
||||
describe("Credentials authorization", () => {
|
||||
it("should authorize user with correct credentials", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "test",
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
salt,
|
||||
});
|
||||
const result = await createCredentialsConfiguration(db).authorize({
|
||||
name: "test",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: userId, name: "test" });
|
||||
});
|
||||
|
||||
const passwordsThatShouldNotAuthorize = ["wrong", "Test", "test ", " test", " test "];
|
||||
|
||||
passwordsThatShouldNotAuthorize.forEach((password) => {
|
||||
it(`should not authorize user with incorrect credentials (${password})`, async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "test",
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
salt,
|
||||
});
|
||||
const result = await createCredentialsConfiguration(db).authorize({
|
||||
name: "test",
|
||||
password,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not authorize user for not existing user", async () => {
|
||||
const db = createDb();
|
||||
const result = await createCredentialsConfiguration(db).authorize({
|
||||
name: "test",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
204
packages/auth/providers/test/ldap-authorization.spec.ts
Normal file
204
packages/auth/providers/test/ldap-authorization.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Adapter } from "@auth/core/adapters";
|
||||
import { CredentialsSignin } from "@auth/core/errors";
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
|
||||
import * as ldapClient from "../credentials/ldap-client";
|
||||
|
||||
vi.mock("../../env.mjs", () => ({
|
||||
env: {
|
||||
AUTH_LDAP_BIND_DN: "bind_dn",
|
||||
AUTH_LDAP_BIND_PASSWORD: "bind_password",
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("authorizeWithLdapCredentials", () => {
|
||||
test("should fail when wrong ldap base credentials", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn(() => Promise.reject(new Error("bindAsync"))),
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
authorizeWithLdapCredentialsAsync(null as unknown as Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow(CredentialsSignin);
|
||||
});
|
||||
|
||||
test("should fail when user not found", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn(() => Promise.resolve()),
|
||||
searchAsync: vi.fn(() => Promise.resolve([])),
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
authorizeWithLdapCredentialsAsync(null as unknown as Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// 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(
|
||||
() =>
|
||||
({
|
||||
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 Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// 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(
|
||||
() =>
|
||||
({
|
||||
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 Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// 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 adapter = DrizzleAdapter(db);
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
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(adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.name).toBe("test");
|
||||
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();
|
||||
});
|
||||
|
||||
test("should authorize user with correct credentials and update name", async () => {
|
||||
// Arrange
|
||||
const userId = createId();
|
||||
const db = createDb();
|
||||
const adapter = DrizzleAdapter(db);
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "test-old",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
email: "test@gmail.com",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithLdapCredentialsAsync(adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ id: userId, name: "test" });
|
||||
|
||||
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");
|
||||
expect(dbUser?.email).toBe("test@gmail.com");
|
||||
});
|
||||
});
|
||||
26
packages/auth/redirect.ts
Normal file
26
packages/auth/redirect.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
if (!headers) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
let protocol = headers.get("x-forwarded-proto") ?? "http";
|
||||
|
||||
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
|
||||
if (protocol.includes(",")) {
|
||||
protocol = protocol.includes("https") ? "https" : "http";
|
||||
}
|
||||
|
||||
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
|
||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||
|
||||
return `${protocol}://${host}${path}`;
|
||||
};
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export default {
|
||||
action: {
|
||||
login: {
|
||||
label: "Login",
|
||||
labelWith: "Login with {provider}",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Login successful",
|
||||
|
||||
@@ -28,6 +28,7 @@ const initUserSchema = createUserSchema;
|
||||
const signInSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
credentialType: z.enum(["basic", "ldap"]),
|
||||
});
|
||||
|
||||
const registrationSchema = z
|
||||
|
||||
Reference in New Issue
Block a user