mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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:
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user