mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-20 15:19:54 +01:00
86495e947b
Now that we're on Node 20, we can just use the global fetch and crypto which work the same in node and in the browser.
980 lines
38 KiB
TypeScript
980 lines
38 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import * as vscode from 'vscode';
|
|
import * as path from 'path';
|
|
import { isSupportedEnvironment } from './common/uri';
|
|
import { IntervalTimer, raceCancellationAndTimeoutError, SequencerByKey } from './common/async';
|
|
import { generateCodeChallenge, generateCodeVerifier, randomUUID } from './cryptoUtils';
|
|
import { BetterTokenStorage, IDidChangeInOtherWindowEvent } from './betterSecretStorage';
|
|
import { LoopbackAuthServer } from './node/authServer';
|
|
import { base64Decode } from './node/buffer';
|
|
import { UriEventHandler } from './UriEventHandler';
|
|
import TelemetryReporter from '@vscode/extension-telemetry';
|
|
import { Environment } from '@azure/ms-rest-azure-env';
|
|
|
|
const redirectUrl = 'https://vscode.dev/redirect';
|
|
const defaultActiveDirectoryEndpointUrl = Environment.AzureCloud.activeDirectoryEndpointUrl;
|
|
const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
|
|
const DEFAULT_TENANT = 'organizations';
|
|
const MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad';
|
|
const MSA_PASSTHRU_TID = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a';
|
|
|
|
const enum MicrosoftAccountType {
|
|
AAD = 'aad',
|
|
MSA = 'msa',
|
|
Unknown = 'unknown'
|
|
}
|
|
|
|
interface IToken {
|
|
accessToken?: string; // When unable to refresh due to network problems, the access token becomes undefined
|
|
idToken?: string; // depending on the scopes can be either supplied or empty
|
|
|
|
expiresIn?: number; // How long access token is valid, in seconds
|
|
expiresAt?: number; // UNIX epoch time at which token will expire
|
|
refreshToken: string;
|
|
|
|
account: {
|
|
label: string;
|
|
id: string;
|
|
type: MicrosoftAccountType;
|
|
};
|
|
scope: string;
|
|
sessionId: string; // The account id + the scope
|
|
}
|
|
|
|
export interface IStoredSession {
|
|
id: string;
|
|
refreshToken: string;
|
|
scope: string; // Scopes are alphabetized and joined with a space
|
|
account: {
|
|
label: string;
|
|
id: string;
|
|
};
|
|
endpoint: string | undefined;
|
|
}
|
|
|
|
export interface ITokenResponse {
|
|
access_token: string;
|
|
expires_in: number;
|
|
ext_expires_in: number;
|
|
refresh_token: string;
|
|
scope: string;
|
|
token_type: string;
|
|
id_token?: string;
|
|
}
|
|
|
|
export interface IMicrosoftTokens {
|
|
accessToken: string;
|
|
idToken?: string;
|
|
}
|
|
|
|
interface IScopeData {
|
|
originalScopes?: string[];
|
|
scopes: string[];
|
|
scopeStr: string;
|
|
scopesToSend: string;
|
|
clientId: string;
|
|
tenant: string;
|
|
}
|
|
|
|
export const REFRESH_NETWORK_FAILURE = 'Network failure';
|
|
|
|
export class AzureActiveDirectoryService {
|
|
// For details on why this is set to 2/3... see https://github.com/microsoft/vscode/issues/133201#issuecomment-966668197
|
|
private static REFRESH_TIMEOUT_MODIFIER = 1000 * 2 / 3;
|
|
private static POLLING_CONSTANT = 1000 * 60 * 30;
|
|
|
|
private _tokens: IToken[] = [];
|
|
private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
|
|
private _sessionChangeEmitter: vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
|
|
|
|
// Used to keep track of current requests when not using the local server approach.
|
|
private _pendingNonces = new Map<string, string[]>();
|
|
private _codeExchangePromises = new Map<string, Promise<vscode.AuthenticationSession>>();
|
|
private _codeVerfifiers = new Map<string, string>();
|
|
|
|
// Used to keep track of tokens that we need to store but can't because we aren't the focused window.
|
|
private _pendingTokensToStore: Map<string, IToken> = new Map<string, IToken>();
|
|
|
|
// Used to sequence requests to the same scope.
|
|
private _sequencer = new SequencerByKey<string>();
|
|
|
|
constructor(
|
|
private readonly _logger: vscode.LogOutputChannel,
|
|
_context: vscode.ExtensionContext,
|
|
private readonly _uriHandler: UriEventHandler,
|
|
private readonly _tokenStorage: BetterTokenStorage<IStoredSession>,
|
|
private readonly _telemetryReporter: TelemetryReporter,
|
|
private readonly _env: Environment
|
|
) {
|
|
_context.subscriptions.push(this._tokenStorage.onDidChangeInOtherWindow((e) => this.checkForUpdates(e)));
|
|
_context.subscriptions.push(vscode.window.onDidChangeWindowState(async (e) => e.focused && await this.storePendingTokens()));
|
|
|
|
// In the event that a window isn't focused for a long time, we should still try to store the tokens at some point.
|
|
const timer = new IntervalTimer();
|
|
timer.cancelAndSet(
|
|
() => !vscode.window.state.focused && this.storePendingTokens(),
|
|
// 5 hours + random extra 0-30 seconds so that each window doesn't try to store at the same time
|
|
(18000000) + Math.floor(Math.random() * 30000));
|
|
_context.subscriptions.push(timer);
|
|
}
|
|
|
|
public async initialize(): Promise<void> {
|
|
this._logger.trace('Reading sessions from secret storage...');
|
|
const sessions = await this._tokenStorage.getAll(item => this.sessionMatchesEndpoint(item));
|
|
this._logger.trace(`Got ${sessions.length} stored sessions`);
|
|
|
|
const refreshes = sessions.map(async session => {
|
|
this._logger.trace(`[${session.scope}] '${session.id}' Read stored session`);
|
|
const scopes = session.scope.split(' ');
|
|
const scopeData: IScopeData = {
|
|
scopes,
|
|
scopeStr: session.scope,
|
|
// filter our special scopes
|
|
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
|
|
clientId: this.getClientId(scopes),
|
|
tenant: this.getTenantId(scopes),
|
|
};
|
|
try {
|
|
await this.refreshToken(session.refreshToken, scopeData, session.id);
|
|
} catch (e) {
|
|
// If we aren't connected to the internet, then wait and try to refresh again later.
|
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
|
this._tokens.push({
|
|
accessToken: undefined,
|
|
refreshToken: session.refreshToken,
|
|
account: {
|
|
...session.account,
|
|
type: MicrosoftAccountType.Unknown
|
|
},
|
|
scope: session.scope,
|
|
sessionId: session.id
|
|
});
|
|
} else {
|
|
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
|
|
this._logger.error(e);
|
|
await this.removeSessionByIToken({
|
|
accessToken: undefined,
|
|
refreshToken: session.refreshToken,
|
|
account: {
|
|
...session.account,
|
|
type: MicrosoftAccountType.Unknown
|
|
},
|
|
scope: session.scope,
|
|
sessionId: session.id
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const result = await Promise.allSettled(refreshes);
|
|
for (const res of result) {
|
|
if (res.status === 'rejected') {
|
|
this._logger.error(`Failed to initialize stored data: ${res.reason}`);
|
|
this.clearSessions();
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const token of this._tokens) {
|
|
/* __GDPR__
|
|
"login" : {
|
|
"owner": "TylerLeonhardt",
|
|
"comment": "Used to determine the usage of the Microsoft Auth Provider.",
|
|
"scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." },
|
|
"accountType": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what account types are being used." }
|
|
}
|
|
*/
|
|
this._telemetryReporter.sendTelemetryEvent('account', {
|
|
// Get rid of guids from telemetry.
|
|
scopes: JSON.stringify(token.scope.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}').split(' ')),
|
|
accountType: token.account.type
|
|
});
|
|
}
|
|
}
|
|
|
|
//#region session operations
|
|
|
|
public get onDidChangeSessions(): vscode.Event<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent> {
|
|
return this._sessionChangeEmitter.event;
|
|
}
|
|
|
|
public getSessions(scopes?: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession[]> {
|
|
if (!scopes) {
|
|
this._logger.info('Getting 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);
|
|
}
|
|
|
|
let modifiedScopes = [...scopes];
|
|
if (!modifiedScopes.includes('openid')) {
|
|
modifiedScopes.push('openid');
|
|
}
|
|
if (!modifiedScopes.includes('email')) {
|
|
modifiedScopes.push('email');
|
|
}
|
|
if (!modifiedScopes.includes('profile')) {
|
|
modifiedScopes.push('profile');
|
|
}
|
|
if (!modifiedScopes.includes('offline_access')) {
|
|
modifiedScopes.push('offline_access');
|
|
}
|
|
modifiedScopes = modifiedScopes.sort();
|
|
|
|
const modifiedScopesStr = modifiedScopes.join(' ');
|
|
const clientId = this.getClientId(scopes);
|
|
const scopeData: IScopeData = {
|
|
clientId,
|
|
originalScopes: scopes,
|
|
scopes: modifiedScopes,
|
|
scopeStr: modifiedScopesStr,
|
|
// filter our special scopes
|
|
scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
|
|
tenant: this.getTenantId(scopes),
|
|
};
|
|
|
|
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, 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)
|
|
.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 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.`);
|
|
try {
|
|
const itoken = await this.doRefreshToken(token.refreshToken, scopeData);
|
|
this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(itoken)], removed: [], changed: [] });
|
|
matchingTokens.push(itoken);
|
|
} catch (err) {
|
|
this._logger.error(`[${scopeData.scopeStr}] Attempted to get a new session using the existing session with scopes '${token.scope}' but it failed due to: ${err.message ?? err}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
this._logger.info(`[${scopeData.scopeStr}] Got ${matchingTokens.length} sessions`);
|
|
const results = await Promise.allSettled(matchingTokens.map(token => this.convertToSession(token, scopeData)));
|
|
return results
|
|
.filter(result => result.status === 'fulfilled')
|
|
.map(result => (result as PromiseFulfilledResult<vscode.AuthenticationSession>).value);
|
|
}
|
|
|
|
public createSession(scopes: string[], account?: vscode.AuthenticationSessionAccountInformation): Promise<vscode.AuthenticationSession> {
|
|
let modifiedScopes = [...scopes];
|
|
if (!modifiedScopes.includes('openid')) {
|
|
modifiedScopes.push('openid');
|
|
}
|
|
if (!modifiedScopes.includes('email')) {
|
|
modifiedScopes.push('email');
|
|
}
|
|
if (!modifiedScopes.includes('profile')) {
|
|
modifiedScopes.push('profile');
|
|
}
|
|
if (!modifiedScopes.includes('offline_access')) {
|
|
modifiedScopes.push('offline_access');
|
|
}
|
|
modifiedScopes = modifiedScopes.sort();
|
|
const scopeData: IScopeData = {
|
|
originalScopes: scopes,
|
|
scopes: modifiedScopes,
|
|
scopeStr: modifiedScopes.join(' '),
|
|
// filter our special scopes
|
|
scopesToSend: modifiedScopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
|
|
clientId: this.getClientId(scopes),
|
|
tenant: this.getTenantId(scopes),
|
|
};
|
|
|
|
this._logger.trace(`[${scopeData.scopeStr}] Queued creating session`);
|
|
return this._sequencer.queue(scopeData.scopeStr, () => this.doCreateSession(scopeData, account));
|
|
}
|
|
|
|
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;
|
|
|
|
if (runsServerless && this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {
|
|
throw new Error('Sign in to non-public clouds is not supported on the web.');
|
|
}
|
|
|
|
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, account?.label, token);
|
|
}
|
|
|
|
try {
|
|
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, account?.label, token);
|
|
}
|
|
|
|
throw e;
|
|
}
|
|
});
|
|
}
|
|
|
|
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);
|
|
const qs = new URLSearchParams({
|
|
response_type: 'code',
|
|
response_mode: 'query',
|
|
client_id: scopeData.clientId,
|
|
redirect_uri: redirectUrl,
|
|
scope: scopeData.scopesToSend,
|
|
code_challenge_method: 'S256',
|
|
code_challenge: codeChallenge,
|
|
});
|
|
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();
|
|
|
|
let codeToExchange;
|
|
try {
|
|
vscode.env.openExternal(vscode.Uri.parse(`http://127.0.0.1:${server.port}/signin?nonce=${encodeURIComponent(server.nonce)}`));
|
|
const { code } = await raceCancellationAndTimeoutError(server.waitForOAuthResponse(), token, 1000 * 60 * 5); // 5 minutes
|
|
codeToExchange = code;
|
|
} finally {
|
|
setTimeout(() => {
|
|
void server.stop();
|
|
}, 5000);
|
|
}
|
|
|
|
const session = await this.exchangeCodeForSession(codeToExchange, codeVerifier, scopeData);
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' Sending change event for added session`);
|
|
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
|
|
this._logger.info(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);
|
|
return session;
|
|
}
|
|
|
|
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();
|
|
const callbackQuery = new URLSearchParams(callbackUri.query);
|
|
callbackQuery.set('nonce', encodeURIComponent(nonce));
|
|
callbackUri = callbackUri.with({
|
|
query: callbackQuery.toString()
|
|
});
|
|
const state = encodeURIComponent(callbackUri.toString(true));
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
const signInUrl = new URL(`${scopeData.tenant}/oauth2/v2.0/authorize`, this._env.activeDirectoryEndpointUrl);
|
|
const qs = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: encodeURIComponent(scopeData.clientId),
|
|
response_mode: 'query',
|
|
redirect_uri: redirectUrl,
|
|
state,
|
|
scope: scopeData.scopesToSend,
|
|
code_challenge_method: 'S256',
|
|
code_challenge: codeChallenge,
|
|
});
|
|
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);
|
|
|
|
|
|
const existingNonces = this._pendingNonces.get(scopeData.scopeStr) || [];
|
|
this._pendingNonces.set(scopeData.scopeStr, [...existingNonces, nonce]);
|
|
|
|
// Register a single listener for the URI callback, in case the user starts the login process multiple times
|
|
// before completing it.
|
|
let existingPromise = this._codeExchangePromises.get(scopeData.scopeStr);
|
|
let inputBox: vscode.InputBox | undefined;
|
|
if (!existingPromise) {
|
|
if (isSupportedEnvironment(callbackUri)) {
|
|
existingPromise = this.handleCodeResponse(scopeData);
|
|
} else {
|
|
inputBox = vscode.window.createInputBox();
|
|
existingPromise = this.handleCodeInputBox(inputBox, codeVerifier, scopeData);
|
|
}
|
|
this._codeExchangePromises.set(scopeData.scopeStr, existingPromise);
|
|
}
|
|
|
|
this._codeVerfifiers.set(nonce, codeVerifier);
|
|
|
|
return await raceCancellationAndTimeoutError(existingPromise, token, 1000 * 60 * 5) // 5 minutes
|
|
.finally(() => {
|
|
this._pendingNonces.delete(scopeData.scopeStr);
|
|
this._codeExchangePromises.delete(scopeData.scopeStr);
|
|
this._codeVerfifiers.delete(nonce);
|
|
inputBox?.dispose();
|
|
});
|
|
}
|
|
|
|
public async removeSessionById(sessionId: string, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
|
|
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
|
|
if (tokenIndex === -1) {
|
|
this._logger.warn(`'${sessionId}' Session not found to remove`);
|
|
return Promise.resolve(undefined);
|
|
}
|
|
|
|
const token = this._tokens.splice(tokenIndex, 1)[0];
|
|
this._logger.trace(`[${token.scope}] '${sessionId}' Queued removing session`);
|
|
return this._sequencer.queue(token.scope, () => this.removeSessionByIToken(token, writeToDisk));
|
|
}
|
|
|
|
public async clearSessions() {
|
|
this._logger.trace('Logging out of all sessions');
|
|
this._tokens = [];
|
|
await this._tokenStorage.deleteAll(item => this.sessionMatchesEndpoint(item));
|
|
|
|
this._refreshTimeouts.forEach(timeout => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
this._refreshTimeouts.clear();
|
|
this._logger.trace('All sessions logged out');
|
|
}
|
|
|
|
private async removeSessionByIToken(token: IToken, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
|
|
this._logger.info(`[${token.scope}] '${token.sessionId}' Logging out of session`);
|
|
this.removeSessionTimeout(token.sessionId);
|
|
|
|
if (writeToDisk) {
|
|
await this._tokenStorage.delete(token.sessionId);
|
|
}
|
|
|
|
const tokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
|
|
if (tokenIndex !== -1) {
|
|
this._tokens.splice(tokenIndex, 1);
|
|
}
|
|
|
|
const session = this.convertToSessionSync(token);
|
|
this._logger.trace(`[${token.scope}] '${token.sessionId}' Sending change event for session that was removed`);
|
|
this._sessionChangeEmitter.fire({ added: [], removed: [session], changed: [] });
|
|
this._logger.info(`[${token.scope}] '${token.sessionId}' Logged out of session successfully!`);
|
|
return session;
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region timeout
|
|
|
|
private setSessionTimeout(sessionId: string, refreshToken: string, scopeData: IScopeData, timeout: number) {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Setting refresh timeout for ${timeout} milliseconds`);
|
|
this.removeSessionTimeout(sessionId);
|
|
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
|
|
try {
|
|
const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Sending change event for session that was refreshed`);
|
|
this._sessionChangeEmitter.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' refresh timeout complete`);
|
|
} catch (e) {
|
|
if (e.message !== REFRESH_NETWORK_FAILURE) {
|
|
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
|
|
await this.removeSessionById(sessionId);
|
|
}
|
|
}
|
|
}, timeout));
|
|
}
|
|
|
|
private removeSessionTimeout(sessionId: string): void {
|
|
const timeout = this._refreshTimeouts.get(sessionId);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this._refreshTimeouts.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region convert operations
|
|
|
|
private convertToTokenSync(json: ITokenResponse, scopeData: IScopeData, existingId?: string): IToken {
|
|
let claims = undefined;
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${existingId ?? 'new'}' Attempting to parse token response.`);
|
|
|
|
try {
|
|
if (json.id_token) {
|
|
claims = JSON.parse(base64Decode(json.id_token.split('.')[1]));
|
|
} else {
|
|
this._logger.warn(`[${scopeData.scopeStr}] '${existingId ?? 'new'}' Attempting to parse access_token instead since no id_token was included in the response.`);
|
|
claims = JSON.parse(base64Decode(json.access_token.split('.')[1]));
|
|
}
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
|
|
const id = `${claims.tid}/${(claims.oid ?? (claims.altsecid ?? '' + claims.ipd ?? ''))}`;
|
|
const sessionId = existingId || `${id}/${randomUUID()}`;
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId}' Token response parsed successfully.`);
|
|
return {
|
|
expiresIn: json.expires_in,
|
|
expiresAt: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
|
|
accessToken: json.access_token,
|
|
idToken: json.id_token,
|
|
refreshToken: json.refresh_token,
|
|
scope: scopeData.scopeStr,
|
|
sessionId,
|
|
account: {
|
|
label: claims.preferred_username ?? claims.email ?? claims.unique_name ?? 'user@example.com',
|
|
id,
|
|
type: claims.tid === MSA_TID || claims.tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Return a session object without checking for expiry and potentially refreshing.
|
|
* @param token The token information.
|
|
*/
|
|
private convertToSessionSync(token: IToken): vscode.AuthenticationSession {
|
|
return {
|
|
id: token.sessionId,
|
|
accessToken: token.accessToken!,
|
|
idToken: token.idToken,
|
|
account: token.account,
|
|
scopes: token.scope.split(' ')
|
|
};
|
|
}
|
|
|
|
private async convertToSession(token: IToken, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
|
|
if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token available from cache${token.expiresAt ? `, expires in ${token.expiresAt - Date.now()} milliseconds` : ''}.`);
|
|
return {
|
|
id: token.sessionId,
|
|
accessToken: token.accessToken,
|
|
idToken: token.idToken,
|
|
account: token.account,
|
|
scopes: scopeData.originalScopes ?? scopeData.scopes
|
|
};
|
|
}
|
|
|
|
try {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token expired or unavailable, trying refresh`);
|
|
const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId);
|
|
if (refreshedToken.accessToken) {
|
|
return {
|
|
id: token.sessionId,
|
|
accessToken: refreshedToken.accessToken,
|
|
idToken: refreshedToken.idToken,
|
|
account: token.account,
|
|
// We always prefer the original scopes requested since that array is used as a key in the AuthService
|
|
scopes: scopeData.originalScopes ?? scopeData.scopes
|
|
};
|
|
} else {
|
|
throw new Error();
|
|
}
|
|
} catch (e) {
|
|
throw new Error('Unavailable due to network problems');
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region refresh logic
|
|
|
|
private refreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Queued refreshing token`);
|
|
return this._sequencer.queue(scopeData.scopeStr, () => this.doRefreshToken(refreshToken, scopeData, sessionId));
|
|
}
|
|
|
|
private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId?: string): Promise<IToken> {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Refreshing token`);
|
|
const postData = new URLSearchParams({
|
|
refresh_token: refreshToken,
|
|
client_id: scopeData.clientId,
|
|
grant_type: 'refresh_token',
|
|
scope: scopeData.scopesToSend
|
|
}).toString();
|
|
|
|
try {
|
|
const json = await this.fetchTokenResponse(postData, scopeData);
|
|
const token = this.convertToTokenSync(json, scopeData, sessionId);
|
|
if (token.expiresIn) {
|
|
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
|
|
}
|
|
this.setToken(token, scopeData);
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Token refresh success`);
|
|
return token;
|
|
} catch (e) {
|
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
|
// We were unable to refresh because of a network failure (i.e. the user lost internet access).
|
|
// so set up a timeout to try again later. We only do this if we have a session id to reference later.
|
|
if (sessionId) {
|
|
this.setSessionTimeout(sessionId, refreshToken, scopeData, AzureActiveDirectoryService.POLLING_CONSTANT);
|
|
}
|
|
throw e;
|
|
}
|
|
this._logger.error(`[${scopeData.scopeStr}] '${sessionId ?? 'new'}' Refreshing token failed: ${e.message}`);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region scope parsers
|
|
|
|
private getClientId(scopes: string[]) {
|
|
return scopes.reduce<string | undefined>((prev, current) => {
|
|
if (current.startsWith('VSCODE_CLIENT_ID:')) {
|
|
return current.split('VSCODE_CLIENT_ID:')[1];
|
|
}
|
|
return prev;
|
|
}, undefined) ?? DEFAULT_CLIENT_ID;
|
|
}
|
|
|
|
private getTenantId(scopes: string[]) {
|
|
return scopes.reduce<string | undefined>((prev, current) => {
|
|
if (current.startsWith('VSCODE_TENANT:')) {
|
|
return current.split('VSCODE_TENANT:')[1];
|
|
}
|
|
return prev;
|
|
}, undefined) ?? DEFAULT_TENANT;
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region oauth flow
|
|
|
|
private async handleCodeResponse(scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
|
|
let uriEventListener: vscode.Disposable;
|
|
return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {
|
|
uriEventListener = this._uriHandler.event(async (uri: vscode.Uri) => {
|
|
try {
|
|
const query = new URLSearchParams(uri.query);
|
|
let code = query.get('code');
|
|
let nonce = query.get('nonce');
|
|
if (Array.isArray(code)) {
|
|
code = code[0];
|
|
}
|
|
if (!code) {
|
|
throw new Error('No code included in query');
|
|
}
|
|
if (Array.isArray(nonce)) {
|
|
nonce = nonce[0];
|
|
}
|
|
if (!nonce) {
|
|
throw new Error('No nonce included in query');
|
|
}
|
|
|
|
const acceptedStates = this._pendingNonces.get(scopeData.scopeStr) || [];
|
|
// Workaround double encoding issues of state in web
|
|
if (!acceptedStates.includes(nonce) && !acceptedStates.includes(decodeURIComponent(nonce))) {
|
|
throw new Error('Nonce does not match.');
|
|
}
|
|
|
|
const verifier = this._codeVerfifiers.get(nonce) ?? this._codeVerfifiers.get(decodeURIComponent(nonce));
|
|
if (!verifier) {
|
|
throw new Error('No available code verifier');
|
|
}
|
|
|
|
const session = await this.exchangeCodeForSession(code, verifier, scopeData);
|
|
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
|
|
this._logger.info(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);
|
|
resolve(session);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
}).then(result => {
|
|
uriEventListener.dispose();
|
|
return result;
|
|
}).catch(err => {
|
|
uriEventListener.dispose();
|
|
throw err;
|
|
});
|
|
}
|
|
|
|
private async handleCodeInputBox(inputBox: vscode.InputBox, verifier: string, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
|
|
this._logger.trace(`[${scopeData.scopeStr}] Starting login flow with input box`);
|
|
inputBox.ignoreFocusOut = true;
|
|
inputBox.title = vscode.l10n.t('Microsoft Authentication');
|
|
inputBox.prompt = vscode.l10n.t('Provide the authorization code to complete the sign in flow.');
|
|
inputBox.placeholder = vscode.l10n.t('Paste authorization code here...');
|
|
return new Promise((resolve: (value: vscode.AuthenticationSession) => void, reject) => {
|
|
inputBox.show();
|
|
inputBox.onDidAccept(async () => {
|
|
const code = inputBox.value;
|
|
if (code) {
|
|
inputBox.dispose();
|
|
const session = await this.exchangeCodeForSession(code, verifier, scopeData);
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' sending session changed event because session was added.`);
|
|
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' session successfully created!`);
|
|
resolve(session);
|
|
}
|
|
});
|
|
inputBox.onDidHide(() => {
|
|
if (!inputBox.value) {
|
|
inputBox.dispose();
|
|
reject('Cancelled');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
private async exchangeCodeForSession(code: string, codeVerifier: string, scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
|
|
this._logger.trace(`[${scopeData.scopeStr}] Exchanging login code for session`);
|
|
let token: IToken | undefined;
|
|
try {
|
|
const postData = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: code,
|
|
client_id: scopeData.clientId,
|
|
scope: scopeData.scopesToSend,
|
|
code_verifier: codeVerifier,
|
|
redirect_uri: redirectUrl
|
|
}).toString();
|
|
|
|
const json = await this.fetchTokenResponse(postData, scopeData);
|
|
this._logger.trace(`[${scopeData.scopeStr}] Exchanging code for token succeeded!`);
|
|
token = this.convertToTokenSync(json, scopeData);
|
|
} catch (e) {
|
|
this._logger.error(`[${scopeData.scopeStr}] Error exchanging code for token: ${e}`);
|
|
throw e;
|
|
}
|
|
|
|
if (token.expiresIn) {
|
|
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
|
|
}
|
|
this.setToken(token, scopeData);
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Exchanging login code for session succeeded!`);
|
|
return await this.convertToSession(token, scopeData);
|
|
}
|
|
|
|
private async fetchTokenResponse(postData: string, scopeData: IScopeData): Promise<ITokenResponse> {
|
|
let endpointUrl: string;
|
|
if (this._env.activeDirectoryEndpointUrl !== defaultActiveDirectoryEndpointUrl) {
|
|
// If this is for sovereign clouds, don't try using the proxy endpoint, which supports only public cloud
|
|
endpointUrl = this._env.activeDirectoryEndpointUrl;
|
|
} else {
|
|
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
|
|
endpointUrl = proxyEndpoints?.microsoft || this._env.activeDirectoryEndpointUrl;
|
|
}
|
|
const endpoint = new URL(`${scopeData.tenant}/oauth2/v2.0/token`, endpointUrl);
|
|
|
|
let attempts = 0;
|
|
while (attempts <= 3) {
|
|
attempts++;
|
|
let result;
|
|
let errorMessage: string | undefined;
|
|
try {
|
|
result = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Content-Length': postData.length.toString()
|
|
},
|
|
body: postData
|
|
});
|
|
} catch (e) {
|
|
errorMessage = e.message ?? e;
|
|
}
|
|
|
|
if (!result || result.status > 499) {
|
|
if (attempts > 3) {
|
|
this._logger.error(`[${scopeData.scopeStr}] Fetching token failed: ${result ? await result.text() : errorMessage}`);
|
|
break;
|
|
}
|
|
// Exponential backoff
|
|
await new Promise(resolve => setTimeout(resolve, 5 * attempts * attempts * 1000));
|
|
continue;
|
|
} else if (!result.ok) {
|
|
// For 4XX errors, the user may actually have an expired token or have changed
|
|
// their password recently which is throwing a 4XX. For this, we throw an error
|
|
// so that the user can be prompted to sign in again.
|
|
throw new Error(await result.text());
|
|
}
|
|
|
|
return await result.json() as ITokenResponse;
|
|
}
|
|
|
|
throw new Error(REFRESH_NETWORK_FAILURE);
|
|
}
|
|
|
|
//#endregion
|
|
|
|
//#region storage operations
|
|
|
|
private setToken(token: IToken, scopeData: IScopeData): void {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Setting token`);
|
|
|
|
const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
|
|
if (existingTokenIndex > -1) {
|
|
this._tokens.splice(existingTokenIndex, 1, token);
|
|
} else {
|
|
this._tokens.push(token);
|
|
}
|
|
|
|
// Don't await because setting the token is only useful for any new windows that open.
|
|
void this.storeToken(token, scopeData);
|
|
}
|
|
|
|
private async storeToken(token: IToken, scopeData: IScopeData): Promise<void> {
|
|
if (!vscode.window.state.focused) {
|
|
if (this._pendingTokensToStore.has(token.sessionId)) {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Window is not focused, replacing token to be stored`);
|
|
} else {
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Window is not focused, pending storage of token`);
|
|
}
|
|
this._pendingTokensToStore.set(token.sessionId, token);
|
|
return;
|
|
}
|
|
|
|
await this._tokenStorage.store(token.sessionId, {
|
|
id: token.sessionId,
|
|
refreshToken: token.refreshToken,
|
|
scope: token.scope,
|
|
account: token.account,
|
|
endpoint: this._env.activeDirectoryEndpointUrl,
|
|
});
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Stored token`);
|
|
}
|
|
|
|
private async storePendingTokens(): Promise<void> {
|
|
if (this._pendingTokensToStore.size === 0) {
|
|
this._logger.trace('No pending tokens to store');
|
|
return;
|
|
}
|
|
|
|
const tokens = [...this._pendingTokensToStore.values()];
|
|
this._pendingTokensToStore.clear();
|
|
|
|
this._logger.trace(`Storing ${tokens.length} pending tokens...`);
|
|
await Promise.allSettled(tokens.map(async token => {
|
|
this._logger.trace(`[${token.scope}] '${token.sessionId}' Storing pending token`);
|
|
await this._tokenStorage.store(token.sessionId, {
|
|
id: token.sessionId,
|
|
refreshToken: token.refreshToken,
|
|
scope: token.scope,
|
|
account: token.account,
|
|
endpoint: this._env.activeDirectoryEndpointUrl,
|
|
});
|
|
this._logger.trace(`[${token.scope}] '${token.sessionId}' Stored pending token`);
|
|
}));
|
|
this._logger.trace('Done storing pending tokens');
|
|
}
|
|
|
|
private async checkForUpdates(e: IDidChangeInOtherWindowEvent<IStoredSession>): Promise<void> {
|
|
for (const key of e.added) {
|
|
const session = await this._tokenStorage.get(key);
|
|
if (!session) {
|
|
this._logger.error('session not found that was apparently just added');
|
|
continue;
|
|
}
|
|
|
|
if (!this.sessionMatchesEndpoint(session)) {
|
|
// If the session wasn't made for this login endpoint, ignore this update
|
|
continue;
|
|
}
|
|
|
|
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
|
|
if (!matchesExisting && session.refreshToken) {
|
|
try {
|
|
const scopes = session.scope.split(' ');
|
|
const scopeData: IScopeData = {
|
|
scopes,
|
|
scopeStr: session.scope,
|
|
// filter our special scopes
|
|
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
|
|
clientId: this.getClientId(scopes),
|
|
tenant: this.getTenantId(scopes),
|
|
};
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${session.id}' Session added in another window`);
|
|
const token = await this.refreshToken(session.refreshToken, scopeData, session.id);
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Sending change event for session that was added`);
|
|
this._sessionChangeEmitter.fire({ added: [this.convertToSessionSync(token)], removed: [], changed: [] });
|
|
this._logger.trace(`[${scopeData.scopeStr}] '${token.sessionId}' Session added in another window added here`);
|
|
continue;
|
|
} catch (e) {
|
|
// Network failures will automatically retry on next poll.
|
|
if (e.message !== REFRESH_NETWORK_FAILURE) {
|
|
vscode.window.showErrorMessage(vscode.l10n.t('You have been signed out because reading stored authentication information failed.'));
|
|
await this.removeSessionById(session.id);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const { value } of e.removed) {
|
|
this._logger.trace(`[${value.scope}] '${value.id}' Session removed in another window`);
|
|
if (!this.sessionMatchesEndpoint(value)) {
|
|
// If the session wasn't made for this login endpoint, ignore this update
|
|
this._logger.trace(`[${value.scope}] '${value.id}' Session doesn't match endpoint. Skipping...`);
|
|
continue;
|
|
}
|
|
|
|
await this.removeSessionById(value.id, false);
|
|
this._logger.trace(`[${value.scope}] '${value.id}' Session removed in another window removed here`);
|
|
}
|
|
|
|
// NOTE: We don't need to handle changed sessions because all that really would give us is a new refresh token
|
|
// because access tokens are not stored in Secret Storage due to their short lifespan. This new refresh token
|
|
// is not useful in this window because we really only care about the lifetime of the _access_ token which we
|
|
// are already managing (see usages of `setSessionTimeout`).
|
|
// However, in order to minimize the amount of times we store tokens, if a token was stored via another window,
|
|
// we cancel any pending token storage operations.
|
|
for (const sessionId of e.updated) {
|
|
if (this._pendingTokensToStore.delete(sessionId)) {
|
|
this._logger.trace(`'${sessionId}' Cancelled pending token storage because token was updated in another window`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sessionMatchesEndpoint(session: IStoredSession): boolean {
|
|
// For older sessions with no endpoint set, it can be assumed to be the default endpoint
|
|
session.endpoint ||= defaultActiveDirectoryEndpointUrl;
|
|
|
|
return session.endpoint === this._env.activeDirectoryEndpointUrl;
|
|
}
|
|
|
|
//#endregion
|
|
}
|