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:
Meier Lukas
2024-07-20 22:23:58 +02:00
committed by GitHub
parent 5da74ca7e0
commit dc75ffb9e6
27 changed files with 1112 additions and 189 deletions

View File

@@ -43,6 +43,7 @@ COPY --from=builder /app/tasks-out/full/ .
COPY --from=builder /app/websocket-out/full/ .
COPY --from=builder /app/next-out/full/ .
COPY --from=builder /app/migration-out/full/ .
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION=true
@@ -83,5 +84,6 @@ COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS=credentials
CMD ["sh", "run.sh"]

View File

@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Alert, Button, PasswordInput, rem, Stack, TextInput } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button, Divider, PasswordInput, Stack, TextInput } from "@mantine/core";
import { signIn } from "@homarr/auth/client";
import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -14,65 +15,138 @@ import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
export const LoginForm = () => {
interface LoginFormProps {
providers: string[];
oidcClientName: string;
isOidcAutoLoginEnabled: boolean;
callbackUrl: string;
}
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user");
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
const [isPending, setIsPending] = useState(false);
const form = useZodForm(validation.user.signIn, {
initialValues: {
name: "",
password: "",
credentialType: "basic",
},
});
const handleSubmitAsync = async (values: z.infer<typeof validation.user.signIn>) => {
setIsLoading(true);
setError(undefined);
await signIn("credentials", {
...values,
redirect: false,
callbackUrl: "/",
})
.then(async (response) => {
if (!response?.ok || response.error) {
throw response?.error;
}
const credentialInputsVisible = providers.includes("credentials") || providers.includes("ldap");
showSuccessNotification({
title: t("action.login.notification.success.title"),
message: t("action.login.notification.success.message"),
});
await revalidatePathActionAsync("/");
router.push("/");
})
.catch((error: Error | string) => {
setIsLoading(false);
setError(error.toString());
showErrorNotification({
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
});
const onSuccess = useCallback(
async (response: Awaited<ReturnType<typeof signIn>>) => {
if (response && (!response.ok || response.error)) {
throw response.error;
}
showSuccessNotification({
title: t("action.login.notification.success.title"),
message: t("action.login.notification.success.message"),
});
};
// Redirect to the callback URL if the response is defined and comes from a credentials provider (ldap or credentials). oidc is redirected automatically.
if (response) {
await revalidatePathActionAsync("/");
router.push(callbackUrl);
}
},
[t, router, callbackUrl],
);
const onError = useCallback(() => {
setIsPending(false);
showErrorNotification({
title: t("action.login.notification.error.title"),
message: t("action.login.notification.error.message"),
autoClose: 10000,
});
}, [t]);
const signInAsync = useCallback(
async (provider: string, options?: Parameters<typeof signIn>[1]) => {
setIsPending(true);
await signIn(provider, {
...options,
redirect: false,
callbackUrl: new URL(callbackUrl, window.location.href).href,
})
.then(onSuccess)
.catch(onError);
},
[setIsPending, onSuccess, onError, callbackUrl],
);
const isLoginInProgress = useRef(false);
useEffect(() => {
if (isOidcAutoLoginEnabled && !isPending && !isLoginInProgress.current) {
isLoginInProgress.current = true;
void signInAsync("oidc");
}
}, [signInAsync, isOidcAutoLoginEnabled, isPending]);
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
<Button type="submit" fullWidth loading={isLoading}>
{t("action.login.label")}
</Button>
</Stack>
</form>
<Stack gap="lg">
{credentialInputsVisible && (
<>
<form onSubmit={form.onSubmit((credentials) => void signInAsync("credentials", credentials))}>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("name")} />
<PasswordInput label={t("field.password.label")} {...form.getInputProps("password")} />
{error && (
<Alert icon={<IconAlertTriangle size={rem(16)} />} color="red">
{error}
</Alert>
)}
{providers.includes("credentials") && (
<SubmitButton isPending={isPending} form={form} credentialType="basic">
{t("action.login.label")}
</SubmitButton>
)}
{providers.includes("ldap") && (
<SubmitButton isPending={isPending} form={form} credentialType="ldap">
{t("action.login.labelWith", { provider: "LDAP" })}
</SubmitButton>
)}
</Stack>
</form>
{providers.includes("oidc") && <Divider label="OIDC" labelPosition="center" />}
</>
)}
{providers.includes("oidc") && (
<Button fullWidth variant="light" onClick={async () => await signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button>
)}
</Stack>
</Stack>
);
};
interface SubmitButtonProps {
isPending: boolean;
form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
credentialType: "basic" | "ldap";
}
const SubmitButton = ({ isPending, form, credentialType, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().credentialType === credentialType;
return (
<Button
type="submit"
name={credentialType}
fullWidth
onClick={() => form.setFieldValue("credentialType", credentialType)}
loading={isPending && isCurrentProviderActive}
disabled={isPending && !isCurrentProviderActive}
>
{children}
</Button>
);
};
type FormType = z.infer<typeof validation.user.signIn>;

View File

@@ -1,11 +1,26 @@
import { redirect } from "next/navigation";
import { Card, Center, Stack, Text, Title } from "@mantine/core";
import { env } from "@homarr/auth/env.mjs";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
export default async function Login() {
interface LoginProps {
searchParams: {
redirectAfterLogin?: string;
};
}
export default async function Login({ searchParams }: LoginProps) {
const session = await auth();
if (session) {
redirect(searchParams.redirectAfterLogin ?? "/");
}
const t = await getScopedI18n("user.page.login");
return (
@@ -21,7 +36,12 @@ export default async function Login() {
</Text>
</Stack>
<Card bg="dark.8" w={64 * 6} maw="90vw">
<LoginForm />
<LoginForm
providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN}
callbackUrl={searchParams.redirectAfterLogin ?? "/"}
/>
</Card>
</Stack>
</Center>

View File

@@ -1,14 +1,33 @@
import type { NextRequest } from "next/server";
import { NextRequest } from "next/server";
import { createHandlers } from "@homarr/auth";
import { logger } from "@homarr/log";
export const GET = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.GET(req);
return await createHandlers(isCredentialsRequest(req)).handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = async (req: NextRequest) => {
return await createHandlers(isCredentialsRequest(req)).handlers.POST(req);
return await createHandlers(isCredentialsRequest(req)).handlers.POST(reqWithTrustedOrigin(req));
};
const isCredentialsRequest = (req: NextRequest) => {
return req.url.includes("credentials") && req.method === "POST";
};
/**
* This is a workaround to allow the authentication to work with behind a proxy.
* See https://github.com/nextauthjs/next-auth/issues/10928#issuecomment-2162893683
*/
const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
const proto = req.headers.get("x-forwarded-proto");
const host = req.headers.get("x-forwarded-host");
if (!proto || !host) {
logger.warn("Missing x-forwarded-proto or x-forwarded-host headers.");
return req;
}
const envOrigin = `${proto}://${host}`;
const { href, origin } = req.nextUrl;
logger.debug(`Rewriting origin from ${origin} to ${envOrigin}`);
return new NextRequest(href.replace(origin, envOrigin), req);
};

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

@@ -0,0 +1,5 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@homarr/db";
export const adapter = DrizzleAdapter(db);

View File

@@ -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",

View File

@@ -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,
});

View File

@@ -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";

View File

@@ -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.

View File

@@ -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",

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -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})`;
};

View 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;

View 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();
}
}

View 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);
});
};

View 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,
};
},
});

View 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();
});
});

View File

@@ -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();
});
});

View 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
View 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}`;
};

View File

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

View File

@@ -42,6 +42,7 @@ export default {
action: {
login: {
label: "Login",
labelWith: "Login with {provider}",
notification: {
success: {
title: "Login successful",

View File

@@ -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

59
pnpm-lock.yaml generated
View File

@@ -526,9 +526,21 @@ importers:
'@auth/drizzle-adapter':
specifier: ^1.4.1
version: 1.4.1
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/db':
specifier: workspace:^0.1.0
version: link:../db
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
'@t3-oss/env-nextjs':
specifier: ^0.10.1
version: 0.10.1(typescript@5.5.3)(zod@3.23.8)
@@ -538,6 +550,9 @@ importers:
cookies:
specifier: ^0.9.1
version: 0.9.1
ldapts:
specifier: 7.0.12
version: 7.0.12
next:
specifier: ^14.2.5
version: 14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8)
@@ -551,9 +566,6 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
devDependencies:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint
@@ -563,9 +575,6 @@ importers:
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
'@types/bcrypt':
specifier: 5.0.2
version: 5.0.2
@@ -2690,6 +2699,9 @@ packages:
resolution: {integrity: sha512-+OTrQULhuv1qOKE+0DC360sSDB6ad7opEKLGFcLlmLgM7D75qv6UThfnw1Rjh8inIlBSSCCu/co2BaJjgkkpAw==}
hasBin: true
'@types/asn1@0.2.4':
resolution: {integrity: sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2837,6 +2849,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/video.js@7.3.58':
resolution: {integrity: sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==}
@@ -4767,6 +4782,10 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
ldapts@7.0.12:
resolution: {integrity: sha512-orwgIejUi/ZyGah9y8jWZmFUg8Ci5M8WAv0oZjSf3MVuk1sRBdor9Qy1ttGHbYpWj96HXKFunQ8AYZ8WWGp17g==}
engines: {node: '>=18'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -5874,6 +5893,9 @@ packages:
streamx@2.18.0:
resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==}
strict-event-emitter-types@2.0.0:
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -6393,6 +6415,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -7735,6 +7761,10 @@ snapshots:
semver: 7.6.0
update-check: 1.5.4
'@types/asn1@0.2.4':
dependencies:
'@types/node': 20.14.11
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.24.7
@@ -7914,6 +7944,8 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/uuid@10.0.0': {}
'@types/video.js@7.3.58': {}
'@types/ws@8.5.11':
@@ -10270,6 +10302,17 @@ snapshots:
dependencies:
readable-stream: 2.3.8
ldapts@7.0.12:
dependencies:
'@types/asn1': 0.2.4
'@types/uuid': 10.0.0
asn1: 0.2.6
debug: 4.3.5
strict-event-emitter-types: 2.0.0
uuid: 9.0.1
transitivePeerDependencies:
- supports-color
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -11489,6 +11532,8 @@ snapshots:
optionalDependencies:
bare-events: 2.4.2
strict-event-emitter-types@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -12043,6 +12088,8 @@ snapshots:
uuid@8.3.2: {}
uuid@9.0.1: {}
v8-compile-cache-lib@3.0.1: {}
validate-npm-package-name@5.0.0:

View File

@@ -4,12 +4,40 @@
"**/.env"
],
"globalEnv": [
"DATABASE_URL",
"AUTH_DISCORD_ID",
"AUTH_DISCORD_SECRET",
"AUTH_REDIRECT_PROXY_URL",
"AUTH_LDAP_BASE",
"AUTH_LDAP_BIND_DN",
"AUTH_LDAP_BIND_PASSWORD",
"AUTH_LDAP_GROUP_CLASS",
"AUTH_LDAP_GROUP_FILTER_EXTRA_ARG",
"AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE",
"AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE",
"AUTH_LDAP_SEARCH_SCOPE",
"AUTH_LDAP_URI",
"AUTH_OIDC_CLIENT_ID",
"AUTH_OIDC_CLIENT_NAME",
"AUTH_OIDC_CLIENT_SECRET",
"AUTH_OIDC_ISSUER",
"AUTH_OIDC_SCOPE_OVERWRITE",
"AUTH_LDAP_USERNAME_ATTRIBUTE",
"AUTH_LDAP_USER_MAIL_ATTRIBUTE",
"AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG",
"AUTH_OIDC_AUTO_LOGIN",
"AUTH_PROVIDERS",
"AUTH_SECRET",
"AUTH_URL"
"CI",
"DB_URL",
"DB_HOST",
"DB_USER",
"DB_PASSWORD",
"DB_NAME",
"DB_PORT",
"DB_DRIVER",
"DOCKER_HOSTNAMES",
"DOCKER_PORTS",
"NODE_ENV",
"PORT",
"SKIP_ENV_VALIDATION",
"VERCEL_URL"
],
"ui": "stream",
"tasks": {