large refactor including new secret storage wrapper and overall code clean up and organization

This commit is contained in:
Tyler Leonhardt
2022-02-01 18:14:31 -08:00
parent b1781cc7ae
commit f498b374c0
3 changed files with 634 additions and 398 deletions

View File

@@ -7,15 +7,15 @@ import * as randomBytes from 'randombytes';
import * as querystring from 'querystring';
import { Buffer } from 'buffer';
import * as vscode from 'vscode';
import { createServer, startServer } from './authServer';
import * as nls from 'vscode-nls';
import { v4 as uuid } from 'uuid';
import fetch, { Response } from 'node-fetch';
import { createServer, startServer } from './authServer';
import { Keychain } from './keychain';
import Logger from './logger';
import { toBase64UrlEncoding } from './utils';
import fetch, { Response } from 'node-fetch';
import { sha256 } from './env/node/sha256';
import * as nls from 'vscode-nls';
import { BetterTokenStorage, IDidChangeInOtherWindowEvent } from './betterSecretStorage';
const localize = nls.loadMessageBundle();
@@ -105,241 +105,80 @@ class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.
}
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 _refreshingPromise: Promise<any> | undefined;
private _uriHandler: UriEventHandler;
private _disposable: vscode.Disposable;
// Used to keep track of current requests when not using the local server approach.
private _pendingStates = new Map<string, string[]>();
private _codeExchangePromises = new Map<string, Promise<vscode.AuthenticationSession>>();
private _codeVerfifiers = new Map<string, string>();
private _keychain: Keychain;
private readonly _tokenStorage: BetterTokenStorage<IStoredSession>;
constructor(private _context: vscode.ExtensionContext) {
this._keychain = new Keychain(_context);
this._tokenStorage = new BetterTokenStorage('microsoft.login.keylist', _context);
this._uriHandler = new UriEventHandler();
this._disposable = vscode.Disposable.from(
vscode.window.registerUriHandler(this._uriHandler),
this._context.secrets.onDidChange(() => this.checkForUpdates()));
_context.subscriptions.push(vscode.window.registerUriHandler(this._uriHandler));
_context.subscriptions.push(this._tokenStorage.onDidChangeInOtherWindow((e) => this.checkForUpdates(e)));
}
public async initialize(): Promise<void> {
Logger.info('Reading sessions from keychain...');
const storedData = await this._keychain.getToken();
if (!storedData) {
Logger.info('No stored sessions found.');
return;
}
Logger.info('Got stored sessions!');
Logger.info('Reading sessions from secret storage...');
let sessions = await this._tokenStorage.getAll();
Logger.info(`Got ${sessions.length} stored sessions`);
try {
const sessions = this.parseStoredData(storedData);
const refreshes = sessions.map(async session => {
Logger.trace(`Read the following session from the keychain with the following scopes: ${session.scope}`);
if (!session.refreshToken) {
Logger.trace(`Session with the following scopes does not have a refresh token so we will not try to refresh it: ${session.scope}`);
return Promise.resolve();
}
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),
};
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: {
label: session.account.label ?? session.account.displayName!,
id: session.account.id
},
scope: session.scope,
sessionId: session.id
});
} else {
await this.removeSession(session.id);
}
}
});
await Promise.all(refreshes);
} catch (e) {
Logger.error(`Failed to initialize stored data: ${e}`);
await this.clearSessions();
}
}
private parseStoredData(data: string): IStoredSession[] {
return JSON.parse(data);
}
private async storeTokenData(): Promise<void> {
const serializedData: IStoredSession[] = this._tokens.map(token => {
return {
id: token.sessionId,
refreshToken: token.refreshToken,
scope: token.scope,
account: token.account
};
});
Logger.trace('storing data into keychain...');
await this._keychain.setToken(JSON.stringify(serializedData));
}
private async checkForUpdates(): Promise<void> {
const added: vscode.AuthenticationSession[] = [];
let removed: vscode.AuthenticationSession[] = [];
const storedData = await this._keychain.getToken();
if (storedData) {
try {
const sessions = this.parseStoredData(storedData);
let promises = sessions.map(async session => {
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),
};
const token = await this.refreshToken(session.refreshToken, scopeData, session.id);
added.push(this.convertToSessionSync(token));
} catch (e) {
// Network failures will automatically retry on next poll.
if (e.message !== REFRESH_NETWORK_FAILURE) {
await this.removeSession(session.id);
}
}
}
});
promises = promises.concat(this._tokens.map(async token => {
const matchesExisting = sessions.some(session => token.scope === session.scope && token.sessionId === session.id);
if (!matchesExisting) {
await this.removeSession(token.sessionId);
removed.push(this.convertToSessionSync(token));
}
}));
await Promise.all(promises);
} catch (e) {
Logger.error(e.message);
// if data is improperly formatted, remove all of it and send change event
removed = this._tokens.map(this.convertToSessionSync);
this.clearSessions();
}
} else {
if (this._tokens.length) {
// Log out all, remove all local data
removed = this._tokens.map(this.convertToSessionSync);
Logger.info('No stored keychain data, clearing local data');
this._tokens = [];
this._refreshTimeouts.forEach(timeout => {
clearTimeout(timeout);
});
this._refreshTimeouts.clear();
}
if (!sessions.length) {
sessions = await this.migrate();
}
if (added.length || removed.length) {
Logger.info(`Sending change event with ${added.length} added and ${removed.length} removed`);
onDidChangeSessions.fire({ added: added, removed: removed, changed: [] });
}
}
/**
* 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): Promise<vscode.AuthenticationSession> {
const resolvedTokens = await this.resolveAccessAndIdTokens(token);
return {
id: token.sessionId,
accessToken: resolvedTokens.accessToken,
idToken: resolvedTokens.idToken,
account: token.account,
scopes: token.scope.split(' ')
};
}
private async resolveAccessAndIdTokens(token: IToken): Promise<IMicrosoftTokens> {
if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {
token.expiresAt
? Logger.info(`Token available from cache (for scopes ${token.scope}), expires in ${token.expiresAt - Date.now()} milliseconds`)
: Logger.info('Token available from cache (for scopes ${token.scope})');
return Promise.resolve({
accessToken: token.accessToken,
idToken: token.idToken
});
}
try {
Logger.info(`Token expired or unavailable (for scopes ${token.scope}), trying refresh`);
const scopes = token.scope.split(' ');
const refreshes = sessions.map(async session => {
Logger.trace(`Read the following stored session with scopes: ${session.scope}`);
const scopes = session.scope.split(' ');
const scopeData: IScopeData = {
scopes,
scopeStr: token.scope,
scopeStr: session.scope,
// filter our special scopes
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
clientId: this.getClientId(scopes),
tenant: this.getTenantId(scopes),
};
const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId);
if (refreshedToken.accessToken) {
return {
accessToken: refreshedToken.accessToken,
idToken: refreshedToken.idToken
};
} else {
throw new Error();
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: {
label: session.account.label ?? session.account.displayName!,
id: session.account.id
},
scope: session.scope,
sessionId: session.id
});
} else {
Logger.error(e);
await this.removeSession(session.id);
}
}
});
const result = await Promise.allSettled(refreshes);
for (const res of result) {
if (res.status === 'rejected') {
Logger.error(`Failed to initialize stored data: ${res.reason}`);
this.clearSessions();
}
} catch (e) {
throw new Error('Unavailable due to network problems');
}
}
private getTokenClaims(accessToken: string): ITokenClaims {
try {
return JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64').toString());
} catch (e) {
Logger.error(e.message);
throw new Error('Unable to read token claims');
}
}
get sessions(): Promise<vscode.AuthenticationSession[]> {
return Promise.all(this._tokens.map(token => this.convertToSession(token)));
}
//#region session operations
async getSessions(scopes?: string[]): Promise<vscode.AuthenticationSession[]> {
Logger.info(`Getting sessions for ${scopes?.join(',') ?? 'all scopes'}...`);
@@ -363,7 +202,7 @@ export class AzureActiveDirectoryService {
return Promise.all(matchingTokens.map(token => this.convertToSession(token)));
}
public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
public createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
const scopeData: IScopeData = {
scopes,
scopeStr: scopes.join(' '),
@@ -381,9 +220,24 @@ export class AzureActiveDirectoryService {
const runsRemote = vscode.env.remoteName !== undefined;
const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web;
if (runsRemote || runsServerless) {
return this.loginWithoutLocalServer(scopeData);
return this.createSessionWithoutLocalServer(scopeData);
}
try {
return this.createSessionWithLocalServer(scopeData);
} catch (e) {
Logger.error(`Error creating session for scopes: ${scopeData.scopeStr} Error: ${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);
}
throw e;
}
}
private async createSessionWithLocalServer(scopeData: IScopeData) {
const nonce = randomBytes(16).toString('base64');
const { server, redirectPromise, codePromise } = createServer(nonce);
@@ -422,6 +276,9 @@ export class AzureActiveDirectoryService {
throw codeRes.err;
}
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scopeData);
if (token.expiresIn) {
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
}
await this.setToken(token, scopeData);
Logger.info(`Login successful for scopes: ${scopeData.scopeStr}`);
res.writeHead(302, { Location: '/' });
@@ -433,15 +290,6 @@ export class AzureActiveDirectoryService {
} finally {
res.end();
}
} catch (e) {
Logger.error(`Error creating session for scopes: ${scopeData.scopeStr} Error: ${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.loginWithoutLocalServer(scopeData);
}
throw e;
} finally {
setTimeout(() => {
server.close();
@@ -449,32 +297,11 @@ export class AzureActiveDirectoryService {
}
}
public dispose(): void {
this._disposable.dispose();
}
private getCallbackEnvironment(callbackUri: vscode.Uri): string {
if (callbackUri.scheme !== 'https' && callbackUri.scheme !== 'http') {
return callbackUri.scheme;
}
switch (callbackUri.authority) {
case 'online.visualstudio.com':
return 'vso';
case 'online-ppe.core.vsengsaas.visualstudio.com':
return 'vsoppe';
case 'online.dev.core.vsengsaas.visualstudio.com':
return 'vsodev';
default:
return callbackUri.authority;
}
}
private async loginWithoutLocalServer(scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
private async createSessionWithoutLocalServer(scopeData: IScopeData): Promise<vscode.AuthenticationSession> {
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://vscode.microsoft-authentication`));
const nonce = randomBytes(16).toString('base64');
const port = (callbackUri.authority.match(/:([0-9]*)$/) || [])[1] || (callbackUri.scheme === 'https' ? 443 : 80);
const callbackEnvironment = this.getCallbackEnvironment(callbackUri);
const callbackEnvironment = AzureActiveDirectoryService.getCallbackEnvironment(callbackUri);
const state = `${callbackEnvironment},${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`;
const signInUrl = `${loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize`;
let uri = vscode.Uri.parse(signInUrl);
@@ -513,81 +340,81 @@ export class AzureActiveDirectoryService {
});
}
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 = parseQuery(uri);
const code = query.code;
const acceptedStates = this._pendingStates.get(scopeData.scopeStr) || [];
// Workaround double encoding issues of state in web
if (!acceptedStates.includes(query.state) && !acceptedStates.includes(decodeURIComponent(query.state))) {
throw new Error('State does not match.');
}
public async removeSession(sessionId: string, writeToDisk: boolean = true): Promise<vscode.AuthenticationSession | undefined> {
Logger.info(`Logging out of session '${sessionId}'`);
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
if (tokenIndex === -1) {
Logger.info(`Session not found '${sessionId}'`);
return undefined;
}
const verifier = this._codeVerfifiers.get(query.state) ?? this._codeVerfifiers.get(decodeURIComponent(query.state));
if (!verifier) {
throw new Error('No available code verifier');
}
const token = this._tokens[tokenIndex];
this._tokens.splice(tokenIndex, 1);
this.removeSessionTimeout(sessionId);
const token = await this.exchangeCodeForToken(code, verifier, scopeData);
await this.setToken(token, scopeData);
if (writeToDisk) {
await this._tokenStorage.delete(sessionId);
}
const session = await this.convertToSession(token);
resolve(session);
} catch (err) {
reject(err);
}
});
}).then(result => {
uriEventListener.dispose();
return result;
}).catch(err => {
uriEventListener.dispose();
throw err;
const session = this.convertToSessionSync(token);
Logger.info(`Sending change event for session that was removed with scopes: ${token.scope}`);
onDidChangeSessions.fire({ added: [], removed: [session], changed: [] });
Logger.info(`Logged out of session '${sessionId}' with scopes: ${token.scope}`);
return session;
}
public async clearSessions() {
Logger.info('Logging out of all sessions');
this._tokens = [];
await this._tokenStorage.deleteAll();
this._refreshTimeouts.forEach(timeout => {
clearTimeout(timeout);
});
this._refreshTimeouts.clear();
}
private async setToken(token: IToken, scopeData: IScopeData): Promise<void> {
Logger.info(`Setting token for scopes: ${scopeData.scopeStr}`);
const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
if (existingTokenIndex > -1) {
this._tokens.splice(existingTokenIndex, 1, token);
} else {
this._tokens.push(token);
}
//#endregion
this.clearSessionTimeout(token.sessionId);
//#region timeout
if (token.expiresIn) {
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
try {
const refreshedToken = await this.refreshToken(token.refreshToken, scopeData, token.sessionId);
Logger.info('Triggering change session event...');
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
} catch (e) {
if (e.message !== REFRESH_NETWORK_FAILURE) {
await this.removeSession(token.sessionId);
onDidChangeSessions.fire({ added: [], removed: [this.convertToSessionSync(token)], changed: [] });
}
private setSessionTimeout(sessionId: string, refreshToken: string, scopeData: IScopeData, timeout: number) {
this.removeSessionTimeout(sessionId);
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
try {
const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);
Logger.info('Triggering change session event...');
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
} catch (e) {
if (e.message !== REFRESH_NETWORK_FAILURE) {
await this.removeSession(sessionId);
}
// For details on why this is set to 2/3... see https://github.com/microsoft/vscode/issues/133201#issuecomment-966668197
}, 1000 * (token.expiresIn * 2 / 3)));
}
await this.storeTokenData();
}
}, timeout));
}
private getTokenFromResponse(json: ITokenResponse, scopeData: IScopeData, existingId?: string): IToken {
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;
try {
claims = this.getTokenClaims(json.access_token);
claims = JSON.parse(Buffer.from(json.access_token.split('.')[1], 'base64').toString());
} catch (e) {
if (json.id_token) {
Logger.info('Failed to fetch token claims from access_token. Attempting to parse id_token instead');
claims = this.getTokenClaims(json.id_token);
Logger.info('Attempting to parse id_token instead since access_token was not parsable');
claims = JSON.parse(Buffer.from(json.id_token.split('.')[1], 'base64').toString());
} else {
throw e;
}
@@ -608,6 +435,115 @@ export class AzureActiveDirectoryService {
};
}
/**
* 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): Promise<vscode.AuthenticationSession> {
if (token.accessToken && (!token.expiresAt || token.expiresAt > Date.now())) {
token.expiresAt
? Logger.info(`Token available from cache (for scopes ${token.scope}), expires in ${token.expiresAt - Date.now()} milliseconds`)
: Logger.info('Token available from cache (for scopes ${token.scope})');
return {
id: token.sessionId,
accessToken: token.accessToken,
idToken: token.idToken,
account: token.account,
scopes: token.scope.split(' ')
};
}
try {
Logger.info(`Token expired or unavailable (for scopes ${token.scope}), trying refresh`);
const scopes = token.scope.split(' ');
const scopeData: IScopeData = {
scopes,
scopeStr: token.scope,
// filter our special scopes
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
clientId: this.getClientId(scopes),
tenant: this.getTenantId(scopes),
};
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,
scopes: token.scope.split(' ')
};
} else {
throw new Error();
}
} catch (e) {
throw new Error('Unavailable due to network problems');
}
}
//#endregion
//#region refresh logic
private async refreshToken(refreshToken: string, scopeData: IScopeData, sessionId: string): Promise<IToken> {
this._refreshingPromise = this.doRefreshToken(refreshToken, scopeData, sessionId);
try {
const result = await this._refreshingPromise;
return result;
} finally {
this._refreshingPromise = undefined;
}
}
private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId: string): Promise<IToken> {
Logger.info(`Refreshing token for scopes: ${scopeData.scopeStr}`);
const postData = querystring.stringify({
refresh_token: refreshToken,
client_id: scopeData.clientId,
grant_type: 'refresh_token',
scope: scopeData.scopesToSend
});
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl;
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;
try {
const json = await this.fetchTokenResponse(endpoint, 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);
}
await this.setToken(token, scopeData);
Logger.info(`Token refresh success for scopes: ${token.scope}`);
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.
this.setSessionTimeout(sessionId, refreshToken, scopeData, AzureActiveDirectoryService.POLLING_CONSTANT);
throw e;
}
vscode.window.showErrorMessage(localize('signOut', "You have been signed out because reading stored authentication information failed."));
Logger.error(`Refreshing token failed (for scopes: ${scopeData.scopeStr}): ${e.message}`);
throw new Error('Refreshing token failed');
}
}
//#endregion
//#region scope parsers
private getClientId(scopes: string[]) {
return scopes.reduce<string | undefined>((prev, current) => {
if (current.startsWith('VSCODE_CLIENT_ID:')) {
@@ -626,6 +562,49 @@ export class AzureActiveDirectoryService {
}, 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 = parseQuery(uri);
const code = query.code;
const acceptedStates = this._pendingStates.get(scopeData.scopeStr) || [];
// Workaround double encoding issues of state in web
if (!acceptedStates.includes(query.state) && !acceptedStates.includes(decodeURIComponent(query.state))) {
throw new Error('State does not match.');
}
const verifier = this._codeVerfifiers.get(query.state) ?? this._codeVerfifiers.get(decodeURIComponent(query.state));
if (!verifier) {
throw new Error('No available code verifier');
}
const token = await this.exchangeCodeForToken(code, verifier, scopeData);
if (token.expiresIn) {
this.setSessionTimeout(token.sessionId, token.refreshToken, scopeData, token.expiresIn * AzureActiveDirectoryService.REFRESH_TIMEOUT_MODIFIER);
}
await this.setToken(token, scopeData);
const session = await this.convertToSession(token);
resolve(session);
} catch (err) {
reject(err);
}
});
}).then(result => {
uriEventListener.dispose();
return result;
}).catch(err => {
uriEventListener.dispose();
throw err;
});
}
private async exchangeCodeForToken(code: string, codeVerifier: string, scopeData: IScopeData): Promise<IToken> {
Logger.info(`Exchanging login code for token for scopes: ${scopeData.scopeStr}`);
try {
@@ -644,23 +623,13 @@ export class AzureActiveDirectoryService {
const json = await this.fetchTokenResponse(endpoint, postData, scopeData);
Logger.info(`Exchanging login code for token (for scopes: ${scopeData.scopeStr}) succeeded!`);
return this.getTokenFromResponse(json, scopeData);
return this.convertToTokenSync(json, scopeData);
} catch (e) {
Logger.error(`Error exchanging code for token (for scopes ${scopeData.scopeStr}): ${e}`);
throw e;
}
}
private async refreshToken(refreshToken: string, scopeData: IScopeData, sessionId: string): Promise<IToken> {
this._refreshingPromise = this.doRefreshToken(refreshToken, scopeData, sessionId);
try {
const result = await this._refreshingPromise;
return result;
} finally {
this._refreshingPromise = undefined;
}
}
private async fetchTokenResponse(endpoint: string, postData: string, scopeData: IScopeData): Promise<ITokenResponse> {
let attempts = 0;
while (attempts <= 3) {
@@ -701,97 +670,119 @@ export class AzureActiveDirectoryService {
throw new Error(REFRESH_NETWORK_FAILURE);
}
private async doRefreshToken(refreshToken: string, scopeData: IScopeData, sessionId: string): Promise<IToken> {
Logger.info(`Refreshing token for scopes: ${scopeData.scopeStr}`);
const postData = querystring.stringify({
refresh_token: refreshToken,
client_id: scopeData.clientId,
grant_type: 'refresh_token',
scope: scopeData.scopesToSend
});
//#endregion
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl;
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;
//#region storage operations
private async setToken(token: IToken, scopeData: IScopeData): Promise<void> {
Logger.info(`Setting token for scopes: ${scopeData.scopeStr}`);
const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
if (existingTokenIndex > -1) {
this._tokens.splice(existingTokenIndex, 1, token);
} else {
this._tokens.push(token);
}
await this._tokenStorage.store(token.sessionId, {
id: token.sessionId,
refreshToken: token.refreshToken,
scope: token.scope,
account: token.account
});
}
private async checkForUpdates(e: IDidChangeInOtherWindowEvent<IStoredSession>): Promise<void> {
const added: vscode.AuthenticationSession[] = [];
const removed: vscode.AuthenticationSession[] = [];
for (const key of e.added) {
const session = await this._tokenStorage.get(key);
if (!session) {
Logger.error('session not found that was apparently just added');
return;
}
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),
};
Logger.info(`Session added in another window with scopes: ${session.scope}`);
const token = await this.refreshToken(session.refreshToken, scopeData, session.id);
Logger.info(`Sending change event for session that was added with scopes: ${scopeData.scopeStr}`);
onDidChangeSessions.fire({ added: [this.convertToSessionSync(token)], removed: [], changed: [] });
return;
} catch (e) {
// Network failures will automatically retry on next poll.
if (e.message !== REFRESH_NETWORK_FAILURE) {
await this.removeSession(session.id);
}
return;
}
}
}
for (const { value } of e.removed) {
Logger.info(`Session removed in another window with scopes: ${value.scope}`);
const session = await this.removeSession(value.id, false);
if (session) {
removed.push(session);
}
}
}
private async migrate() {
Logger.info('Attempting to migrate stored sessions.');
// const migrated = this._context.globalState.get<{ migrated: boolean }>('microsoft-better-storage-layout-migrated');
// if (migrated?.migrated) {
// return [];
// }
await this._context.globalState.update('microsoft-better-storage-layout-migrated', { migrated: true });
const keychain = new Keychain(this._context);
const storedData = await keychain.getToken();
if (!storedData) {
Logger.info('No stored sessions found.');
return [];
}
try {
const json = await this.fetchTokenResponse(endpoint, postData, scopeData);
const token = this.getTokenFromResponse(json, scopeData, sessionId);
await this.setToken(token, scopeData);
Logger.info(`Token refresh success for scopes: ${token.scope}`);
return token;
const sessions = JSON.parse(storedData) as IStoredSession[];
Logger.info(`Migrated ${sessions.length} stored sessions.`);
return sessions;
} 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.
this.pollForReconnect(sessionId, refreshToken, scopeData);
throw e;
}
vscode.window.showErrorMessage(localize('signOut', "You have been signed out because reading stored authentication information failed."));
Logger.error(`Refreshing token failed (for scopes: ${scopeData.scopeStr}): ${e.message}`);
throw new Error('Refreshing token failed');
Logger.info('Failed to parse stored sessions. Migrating no sessions.');
return [];
} finally {
// await keychain.deleteToken();
}
}
private clearSessionTimeout(sessionId: string): void {
const timeout = this._refreshTimeouts.get(sessionId);
if (timeout) {
clearTimeout(timeout);
this._refreshTimeouts.delete(sessionId);
//#endregion
//#region static methods
private static getCallbackEnvironment(callbackUri: vscode.Uri): string {
if (callbackUri.scheme !== 'https' && callbackUri.scheme !== 'http') {
return callbackUri.scheme;
}
switch (callbackUri.authority) {
case 'online.visualstudio.com':
return 'vso';
case 'online-ppe.core.vsengsaas.visualstudio.com':
return 'vsoppe';
case 'online.dev.core.vsengsaas.visualstudio.com':
return 'vsodev';
default:
return callbackUri.authority;
}
}
private removeInMemorySessionData(sessionId: string): IToken | undefined {
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
let token: IToken | undefined;
if (tokenIndex > -1) {
token = this._tokens[tokenIndex];
this._tokens.splice(tokenIndex, 1);
}
this.clearSessionTimeout(sessionId);
return token;
}
private pollForReconnect(sessionId: string, refreshToken: string, scopeData: IScopeData): void {
this.clearSessionTimeout(sessionId);
Logger.trace(`Setting up reconnection timeout for scopes: ${scopeData.scopeStr}...`);
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
try {
const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
} catch (e) {
this.pollForReconnect(sessionId, refreshToken, scopeData);
}
}, 1000 * 60 * 30));
}
public async removeSession(sessionId: string): Promise<vscode.AuthenticationSession | undefined> {
Logger.info(`Logging out of session '${sessionId}'`);
const token = this.removeInMemorySessionData(sessionId);
let session: vscode.AuthenticationSession | undefined;
if (token) {
session = this.convertToSessionSync(token);
}
if (this._tokens.length === 0) {
await this._keychain.deleteToken();
} else {
await this.storeTokenData();
}
return session;
}
public async clearSessions() {
Logger.info('Logging out of all sessions');
this._tokens = [];
await this._keychain.deleteToken();
this._refreshTimeouts.forEach(timeout => {
clearTimeout(timeout);
});
this._refreshTimeouts.clear();
}
//#endregion
}