diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index f7b0d064ba..75897a628e 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -49,6 +49,7 @@ "idp": { "title": "Mit SSO anmelden", "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "orSignInWith": "oder melden Sie sich an mit", "signInWithApple": "Mit Apple anmelden", "signInWithGoogle": "Mit Google anmelden", "signInWithAzureAD": "Mit AzureAD anmelden", @@ -79,6 +80,13 @@ "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." } }, + "ldap": { + "title": "LDAP Login", + "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", + "username": "Benutzername", + "password": "Passwort", + "submit": "Weiter" + }, "mfa": { "verify": { "title": "Bestätigen Sie Ihre Identität", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 6ce32d9833..9f95403063 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -49,6 +49,7 @@ "idp": { "title": "Sign in with SSO", "description": "Select one of the following providers to sign in", + "orSignInWith": "or sign in with", "signInWithApple": "Sign in with Apple", "signInWithGoogle": "Sign in with Google", "signInWithAzureAD": "Sign in with AzureAD", @@ -79,6 +80,13 @@ "description": "You need to complete your registration by providing your email address and name." } }, + "ldap": { + "title": "LDAP Login", + "description": "Enter your LDAP credentials.", + "username": "Username", + "password": "Password", + "submit": "Continue" + }, "mfa": { "verify": { "title": "Verify your identity", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index b9a4140bce..fe88bb94c6 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -49,6 +49,7 @@ "idp": { "title": "Iniciar sesión con SSO", "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "orSignInWith": "o iniciar sesión con", "signInWithApple": "Iniciar sesión con Apple", "signInWithGoogle": "Iniciar sesión con Google", "signInWithAzureAD": "Iniciar sesión con AzureAD", @@ -79,6 +80,13 @@ "description": "Para completar el registro, debes establecer una contraseña." } }, + "ldap": { + "title": "Iniciar sesión con LDAP", + "description": "Introduce tus credenciales LDAP.", + "username": "Nombre de usuario", + "password": "Contraseña", + "submit": "Continuar" + }, "mfa": { "verify": { "title": "Verifica tu identidad", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 109ab15b52..1229a1a4c0 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -49,6 +49,7 @@ "idp": { "title": "Accedi con SSO", "description": "Seleziona uno dei seguenti provider per accedere", + "orSignInWith": "o accedi con", "signInWithApple": "Accedi con Apple", "signInWithGoogle": "Accedi con Google", "signInWithAzureAD": "Accedi con AzureAD", @@ -79,6 +80,13 @@ "description": "Completa la registrazione del tuo account." } }, + "ldap": { + "title": "Accedi con LDAP", + "description": "Inserisci le tue credenziali LDAP.", + "username": "Nome utente", + "password": "Password", + "submit": "Continua" + }, "mfa": { "verify": { "title": "Verifica la tua identità", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 0c664101ae..9fea6a19fa 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -49,6 +49,7 @@ "idp": { "title": "Zaloguj się za pomocą SSO", "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "orSignInWith": "lub zaloguj się przez", "signInWithApple": "Zaloguj się przez Apple", "signInWithGoogle": "Zaloguj się przez Google", "signInWithAzureAD": "Zaloguj się przez AzureAD", @@ -79,6 +80,13 @@ "description": "Ukończ rejestrację swojego konta." } }, + "ldap": { + "title": "Zaloguj się przez LDAP", + "description": "Wprowadź swoje dane logowania LDAP.", + "username": "Nazwa użytkownika", + "password": "Hasło", + "submit": "Kontynuuj" + }, "mfa": { "verify": { "title": "Zweryfikuj swoją tożsamość", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index c9fee46295..e745f1ae59 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -49,6 +49,7 @@ "idp": { "title": "Войти через SSO", "description": "Выберите одного из провайдеров для входа", + "orSignInWith": "или войти через", "signInWithApple": "Войти через Apple", "signInWithGoogle": "Войти через Google", "signInWithAzureAD": "Войти через AzureAD", @@ -79,6 +80,13 @@ "description": "Завершите регистрацию вашего аккаунта." } }, + "ldap": { + "title": "Войти через LDAP", + "description": "Введите ваши учетные данные LDAP.", + "username": "Имя пользователя", + "password": "Пароль", + "submit": "Продолжить" + }, "mfa": { "verify": { "title": "Подтвердите вашу личность", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 1601d7b7bd..5a9cb3a4eb 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -49,6 +49,7 @@ "idp": { "title": "使用 SSO 登录", "description": "选择以下提供商中的一个进行登录", + "orSignInWith": "或使用以下方式登录", "signInWithApple": "用 Apple 登录", "signInWithGoogle": "用 Google 登录", "signInWithAzureAD": "用 AzureAD 登录", @@ -79,6 +80,13 @@ "description": "完成您的账户注册。" } }, + "ldap": { + "title": "使用 LDAP 登录", + "description": "请输入您的 LDAP 凭据。", + "username": "用户名", + "password": "密码", + "submit": "继续" + }, "mfa": { "verify": { "title": "验证您的身份", diff --git a/apps/login/src/app/(login)/idp/ldap/page.tsx b/apps/login/src/app/(login)/idp/ldap/page.tsx new file mode 100644 index 0000000000..372c814525 --- /dev/null +++ b/apps/login/src/app/(login)/idp/ldap/page.tsx @@ -0,0 +1,56 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { LDAPUsernamePasswordForm } from "@/components/ldap-username-password-form"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + const { idpId, organization, link } = searchParams; + + if (!idpId) { + throw new Error("No idpId provided in searchParams"); + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + // return login failed if no linking or creation is allowed and no user was found + return ( + +
+

+ +

+

+ +

+ + +
+
+ ); +} diff --git a/apps/login/src/app/(login)/loginname/page.tsx b/apps/login/src/app/(login)/loginname/page.tsx index 859fbae085..6d8f209572 100644 --- a/apps/login/src/app/(login)/loginname/page.tsx +++ b/apps/login/src/app/(login)/loginname/page.tsx @@ -76,15 +76,17 @@ export default async function Page(props: { suffix={suffix} submit={submit} allowRegister={!!loginSettings?.allowRegister} - > - {identityProviders && loginSettings?.allowExternalIdp && ( + > + + {identityProviders && loginSettings?.allowExternalIdp && ( +
- )} - +
+ )} ); diff --git a/apps/login/src/app/(login)/password/page.tsx b/apps/login/src/app/(login)/password/page.tsx index 56b0ee4473..461c095157 100644 --- a/apps/login/src/app/(login)/password/page.tsx +++ b/apps/login/src/app/(login)/password/page.tsx @@ -11,7 +11,6 @@ import { getLoginSettings, } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; -import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { headers } from "next/headers"; export default async function Page(props: { @@ -95,10 +94,6 @@ export default async function Page(props: { requestId={requestId} organization={organization} // stick to "organization" as we still want to do user discovery based on the searchParams not the default organization, later the organization is determined by the found user loginSettings={loginSettings} - promptPasswordless={ - loginSettings?.passkeysType == PasskeysType.ALLOWED - } - isAlternative={alt === "true"} /> )} diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 0e181b76f1..cdb25bae65 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -24,6 +24,7 @@ import { } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { DEFAULT_CSP } from "../../../constants/csp"; @@ -191,6 +192,19 @@ export async function GET(request: NextRequest) { const origin = request.nextUrl.origin; const identityProviderType = identityProviders[0].type; + + if (identityProviderType === IdentityProviderType.LDAP) { + const ldapUrl = constructUrl(request, "/ldap"); + if (authRequest.id) { + ldapUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); + } + if (organization) { + ldapUrl.searchParams.set("organization", organization); + } + + return NextResponse.redirect(ldapUrl); + } + let provider = idpTypeToSlug(identityProviderType); const params = new URLSearchParams(); diff --git a/apps/login/src/components/ldap-username-password-form.tsx b/apps/login/src/components/ldap-username-password-form.tsx new file mode 100644 index 0000000000..f7ea9aea0e --- /dev/null +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { createNewSessionForLDAP } from "@/lib/server/idp"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + loginName: string; + password: string; +}; + +type Props = { + idpId: string; + link: boolean; +}; + +export function LDAPUsernamePasswordForm({ idpId, link }: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitUsernamePassword(values: Inputs) { + setError(""); + setLoading(true); + + const response = await createNewSessionForLDAP({ + idpId: idpId, + username: values.loginName, + password: values.password, + link: link, + }) + .catch(() => { + setError("Could not start LDAP flow"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response.redirect) { + return router.push(response.redirect); + } + } + + return ( +
+ + +
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ + ); +} diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index 0b7ecd5a9e..5a3b0b6496 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -208,26 +208,26 @@ export function LoginPasskey({ type="button" variant={ButtonVariants.Secondary} onClick={() => { - const params: any = { alt: "true" }; + const params = new URLSearchParams(); if (loginName) { - params.loginName = loginName; + params.append("loginName", loginName); } if (sessionId) { - params.sessionId = sessionId; + params.append("sessionId", sessionId); } if (requestId) { - params.requestId = requestId; + params.append("requestId", requestId); } if (organization) { - params.organization = organization; + params.append("organization", organization); } return router.push( - "/password?" + new URLSearchParams(params), // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped + "/password?" + params, // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped ); }} data-testid="password-button" diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index c65a4049f8..3cd455c69c 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -23,8 +23,6 @@ type Props = { loginName: string; organization?: string; requestId?: string; - isAlternative?: boolean; // whether password was requested as alternative auth method - promptPasswordless?: boolean; }; export function PasswordForm({ @@ -32,8 +30,6 @@ export function PasswordForm({ loginName, organization, requestId, - promptPasswordless, - isAlternative, }: Props) { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", diff --git a/apps/login/src/components/sign-in-with-idp.tsx b/apps/login/src/components/sign-in-with-idp.tsx index 9c1d339ffa..ec9cfb36f8 100644 --- a/apps/login/src/components/sign-in-with-idp.tsx +++ b/apps/login/src/components/sign-in-with-idp.tsx @@ -15,6 +15,7 @@ import { SignInWithGeneric } from "./idps/sign-in-with-generic"; import { SignInWithGithub } from "./idps/sign-in-with-github"; import { SignInWithGitlab } from "./idps/sign-in-with-gitlab"; import { SignInWithGoogle } from "./idps/sign-in-with-google"; +import { Translated } from "./translated"; export interface SignInWithIDPProps { children?: ReactNode; @@ -53,6 +54,7 @@ export function SignInWithIdp({ [IdentityProviderType.GITLAB]: SignInWithGitlab, [IdentityProviderType.GITLAB_SELF_HOSTED]: SignInWithGitlab, [IdentityProviderType.SAML]: SignInWithGeneric, + [IdentityProviderType.LDAP]: SignInWithGeneric, [IdentityProviderType.JWT]: SignInWithGeneric, }; @@ -75,6 +77,9 @@ export function SignInWithIdp({ return (
+

+ +

{!!identityProviders.length && identityProviders?.map(renderIDPButton)} {state?.error && (
diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index b16092bd9e..1dffade4b5 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -136,9 +136,6 @@ export function UsernameForm({ {error}
)} - -
{children}
-
diff --git a/apps/login/src/lib/idp.ts b/apps/login/src/lib/idp.ts index a62889efa3..d355f9ab56 100644 --- a/apps/login/src/lib/idp.ts +++ b/apps/login/src/lib/idp.ts @@ -24,6 +24,8 @@ export function idpTypeToSlug(idpType: IdentityProviderType) { return "oauth"; case IdentityProviderType.OIDC: return "oidc"; + case IdentityProviderType.LDAP: + return "ldap"; case IdentityProviderType.JWT: return "jwt"; default: diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index aaf5b77779..1925fe43d6 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -4,6 +4,7 @@ import { getLoginSettings, getUserByID, startIdentityProviderFlow, + startLDAPIdentityProviderFlow, } from "@/lib/zitadel"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -18,6 +19,13 @@ export async function redirectToIdp( prevState: RedirectToIdpState, formData: FormData, ): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + if (!host) { + return { error: "Could not get host" }; + } + const params = new URLSearchParams(); const linkOnly = formData.get("linkOnly") === "true"; @@ -30,44 +38,48 @@ export async function redirectToIdp( if (requestId) params.set("requestId", requestId); if (organization) params.set("organization", organization); + // redirect to LDAP page where username and password is requested + if (provider === "ldap") { + params.set("idpId", idpId); + redirect(`/idp/ldap?` + params.toString()); + } + const response = await startIDPFlow({ + serviceUrl, + host, idpId, successUrl: `/idp/${provider}/success?` + params.toString(), failureUrl: `/idp/${provider}/failure?` + params.toString(), }); - if (response && "error" in response && response?.error) { - return { error: response.error }; + if (!response) { + return { error: "Could not start IDP flow" }; } if (response && "redirect" in response && response?.redirect) { redirect(response.redirect); } + + return { error: "Unexpected response from IDP flow" }; } export type StartIDPFlowCommand = { + serviceUrl: string; + host: string; idpId: string; successUrl: string; failureUrl: string; }; -export async function startIDPFlow(command: StartIDPFlowCommand) { - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = _headers.get("host"); - - if (!host) { - return { error: "Could not get host" }; - } - +async function startIDPFlow(command: StartIDPFlowCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; return startIdentityProviderFlow({ - serviceUrl, + serviceUrl: command.serviceUrl, idpId: command.idpId, urls: { - successUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.successUrl}`, - failureUrl: `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}${command.failureUrl}`, + successUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.successUrl}`, + failureUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.failureUrl}`, }, }).then((response) => { if ( @@ -174,3 +186,58 @@ export async function createNewSessionFromIdpIntent( return { redirect: url }; } } + +type createNewSessionForLDAPCommand = { + username: string; + password: string; + idpId: string; + link: boolean; +}; + +export async function createNewSessionForLDAP( + command: createNewSessionForLDAPCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.username || !command.password) { + return { error: "No username or password provided" }; + } + + const response = await startLDAPIdentityProviderFlow({ + serviceUrl, + idpId: command.idpId, + username: command.username, + password: command.password, + }); + + if ( + !response || + response.nextStep.case !== "idpIntent" || + !response.nextStep.value + ) { + return { error: "Could not start LDAP identity provider flow" }; + } + + const { userId, idpIntentId, idpIntentToken } = response.nextStep.value; + + const params = new URLSearchParams({ + userId, + id: idpIntentId, + token: idpIntentToken, + }); + + if (command.link) { + params.set("link", "true"); + } + + return { + redirect: `/idp/ldap/success?` + params.toString(), + }; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index e148e7c0be..c5f72266b9 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -962,7 +962,6 @@ export async function startIdentityProviderFlow({ urls, }: { serviceUrl: string; - idpId: string; urls: RedirectURLsJson; }) { @@ -980,6 +979,34 @@ export async function startIdentityProviderFlow({ }); } +export async function startLDAPIdentityProviderFlow({ + serviceUrl, + idpId, + username, + password, +}: { + serviceUrl: string; + idpId: string; + username: string; + password: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.startIdentityProviderIntent({ + idpId, + content: { + case: "ldap", + value: { + username, + password, + }, + }, + }); +} + export async function getAuthRequest({ serviceUrl, authRequestId,