Skip to content

Commit

Permalink
Show InputBox for unsupported clients
Browse files Browse the repository at this point in the history
Fixes #238147
  • Loading branch information
TylerLeonhardt committed Feb 1, 2025
1 parent 8237317 commit 4bf6d38
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 13 deletions.
66 changes: 66 additions & 0 deletions extensions/microsoft-authentication/src/common/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,69 @@ function once<T>(event: Event<T>): Event<T> {
export function toPromise<T>(event: Event<T>): Promise<T> {
return new Promise(resolve => once(event)(resolve));
}

//#region DeferredPromise

export type ValueCallback<T = unknown> = (value: T | Promise<T>) => void;

const enum DeferredOutcome {
Resolved,
Rejected
}

/**
* Creates a promise whose resolution or rejection can be controlled imperatively.
*/
export class DeferredPromise<T> {

private completeCallback!: ValueCallback<T>;
private errorCallback!: (err: unknown) => void;
private outcome?: { outcome: DeferredOutcome.Rejected; value: any } | { outcome: DeferredOutcome.Resolved; value: T };

public get isRejected() {
return this.outcome?.outcome === DeferredOutcome.Rejected;
}

public get isResolved() {
return this.outcome?.outcome === DeferredOutcome.Resolved;
}

public get isSettled() {
return !!this.outcome;
}

public get value() {
return this.outcome?.outcome === DeferredOutcome.Resolved ? this.outcome?.value : undefined;
}

public readonly p: Promise<T>;

constructor() {
this.p = new Promise<T>((c, e) => {
this.completeCallback = c;
this.errorCallback = e;
});
}

public complete(value: T) {
return new Promise<void>(resolve => {
this.completeCallback(value);
this.outcome = { outcome: DeferredOutcome.Resolved, value };
resolve();
});
}

public error(err: unknown) {
return new Promise<void>(resolve => {
this.errorCallback(err);
this.outcome = { outcome: DeferredOutcome.Rejected, value: err };
resolve();
});
}

public cancel() {
return this.error(new CancellationError());
}
}

//#endregion
28 changes: 28 additions & 0 deletions extensions/microsoft-authentication/src/common/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Uri } from 'vscode';

const VALID_DESKTOP_CALLBACK_SCHEMES = [
'vscode',
'vscode-insiders',
// On Windows, some browsers don't seem to redirect back to OSS properly.
// As a result, you get stuck in the auth flow. We exclude this from the
// list until we can figure out a way to fix this behavior in browsers.
// 'code-oss',
'vscode-wsl',
'vscode-exploration'
];

export function isSupportedClient(uri: Uri): boolean {
return (
VALID_DESKTOP_CALLBACK_SCHEMES.includes(uri.scheme) ||
// vscode.dev & insiders.vscode.dev
/(?:^|\.)vscode\.dev$/.test(uri.authority) ||
// github.dev & codespaces
/(?:^|\.)github\.dev$/.test(uri.authority) ||
// localhost
/^localhost:\d+$/.test(uri.authority)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,32 @@

import type { ILoopbackClient, ServerAuthorizationCodeResponse } from '@azure/msal-node';
import type { UriEventHandler } from '../UriEventHandler';
import { env, LogOutputChannel, Uri } from 'vscode';
import { toPromise } from './async';
import { Disposable, env, l10n, LogOutputChannel, Uri, window } from 'vscode';
import { DeferredPromise, toPromise } from './async';
import { isSupportedClient } from './env';

export interface ILoopbackClientAndOpener extends ILoopbackClient {
openBrowser(url: string): Promise<void>;
}

export class UriHandlerLoopbackClient implements ILoopbackClientAndOpener {
private _responseDeferred: DeferredPromise<ServerAuthorizationCodeResponse> | undefined;

constructor(
private readonly _uriHandler: UriEventHandler,
private readonly _redirectUri: string,
private readonly _logger: LogOutputChannel
) { }

async listenForAuthCode(): Promise<ServerAuthorizationCodeResponse> {
const url = await toPromise(this._uriHandler.event);
this._logger.debug(`Received URL event. Authority: ${url.authority}`);
const result = new URL(url.toString(true));

return {
code: result.searchParams.get('code') ?? undefined,
state: result.searchParams.get('state') ?? undefined,
error: result.searchParams.get('error') ?? undefined,
error_description: result.searchParams.get('error_description') ?? undefined,
error_uri: result.searchParams.get('error_uri') ?? undefined,
};
await this._responseDeferred?.cancel();
this._responseDeferred = new DeferredPromise();
const result = await this._responseDeferred.p;
this._responseDeferred = undefined;
if (result) {
return result;
}
throw new Error('No valid response received for authorization code.');
}

getRedirectUri(): string {
Expand All @@ -46,7 +46,90 @@ export class UriHandlerLoopbackClient implements ILoopbackClientAndOpener {
async openBrowser(url: string): Promise<void> {
const callbackUri = await env.asExternalUri(Uri.parse(`${env.uriScheme}://vscode.microsoft-authentication`));

if (isSupportedClient(callbackUri)) {
void this._getCodeResponseFromUriHandler();
} else {
void this._getCodeResponseFromQuickPick();
}

const uri = Uri.parse(url + `&state=${encodeURI(callbackUri.toString(true))}`);
await env.openExternal(uri);
}

private async _getCodeResponseFromUriHandler(): Promise<void> {
if (!this._responseDeferred) {
throw new Error('No listener for auth code');
}
const url = await toPromise(this._uriHandler.event);
this._logger.debug(`Received URL event. Authority: ${url.authority}`);
const result = new URL(url.toString(true));

this._responseDeferred?.complete({
code: result.searchParams.get('code') ?? undefined,
state: result.searchParams.get('state') ?? undefined,
error: result.searchParams.get('error') ?? undefined,
error_description: result.searchParams.get('error_description') ?? undefined,
error_uri: result.searchParams.get('error_uri') ?? undefined,
});
}

private async _getCodeResponseFromQuickPick(): Promise<void> {
if (!this._responseDeferred) {
throw new Error('No listener for auth code');
}
const inputBox = window.createInputBox();
inputBox.ignoreFocusOut = true;
inputBox.title = l10n.t('Microsoft Authentication');
inputBox.prompt = l10n.t('Provide the authorization code to complete the sign in flow.');
inputBox.placeholder = l10n.t('Paste authorization code here...');
inputBox.show();
const code = await new Promise<string | undefined>((resolve) => {
let resolvedValue: string | undefined = undefined;
const disposable = Disposable.from(
inputBox,
inputBox.onDidAccept(async () => {
if (!inputBox.value) {
inputBox.validationMessage = l10n.t('Authorization code is required.');
return;
}
const code = inputBox.value;
resolvedValue = code;
resolve(code);
inputBox.hide();
}),
inputBox.onDidChangeValue(() => {
inputBox.validationMessage = undefined;
}),
inputBox.onDidHide(() => {
disposable.dispose();
if (!resolvedValue) {
resolve(undefined);
}
})
);
Promise.allSettled([this._responseDeferred?.p]).then(() => disposable.dispose());
});
// Something canceled the original deferred promise, so just return.
if (this._responseDeferred.isSettled) {
return;
}
if (code) {
this._logger.debug('Received auth code from quick pick');
this._responseDeferred.complete({
code,
state: undefined,
error: undefined,
error_description: undefined,
error_uri: undefined
});
return;
}
this._responseDeferred.complete({
code: undefined,
state: undefined,
error: 'User cancelled',
error_description: 'User cancelled',
error_uri: undefined
});
}
}

0 comments on commit 4bf6d38

Please sign in to comment.