fix(login): don't prepend base path to absolute IdP URL on login_hint redirect (#12610)

# Which Problems Are Solved

When an OIDC authorize request carries a `login_hint` that resolves via
domain discovery to an organization configured to auto-redirect to a
single external IdP, Login V2's server-side flow initiation returns a
malformed `Location` header: the login base path is concatenated with
the absolute IdP authorize URL without a separator (e.g.
`https://<instance>/ui/v2/loginhttps://login.microsoftonline.com/...`).
The flow ends in `{"code":5,"message":"Not Found"}` and the IdP is never
contacted. The same IdP works when the user submits the login name
interactively, because the client-side handler distinguishes external
URLs; only the server-side `login_hint` fast-path introduced in #12431
is affected.

Additionally, the same fast-path only handled the `redirect` response
shape from `sendLoginname`. When the discovered organization's IdP is
SAML with POST binding, `sendLoginname` returns `samlData` (form fields
instead of a URL), which was silently ignored — the user fell back to
the prefilled `/loginname` screen instead of being signed in silently,
unlike the client-side flow, which auto-submits the SAML AuthnRequest
form.

# How the Problems Are Solved

`resolveLoginHint` passed every redirect returned by `sendLoginname`
through `constructUrl`, which unconditionally prepends
`NEXT_PUBLIC_BASE_PATH` and assumes a relative path. It now checks
`isExternalUrl` first: absolute URLs are validated with
`isSafeRedirectUri` (blocking `javascript:`/`data:`/etc., falling back
to `/loginname` if unsafe) and redirected as-is, while relative paths
keep being resolved against the base path — matching the handling
already used in the idp-scope branch of `handleOIDCFlowInitiation` and
in the client-side `handleServerActionResponse`.

`resolveLoginHint` also handles the `samlData` response shape now: after
validating the target URL with `isSafeRedirectUri`, it responds with the
same auto-submit HTML form used by the existing SAML flows, so a
`login_hint` resolving to a POST-binding SAML IdP completes silently as
well. Since this form was previously duplicated inline three times in
`flow-initiation.ts`, it is extracted into a shared
`buildAutoSubmitFormResponse` helper used by all call sites.

Regression tests cover the absolute-URL redirect (fails on the previous
code with the exact malformed URL), the SAML POST auto-submit response,
and the unsafe-scheme fallbacks for both shapes.
This commit is contained in:
Max Peintner
2026-08-17 15:17:08 +02:00
committed by GitHub
parent 632a519680
commit d488ecb07f
3 changed files with 314 additions and 38 deletions
+6
View File
@@ -15,6 +15,12 @@ The **Login App** (`apps/login`) provides the user interface for authentication
- **State**: Critical authentication state is often managed via URL parameters (Auth Requests) and cookies/sessions.
- **Scope Rule**: For shared API typings and client behavior, also read `packages/AGENTS.md` and `proto/AGENTS.md`.
## Base Path & Redirects (critical — read before touching any redirect logic)
- **The login app is served under a non-root base path in production.** ZITADEL Cloud and the official container image are built with `NEXT_PUBLIC_BASE_PATH=/ui/v2/login` (see `apps/login/.env`; the value is inlined at build time by Next.js). Never assume the app lives at the domain root.
- **`constructUrl()` (`src/lib/service-url.ts`) prepends the base path and must ONLY ever receive root-relative paths** (`/loginname`, `/password?...`). Passing an absolute URL glues the base path onto it and produces a broken redirect like `https://<host>/ui/v2/loginhttps://login.microsoftonline.com/...`.
- **Some redirect targets ARE absolute URLs**: IdP authorize URLs returned by `startIdentityProviderFlow`, OIDC callback URLs from `createCallback`, and SAML endpoints from `createResponse`. When handling a redirect value that could be absolute, branch on `isExternalUrl()` first, validate absolute URLs with `isSafeRedirectUri()`, and pass them to `NextResponse.redirect` (server) / `window.location.href` (client) untouched. Canonical pattern: `resolveLoginHint` in `src/lib/server/flow-initiation.ts` and `handleServerActionResponse` in `src/lib/client-utils.ts`.
- **An empty base path masks this whole bug class**: with `NEXT_PUBLIC_BASE_PATH` unset, `new URL("" + absoluteUrl, origin)` still parses the absolute URL correctly, so local setups without the base path won't reproduce base-path bugs. Always test redirect changes with `NEXT_PUBLIC_BASE_PATH=/ui/v2/login` set, and verify the raw `Location` header (e.g. `curl -I`), not just browser behavior.
## Verified Nx Targets
- **Dev Server**: `pnpm nx run @zitadel/login:dev`
- **Build**: `pnpm nx run @zitadel/login:build`
@@ -533,6 +533,117 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
expect(location).toContain("/password");
expect(location).not.toContain("/loginname");
});
test("should redirect to an absolute IdP URL as-is without prepending the base path (domain discovery auto-redirect)", async () => {
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: [],
prompt: [],
loginHint: "user@discovered-org.com",
},
});
// Simulate a base path being configured, as in ZITADEL Cloud (/ui/v2/login).
mockConstructUrl.mockImplementation((_req: any, path: string) => {
return new URL(`https://example.com/ui/v2/login${path}`);
});
// sendLoginname resolved the hint via domain discovery to an org with a
// single external IdP and returns the absolute authorize URL of that IdP.
const idpUrl = "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize?client_id=xyz&state=abc";
mockSendLoginname.mockResolvedValue({ redirect: idpUrl });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
const location = res.headers.get("location") ?? "";
expect(location).toBe(idpUrl);
// The base path must never be glued onto an absolute URL
// (regression: https://<host>/ui/v2/loginhttps://login.microsoftonline.com/...).
expect(location).not.toContain("/ui/v2/login");
expect(mockConstructUrl).not.toHaveBeenCalledWith(expect.anything(), idpUrl);
});
test("should render an auto-submit form when loginHint resolves to a SAML POST-binding IdP", async () => {
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: [],
prompt: [],
loginHint: "user@discovered-org.com",
},
});
// sendLoginname resolved the hint via domain discovery to an org whose
// single IdP is SAML with POST binding: the AuthnRequest is delivered as
// form fields, not a redirect URL.
mockSendLoginname.mockResolvedValue({
samlData: {
url: "https://adfs.example.com/adfs/ls",
fields: { SAMLRequest: "PHNhbWxwOkF1dGhuUmVxdWVzdD4=", RelayState: "relay-123" },
},
});
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
// No redirect: the response is an HTML page auto-posting the form to the IdP.
expect(res.headers.get("location")).toBeNull();
expect(res.headers.get("content-type")).toContain("text/html");
const html = await res.text();
expect(html).toContain('action="https://adfs.example.com/adfs/ls"');
expect(html).toContain('name="SAMLRequest"');
expect(html).toContain('value="PHNhbWxwOkF1dGhuUmVxdWVzdD4="');
expect(html).toContain('name="RelayState"');
expect(html).toContain("document.forms[0].submit()");
});
test("should block unsafe SAML post URLs from loginHint resolution and fall back to /loginname", async () => {
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: [],
prompt: [],
loginHint: "user@example.com",
},
});
mockSendLoginname.mockResolvedValue({
samlData: {
url: "javascript:alert(1)",
fields: { SAMLRequest: "abc" },
},
});
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/loginname");
expect(location).not.toContain("javascript:");
});
test("should block unsafe absolute redirect URLs from loginHint resolution and fall back to /loginname", async () => {
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: [],
prompt: [],
loginHint: "user@example.com",
},
});
mockSendLoginname.mockResolvedValue({ redirect: "javascript:alert(1)" });
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
const location = res.headers.get("location") ?? "";
expect(location).toContain("/loginname");
expect(location).not.toContain("javascript:");
});
});
describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix", () => {
@@ -634,3 +745,130 @@ describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix
);
});
});
describe("handleOIDCFlowInitiation — idp scope (urn:zitadel:iam:org:idp:id)", () => {
let mockGetAuthRequest: ReturnType<typeof vi.fn>;
let mockConstructUrl: ReturnType<typeof vi.fn>;
let mockGetActiveIdentityProviders: ReturnType<typeof vi.fn>;
let mockStartIdentityProviderFlow: ReturnType<typeof vi.fn>;
let mockIdpTypeToSlug: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
vi.unstubAllEnvs();
const zitadel = await import("@/lib/zitadel");
const serviceUrl = await import("@/lib/service-url");
const authUtils = await import("@/lib/auth-utils");
const idpLib = await import("@/lib/idp");
mockGetAuthRequest = vi.mocked(zitadel.getAuthRequest);
mockConstructUrl = vi.mocked(serviceUrl.constructUrl);
mockGetActiveIdentityProviders = vi.mocked(zitadel.getActiveIdentityProviders);
mockStartIdentityProviderFlow = vi.mocked(zitadel.startIdentityProviderFlow);
mockIdpTypeToSlug = vi.mocked(idpLib.idpTypeToSlug);
vi.mocked(authUtils.getValidLocaleFromUILocales).mockReturnValue(null);
mockConstructUrl.mockImplementation((_req: any, path: string) => {
return new URL(`https://example.com${path}`);
});
mockIdpTypeToSlug.mockReturnValue("azure");
});
afterEach(() => {
vi.unstubAllEnvs();
});
test("should use the type of the scoped IdP, not the first active IdP (multi-IdP org)", async () => {
const { IdentityProviderType } = await import("@zitadel/proto/zitadel/settings/v2/login_settings_pb");
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: ["urn:zitadel:iam:org:idp:id:idp-2"],
prompt: [],
loginHint: undefined,
},
});
// Two active IdPs: the scope selects the SECOND one. Regression: the slug
// was previously derived from identityProviders[0].type.
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [
{ id: "idp-1", type: IdentityProviderType.GITHUB },
{ id: "idp-2", type: IdentityProviderType.AZURE_AD },
],
});
mockStartIdentityProviderFlow.mockResolvedValue({
url: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=xyz",
});
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
expect(mockIdpTypeToSlug).toHaveBeenCalledWith(IdentityProviderType.AZURE_AD);
expect(mockIdpTypeToSlug).not.toHaveBeenCalledWith(IdentityProviderType.GITHUB);
expect(res.headers.get("location")).toBe("https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=xyz");
});
test("should block unsafe IdP URLs with a 400 instead of redirecting or rendering a form", async () => {
const { IdentityProviderType } = await import("@zitadel/proto/zitadel/settings/v2/login_settings_pb");
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: ["urn:zitadel:iam:org:idp:id:idp-1"],
prompt: [],
loginHint: undefined,
},
});
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [{ id: "idp-1", type: IdentityProviderType.SAML }],
});
mockStartIdentityProviderFlow.mockResolvedValue({
url: "javascript:alert(1)",
fields: { SAMLRequest: "abc" },
});
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("Unsafe redirect URI");
});
test("should render the auto-submit form for a scoped SAML POST-binding IdP", async () => {
const { IdentityProviderType } = await import("@zitadel/proto/zitadel/settings/v2/login_settings_pb");
mockGetAuthRequest.mockResolvedValue({
authRequest: {
id: "abc123",
uiLocales: [],
scope: ["urn:zitadel:iam:org:idp:id:idp-1"],
prompt: [],
loginHint: undefined,
},
});
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [{ id: "idp-1", type: IdentityProviderType.SAML }],
});
mockStartIdentityProviderFlow.mockResolvedValue({
url: "https://adfs.example.com/adfs/ls",
fields: { SAMLRequest: "PHNhbWxwOkF1dGhuUmVxdWVzdD4=", RelayState: "relay-123" },
});
const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] }));
expect(res.headers.get("location")).toBeNull();
expect(res.headers.get("content-type")).toContain("text/html");
const html = await res.text();
expect(html).toContain('action="https://adfs.example.com/adfs/ls"');
expect(html).toContain('name="SAMLRequest"');
});
});
+70 -38
View File
@@ -1,5 +1,5 @@
import { getValidLocaleFromUILocales } from "@/lib/auth-utils";
import { isSafeRedirectUri } from "@/lib/client-utils";
import { isExternalUrl, isSafeRedirectUri } from "@/lib/client-utils";
import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies";
import { shouldUILocalesOverrideCookie } from "@/lib/i18n";
@@ -53,6 +53,36 @@ function setCSPHeaders(
}
}
/**
* Renders a minimal HTML page that immediately POSTs the given fields to
* `url`, with a <noscript> fallback button. Used for flows whose next hop
* requires a form post instead of a redirect (e.g. SAML POST bindings).
* Callers must validate `url` (isSafeRedirectUri) before calling; all values
* are HTML-escaped here.
*/
function buildAutoSubmitFormResponse(url: string, fields: Record<string, string>): NextResponse {
const hiddenInputs = Object.entries(fields)
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}" />`)
.join("\n");
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${escapeHtml(url)}" method="post">
${hiddenInputs}
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}
const gotoAccounts = ({
request,
requestId,
@@ -142,9 +172,35 @@ const resolveLoginHint = async ({
});
if (res && "redirect" in res && res.redirect) {
// sendLoginname can return an absolute URL, e.g. the IdP authorize
// endpoint when domain discovery resolves to an org that auto-redirects
// to its external IdP. Only relative paths may be resolved against the
// login's base path — prepending it to an absolute URL produces a
// malformed URL like "https://<host>/ui/v2/loginhttps://idp.example/...".
if (isExternalUrl(res.redirect)) {
if (!isSafeRedirectUri(res.redirect)) {
logger.warn("Blocked unsafe login_hint redirect URL", { redirect: res.redirect });
return null;
}
return NextResponse.redirect(res.redirect);
}
return NextResponse.redirect(constructUrl(request, res.redirect));
}
if (res && "samlData" in res && res.samlData) {
// SAML IdP with POST binding: the AuthnRequest must be submitted as a
// form post. Render the same auto-submit form used in the idp-scope
// branch above and in handleSAMLFlowInitiation, so a login_hint
// resolves silently for POST-binding SAML IdPs just like for
// redirect-based IdPs.
if (!isSafeRedirectUri(res.samlData.url)) {
logger.warn("Blocked unsafe SAML post URL from login_hint resolution", { url: res.samlData.url });
return null;
}
return buildAutoSubmitFormResponse(res.samlData.url, res.samlData.fields);
}
if (res && "error" in res && res.error) {
logger.debug("login_hint could not be resolved, falling back to /loginname", { error: res.error });
}
@@ -234,7 +290,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
const idp = identityProviders.find((idp) => idp.id === idpId);
if (idp) {
const identityProviderType = identityProviders[0].type;
const identityProviderType = idp.type;
if (identityProviderType === IdentityProviderType.LDAP) {
const ldapUrl = constructUrl(request, "/ldap");
@@ -269,27 +325,16 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
return NextResponse.json({ error: "Could not start IDP flow" }, { status: 500 });
}
// Covers both branches below: the form post (SAML POST binding) and
// the redirect. Relative paths and same-host/https URLs pass;
// javascript:/data:/file: style schemes are blocked.
if (!isSafeRedirectUri(response.url)) {
logger.warn("Blocked unsafe IdP URL", { url: response.url });
return NextResponse.json({ error: "Unsafe redirect URI was blocked" }, { status: 400 });
}
if (response.fields) {
const hiddenInputs = Object.entries(response.fields)
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}" />`)
.join("\n");
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${escapeHtml(response.url)}" method="post">
${hiddenInputs}
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
return buildAutoSubmitFormResponse(response.url, response.fields);
}
let url = response.url;
@@ -581,22 +626,9 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr
logger.warn("Blocked unsafe SAML post URL", { url });
return NextResponse.json({ error: "Unsafe redirect URI was blocked" }, { status: 400 });
}
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${escapeHtml(url)}" method="post">
<input type="hidden" name="RelayState" value="${escapeHtml(binding.value.relayState)}" />
<input type="hidden" name="SAMLResponse" value="${escapeHtml(binding.value.samlResponse)}" />
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
return buildAutoSubmitFormResponse(url, {
RelayState: binding.value.relayState,
SAMLResponse: binding.value.samlResponse,
});
}
} catch (error) {