From 7b2198e18cff4cb507728f4c6009cf01ce356346 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sat, 3 Oct 2026 21:03:31 +0800 Subject: [PATCH] fix(auth): reject unresolved legacy identity provisioning (#1550) --- src/backend/auth/provisioning.ts | 19 +++++ src/backend/tests/auth/login-pipeline.test.ts | 83 +++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/backend/auth/provisioning.ts b/src/backend/auth/provisioning.ts index e25420767..e071667a4 100644 --- a/src/backend/auth/provisioning.ts +++ b/src/backend/auth/provisioning.ts @@ -198,6 +198,25 @@ async function mayClaimLegacyAccount( const links = await createCurrentUserAuthRepository().listIdentitiesForUser( legacy.id, ); + // An unscoped legacy subject may already have been migrated under a + // different provider. Creating another account loses access to the old + // data; claiming it automatically could cross an identity-provider boundary. + if ( + legacy.ssoProviderId == null && + identity.ssoProviderId != null && + identity.legacyIdentifier === identity.subject && + links.some( + (link) => + link.subject === identity.subject && + link.providerId !== identity.provider, + ) + ) { + throw new LoginMethodError( + "A legacy SSO account has an unresolved provider mapping. Ask an administrator to verify and repair its identity mapping before signing in.", + 409, + "legacy_identity_conflict", + ); + } return links.length === 0; } diff --git a/src/backend/tests/auth/login-pipeline.test.ts b/src/backend/tests/auth/login-pipeline.test.ts index 20011ca93..ad9c4f412 100644 --- a/src/backend/tests/auth/login-pipeline.test.ts +++ b/src/backend/tests/auth/login-pipeline.test.ts @@ -521,6 +521,89 @@ describe("external identities", () => { ).rejects.toMatchObject({ code: "user_not_allowed" }); }); + it.each(["true", "false"])( + "does not duplicate an unscoped migrated account with provisioning %s", + async (enabled) => { + addUser({ + id: "old", + username: "Alice", + isOidc: true, + passwordHash: "", + oidcIdentifier: "sub-1", + ssoProviderId: null, + }); + const { runExternalIdentityMigration } = + await import("../../upgrade/external-identity-migration.js"); + await runExternalIdentityMigration(); + h.state.settings.set("oidc_auto_provision", enabled); + await expect( + runLogin(fakeRequest() as never, external, { + methodId: "oidc", + rememberMe: false, + }), + ).rejects.toMatchObject({ code: "legacy_identity_conflict" }); + expect([...h.state.users.keys()]).toEqual(["old"]); + expect(h.state.identities).toHaveLength(1); + expect(h.state.identities[0]).toMatchObject({ + userId: "old", + providerId: "legacy-oidc", + subject: "sub-1", + }); + }, + ); + + it("keeps equal subjects on explicitly different providers separate", async () => { + addUser({ + id: "other", + username: "Other Alice", + isOidc: true, + passwordHash: "", + oidcIdentifier: "sub-1", + ssoProviderId: 9, + }); + h.state.identities.push({ + id: 1, + userId: "other", + providerId: "9", + subject: "sub-1", + email: null, + }); + h.state.settings.set("oidc_auto_provision", "true"); + const result = await runLogin(fakeRequest() as never, external, { + methodId: "oidc", + rememberMe: false, + }); + expect(result.kind).toBe("session"); + expect(h.state.users.size).toBe(2); + expect( + h.state.identities.find((link) => link.providerId === "3")?.userId, + ).not.toBe("other"); + }); + + it("uses a verified provider link even when the old provider column is empty", async () => { + addUser({ + id: "old", + username: "Alice", + isOidc: true, + passwordHash: "", + oidcIdentifier: "sub-1", + ssoProviderId: null, + }); + h.state.identities.push({ + id: 1, + userId: "old", + providerId: "3", + subject: "sub-1", + email: null, + }); + const result = await runLogin(fakeRequest() as never, external, { + methodId: "oidc", + rememberMe: false, + }); + expect(result.kind).toBe("session"); + expect(h.state.users.size).toBe(1); + }); + it("re-checks the allowed list for existing users", async () => { addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" }); h.state.identities.push({