Files
zitadel/apps/login/src/lib/server/idp.test.ts
T
Max PeintnerandGitHub 00beaf6ddc fix(login): provide a postError redirect url for idp flows (#10883)
# Which Problems Are Solved

Improves the user experience when IDP authentication succeeds but user
creation/linking fails by introducing a `postErrorRedirectUrl` parameter
and dedicated error pages instead of generic error screens.

<img width="580" height="636" alt="Screenshot 2025-10-16 at 09 21 07"
src="https://github.com/user-attachments/assets/db653c8f-b648-4cfe-922a-2f237f3b70b3"
/>

# How the Problems Are Solved

## New Pages
- **`/idp/[provider]/account-not-found`**: Displayed when no user
account exists and creation/linking is not allowed
- **`/idp/[provider]/registration-failed`**: Displayed when user
registration fails due to organization resolution issues

## Flow Improvements
- Added `postErrorRedirectUrl` parameter to track where the IDP flow was
initiated
- Each entry point (loginname, register, idp, authenticator/set)
specifies its own redirect URL
- Users are now redirected to appropriate error pages with clear
messaging instead of generic error screens
- All context (`requestId`, `organization`, `postErrorRedirectUrl`) is
preserved throughout the flow

## Updated Components
- `SignInWithIdp`: Now accepts and passes `postErrorRedirectUrl`
parameter
- `redirectToIdp` server action: Extracts and forwards
`postErrorRedirectUrl` through the IDP flow
- IDP success page: Routes to appropriate error pages based on failure
reason

## i18n
Added new translation keys:
- `idp.accountNotFound.*` - For missing account scenarios
- `idp.registrationFailed.*` - For organization resolution failures
2025-10-16 11:24:54 +02:00

277 lines
9.9 KiB
TypeScript

import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { redirectToIdp } from "./idp";
// Mock all the dependencies
vi.mock("next/headers", () => ({
headers: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn((url: string) => {
throw new Error(`REDIRECT: ${url}`);
}),
}));
vi.mock("../service-url", () => ({
getServiceUrlFromHeaders: vi.fn(),
}));
vi.mock("./host", () => ({
getOriginalHost: vi.fn(),
}));
vi.mock("../zitadel", () => ({
startIdentityProviderFlow: vi.fn(),
}));
describe("redirectToIdp", () => {
let mockHeaders: any;
let mockGetServiceUrlFromHeaders: any;
let mockGetOriginalHost: any;
let mockStartIdentityProviderFlow: any;
beforeEach(async () => {
vi.clearAllMocks();
// Import mocked modules
const { headers } = await import("next/headers");
const { getServiceUrlFromHeaders } = await import("../service-url");
const { getOriginalHost } = await import("./host");
const { startIdentityProviderFlow } = await import("../zitadel");
// Setup mocks
mockHeaders = vi.mocked(headers);
mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders);
mockGetOriginalHost = vi.mocked(getOriginalHost);
mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow);
// Default mock implementations
mockHeaders.mockResolvedValue({} as any);
mockGetServiceUrlFromHeaders.mockReturnValue({
serviceUrl: "https://api.example.com",
});
mockGetOriginalHost.mockResolvedValue("example.com");
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("postErrorRedirectUrl parameter handling", () => {
test("should include postErrorRedirectUrl in success and failure URLs when provided", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
formData.append("requestId", "req123");
formData.append("organization", "org123");
formData.append("postErrorRedirectUrl", "https://app.example.com/error");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT:");
}
expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({
serviceUrl: "https://api.example.com",
idpId: "idp123",
urls: {
successUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"),
failureUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"),
},
});
// Verify both URLs contain all expected parameters
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
const successUrl = callArgs.urls.successUrl;
const failureUrl = callArgs.urls.failureUrl;
expect(successUrl).toContain("requestId=req123");
expect(successUrl).toContain("organization=org123");
expect(successUrl).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror");
expect(failureUrl).toContain("requestId=req123");
expect(failureUrl).toContain("organization=org123");
expect(failureUrl).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror");
});
test("should not include postErrorRedirectUrl in URLs when not provided", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
formData.append("requestId", "req123");
formData.append("organization", "org123");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT:");
}
expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({
serviceUrl: "https://api.example.com",
idpId: "idp123",
urls: {
successUrl: expect.not.stringContaining("postErrorRedirectUrl"),
failureUrl: expect.not.stringContaining("postErrorRedirectUrl"),
},
});
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
expect(callArgs.urls.successUrl).not.toContain("postErrorRedirectUrl");
expect(callArgs.urls.failureUrl).not.toContain("postErrorRedirectUrl");
});
test("should not include postErrorRedirectUrl when it is an empty string", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
formData.append("postErrorRedirectUrl", "");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT:");
}
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
expect(callArgs.urls.successUrl).not.toContain("postErrorRedirectUrl");
expect(callArgs.urls.failureUrl).not.toContain("postErrorRedirectUrl");
});
test("should include postErrorRedirectUrl in LDAP redirect URL", async () => {
const formData = new FormData();
formData.append("id", "ldap123");
formData.append("provider", "ldap");
formData.append("requestId", "req123");
formData.append("organization", "org123");
formData.append("postErrorRedirectUrl", "/custom-error");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT: /idp/ldap?");
expect(error.message).toContain("requestId=req123");
expect(error.message).toContain("organization=org123");
expect(error.message).toContain("postErrorRedirectUrl=%2Fcustom-error");
}
});
test("should handle postErrorRedirectUrl with special characters", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
formData.append("postErrorRedirectUrl", "https://app.example.com/error?code=123&message=test");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT:");
}
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
const successUrl = new URL(callArgs.urls.successUrl);
const failureUrl = new URL(callArgs.urls.failureUrl);
// Verify the URL is properly encoded
expect(successUrl.searchParams.get("postErrorRedirectUrl")).toBe(
"https://app.example.com/error?code=123&message=test",
);
expect(failureUrl.searchParams.get("postErrorRedirectUrl")).toBe(
"https://app.example.com/error?code=123&message=test",
);
});
test("should preserve postErrorRedirectUrl alongside linkOnly parameter", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
formData.append("linkOnly", "true");
formData.append("postErrorRedirectUrl", "/custom-error");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT:");
}
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
const successUrl = callArgs.urls.successUrl;
const failureUrl = callArgs.urls.failureUrl;
// Both parameters should be present
expect(successUrl).toContain("link=true");
expect(successUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
expect(failureUrl).toContain("link=true");
expect(failureUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
});
test("should handle relative postErrorRedirectUrl paths", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "github");
formData.append("postErrorRedirectUrl", "/loginname");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toContain("REDIRECT:");
}
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
expect(callArgs.urls.successUrl).toContain("postErrorRedirectUrl=%2Floginname");
expect(callArgs.urls.failureUrl).toContain("postErrorRedirectUrl=%2Floginname");
});
});
describe("General redirect behavior", () => {
test("should return error when IDP flow returns null", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
mockStartIdentityProviderFlow.mockResolvedValue(null);
const result = await redirectToIdp(undefined, formData);
expect(result).toEqual({ error: "Unexpected response from IDP flow" });
});
test("should redirect when IDP flow returns a valid URL", async () => {
const formData = new FormData();
formData.append("id", "idp123");
formData.append("provider", "google");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
try {
await redirectToIdp(undefined, formData);
// Should not reach here
expect(true).toBe(false);
} catch (error: any) {
// Redirect throws in tests
expect(error.message).toBe("REDIRECT: https://idp.example.com/auth");
}
});
});
});