mirror of
https://github.com/ajnart/homarr.git
synced 2026-03-06 20:30:57 +01:00
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:
@@ -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"]
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
5
packages/auth/adapter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
export const adapter = DrizzleAdapter(db);
|
||||
@@ -1,18 +1,20 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { adapter } from "./adapter";
|
||||
import { createSessionCallback, createSignInCallback } from "./callbacks";
|
||||
import { createCredentialsConfiguration } from "./providers/credentials";
|
||||
import { EmptyNextAuthProvider } from "./providers/empty";
|
||||
import { createCredentialsConfiguration } from "./providers/credentials/credentials-provider";
|
||||
import { EmptyNextAuthProvider } from "./providers/empty/empty-provider";
|
||||
import { filterProviders } from "./providers/filter-providers";
|
||||
import { OidcProvider } from "./providers/oidc/oidc-provider";
|
||||
import { createRedirectUri } from "./redirect";
|
||||
import { sessionMaxAgeInSeconds, sessionTokenCookieName } from "./session";
|
||||
|
||||
const adapter = DrizzleAdapter(db);
|
||||
|
||||
export const createConfiguration = (isCredentialsRequest: boolean) =>
|
||||
export const createConfiguration = (isCredentialsRequest: boolean, headers: ReadonlyHeaders | null) =>
|
||||
NextAuth({
|
||||
logger: {
|
||||
error: (code, ...message) => {
|
||||
@@ -28,11 +30,16 @@ export const createConfiguration = (isCredentialsRequest: boolean) =>
|
||||
},
|
||||
trustHost: true,
|
||||
adapter,
|
||||
providers: [Credentials(createCredentialsConfiguration(db)), EmptyNextAuthProvider()],
|
||||
providers: filterProviders([
|
||||
Credentials(createCredentialsConfiguration(db)),
|
||||
EmptyNextAuthProvider(),
|
||||
OidcProvider(headers),
|
||||
]),
|
||||
callbacks: {
|
||||
session: createSessionCallback(db),
|
||||
signIn: createSignInCallback(adapter, isCredentialsRequest),
|
||||
},
|
||||
redirectProxyUrl: createRedirectUri(headers, "/api/auth"),
|
||||
secret: "secret-is-not-defined-yet", // TODO: This should be added later
|
||||
session: {
|
||||
strategy: "database",
|
||||
|
||||
@@ -1,13 +1,95 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const trueStrings = ["1", "yes", "t", "true"];
|
||||
const falseStrings = ["0", "no", "f", "false"];
|
||||
|
||||
const supportedAuthProviders = ["credentials", "oidc", "ldap"];
|
||||
const authProvidersSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((providers) =>
|
||||
providers
|
||||
.replaceAll(" ", "")
|
||||
.toLowerCase()
|
||||
.split(",")
|
||||
.filter((provider) => {
|
||||
if (supportedAuthProviders.includes(provider)) return true;
|
||||
else if (!provider)
|
||||
console.log("One or more of the entries for AUTH_PROVIDER could not be parsed and/or returned null.");
|
||||
else console.log(`The value entered for AUTH_PROVIDER "${provider}" is incorrect.`);
|
||||
return false;
|
||||
}),
|
||||
)
|
||||
.default("credentials");
|
||||
|
||||
const booleanSchema = z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((value, ctx) => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (trueStrings.includes(normalized)) return true;
|
||||
if (falseStrings.includes(normalized)) return false;
|
||||
|
||||
throw new Error(`Invalid boolean value for ${ctx.path.join(".")}`);
|
||||
});
|
||||
|
||||
const skipValidation = Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION);
|
||||
const authProviders = skipValidation ? [] : authProvidersSchema.parse(process.env.AUTH_PROVIDERS);
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
AUTH_SECRET: process.env.NODE_ENV === "production" ? z.string().min(1) : z.string().min(1).optional(),
|
||||
AUTH_PROVIDERS: authProvidersSchema,
|
||||
...(authProviders.includes("oidc")
|
||||
? {
|
||||
AUTH_OIDC_ISSUER: z.string().url(),
|
||||
AUTH_OIDC_CLIENT_ID: z.string().min(1),
|
||||
AUTH_OIDC_CLIENT_SECRET: z.string().min(1),
|
||||
AUTH_OIDC_CLIENT_NAME: z.string().min(1).default("OIDC"),
|
||||
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
|
||||
}
|
||||
: {}),
|
||||
...(authProviders.includes("ldap")
|
||||
? {
|
||||
AUTH_LDAP_URI: z.string().url(),
|
||||
AUTH_LDAP_BIND_DN: z.string(),
|
||||
AUTH_LDAP_BIND_PASSWORD: z.string(),
|
||||
AUTH_LDAP_BASE: z.string(),
|
||||
AUTH_LDAP_SEARCH_SCOPE: z.enum(["base", "one", "sub"]).default("base"),
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: z.string().default("uid"),
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: z.string().default("mail"),
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: z.string().optional(),
|
||||
AUTH_LDAP_GROUP_CLASS: z.string().default("groupOfUniqueNames"),
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: z.string().default("member"),
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: z.string().default("dn"),
|
||||
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: z.string().optional(),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
client: {},
|
||||
runtimeEnv: {
|
||||
AUTH_SECRET: process.env.AUTH_SECRET,
|
||||
AUTH_PROVIDERS: process.env.AUTH_PROVIDERS,
|
||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
|
||||
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
|
||||
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
|
||||
AUTH_LDAP_GROUP_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG,
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
|
||||
AUTH_LDAP_SEARCH_SCOPE: process.env.AUTH_LDAP_SEARCH_SCOPE,
|
||||
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
|
||||
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
|
||||
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
|
||||
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||
AUTH_OIDC_ISSUER: process.env.AUTH_OIDC_ISSUER,
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: process.env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
|
||||
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
|
||||
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
|
||||
},
|
||||
skipValidation: Boolean(process.env.CI) || Boolean(process.env.SKIP_ENV_VALIDATION),
|
||||
skipValidation,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DefaultSession } from "next-auth";
|
||||
import { headers } from "next/headers";
|
||||
import type { DefaultSession } from "@auth/core/types";
|
||||
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
@@ -17,6 +18,6 @@ declare module "next-auth" {
|
||||
|
||||
export * from "./security";
|
||||
|
||||
export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest);
|
||||
export const createHandlers = (isCredentialsRequest: boolean) => createConfiguration(isCredentialsRequest, headers());
|
||||
|
||||
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cache } from "react";
|
||||
|
||||
import { createConfiguration } from "./configuration";
|
||||
|
||||
const { auth: defaultAuth } = createConfiguration(false);
|
||||
const { auth: defaultAuth } = createConfiguration(false, null);
|
||||
|
||||
/**
|
||||
* This is the main way to get session data for your RSCs.
|
||||
|
||||
@@ -23,11 +23,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@auth/core": "^0.34.1",
|
||||
"@auth/drizzle-adapter": "^1.4.1",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.0.12",
|
||||
"next": "^14.2.5",
|
||||
"next-auth": "5.0.0-beta.19",
|
||||
"react": "^18.3.1",
|
||||
@@ -37,8 +42,6 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.7.0",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type Credentials from "@auth/core/providers/credentials";
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
|
||||
|
||||
export const createCredentialsConfiguration = (db: Database) =>
|
||||
({
|
||||
type: "credentials",
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
name: {
|
||||
label: "Username",
|
||||
type: "text",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async authorize(credentials) {
|
||||
const data = await validation.user.signIn.parseAsync(credentials);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.name, data.name),
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
console.log(`user ${data.name} was not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(data.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
console.log(`password for user ${user.name} was incorrect`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`user ${user.name} successfully authorized`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
};
|
||||
},
|
||||
}) satisfies CredentialsConfiguration;
|
||||
@@ -0,0 +1,36 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { validation, z } from "@homarr/validation";
|
||||
|
||||
export const authorizeWithBasicCredentialsAsync = async (
|
||||
db: Database,
|
||||
credentials: z.infer<typeof validation.user.signIn>,
|
||||
) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.name, credentials.name),
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
logger.info(`user ${credentials.name} was not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(credentials.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
logger.warn(`password for user ${user.name} was incorrect`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`user ${user.name} successfully authorized`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { Adapter } from "@auth/core/adapters";
|
||||
import { CredentialsSignin } from "@auth/core/errors";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { validation } from "@homarr/validation";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
import { LdapClient } from "../ldap-client";
|
||||
|
||||
export const authorizeWithLdapCredentialsAsync = async (
|
||||
adapter: Adapter,
|
||||
credentials: z.infer<typeof validation.user.signIn>,
|
||||
) => {
|
||||
logger.info(`user ${credentials.name} is trying to log in using LDAP. Connecting to LDAP server...`);
|
||||
const client = new LdapClient();
|
||||
await client
|
||||
.bindAsync({
|
||||
distinguishedName: env.AUTH_LDAP_BIND_DN,
|
||||
password: env.AUTH_LDAP_BIND_PASSWORD,
|
||||
})
|
||||
.catch(() => {
|
||||
logger.error("Failed to connect to LDAP server");
|
||||
throw new CredentialsSignin();
|
||||
});
|
||||
|
||||
logger.info("Connected to LDAP server. Searching for user...");
|
||||
|
||||
const ldapUser = await client
|
||||
.searchAsync({
|
||||
base: env.AUTH_LDAP_BASE,
|
||||
options: {
|
||||
filter: createLdapUserFilter(credentials.name),
|
||||
scope: env.AUTH_LDAP_SEARCH_SCOPE,
|
||||
attributes: [env.AUTH_LDAP_USERNAME_ATTRIBUTE, env.AUTH_LDAP_USER_MAIL_ATTRIBUTE],
|
||||
},
|
||||
})
|
||||
.then((entries) => entries.at(0));
|
||||
|
||||
if (!ldapUser) {
|
||||
logger.warn(`User ${credentials.name} not found in LDAP`);
|
||||
throw new CredentialsSignin();
|
||||
}
|
||||
|
||||
// Validate email
|
||||
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
|
||||
|
||||
if (!mailResult.success) {
|
||||
logger.error(
|
||||
`User ${credentials.name} found but with invalid or non-existing Email. Not Supported: "${ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]}"`,
|
||||
);
|
||||
throw new CredentialsSignin();
|
||||
}
|
||||
|
||||
logger.info(`User ${credentials.name} found in LDAP. Logging in...`);
|
||||
|
||||
// Bind with user credentials to check if the password is correct
|
||||
const userClient = new LdapClient();
|
||||
await userClient
|
||||
.bindAsync({
|
||||
distinguishedName: ldapUser.dn,
|
||||
password: credentials.password,
|
||||
})
|
||||
.catch(() => {
|
||||
logger.warn(`Wrong credentials for user ${credentials.name}`);
|
||||
throw new CredentialsSignin();
|
||||
});
|
||||
await userClient.disconnectAsync();
|
||||
|
||||
logger.info(`User ${credentials.name} logged in successfully, retrieving user groups...`);
|
||||
|
||||
const userGroups = await client
|
||||
.searchAsync({
|
||||
base: env.AUTH_LDAP_BASE,
|
||||
options: {
|
||||
// For example, if the user is doejohn, the filter will be (&(objectClass=group)(uid=doejohn)) or (&(objectClass=group)(uid=doejohn)(sAMAccountType=1234))
|
||||
filter: `(&(objectClass=${env.AUTH_LDAP_GROUP_CLASS})(${
|
||||
env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE
|
||||
}=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE]})${env.AUTH_LDAP_GROUP_FILTER_EXTRA_ARG ?? ""})`,
|
||||
scope: env.AUTH_LDAP_SEARCH_SCOPE,
|
||||
attributes: ["cn"],
|
||||
},
|
||||
})
|
||||
.then((entries) => entries.map((entry) => entry.cn).filter((group): group is string => group !== undefined));
|
||||
|
||||
logger.info(`Found ${userGroups.length} groups for user ${credentials.name}.`);
|
||||
|
||||
await client.disconnectAsync();
|
||||
|
||||
// Create or update user in the database
|
||||
let user = await adapter.getUserByEmail?.(mailResult.data);
|
||||
|
||||
if (!user) {
|
||||
logger.info(`User ${credentials.name} not found in the database. Creating...`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
user = await adapter.createUser!({
|
||||
id: createId(),
|
||||
name: credentials.name,
|
||||
email: mailResult.data,
|
||||
emailVerified: new Date(), // assume email is verified
|
||||
});
|
||||
|
||||
logger.info(`User ${credentials.name} created successfully.`);
|
||||
}
|
||||
|
||||
if (user.name !== credentials.name) {
|
||||
logger.warn(`User ${credentials.name} found in the database but with different name. Updating...`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
user = await adapter.updateUser!({
|
||||
id: user.id,
|
||||
name: credentials.name,
|
||||
});
|
||||
|
||||
logger.info(`User ${credentials.name} updated successfully.`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
};
|
||||
};
|
||||
|
||||
const createLdapUserFilter = (username: string) => {
|
||||
if (env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG) {
|
||||
// For example, if the username is doejohn and the extra arg is (sAMAccountType=1234), the filter will be (&(uid=doejohn)(sAMAccountType=1234))
|
||||
return `(&(${env.AUTH_LDAP_USERNAME_ATTRIBUTE}=${username})${env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG})`;
|
||||
}
|
||||
|
||||
// For example, if the username is doejohn, the filter will be (uid=doejohn)
|
||||
return `(${env.AUTH_LDAP_USERNAME_ATTRIBUTE}=${username})`;
|
||||
};
|
||||
40
packages/auth/providers/credentials/credentials-provider.ts
Normal file
40
packages/auth/providers/credentials/credentials-provider.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type Credentials from "@auth/core/providers/credentials";
|
||||
|
||||
import type { Database } from "@homarr/db";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { adapter } from "../../adapter";
|
||||
import { authorizeWithBasicCredentialsAsync } from "./authorization/basic-authorization";
|
||||
import { authorizeWithLdapCredentialsAsync } from "./authorization/ldap-authorization";
|
||||
|
||||
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
|
||||
|
||||
export const createCredentialsConfiguration = (db: Database) =>
|
||||
({
|
||||
type: "credentials",
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
name: {
|
||||
label: "Username",
|
||||
type: "text",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
},
|
||||
isLdap: {
|
||||
label: "LDAP",
|
||||
type: "checkbox",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async authorize(credentials) {
|
||||
const data = await validation.user.signIn.parseAsync(credentials);
|
||||
|
||||
if (data.credentialType === "ldap") {
|
||||
return await authorizeWithLdapCredentialsAsync(adapter, data).catch(() => null);
|
||||
}
|
||||
|
||||
return await authorizeWithBasicCredentialsAsync(db, data);
|
||||
},
|
||||
}) satisfies CredentialsConfiguration;
|
||||
89
packages/auth/providers/credentials/ldap-client.ts
Normal file
89
packages/auth/providers/credentials/ldap-client.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { Entry, SearchOptions as LdapSearchOptions } from "ldapts";
|
||||
import { Client } from "ldapts";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import { env } from "../../env.mjs";
|
||||
|
||||
export interface BindOptions {
|
||||
distinguishedName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface SearchOptions {
|
||||
base: string;
|
||||
options: LdapSearchOptions;
|
||||
}
|
||||
|
||||
export class LdapClient {
|
||||
private client: Client;
|
||||
|
||||
constructor() {
|
||||
this.client = new Client({
|
||||
url: env.AUTH_LDAP_URI,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds to the LDAP server with the provided distinguishedName and password.
|
||||
* @param distinguishedName distinguishedName to bind to
|
||||
* @param password password to bind with
|
||||
* @returns void
|
||||
*/
|
||||
public async bindAsync({ distinguishedName, password }: BindOptions) {
|
||||
return await this.client.bind(distinguishedName, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for entries in the LDAP server.
|
||||
* @param base base DN to start the search
|
||||
* @param options search options
|
||||
* @returns list of search results
|
||||
*/
|
||||
public async searchAsync({ base, options }: SearchOptions) {
|
||||
const { searchEntries } = await this.client.search(base, options);
|
||||
|
||||
return searchEntries.map((entry) => {
|
||||
return {
|
||||
...objectEntries(entry)
|
||||
.map(([key, value]) => [key, LdapClient.convertEntryPropertyToString(value)] as const)
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {} as Record<string, string>),
|
||||
dn: LdapClient.getEntryDn(entry),
|
||||
} as {
|
||||
[key: string]: string;
|
||||
dn: string;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private static convertEntryPropertyToString(value: Entry[string]) {
|
||||
const firstValue = Array.isArray(value) ? (value[0] ?? "") : value;
|
||||
|
||||
if (firstValue instanceof Buffer) {
|
||||
return firstValue.toString("utf8");
|
||||
}
|
||||
return firstValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* dn is the only attribute returned with special characters formatted in UTF-8 (Bad for any letters with an accent)
|
||||
* Regex replaces any backslash followed by 2 hex characters with a percentage unless said backslash is preceded by another backslash.
|
||||
* That can then be processed by decodeURIComponent which will turn back characters to normal.
|
||||
* @param entry search entry from ldap
|
||||
* @returns normalized distinguishedName
|
||||
*/
|
||||
private static getEntryDn(entry: Entry) {
|
||||
try {
|
||||
return decodeURIComponent(entry.dn.replace(/(?<!\\)\\([0-9a-fA-F]{2})/g, "%$1"));
|
||||
} catch {
|
||||
throw new Error(`Cannot resolve distinguishedName for the entry ${entry.dn}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the client from the LDAP server.
|
||||
*/
|
||||
public async disconnectAsync() {
|
||||
await this.client.unbind();
|
||||
}
|
||||
}
|
||||
24
packages/auth/providers/filter-providers.ts
Normal file
24
packages/auth/providers/filter-providers.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Provider } from "next-auth/providers";
|
||||
|
||||
import { env } from "../env.mjs";
|
||||
|
||||
export const filterProviders = (providers: Exclude<Provider, () => unknown>[]) => {
|
||||
// During build this will be undefined, so we default to an empty array
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const authProviders = env.AUTH_PROVIDERS ?? [];
|
||||
|
||||
return providers.filter((provider) => {
|
||||
if (provider.id === "empty") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
provider.id === "credentials" &&
|
||||
["ldap", "credentials"].some((credentialType) => authProviders.includes(credentialType))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return authProviders.includes(provider.id);
|
||||
});
|
||||
};
|
||||
37
packages/auth/providers/oidc/oidc-provider.ts
Normal file
37
packages/auth/providers/oidc/oidc-provider.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import type { OIDCConfig } from "next-auth/providers";
|
||||
|
||||
import { env } from "../../env.mjs";
|
||||
import { createRedirectUri } from "../../redirect";
|
||||
|
||||
interface Profile {
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
preferred_username: string;
|
||||
email_verified: boolean;
|
||||
}
|
||||
|
||||
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
|
||||
id: "oidc",
|
||||
name: env.AUTH_OIDC_CLIENT_NAME,
|
||||
type: "oidc",
|
||||
clientId: env.AUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
|
||||
issuer: env.AUTH_OIDC_ISSUER,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: env.AUTH_OIDC_SCOPE_OVERWRITE,
|
||||
redirect_uri: createRedirectUri(headers, "/api/auth/callback/oidc"),
|
||||
},
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
// Use the name as the username if the preferred_username is an email address
|
||||
name: profile.preferred_username.includes("@") ? profile.name : profile.preferred_username,
|
||||
email: profile.email,
|
||||
};
|
||||
},
|
||||
});
|
||||
97
packages/auth/providers/test/basic-authorization.spec.ts
Normal file
97
packages/auth/providers/test/basic-authorization.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { authorizeWithBasicCredentialsAsync } from "../credentials/authorization/basic-authorization";
|
||||
|
||||
const defaultUserId = createId();
|
||||
|
||||
describe("authorizeWithBasicCredentials", () => {
|
||||
test("should authorize user with correct credentials", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithBasicCredentialsAsync(db, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ id: defaultUserId, name: "test" });
|
||||
});
|
||||
|
||||
test("should not authorize user with incorrect credentials", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithBasicCredentialsAsync(db, {
|
||||
name: "test",
|
||||
password: "wrong",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should not authorize user with incorrect username", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithBasicCredentialsAsync(db, {
|
||||
name: "wrong",
|
||||
password: "test",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should not authorize user when password is not set", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
await db.insert(users).values({
|
||||
id: defaultUserId,
|
||||
name: "test",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithBasicCredentialsAsync(db, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "basic",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { createCredentialsConfiguration } from "../credentials";
|
||||
|
||||
describe("Credentials authorization", () => {
|
||||
it("should authorize user with correct credentials", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "test",
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
salt,
|
||||
});
|
||||
const result = await createCredentialsConfiguration(db).authorize({
|
||||
name: "test",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: userId, name: "test" });
|
||||
});
|
||||
|
||||
const passwordsThatShouldNotAuthorize = ["wrong", "Test", "test ", " test", " test "];
|
||||
|
||||
passwordsThatShouldNotAuthorize.forEach((password) => {
|
||||
it(`should not authorize user with incorrect credentials (${password})`, async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "test",
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
salt,
|
||||
});
|
||||
const result = await createCredentialsConfiguration(db).authorize({
|
||||
name: "test",
|
||||
password,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not authorize user for not existing user", async () => {
|
||||
const db = createDb();
|
||||
const result = await createCredentialsConfiguration(db).authorize({
|
||||
name: "test",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
204
packages/auth/providers/test/ldap-authorization.spec.ts
Normal file
204
packages/auth/providers/test/ldap-authorization.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type { Adapter } from "@auth/core/adapters";
|
||||
import { CredentialsSignin } from "@auth/core/errors";
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import { users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { createSaltAsync, hashPasswordAsync } from "../../security";
|
||||
import { authorizeWithLdapCredentialsAsync } from "../credentials/authorization/ldap-authorization";
|
||||
import * as ldapClient from "../credentials/ldap-client";
|
||||
|
||||
vi.mock("../../env.mjs", () => ({
|
||||
env: {
|
||||
AUTH_LDAP_BIND_DN: "bind_dn",
|
||||
AUTH_LDAP_BIND_PASSWORD: "bind_password",
|
||||
AUTH_LDAP_USER_MAIL_ATTRIBUTE: "mail",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("authorizeWithLdapCredentials", () => {
|
||||
test("should fail when wrong ldap base credentials", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn(() => Promise.reject(new Error("bindAsync"))),
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
authorizeWithLdapCredentialsAsync(null as unknown as Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow(CredentialsSignin);
|
||||
});
|
||||
|
||||
test("should fail when user not found", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn(() => Promise.resolve()),
|
||||
searchAsync: vi.fn(() => Promise.resolve([])),
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
authorizeWithLdapCredentialsAsync(null as unknown as Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow(CredentialsSignin);
|
||||
});
|
||||
|
||||
test("should fail when user has invalid email", async () => {
|
||||
// Arrange
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn(() => Promise.resolve()),
|
||||
searchAsync: vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
dn: "test",
|
||||
mail: "test",
|
||||
},
|
||||
]),
|
||||
),
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
authorizeWithLdapCredentialsAsync(null as unknown as Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow(CredentialsSignin);
|
||||
});
|
||||
|
||||
test("should fail when user password is incorrect", async () => {
|
||||
// Arrange
|
||||
const searchSpy = vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
dn: "test",
|
||||
mail: "test@gmail.com",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn((props: ldapClient.BindOptions) =>
|
||||
props.distinguishedName === "test" ? Promise.reject(new Error("bindAsync")) : Promise.resolve(),
|
||||
),
|
||||
searchAsync: searchSpy,
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const act = () =>
|
||||
authorizeWithLdapCredentialsAsync(null as unknown as Adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow(CredentialsSignin);
|
||||
expect(searchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should authorize user with correct credentials and create user", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const adapter = DrizzleAdapter(db);
|
||||
const spy = vi.spyOn(ldapClient, "LdapClient");
|
||||
spy.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
bindAsync: vi.fn(() => Promise.resolve()),
|
||||
searchAsync: vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
dn: "test",
|
||||
mail: "test@gmail.com",
|
||||
},
|
||||
]),
|
||||
),
|
||||
disconnectAsync: vi.fn(),
|
||||
}) as unknown as ldapClient.LdapClient,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithLdapCredentialsAsync(adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.name).toBe("test");
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
where: eq(users.name, "test"),
|
||||
});
|
||||
expect(dbUser).toBeDefined();
|
||||
expect(dbUser?.id).toBe(result.id);
|
||||
expect(dbUser?.email).toBe("test@gmail.com");
|
||||
expect(dbUser?.emailVerified).not.toBeNull();
|
||||
});
|
||||
|
||||
test("should authorize user with correct credentials and update name", async () => {
|
||||
// Arrange
|
||||
const userId = createId();
|
||||
const db = createDb();
|
||||
const adapter = DrizzleAdapter(db);
|
||||
const salt = await createSaltAsync();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "test-old",
|
||||
salt,
|
||||
password: await hashPasswordAsync("test", salt),
|
||||
email: "test@gmail.com",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await authorizeWithLdapCredentialsAsync(adapter, {
|
||||
name: "test",
|
||||
password: "test",
|
||||
credentialType: "ldap",
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ id: userId, name: "test" });
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
expect(dbUser).toBeDefined();
|
||||
expect(dbUser?.id).toBe(userId);
|
||||
expect(dbUser?.name).toBe("test");
|
||||
expect(dbUser?.email).toBe("test@gmail.com");
|
||||
});
|
||||
});
|
||||
26
packages/auth/redirect.ts
Normal file
26
packages/auth/redirect.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
|
||||
/**
|
||||
* The redirect_uri is constructed to work behind a reverse proxy. It is constructed from the headers x-forwarded-proto and x-forwarded-host.
|
||||
* @param headers
|
||||
* @param pathname
|
||||
* @returns
|
||||
*/
|
||||
export const createRedirectUri = (headers: ReadonlyHeaders | null, pathname: string) => {
|
||||
if (!headers) {
|
||||
return pathname;
|
||||
}
|
||||
|
||||
let protocol = headers.get("x-forwarded-proto") ?? "http";
|
||||
|
||||
// @see https://support.glitch.com/t/x-forwarded-proto-contains-multiple-protocols/17219
|
||||
if (protocol.includes(",")) {
|
||||
protocol = protocol.includes("https") ? "https" : "http";
|
||||
}
|
||||
|
||||
const path = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
|
||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||
|
||||
return `${protocol}://${host}${path}`;
|
||||
};
|
||||
59
packages/auth/test/redirect.spec.ts
Normal file
59
packages/auth/test/redirect.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createRedirectUri } from "../redirect";
|
||||
|
||||
describe("redirect", () => {
|
||||
test("Callback should return http url when not defining protocol", () => {
|
||||
// Arrange
|
||||
const headers = new Map<string, string>([["x-forwarded-host", "localhost:3000"]]) as unknown as ReadonlyHeaders;
|
||||
|
||||
// Act
|
||||
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("http://localhost:3000/api/auth/callback/oidc");
|
||||
});
|
||||
|
||||
test("Callback should return https url when defining protocol", () => {
|
||||
// Arrange
|
||||
const headers = new Map<string, string>([
|
||||
["x-forwarded-proto", "https"],
|
||||
["x-forwarded-host", "localhost:3000"],
|
||||
]) as unknown as ReadonlyHeaders;
|
||||
|
||||
// Act
|
||||
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("https://localhost:3000/api/auth/callback/oidc");
|
||||
});
|
||||
|
||||
test("Callback should return https url when defining protocol and host", () => {
|
||||
// Arrange
|
||||
const headers = new Map<string, string>([
|
||||
["x-forwarded-proto", "https"],
|
||||
["host", "something.else"],
|
||||
]) as unknown as ReadonlyHeaders;
|
||||
|
||||
// Act
|
||||
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("https://something.else/api/auth/callback/oidc");
|
||||
});
|
||||
|
||||
test("Callback should return https url when defining protocol as http,https and host", () => {
|
||||
// Arrange
|
||||
const headers = new Map<string, string>([
|
||||
["x-forwarded-proto", "http,https"],
|
||||
["x-forwarded-host", "hello.world"],
|
||||
]) as unknown as ReadonlyHeaders;
|
||||
|
||||
// Act
|
||||
const result = createRedirectUri(headers, "/api/auth/callback/oidc");
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("https://hello.world/api/auth/callback/oidc");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export default {
|
||||
action: {
|
||||
login: {
|
||||
label: "Login",
|
||||
labelWith: "Login with {provider}",
|
||||
notification: {
|
||||
success: {
|
||||
title: "Login successful",
|
||||
|
||||
@@ -28,6 +28,7 @@ const initUserSchema = createUserSchema;
|
||||
const signInSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
credentialType: z.enum(["basic", "ldap"]),
|
||||
});
|
||||
|
||||
const registrationSchema = z
|
||||
|
||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
38
turbo.json
38
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": {
|
||||
|
||||
Reference in New Issue
Block a user