From afef28fb6fa57de6912d80d43a588dfa093bee28 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 25 Jun 2026 07:44:40 -0500 Subject: [PATCH] fix(backend): expose externalAccountId on ExternalAccount resource --- .changeset/backend-external-account-id.md | 5 ++ .../backend/src/api/__tests__/factory.test.ts | 6 ++ .../src/api/resources/ExternalAccount.ts | 8 +++ packages/backend/src/api/resources/JSON.ts | 4 ++ .../__tests__/ExternalAccount.test.ts | 57 +++++++++++++++++++ packages/backend/src/fixtures/user.json | 26 ++++++++- 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 .changeset/backend-external-account-id.md create mode 100644 packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts diff --git a/.changeset/backend-external-account-id.md b/.changeset/backend-external-account-id.md new file mode 100644 index 00000000000..3a772f68318 --- /dev/null +++ b/.changeset/backend-external-account-id.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add an optional `externalAccountId` to the backend `ExternalAccount` resource. For Google and Facebook accounts the resource `id` is the `idn_`-prefixed identification id, which `users.deleteUserExternalAccount()` rejects; `externalAccountId` now exposes the `eac_`-prefixed id those calls expect. For all other providers `id` is already the `eac_` id and `externalAccountId` is `undefined`, so use `externalAccountId ?? id` to get an id you can delete with. diff --git a/packages/backend/src/api/__tests__/factory.test.ts b/packages/backend/src/api/__tests__/factory.test.ts index 6bfbbdf4ce0..23b375103b0 100644 --- a/packages/backend/src/api/__tests__/factory.test.ts +++ b/packages/backend/src/api/__tests__/factory.test.ts @@ -29,6 +29,12 @@ describe('api.client', () => { expect(response.emailAddresses[0].emailAddress).toBe('john.doe@clerk.test'); expect(response.phoneNumbers[0].phoneNumber).toBe('+311-555-2368'); expect(response.externalAccounts[0].emailAddress).toBe('john.doe@clerk.test'); + // Google/Facebook: `id` is the `idn_` identification id and `externalAccountId` carries the `eac_` id. + expect(response.externalAccounts[0].id).toBe('idn_2abcGoogleIdentification00000'); + expect(response.externalAccounts[0].externalAccountId).toBe('eac_2abcGoogleExternalAccount0000'); + // Other providers: `id` is already the `eac_` id and `externalAccountId` is absent. + expect(response.externalAccounts[1].id).toBe('eac_2defGithubExternalAccount0000'); + expect(response.externalAccounts[1].externalAccountId).toBeUndefined(); expect(response.enterpriseAccounts[0].emailAddress).toBe('john.doe@clerk.test'); expect(response.enterpriseAccounts[0].provider).toBe('saml_okta'); expect(response.enterpriseAccounts[0].enterpriseConnection?.name).toBe('Okta SSO'); diff --git a/packages/backend/src/api/resources/ExternalAccount.ts b/packages/backend/src/api/resources/ExternalAccount.ts index a623a7f8a51..70e01583206 100644 --- a/packages/backend/src/api/resources/ExternalAccount.ts +++ b/packages/backend/src/api/resources/ExternalAccount.ts @@ -69,6 +69,13 @@ export class ExternalAccount { * An object holding information on the verification of this external account. */ readonly verification: Verification | null, + /** + * The `eac_`-prefixed id of the external account resource, which is the id + * `users.deleteUserExternalAccount()` expects. Only returned for Google and + * Facebook accounts; for other providers it is `undefined` and `id` already + * holds the `eac_` value. Use `externalAccountId ?? id` to get a deletable id. + */ + readonly externalAccountId?: string, ) {} static fromJSON(data: ExternalAccountJSON): ExternalAccount { @@ -88,6 +95,7 @@ export class ExternalAccount { data.public_metadata, data.label, data.verification && Verification.fromJSON(data.verification), + data.external_account_id, ); } } diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 3193401523d..65fc8b3a9ac 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -231,6 +231,10 @@ export interface EnterpriseAccountJSON extends ClerkResourceJSON { export interface ExternalAccountJSON extends ClerkResourceJSON { object: typeof ObjectType.ExternalAccount; + /** + * The `eac_`-prefixed external account id. Only present for Google and Facebook accounts. + */ + external_account_id?: string; provider: string; identification_id: string; provider_user_id: string; diff --git a/packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts b/packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts new file mode 100644 index 00000000000..aa2f8b37e41 --- /dev/null +++ b/packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { ExternalAccount } from '../ExternalAccount'; +import type { ExternalAccountJSON } from '../JSON'; + +describe('ExternalAccount', () => { + describe('fromJSON', () => { + const base = { + provider: 'oauth_google', + provider_user_id: '1029384756', + approved_scopes: 'email profile', + email_address: 'jane@example.com', + first_name: 'Jane', + last_name: 'Doe', + image_url: 'https://img.clerk.com/jane.png', + username: 'jane', + phone_number: null, + public_metadata: {}, + label: null, + verification: null, + }; + + it('maps external_account_id to externalAccountId when present (Google/Facebook)', () => { + // Google/Facebook responses set `id` to the `idn_` identification id and add `external_account_id`. + const data = { + ...base, + object: 'external_account', + id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + external_account_id: 'eac_2ABXLObDmeHsnLsLgOd5panvOPJ', + identification_id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + } as ExternalAccountJSON; + + const externalAccount = ExternalAccount.fromJSON(data); + + expect(externalAccount.externalAccountId).toBe('eac_2ABXLObDmeHsnLsLgOd5panvOPJ'); + // `id` and `identificationId` keep the `idn_` value for these providers. + expect(externalAccount.id).toBe('idn_2ABXLLckIF5kLikvzAVRxuuN31M'); + expect(externalAccount.identificationId).toBe('idn_2ABXLLckIF5kLikvzAVRxuuN31M'); + }); + + it('leaves externalAccountId undefined for other providers, where id is already the eac_ id', () => { + // Other providers omit `external_account_id`; `id` already holds the `eac_` value. + const data = { + ...base, + object: 'external_account', + provider: 'oauth_github', + id: 'eac_2ABXLObDmeHsnLsLgOd5panvOPJ', + identification_id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + } as ExternalAccountJSON; + + const externalAccount = ExternalAccount.fromJSON(data); + + expect(externalAccount.externalAccountId).toBeUndefined(); + expect(externalAccount.id).toBe('eac_2ABXLObDmeHsnLsLgOd5panvOPJ'); + }); + }); +}); diff --git a/packages/backend/src/fixtures/user.json b/packages/backend/src/fixtures/user.json index 74ced8e4f9c..f8a78fb4e2b 100644 --- a/packages/backend/src/fixtures/user.json +++ b/packages/backend/src/fixtures/user.json @@ -72,10 +72,11 @@ ], "external_accounts": [ { - "object": "external_account", + "object": "google_account", "provider": "google", - "id": "gac_google", - "identification_id": "1234567890", + "id": "idn_2abcGoogleIdentification00000", + "external_account_id": "eac_2abcGoogleExternalAccount0000", + "identification_id": "idn_2abcGoogleIdentification00000", "provider_user_id": "1234567890", "approved_scopes": "email https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid profile", "email_address": "john.doe@clerk.test", @@ -89,6 +90,25 @@ "verification": null, "created_at": 1611948436, "updated_at": 1611948436 + }, + { + "object": "external_account", + "provider": "github", + "id": "eac_2defGithubExternalAccount0000", + "identification_id": "idn_2defGithubIdentification00000", + "provider_user_id": "9876543210", + "approved_scopes": "read:user user:email", + "email_address": "john.doe@clerk.test", + "first_name": "John", + "last_name": "Doe", + "avatar_url": "https://clerk.com/test.jpg", + "username": "jdoe", + "phone_number": null, + "public_metadata": {}, + "label": null, + "verification": null, + "created_at": 1611948436, + "updated_at": 1611948436 } ], "enterprise_accounts": [