Files
zitadel/apps/login/src/lib/saml.ts
T
4dc89b499f fix(login): allow custom protocols for native apps again (#12332)
# Which Problems Are Solved

- Native apps (mobile/desktop) use custom protocol schemes (e.g.
`myapp://callback`, `io.zitadel.app://auth`) as OAuth/OIDC redirect
URIs. The previous redirect-safety check in the login UI only allowed
`http:`/`https:` absolute URLs, so these custom-scheme callbacks were
wrongly rejected and native app login flows were broken.
- Even for valid external redirects, `router.push()` triggers an RSC
prefetch `fetch()` that is blocked by the CSP `connect-src 'self'`
directive for non-same-origin URLs, so navigation to
external/custom-protocol targets failed.
- SAML and OIDC callback URLs returned from the backend were not
consistently validated before being used for redirects/POSTs across the
various flow entry points.

# How the Problems Are Solved

- Reworked `isSafeRedirectUri` to use `@braintree/sanitize-url` instead
of a strict `http/https`-only allowlist. Custom protocol schemes are now
permitted, while dangerous schemes (`javascript:`, `data:`, plus an
explicit block of `file:`, `blob:`, `about:`) are still rejected.
- Added `isExternalUrl` helper to distinguish internal relative paths
(`/...`) from external/custom-protocol targets.
- In `handleServerActionResponse`, external/custom-protocol redirects
now navigate via `window.location.href` (full navigation) instead of
`router.push()`, avoiding the CSP-blocked RSC prefetch. Internal paths
continue to use `router.push()`.
- Added consistent redirect-URI validation for SAML and OIDC callbacks
across all relevant entry points (`oidc.ts`, `saml.ts`,
`server/flow-initiation.ts` including the `prompt=none` path, and the
SAML `samlData` POST case), blocking unsafe URLs with a clear warning
and error response.

# Additional Changes

- Added `@braintree/sanitize-url` (`^7.1.2`) dependency to `apps/login`
(`package.json` / `pnpm-lock.yaml`).
- Added unit tests for `isExternalUrl` covering relative paths, absolute
HTTP/HTTPS URLs, custom protocol schemes, and protocol-relative URLs.
- Annotated the intentional `window.location.href` navigation with a
CodeQL suppression comment, documenting that the URL is validated by
`isSafeRedirectUri` beforehand.
- Minor formatting cleanup in `console` JWT provider component.

# Additional Context

- This restores behavior that previously worked for native apps but
regressed when redirect validation was tightened to `http`/`https` only.

---------

Co-authored-by: Max Peintner <[email protected]>
2026-06-30 13:19:40 +00:00

134 lines
4.9 KiB
TypeScript

import { isSafeRedirectUri } from "@/lib/client-utils";
import { Cookie } from "@/lib/cookies";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { createResponse, getLoginSettings, ServiceConfig } from "@/lib/zitadel";
import { Code, create } from "@zitadel/client";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { isSessionValid } from "./session";
type LoginWithSAMLAndSession = {
serviceConfig: ServiceConfig;
samlRequest: string;
sessionId: string;
sessions: Session[];
sessionCookies: Cookie[];
};
export async function loginWithSAMLAndSession({
serviceConfig,
samlRequest,
sessionId,
sessions,
sessionCookies,
}: LoginWithSAMLAndSession): Promise<
{ error: string } | { redirect: string } | { samlData: { url: string; fields: Record<string, string> } }
> {
console.log(`Login with session: ${sessionId} and samlRequest: ${samlRequest}`);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
const isValid = await isSessionValid({ serviceConfig, session: selectedSession });
if (!isValid && selectedSession.factors?.user) {
// if the session is not valid anymore, we need to redirect the user to re-authenticate /
// TODO: handle IDP intent direcly if available
const command: SendLoginnameCommand = {
loginName: selectedSession.factors.user?.loginName,
organization: selectedSession.factors?.user?.organizationId,
requestId: `saml_${samlRequest}`,
};
const res = await sendLoginname(command);
if (res && "redirect" in res && res?.redirect) {
return { redirect: res.redirect };
}
if (res && "samlData" in res && res?.samlData) {
return { samlData: res.samlData };
}
}
const cookie = sessionCookies.find((cookie) => cookie.id === selectedSession?.id);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
// works not with _rsc request
try {
const { url, binding } = await createResponse({
serviceConfig,
req: create(CreateResponseRequestSchema, {
samlRequestId: samlRequest,
responseKind: {
case: "session",
value: session,
},
}),
});
if (url && binding.case === "redirect") {
if (!isSafeRedirectUri(url)) {
console.warn("loginWithSAMLAndSession: Blocked unsafe SAML redirect URL:", url);
return { error: "Unsafe redirect URI was blocked" };
}
return { redirect: url };
} else if (url && binding.case === "post") {
if (!isSafeRedirectUri(url)) {
console.warn("loginWithSAMLAndSession: Blocked unsafe SAML post URL:", url);
return { error: "Unsafe redirect URI was blocked" };
}
return {
samlData: {
url,
fields: {
RelayState: binding.value.relayState,
SAMLResponse: binding.value.samlResponse,
},
},
};
} else {
return { error: "An error occurred!" };
}
} catch (error: unknown) {
// handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.)
console.error(error);
if (isClassifiedError(error) && error.code === Code.FailedPrecondition) {
const loginSettings = await getLoginSettings({
serviceConfig,
organization: selectedSession.factors?.user?.organizationId,
});
if (loginSettings?.defaultRedirectUri && isSafeRedirectUri(loginSettings.defaultRedirectUri)) {
return { redirect: loginSettings.defaultRedirectUri };
} else if (loginSettings?.defaultRedirectUri) {
console.warn("loginWithSAMLAndSession: Unsafe defaultRedirectUri prevented:", loginSettings.defaultRedirectUri);
}
const signedinUrl = "/signedin";
const params = new URLSearchParams();
if (selectedSession.factors?.user?.loginName) {
params.append("loginName", selectedSession.factors?.user?.loginName);
}
if (selectedSession.factors?.user?.organizationId) {
params.append("organization", selectedSession.factors?.user?.organizationId);
}
return { redirect: signedinUrl + "?" + params.toString() };
} else {
return { error: "Unknown error occurred" };
}
}
}
}
// If no session found or no valid cookie, return error
return { error: "Session not found or invalid" };
}