diff --git a/changelog.d/fixes/10815-kiro-social-multi-account.md b/changelog.d/fixes/10815-kiro-social-multi-account.md new file mode 100644 index 0000000000..45b2cfed59 --- /dev/null +++ b/changelog.d/fixes/10815-kiro-social-multi-account.md @@ -0,0 +1 @@ +- fix(oauth): stop treating the Kiro profile ARN as an account identity in `findKiroConnectionByIdentity()`, so a second Google/GitHub social login creates a new connection instead of overwriting the first — distinct Builder ID accounts share the same CodeWhisperer profile ARN, and the social token is not a JWT, so no e-mail was available to disambiguate them (#10815) diff --git a/src/lib/oauth/kiroConnectionIdentity.ts b/src/lib/oauth/kiroConnectionIdentity.ts index d5ff76157f..7a2a801b30 100644 --- a/src/lib/oauth/kiroConnectionIdentity.ts +++ b/src/lib/oauth/kiroConnectionIdentity.ts @@ -30,6 +30,27 @@ function providerData(connection: KiroConnectionLike): Record { : {}; } +/** True when the identity carries something that identifies the ACCOUNT (not the profile). */ +function hasAccountIdentifier(identity: KiroConnectionIdentity): boolean { + return Boolean(folded(identity.email) || trimmed(identity.clientId)); +} + +/** True when a shared field is present on both sides and disagrees — different accounts. */ +function contradictsAccount( + connection: KiroConnectionLike, + identity: KiroConnectionIdentity +): boolean { + const email = folded(identity.email); + const existingEmail = folded(connection.email); + if (email && existingEmail && email !== existingEmail) return true; + + const clientId = trimmed(identity.clientId); + const existingClientId = trimmed(providerData(connection).clientId); + if (clientId && existingClientId && clientId !== existingClientId) return true; + + return false; +} + /** Find an existing Kiro account without comparing OAuth tokens or API keys. */ export function findKiroConnectionByIdentity( connections: KiroConnectionLike[], @@ -45,7 +66,14 @@ export function findKiroConnectionByIdentity( const match = candidates.find( (connection) => trimmed(providerData(connection).profileArn) === profileArn ); - if (match) return match; + // A profile ARN identifies the CodeWhisperer PROFILE, not the account: distinct + // Builder ID accounts (Google/GitHub social login) share the same ARN. Accepting it + // as identity made a second social login overwrite the first connection (#10815). + // Only trust the ARN when the incoming identity carries an account-level identifier + // that does not contradict the stored one. + if (match && hasAccountIdentifier(identity) && !contradictsAccount(match, identity)) { + return match; + } } const clientId = trimmed(identity.clientId); diff --git a/tests/unit/kiro-connection-identity.test.ts b/tests/unit/kiro-connection-identity.test.ts index c5ac2b134f..bcda14c1db 100644 --- a/tests/unit/kiro-connection-identity.test.ts +++ b/tests/unit/kiro-connection-identity.test.ts @@ -79,3 +79,49 @@ test("findKiroConnectionByIdentity never overwrites a different authentication t null ); }); + +// #10815 — a profile ARN identifies the CodeWhisperer profile, not the account: two +// distinct social (Google/GitHub) Builder ID accounts share the same ARN, so matching +// on it alone made the second login overwrite the first connection. +const SHARED_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:1:profile/SHARED"; + +const firstSocialAccount = { + id: "social-account-1", + authType: "oauth", + name: null, + email: null, + providerSpecificData: { + profileArn: SHARED_PROFILE_ARN, + authMethod: "imported", + provider: "Github", + }, +}; + +test("findKiroConnectionByIdentity does not match a shared profile ARN without an account identifier", () => { + const match = findKiroConnectionByIdentity([firstSocialAccount], { + authType: "oauth", + profileArn: SHARED_PROFILE_ARN, + email: null, + }); + assert.equal(match, null); +}); + +test("findKiroConnectionByIdentity treats diverging emails on a shared profile ARN as distinct accounts", () => { + const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" }; + const match = findKiroConnectionByIdentity([stored], { + authType: "oauth", + profileArn: SHARED_PROFILE_ARN, + email: "b@example.com", + }); + assert.equal(match, null); +}); + +test("findKiroConnectionByIdentity still matches the same account on a shared profile ARN", () => { + const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" }; + const match = findKiroConnectionByIdentity([stored], { + authType: "oauth", + profileArn: SHARED_PROFILE_ARN, + email: "a@example.com", + }); + assert.equal(match?.id, "social-a"); +});