mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
Merge commit from fork
* fix(security): prevent unauthenticated IDP-linking account takeover Two distinct defects allowed an attacker to bind their own external identity to a victim's account and, because external IDP links are globally unique (first claim wins), hijack the victim's future federated logins. LoginV2: resolveUserIdFromSession returned the session's user id after only a null check, trusting an "identify-only" session (login name submitted, no verified primary factor). An unauthenticated attacker who knew a victim's login name could drive explicit linking against that session. Gate it on isSessionValid so only a fully authenticated session can link. Backend: AddUserIDPLink skipped the user-write permission check when the caller targeted their own account (userID == ctx user). Because the external subject is caller-supplied and unverified, this let any authenticated user self-link an unproven identity. Make the permission check unconditional. The login/federation flow uses a service account (userID != ctx user) and already took the permission branch, so it is unaffected; verified self-service linking goes through the IDP-intent flow. Adds a LoginV2 regression test (identify-only session is rejected) and the first command-level test for AddUserIDPLink (self-link without permission is denied). Co-Authored-By: Claude Opus 4.8 <[email protected]> * test(user_idp_link): address review on AddUserIDPLink hardening - add success regression cases for AddUserIDPLink (self-link with permission, other-user link with permission) so the login-client and managed-admin paths are pinned and the assert.NoError branch is live - add an integration case: UserCTX self-linking on its own account is now denied - document the intentional no-permission-check invariant on BulkAddedUserIDPLinks (verified Login V1 flow only; not API-exposed) Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01RUhwzSqHhycHNAJEfMcrdd * fix(login): authorize IDP linking via enrollment guard, not isSessionValid Replace the isSessionValid gate in resolveUserIdFromSession with getEnrollmentAuthorizationError. isSessionValid requires a verified primary factor, which rejected the legitimate "link an IDP as the first authenticator" onboarding flow (a freshly verified user with no credentials yet). The enrollment guard authorizes when the session has a verified, non-expired primary factor OR the user has zero auth methods and passed the user-verification check (email/invite-code proof bound to the browser fingerprint) — closing the identify-only ATO while preserving first-authenticator linking. Replace the single mocked rejection test with four that run the real guard: identify-only + has credentials -> rejected; identify-only + no credentials + no verification check -> rejected; expired primary factor -> rejected; identify-only + no credentials + verification check -> allowed. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01T24WwsRLbaC7aHxBmyznnd --------- Co-authored-by: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
78be618357
commit
dbbab75420
@@ -1,4 +1,5 @@
|
||||
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import crypto from "crypto";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { processIDPCallback } from "./idp-intent";
|
||||
@@ -10,6 +11,9 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
vi.mock("@zitadel/client", () => ({
|
||||
create: vi.fn((schema: any, data: any) => data),
|
||||
// Convert a {seconds} Timestamp-like object into a real Date so the real
|
||||
// hasVerifiedPrimaryFactor (via the enrollment guard) can evaluate expiry.
|
||||
timestampDate: vi.fn((ts: { seconds: bigint | number }) => new Date(Number(ts.seconds) * 1000)),
|
||||
ConnectError: class extends Error {
|
||||
code: any;
|
||||
constructor(message: string, code: any) {
|
||||
@@ -39,12 +43,19 @@ vi.mock("../zitadel", () => ({
|
||||
getUserByID: vi.fn(),
|
||||
getDefaultOrg: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
listAuthenticationMethodTypes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./idp", () => ({
|
||||
createNewSessionFromIdpIntent: vi.fn(),
|
||||
}));
|
||||
|
||||
// The linking guard (getEnrollmentAuthorizationError) runs for real; only its
|
||||
// leaf dependency checkUserVerification (cookie/fingerprint proof) is mocked.
|
||||
vi.mock("@/lib/verify-helper", () => ({
|
||||
checkUserVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cookies", () => ({
|
||||
getSessionCookieById: vi.fn(),
|
||||
}));
|
||||
@@ -76,6 +87,8 @@ describe("processIDPCallback", () => {
|
||||
let mockCreateNewSessionFromIdpIntent: any;
|
||||
let mockGetSessionCookieById: any;
|
||||
let mockGetFingerprintIdCookie: any;
|
||||
let mockListAuthenticationMethodTypes: any;
|
||||
let mockCheckUserVerification: any;
|
||||
|
||||
const defaultParams = {
|
||||
provider: "google",
|
||||
@@ -156,10 +169,12 @@ describe("processIDPCallback", () => {
|
||||
getUserByID,
|
||||
getDefaultOrg,
|
||||
getSession,
|
||||
listAuthenticationMethodTypes,
|
||||
} = await import("../zitadel");
|
||||
const { createNewSessionFromIdpIntent } = await import("./idp");
|
||||
const { getSessionCookieById } = await import("@/lib/cookies");
|
||||
const { getFingerprintIdCookie } = await import("../fingerprint");
|
||||
const { checkUserVerification } = await import("@/lib/verify-helper");
|
||||
|
||||
// Setup mocks
|
||||
mockHeaders = vi.mocked(headers);
|
||||
@@ -180,6 +195,8 @@ describe("processIDPCallback", () => {
|
||||
mockCreateNewSessionFromIdpIntent = vi.mocked(createNewSessionFromIdpIntent);
|
||||
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
|
||||
mockGetFingerprintIdCookie = vi.mocked(getFingerprintIdCookie);
|
||||
mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes);
|
||||
mockCheckUserVerification = vi.mocked(checkUserVerification);
|
||||
|
||||
// Default mock implementations
|
||||
mockHeaders.mockResolvedValue({} as any);
|
||||
@@ -205,6 +222,11 @@ describe("processIDPCallback", () => {
|
||||
mockGetActiveIdentityProviders.mockResolvedValue({
|
||||
identityProviders: [{ id: "idp123", name: "Test IDP" }],
|
||||
});
|
||||
// Default: the linking guard authorizes (no configured auth methods yet + a passed
|
||||
// user-verification check — the first-authenticator onboarding path). Individual tests
|
||||
// override these to exercise the identify-only rejection paths.
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({ authMethodTypes: [] });
|
||||
mockCheckUserVerification.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -566,6 +588,117 @@ describe("processIDPCallback", () => {
|
||||
expect(mockAddIDPLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Security regression: an "identify-only" session (login name submitted, no verified
|
||||
// primary factor) must never drive linking, even when the IDP allows it and the browser
|
||||
// fingerprint matches. Otherwise an unauthenticated attacker who only knows a victim's
|
||||
// login name could bind their own external identity to the victim's account (ATO).
|
||||
//
|
||||
// These four cases run the real enrollment guard (getEnrollmentAuthorizationError); only
|
||||
// its leaf dependencies (listAuthenticationMethodTypes, checkUserVerification) are mocked.
|
||||
const linkingAllowedIdp = {
|
||||
...defaultIdp,
|
||||
config: {
|
||||
options: {
|
||||
...defaultIdp.config.options,
|
||||
isLinkingAllowed: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test("should reject linking for an identify-only session when the user already has credentials", async () => {
|
||||
// Identify-only session (default) + user has a configured auth method → not authorized.
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({
|
||||
authMethodTypes: [AuthenticationMethodType.PASSWORD],
|
||||
});
|
||||
mockGetIDPByID.mockResolvedValue(linkingAllowedIdp);
|
||||
|
||||
const result = await processIDPCallback(linkParams);
|
||||
|
||||
expect(result.redirect).toContain("/idp/google/linking-failed");
|
||||
expect(result.redirect).toContain("error=session_invalid");
|
||||
expect(mockAddIDPLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject linking for an identify-only session with no credentials and no verification check", async () => {
|
||||
// No auth methods yet, but the user-verification check has not been passed → not authorized.
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({ authMethodTypes: [] });
|
||||
mockCheckUserVerification.mockResolvedValue(false);
|
||||
mockGetIDPByID.mockResolvedValue(linkingAllowedIdp);
|
||||
|
||||
const result = await processIDPCallback(linkParams);
|
||||
|
||||
expect(result.redirect).toContain("/idp/google/linking-failed");
|
||||
expect(result.redirect).toContain("error=session_invalid");
|
||||
expect(mockAddIDPLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject linking when the primary factor is expired", async () => {
|
||||
// A password factor was verified, but the session has expired, so it no longer counts
|
||||
// as a verified primary factor. Were it still valid, the guard would authorize at the
|
||||
// primary-factor branch; expired, it falls through and the configured credential
|
||||
// (PASSWORD) means it is not the credential-less onboarding path → rejected.
|
||||
const past = { seconds: BigInt(Math.floor(Date.now() / 1000) - 3600), nanos: 0 };
|
||||
const factorVerifiedAt = { seconds: BigInt(Math.floor(Date.now() / 1000) - 7200), nanos: 0 };
|
||||
mockGetSession.mockResolvedValue({
|
||||
session: {
|
||||
factors: {
|
||||
user: { id: "user123" },
|
||||
password: { verifiedAt: factorVerifiedAt },
|
||||
},
|
||||
expirationDate: past,
|
||||
},
|
||||
});
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({
|
||||
authMethodTypes: [AuthenticationMethodType.PASSWORD],
|
||||
});
|
||||
mockGetIDPByID.mockResolvedValue(linkingAllowedIdp);
|
||||
|
||||
const result = await processIDPCallback(linkParams);
|
||||
|
||||
expect(result.redirect).toContain("/idp/google/linking-failed");
|
||||
expect(result.redirect).toContain("error=session_invalid");
|
||||
expect(mockAddIDPLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should allow linking as the first authenticator when the user-verification check passed", async () => {
|
||||
// Identify-only session, no auth methods yet, but the email/invite-code verification
|
||||
// check passed → the legitimate "link an IDP as the first authenticator" onboarding path.
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({ authMethodTypes: [] });
|
||||
mockCheckUserVerification.mockResolvedValue(true);
|
||||
mockGetIDPByID.mockResolvedValue(linkingAllowedIdp);
|
||||
|
||||
const result = await processIDPCallback(linkParams);
|
||||
|
||||
expect(mockAddIDPLink).toHaveBeenCalled();
|
||||
expect(result.redirect).toBe("https://app.example.com/success");
|
||||
});
|
||||
|
||||
test("should allow linking when the session has a verified, non-expired primary factor", async () => {
|
||||
// A fully authenticated session (verified password, not expired) is authorized at the
|
||||
// primary-factor branch regardless of configured methods / verification check. This pins
|
||||
// the other half of the guard's OR so a refactor cannot silently drop it.
|
||||
const future = { seconds: BigInt(Math.floor(Date.now() / 1000) + 3600), nanos: 0 };
|
||||
mockGetSession.mockResolvedValue({
|
||||
session: {
|
||||
factors: {
|
||||
user: { id: "user123" },
|
||||
password: { verifiedAt: future },
|
||||
},
|
||||
expirationDate: future,
|
||||
},
|
||||
});
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({
|
||||
authMethodTypes: [AuthenticationMethodType.PASSWORD],
|
||||
});
|
||||
mockCheckUserVerification.mockResolvedValue(false);
|
||||
mockGetIDPByID.mockResolvedValue(linkingAllowedIdp);
|
||||
|
||||
const result = await processIDPCallback(linkParams);
|
||||
|
||||
expect(mockAddIDPLink).toHaveBeenCalled();
|
||||
expect(result.redirect).toBe("https://app.example.com/success");
|
||||
});
|
||||
|
||||
test("should return error redirect when linking fails", async () => {
|
||||
mockGetIDPByID.mockResolvedValue({
|
||||
...defaultIdp,
|
||||
|
||||
@@ -33,6 +33,7 @@ import crypto from "crypto";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
import { getFingerprintIdCookie } from "../fingerprint";
|
||||
import { getEnrollmentAuthorizationError } from "./enrollment-guard";
|
||||
import { createNewSessionFromIdpIntent } from "./idp";
|
||||
import { syncInstanceRolesFromIdpIntent } from "./instance-roles";
|
||||
|
||||
@@ -312,6 +313,29 @@ async function resolveUserIdFromSession({
|
||||
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
|
||||
}
|
||||
|
||||
// Explicit linking must be authorized the same way credential enrollment is. An
|
||||
// "identify-only" session — produced by only submitting a login name, with no verified
|
||||
// primary factor (password / passkey / IDP intent) — must NOT be trusted on its own:
|
||||
// otherwise an unauthenticated attacker who merely knows a victim's login name could bind
|
||||
// their own external identity to the victim's account (account takeover). The fingerprint
|
||||
// check above only proves the link request originated from the same browser that started
|
||||
// the flow, not that the session was ever authenticated.
|
||||
//
|
||||
// getEnrollmentAuthorizationError authorizes when the session has a verified, non-expired
|
||||
// primary factor, OR the user has no authentication methods yet AND passed a
|
||||
// user-verification check (the email/invite-code proof bound to this browser's
|
||||
// fingerprint). The latter preserves the legitimate "link an IDP as the first
|
||||
// authenticator" onboarding flow, which isSessionValid would have rejected.
|
||||
const enrollmentError = await getEnrollmentAuthorizationError({
|
||||
serviceConfig,
|
||||
session,
|
||||
userId: session.factors.user.id,
|
||||
});
|
||||
if (enrollmentError) {
|
||||
logger.warn("Session for linking is not authorized", { enrollmentError });
|
||||
return { redirect: `/idp/${provider}/linking-failed?error=session_invalid` };
|
||||
}
|
||||
|
||||
return { userId: session.factors.user.id };
|
||||
} catch (error) {
|
||||
logger.warn("Error retrieving session for linking", { error });
|
||||
|
||||
Reference in New Issue
Block a user