diff --git a/acceptance/docker-compose.yaml b/acceptance/docker-compose.yaml new file mode 100644 index 0000000000..a68a435e83 --- /dev/null +++ b/acceptance/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + zitadel: + user: "${ZITADEL_DEV_UID}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + extra_hosts: + - "localhost:host-gateway" + + db: + restart: "always" + image: postgres:17.0-alpine3.19 + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - 5432:5432 + + wait_for_zitadel: + image: curlimages/curl:8.00.1 + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + setup: + user: "${ZITADEL_DEV_UID}" + container_name: setup + image: acceptance-setup:latest + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /apps/login/.env.local + WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + volumes: + - "./pat:/pat" + - "../apps/login:/apps/login" + - "../acceptance/tests:/acceptance/tests" + depends_on: + wait_for_zitadel: + condition: "service_completed_successfully" + + sink: + image: golang:1.24-alpine + container_name: sink + command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification' + ports: + - 3333:3333 + volumes: + - "./sink:/sink" + depends_on: + setup: + condition: "service_completed_successfully" diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 691bfa6f56..b7a525858c 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -28,6 +28,6 @@ declare namespace NodeJS { * Optional: custom request headers to be added to every request * Split by comma, key value pairs separated by colon */ - CUSTOM_REQUEST_HEADERS: string; + CUSTOM_REQUEST_HEADERS?: string; } } diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index cdb25bae65..db67efa229 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -217,7 +217,7 @@ export async function GET(request: NextRequest) { params.set("organization", organization); } - return startIdentityProviderFlow({ + let url: string | null = await startIdentityProviderFlow({ serviceUrl, idpId, urls: { @@ -228,14 +228,21 @@ export async function GET(request: NextRequest) { `${origin}/idp/${provider}/failure?` + new URLSearchParams(params), }, - }).then((resp) => { - if ( - resp.nextStep.value && - typeof resp.nextStep.value === "string" - ) { - return NextResponse.redirect(resp.nextStep.value); - } }); + + if (!url) { + return NextResponse.json( + { error: "Could not start IDP flow" }, + { status: 500 }, + ); + } + + if (url.startsWith("/")) { + // if the url is a relative path, construct the absolute url + url = constructUrl(request, url).toString(); + } + + return NextResponse.redirect(url); } } } diff --git a/apps/login/src/i18n/request.ts b/apps/login/src/i18n/request.ts index 271d370f7c..9e5e37e231 100644 --- a/apps/login/src/i18n/request.ts +++ b/apps/login/src/i18n/request.ts @@ -16,7 +16,7 @@ export default getRequestConfig(async () => { const { serviceUrl } = getServiceUrlFromHeaders(_headers); const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware - console.log("i18nOrganization:", i18nOrganization); + let translations: JsonObject | {} = {}; try { const i18nJSON = await getHostedLoginTranslation({ diff --git a/apps/login/src/lib/self.ts b/apps/login/src/lib/self.ts index 7375f4f114..df8508c29e 100644 --- a/apps/login/src/lib/self.ts +++ b/apps/login/src/lib/self.ts @@ -1,20 +1,16 @@ "use server"; -import { createServerTransport } from "@zitadel/client/node"; import { createUserServiceClient } from "@zitadel/client/v2"; import { headers } from "next/headers"; import { getSessionCookieById } from "./cookies"; import { getServiceUrlFromHeaders } from "./service-url"; -import { getSession } from "./zitadel"; - -const transport = async (serviceUrl: string, token: string) => { - return createServerTransport(token, { - baseUrl: serviceUrl, - }); -}; +import { createServerTransport, getSession } from "./zitadel"; const myUserService = async (serviceUrl: string, sessionToken: string) => { - const transportPromise = await transport(serviceUrl, sessionToken); + const transportPromise = await createServerTransport( + sessionToken, + serviceUrl, + ); return createUserServiceClient(transportPromise); }; diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 1925fe43d6..87f88a7c32 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -74,22 +74,20 @@ export type StartIDPFlowCommand = { async function startIDPFlow(command: StartIDPFlowCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - return startIdentityProviderFlow({ + const url = await startIdentityProviderFlow({ serviceUrl: command.serviceUrl, idpId: command.idpId, urls: { 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 ( - response && - response.nextStep.case === "authUrl" && - response?.nextStep.value - ) { - return { redirect: response.nextStep.value }; - } }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; } type CreateNewSessionCommand = { diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index fa75929702..68cb345c06 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -102,7 +102,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - const resp = await startIdentityProviderFlow({ + const url = await startIdentityProviderFlow({ serviceUrl, idpId: identityProviders[0].id, urls: { @@ -115,9 +115,11 @@ export async function sendLoginname(command: SendLoginnameCommand) { }, }); - if (resp?.nextStep.case === "authUrl") { - return { redirect: resp.nextStep.value }; + if (!url) { + return { error: "Could not start IDP flow" }; } + + return { redirect: url }; } }; @@ -166,7 +168,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - const resp = await startIdentityProviderFlow({ + const url = await startIdentityProviderFlow({ serviceUrl, idpId: idp.id, urls: { @@ -179,9 +181,11 @@ export async function sendLoginname(command: SendLoginnameCommand) { }, }); - if (resp?.nextStep.case === "authUrl") { - return { redirect: resp.nextStep.value }; + if (!url) { + return { error: "Could not start IDP flow" }; } + + return { redirect: url }; } }; diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 3786145157..5c6fb03aa5 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -17,7 +17,6 @@ import { setUserPassword, } from "@/lib/zitadel"; import { ConnectError, create } from "@zitadel/client"; -import { createServerTransport } from "@zitadel/client/node"; import { createUserServiceClient } from "@zitadel/client/v2"; import { Checks, @@ -39,6 +38,7 @@ import { checkPasswordChangeRequired, checkUserVerification, } from "../verify-helper"; +import { createServerTransport } from "../zitadel"; type ResetPasswordCommand = { loginName: string; @@ -428,9 +428,7 @@ export async function checkSessionAndSetPassword({ }); } else { const transport = async (serviceUrl: string, token: string) => { - return createServerTransport(token, { - baseUrl: serviceUrl, - }); + return createServerTransport(token, serviceUrl); }; const myUserService = async (serviceUrl: string, sessionToken: string) => { diff --git a/apps/login/src/lib/service.ts b/apps/login/src/lib/service.ts index 0fbb083b05..f7e81cc9d6 100644 --- a/apps/login/src/lib/service.ts +++ b/apps/login/src/lib/service.ts @@ -1,5 +1,4 @@ import { createClientFor } from "@zitadel/client"; -import { createServerTransport } from "@zitadel/client/node"; import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; @@ -8,6 +7,7 @@ import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_servic import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { systemAPIToken } from "./api"; +import { createServerTransport } from "./zitadel"; type ServiceClass = | typeof IdentityProviderService @@ -43,24 +43,7 @@ export async function createServiceForHost( throw new Error("No token found"); } - const transport = createServerTransport(token, { - baseUrl: serviceUrl, - interceptors: !process.env.CUSTOM_REQUEST_HEADERS - ? undefined - : [ - (next) => { - return (req) => { - process.env.CUSTOM_REQUEST_HEADERS.split(",").forEach( - (header) => { - const kv = header.split(":"); - req.header.set(kv[0], kv[1]); - }, - ); - return next(req); - }; - }, - ], - }); + const transport = createServerTransport(token, serviceUrl); return createClientFor(service)(transport); } diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index c5f72266b9..483d4e4ac9 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -1,4 +1,5 @@ import { Client, create, Duration } from "@zitadel/client"; +import { createServerTransport as libCreateServerTransport } from "@zitadel/client/node"; import { makeReqCtx } from "@zitadel/client/v2"; import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; import { @@ -23,7 +24,10 @@ import { import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; -import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb"; +import type { + FormData, + RedirectURLsJson, +} from "@zitadel/proto/zitadel/user/v2/idp_pb"; import { NotificationType, SendPasswordResetLinkSchema, @@ -88,7 +92,6 @@ export async function getHostedLoginTranslation({ {}, ) .then((resp) => { - console.log(resp); return resp.translations ? resp.translations : undefined; }); @@ -964,19 +967,38 @@ export async function startIdentityProviderFlow({ serviceUrl: string; idpId: string; urls: RedirectURLsJson; -}) { +}): Promise { const userService: Client = await createServiceForHost( UserService, serviceUrl, ); - return userService.startIdentityProviderIntent({ - idpId, - content: { - case: "urls", - value: urls, - }, - }); + return userService + .startIdentityProviderIntent({ + idpId, + content: { + case: "urls", + value: urls, + }, + }) + .then((resp) => { + if (resp.nextStep.case === "authUrl" && resp.nextStep.value) { + return resp.nextStep.value; + } else if (resp.nextStep.case === "formData" && resp.nextStep.value) { + const formData: FormData = resp.nextStep.value; + const redirectUrl = "/saml-post"; + + const params = new URLSearchParams({ url: formData.url }); + + Object.entries(formData.fields).forEach(([k, v]) => { + params.append(k, v); + }); + + return `${redirectUrl}?${params.toString()}`; + } else { + return null; + } + }); } export async function startLDAPIdentityProviderFlow({ @@ -1476,3 +1498,28 @@ export async function listAuthenticationMethodTypes({ userId, }); } + +export function createServerTransport(token: string, baseUrl: string) { + return libCreateServerTransport(token, { + baseUrl, + interceptors: !process.env.CUSTOM_REQUEST_HEADERS + ? undefined + : [ + (next) => { + return (req) => { + process.env + .CUSTOM_REQUEST_HEADERS!.split(",") + .forEach((header) => { + const kv = header.split(":"); + if (kv.length === 2) { + req.header.set(kv[0].trim(), kv[1].trim()); + } else { + console.warn(`Skipping malformed header: ${header}`); + } + }); + return next(req); + }; + }, + ], + }); +}