fix(editor): Show the connected provider account, not the n8n account email (#35553)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-08-11 17:25:52 +02:00 committed by GitHub
parent 7352eb7def
commit ee03e023d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 592 additions and 150 deletions

View File

@ -5,6 +5,12 @@
*/
export interface CredentialConnectionStatus {
connectedByMe?: boolean;
/**
* The provider account the requesting user's own connection authenticates as
* (e.g. the connected Gmail address). Undefined when the provider returns no
* identity claim, which is common — never fall back to the n8n account here.
*/
connectedAccountIdentifier?: string;
/** Total number of users who have a per-user entry for this credential. */
connectedUserCount?: number;
}

View File

@ -1249,16 +1249,21 @@ describe('CredentialsService', () => {
transactionManager.findOneBy.mockResolvedValue(credential);
setRepositoryTransaction(transactionManager);
const encrypted = mock<ICredentialsDb>({ id: credential.id });
connectionStatusProxy.findConnectedCredentialIds.mockResolvedValue(new Set([credential.id]));
connectionStatusProxy.findMyConnections.mockResolvedValue(
new Map([[credential.id, { accountIdentifier: 'me@gmail.com' }]]),
);
const result = await service.update(credential.id, encrypted, undefined, {
user: ownerUser,
});
expect(connectionStatusProxy.findConnectedCredentialIds).toHaveBeenCalledWith(ownerUser.id, [
expect(connectionStatusProxy.findMyConnections).toHaveBeenCalledWith(ownerUser.id, [
credential.id,
]);
expect(result).toMatchObject({ connectedByMe: true });
expect(result).toMatchObject({
connectedByMe: true,
connectedAccountIdentifier: 'me@gmail.com',
});
});
it('does not enrich the returned credential when no user is passed', async () => {
@ -1273,7 +1278,7 @@ describe('CredentialsService', () => {
await service.update(credential.id, encrypted);
expect(connectionStatusProxy.findConnectedCredentialIds).not.toHaveBeenCalled();
expect(connectionStatusProxy.findMyConnections).not.toHaveBeenCalled();
});
});
@ -2470,7 +2475,7 @@ describe('CredentialsService', () => {
(c) => ({ ...c, scopes: ['credential:read'] }) as ListQueryDb.Credentials.WithScopes,
);
sharedCredentialsRepository.getAllRelationsForCredentials.mockResolvedValue([]);
connectionStatusProxy.findConnectedCredentialIds.mockResolvedValue(new Set());
connectionStatusProxy.findMyConnections.mockResolvedValue(new Map());
ownershipService.addOwnedByAndSharedWith.mockImplementation(
(c: ListQueryDb.Credentials.WithOwnedByAndSharedWith) => {
c.homeProject = null;

View File

@ -1,5 +1,15 @@
import type { EntityManager } from '@n8n/typeorm';
/** One user's connection to one credential. */
export type UserConnection = {
/**
* The provider account the connection authenticates as (e.g. the connected
* Gmail address). Undefined whenever the provider returns no identity claim,
* which is common — callers must be able to render a connection without it.
*/
accountIdentifier?: string;
};
/**
* Interface for per-user credential connection state providers.
*
@ -10,17 +20,17 @@ import type { EntityManager } from '@n8n/typeorm';
*
* Modules register a concrete provider at init time; if no provider is
* registered, the {@link CredentialConnectionStatusProxy} degrades to a no-op
* (empty set), so read endpoints stay functional even when the
* (empty map), so read endpoints stay functional even when the
* dynamic-credentials feature is disabled.
*/
export interface ICredentialConnectionStatusProvider {
/**
* Returns the subset of `credentialIds` for which the user has at least
* one per-user storage entry.
* Returns the subset of `credentialIds` the user has a per-user storage entry
* for, keyed by credential id.
*
* Implementations must execute a single bulk query (no N+1).
*/
findConnectedCredentialIds(userId: string, credentialIds: string[]): Promise<Set<string>>;
findMyConnections(userId: string, credentialIds: string[]): Promise<Map<string, UserConnection>>;
/**
* Returns the number of distinct users who have a per-user entry for this

View File

@ -1,7 +1,10 @@
import { Service } from '@n8n/di';
import type { EntityManager } from '@n8n/typeorm';
import type { ICredentialConnectionStatusProvider } from './credential-connection-status-provider.interface';
import type {
ICredentialConnectionStatusProvider,
UserConnection,
} from './credential-connection-status-provider.interface';
/**
* Proxy between the core credentials service and module-owned per-user
@ -17,9 +20,12 @@ export class CredentialConnectionStatusProxy implements ICredentialConnectionSta
this.provider = provider;
}
async findConnectedCredentialIds(userId: string, credentialIds: string[]): Promise<Set<string>> {
if (!this.provider || credentialIds.length === 0) return new Set();
return await this.provider.findConnectedCredentialIds(userId, credentialIds);
async findMyConnections(
userId: string,
credentialIds: string[],
): Promise<Map<string, UserConnection>> {
if (!this.provider || credentialIds.length === 0) return new Map();
return await this.provider.findMyConnections(userId, credentialIds);
}
async countConnectedUsers(credentialId: string): Promise<number> {

View File

@ -1,3 +1,4 @@
import type { CredentialConnectionStatus } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import {
@ -157,7 +158,7 @@ export class EnterpriseCredentialsService {
const { data: _, ...rest } = credential;
const enriched: typeof rest & { connectedByMe?: boolean; connectedUserCount?: number } = rest;
const enriched: typeof rest & CredentialConnectionStatus = rest;
await this.credentialsService.populateConnectedByMe([enriched], user);
if (credential.isResolvable) {

View File

@ -1,4 +1,4 @@
import type { CreateCredentialDto } from '@n8n/api-types';
import type { CreateCredentialDto, CredentialConnectionStatus } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import {
Project,
@ -155,8 +155,7 @@ type WorkflowCredentialResult = {
homeProject: SlimProject | null;
sharedWithProjects: SlimProject[];
currentUserHasAccess: boolean;
connectedByMe?: boolean;
};
} & CredentialConnectionStatus;
/** Codes an auth probe must not treat as rejection, stored as a JSON array in the credential. */
function parseAcceptedStatusCodes(raw: unknown): number[] | undefined {
@ -193,18 +192,18 @@ export class CredentialsService {
private readonly dbLockService: DbLockService,
) {}
/**
* Sets `connectedByMe` on every resolvable credential in `credentials`,
* using a single bulk lookup against the per-user storage. Static
* (non-resolvable) credentials are left untouched.
*
* Mutates in place; callers may pass entities, decrypted DTOs, or plain
* object literals — any shape that carries `id` and `isResolvable`.
*/
async countConnectedUsers(credentialId: string): Promise<number> {
return await this.connectionStatusProxy.countConnectedUsers(credentialId);
}
/**
* Sets `connectedByMe` and `connectedAccountIdentifier` on every resolvable
* credential in `credentials`, using a single bulk lookup against the per-user
* storage. Static (non-resolvable) credentials are left untouched.
*
* Mutates in place; callers may pass entities, decrypted DTOs, or plain
* object literals — any shape that carries `id` and `isResolvable`.
*/
async populateConnectedByMe<T extends { id: string; isResolvable?: boolean }>(
credentials: T[],
user: User,
@ -212,13 +211,18 @@ export class CredentialsService {
const resolvable = credentials.filter((c) => c.isResolvable === true);
if (resolvable.length === 0) return;
const connected = await this.connectionStatusProxy.findConnectedCredentialIds(
const connections = await this.connectionStatusProxy.findMyConnections(
user.id,
resolvable.map((c) => c.id),
);
for (const c of resolvable) {
(c as T & { connectedByMe?: boolean }).connectedByMe = connected.has(c.id);
const connection = connections.get(c.id);
const enriched = c as T & CredentialConnectionStatus;
enriched.connectedByMe = connection !== undefined;
// The account the caller's own connection authenticates as. Only ever
// their own — a connection is never labelled with someone else's account.
enriched.connectedAccountIdentifier = connection?.accountIdentifier;
}
}
@ -996,7 +1000,7 @@ export class CredentialsService {
}
// Reflect connections cleared above by deleteUserEntries, not a stale pre-update value.
const enriched: (CredentialsEntity & { connectedByMe?: boolean }) | null = result;
const enriched: (CredentialsEntity & CredentialConnectionStatus) | null = result;
if (enriched && options?.user) {
await this.populateConnectedByMe([enriched], options.user);
}
@ -1564,7 +1568,7 @@ export class CredentialsService {
const { data: _, ...rest } = credential;
const enriched: typeof rest & { connectedByMe?: boolean; connectedUserCount?: number } = rest;
const enriched: typeof rest & CredentialConnectionStatus = rest;
await this.populateConnectedByMe([enriched], user);
if (credential.isResolvable) {

View File

@ -1,3 +1,4 @@
import type { Logger } from '@n8n/backend-common';
import type {
ProjectRelationRepository,
SharedCredentialsRepository,
@ -5,10 +6,12 @@ import type {
UserRepository,
} from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import type { Cipher } from 'n8n-core';
import { mock } from 'vitest-mock-extended';
import type { RoleService } from '@/services/role.service';
import { SYSTEM_RESOLVER_ID } from '../../constants';
import { DynamicCredentialUserEntry } from '../../database/entities/dynamic-credential-user-entry';
import type { DynamicCredentialUserEntryRepository } from '../../database/repositories/dynamic-credential-user-entry.repository';
import { CredentialConnectionStatusService } from '../credential-connection-status.service';
@ -17,10 +20,11 @@ import { CredentialConnectionStatusService } from '../credential-connection-stat
const makeUser = (id: string, globalScopes: string[] = []): User =>
({ id, role: { scopes: globalScopes.map((slug) => ({ slug })) } }) as unknown as User;
const makeEntry = (credentialId: string, userId: string): DynamicCredentialUserEntry => {
const makeEntry = (credentialId: string, userId: string, data = ''): DynamicCredentialUserEntry => {
const e = new DynamicCredentialUserEntry();
e.credentialId = credentialId;
e.userId = userId;
e.data = data;
return e;
};
@ -30,6 +34,8 @@ describe('CredentialConnectionStatusService', () => {
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();
const roleService = mock<RoleService>();
const projectRelationRepository = mock<ProjectRelationRepository>();
const cipher = mock<Cipher>();
const logger = mock<Logger>();
const em = mock<EntityManager>();
const service = new CredentialConnectionStatusService(
@ -38,6 +44,8 @@ describe('CredentialConnectionStatusService', () => {
sharedCredentialsRepository,
roleService,
projectRelationRepository,
cipher,
logger,
);
const CRED_ID = 'cred-1';
@ -48,6 +56,80 @@ describe('CredentialConnectionStatusService', () => {
roleService.rolesWithScope.mockResolvedValue(VALID_CRED_ROLES);
});
// ──────────────────────────── findMyConnections ───────────────────────────
describe('findMyConnections', () => {
const storedToken = (tokenData: Record<string, unknown>) =>
JSON.stringify({ oauthTokenData: tokenData });
it('returns an empty map without querying when no credential ids are given', async () => {
const result = await service.findMyConnections('user-1', []);
expect(result.size).toBe(0);
expect(repository.find).not.toHaveBeenCalled();
});
it('reports the provider account the stored token belongs to', async () => {
repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'encrypted')]);
cipher.decryptV2.mockResolvedValueOnce(storedToken({ email: 'me@gmail.com' }));
const result = await service.findMyConnections('user-1', [CRED_ID]);
expect(result.get(CRED_ID)).toEqual({ accountIdentifier: 'me@gmail.com' });
});
it('scopes the lookup to the caller and the system resolver', async () => {
repository.find.mockResolvedValueOnce([]);
await service.findMyConnections('user-1', [CRED_ID]);
expect(repository.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: 'user-1', resolverId: SYSTEM_RESOLVER_ID }),
}),
);
});
it('reports a connection with no account when the token carries no identity', async () => {
// Gmail asks for no identity scope, so Google returns no id_token. Routine,
// so it must stay silent rather than look like a failure.
repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'encrypted')]);
cipher.decryptV2.mockResolvedValueOnce(storedToken({ access_token: 'tok' }));
const result = await service.findMyConnections('user-1', [CRED_ID]);
expect(result.has(CRED_ID)).toBe(true);
expect(result.get(CRED_ID)?.accountIdentifier).toBeUndefined();
expect(logger.warn).not.toHaveBeenCalled();
});
it('warns but still reports a connection when its entry cannot be decrypted', async () => {
// Unreadable stored data breaks the credential at execution too, so it is
// not the same class of event as a token without an identity claim.
repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'unreadable')]);
cipher.decryptV2.mockRejectedValueOnce(new Error('bad decrypt'));
const result = await service.findMyConnections('user-1', [CRED_ID]);
expect(result.has(CRED_ID)).toBe(true);
expect(result.get(CRED_ID)?.accountIdentifier).toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not read the stored data'),
expect.objectContaining({ credentialId: CRED_ID }),
);
});
it('still reports a connection when its entry holds no token', async () => {
repository.find.mockResolvedValueOnce([makeEntry(CRED_ID, 'user-1', 'encrypted')]);
cipher.decryptV2.mockResolvedValueOnce(JSON.stringify({ apiKey: 'k' }));
const result = await service.findMyConnections('user-1', [CRED_ID]);
expect(result.has(CRED_ID)).toBe(true);
expect(result.get(CRED_ID)?.accountIdentifier).toBeUndefined();
});
});
// ─────────────────────────── early-exit guards ────────────────────────────
describe('cleanupOrphanedEntriesForUsers', () => {

View File

@ -1,3 +1,4 @@
import { Logger } from '@n8n/backend-common';
import {
In,
ProjectRelationRepository,
@ -8,8 +9,14 @@ import {
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import type { EntityManager } from '@n8n/typeorm';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';
import type { ICredentialConnectionStatusProvider } from '@/credentials/credential-connection-status-provider.interface';
import type {
ICredentialConnectionStatusProvider,
UserConnection,
} from '@/credentials/credential-connection-status-provider.interface';
import { extractAccountIdentifierFromData } from '@/oauth/account-identifier';
import { RoleService } from '@/services/role.service';
import { SYSTEM_RESOLVER_ID } from '../constants';
@ -25,9 +32,9 @@ const CREDENTIAL_RETAIN_SCOPE = 'credential:connect' as const;
const keyOf = (pair: CredentialUserPair) => `${pair.credentialId}|${pair.userId}`;
/**
* Returns the set of credential ids for which a given user has a per-user
* storage entry under the system resolver. Existence is the signal — no
* decryption is performed.
* Reports which credentials a given user has a per-user storage entry for under
* the system resolver, and which provider account each of those entries
* authenticates as.
*
* Scoped to the system resolver ({@link SYSTEM_RESOLVER_ID}) because that is
* the only resolver used to record per-user OAuth connections today. Entries
@ -44,13 +51,18 @@ export class CredentialConnectionStatusService implements ICredentialConnectionS
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
private readonly roleService: RoleService,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly cipher: Cipher,
private readonly logger: Logger,
) {}
async findConnectedCredentialIds(userId: string, credentialIds: string[]): Promise<Set<string>> {
if (credentialIds.length === 0) return new Set();
async findMyConnections(
userId: string,
credentialIds: string[],
): Promise<Map<string, UserConnection>> {
if (credentialIds.length === 0) return new Map();
const rows = await this.repository.find({
select: ['credentialId'],
select: ['credentialId', 'data'],
where: {
userId,
resolverId: SYSTEM_RESOLVER_ID,
@ -58,12 +70,54 @@ export class CredentialConnectionStatusService implements ICredentialConnectionS
},
});
return new Set(rows.map((row) => row.credentialId));
return new Map(
await Promise.all(
rows.map(
async (row) =>
[
row.credentialId,
{ accountIdentifier: await this.readAccountIdentifier(row) },
] as const,
),
),
);
}
/**
* The provider account (e.g. the Gmail address) this connection authenticates
* as, read out of the stored token. Derived on read rather than denormalized so
* it stays right after a "Switch account" and needs no backfill for connections
* made before it was surfaced.
*
* Never throws — a connection we cannot label is still a connection. The two
* reasons differ in severity, so they are kept apart: unreadable stored data
* means the connection is broken for execution too and is worth a warning,
* whereas a token carrying no identity claim is routine (Gmail asks for no
* identity scope) and stays silent.
*/
private async readAccountIdentifier(
row: Pick<DynamicCredentialUserEntry, 'credentialId' | 'data'>,
): Promise<string | undefined> {
let stored: Record<string, unknown>;
try {
stored = jsonParse<Record<string, unknown>>(await this.cipher.decryptV2(row.data));
} catch (error) {
// Only a failed decrypt or a corrupt payload reaches here. The resolver
// reads the same row the same way when the credential is used, so this
// connection will fail at execution — surface it rather than hide it.
this.logger.warn('Could not read the stored data of a per-user credential connection', {
credentialId: row.credentialId,
error,
});
return undefined;
}
return extractAccountIdentifierFromData(stored);
}
/**
* Deletes the running user's connection row(s) for the given credential.
* Scoped to the system resolver to mirror {@link findConnectedCredentialIds}.
* Scoped to the system resolver to mirror {@link findMyConnections}.
* Returns the number of rows deleted.
*/
async deleteMyConnection(userId: string, credentialId: string): Promise<number> {

View File

@ -1872,15 +1872,12 @@ export class InstanceAiAdapterService {
// Fallback for legacy credentials: oauthTokenData is blanked by
// redaction, so we need unredacted access here only.
const raw = await credentialsService.decrypt(credential, true);
const tokenData = raw.oauthTokenData;
if (tokenData && typeof tokenData === 'object') {
const { OauthService } = await import('@/oauth/oauth.service.js');
const identifier = OauthService.extractAccountIdentifier(
tokenData as Record<string, unknown>,
);
if (identifier) {
return { accountIdentifier: mask(identifier) };
}
const { extractAccountIdentifierFromData } = await import(
'@/oauth/account-identifier.js'
);
const identifier = extractAccountIdentifierFromData(raw);
if (identifier) {
return { accountIdentifier: mask(identifier) };
}
return { accountIdentifier: undefined };

View File

@ -0,0 +1,91 @@
import { extractAccountIdentifier, extractAccountIdentifierFromData } from '../account-identifier';
describe('extractAccountIdentifier', () => {
it('returns email from direct token field', () => {
expect(extractAccountIdentifier({ email: 'user@example.com', access_token: 'tok' })).toBe(
'user@example.com',
);
});
it('returns login from direct token field (GitHub-style)', () => {
expect(extractAccountIdentifier({ login: 'octocat', access_token: 'tok' })).toBe('octocat');
});
it('extracts email from JWT id_token', () => {
const payload = { email: 'user@gmail.com', sub: '123' };
const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
expect(extractAccountIdentifier({ id_token: idToken })).toBe('user@gmail.com');
});
it('extracts preferred_username from JWT id_token when no email', () => {
const payload = { preferred_username: 'admin@contoso.com', sub: '123' };
const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
expect(extractAccountIdentifier({ id_token: idToken })).toBe('admin@contoso.com');
});
it('falls back to the display name when only the profile scope was granted', () => {
// Google returns no `email` claim unless the email scope was requested.
const payload = { sub: '10464857079', name: 'Ada Lovelace', given_name: 'Ada' };
const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
expect(extractAccountIdentifier({ id_token: idToken })).toBe('Ada Lovelace');
});
it('never labels a connection with the opaque subject id', () => {
const payload = { sub: '10464857079', iss: 'https://accounts.google.com' };
const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
expect(extractAccountIdentifier({ id_token: idToken })).toBeUndefined();
});
it('returns undefined for token data without identifiers', () => {
expect(extractAccountIdentifier({ access_token: 'tok', refresh_token: 'ref' })).toBeUndefined();
});
it('returns undefined for a Google token granted only resource scopes', () => {
// A Drive-scoped grant carries no id_token at all.
expect(
extractAccountIdentifier({
access_token: 'ya29.tok',
refresh_token: '1//ref',
scope: 'https://www.googleapis.com/auth/drive',
token_type: 'Bearer',
callbackQueryString: { iss: 'https://accounts.google.com' },
}),
).toBeUndefined();
});
it('handles malformed JWT gracefully', () => {
expect(extractAccountIdentifier({ id_token: 'not.a.jwt' })).toBeUndefined();
});
it('prefers direct fields over id_token', () => {
const payload = { email: 'jwt@example.com' };
const idToken = `h.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.s`;
expect(extractAccountIdentifier({ email: 'direct@example.com', id_token: idToken })).toBe(
'direct@example.com',
);
});
it('falls back to the Slack-style authed_user id', () => {
expect(extractAccountIdentifier({ authed_user: { id: 'U123' } })).toBe('U123');
});
});
describe('extractAccountIdentifierFromData', () => {
it('reads the identifier off a payload carrying a token', () => {
expect(
extractAccountIdentifierFromData({
clientId: 'abc',
oauthTokenData: { email: 'user@example.com' },
}),
).toBe('user@example.com');
});
it.each([
['no payload', undefined],
['a payload with no token', { clientId: 'abc' }],
['a token that is not an object', { oauthTokenData: 'redacted' }],
['a null token', { oauthTokenData: null }],
])('returns undefined for %s', (_case, data) => {
expect(extractAccountIdentifierFromData(data)).toBeUndefined();
});
});

View File

@ -5204,52 +5204,6 @@ describe('OauthService', () => {
});
});
describe('extractAccountIdentifier', () => {
it('returns email from direct token field', () => {
expect(
OauthService.extractAccountIdentifier({ email: 'user@example.com', access_token: 'tok' }),
).toBe('user@example.com');
});
it('returns login from direct token field (GitHub-style)', () => {
expect(OauthService.extractAccountIdentifier({ login: 'octocat', access_token: 'tok' })).toBe(
'octocat',
);
});
it('extracts email from JWT id_token', () => {
const payload = { email: 'user@gmail.com', sub: '123' };
const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
expect(OauthService.extractAccountIdentifier({ id_token: idToken })).toBe('user@gmail.com');
});
it('extracts preferred_username from JWT id_token when no email', () => {
const payload = { preferred_username: 'admin@contoso.com', sub: '123' };
const idToken = `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`;
expect(OauthService.extractAccountIdentifier({ id_token: idToken })).toBe(
'admin@contoso.com',
);
});
it('returns undefined for token data without identifiers', () => {
expect(
OauthService.extractAccountIdentifier({ access_token: 'tok', refresh_token: 'ref' }),
).toBeUndefined();
});
it('handles malformed JWT gracefully', () => {
expect(OauthService.extractAccountIdentifier({ id_token: 'not.a.jwt' })).toBeUndefined();
});
it('prefers direct fields over id_token', () => {
const payload = { email: 'jwt@example.com' };
const idToken = `h.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.s`;
expect(
OauthService.extractAccountIdentifier({ email: 'direct@example.com', id_token: idToken }),
).toBe('direct@example.com');
});
});
describe('refreshOAuth2CredentialById', () => {
const credentialId = 'cred-123';
const projectId = 'proj-456';

View File

@ -0,0 +1,59 @@
import { jsonParse } from 'n8n-workflow';
/**
* Best-effort extraction of the provider-side account an OAuth token belongs to
* (an email, login, display name or user id), used to label a connection in the
* UI as "Connected as …". Providers expose this inconsistently and many expose it
* not at all — `undefined` is an expected result, not a failure. What comes back
* is only ever a label; nothing keys off it.
*/
export function extractAccountIdentifier(tokenData: Record<string, unknown>): string | undefined {
for (const key of ['email', 'login', 'username', 'user', 'account']) {
if (typeof tokenData[key] === 'string' && tokenData[key]) {
return tokenData[key];
}
}
if (typeof tokenData.id_token === 'string') {
const parts = tokenData.id_token.split('.');
if (parts.length === 3) {
// A malformed JWT just means no identity, so fall through rather than throw.
const payload = jsonParse<Record<string, unknown>>(
Buffer.from(parts[1], 'base64url').toString(),
{ fallbackValue: {} },
);
// Standard OIDC claims, most to least precise. `name` is the display name
// a `profile`-scoped grant returns when `email` was not requested — worth
// showing, unlike the opaque `sub`, which we deliberately never use.
for (const claim of ['email', 'preferred_username', 'name']) {
if (typeof payload[claim] === 'string' && payload[claim]) {
return payload[claim];
}
}
}
}
const authedUser = tokenData.authed_user;
if (authedUser && typeof authedUser === 'object') {
const user = authedUser as Record<string, unknown>;
if (typeof user.id === 'string' && user.id) {
return user.id;
}
}
return undefined;
}
/**
* {@link extractAccountIdentifier} for a decrypted credential payload, read off its
* `oauthTokenData` if it has one. Callers reach this from three different sources —
* a per-user storage entry, a credential blob, an in-flight update — so the guard
* and the cast live here once rather than at each call site.
*/
export function extractAccountIdentifierFromData(
data: Record<string, unknown> | undefined,
): string | undefined {
const tokenData = data?.oauthTokenData;
if (!tokenData || typeof tokenData !== 'object') return undefined;
return extractAccountIdentifier(tokenData as Record<string, unknown>);
}

View File

@ -26,6 +26,7 @@ import { AuthError } from '@/errors/response-errors/auth.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { OAuthRequest } from '@/requests';
import { extractAccountIdentifierFromData } from '@/oauth/account-identifier';
import { validateOAuthUrl } from '@/oauth/validate-oauth-url';
import { UrlService } from '@/services/url.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
@ -397,13 +398,9 @@ export class OauthService {
toUpdate: ICredentialDataDecryptedObject,
toDelete: string[] = [],
) {
if (toUpdate.oauthTokenData && typeof toUpdate.oauthTokenData === 'object') {
const identifier = OauthService.extractAccountIdentifier(
toUpdate.oauthTokenData as Record<string, unknown>,
);
if (identifier) {
toUpdate.accountIdentifier = identifier;
}
const identifier = extractAccountIdentifierFromData(toUpdate);
if (identifier) {
toUpdate.accountIdentifier = identifier;
}
const credentials = new Credentials(credential, credential.type, credential.data);
@ -414,41 +411,6 @@ export class OauthService {
});
}
static extractAccountIdentifier(tokenData: Record<string, unknown>): string | undefined {
for (const key of ['email', 'login', 'username', 'user', 'account']) {
if (typeof tokenData[key] === 'string' && tokenData[key]) {
return tokenData[key];
}
}
if (typeof tokenData.id_token === 'string') {
const parts = tokenData.id_token.split('.');
if (parts.length === 3) {
try {
const payload: Record<string, unknown> = JSON.parse(
Buffer.from(parts[1], 'base64url').toString(),
);
if (typeof payload.email === 'string' && payload.email) {
return payload.email;
}
if (typeof payload.preferred_username === 'string' && payload.preferred_username) {
return payload.preferred_username;
}
} catch {}
}
}
const authedUser = tokenData.authed_user;
if (authedUser && typeof authedUser === 'object') {
const user = authedUser as Record<string, unknown>;
if (typeof user.id === 'string' && user.id) {
return user.id;
}
}
return undefined;
}
/** Get a credential without user check */
protected async getCredentialWithoutUser(
credentialId: string,

View File

@ -8,6 +8,7 @@ import {
} from '@n8n/backend-test-utils';
import type { Project, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { Cipher } from 'n8n-core';
import {
SYSTEM_RESOLVER_ID,
@ -94,6 +95,16 @@ const seedUserEntry = async (credentialId: string, userId: string) => {
);
};
/** Seeds a connection the way the OAuth callback does: an encrypted token blob. */
const seedConnectedUserEntry = async (
credentialId: string,
userId: string,
oauthTokenData: Record<string, unknown>,
) => {
const encrypted = await Container.get(Cipher).encryptV2(JSON.stringify({ oauthTokenData }));
await storage.setCredentialData(credentialId, userId, SYSTEM_RESOLVER_ID, encrypted, {});
};
const saveResolvableCredential = async () =>
await saveCredential(randomCredentialPayload({ isResolvable: true }), {
project: teamProject,
@ -116,6 +127,7 @@ type CredentialResponseFields = {
id: string;
isResolvable?: boolean;
connectedByMe?: boolean;
connectedAccountIdentifier?: string;
data?: { oauthTokenData?: unknown };
};
@ -146,6 +158,44 @@ describe('GET /credentials — connectedByMe', () => {
expect(cred?.connectedByMe).toBe(false);
});
test('reports the provider account my own connection authenticates as', async () => {
const resolvable = await saveResolvableCredential();
await seedConnectedUserEntry(resolvable.id, memberA.id, { email: 'connected@gmail.com' });
const response = await testServer.authAgentFor(memberA).get('/credentials').expect(200);
const cred = (response.body.data as CredentialResponseFields[]).find(
(c) => c.id === resolvable.id,
);
expect(cred?.connectedAccountIdentifier).toBe('connected@gmail.com');
});
test('never reports the connected account of another user', async () => {
const resolvable = await saveResolvableCredential();
await seedConnectedUserEntry(resolvable.id, memberA.id, { email: 'connected@gmail.com' });
await seedConnectedUserEntry(resolvable.id, memberB.id, { email: 'other@gmail.com' });
const response = await testServer.authAgentFor(memberB).get('/credentials').expect(200);
const cred = (response.body.data as CredentialResponseFields[]).find(
(c) => c.id === resolvable.id,
);
expect(cred?.connectedAccountIdentifier).toBe('other@gmail.com');
});
test('reports a connection without an account when the token carries no identity', async () => {
const resolvable = await saveResolvableCredential();
await seedConnectedUserEntry(resolvable.id, memberA.id, { access_token: 'tok' });
const response = await testServer.authAgentFor(memberA).get('/credentials').expect(200);
const cred = (response.body.data as CredentialResponseFields[]).find(
(c) => c.id === resolvable.id,
);
expect(cred?.connectedByMe).toBe(true);
expect(cred?.connectedAccountIdentifier).toBeUndefined();
});
test('omits connectedByMe for static credentials', async () => {
const staticCred = await saveStaticCredential();
@ -255,6 +305,21 @@ describe('GET /credentials/:id — per-user oauthTokenData', () => {
expect(bodyB.data?.oauthTokenData).toBeUndefined();
});
test('reports the connected account the credential modal reads on open', async () => {
const resolvable = await saveResolvableCredential();
await seedConnectedUserEntry(resolvable.id, memberA.id, { email: 'connected@gmail.com' });
const response = await testServer
.authAgentFor(memberA)
.get(`/credentials/${resolvable.id}`)
.query({ includeData: true })
.expect(200);
expect((response.body.data as CredentialResponseFields).connectedAccountIdentifier).toBe(
'connected@gmail.com',
);
});
test('preserves static oauthTokenData behavior (shared across all readers)', async () => {
const staticCred = await saveStaticCredentialWithOauthTokenData();

View File

@ -351,6 +351,7 @@ export type CredentialsResource = BaseResource & {
isGlobal?: boolean;
isResolvable?: boolean;
connectedByMe?: boolean;
connectedAccountIdentifier?: string;
};
// Base resource types that are always available

View File

@ -598,6 +598,61 @@ describe('CredentialConfig', () => {
expect(emitted().oauth).toBeTruthy();
});
describe('connected account label', () => {
it('names the provider account an end-user credential is connected as', () => {
renderComponent({
props: {
...oAuthConnectedProps,
isPrivateCredentialsEnabled: true,
isResolvable: true,
connectedByMe: true,
connectedAccountIdentifier: 'jane@gmail.com',
},
});
expect(screen.getByTestId('oauth-connect-success-banner')).toHaveTextContent(
'Connected as jane@gmail.com',
);
});
it('stays generic rather than naming the n8n account when the provider tells us none', () => {
renderComponent({
pinia: createTestingPinia({
initialState: {
[STORES.USERS]: {
currentUserId: 'user-1',
usersById: { 'user-1': { id: 'user-1', email: 'signed-in@n8n.io' } },
},
},
}),
props: {
...oAuthConnectedProps,
isPrivateCredentialsEnabled: true,
isResolvable: true,
connectedByMe: true,
},
});
const banner = screen.getByTestId('oauth-connect-success-banner');
expect(banner).toHaveTextContent('Account connected');
expect(banner).not.toHaveTextContent('signed-in@n8n.io');
});
it('names the provider account a fixed credential is connected as', () => {
renderComponent({
props: {
...oAuthConnectedProps,
isResolvable: false,
credentialData: { accountIdentifier: 'octocat' } as ICredentialDataDecryptedObject,
},
});
expect(screen.getByTestId('oauth-connect-success-banner')).toHaveTextContent(
'Connected as octocat',
);
});
});
it('shows stale-connection actions and emits their events on auth error', async () => {
const { emitted } = renderComponent({
props: {

View File

@ -27,7 +27,6 @@ import { useCredentialsStore } from '../../credentials.store';
import { injectNDVStore } from '@/features/ndv/shared/ndv.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@n8n/stores/users.store';
import Banner from '@/app/components/Banner.vue';
import CopyInput from '@/app/components/CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue';
@ -78,6 +77,8 @@ type Props = {
isPrivateCredentialsEnabled?: boolean;
isResolvable?: boolean;
connectedByMe?: boolean;
/** Provider account of the caller's own connection, for end-user credentials. */
connectedAccountIdentifier?: string;
isNewCredential?: boolean;
managedOauthAvailable?: boolean;
useCustomOauth?: boolean;
@ -96,6 +97,7 @@ const props = withDefaults(defineProps<Props>(), {
authError: '',
showValidationWarning: false,
credentialPermissions: () => ({}) as PermissionsRecord['credential'],
connectedAccountIdentifier: undefined,
instanceAiCredentialHelp: undefined,
});
const emit = defineEmits<{
@ -114,7 +116,6 @@ const credentialsStore = useCredentialsStore();
const ndvStore = injectNDVStore();
const rootStore = useRootStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const assistantStore = useAssistantStore();
const chatPanelStore = useChatPanelStore();
@ -233,13 +234,14 @@ const isConnectedOAuth = computed(
// expired). In this state we promote Switch account over the plain Retry button.
const isStale = computed(() => isConnectedOAuth.value && !!props.authError);
// The connected account label: for end-user creds it's the current user's own
// connection; for fixed creds it's the identifier derived from the stored token
// (may be absent for providers that don't return one — then fall back to a
// generic "Account connected" message).
// The connected account label — always the provider account the token belongs to,
// never the n8n account. For end-user creds it comes from the caller's own per-user
// connection, for fixed creds from the token stored on the credential. Many
// providers return no identity at all (Gmail asks for no identity scope), so an
// absent value is normal and falls back to a generic "Account connected" message.
const connectedAccountName = computed<string | undefined>(() => {
if (props.isResolvable) {
return usersStore.currentUser?.email ?? undefined;
return props.connectedAccountIdentifier;
}
const identifier = props.credentialData?.accountIdentifier;
return typeof identifier === 'string' && identifier ? identifier : undefined;

View File

@ -234,6 +234,7 @@ const {
showValidationWarning,
isResolvable,
connectedByMe,
connectedAccountIdentifier,
useCustomOAuth,
activeNodeType,
credentialTypeName,
@ -596,6 +597,7 @@ async function onResolvableChange(value: boolean) {
// doesn't apply to the new mode, and `oauthTokenData` (mirrored true for a connected
// end-user credential) would otherwise be read as "connected" once static.
connectedByMe.value = false;
connectedAccountIdentifier.value = undefined;
credentialData.value = {
...credentialData.value,
oauthTokenData: null as unknown as CredentialInformation,
@ -1160,8 +1162,15 @@ async function oAuthCredentialAuthorize() {
connectedByMe.value = true;
void credentialsStore.fetchAllCredentials().then(() => {
void credentialsStore.fetchAllCredentials().then((credentials) => {
nodeHelpers.updateNodesCredentialsIssues();
// The account just connected is only known server-side, so pick it up
// from the refresh rather than guessing at who the user is. Read this
// request's own response, not the store: any other credentials fetch
// that resolves later replaces the whole store map with its own view.
connectedAccountIdentifier.value = credentials.find(
(credential) => credential.id === credentialId.value,
)?.connectedAccountIdentifier;
});
// Close the window
@ -1225,6 +1234,7 @@ async function onDisconnectMyConnection(): Promise<void> {
} else {
await credentialsStore.disconnectOauthToken({ id: credentialId.value });
}
connectedAccountIdentifier.value = undefined;
credentialData.value = {
...credentialData.value,
oauthTokenData: null as unknown as CredentialInformation,
@ -1270,6 +1280,7 @@ async function onAuthTypeChanged(payload: CredentialModeOption): Promise<void> {
// this session (or carried over from the loaded credential) makes the banner
// misreport "connected" for a mode that was never actually saved.
connectedByMe.value = false;
connectedAccountIdentifier.value = undefined;
credentialData.value = {
...credentialData.value,
oauthTokenData: null as unknown as CredentialInformation,
@ -1435,6 +1446,7 @@ const { width } = useElementSize(credNameRef);
:is-private-credentials-enabled="isPrivateCredentialsEnabled && !isInstanceCredential"
:is-resolvable="isResolvable"
:connected-by-me="connectedByMe"
:connected-account-identifier="connectedAccountIdentifier"
:is-new-credential="isNewCredential"
:managed-oauth-available="managedOAuthAvailable"
:use-custom-oauth="useCustomOAuth"

View File

@ -22,6 +22,8 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUsersStore } from '@n8n/stores/users.store';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { useAiGateway } from '@/app/composables/useAiGateway';
import { ChatHubToolContextKey, WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import {
@ -2529,6 +2531,37 @@ describe('NodeCredentials', () => {
expect(screen.queryByTestId('node-credential-private-connect')).not.toBeInTheDocument();
});
it('names the provider account the connection authenticates as', async () => {
credentialsStore.state.credentials = {
'private-cred-id': {
...privateCredential,
connectedByMe: true,
connectedAccountIdentifier: 'jane@gmail.com',
},
};
renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } });
expect(screen.getByTestId('node-credential-private-row')).toHaveTextContent(
'Connected as jane@gmail.com',
);
});
it('does not name the n8n account when the provider tells us no account', async () => {
const usersStore = mockedStore(useUsersStore);
usersStore.usersById = {
'user-1': { id: 'user-1', email: 'signed-in@n8n.io' } as IUser,
};
usersStore.currentUserId = 'user-1';
credentialsStore.state.credentials = {
'private-cred-id': { ...privateCredential, connectedByMe: true },
};
renderComponent({ props: { node: notionNode, overrideCredType: 'openAiApi' } });
const row = screen.getByTestId('node-credential-private-row');
expect(row).toHaveTextContent('Connected');
expect(row).not.toHaveTextContent('signed-in@n8n.io');
});
it('shows the Connect button when connectedByMe is false', async () => {
credentialsStore.state.credentials = {
'private-cred-id': { ...privateCredential, connectedByMe: false },

View File

@ -40,7 +40,6 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { assert } from '@n8n/utils/assert';
import { isEmpty } from '@/app/utils/typesUtils';
import { getResourcePermissions } from '@n8n/permissions';
@ -141,7 +140,6 @@ const ndvStore = injectNDVStoreIfProvided();
const uiStore = useUIStore();
const projectsStore = useProjectsStore();
const workflowsStore = useWorkflowsStore();
const usersStore = useUsersStore();
const workflowDocumentStore = props.standalone ? undefined : injectWorkflowDocumentStore();
const { isEnabled: isPrivateCredentialsEnabled } = usePrivateCredentials();
@ -244,7 +242,21 @@ async function onConnectFromRow(credentialType: string): Promise<void> {
if (!credential) return;
const success = await authorize(credential);
if (success) {
credentialsStore.setConnectedByMe(credential.id, true);
credentialsStore.setConnectedByMe(credential.id, true, await fetchMyAccount(credential.id));
}
}
/**
* The provider account a fresh connection authenticates as, which only the
* server can tell us. Purely a label — a failed lookup must not undo a
* successful connection.
*/
async function fetchMyAccount(credentialId: string): Promise<string | undefined> {
try {
const credential = await credentialsStore.getCredentialData({ id: credentialId });
return credential?.connectedAccountIdentifier;
} catch {
return undefined;
}
}
@ -1191,7 +1203,9 @@ async function onQuickConnectSignIn(credentialTypeName: string) {
:credential-type-name="type.name"
:credential-name="getServiceName(type.name)"
:is-connected="isPrivateConnected(type.name)"
:connected-account-name="usersStore.currentUser?.email ?? undefined"
:connected-account-name="
getSelectedPrivateCredential(type.name)?.connectedAccountIdentifier
"
:can-connect="canConnectPrivateCredential(type.name)"
data-test-id="node-credential-private-row"
@connect="onConnectFromRow(type.name)"

View File

@ -105,6 +105,8 @@ export function useCredentialForm(options: UseCredentialFormOptions) {
const showValidationWarning = ref(false);
const isResolvable = ref(false);
const connectedByMe = ref(false);
/** The provider account my own connection authenticates as, when the provider tells us. */
const connectedAccountIdentifier = ref<string | undefined>(undefined);
const useCustomOAuth = ref(false);
// --- type resolution ---------------------------------------------------
@ -479,6 +481,11 @@ export function useCredentialForm(options: UseCredentialFormOptions) {
'connectedByMe' in loaded && typeof loaded.connectedByMe === 'boolean'
? loaded.connectedByMe
: false;
connectedAccountIdentifier.value =
'connectedAccountIdentifier' in loaded &&
typeof loaded.connectedAccountIdentifier === 'string'
? loaded.connectedAccountIdentifier
: undefined;
}
// An existing credential whose managed clientId/secret were overridden was
@ -642,6 +649,7 @@ export function useCredentialForm(options: UseCredentialFormOptions) {
showValidationWarning,
isResolvable,
connectedByMe,
connectedAccountIdentifier,
useCustomOAuth,
// derived
activeNodeType,

View File

@ -416,12 +416,21 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => {
await credentialsApi.disconnectOauthToken(rootStore.restApiContext, id);
};
const setConnectedByMe = (id: string, connectedByMe: boolean) => {
/**
* Mirrors the caller's own connection state locally. The account identifier is
* only known server-side, so it is cleared unless one is passed in — better no
* label than a stale one from the previously connected account.
*/
const setConnectedByMe = (
id: string,
connectedByMe: boolean,
connectedAccountIdentifier?: string,
) => {
const existing = state.value.credentials[id];
if (existing) {
state.value.credentials = {
...state.value.credentials,
[id]: { ...existing, connectedByMe },
[id]: { ...existing, connectedByMe, connectedAccountIdentifier },
};
}
};

View File

@ -19,6 +19,12 @@ export interface ICredentialsResponse extends ICredentialsEncrypted {
usageScope?: 'project' | 'instance';
/** Whether the current user has personally connected this credential. Set on resolvable credentials only. */
connectedByMe?: boolean;
/**
* The provider account the current user's own connection authenticates as
* (e.g. the connected Gmail address). Set on resolvable credentials only, and
* absent whenever the provider returns no identity claim.
*/
connectedAccountIdentifier?: string;
/** Total number of users connected to this credential. Set on resolvable credentials only. */
connectedUserCount?: number;
}
@ -42,6 +48,12 @@ export interface ICredentialsDecryptedResponse extends ICredentialsBase, ICreden
isResolvable?: boolean;
/** Whether the current user has personally connected this credential. Set on resolvable credentials only. */
connectedByMe?: boolean;
/**
* The provider account the current user's own connection authenticates as
* (e.g. the connected Gmail address). Set on resolvable credentials only, and
* absent whenever the provider returns no identity claim.
*/
connectedAccountIdentifier?: string;
/** Total number of users connected to this credential. Set on resolvable credentials only. */
connectedUserCount?: number;
}