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:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user