eae5d8c807
These conflicts will be resolved in the following commits. We do it this way so that PR review is possible.
690 lines
22 KiB
TypeScript
690 lines
22 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 randomBytes from 'randombytes';
|
|
import * as querystring from 'querystring';
|
|
import { Buffer } from 'buffer';
|
|
import * as vscode from 'vscode';
|
|
import { createServer, startServer } from './authServer';
|
|
|
|
import { v4 as uuid } from 'uuid';
|
|
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 { MicrosoftAuthenticationSession } from './microsoft-authentication';
|
|
|
|
const localize = nls.loadMessageBundle();
|
|
|
|
const redirectUrl = 'https://vscode-redirect.azurewebsites.net/';
|
|
const loginEndpointUrl = 'https://login.microsoftonline.com/';
|
|
const clientId = 'aebc6443-996d-45c2-90f0-388ff96faa56';
|
|
const tenant = 'organizations';
|
|
|
|
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;
|
|
};
|
|
scope: string;
|
|
sessionId: string; // The account id + the scope
|
|
}
|
|
|
|
interface ITokenClaims {
|
|
tid: string;
|
|
email?: string;
|
|
unique_name?: string;
|
|
preferred_username?: string;
|
|
oid?: string;
|
|
altsecid?: string;
|
|
ipd?: string;
|
|
scp: string;
|
|
}
|
|
|
|
interface IStoredSession {
|
|
id: string;
|
|
refreshToken: string;
|
|
scope: string; // Scopes are alphabetized and joined with a space
|
|
account: {
|
|
label?: string;
|
|
displayName?: string,
|
|
id: string
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function parseQuery(uri: vscode.Uri) {
|
|
return uri.query.split('&').reduce((prev: any, current) => {
|
|
const queryString = current.split('=');
|
|
prev[queryString[0]] = queryString[1];
|
|
return prev;
|
|
}, {});
|
|
}
|
|
|
|
export const onDidChangeSessions = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
|
|
|
|
export const REFRESH_NETWORK_FAILURE = 'Network failure';
|
|
|
|
class UriEventHandler extends vscode.EventEmitter<vscode.Uri> implements vscode.UriHandler {
|
|
public handleUri(uri: vscode.Uri) {
|
|
this.fire(uri);
|
|
}
|
|
}
|
|
|
|
export class AzureActiveDirectoryService {
|
|
private _tokens: IToken[] = [];
|
|
private _refreshTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>();
|
|
private _uriHandler: UriEventHandler;
|
|
private _disposables: 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;
|
|
|
|
constructor(private _context: vscode.ExtensionContext) {
|
|
this._keychain = new Keychain(_context);
|
|
this._uriHandler = new UriEventHandler();
|
|
this._disposables.push(vscode.window.registerUriHandler(this._uriHandler));
|
|
}
|
|
|
|
public async initialize(): Promise<void> {
|
|
const storedData = await this._keychain.getToken() || await this._keychain.tryMigrate();
|
|
if (storedData) {
|
|
try {
|
|
const sessions = this.parseStoredData(storedData);
|
|
const refreshes = sessions.map(async session => {
|
|
if (!session.refreshToken) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
try {
|
|
await this.refreshToken(session.refreshToken, session.scope, session.id);
|
|
} catch (e) {
|
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
|
const didSucceedOnRetry = await this.handleRefreshNetworkError(session.id, session.refreshToken, session.scope);
|
|
if (!didSucceedOnRetry) {
|
|
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
|
|
});
|
|
this.pollForReconnect(session.id, session.refreshToken, session.scope);
|
|
}
|
|
} else {
|
|
await this.logout(session.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
await Promise.all(refreshes);
|
|
} catch (e) {
|
|
Logger.info('Failed to initialize stored data');
|
|
await this.clearSessions();
|
|
}
|
|
}
|
|
|
|
this._disposables.push(this._context.secrets.onDidChange(() => this.checkForUpdates));
|
|
}
|
|
|
|
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
|
|
};
|
|
});
|
|
|
|
await this._keychain.setToken(JSON.stringify(serializedData));
|
|
}
|
|
|
|
private async checkForUpdates(): Promise<void> {
|
|
const addedIds: string[] = [];
|
|
let removedIds: string[] = [];
|
|
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 {
|
|
await this.refreshToken(session.refreshToken, session.scope, session.id);
|
|
addedIds.push(session.id);
|
|
} catch (e) {
|
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
|
// Ignore, will automatically retry on next poll.
|
|
} else {
|
|
await this.logout(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.logout(token.sessionId);
|
|
removedIds.push(token.sessionId);
|
|
}
|
|
}));
|
|
|
|
await Promise.all(promises);
|
|
} catch (e) {
|
|
Logger.error(e.message);
|
|
// if data is improperly formatted, remove all of it and send change event
|
|
removedIds = this._tokens.map(token => token.sessionId);
|
|
this.clearSessions();
|
|
}
|
|
} else {
|
|
if (this._tokens.length) {
|
|
// Log out all, remove all local data
|
|
removedIds = this._tokens.map(token => token.sessionId);
|
|
Logger.info('No stored keychain data, clearing local data');
|
|
|
|
this._tokens = [];
|
|
|
|
this._refreshTimeouts.forEach(timeout => {
|
|
clearTimeout(timeout);
|
|
});
|
|
|
|
this._refreshTimeouts.clear();
|
|
}
|
|
}
|
|
|
|
if (addedIds.length || removedIds.length) {
|
|
onDidChangeSessions.fire({ added: addedIds, removed: removedIds, changed: [] });
|
|
}
|
|
}
|
|
|
|
private async convertToSession(token: IToken): Promise<MicrosoftAuthenticationSession> {
|
|
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, expires in ${token.expiresAt - Date.now()} milliseconds`)
|
|
: Logger.info('Token available from cache');
|
|
return Promise.resolve({
|
|
accessToken: token.accessToken,
|
|
idToken: token.idToken
|
|
});
|
|
}
|
|
|
|
try {
|
|
Logger.info('Token expired or unavailable, trying refresh');
|
|
const refreshedToken = await this.refreshToken(token.refreshToken, token.scope, token.sessionId);
|
|
if (refreshedToken.accessToken) {
|
|
return {
|
|
accessToken: refreshedToken.accessToken,
|
|
idToken: refreshedToken.idToken
|
|
};
|
|
} else {
|
|
throw new Error();
|
|
}
|
|
} 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)));
|
|
}
|
|
|
|
public async login(scope: string): Promise<vscode.AuthenticationSession> {
|
|
Logger.info('Logging in...');
|
|
if (!scope.includes('offline_access')) {
|
|
Logger.info('Warning: The \'offline_access\' scope was not included, so the generated token will not be able to be refreshed.');
|
|
}
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
if (vscode.env.remoteName !== undefined) {
|
|
resolve(this.loginWithoutLocalServer(scope));
|
|
return;
|
|
}
|
|
|
|
const nonce = randomBytes(16).toString('base64');
|
|
const { server, redirectPromise, codePromise } = createServer(nonce);
|
|
|
|
let token: IToken | undefined;
|
|
try {
|
|
const port = await startServer(server);
|
|
vscode.env.openExternal(vscode.Uri.parse(`http://localhost:${port}/signin?nonce=${encodeURIComponent(nonce)}`));
|
|
|
|
const redirectReq = await redirectPromise;
|
|
if ('err' in redirectReq) {
|
|
const { err, res } = redirectReq;
|
|
res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
|
|
res.end();
|
|
throw err;
|
|
}
|
|
|
|
const host = redirectReq.req.headers.host || '';
|
|
const updatedPortStr = (/^[^:]+:(\d+)$/.exec(Array.isArray(host) ? host[0] : host) || [])[1];
|
|
const updatedPort = updatedPortStr ? parseInt(updatedPortStr, 10) : port;
|
|
|
|
const state = `${updatedPort},${encodeURIComponent(nonce)}`;
|
|
|
|
const codeVerifier = toBase64UrlEncoding(randomBytes(32).toString('base64'));
|
|
const codeChallenge = toBase64UrlEncoding(await sha256(codeVerifier));
|
|
const loginUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&scope=${encodeURIComponent(scope)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`;
|
|
|
|
await redirectReq.res.writeHead(302, { Location: loginUrl });
|
|
redirectReq.res.end();
|
|
|
|
const codeRes = await codePromise;
|
|
const res = codeRes.res;
|
|
|
|
try {
|
|
if ('err' in codeRes) {
|
|
throw codeRes.err;
|
|
}
|
|
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scope);
|
|
this.setToken(token, scope);
|
|
Logger.info('Login successful');
|
|
res.writeHead(302, { Location: '/' });
|
|
const session = await this.convertToSession(token);
|
|
resolve(session);
|
|
res.end();
|
|
} catch (err) {
|
|
res.writeHead(302, { Location: `/?error=${encodeURIComponent(err && err.message || 'Unknown error')}` });
|
|
res.end();
|
|
reject(err.message);
|
|
}
|
|
} catch (e) {
|
|
Logger.error(e.message);
|
|
|
|
// 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') {
|
|
await this.loginWithoutLocalServer(scope);
|
|
}
|
|
|
|
reject(e.message);
|
|
} finally {
|
|
setTimeout(() => {
|
|
server.close();
|
|
}, 5000);
|
|
}
|
|
});
|
|
}
|
|
|
|
public dispose(): void {
|
|
this._disposables.forEach(disposable => disposable.dispose());
|
|
this._disposables = [];
|
|
}
|
|
|
|
private getCallbackEnvironment(callbackUri: vscode.Uri): string {
|
|
if (callbackUri.authority.endsWith('.workspaces.github.com') || callbackUri.authority.endsWith('.github.dev')) {
|
|
return `${callbackUri.authority},`;
|
|
}
|
|
|
|
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.scheme},`;
|
|
}
|
|
}
|
|
|
|
private async loginWithoutLocalServer(scope: string): 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 state = `${callbackEnvironment}${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`;
|
|
const signInUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize`;
|
|
let uri = vscode.Uri.parse(signInUrl);
|
|
const codeVerifier = toBase64UrlEncoding(randomBytes(32).toString('base64'));
|
|
const codeChallenge = toBase64UrlEncoding(await sha256(codeVerifier));
|
|
uri = uri.with({
|
|
query: `response_type=code&client_id=${encodeURIComponent(clientId)}&response_mode=query&redirect_uri=${redirectUrl}&state=${state}&scope=${scope}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`
|
|
});
|
|
vscode.env.openExternal(uri);
|
|
|
|
const timeoutPromise = new Promise((_: (value: vscode.AuthenticationSession) => void, reject) => {
|
|
const wait = setTimeout(() => {
|
|
clearTimeout(wait);
|
|
reject('Login timed out.');
|
|
}, 1000 * 60 * 5);
|
|
});
|
|
|
|
const existingStates = this._pendingStates.get(scope) || [];
|
|
this._pendingStates.set(scope, [...existingStates, state]);
|
|
|
|
// 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(scope);
|
|
if (!existingPromise) {
|
|
existingPromise = this.handleCodeResponse(scope);
|
|
this._codeExchangePromises.set(scope, existingPromise);
|
|
}
|
|
|
|
this._codeVerfifiers.set(state, codeVerifier);
|
|
|
|
return Promise.race([existingPromise, timeoutPromise])
|
|
.finally(() => {
|
|
this._pendingStates.delete(scope);
|
|
this._codeExchangePromises.delete(scope);
|
|
this._codeVerfifiers.delete(state);
|
|
});
|
|
}
|
|
|
|
private async handleCodeResponse(scope: string): 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(scope) || [];
|
|
// 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, scope);
|
|
this.setToken(token, scope);
|
|
|
|
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 setToken(token: IToken, scope: string): Promise<void> {
|
|
const existingTokenIndex = this._tokens.findIndex(t => t.sessionId === token.sessionId);
|
|
if (existingTokenIndex > -1) {
|
|
this._tokens.splice(existingTokenIndex, 1, token);
|
|
} else {
|
|
this._tokens.push(token);
|
|
}
|
|
|
|
this.clearSessionTimeout(token.sessionId);
|
|
|
|
if (token.expiresIn) {
|
|
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
|
|
try {
|
|
await this.refreshToken(token.refreshToken, scope, token.sessionId);
|
|
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
|
|
} catch (e) {
|
|
if (e.message === REFRESH_NETWORK_FAILURE) {
|
|
const didSucceedOnRetry = await this.handleRefreshNetworkError(token.sessionId, token.refreshToken, scope);
|
|
if (!didSucceedOnRetry) {
|
|
this.pollForReconnect(token.sessionId, token.refreshToken, token.scope);
|
|
}
|
|
} else {
|
|
await this.logout(token.sessionId);
|
|
onDidChangeSessions.fire({ added: [], removed: [token.sessionId], changed: [] });
|
|
}
|
|
}
|
|
}, 1000 * (token.expiresIn - 30)));
|
|
}
|
|
|
|
this.storeTokenData();
|
|
}
|
|
|
|
private getTokenFromResponse(json: ITokenResponse, scope: string, existingId?: string): IToken {
|
|
let claims = undefined;
|
|
|
|
try {
|
|
claims = this.getTokenClaims(json.access_token);
|
|
} 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);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
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,
|
|
sessionId: existingId || `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${uuid()}`,
|
|
account: {
|
|
label: claims.email || claims.unique_name || claims.preferred_username || 'user@example.com',
|
|
id: `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}`
|
|
}
|
|
};
|
|
}
|
|
|
|
private async exchangeCodeForToken(code: string, codeVerifier: string, scope: string): Promise<IToken> {
|
|
Logger.info('Exchanging login code for token');
|
|
try {
|
|
const postData = querystring.stringify({
|
|
grant_type: 'authorization_code',
|
|
code: code,
|
|
client_id: clientId,
|
|
scope: scope,
|
|
code_verifier: codeVerifier,
|
|
redirect_uri: redirectUrl
|
|
});
|
|
|
|
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
|
|
const endpoint = proxyEndpoints && proxyEndpoints['microsoft'] || `${loginEndpointUrl}${tenant}/oauth2/v2.0/token`;
|
|
|
|
const result = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Content-Length': postData.length.toString()
|
|
},
|
|
body: postData
|
|
});
|
|
|
|
if (result.ok) {
|
|
Logger.info('Exchanging login code for token success');
|
|
const json = await result.json();
|
|
return this.getTokenFromResponse(json, scope);
|
|
} else {
|
|
Logger.error('Exchanging login code for token failed');
|
|
throw new Error('Unable to login.');
|
|
}
|
|
} catch (e) {
|
|
Logger.error(e.message);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
private async refreshToken(refreshToken: string, scope: string, sessionId: string): Promise<IToken> {
|
|
Logger.info('Refreshing token...');
|
|
const postData = querystring.stringify({
|
|
refresh_token: refreshToken,
|
|
client_id: clientId,
|
|
grant_type: 'refresh_token',
|
|
scope: scope
|
|
});
|
|
|
|
let result: Response;
|
|
try {
|
|
result = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Content-Length': postData.length.toString()
|
|
},
|
|
body: postData
|
|
});
|
|
} catch (e) {
|
|
Logger.error('Refreshing token failed');
|
|
throw new Error(REFRESH_NETWORK_FAILURE);
|
|
}
|
|
|
|
try {
|
|
if (result.ok) {
|
|
const json = await result.json();
|
|
const token = this.getTokenFromResponse(json, scope, sessionId);
|
|
this.setToken(token, scope);
|
|
Logger.info('Token refresh success');
|
|
return token;
|
|
} else {
|
|
throw new Error('Bad request.');
|
|
}
|
|
} catch (e) {
|
|
vscode.window.showErrorMessage(localize('signOut', "You have been signed out because reading stored authentication information failed."));
|
|
Logger.error(`Refreshing token failed: ${result.statusText}`);
|
|
throw new Error('Refreshing token failed');
|
|
}
|
|
}
|
|
|
|
private clearSessionTimeout(sessionId: string): void {
|
|
const timeout = this._refreshTimeouts.get(sessionId);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this._refreshTimeouts.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
private removeInMemorySessionData(sessionId: string) {
|
|
const tokenIndex = this._tokens.findIndex(token => token.sessionId === sessionId);
|
|
if (tokenIndex > -1) {
|
|
this._tokens.splice(tokenIndex, 1);
|
|
}
|
|
|
|
this.clearSessionTimeout(sessionId);
|
|
}
|
|
|
|
private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
|
|
this.clearSessionTimeout(sessionId);
|
|
|
|
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
|
|
try {
|
|
await this.refreshToken(refreshToken, scope, sessionId);
|
|
} catch (e) {
|
|
this.pollForReconnect(sessionId, refreshToken, scope);
|
|
}
|
|
}, 1000 * 60 * 30));
|
|
}
|
|
|
|
private handleRefreshNetworkError(sessionId: string, refreshToken: string, scope: string, attempts: number = 1): Promise<boolean> {
|
|
return new Promise((resolve, _) => {
|
|
if (attempts === 3) {
|
|
Logger.error('Token refresh failed after 3 attempts');
|
|
return resolve(false);
|
|
}
|
|
|
|
if (attempts === 1) {
|
|
const token = this._tokens.find(token => token.sessionId === sessionId);
|
|
if (token) {
|
|
token.accessToken = undefined;
|
|
onDidChangeSessions.fire({ added: [], removed: [], changed: [token.sessionId] });
|
|
}
|
|
}
|
|
|
|
const delayBeforeRetry = 5 * attempts * attempts;
|
|
|
|
this.clearSessionTimeout(sessionId);
|
|
|
|
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
|
|
try {
|
|
await this.refreshToken(refreshToken, scope, sessionId);
|
|
return resolve(true);
|
|
} catch (e) {
|
|
return resolve(await this.handleRefreshNetworkError(sessionId, refreshToken, scope, attempts + 1));
|
|
}
|
|
}, 1000 * delayBeforeRetry));
|
|
});
|
|
}
|
|
|
|
public async logout(sessionId: string) {
|
|
Logger.info(`Logging out of session '${sessionId}'`);
|
|
this.removeInMemorySessionData(sessionId);
|
|
|
|
if (this._tokens.length === 0) {
|
|
await this._keychain.deleteToken();
|
|
} else {
|
|
this.storeTokenData();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|