diff --git a/Dockerfile b/Dockerfile index 03cd2719d..6f994ff8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index 555862a51..fec07fd57 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -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(); + const [isPending, setIsPending] = useState(false); const form = useZodForm(validation.user.signIn, { initialValues: { name: "", password: "", + credentialType: "basic", }, }); - const handleSubmitAsync = async (values: z.infer) => { - 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>) => { + 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[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 ( -
void handleSubmitAsync(values))}> - - - - - -
+ + {credentialInputsVisible && ( + <> +
void signInAsync("credentials", credentials))}> + + + - {error && ( - } color="red"> - {error} - - )} + {providers.includes("credentials") && ( + + {t("action.login.label")} + + )} + + {providers.includes("ldap") && ( + + {t("action.login.labelWith", { provider: "LDAP" })} + + )} + +
+ {providers.includes("oidc") && } + + )} + + {providers.includes("oidc") && ( + + )} +
); }; + +interface SubmitButtonProps { + isPending: boolean; + form: ReturnType FormType>>; + credentialType: "basic" | "ldap"; +} + +const SubmitButton = ({ isPending, form, credentialType, children }: PropsWithChildren) => { + const isCurrentProviderActive = form.getValues().credentialType === credentialType; + + return ( + + ); +}; + +type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index 00de2f2f3..7823d050d 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -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() { - + diff --git a/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts b/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts index 7b15a8cba..0c17a094e 100644 --- a/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts +++ b/apps/nextjs/src/app/api/auth/[...nextauth]/route.ts @@ -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); +}; diff --git a/packages/auth/adapter.ts b/packages/auth/adapter.ts new file mode 100644 index 000000000..4c2929613 --- /dev/null +++ b/packages/auth/adapter.ts @@ -0,0 +1,5 @@ +import { DrizzleAdapter } from "@auth/drizzle-adapter"; + +import { db } from "@homarr/db"; + +export const adapter = DrizzleAdapter(db); diff --git a/packages/auth/configuration.ts b/packages/auth/configuration.ts index 290df5f10..0ee8ab302 100644 --- a/packages/auth/configuration.ts +++ b/packages/auth/configuration.ts @@ -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", diff --git a/packages/auth/env.mjs b/packages/auth/env.mjs index 2e7ddcad0..72f8fa396 100644 --- a/packages/auth/env.mjs +++ b/packages/auth/env.mjs @@ -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, }); diff --git a/packages/auth/index.ts b/packages/auth/index.ts index b8cbc55a1..78c3e7e88 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -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"; diff --git a/packages/auth/next.ts b/packages/auth/next.ts index 77b178d13..11b66c4cd 100644 --- a/packages/auth/next.ts +++ b/packages/auth/next.ts @@ -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. diff --git a/packages/auth/package.json b/packages/auth/package.json index 22f83474b..57e21a0a6 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -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", diff --git a/packages/auth/providers/credentials.ts b/packages/auth/providers/credentials.ts deleted file mode 100644 index ecc101078..000000000 --- a/packages/auth/providers/credentials.ts +++ /dev/null @@ -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[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; diff --git a/packages/auth/providers/credentials/authorization/basic-authorization.ts b/packages/auth/providers/credentials/authorization/basic-authorization.ts new file mode 100644 index 000000000..5113b83af --- /dev/null +++ b/packages/auth/providers/credentials/authorization/basic-authorization.ts @@ -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, +) => { + 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, + }; +}; diff --git a/packages/auth/providers/credentials/authorization/ldap-authorization.ts b/packages/auth/providers/credentials/authorization/ldap-authorization.ts new file mode 100644 index 000000000..e78c1663a --- /dev/null +++ b/packages/auth/providers/credentials/authorization/ldap-authorization.ts @@ -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, +) => { + 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})`; +}; diff --git a/packages/auth/providers/credentials/credentials-provider.ts b/packages/auth/providers/credentials/credentials-provider.ts new file mode 100644 index 000000000..2af81345a --- /dev/null +++ b/packages/auth/providers/credentials/credentials-provider.ts @@ -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[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; diff --git a/packages/auth/providers/credentials/ldap-client.ts b/packages/auth/providers/credentials/ldap-client.ts new file mode 100644 index 000000000..1c0119322 --- /dev/null +++ b/packages/auth/providers/credentials/ldap-client.ts @@ -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), + 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(/(? 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); + }); +}; diff --git a/packages/auth/providers/oidc/oidc-provider.ts b/packages/auth/providers/oidc/oidc-provider.ts new file mode 100644 index 000000000..32cc43968 --- /dev/null +++ b/packages/auth/providers/oidc/oidc-provider.ts @@ -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 => ({ + 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, + }; + }, +}); diff --git a/packages/auth/providers/test/basic-authorization.spec.ts b/packages/auth/providers/test/basic-authorization.spec.ts new file mode 100644 index 000000000..ac070d024 --- /dev/null +++ b/packages/auth/providers/test/basic-authorization.spec.ts @@ -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(); + }); +}); diff --git a/packages/auth/providers/test/credentials.spec.ts b/packages/auth/providers/test/credentials.spec.ts deleted file mode 100644 index 0f7c7c98b..000000000 --- a/packages/auth/providers/test/credentials.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/auth/providers/test/ldap-authorization.spec.ts b/packages/auth/providers/test/ldap-authorization.spec.ts new file mode 100644 index 000000000..6f023cdc7 --- /dev/null +++ b/packages/auth/providers/test/ldap-authorization.spec.ts @@ -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"); + }); +}); diff --git a/packages/auth/redirect.ts b/packages/auth/redirect.ts new file mode 100644 index 000000000..4f3848981 --- /dev/null +++ b/packages/auth/redirect.ts @@ -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}`; +}; diff --git a/packages/auth/test/redirect.spec.ts b/packages/auth/test/redirect.spec.ts new file mode 100644 index 000000000..89498483c --- /dev/null +++ b/packages/auth/test/redirect.spec.ts @@ -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([["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([ + ["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([ + ["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([ + ["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"); + }); +}); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 98e9e843e..9a448e5b7 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -42,6 +42,7 @@ export default { action: { login: { label: "Login", + labelWith: "Login with {provider}", notification: { success: { title: "Login successful", diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 9653b7991..fa618eadf 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bae6f0e55..8a1e1e65f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/turbo.json b/turbo.json index 2ef5dfb2d..9d7bd177c 100644 --- a/turbo.json +++ b/turbo.json @@ -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": {