diff --git a/.github/custom-i18n.png b/.github/custom-i18n.png new file mode 100644 index 0000000000..2306e62f87 Binary files /dev/null and b/.github/custom-i18n.png differ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19468a4ccb..5c858cc03a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,9 +15,6 @@ on: jobs: quality: name: Ensure Quality - if: github.event_name == 'workflow_dispatch' || - (github.event_name == 'push' && github.repository_owner != 'zitadel') || - (github.event_name == 'pull_request' && github.repository_owner != 'zitadel') runs-on: ubuntu-22.04 timeout-minutes: 30 permissions: diff --git a/Makefile b/Makefile index c1280810bc..e7b863dc72 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCK export POSTGRES_TAG := postgres:17.0-alpine3.19 export GOLANG_TAG := golang:1.24-alpine export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest -export CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} login_help: @echo "Makefile for the login service" diff --git a/README.md b/README.md index b2f34b7525..c3601e666b 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ You can already use the current state, and extend it with your needs. - [x] Generic OIDC - [x] Generic OAuth - [x] Generic JWT - - [ ] LDAP - - [ ] SAML SP + - [x] LDAP + - [x] SAML SP - Multifactor Registration an Login - [x] Passkeys - [x] TOTP @@ -143,6 +143,13 @@ You can already use the current state, and extend it with your needs. You can find a more detailed documentation of the different pages [here](./apps/login/readme.md). +#### Custom translations + +The new login uses the [SettingsApi](https://zitadel.com/docs/apis/resources/settings_service_v2/settings-service-get-hosted-login-translation) to load custom translations. +Translations can be overriden at both the instance and organization levels. +To find the keys more easily, you can inspect the HTML and search for a `data-i18n-key` attribute, or look at the defaults in `/apps/login/locales/[locale].ts`. +![Custom Translations](.github/custom-i18n.png) + ## Tooling - [TypeScript](https://www.typescriptlang.org/) for static type checking 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/package.json b/apps/login/package.json index 49c06d8c3a..f498b912c2 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "pnpm next dev --turbopack", "test:unit": "pnpm vitest", + "test:unit:standalone": "pnpm test:unit", "test:unit:watch": "pnpm test:unit --watch", "lint": "pnpm exec next lint && pnpm exec prettier --check .", "lint:fix": "pnpm exec prettier --write .", 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)/saml-post/page.tsx b/apps/login/src/app/(login)/saml-post/page.tsx deleted file mode 100644 index d5765c8d56..0000000000 --- a/apps/login/src/app/(login)/saml-post/page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import { useEffect } from "react"; - -export default function SamlPost() { - const searchParams = useSearchParams(); - - const url = searchParams.get("url"); - const relayState = searchParams.get("RelayState"); - const samlResponse = searchParams.get("SAMLResponse"); - - useEffect(() => { - // Automatically submit the form after rendering - const form = document.getElementById("samlForm") as HTMLFormElement; - if (form) { - form.submit(); - } - }, []); - - if (!url || !relayState || !samlResponse) { - return ( -

Missing required parameters for SAML POST.

- ); - } - - return ( - - - - - Redirecting... - - -
- - -
-

Redirecting...

- - - ); -} diff --git a/apps/login/src/app/(login)/saml-post/route.ts b/apps/login/src/app/(login)/saml-post/route.ts new file mode 100644 index 0000000000..f2834f3884 --- /dev/null +++ b/apps/login/src/app/(login)/saml-post/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get("url"); + const relayState = searchParams.get("RelayState"); + const samlResponse = searchParams.get("SAMLResponse"); + + if (!url || !relayState || !samlResponse) { + return new NextResponse("Missing required parameters", { status: 400 }); + } + + // Respond with an HTML form that auto-submits via POST + const html = ` + + +
+ + + +
+ + + `; + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); +} 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..2f9824dff2 --- /dev/null +++ b/apps/login/src/components/ldap-username-password-form.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { createNewSessionForLDAP } from "@/lib/server/idp"; +import { useTranslations } from "next-intl"; +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 t = useTranslations("ldap"); + + 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, diff --git a/apps/login/turbo.json b/apps/login/turbo.json index 030d45d581..bc63a2dbc4 100644 --- a/apps/login/turbo.json +++ b/apps/login/turbo.json @@ -14,6 +14,7 @@ "test:unit": { "dependsOn": ["@zitadel/client#build"] }, + "test:unit:standalone": {}, "test:watch": { "dependsOn": ["@zitadel/client#build"] } diff --git a/docker-bake.hcl b/docker-bake.hcl index b5f3ea7406..3d98c09b2c 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -82,7 +82,7 @@ target "login-client" { } variable "LOGIN_CORE_MOCK_TAG" { - default = "core-mock:local" + default = "login-core-mock:local" } # the core-mock context must not be overwritten, so we don't prefix it with login-. diff --git a/dockerfiles/login-client.Dockerfile b/dockerfiles/login-client.Dockerfile index 783bc92062..107c9e1846 100644 --- a/dockerfiles/login-client.Dockerfile +++ b/dockerfiles/login-client.Dockerfile @@ -1,7 +1,8 @@ FROM typescript-proto-client AS login-client COPY packages/zitadel-tsconfig packages/zitadel-tsconfig COPY packages/zitadel-client/package.json ./packages/zitadel-client/ +RUN ls -la RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --workspace-root --filter ./packages/zitadel-client -COPY packages/zitadel-client packages/zitadel-client +COPY packages/zitadel-client ./packages/zitadel-client RUN pnpm build:client:standalone diff --git a/dockerfiles/login-test-unit.Dockerfile b/dockerfiles/login-test-unit.Dockerfile index b0cfdbd086..d456a4fac4 100644 --- a/dockerfiles/login-test-unit.Dockerfile +++ b/dockerfiles/login-test-unit.Dockerfile @@ -3,4 +3,4 @@ COPY apps/login/package.json ./apps/login/ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --workspace-root --filter ./apps/login COPY apps/login ./apps/login -RUN cd apps/login && pnpm test:unit +RUN pnpm test:unit:standalone diff --git a/package.json b/package.json index 8b33aee194..ce844c4b2c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start": "pnpm exec turbo run start", "start:built": "pnpm exec turbo run start:built", "test:unit": "pnpm exec turbo run test:unit -- --passWithNoTests", + "test:unit:standalone": "pnpm exec turbo run test:unit:standalone -- --passWithNoTests", "test:integration": "cd apps/login-test-integration && pnpm test:integration", "test:integration:setup": "NODE_ENV=test pnpm exec turbo run test:integration:setup", "test:acceptance": "cd apps/login-test-acceptance && pnpm test:acceptance",