mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-06 06:12:16 +00:00
# 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]>
134 lines
4.9 KiB
TypeScript
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" };
|
|
}
|