Add authSessionAccountIcon proposed API and account avatar support (#308654)

* Add authSessionAccountIcon proposed API and account avatar support

Introduces a new proposed API `authSessionAccountIcon` that allows authentication
providers to supply an icon URL (typically a profile avatar) for authentication
session accounts.

Changes:
- New proposed API: `AuthenticationSessionAccountInformation.iconUrl`
- GitHub authentication extension updated to fetch and provide user avatar URLs
- Activity bar and global composite bar updated to display account avatars
- Authentication service extended to propagate icon information

* Address Copilot review feedback

- Use removeAttribute('src') instead of empty string to avoid spurious requests
- Add alt='', aria-hidden='true', draggable=false to avatar img for accessibility
- Update iconUrl in addOrUpdateAccount when provider supplies/changes it
- Fetch avatarUrl for existing sessions that are missing iconUrl

* Address review feedback: account icon as Uri, avatar setting, Agents window consolidation

- Change AuthenticationSessionAccountInformation.iconUrl (string) to icon (Uri)
  per review feedback, and use URI in the internal workbench type
- Revive the icon URI at the RPC boundaries (MainThread/ExtHost), typing the
  proxies as Proxied<T>
- Persist the fetched avatar in stored GitHub auth sessions so it is not
  refetched on every read
- Add workbench.accounts.showAvatar setting to show/hide account avatars
- Agents window: prefer the authentication session's account icon over the
  hardcoded github.com avatar URL pattern and honor the new setting

* Fix Compile & Hygiene: tab indentation in extensionsApiProposals and remove unused import

* Fix DynamicAuthProvider test mock to match Proxied proxy typing

* auth - improve account avatar support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Dmitriy Vasyura <dmitriv@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
William Reiske
2026-08-14 06:47:04 +00:00
committed by GitHub
co-authored by Copilot Dmitriy Vasyura
parent a589bb7374
commit 979e48cec5
23 changed files with 626 additions and 55 deletions
+75 -12
View File
@@ -14,6 +14,17 @@ import { crypto } from './node/crypto';
import { TIMED_OUT_ERROR, USER_CANCELLATION_ERROR } from './common/errors';
import { GitHubSocialSignInProvider, isSocialSignInProvider } from './flows';
/**
* The stored (JSON) form of a vscode.Uri pointing to the account's avatar.
*/
interface StoredAccountIcon {
scheme: string;
authority?: string;
path?: string;
query?: string;
fragment?: string;
}
interface SessionData {
id: string;
account?: {
@@ -22,11 +33,31 @@ interface SessionData {
// Unfortunately, for some time the id was a number, so we need to support both.
// This can be removed once we are confident that all users have migrated to the new id.
id: string | number;
// `undefined` means the avatar has not been looked up yet, `null` means a lookup
// completed and found no avatar, and a `StoredAccountIcon` is a resolved avatar.
icon?: StoredAccountIcon | null;
};
scopes: string[];
accessToken: string;
}
/**
* Whether a stored session's account icon still needs to be looked up.
*/
export function needsAccountIconLookup(session: SessionData): boolean {
return !session.account || session.account.icon === undefined;
}
/**
* Serializes an account icon for storage, using `null` to mark a completed lookup that found no avatar.
*/
export function serializeAccountIcon(icon: vscode.Uri | undefined, hasNoAvatar: boolean): StoredAccountIcon | null | undefined {
if (icon) {
return { scheme: icon.scheme, authority: icon.authority, path: icon.path, query: icon.query, fragment: icon.fragment };
}
return hasNoAvatar ? null : undefined;
}
export enum AuthProviderType {
github = 'github',
githubEnterprise = 'github-enterprise'
@@ -134,6 +165,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
private readonly _telemetryReporter: ExperimentationTelemetry;
private readonly _keychain: Keychain;
private readonly _accountsSeen = new Set<string>();
private readonly _sessionsWithoutAvatars = new WeakSet<vscode.AuthenticationSession>();
private readonly _disposable: vscode.Disposable | undefined;
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
@@ -278,19 +310,25 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
// the sessions to migrate away from the bad number usage.
// TODO@TylerLeonhardt: Remove this after we are confident that all users have migrated to the new id.
let seenNumberAccountId: boolean = false;
// Sessions that were stored before the account icon was introduced are re-stored
// once an icon has been fetched so that we don't refetch it on every read.
let seenIconUpdate: boolean = false;
// TODO: eventually remove this Set because we should only have one session per set of scopes.
const scopesSeen = new Set<string>();
const sessionPromises = sessionData.map(async (session: SessionData): Promise<vscode.AuthenticationSession | undefined> => {
// For GitHub scope list, order doesn't matter so we immediately sort the scopes
const scopesStr = [...session.scopes].sort().join(' ');
let userInfo: { id: string; accountName: string } | undefined;
if (!session.account) {
let userInfo: { id: string; accountName: string; avatarUrl: string | undefined } | undefined;
if (needsAccountIconLookup(session)) {
const needsAccount = !session.account;
try {
userInfo = await this._githubServer.getUserInfo(session.accessToken);
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
seenIconUpdate = true;
if (needsAccount) {
this._logger.info(`Verified session with the following scopes: ${scopesStr}`);
}
} catch (e) {
// Remove sessions that return unauthorized response
if (e.message === 'Unauthorized') {
if (e.message === 'Unauthorized' && needsAccount) {
return undefined;
}
}
@@ -308,19 +346,30 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
} else {
accountId = userInfo?.id ?? '<unknown>';
}
return {
let icon: vscode.Uri | undefined;
if (session.account?.icon?.scheme) {
icon = vscode.Uri.from(session.account.icon);
} else if (userInfo?.avatarUrl) {
icon = vscode.Uri.parse(userInfo.avatarUrl);
}
const resolvedSession: vscode.AuthenticationSession = {
id: session.id,
account: {
label: session.account
? session.account.label ?? session.account.displayName ?? '<unknown>'
: userInfo?.accountName ?? '<unknown>',
id: accountId
: (userInfo?.accountName ?? '<unknown>'),
id: accountId,
icon,
},
// we set this to session.scopes to maintain the original order of the scopes requested
// by the extension that called getSession()
scopes: session.scopes,
accessToken: session.accessToken
};
if (!icon && (session.account?.icon === null || userInfo)) {
this._sessionsWithoutAvatars.add(resolvedSession);
}
return resolvedSession;
});
const verifiedSessions = (await Promise.allSettled(sessionPromises))
@@ -329,7 +378,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
.filter(<T>(p?: T): p is T => Boolean(p));
this._logger.info(`Got ${verifiedSessions.length} verified sessions.`);
if (seenNumberAccountId || verifiedSessions.length !== sessionData.length) {
if (seenNumberAccountId || seenIconUpdate || verifiedSessions.length !== sessionData.length) {
await this.storeSessions(verifiedSessions);
}
@@ -339,7 +388,17 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
private async storeSessions(sessions: vscode.AuthenticationSession[]): Promise<void> {
this._logger.info(`Storing ${sessions.length} sessions...`);
this._sessionsPromise = Promise.resolve(sessions);
await this._keychain.setToken(JSON.stringify(sessions));
const storedSessions: SessionData[] = sessions.map(session => ({
id: session.id,
account: {
label: session.account.label,
id: session.account.id,
icon: serializeAccountIcon(session.account.icon, this._sessionsWithoutAvatars.has(session)),
},
scopes: [...session.scopes],
accessToken: session.accessToken
}));
await this._keychain.setToken(JSON.stringify(storedSessions));
this._logger.info(`Stored ${sessions.length} sessions!`);
}
@@ -409,12 +468,16 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
private async tokenToSession(token: string, scopes: string[]): Promise<vscode.AuthenticationSession> {
const userInfo = await this._githubServer.getUserInfo(token);
return {
const session: vscode.AuthenticationSession = {
id: crypto.getRandomValues(new Uint32Array(2)).reduce((prev, curr) => prev += curr.toString(16), ''),
accessToken: token,
account: { label: userInfo.accountName, id: userInfo.id },
account: { label: userInfo.accountName, id: userInfo.id, icon: userInfo.avatarUrl ? vscode.Uri.parse(userInfo.avatarUrl) : undefined },
scopes
};
if (!session.account.icon) {
this._sessionsWithoutAvatars.add(session);
}
return session;
}
public async removeSession(id: string) {
@@ -21,7 +21,7 @@ const REDIRECT_URL_INSIDERS = 'https://insiders.vscode.dev/redirect';
export interface IGitHubServer {
login(scopes: string, signInProvider?: GitHubSocialSignInProvider, extraAuthorizeParameters?: Record<string, string>, existingLogin?: string): Promise<string>;
logout(session: vscode.AuthenticationSession): Promise<void>;
getUserInfo(token: string): Promise<{ id: string; accountName: string }>;
getUserInfo(token: string): Promise<{ id: string; accountName: string; avatarUrl: string | undefined }>;
sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void>;
friendlyName: string;
}
@@ -217,7 +217,7 @@ export class GitHubServer implements IGitHubServer {
return vscode.Uri.parse(`${apiUri.scheme}://${apiUri.authority}/api/v3${path}`);
}
public async getUserInfo(token: string): Promise<{ id: string; accountName: string }> {
public async getUserInfo(token: string): Promise<{ id: string; accountName: string; avatarUrl: string | undefined }> {
let result;
try {
this._logger.info('Getting user info...');
@@ -237,9 +237,9 @@ export class GitHubServer implements IGitHubServer {
if (result.ok) {
try {
const json = await result.json() as { id: number; login: string };
const json = await result.json() as { id: number; login: string; avatar_url?: string };
this._logger.info('Got account info!');
return { id: `${json.id}`, accountName: json.login };
return { id: `${json.id}`, accountName: json.login, avatarUrl: json.avatar_url };
} catch (e) {
this._logger.error(`Unexpected error parsing response from GitHub: ${e.message ?? e}`);
throw e;
@@ -0,0 +1,43 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import * as vscode from 'vscode';
import { needsAccountIconLookup, serializeAccountIcon } from '../github';
suite('account avatar caching', () => {
test('a pending session needs a lookup, and its serialized no-avatar result no longer needs one', () => {
const pendingSession = {
id: 'session1',
account: { id: 'account1', label: 'Some One' },
scopes: [],
accessToken: 'token'
};
const noAvatarIcon = serializeAccountIcon(undefined, true);
const cachedNoAvatarSession = {
id: 'session1',
account: { id: 'account1', label: 'Some One', icon: noAvatarIcon },
scopes: [],
accessToken: 'token'
};
assert.deepStrictEqual(
[needsAccountIconLookup(pendingSession), noAvatarIcon, needsAccountIconLookup(cachedNoAvatarSession)],
[true, null, false]
);
});
test('a resolved avatar URI is serialized as-is and is never replaced by null', () => {
const icon = vscode.Uri.parse('https://example.com/avatar.png');
assert.deepStrictEqual(serializeAccountIcon(icon, true), {
scheme: icon.scheme,
authority: icon.authority,
path: icon.path,
query: icon.query,
fragment: icon.fragment
});
});
});