mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-03 07:26:25 +01:00
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:
co-authored by
Copilot
Dmitriy Vasyura
parent
a589bb7374
commit
979e48cec5
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user