Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement getAccounts API over getSessions #215874

Merged
merged 1 commit into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion extensions/microsoft-authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
],
"activationEvents": [],
"enabledApiProposals": [
"idToken"
"idToken",
"authGetSessions"
],
"capabilities": {
"virtualWorkspaces": true,
Expand Down
85 changes: 58 additions & 27 deletions extensions/microsoft-authentication/src/AADHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,13 @@ export class AzureActiveDirectoryService {
return this._sessionChangeEmitter.event;
}

public getSessions(scopes?: string[]): Promise<vscode.AuthenticationSession[]> {
public getSessions(scopes?: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession[]> {
if (!scopes) {
this._logger.info('Getting sessions for all scopes...');
const sessions = this._tokens.map(token => this.convertToSessionSync(token));
this._logger.info(`Got ${sessions.length} sessions for all scopes...`);
const sessions = this._tokens
.filter(token => !account?.label || token.account.label === account.label)
.map(token => this.convertToSessionSync(token));
this._logger.info(`Got ${sessions.length} sessions for all scopes${account ? ` for account '${account.label}'` : ''}...`);
return Promise.resolve(sessions);
}

Expand Down Expand Up @@ -238,23 +240,43 @@ export class AzureActiveDirectoryService {
tenant: this.getTenantId(scopes),
};

this._logger.trace(`[${scopeData.scopeStr}] Queued getting sessions`);
return this._sequencer.queue(modifiedScopesStr, () => this.doGetSessions(scopeData));
this._logger.trace(`[${scopeData.scopeStr}] Queued getting sessions` + account ? ` for ${account?.label}` : '');
return this._sequencer.queue(modifiedScopesStr, () => this.doGetSessions(scopeData, account));
}

private async doGetSessions(scopeData: IScopeData): Promise<vscode.AuthenticationSession[]> {
this._logger.info(`[${scopeData.scopeStr}] Getting sessions`);
private async doGetSessions(scopeData: IScopeData, account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession[]> {
this._logger.info(`[${scopeData.scopeStr}] Getting sessions` + account ? ` for ${account?.label}` : '');

const matchingTokens = this._tokens.filter(token => token.scope === scopeData.scopeStr);
const matchingTokens = this._tokens
.filter(token => token.scope === scopeData.scopeStr)
.filter(token => !account?.label || token.account.label === account.label);
// If we still don't have a matching token try to get a new token from an existing token by using
// the refreshToken. This is documented here:
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow#refresh-the-access-token
// "Refresh tokens are valid for all permissions that your client has already received consent for."
if (!matchingTokens.length) {
// Get a token with the correct client id.
const token = scopeData.clientId === DEFAULT_CLIENT_ID
? this._tokens.find(t => t.refreshToken && !t.scope.includes('VSCODE_CLIENT_ID'))
: this._tokens.find(t => t.refreshToken && t.scope.includes(`VSCODE_CLIENT_ID:${scopeData.clientId}`));
// Get a token with the correct client id and account.
let token: IToken | undefined;
for (const t of this._tokens) {
// No refresh token, so we can't make a new token from this session
if (!t.refreshToken) {
continue;
}
// Need to make sure the account matches if we were provided one
if (account?.label && t.account.label !== account.label) {
continue;
}
// If the client id is the default client id, then check for the absence of the VSCODE_CLIENT_ID scope
if (scopeData.clientId === DEFAULT_CLIENT_ID && !t.scope.includes('VSCODE_CLIENT_ID')) {
token = t;
break;
}
// If the client id is not the default client id, then check for the matching VSCODE_CLIENT_ID scope
if (scopeData.clientId !== DEFAULT_CLIENT_ID && t.scope.includes(`VSCODE_CLIENT_ID:${scopeData.clientId}`)) {
token = t;
break;
}
}

if (token) {
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Found a matching token with a different scopes '${token.scope}'. Attempting to get a new session using the existing session.`);
Expand All @@ -275,7 +297,7 @@ export class AzureActiveDirectoryService {
.map(result => (result as PromiseFulfilledResult<vscode.AuthenticationSession>).value);
}

public createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
public createSession(scopes: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession> {
let modifiedScopes = [...scopes];
if (!modifiedScopes.includes('openid')) {
modifiedScopes.push('openid');
Expand All @@ -301,11 +323,11 @@ export class AzureActiveDirectoryService {
};

this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`);
return this._sequencer.queue(scopeData.scopeStr, () => this.doCreateSession(scopeData));
return this._sequencer.queue(scopeData.scopeStr, () => this.doCreateSession(scopeData, account));
}

private async doCreateSession(scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
this._logger.info(`[${scopeData.scopeStr}] Creating session`);
private async doCreateSession(scopeData: IScopeData, account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession> {
this._logger.info(`[${scopeData.scopeStr}] Creating session` + account ? ` for ${account?.label}` : '');

const runsRemote = vscode.env.remoteName !== undefined;
const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web;
Expand All @@ -316,25 +338,25 @@ export class AzureActiveDirectoryService {

return await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Signing in to your account...'), cancellable: true }, async (_progress, token) => {
if (runsRemote || runsServerless) {
return await this.createSessionWithoutLocalServer(scopeData, token);
return await this.createSessionWithoutLocalServer(scopeData, account?.label, token);
}

try {
return await this.createSessionWithLocalServer(scopeData, token);
return await this.createSessionWithLocalServer(scopeData, account?.label, token);
} catch (e) {
this._logger.error(`[${scopeData.scopeStr}] Error creating session: ${e}`);

// If the error was about starting the server, try directly hitting the login endpoint instead
if (e.message === 'Error listening to server' || e.message === 'Closed' || e.message === 'Timeout waiting for port') {
return this.createSessionWithoutLocalServer(scopeData, token);
return this.createSessionWithoutLocalServer(scopeData, account?.label, token);
}

throw e;
}
});
}

private async createSessionWithLocalServer(scopeData: IScopeData, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {
private async createSessionWithLocalServer(scopeData: IScopeData, loginHint: string | undefined, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {
this._logger.trace(`[${scopeData.scopeStr}] Starting login flow with local server`);
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
Expand All @@ -344,11 +366,15 @@ export class AzureActiveDirectoryService {
client_id: scopeData.clientId,
redirect_uri: redirectUrl,
scope: scopeData.scopesToSend,
prompt: 'select_account',
code_challenge_method: 'S256',
code_challenge: codeChallenge,
}).toString();
const loginUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize?${qs}`, this._env.activeDirectoryEndpointUrl).toString();
});
if (loginHint) {
qs.set('login_hint', loginHint);
} else {
qs.set('prompt', 'select_account');
}
const loginUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize?${qs.toString()}`, this._env.activeDirectoryEndpointUrl).toString();
const server = new LoopbackAuthServer(path.join(__dirname, '../media'), loginUrl);
await server.start();

Expand All @@ -370,7 +396,7 @@ export class AzureActiveDirectoryService {
return session;
}

private async createSessionWithoutLocalServer(scopeData: IScopeData, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {
private async createSessionWithoutLocalServer(scopeData: IScopeData, loginHint: string | undefined, token: vscode.CancellationToken): Promise<vscode.AuthenticationSession> {
this._logger.trace(`[${scopeData.scopeStr}] Starting login flow without local server`);
let callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.microsoft-authentication`));
const nonce = generateCodeVerifier();
Expand All @@ -383,17 +409,22 @@ export class AzureActiveDirectoryService {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const signInUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize`, this._env.activeDirectoryEndpointUrl);
signInUrl.search = new URLSearchParams({
const qs = new URLSearchParams({
response_type: 'code',
client_id: encodeURIComponent(scopeData.clientId),
response_mode: 'query',
redirect_uri: redirectUrl,
state,
scope: scopeData.scopesToSend,
prompt: 'select_account',
code_challenge_method: 'S256',
code_challenge: codeChallenge,
}).toString();
});
if (loginHint) {
qs.append('login_hint', loginHint);
} else {
qs.append('prompt', 'select_account');
}
signInUrl.search = qs.toString();
const uri = vscode.Uri.parse(signInUrl.toString());
vscode.env.openExternal(uri);

Expand Down
6 changes: 3 additions & 3 deletions extensions/microsoft-authentication/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ export async function activate(context: vscode.ExtensionContext) {

context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('microsoft', 'Microsoft', {
onDidChangeSessions: loginService.onDidChangeSessions,
getSessions: (scopes: string[]) => loginService.getSessions(scopes),
createSession: async (scopes: string[]) => {
getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options?.account),
createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => {
try {
/* __GDPR__
"login" : {
Expand All @@ -138,7 +138,7 @@ export async function activate(context: vscode.ExtensionContext) {
scopes: JSON.stringify(scopes.map(s => s.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}'))),
});

return await loginService.createSession(scopes);
return await loginService.createSession(scopes, options?.account);
} catch (e) {
/* __GDPR__
"loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." }
Expand Down
3 changes: 2 additions & 1 deletion extensions/microsoft-authentication/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"include": [
"src/**/*",
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.idToken.d.ts"
"../../src/vscode-dts/vscode.proposed.idToken.d.ts",
"../../src/vscode-dts/vscode.proposed.authGetSessions.d.ts"
]
}
36 changes: 24 additions & 12 deletions src/vs/workbench/api/browser/mainThreadAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { Disposable, DisposableMap } from 'vs/base/common/lifecycle';
import * as nls from 'vs/nls';
import { extHostNamedCustomer, IExtHostContext } from 'vs/workbench/services/extensions/common/extHostCustomers';
import { IAuthenticationCreateSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, INTERNAL_AUTH_PROVIDER_PREFIX as INTERNAL_MODEL_AUTH_PROVIDER_PREFIX } from 'vs/workbench/services/authentication/common/authentication';
import { IAuthenticationCreateSessionOptions, AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, IAuthenticationExtensionsService, INTERNAL_AUTH_PROVIDER_PREFIX as INTERNAL_MODEL_AUTH_PROVIDER_PREFIX, AuthenticationSessionAccount, IAuthenticationProviderSessionOptions } from 'vs/workbench/services/authentication/common/authentication';
import { ExtHostAuthenticationShape, ExtHostContext, MainContext, MainThreadAuthenticationShape } from '../common/extHost.protocol';
import { IDialogService, IPromptButton } from 'vs/platform/dialogs/common/dialogs';
import Severity from 'vs/base/common/severity';
Expand All @@ -31,6 +31,7 @@ interface AuthenticationGetSessionOptions {
createIfNone?: boolean;
forceNewSession?: boolean | AuthenticationForceNewSessionOptions;
silent?: boolean;
account?: AuthenticationSessionAccount;
}

export class MainThreadAuthenticationProvider extends Disposable implements IAuthenticationProvider {
Expand All @@ -49,8 +50,8 @@ export class MainThreadAuthenticationProvider extends Disposable implements IAut
this.onDidChangeSessions = onDidChangeSessionsEmitter.event;
}

async getSessions(scopes?: string[]) {
return this._proxy.$getSessions(this.id, scopes);
async getSessions(scopes: string[] | undefined, options: IAuthenticationProviderSessionOptions) {
return this._proxy.$getSessions(this.id, scopes, options);
}

createSession(scopes: string[], options: IAuthenticationCreateSessionOptions): Promise<AuthenticationSession> {
Expand Down Expand Up @@ -159,7 +160,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
}

private async doGetSession(providerId: string, scopes: string[], extensionId: string, extensionName: string, options: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined> {
const sessions = await this.authenticationService.getSessions(providerId, scopes, true);
const sessions = await this.authenticationService.getSessions(providerId, scopes, options.account, true);
const provider = this.authenticationService.getProvider(providerId);

// Error cases
Expand Down Expand Up @@ -213,18 +214,16 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu

let session;
if (sessions?.length && !options.forceNewSession) {
session = provider.supportsMultipleAccounts
session = provider.supportsMultipleAccounts && !options.account
? await this.authenticationExtensionsService.selectSession(providerId, extensionId, extensionName, scopes, sessions)
: sessions[0];
} else {
let sessionToRecreate: AuthenticationSession | undefined;
if (typeof options.forceNewSession === 'object' && options.forceNewSession.sessionToRecreate) {
sessionToRecreate = options.forceNewSession.sessionToRecreate as AuthenticationSession;
} else {
let account: AuthenticationSessionAccount | undefined = options.account;
if (!account) {
const sessionIdToRecreate = this.authenticationExtensionsService.getSessionPreference(providerId, extensionId, scopes);
sessionToRecreate = sessionIdToRecreate ? sessions.find(session => session.id === sessionIdToRecreate) : undefined;
account = sessionIdToRecreate ? sessions.find(session => session.id === sessionIdToRecreate)?.account : undefined;
}
session = await this.authenticationService.createSession(providerId, scopes, { activateImmediate: true, sessionToRecreate });
session = await this.authenticationService.createSession(providerId, scopes, { activateImmediate: true, account });
}

this.authenticationAccessService.updateAllowedExtensions(providerId, session.account.label, [{ id: extensionId, name: extensionName, allowed: true }]);
Expand Down Expand Up @@ -262,7 +261,7 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
}

async $getSessions(providerId: string, scopes: readonly string[], extensionId: string, extensionName: string): Promise<AuthenticationSession[]> {
const sessions = await this.authenticationService.getSessions(providerId, [...scopes], true);
const sessions = await this.authenticationService.getSessions(providerId, [...scopes], undefined, true);
const accessibleSessions = sessions.filter(s => this.authenticationAccessService.isAccessAllowed(providerId, s.account.label, extensionId));
if (accessibleSessions.length) {
this.sendProviderUsageTelemetry(extensionId, providerId);
Expand All @@ -273,6 +272,19 @@ export class MainThreadAuthentication extends Disposable implements MainThreadAu
return accessibleSessions;
}

async $getAccounts(providerId: string): Promise<ReadonlyArray<AuthenticationSessionAccount>> {
const sessions = await this.authenticationService.getSessions(providerId);
const accounts = new Array<AuthenticationSessionAccount>();
const seenAccounts = new Set<string>();
for (const session of sessions) {
if (!seenAccounts.has(session.account.label)) {
seenAccounts.add(session.account.label);
accounts.push(session.account);
}
}
return accounts;
}

private sendProviderUsageTelemetry(extensionId: string, providerId: string): void {
type AuthProviderUsageClassification = {
owner: 'TylerLeonhardt';
Expand Down
6 changes: 3 additions & 3 deletions src/vs/workbench/api/browser/mainThreadLanguageModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { ExtHostLanguageModelsShape, ExtHostContext, MainContext, MainThreadLang
import { ILanguageModelStatsService } from 'vs/workbench/contrib/chat/common/languageModelStats';
import { ILanguageModelChatMetadata, IChatResponseFragment, ILanguageModelsService, IChatMessage, ILanguageModelChatSelector } from 'vs/workbench/contrib/chat/common/languageModels';
import { IAuthenticationAccessService } from 'vs/workbench/services/authentication/browser/authenticationAccessService';
import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationProviderCreateSessionOptions, IAuthenticationService, INTERNAL_AUTH_PROVIDER_PREFIX } from 'vs/workbench/services/authentication/common/authentication';
import { AuthenticationSession, AuthenticationSessionsChangeEvent, IAuthenticationProvider, IAuthenticationService, INTERNAL_AUTH_PROVIDER_PREFIX } from 'vs/workbench/services/authentication/common/authentication';
import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';

Expand Down Expand Up @@ -161,9 +161,9 @@ class LanguageModelAccessAuthProvider implements IAuthenticationProvider {
if (this._session) {
return [this._session];
}
return [await this.createSession(scopes || [], {})];
return [await this.createSession(scopes || [])];
}
async createSession(scopes: string[], options: IAuthenticationProviderCreateSessionOptions): Promise<AuthenticationSession> {
async createSession(scopes: string[]): Promise<AuthenticationSession> {
this._session = this._createFakeSession(scopes);
this._onDidChangeSessions.fire({ added: [this._session], changed: [], removed: [] });
return this._session;
Expand Down
7 changes: 7 additions & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,19 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
if (typeof options?.forceNewSession === 'object' && options.forceNewSession.learnMore) {
checkProposedApiEnabled(extension, 'authLearnMore');
}
if (options?.account) {
checkProposedApiEnabled(extension, 'authGetSessions');
}
return extHostAuthentication.getSession(extension, providerId, scopes, options as any);
},
getSessions(providerId: string, scopes: readonly string[]) {
checkProposedApiEnabled(extension, 'authGetSessions');
return extHostAuthentication.getSessions(extension, providerId, scopes);
},
getAccounts(providerId: string) {
checkProposedApiEnabled(extension, 'authGetSessions');
return extHostAuthentication.getAccounts(providerId);
},
// TODO: remove this after GHPR and Codespaces move off of it
async hasSession(providerId: string, scopes: readonly string[]) {
checkProposedApiEnabled(extension, 'authSession');
Expand Down
Loading
Loading