allow overridding of client id and tenant for microsoft auth

This commit is contained in:
Tyler Leonhardt
2022-01-14 14:36:25 -08:00
parent f89d5d3677
commit 12902bc7a1
2 changed files with 125 additions and 63 deletions
@@ -22,8 +22,8 @@ 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';
const DEFAULT_CLIENT_ID = 'aebc6443-996d-45c2-90f0-388ff96faa56';
const DEFAULT_TENANT = 'organizations';
interface IToken {
accessToken?: string; // When unable to refresh due to network problems, the access token becomes undefined
@@ -79,6 +79,14 @@ export interface IMicrosoftTokens {
idToken?: string;
}
interface IScopeData {
scopes: string[],
scopeStr: string,
scopesToSend: string,
clientId: string,
tenant: string
}
function parseQuery(uri: vscode.Uri) {
return uri.query.split('&').reduce((prev: any, current) => {
const queryString = current.split('=');
@@ -138,7 +146,16 @@ export class AzureActiveDirectoryService {
}
try {
await this.refreshToken(session.refreshToken, session.scope, session.id);
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) {
@@ -194,7 +211,16 @@ export class AzureActiveDirectoryService {
const matchesExisting = this._tokens.some(token => token.scope === session.scope && token.sessionId === session.id);
if (!matchesExisting && session.refreshToken) {
try {
const token = await this.refreshToken(session.refreshToken, session.scope, session.id);
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.
@@ -280,7 +306,16 @@ export class AzureActiveDirectoryService {
try {
Logger.info(`Token expired or unavailable (for scopes ${token.scope}), trying refresh`);
const refreshedToken = await this.refreshToken(token.refreshToken, token.scope, token.sessionId);
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 {
accessToken: refreshedToken.accessToken,
@@ -329,16 +364,25 @@ export class AzureActiveDirectoryService {
return Promise.all(matchingTokens.map(token => this.convertToSession(token)));
}
public async createSession(scope: string): Promise<vscode.AuthenticationSession> {
Logger.info(`Logging in for the following scopes: ${scope}`);
if (!scope.includes('offline_access')) {
public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
const scopeData: IScopeData = {
scopes,
scopeStr: scopes.join(' '),
// filter our special scopes
scopesToSend: scopes.filter(s => !s.startsWith('VSCODE_')).join(' '),
clientId: this.getClientId(scopes),
tenant: this.getTenantId(scopes),
};
Logger.info(`Logging in for the following scopes: ${scopeData.scopeStr}`);
if (!scopeData.scopes.includes('offline_access')) {
Logger.info('Warning: The \'offline_access\' scope was not included, so the generated token will not be able to be refreshed.');
}
const runsRemote = vscode.env.remoteName !== undefined;
const runsServerless = vscode.env.remoteName === undefined && vscode.env.uiKind === vscode.UIKind.Web;
if (runsRemote || runsServerless) {
return this.loginWithoutLocalServer(scope);
return this.loginWithoutLocalServer(scopeData);
}
const nonce = randomBytes(16).toString('base64');
@@ -365,7 +409,8 @@ export class AzureActiveDirectoryService {
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}`;
const loginUrl = `${loginEndpointUrl}${scopeData.tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query&client_id=${encodeURIComponent(scopeData.clientId)}&redirect_uri=${encodeURIComponent(redirectUrl)}&state=${state}&scope=${encodeURIComponent(scopeData.scopesToSend)}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`;
redirectReq.res.writeHead(302, { Location: loginUrl });
redirectReq.res.end();
@@ -377,9 +422,9 @@ export class AzureActiveDirectoryService {
if ('err' in codeRes) {
throw codeRes.err;
}
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scope);
await this.setToken(token, scope);
Logger.info(`Login successful for scopes: ${scope}`);
token = await this.exchangeCodeForToken(codeRes.code, codeVerifier, scopeData);
await this.setToken(token, scopeData);
Logger.info(`Login successful for scopes: ${scopeData.scopeStr}`);
res.writeHead(302, { Location: '/' });
const session = await this.convertToSession(token);
return session;
@@ -390,11 +435,11 @@ export class AzureActiveDirectoryService {
res.end();
}
} catch (e) {
Logger.error(`Error creating session for scopes: ${scope} Error: ${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(scope);
return this.loginWithoutLocalServer(scopeData);
}
throw e;
@@ -426,18 +471,18 @@ export class AzureActiveDirectoryService {
}
}
private async loginWithoutLocalServer(scope: string): Promise<vscode.AuthenticationSession> {
private async loginWithoutLocalServer(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 state = `${callbackEnvironment},${port},${encodeURIComponent(nonce)},${encodeURIComponent(callbackUri.query)}`;
const signInUrl = `${loginEndpointUrl}${tenant}/oauth2/v2.0/authorize`;
const signInUrl = `${loginEndpointUrl}${scopeData.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}`
query: `response_type=code&client_id=${encodeURIComponent(scopeData.clientId)}&response_mode=query&redirect_uri=${redirectUrl}&state=${state}&scope=${scopeData.scopesToSend}&prompt=select_account&code_challenge_method=S256&code_challenge=${codeChallenge}`
});
vscode.env.openExternal(uri);
@@ -448,36 +493,35 @@ export class AzureActiveDirectoryService {
}, 1000 * 60 * 5);
});
const existingStates = this._pendingStates.get(scope) || [];
this._pendingStates.set(scope, [...existingStates, state]);
const existingStates = this._pendingStates.get(scopeData.scopeStr) || [];
this._pendingStates.set(scopeData.scopeStr, [...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);
let existingPromise = this._codeExchangePromises.get(scopeData.scopeStr);
if (!existingPromise) {
existingPromise = this.handleCodeResponse(scope);
this._codeExchangePromises.set(scope, existingPromise);
existingPromise = this.handleCodeResponse(scopeData);
this._codeExchangePromises.set(scopeData.scopeStr, existingPromise);
}
this._codeVerfifiers.set(state, codeVerifier);
return Promise.race([existingPromise, timeoutPromise])
.finally(() => {
this._pendingStates.delete(scope);
this._codeExchangePromises.delete(scope);
this._pendingStates.delete(scopeData.scopeStr);
this._codeExchangePromises.delete(scopeData.scopeStr);
this._codeVerfifiers.delete(state);
});
}
private async handleCodeResponse(scope: string): Promise<vscode.AuthenticationSession> {
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(scope) || [];
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.');
@@ -488,8 +532,8 @@ export class AzureActiveDirectoryService {
throw new Error('No available code verifier');
}
const token = await this.exchangeCodeForToken(code, verifier, scope);
await this.setToken(token, scope);
const token = await this.exchangeCodeForToken(code, verifier, scopeData);
await this.setToken(token, scopeData);
const session = await this.convertToSession(token);
resolve(session);
@@ -506,8 +550,8 @@ export class AzureActiveDirectoryService {
});
}
private async setToken(token: IToken, scope: string): Promise<void> {
Logger.info(`Setting token for scopes: ${scope}`);
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);
@@ -520,7 +564,7 @@ export class AzureActiveDirectoryService {
if (token.expiresIn) {
this._refreshTimeouts.set(token.sessionId, setTimeout(async () => {
try {
const refreshedToken = await this.refreshToken(token.refreshToken, scope, token.sessionId);
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) {
@@ -536,7 +580,7 @@ export class AzureActiveDirectoryService {
await this.storeTokenData();
}
private getTokenFromResponse(json: ITokenResponse, scope: string, existingId?: string): IToken {
private getTokenFromResponse(json: ITokenResponse, scopeData: IScopeData, existingId?: string): IToken {
let claims = undefined;
try {
@@ -556,7 +600,7 @@ export class AzureActiveDirectoryService {
accessToken: json.access_token,
idToken: json.id_token,
refreshToken: json.refresh_token,
scope,
scope: scopeData.scopeStr,
sessionId: existingId || `${claims.tid}/${(claims.oid || (claims.altsecid || '' + claims.ipd || ''))}/${uuid()}`,
account: {
label: claims.email || claims.unique_name || claims.preferred_username || 'user@example.com',
@@ -565,33 +609,51 @@ export class AzureActiveDirectoryService {
};
}
private async exchangeCodeForToken(code: string, codeVerifier: string, scope: string): Promise<IToken> {
Logger.info(`Exchanging login code for token for scopes: ${scope}`);
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;
}
private async exchangeCodeForToken(code: string, codeVerifier: string, scopeData: IScopeData): Promise<IToken> {
Logger.info(`Exchanging login code for token for scopes: ${scopeData.scopeStr}`);
try {
const postData = querystring.stringify({
grant_type: 'authorization_code',
code: code,
client_id: clientId,
scope: scope,
client_id: scopeData.clientId,
scope: scopeData.scopesToSend,
code_verifier: codeVerifier,
redirect_uri: redirectUrl
});
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl;
const endpoint = `${endpointUrl}${tenant}/oauth2/v2.0/token`;
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;
const json = await this.fetchTokenResponse(endpoint, postData, scope);
Logger.info(`Exchanging login code for token (for scopes: ${scope}) succeeded!`);
return this.getTokenFromResponse(json, scope);
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);
} catch (e) {
Logger.error(`Error exchanging code for token (for scopes ${scope}): ${e}`);
Logger.error(`Error exchanging code for token (for scopes ${scopeData.scopeStr}): ${e}`);
throw e;
}
}
private async refreshToken(refreshToken: string, scope: string, sessionId: string): Promise<IToken> {
this._refreshingPromise = this.doRefreshToken(refreshToken, scope, sessionId);
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;
@@ -600,7 +662,7 @@ export class AzureActiveDirectoryService {
}
}
private async fetchTokenResponse(endpoint: string, postData: string, scopes: string): Promise<ITokenResponse> {
private async fetchTokenResponse(endpoint: string, postData: string, scopeData: IScopeData): Promise<ITokenResponse> {
let attempts = 0;
while (attempts <= 3) {
attempts++;
@@ -621,7 +683,7 @@ export class AzureActiveDirectoryService {
if (!result || result.status > 499) {
if (attempts > 3) {
Logger.error(`Fetching token failed for scopes (${scopes}): ${result ? await result.text() : errorMessage}`);
Logger.error(`Fetching token failed for scopes (${scopeData.scopeStr}): ${result ? await result.text() : errorMessage}`);
break;
}
// Exponential backoff
@@ -640,34 +702,34 @@ export class AzureActiveDirectoryService {
throw new Error(REFRESH_NETWORK_FAILURE);
}
private async doRefreshToken(refreshToken: string, scope: string, sessionId: string): Promise<IToken> {
Logger.info(`Refreshing token for scopes: ${scope}`);
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: clientId,
client_id: scopeData.clientId,
grant_type: 'refresh_token',
scope: scope
scope: scopeData.scopesToSend
});
const proxyEndpoints: { [providerId: string]: string } | undefined = await vscode.commands.executeCommand('workbench.getCodeExchangeProxyEndpoints');
const endpointUrl = proxyEndpoints?.microsoft || loginEndpointUrl;
const endpoint = `${endpointUrl}${tenant}/oauth2/v2.0/token`;
const endpoint = `${endpointUrl}${scopeData.tenant}/oauth2/v2.0/token`;
try {
const json = await this.fetchTokenResponse(endpoint, postData, scope);
const token = this.getTokenFromResponse(json, scope, sessionId);
await this.setToken(token, scope);
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;
} 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, scope);
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: ${scope}): ${e.message}`);
Logger.error(`Refreshing token failed (for scopes: ${scopeData.scopeStr}): ${e.message}`);
throw new Error('Refreshing token failed');
}
}
@@ -692,15 +754,15 @@ export class AzureActiveDirectoryService {
return token;
}
private pollForReconnect(sessionId: string, refreshToken: string, scope: string): void {
private pollForReconnect(sessionId: string, refreshToken: string, scopeData: IScopeData): void {
this.clearSessionTimeout(sessionId);
Logger.trace(`Setting up reconnection timeout for scopes: ${scope}...`);
Logger.trace(`Setting up reconnection timeout for scopes: ${scopeData.scopeStr}...`);
this._refreshTimeouts.set(sessionId, setTimeout(async () => {
try {
const refreshedToken = await this.refreshToken(refreshToken, scope, sessionId);
const refreshedToken = await this.refreshToken(refreshToken, scopeData, sessionId);
onDidChangeSessions.fire({ added: [], removed: [], changed: [this.convertToSessionSync(refreshedToken)] });
} catch (e) {
this.pollForReconnect(sessionId, refreshToken, scope);
this.pollForReconnect(sessionId, refreshToken, scopeData);
}
}, 1000 * 60 * 30));
}
@@ -31,7 +31,7 @@ export async function activate(context: vscode.ExtensionContext) {
scopes: JSON.stringify(scopes.map(s => s.replace(/[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}/i, '{guid}'))),
});
const session = await loginService.createSession(scopes.sort().join(' '));
const session = await loginService.createSession(scopes.sort());
onDidChangeSessions.fire({ added: [session], removed: [], changed: [] });
return session;
} catch (e) {