From 8097c8af1f660641c1cf2c49e6fec7ee270e1ec6 Mon Sep 17 00:00:00 2001 From: VihaanAgarwal Date: Wed, 24 Jun 2026 12:01:47 -0700 Subject: [PATCH 1/2] fix(backend): expose externalAccountId on ExternalAccount resource The backend ExternalAccount mapped the API's `id` field, which holds an `idn_` identification id, and never read `external_account_id` (the `eac_` resource id). Passing `getUser().externalAccounts[].id` to `deleteUserExternalAccount()` then returned a 404 because that method expects the `eac_` id. Map `external_account_id` to a new `externalAccountId` property so the resource id is reachable without a raw API call. `id` keeps its existing value to stay backwards compatible. --- .changeset/external-account-id.md | 5 +++ .../src/api/resources/ExternalAccount.ts | 6 +++ packages/backend/src/api/resources/JSON.ts | 1 + .../__tests__/ExternalAccount.test.ts | 40 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 .changeset/external-account-id.md create mode 100644 packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts diff --git a/.changeset/external-account-id.md b/.changeset/external-account-id.md new file mode 100644 index 00000000000..41722a3957c --- /dev/null +++ b/.changeset/external-account-id.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Add `externalAccountId` to the backend `ExternalAccount` resource. This exposes the external account's `eac_`-prefixed id returned by `getUser()`, which is the id `users.deleteUserExternalAccount()` expects. Previously only the `idn_`-prefixed identification id was reachable through `id`, so deleting an external account fetched from `getUser()` failed with a 404. diff --git a/packages/backend/src/api/resources/ExternalAccount.ts b/packages/backend/src/api/resources/ExternalAccount.ts index a623a7f8a51..849ea239098 100644 --- a/packages/backend/src/api/resources/ExternalAccount.ts +++ b/packages/backend/src/api/resources/ExternalAccount.ts @@ -12,6 +12,11 @@ export class ExternalAccount { * The unique identifier for this external account. */ readonly id: string, + /** + * The unique identifier for the external account resource (prefixed with `eac_`). + * This is the value expected by methods such as `users.deleteUserExternalAccount()`. + */ + readonly externalAccountId: string, /** * The provider name (e.g., `google`). */ @@ -74,6 +79,7 @@ export class ExternalAccount { static fromJSON(data: ExternalAccountJSON): ExternalAccount { return new ExternalAccount( data.id, + data.external_account_id, data.provider, data.provider_user_id, data.identification_id, diff --git a/packages/backend/src/api/resources/JSON.ts b/packages/backend/src/api/resources/JSON.ts index 3193401523d..03adce54096 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -231,6 +231,7 @@ export interface EnterpriseAccountJSON extends ClerkResourceJSON { export interface ExternalAccountJSON extends ClerkResourceJSON { object: typeof ObjectType.ExternalAccount; + 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..35b3002c623 --- /dev/null +++ b/packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { ExternalAccount } from '../ExternalAccount'; +import type { ExternalAccountJSON } from '../JSON'; + +describe('ExternalAccount', () => { + describe('fromJSON', () => { + const data: ExternalAccountJSON = { + object: 'external_account', + id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + external_account_id: 'eac_2ABXLObDmeHsnLsLgOd5panvOPJ', + identification_id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + 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, + } as ExternalAccountJSON; + + it('maps external_account_id to externalAccountId', () => { + const externalAccount = ExternalAccount.fromJSON(data); + + expect(externalAccount.externalAccountId).toBe('eac_2ABXLObDmeHsnLsLgOd5panvOPJ'); + }); + + it('keeps id and identificationId pointing at the identification id', () => { + const externalAccount = ExternalAccount.fromJSON(data); + + expect(externalAccount.id).toBe('idn_2ABXLLckIF5kLikvzAVRxuuN31M'); + expect(externalAccount.identificationId).toBe('idn_2ABXLLckIF5kLikvzAVRxuuN31M'); + }); + }); +}); From f16e143099828ea8ef07ed94558d4d54a76fe584 Mon Sep 17 00:00:00 2001 From: VihaanAgarwal Date: Thu, 25 Jun 2026 06:10:31 -0700 Subject: [PATCH 2/2] fix(backend): make externalAccountId optional on ExternalAccount Only Google and Facebook accounts return external_account_id, so model it as an optional field at the end of the constructor instead of a required one, keeping it backwards-compatible. Cover the non-Google shape in the test and note externalAccountId ?? id in the changeset. --- .changeset/external-account-id.md | 2 +- .../src/api/resources/ExternalAccount.ts | 15 ++++---- packages/backend/src/api/resources/JSON.ts | 5 ++- .../__tests__/ExternalAccount.test.ts | 34 ++++++++++++++----- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/.changeset/external-account-id.md b/.changeset/external-account-id.md index 41722a3957c..3a772f68318 100644 --- a/.changeset/external-account-id.md +++ b/.changeset/external-account-id.md @@ -2,4 +2,4 @@ '@clerk/backend': patch --- -Add `externalAccountId` to the backend `ExternalAccount` resource. This exposes the external account's `eac_`-prefixed id returned by `getUser()`, which is the id `users.deleteUserExternalAccount()` expects. Previously only the `idn_`-prefixed identification id was reachable through `id`, so deleting an external account fetched from `getUser()` failed with a 404. +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/resources/ExternalAccount.ts b/packages/backend/src/api/resources/ExternalAccount.ts index 849ea239098..561efee0212 100644 --- a/packages/backend/src/api/resources/ExternalAccount.ts +++ b/packages/backend/src/api/resources/ExternalAccount.ts @@ -12,11 +12,6 @@ export class ExternalAccount { * The unique identifier for this external account. */ readonly id: string, - /** - * The unique identifier for the external account resource (prefixed with `eac_`). - * This is the value expected by methods such as `users.deleteUserExternalAccount()`. - */ - readonly externalAccountId: string, /** * The provider name (e.g., `google`). */ @@ -74,12 +69,19 @@ 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, where `id` holds the `idn_` identification id instead; + * for other providers it is `undefined` and `id` is already the `eac_` value. + * Use `externalAccountId ?? id` to get an id you can delete with. + */ + readonly externalAccountId?: string, ) {} static fromJSON(data: ExternalAccountJSON): ExternalAccount { return new ExternalAccount( data.id, - data.external_account_id, data.provider, data.provider_user_id, data.identification_id, @@ -94,6 +96,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 03adce54096..65fc8b3a9ac 100644 --- a/packages/backend/src/api/resources/JSON.ts +++ b/packages/backend/src/api/resources/JSON.ts @@ -231,7 +231,10 @@ export interface EnterpriseAccountJSON extends ClerkResourceJSON { export interface ExternalAccountJSON extends ClerkResourceJSON { object: typeof ObjectType.ExternalAccount; - external_account_id: string; + /** + * 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 index 35b3002c623..2faa1047033 100644 --- a/packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts +++ b/packages/backend/src/api/resources/__tests__/ExternalAccount.test.ts @@ -5,11 +5,8 @@ import type { ExternalAccountJSON } from '../JSON'; describe('ExternalAccount', () => { describe('fromJSON', () => { - const data: ExternalAccountJSON = { + const base = { object: 'external_account', - id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', - external_account_id: 'eac_2ABXLObDmeHsnLsLgOd5panvOPJ', - identification_id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', provider: 'oauth_google', provider_user_id: '1029384756', approved_scopes: 'email profile', @@ -22,19 +19,38 @@ describe('ExternalAccount', () => { public_metadata: {}, label: null, verification: null, - } as ExternalAccountJSON; + }; + + it('maps external_account_id to externalAccountId for Google/Facebook accounts', () => { + // Google/Facebook responses set `id` to the `idn_` identification id and add `external_account_id`. + const data = { + ...base, + id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + external_account_id: 'eac_2ABXLObDmeHsnLsLgOd5panvOPJ', + identification_id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + } as ExternalAccountJSON; - it('maps external_account_id to externalAccountId', () => { 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('keeps id and identificationId pointing at the identification id', () => { + 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, + provider: 'oauth_github', + id: 'eac_2ABXLObDmeHsnLsLgOd5panvOPJ', + identification_id: 'idn_2ABXLLckIF5kLikvzAVRxuuN31M', + } as ExternalAccountJSON; + const externalAccount = ExternalAccount.fromJSON(data); - expect(externalAccount.id).toBe('idn_2ABXLLckIF5kLikvzAVRxuuN31M'); - expect(externalAccount.identificationId).toBe('idn_2ABXLLckIF5kLikvzAVRxuuN31M'); + expect(externalAccount.externalAccountId).toBeUndefined(); + expect(externalAccount.id).toBe('eac_2ABXLObDmeHsnLsLgOd5panvOPJ'); }); }); });