diff --git a/extensions/microsoft-authentication/extension-browser.webpack.config.js b/extensions/microsoft-authentication/extension-browser.webpack.config.js index c0f4b92069f..0d395fc0f96 100644 --- a/extensions/microsoft-authentication/extension-browser.webpack.config.js +++ b/extensions/microsoft-authentication/extension-browser.webpack.config.js @@ -23,7 +23,8 @@ module.exports = withBrowserDefaults({ resolve: { alias: { './node/authServer': path.resolve(__dirname, 'src/browser/authServer'), - './node/buffer': path.resolve(__dirname, 'src/browser/buffer') + './node/buffer': path.resolve(__dirname, 'src/browser/buffer'), + './node/authProvider': path.resolve(__dirname, 'src/browser/authProvider'), } } }); diff --git a/extensions/microsoft-authentication/package.json b/extensions/microsoft-authentication/package.json index 31a7d4bd7e8..202a7badd7e 100644 --- a/extensions/microsoft-authentication/package.json +++ b/extensions/microsoft-authentication/package.json @@ -95,6 +95,16 @@ ] } } + }, + { + "title": "Microsoft", + "properties": { + "microsoft.useMsal": { + "type": "boolean", + "default": false, + "description": "%useMsal.description%" + } + } } ] }, @@ -117,6 +127,7 @@ }, "dependencies": { "@azure/ms-rest-azure-env": "^2.0.0", + "@azure/msal-node": "^2.12.0", "@vscode/extension-telemetry": "^0.9.0" }, "repository": { diff --git a/extensions/microsoft-authentication/package.nls.json b/extensions/microsoft-authentication/package.nls.json index 14c625dc762..80cbb32d4ab 100644 --- a/extensions/microsoft-authentication/package.nls.json +++ b/extensions/microsoft-authentication/package.nls.json @@ -3,6 +3,7 @@ "description": "Microsoft authentication provider", "signIn": "Sign In", "signOut": "Sign Out", + "useMsal.description": "Use the Microsoft Authentication Library (MSAL) to sign in with a Microsoft account.", "microsoft-sovereign-cloud.environment.description": { "message": "The Sovereign Cloud to use for authentication. If you select `custom`, you must also set the `#microsoft-sovereign-cloud.customEnvironment#` setting.", "comment": [ diff --git a/extensions/microsoft-authentication/src/UriEventHandler.ts b/extensions/microsoft-authentication/src/UriEventHandler.ts index 3dc753af835..f525912fa51 100644 --- a/extensions/microsoft-authentication/src/UriEventHandler.ts +++ b/extensions/microsoft-authentication/src/UriEventHandler.ts @@ -6,7 +6,14 @@ import * as vscode from 'vscode'; export class UriEventHandler extends vscode.EventEmitter implements vscode.UriHandler { - public handleUri(uri: vscode.Uri) { + private _disposable = vscode.window.registerUriHandler(this); + + handleUri(uri: vscode.Uri) { this.fire(uri); } + + override dispose(): void { + super.dispose(); + this._disposable.dispose(); + } } diff --git a/extensions/microsoft-authentication/src/browser/authProvider.ts b/extensions/microsoft-authentication/src/browser/authProvider.ts new file mode 100644 index 00000000000..3b4da5b18fa --- /dev/null +++ b/extensions/microsoft-authentication/src/browser/authProvider.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationSession, EventEmitter } from 'vscode'; + +export class MsalAuthProvider implements AuthenticationProvider { + private _onDidChangeSessions = new EventEmitter(); + onDidChangeSessions = this._onDidChangeSessions.event; + + initialize(): Thenable { + throw new Error('Method not implemented.'); + } + + getSessions(): Thenable { + throw new Error('Method not implemented.'); + } + createSession(): Thenable { + throw new Error('Method not implemented.'); + } + removeSession(): Thenable { + throw new Error('Method not implemented.'); + } + + dispose() { + this._onDidChangeSessions.dispose(); + } +} diff --git a/extensions/microsoft-authentication/src/common/async.ts b/extensions/microsoft-authentication/src/common/async.ts index 641faaff0dd..094861518fc 100644 --- a/extensions/microsoft-authentication/src/common/async.ts +++ b/extensions/microsoft-authentication/src/common/async.ts @@ -3,7 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CancellationError, CancellationToken, Disposable } from 'vscode'; +import { CancellationError, CancellationToken, Disposable, Event, EventEmitter } from 'vscode'; + +/** + * Can be passed into the Delayed to defer using a microtask + */ +export const MicrotaskDelay = Symbol('MicrotaskDelay'); export class SequencerByKey { @@ -80,3 +85,473 @@ export function raceTimeoutError(promise: Promise, timeout: number): Promi export function raceCancellationAndTimeoutError(promise: Promise, token: CancellationToken, timeout: number): Promise { return raceCancellationError(raceTimeoutError(promise, timeout), token); } + +interface ILimitedTaskFactory { + factory: () => Promise; + c: (value: T | Promise) => void; + e: (error?: unknown) => void; +} + +export interface ILimiter { + + readonly size: number; + + queue(factory: () => Promise): Promise; + + clear(): void; +} + +/** + * A helper to queue N promises and run them all with a max degree of parallelism. The helper + * ensures that at any time no more than M promises are running at the same time. + */ +export class Limiter implements ILimiter { + + private _size = 0; + private _isDisposed = false; + private runningPromises: number; + private readonly maxDegreeOfParalellism: number; + private readonly outstandingPromises: ILimitedTaskFactory[]; + private readonly _onDrained: EventEmitter; + + constructor(maxDegreeOfParalellism: number) { + this.maxDegreeOfParalellism = maxDegreeOfParalellism; + this.outstandingPromises = []; + this.runningPromises = 0; + this._onDrained = new EventEmitter(); + } + + /** + * + * @returns A promise that resolved when all work is done (onDrained) or when + * there is nothing to do + */ + whenIdle(): Promise { + return this.size > 0 + ? toPromise(this.onDrained) + : Promise.resolve(); + } + + get onDrained(): Event { + return this._onDrained.event; + } + + get size(): number { + return this._size; + } + + queue(factory: () => Promise): Promise { + if (this._isDisposed) { + throw new Error('Object has been disposed'); + } + this._size++; + + return new Promise((c, e) => { + this.outstandingPromises.push({ factory, c, e }); + this.consume(); + }); + } + + private consume(): void { + while (this.outstandingPromises.length && this.runningPromises < this.maxDegreeOfParalellism) { + const iLimitedTask = this.outstandingPromises.shift()!; + this.runningPromises++; + + const promise = iLimitedTask.factory(); + promise.then(iLimitedTask.c, iLimitedTask.e); + promise.then(() => this.consumed(), () => this.consumed()); + } + } + + private consumed(): void { + if (this._isDisposed) { + return; + } + this.runningPromises--; + if (--this._size === 0) { + this._onDrained.fire(); + } + + if (this.outstandingPromises.length > 0) { + this.consume(); + } + } + + clear(): void { + if (this._isDisposed) { + throw new Error('Object has been disposed'); + } + this.outstandingPromises.length = 0; + this._size = this.runningPromises; + } + + dispose(): void { + this._isDisposed = true; + this.outstandingPromises.length = 0; // stop further processing + this._size = 0; + this._onDrained.dispose(); + } +} + + +interface IScheduledLater extends Disposable { + isTriggered(): boolean; +} + +const timeoutDeferred = (timeout: number, fn: () => void): IScheduledLater => { + let scheduled = true; + const handle = setTimeout(() => { + scheduled = false; + fn(); + }, timeout); + return { + isTriggered: () => scheduled, + dispose: () => { + clearTimeout(handle); + scheduled = false; + }, + }; +}; + +const microtaskDeferred = (fn: () => void): IScheduledLater => { + let scheduled = true; + queueMicrotask(() => { + if (scheduled) { + scheduled = false; + fn(); + } + }); + + return { + isTriggered: () => scheduled, + dispose: () => { scheduled = false; }, + }; +}; + +/** + * A helper to delay (debounce) execution of a task that is being requested often. + * + * Following the throttler, now imagine the mail man wants to optimize the number of + * trips proactively. The trip itself can be long, so he decides not to make the trip + * as soon as a letter is submitted. Instead he waits a while, in case more + * letters are submitted. After said waiting period, if no letters were submitted, he + * decides to make the trip. Imagine that N more letters were submitted after the first + * one, all within a short period of time between each other. Even though N+1 + * submissions occurred, only 1 delivery was made. + * + * The delayer offers this behavior via the trigger() method, into which both the task + * to be executed and the waiting period (delay) must be passed in as arguments. Following + * the example: + * + * const delayer = new Delayer(WAITING_PERIOD); + * const letters = []; + * + * function letterReceived(l) { + * letters.push(l); + * delayer.trigger(() => { return makeTheTrip(); }); + * } + */ +export class Delayer implements Disposable { + + private deferred: IScheduledLater | null; + private completionPromise: Promise | null; + private doResolve: ((value?: any | Promise) => void) | null; + private doReject: ((err: any) => void) | null; + private task: (() => T | Promise) | null; + + constructor(public defaultDelay: number | typeof MicrotaskDelay) { + this.deferred = null; + this.completionPromise = null; + this.doResolve = null; + this.doReject = null; + this.task = null; + } + + trigger(task: () => T | Promise, delay = this.defaultDelay): Promise { + this.task = task; + this.cancelTimeout(); + + if (!this.completionPromise) { + this.completionPromise = new Promise((resolve, reject) => { + this.doResolve = resolve; + this.doReject = reject; + }).then(() => { + this.completionPromise = null; + this.doResolve = null; + if (this.task) { + const task = this.task; + this.task = null; + return task(); + } + return undefined; + }); + } + + const fn = () => { + this.deferred = null; + this.doResolve?.(null); + }; + + this.deferred = delay === MicrotaskDelay ? microtaskDeferred(fn) : timeoutDeferred(delay, fn); + + return this.completionPromise; + } + + isTriggered(): boolean { + return !!this.deferred?.isTriggered(); + } + + cancel(): void { + this.cancelTimeout(); + + if (this.completionPromise) { + this.doReject?.(new CancellationError()); + this.completionPromise = null; + } + } + + private cancelTimeout(): void { + this.deferred?.dispose(); + this.deferred = null; + } + + dispose(): void { + this.cancel(); + } +} + +/** + * A helper to prevent accumulation of sequential async tasks. + * + * Imagine a mail man with the sole task of delivering letters. As soon as + * a letter submitted for delivery, he drives to the destination, delivers it + * and returns to his base. Imagine that during the trip, N more letters were submitted. + * When the mail man returns, he picks those N letters and delivers them all in a + * single trip. Even though N+1 submissions occurred, only 2 deliveries were made. + * + * The throttler implements this via the queue() method, by providing it a task + * factory. Following the example: + * + * const throttler = new Throttler(); + * const letters = []; + * + * function deliver() { + * const lettersToDeliver = letters; + * letters = []; + * return makeTheTrip(lettersToDeliver); + * } + * + * function onLetterReceived(l) { + * letters.push(l); + * throttler.queue(deliver); + * } + */ +export class Throttler implements Disposable { + + private activePromise: Promise | null; + private queuedPromise: Promise | null; + private queuedPromiseFactory: (() => Promise) | null; + + private isDisposed = false; + + constructor() { + this.activePromise = null; + this.queuedPromise = null; + this.queuedPromiseFactory = null; + } + + queue(promiseFactory: () => Promise): Promise { + if (this.isDisposed) { + return Promise.reject(new Error('Throttler is disposed')); + } + + if (this.activePromise) { + this.queuedPromiseFactory = promiseFactory; + + if (!this.queuedPromise) { + const onComplete = () => { + this.queuedPromise = null; + + if (this.isDisposed) { + return; + } + + const result = this.queue(this.queuedPromiseFactory!); + this.queuedPromiseFactory = null; + + return result; + }; + + this.queuedPromise = new Promise(resolve => { + this.activePromise!.then(onComplete, onComplete).then(resolve); + }); + } + + return new Promise((resolve, reject) => { + this.queuedPromise!.then(resolve, reject); + }); + } + + this.activePromise = promiseFactory(); + + return new Promise((resolve, reject) => { + this.activePromise!.then((result: T) => { + this.activePromise = null; + resolve(result); + }, (err: unknown) => { + this.activePromise = null; + reject(err); + }); + }); + } + + dispose(): void { + this.isDisposed = true; + } +} + +/** + * A helper to delay execution of a task that is being requested often, while + * preventing accumulation of consecutive executions, while the task runs. + * + * The mail man is clever and waits for a certain amount of time, before going + * out to deliver letters. While the mail man is going out, more letters arrive + * and can only be delivered once he is back. Once he is back the mail man will + * do one more trip to deliver the letters that have accumulated while he was out. + */ +export class ThrottledDelayer { + + private delayer: Delayer>; + private throttler: Throttler; + + constructor(defaultDelay: number) { + this.delayer = new Delayer(defaultDelay); + this.throttler = new Throttler(); + } + + trigger(promiseFactory: () => Promise, delay?: number): Promise { + return this.delayer.trigger(() => this.throttler.queue(promiseFactory), delay) as unknown as Promise; + } + + isTriggered(): boolean { + return this.delayer.isTriggered(); + } + + cancel(): void { + this.delayer.cancel(); + } + + dispose(): void { + this.delayer.dispose(); + this.throttler.dispose(); + } +} + +/** + * A queue is handles one promise at a time and guarantees that at any time only one promise is executing. + */ +export class Queue extends Limiter { + + constructor() { + super(1); + } +} + +/** + * Given an event, returns another event which only fires once. + * + * @param event The event source for the new event. + */ +export function once(event: Event): Event { + return (listener, thisArgs = null, disposables?) => { + // we need this, in case the event fires during the listener call + let didFire = false; + let result: Disposable | undefined = undefined; + result = event(e => { + if (didFire) { + return; + } else if (result) { + result.dispose(); + } else { + didFire = true; + } + + return listener.call(thisArgs, e); + }, null, disposables); + + if (didFire) { + result.dispose(); + } + + return result; + }; +} + +/** + * Creates a promise out of an event, using the {@link Event.once} helper. + */ +export function toPromise(event: Event): Promise { + return new Promise(resolve => once(event)(resolve)); +} + +export type ValueCallback = (value: T | Promise) => void; + +const enum DeferredOutcome { + Resolved, + Rejected +} + +/** + * Creates a promise whose resolution or rejection can be controlled imperatively. + */ +export class DeferredPromise { + + private completeCallback!: ValueCallback; + 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; + + constructor() { + this.p = new Promise((c, e) => { + this.completeCallback = c; + this.errorCallback = e; + }); + } + + public complete(value: T) { + return new Promise(resolve => { + this.completeCallback(value); + this.outcome = { outcome: DeferredOutcome.Resolved, value }; + resolve(); + }); + } + + public error(err: unknown) { + return new Promise(resolve => { + this.errorCallback(err); + this.outcome = { outcome: DeferredOutcome.Rejected, value: err }; + resolve(); + }); + } + + public cancel() { + return this.error(new CancellationError()); + } +} diff --git a/extensions/microsoft-authentication/src/common/cachePlugin.ts b/extensions/microsoft-authentication/src/common/cachePlugin.ts new file mode 100644 index 00000000000..91b4f0ee6a8 --- /dev/null +++ b/extensions/microsoft-authentication/src/common/cachePlugin.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICachePlugin, TokenCacheContext } from '@azure/msal-node'; +import { Disposable, EventEmitter, SecretStorage } from 'vscode'; + +export class SecretStorageCachePlugin implements ICachePlugin { + private readonly _onDidChange: EventEmitter = new EventEmitter(); + readonly onDidChange = this._onDidChange.event; + + private _disposable: Disposable; + + private _value: string | undefined; + + constructor( + private readonly _secretStorage: SecretStorage, + private readonly _key: string + ) { + this._disposable = Disposable.from( + this._onDidChange, + this._registerChangeHandler() + ); + } + + private _registerChangeHandler() { + return this._secretStorage.onDidChange(e => { + if (e.key === this._key) { + this._onDidChange.fire(); + } + }); + } + + async beforeCacheAccess(tokenCacheContext: TokenCacheContext): Promise { + const data = await this._secretStorage.get(this._key); + this._value = data; + if (data) { + tokenCacheContext.tokenCache.deserialize(data); + } + } + + async afterCacheAccess(tokenCacheContext: TokenCacheContext): Promise { + if (tokenCacheContext.cacheHasChanged) { + const value = tokenCacheContext.tokenCache.serialize(); + if (value !== this._value) { + await this._secretStorage.store(this._key, value); + } + } + } + + dispose() { + this._disposable.dispose(); + } +} diff --git a/extensions/microsoft-authentication/src/common/loggerOptions.ts b/extensions/microsoft-authentication/src/common/loggerOptions.ts new file mode 100644 index 00000000000..86443c0281f --- /dev/null +++ b/extensions/microsoft-authentication/src/common/loggerOptions.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { LogLevel as MsalLogLevel } from '@azure/msal-node'; +import { env, LogLevel, LogOutputChannel } from 'vscode'; + +export class MsalLoggerOptions { + piiLoggingEnabled = false; + + constructor(private readonly _output: LogOutputChannel) { } + + get logLevel(): MsalLogLevel { + return this._toMsalLogLevel(env.logLevel); + } + + loggerCallback(level: MsalLogLevel, message: string, containsPii: boolean): void { + if (containsPii) { + return; + } + + switch (level) { + case MsalLogLevel.Error: + this._output.error(message); + return; + case MsalLogLevel.Warning: + this._output.warn(message); + return; + case MsalLogLevel.Info: + this._output.info(message); + return; + case MsalLogLevel.Verbose: + this._output.debug(message); + return; + case MsalLogLevel.Trace: + this._output.trace(message); + return; + default: + this._output.info(message); + return; + } + } + + private _toMsalLogLevel(logLevel: LogLevel): MsalLogLevel { + switch (logLevel) { + case LogLevel.Trace: + return MsalLogLevel.Trace; + case LogLevel.Debug: + return MsalLogLevel.Verbose; + case LogLevel.Info: + return MsalLogLevel.Info; + case LogLevel.Warning: + return MsalLogLevel.Warning; + case LogLevel.Error: + return MsalLogLevel.Error; + default: + return MsalLogLevel.Info; + } + } +} diff --git a/extensions/microsoft-authentication/src/common/loopbackClientAndOpener.ts b/extensions/microsoft-authentication/src/common/loopbackClientAndOpener.ts new file mode 100644 index 00000000000..4a455ea50f7 --- /dev/null +++ b/extensions/microsoft-authentication/src/common/loopbackClientAndOpener.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { ILoopbackClient, ServerAuthorizationCodeResponse } from '@azure/msal-node'; +import type { UriEventHandler } from '../UriEventHandler'; +import { env, Uri } from 'vscode'; +import { toPromise } from './async'; + +export interface ILoopbackClientAndOpener extends ILoopbackClient { + openBrowser(url: string): Promise; +} + +export class UriHandlerLoopbackClient implements ILoopbackClientAndOpener { + constructor( + private readonly _uriHandler: UriEventHandler, + private readonly _redirectUri: string + ) { } + + async listenForAuthCode(successTemplate?: string, errorTemplate?: string): Promise { + console.log(successTemplate, errorTemplate); + const url = await toPromise(this._uriHandler.event); + 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, + }; + } + + getRedirectUri(): string { + // We always return the constant redirect URL because + // it will handle redirecting back to the extension + return this._redirectUri; + } + + closeServer(): void { + // No-op + } + + async openBrowser(url: string): Promise { + const callbackUri = await env.asExternalUri(Uri.parse(`${env.uriScheme}://vscode.microsoft-authentication`)); + + const uri = Uri.parse(url + `&state=${encodeURI(callbackUri.toString(true))}`); + await env.openExternal(uri); + } +} diff --git a/extensions/microsoft-authentication/src/common/publicClientCache.ts b/extensions/microsoft-authentication/src/common/publicClientCache.ts new file mode 100644 index 00000000000..cb9339f926d --- /dev/null +++ b/extensions/microsoft-authentication/src/common/publicClientCache.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import type { AccountInfo, AuthenticationResult, InteractiveRequest, SilentFlowRequest } from '@azure/msal-node'; +import type { Disposable, Event } from 'vscode'; + +export interface ICachedPublicClientApplication extends Disposable { + initialize(): Promise; + acquireTokenSilent(request: SilentFlowRequest): Promise; + acquireTokenInteractive(request: InteractiveRequest): Promise; + removeAccount(account: AccountInfo): Promise; + accounts: AccountInfo[]; + clientId: string; + authority: string; +} + +export interface ICachedPublicClientApplicationManager { + getOrCreate(clientId: string, authority: string): Promise; + getAll(): ICachedPublicClientApplication[]; +} diff --git a/extensions/microsoft-authentication/src/common/telemetryReporter.ts b/extensions/microsoft-authentication/src/common/telemetryReporter.ts new file mode 100644 index 00000000000..52da2557a4d --- /dev/null +++ b/extensions/microsoft-authentication/src/common/telemetryReporter.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import TelemetryReporter from '@vscode/extension-telemetry'; + +export const enum MicrosoftAccountType { + AAD = 'aad', + MSA = 'msa', + Unknown = 'unknown' +} + +export class MicrosoftAuthenticationTelemetryReporter { + protected _telemetryReporter: TelemetryReporter; + constructor(aiKey: string) { + this._telemetryReporter = new TelemetryReporter(aiKey); + } + + sendLoginEvent(scopes: readonly string[]): void { + /* __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." } + } + */ + this._telemetryReporter.sendTelemetryEvent('login', { + // Get rid of guids from telemetry. + scopes: JSON.stringify(this._scrubGuids(scopes)), + }); + } + sendLoginFailedEvent(): void { + /* __GDPR__ + "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } + */ + this._telemetryReporter.sendTelemetryEvent('loginFailed'); + } + sendLogoutEvent(): void { + /* __GDPR__ + "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } + */ + this._telemetryReporter.sendTelemetryEvent('logout'); + } + sendLogoutFailedEvent(): void { + /* __GDPR__ + "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } + */ + this._telemetryReporter.sendTelemetryEvent('logoutFailed'); + } + /** + * Sends an event for an account type available at startup. + * @param scopes The scopes for the session + * @param accountType The account type for the session + * @todo Remove the scopes since we really don't care about them. + */ + sendAccountEvent(scopes: string[], accountType: MicrosoftAccountType): void { + /* __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(this._scrubGuids(scopes)), + accountType + }); + } + + protected _scrubGuids(scopes: readonly string[]): string[] { + return 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}')); + } +} + +export class MicrosoftSovereignCloudAuthenticationTelemetryReporter extends MicrosoftAuthenticationTelemetryReporter { + override sendLoginEvent(scopes: string[]): void { + /* __GDPR__ + "loginMicrosoftSovereignCloud" : { + "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." } + } + */ + this._telemetryReporter.sendTelemetryEvent('loginMicrosoftSovereignCloud', { + // Get rid of guids from telemetry. + scopes: JSON.stringify(this._scrubGuids(scopes)), + }); + } + override sendLoginFailedEvent(): void { + /* __GDPR__ + "loginMicrosoftSovereignCloudFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } + */ + this._telemetryReporter.sendTelemetryEvent('loginMicrosoftSovereignCloudFailed'); + } + override sendLogoutEvent(): void { + /* __GDPR__ + "logoutMicrosoftSovereignCloud" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } + */ + this._telemetryReporter.sendTelemetryEvent('logoutMicrosoftSovereignCloud'); + } + override sendLogoutFailedEvent(): void { + /* __GDPR__ + "logoutMicrosoftSovereignCloudFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } + */ + this._telemetryReporter.sendTelemetryEvent('logoutMicrosoftSovereignCloudFailed'); + } +} diff --git a/extensions/microsoft-authentication/src/extension.ts b/extensions/microsoft-authentication/src/extension.ts index 87dc94e4c25..e06f2a0400a 100644 --- a/extensions/microsoft-authentication/src/extension.ts +++ b/extensions/microsoft-authentication/src/extension.ts @@ -3,179 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; -import { Environment, EnvironmentParameters } from '@azure/ms-rest-azure-env'; -import { AzureActiveDirectoryService, IStoredSession } from './AADHelper'; -import { BetterTokenStorage } from './betterSecretStorage'; -import { UriEventHandler } from './UriEventHandler'; -import TelemetryReporter from '@vscode/extension-telemetry'; +import { commands, ExtensionContext, l10n, window, workspace } from 'vscode'; +import * as extensionV1 from './extensionV1'; +import * as extensionV2 from './extensionV2'; -async function initMicrosoftSovereignCloudAuthProvider(context: vscode.ExtensionContext, telemetryReporter: TelemetryReporter, uriHandler: UriEventHandler, tokenStorage: BetterTokenStorage): Promise { - const environment = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get('environment'); - let authProviderName: string | undefined; - if (!environment) { - return undefined; - } +const config = workspace.getConfiguration('microsoft'); +const useMsal = config.get('useMsal', false); - if (environment === 'custom') { - const customEnv = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get('customEnvironment'); - if (!customEnv) { - const res = await vscode.window.showErrorMessage(vscode.l10n.t('You must also specify a custom environment in order to use the custom environment auth provider.'), vscode.l10n.t('Open settings')); - if (res) { - await vscode.commands.executeCommand('workbench.action.openSettingsJson', 'microsoft-sovereign-cloud.customEnvironment'); - } - return undefined; +export async function activate(context: ExtensionContext) { + context.subscriptions.push(workspace.onDidChangeConfiguration(async e => { + if (!e.affectsConfiguration('microsoft.useMsal') && useMsal === config.get('useMsal', false)) { + return; } - try { - Environment.add(customEnv); - } catch (e) { - const res = await vscode.window.showErrorMessage(vscode.l10n.t('Error validating custom environment setting: {0}', e.message), vscode.l10n.t('Open settings')); - if (res) { - await vscode.commands.executeCommand('workbench.action.openSettings', 'microsoft-sovereign-cloud.customEnvironment'); - } - return undefined; - } - authProviderName = customEnv.name; - } else { - authProviderName = environment; - } - const env = Environment.get(authProviderName); - if (!env) { - const res = await vscode.window.showErrorMessage(vscode.l10n.t('The environment `{0}` is not a valid environment.', authProviderName), vscode.l10n.t('Open settings')); - return undefined; - } + const reload = l10n.t('Reload'); + const result = await window.showInformationMessage( + 'Reload required', + { + modal: true, + detail: l10n.t('Microsoft Account configuration has been changed.'), + }, + reload + ); - const aadService = new AzureActiveDirectoryService( - vscode.window.createOutputChannel(vscode.l10n.t('Microsoft Sovereign Cloud Authentication'), { log: true }), - context, - uriHandler, - tokenStorage, - telemetryReporter, - env); - await aadService.initialize(); - - const disposable = vscode.authentication.registerAuthenticationProvider('microsoft-sovereign-cloud', authProviderName, { - onDidChangeSessions: aadService.onDidChangeSessions, - getSessions: (scopes: string[]) => aadService.getSessions(scopes), - createSession: async (scopes: string[]) => { - try { - /* __GDPR__ - "login" : { - "owner": "TylerLeonhardt", - "comment": "Used to determine the usage of the Microsoft Sovereign Cloud Auth Provider.", - "scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." } - } - */ - telemetryReporter.sendTelemetryEvent('loginMicrosoftSovereignCloud', { - // Get rid of guids from telemetry. - 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}'))), - }); - - return await aadService.createSession(scopes); - } catch (e) { - /* __GDPR__ - "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } - */ - telemetryReporter.sendTelemetryEvent('loginMicrosoftSovereignCloudFailed'); - - throw e; - } - }, - removeSession: async (id: string) => { - try { - /* __GDPR__ - "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } - */ - telemetryReporter.sendTelemetryEvent('logoutMicrosoftSovereignCloud'); - - await aadService.removeSessionById(id); - } catch (e) { - /* __GDPR__ - "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } - */ - telemetryReporter.sendTelemetryEvent('logoutMicrosoftSovereignCloudFailed'); - } - } - }, { supportsMultipleAccounts: true }); - - context.subscriptions.push(disposable); - return disposable; -} - -export async function activate(context: vscode.ExtensionContext) { - const aiKey: string = context.extension.packageJSON.aiKey; - const telemetryReporter = new TelemetryReporter(aiKey); - - const uriHandler = new UriEventHandler(); - context.subscriptions.push(uriHandler); - context.subscriptions.push(vscode.window.registerUriHandler(uriHandler)); - const betterSecretStorage = new BetterTokenStorage('microsoft.login.keylist', context); - - const loginService = new AzureActiveDirectoryService( - vscode.window.createOutputChannel(vscode.l10n.t('Microsoft Authentication'), { log: true }), - context, - uriHandler, - betterSecretStorage, - telemetryReporter, - Environment.AzureCloud); - await loginService.initialize(); - - context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('microsoft', 'Microsoft', { - onDidChangeSessions: loginService.onDidChangeSessions, - getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options?.account), - createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => { - try { - /* __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." } - } - */ - telemetryReporter.sendTelemetryEvent('login', { - // Get rid of guids from telemetry. - 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}'))), - }); - - return await loginService.createSession(scopes, options?.account); - } catch (e) { - /* __GDPR__ - "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } - */ - telemetryReporter.sendTelemetryEvent('loginFailed'); - - throw e; - } - }, - removeSession: async (id: string) => { - try { - /* __GDPR__ - "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } - */ - telemetryReporter.sendTelemetryEvent('logout'); - - await loginService.removeSessionById(id); - } catch (e) { - /* __GDPR__ - "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } - */ - telemetryReporter.sendTelemetryEvent('logoutFailed'); - } - } - }, { supportsMultipleAccounts: true })); - - let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage); - - context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => { - if (e.affectsConfiguration('microsoft-sovereign-cloud')) { - microsoftSovereignCloudAuthProviderDisposable?.dispose(); - microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage); + if (result === reload) { + commands.executeCommand('workbench.action.reloadWindow'); } })); - - return; + // Only activate the new extension if we are not running in a browser environment + if (useMsal && typeof navigator === 'undefined') { + await extensionV2.activate(context); + } else { + await extensionV1.activate(context); + } } -// this method is called when your extension is deactivated -export function deactivate() { } +export function deactivate() { + if (useMsal) { + extensionV2.deactivate(); + } else { + extensionV1.deactivate(); + } +} diff --git a/extensions/microsoft-authentication/src/extensionV1.ts b/extensions/microsoft-authentication/src/extensionV1.ts new file mode 100644 index 00000000000..f63eedb2410 --- /dev/null +++ b/extensions/microsoft-authentication/src/extensionV1.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Environment, EnvironmentParameters } from '@azure/ms-rest-azure-env'; +import { AzureActiveDirectoryService, IStoredSession } from './AADHelper'; +import { BetterTokenStorage } from './betterSecretStorage'; +import { UriEventHandler } from './UriEventHandler'; +import TelemetryReporter from '@vscode/extension-telemetry'; + +async function initMicrosoftSovereignCloudAuthProvider(context: vscode.ExtensionContext, telemetryReporter: TelemetryReporter, uriHandler: UriEventHandler, tokenStorage: BetterTokenStorage): Promise { + const environment = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get('environment'); + let authProviderName: string | undefined; + if (!environment) { + return undefined; + } + + if (environment === 'custom') { + const customEnv = vscode.workspace.getConfiguration('microsoft-sovereign-cloud').get('customEnvironment'); + if (!customEnv) { + const res = await vscode.window.showErrorMessage(vscode.l10n.t('You must also specify a custom environment in order to use the custom environment auth provider.'), vscode.l10n.t('Open settings')); + if (res) { + await vscode.commands.executeCommand('workbench.action.openSettingsJson', 'microsoft-sovereign-cloud.customEnvironment'); + } + return undefined; + } + try { + Environment.add(customEnv); + } catch (e) { + const res = await vscode.window.showErrorMessage(vscode.l10n.t('Error validating custom environment setting: {0}', e.message), vscode.l10n.t('Open settings')); + if (res) { + await vscode.commands.executeCommand('workbench.action.openSettings', 'microsoft-sovereign-cloud.customEnvironment'); + } + return undefined; + } + authProviderName = customEnv.name; + } else { + authProviderName = environment; + } + + const env = Environment.get(authProviderName); + if (!env) { + const res = await vscode.window.showErrorMessage(vscode.l10n.t('The environment `{0}` is not a valid environment.', authProviderName), vscode.l10n.t('Open settings')); + return undefined; + } + + const aadService = new AzureActiveDirectoryService( + vscode.window.createOutputChannel(vscode.l10n.t('Microsoft Sovereign Cloud Authentication'), { log: true }), + context, + uriHandler, + tokenStorage, + telemetryReporter, + env); + await aadService.initialize(); + + const disposable = vscode.authentication.registerAuthenticationProvider('microsoft-sovereign-cloud', authProviderName, { + onDidChangeSessions: aadService.onDidChangeSessions, + getSessions: (scopes: string[]) => aadService.getSessions(scopes), + createSession: async (scopes: string[]) => { + try { + /* __GDPR__ + "login" : { + "owner": "TylerLeonhardt", + "comment": "Used to determine the usage of the Microsoft Sovereign Cloud Auth Provider.", + "scopes": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight", "comment": "Used to determine what scope combinations are being requested." } + } + */ + telemetryReporter.sendTelemetryEvent('loginMicrosoftSovereignCloud', { + // Get rid of guids from telemetry. + 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}'))), + }); + + return await aadService.createSession(scopes); + } catch (e) { + /* __GDPR__ + "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } + */ + telemetryReporter.sendTelemetryEvent('loginMicrosoftSovereignCloudFailed'); + + throw e; + } + }, + removeSession: async (id: string) => { + try { + /* __GDPR__ + "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } + */ + telemetryReporter.sendTelemetryEvent('logoutMicrosoftSovereignCloud'); + + await aadService.removeSessionById(id); + } catch (e) { + /* __GDPR__ + "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } + */ + telemetryReporter.sendTelemetryEvent('logoutMicrosoftSovereignCloudFailed'); + } + } + }, { supportsMultipleAccounts: true }); + + context.subscriptions.push(disposable); + return disposable; +} + +export async function activate(context: vscode.ExtensionContext) { + const aiKey: string = context.extension.packageJSON.aiKey; + const telemetryReporter = new TelemetryReporter(aiKey); + + const uriHandler = new UriEventHandler(); + context.subscriptions.push(uriHandler); + const betterSecretStorage = new BetterTokenStorage('microsoft.login.keylist', context); + + const loginService = new AzureActiveDirectoryService( + vscode.window.createOutputChannel(vscode.l10n.t('Microsoft Authentication'), { log: true }), + context, + uriHandler, + betterSecretStorage, + telemetryReporter, + Environment.AzureCloud); + await loginService.initialize(); + + context.subscriptions.push(vscode.authentication.registerAuthenticationProvider('microsoft', 'Microsoft', { + onDidChangeSessions: loginService.onDidChangeSessions, + getSessions: (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => loginService.getSessions(scopes, options?.account), + createSession: async (scopes: string[], options?: vscode.AuthenticationProviderSessionOptions) => { + try { + /* __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." } + } + */ + telemetryReporter.sendTelemetryEvent('login', { + // Get rid of guids from telemetry. + 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}'))), + }); + + return await loginService.createSession(scopes, options?.account); + } catch (e) { + /* __GDPR__ + "loginFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users run into issues with the login flow." } + */ + telemetryReporter.sendTelemetryEvent('loginFailed'); + + throw e; + } + }, + removeSession: async (id: string) => { + try { + /* __GDPR__ + "logout" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often users log out." } + */ + telemetryReporter.sendTelemetryEvent('logout'); + + await loginService.removeSessionById(id); + } catch (e) { + /* __GDPR__ + "logoutFailed" : { "owner": "TylerLeonhardt", "comment": "Used to determine how often fail to log out." } + */ + telemetryReporter.sendTelemetryEvent('logoutFailed'); + } + } + }, { supportsMultipleAccounts: true })); + + let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage); + + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration('microsoft-sovereign-cloud')) { + microsoftSovereignCloudAuthProviderDisposable?.dispose(); + microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, telemetryReporter, uriHandler, betterSecretStorage); + } + })); + + return; +} + +// this method is called when your extension is deactivated +export function deactivate() { } diff --git a/extensions/microsoft-authentication/src/extensionV2.ts b/extensions/microsoft-authentication/src/extensionV2.ts new file mode 100644 index 00000000000..571172eda5e --- /dev/null +++ b/extensions/microsoft-authentication/src/extensionV2.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Environment, EnvironmentParameters } from '@azure/ms-rest-azure-env'; +import Logger from './logger'; +import { MsalAuthProvider } from './node/authProvider'; +import { UriEventHandler } from './UriEventHandler'; +import { authentication, commands, ExtensionContext, l10n, window, workspace, Disposable } from 'vscode'; +import { MicrosoftAuthenticationTelemetryReporter, MicrosoftSovereignCloudAuthenticationTelemetryReporter } from './common/telemetryReporter'; + +async function initMicrosoftSovereignCloudAuthProvider( + context: ExtensionContext, + uriHandler: UriEventHandler +): Promise { + const environment = workspace.getConfiguration('microsoft-sovereign-cloud').get('environment'); + let authProviderName: string | undefined; + if (!environment) { + return undefined; + } + + if (environment === 'custom') { + const customEnv = workspace.getConfiguration('microsoft-sovereign-cloud').get('customEnvironment'); + if (!customEnv) { + const res = await window.showErrorMessage(l10n.t('You must also specify a custom environment in order to use the custom environment auth provider.'), l10n.t('Open settings')); + if (res) { + await commands.executeCommand('workbench.action.openSettingsJson', 'microsoft-sovereign-cloud.customEnvironment'); + } + return undefined; + } + try { + Environment.add(customEnv); + } catch (e) { + const res = await window.showErrorMessage(l10n.t('Error validating custom environment setting: {0}', e.message), l10n.t('Open settings')); + if (res) { + await commands.executeCommand('workbench.action.openSettings', 'microsoft-sovereign-cloud.customEnvironment'); + } + return undefined; + } + authProviderName = customEnv.name; + } else { + authProviderName = environment; + } + + const env = Environment.get(authProviderName); + if (!env) { + await window.showErrorMessage(l10n.t('The environment `{0}` is not a valid environment.', authProviderName), l10n.t('Open settings')); + return undefined; + } + + const authProvider = new MsalAuthProvider( + context, + new MicrosoftSovereignCloudAuthenticationTelemetryReporter(context.extension.packageJSON.aiKey), + window.createOutputChannel(l10n.t('Microsoft Sovereign Cloud Authentication'), { log: true }), + uriHandler, + env + ); + await authProvider.initialize(); + const disposable = authentication.registerAuthenticationProvider( + 'microsoft-sovereign-cloud', + authProviderName, + authProvider, + { supportsMultipleAccounts: true } + ); + context.subscriptions.push(disposable); + return disposable; +} + +export async function activate(context: ExtensionContext) { + const uriHandler = new UriEventHandler(); + context.subscriptions.push(uriHandler); + const authProvider = new MsalAuthProvider( + context, + new MicrosoftAuthenticationTelemetryReporter(context.extension.packageJSON.aiKey), + Logger, + uriHandler + ); + await authProvider.initialize(); + context.subscriptions.push(authentication.registerAuthenticationProvider( + 'microsoft', + 'Microsoft', + authProvider, + { supportsMultipleAccounts: true } + )); + + let microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, uriHandler); + + context.subscriptions.push(workspace.onDidChangeConfiguration(async e => { + if (e.affectsConfiguration('microsoft-sovereign-cloud')) { + microsoftSovereignCloudAuthProviderDisposable?.dispose(); + microsoftSovereignCloudAuthProviderDisposable = await initMicrosoftSovereignCloudAuthProvider(context, uriHandler); + } + })); +} + +export function deactivate() { } diff --git a/extensions/microsoft-authentication/src/node/authProvider.ts b/extensions/microsoft-authentication/src/node/authProvider.ts new file mode 100644 index 00000000000..0175b2981b8 --- /dev/null +++ b/extensions/microsoft-authentication/src/node/authProvider.ts @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { AccountInfo, AuthenticationResult } from '@azure/msal-node'; +import { AuthenticationGetSessionOptions, AuthenticationProvider, AuthenticationProviderAuthenticationSessionsChangeEvent, AuthenticationSession, AuthenticationSessionAccountInformation, CancellationError, env, EventEmitter, ExtensionContext, l10n, LogOutputChannel, Memento, SecretStorage, Uri, window } from 'vscode'; +import { Environment } from '@azure/ms-rest-azure-env'; +import { CachedPublicClientApplicationManager } from './publicClientCache'; +import { UriHandlerLoopbackClient } from '../common/loopbackClientAndOpener'; +import { UriEventHandler } from '../UriEventHandler'; +import { ICachedPublicClientApplication } from '../common/publicClientCache'; +import { MicrosoftAccountType, MicrosoftAuthenticationTelemetryReporter } from '../common/telemetryReporter'; +import { loopbackTemplate } from './loopbackTemplate'; +import { Delayer } from '../common/async'; + +const redirectUri = 'https://vscode.dev/redirect'; +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'; + +export class MsalAuthProvider implements AuthenticationProvider { + + private readonly _disposables: { dispose(): void }[]; + private readonly _publicClientManager: CachedPublicClientApplicationManager; + private readonly _refreshDelayer = new DelayerByKey(); + + /** + * Event to signal a change in authentication sessions for this provider. + */ + private readonly _onDidChangeSessionsEmitter = new EventEmitter(); + + /** + * Event to signal a change in authentication sessions for this provider. + * + * NOTE: This event is handled differently in the Microsoft auth provider than "typical" auth providers. Normally, + * this event would fire when the provider's sessions change... which are tied to a specific list of scopes. However, + * since Microsoft identity doesn't care too much about scopes (you can mint a new token from an existing token), + * we just fire this event whenever the account list changes... so essentially there is one session per account. + * + * This is not quite how the API should be used... but this event really is just for signaling that the account list + * has changed. + */ + onDidChangeSessions = this._onDidChangeSessionsEmitter.event; + + constructor( + context: ExtensionContext, + private readonly _telemetryReporter: MicrosoftAuthenticationTelemetryReporter, + private readonly _logger: LogOutputChannel, + private readonly _uriHandler: UriEventHandler, + private readonly _env: Environment = Environment.AzureCloud + ) { + this._disposables = context.subscriptions; + this._publicClientManager = new CachedPublicClientApplicationManager( + context.globalState, + context.secrets, + this._logger, + (e) => this._handleAccountChange(e) + ); + this._disposables.push(this._publicClientManager); + this._disposables.push(this._onDidChangeSessionsEmitter); + + } + + async initialize(): Promise { + await this._publicClientManager.initialize(); + + // Send telemetry for existing accounts + for (const cachedPca of this._publicClientManager.getAll()) { + for (const account of cachedPca.accounts) { + if (!account.idTokenClaims?.tid) { + continue; + } + const tid = account.idTokenClaims.tid; + const type = tid === MSA_TID || tid === MSA_PASSTHRU_TID ? MicrosoftAccountType.MSA : MicrosoftAccountType.AAD; + this._telemetryReporter.sendAccountEvent([], type); + } + } + } + + /** + * See {@link onDidChangeSessions} for more information on how this is used. + * @param param0 Event that contains the added and removed accounts + */ + private _handleAccountChange({ added, deleted }: { added: AccountInfo[]; deleted: AccountInfo[] }) { + const process = (a: AccountInfo) => ({ + // This shouldn't be needed + accessToken: '1234', + id: a.homeAccountId, + scopes: [], + account: { + id: a.homeAccountId, + label: a.username + }, + idToken: a.idToken, + }); + this._onDidChangeSessionsEmitter.fire({ added: added.map(process), changed: [], removed: deleted.map(process) }); + } + + async getSessions(scopes: string[] | undefined, options?: AuthenticationGetSessionOptions): Promise { + this._logger.info('[getSessions]', scopes ?? 'all', 'starting'); + const modifiedScopes = scopes ? [...scopes] : []; + const clientId = this.getClientId(modifiedScopes); + const tenant = this.getTenantId(modifiedScopes); + this._addCommonScopes(modifiedScopes); + if (!scopes) { + const allSessions: AuthenticationSession[] = []; + for (const cachedPca of this._publicClientManager.getAll()) { + const sessions = await this.getAllSessionsForPca(cachedPca, modifiedScopes, modifiedScopes, options?.account); + allSessions.push(...sessions); + } + return allSessions; + } + + const cachedPca = await this.getOrCreatePublicClientApplication(clientId, tenant); + const sessions = await this.getAllSessionsForPca(cachedPca, scopes, modifiedScopes.filter(s => !s.startsWith('VSCODE_'), options?.account)); + this._logger.info(`[getSessions] returned ${sessions.length} sessions`); + return sessions; + + } + + async createSession(scopes: readonly string[]): Promise { + this._logger.info('[createSession]', scopes, 'starting'); + const modifiedScopes = scopes ? [...scopes] : []; + const clientId = this.getClientId(modifiedScopes); + const tenant = this.getTenantId(modifiedScopes); + this._addCommonScopes(modifiedScopes); + + const cachedPca = await this.getOrCreatePublicClientApplication(clientId, tenant); + let result: AuthenticationResult; + try { + result = await cachedPca.acquireTokenInteractive({ + openBrowser: async (url: string) => { await env.openExternal(Uri.parse(url)); }, + scopes: modifiedScopes, + successTemplate: loopbackTemplate, + // TODO: This is currently not working (the loopback client closes before this is rendered). + // There is an issue opened on MSAL Node to fix this. We should workaround this by re-implementing the Loopback client. + // errorTemplate: loopbackTemplate + }); + this.setupRefresh(cachedPca, result, scopes); + } catch (e) { + if (e instanceof CancellationError) { + const yes = l10n.t('Yes'); + const result = await window.showErrorMessage( + l10n.t('Having trouble logging in?'), + { + modal: true, + detail: l10n.t('Would you like to try a different way to sign in to your Microsoft account? ({0})', 'protocol handler') + }, + yes + ); + if (!result) { + this._telemetryReporter.sendLoginFailedEvent(); + throw e; + } + } + const loopbackClient = new UriHandlerLoopbackClient(this._uriHandler, redirectUri); + try { + result = await cachedPca.acquireTokenInteractive({ + openBrowser: (url: string) => loopbackClient.openBrowser(url), + scopes: modifiedScopes, + loopbackClient + }); + this.setupRefresh(cachedPca, result, scopes); + } catch (e) { + this._telemetryReporter.sendLoginFailedEvent(); + throw e; + } + } + + const session = this.toAuthenticationSession(result, scopes); + this._telemetryReporter.sendLoginEvent(session.scopes); + this._logger.info('[createSession]', scopes, 'returned session'); + return session; + } + + async removeSession(sessionId: string): Promise { + this._logger.info('[removeSession]', sessionId, 'starting'); + for (const cachedPca of this._publicClientManager.getAll()) { + const accounts = cachedPca.accounts; + for (const account of accounts) { + if (account.homeAccountId === sessionId) { + this._telemetryReporter.sendLogoutEvent(); + try { + await cachedPca.removeAccount(account); + } catch (e) { + this._telemetryReporter.sendLogoutFailedEvent(); + throw e; + } + this._logger.info('[removeSession]', sessionId, 'removed session'); + return; + } + } + } + this._logger.info('[removeSession]', sessionId, 'session not found'); + } + + private async getOrCreatePublicClientApplication(clientId: string, tenant: string): Promise { + const authority = new URL(tenant, this._env.activeDirectoryEndpointUrl).toString(); + return await this._publicClientManager.getOrCreate(clientId, authority); + } + + private _addCommonScopes(scopes: string[]) { + if (!scopes.includes('openid')) { + scopes.push('openid'); + } + if (!scopes.includes('email')) { + scopes.push('email'); + } + if (!scopes.includes('profile')) { + scopes.push('profile'); + } + if (!scopes.includes('offline_access')) { + scopes.push('offline_access'); + } + return scopes; + } + + private async getAllSessionsForPca( + cachedPca: ICachedPublicClientApplication, + originalScopes: readonly string[], + scopesToSend: string[], + accountFilter?: AuthenticationSessionAccountInformation + ): Promise { + const accounts = accountFilter + ? cachedPca.accounts.filter(a => a.homeAccountId === accountFilter.id) + : cachedPca.accounts; + const sessions: AuthenticationSession[] = []; + for (const account of accounts) { + const result = await cachedPca.acquireTokenSilent({ account, scopes: scopesToSend, redirectUri }); + this.setupRefresh(cachedPca, result, originalScopes); + sessions.push(this.toAuthenticationSession(result, originalScopes)); + } + return sessions; + } + + private setupRefresh(cachedPca: ICachedPublicClientApplication, result: AuthenticationResult, originalScopes: readonly string[]) { + const on = result.refreshOn || result.expiresOn; + if (!result.account || !on) { + return; + } + + const account = result.account; + const scopes = result.scopes; + const timeToRefresh = on.getTime() - Date.now() - 5 * 60 * 1000; // 5 minutes before expiry + const key = JSON.stringify({ accountId: account.homeAccountId, scopes }); + this._refreshDelayer.trigger(key, async () => { + const result = await cachedPca.acquireTokenSilent({ account, scopes, redirectUri, forceRefresh: true }); + this._onDidChangeSessionsEmitter.fire({ added: [], changed: [this.toAuthenticationSession(result, originalScopes)], removed: [] }); + }, timeToRefresh > 0 ? timeToRefresh : 0); + } + + //#region scope parsers + + private getClientId(scopes: string[]) { + return scopes.reduce((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((prev, current) => { + if (current.startsWith('VSCODE_TENANT:')) { + return current.split('VSCODE_TENANT:')[1]; + } + return prev; + }, undefined) ?? DEFAULT_TENANT; + } + + //#endregion + + private toAuthenticationSession(result: AuthenticationResult, scopes: readonly string[]): AuthenticationSession & { idToken: string } { + return { + accessToken: result.accessToken, + idToken: result.idToken, + id: result.account?.homeAccountId ?? result.uniqueId, + account: { + id: result.account?.homeAccountId ?? result.uniqueId, + label: result.account?.username ?? 'Unknown', + }, + scopes + }; + } +} + +class DelayerByKey { + private _delayers = new Map>(); + + trigger(key: string, fn: () => Promise, delay: number): Promise { + let delayer = this._delayers.get(key); + if (!delayer) { + delayer = new Delayer(delay); + this._delayers.set(key, delayer); + } + + return delayer.trigger(fn, delay); + } +} diff --git a/extensions/microsoft-authentication/src/node/loopbackTemplate.ts b/extensions/microsoft-authentication/src/node/loopbackTemplate.ts new file mode 100644 index 00000000000..df41b86d17c --- /dev/null +++ b/extensions/microsoft-authentication/src/node/loopbackTemplate.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +export const loopbackTemplate = ` + + + + + + Microsoft Account - Sign In + + + + + + + Visual Studio Code + +
+
+ You are signed in now and can close this page. +
+
+ An error occurred while signing in: +
+
+
+ + + + +`; diff --git a/extensions/microsoft-authentication/src/node/publicClientCache.ts b/extensions/microsoft-authentication/src/node/publicClientCache.ts new file mode 100644 index 00000000000..8ee95ba5a0c --- /dev/null +++ b/extensions/microsoft-authentication/src/node/publicClientCache.ts @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AccountInfo, AuthenticationResult, Configuration, InteractiveRequest, PublicClientApplication, SilentFlowRequest } from '@azure/msal-node'; +import { SecretStorageCachePlugin } from '../common/cachePlugin'; +import { SecretStorage, LogOutputChannel, Disposable, SecretStorageChangeEvent, EventEmitter, Memento, window, ProgressLocation, l10n } from 'vscode'; +import { MsalLoggerOptions } from '../common/loggerOptions'; +import { ICachedPublicClientApplication, ICachedPublicClientApplicationManager } from '../common/publicClientCache'; +import { raceCancellationAndTimeoutError } from '../common/async'; + +export interface IPublicClientApplicationInfo { + clientId: string; + authority: string; +} + +const _keyPrefix = 'pca:'; + +export class CachedPublicClientApplicationManager implements ICachedPublicClientApplicationManager { + // The key is the clientId and authority stringified + private readonly _pcas = new Map(); + + private _initialized = false; + private _disposable: Disposable; + + constructor( + private readonly _globalMemento: Memento, + private readonly _secretStorage: SecretStorage, + private readonly _logger: LogOutputChannel, + private readonly _accountChangeHandler: (e: { added: AccountInfo[]; deleted: AccountInfo[] }) => void + ) { + this._disposable = _secretStorage.onDidChange(e => this._handleSecretStorageChange(e)); + } + + async initialize() { + this._logger.debug('Initializing PublicClientApplicationManager'); + const keys = await this._secretStorage.get('publicClientApplications'); + if (!keys) { + this._initialized = true; + return; + } + + const promises = new Array>(); + try { + for (const key of JSON.parse(keys) as string[]) { + try { + const { clientId, authority } = JSON.parse(key) as IPublicClientApplicationInfo; + // Load the PCA in memory + promises.push(this.getOrCreate(clientId, authority)); + } catch (e) { + // ignore + } + } + } catch (e) { + // data is corrupted + this._logger.error('Error initializing PublicClientApplicationManager:', e); + await this._secretStorage.delete('publicClientApplications'); + } + + // TODO: should we do anything for when this fails? + await Promise.allSettled(promises); + this._logger.debug('PublicClientApplicationManager initialized'); + this._initialized = true; + } + + dispose() { + this._disposable.dispose(); + Disposable.from(...this._pcas.values()).dispose(); + } + + async getOrCreate(clientId: string, authority: string): Promise { + if (!this._initialized) { + throw new Error('PublicClientApplicationManager not initialized'); + } + + // Use the clientId and authority as the key + const pcasKey = JSON.stringify({ clientId, authority }); + let pca = this._pcas.get(pcasKey); + if (pca) { + this._logger.debug(clientId, authority, 'PublicClientApplicationManager cache hit'); + return pca; + } + + this._logger.debug(clientId, authority, 'PublicClientApplicationManager cache miss, creating new PCA...'); + pca = new CachedPublicClientApplication(clientId, authority, this._globalMemento, this._secretStorage, this._accountChangeHandler, this._logger); + this._pcas.set(pcasKey, pca); + await pca.initialize(); + await this._storePublicClientApplications(); + this._logger.debug(clientId, authority, 'PublicClientApplicationManager PCA created'); + return pca; + } + + getAll(): ICachedPublicClientApplication[] { + if (!this._initialized) { + throw new Error('PublicClientApplicationManager not initialized'); + } + return Array.from(this._pcas.values()); + } + + private async _handleSecretStorageChange(e: SecretStorageChangeEvent) { + if (!e.key.startsWith(_keyPrefix)) { + return; + } + + this._logger.debug('PublicClientApplicationManager secret storage change:', e.key); + const result = await this._secretStorage.get(e.key); + const pcasKey = e.key.split(_keyPrefix)[1]; + + // If the cache was deleted, or the PCA has zero accounts left, remove the PCA + if (!result || this._pcas.get(pcasKey)?.accounts.length === 0) { + this._logger.debug('PublicClientApplicationManager removing PCA:', pcasKey); + this._pcas.delete(pcasKey); + await this._storePublicClientApplications(); + this._logger.debug('PublicClientApplicationManager PCA removed:', pcasKey); + return; + } + + // Load the PCA in memory if it's not already loaded + const { clientId, authority } = JSON.parse(pcasKey) as IPublicClientApplicationInfo; + this._logger.debug('PublicClientApplicationManager loading PCA:', pcasKey); + await this.getOrCreate(clientId, authority); + this._logger.debug('PublicClientApplicationManager PCA loaded:', pcasKey); + } + + private async _storePublicClientApplications() { + await this._secretStorage.store( + 'publicClientApplications', + JSON.stringify(Array.from(this._pcas.keys())) + ); + } +} + +class CachedPublicClientApplication implements ICachedPublicClientApplication { + private _pca: PublicClientApplication; + + private _accounts: AccountInfo[] = []; + private readonly _disposable: Disposable; + + private readonly _loggerOptions = new MsalLoggerOptions(this._logger); + private readonly _secretStorageCachePlugin = new SecretStorageCachePlugin( + this._secretStorage, + // Include the prefix in the key so we can easily identify it later + `${_keyPrefix}${JSON.stringify({ clientId: this._clientId, authority: this._authority })}` + ); + private readonly _config: Configuration = { + auth: { clientId: this._clientId, authority: this._authority }, + system: { + loggerOptions: { + loggerCallback: (level, message, containsPii) => this._loggerOptions.loggerCallback(level, message, containsPii), + } + }, + cache: { + cachePlugin: this._secretStorageCachePlugin + } + }; + + /** + * We keep track of the last time an account was removed so we can recreate the PCA if we detect that an account was removed. + * This is due to MSAL-node not providing a way to detect when an account is removed from the cache. An internal issue has been + * filed to track this. If MSAL-node ever provides a way to detect this or handle this better in the Persistant Cache Plugin, + * we can remove this logic. + */ + private _lastCreated: Date; + + constructor( + private readonly _clientId: string, + private readonly _authority: string, + private readonly _globalMemento: Memento, + private readonly _secretStorage: SecretStorage, + private readonly _accountChangeHandler: (e: { added: AccountInfo[]; deleted: AccountInfo[] }) => void, + private readonly _logger: LogOutputChannel + ) { + this._pca = new PublicClientApplication(this._config); + this._lastCreated = new Date(); + this._disposable = this._registerOnSecretStorageChanged(); + } + + get accounts(): AccountInfo[] { return this._accounts; } + get clientId(): string { return this._clientId; } + get authority(): string { return this._authority; } + + initialize(): Promise { + return this._update(); + } + + dispose(): void { + this._disposable.dispose(); + } + + acquireTokenSilent(request: SilentFlowRequest): Promise { + return this._pca.acquireTokenSilent(request); + } + + async acquireTokenInteractive(request: InteractiveRequest): Promise { + return await window.withProgress( + { + location: ProgressLocation.Notification, + cancellable: true, + title: l10n.t('Signing in to Microsoft...') + }, + (_process, token) => raceCancellationAndTimeoutError(this._pca.acquireTokenInteractive(request), token, 1000 * 60 * 5), // 5 minutes + ); + } + + removeAccount(account: AccountInfo): Promise { + this._globalMemento.update(`lastRemoval:${this._clientId}:${this._authority}`, new Date()); + return this._pca.getTokenCache().removeAccount(account); + } + + private _registerOnSecretStorageChanged() { + return this._secretStorageCachePlugin.onDidChange(() => this._update()); + } + + private async _update() { + const before = this._accounts; + this._logger.debug(this._clientId, this._authority, 'CachedPublicClientApplication update before:', before.length); + // Dates are stored as strings in the memento + const lastRemovalDate = this._globalMemento.get(`lastRemoval:${this._clientId}:${this._authority}`); + if (lastRemovalDate && this._lastCreated && Date.parse(lastRemovalDate) > this._lastCreated.getTime()) { + this._logger.debug(this._clientId, this._authority, 'CachedPublicClientApplication removal detected... recreating PCA...'); + this._pca = new PublicClientApplication(this._config); + this._lastCreated = new Date(); + } + + const after = await this._pca.getAllAccounts(); + this._accounts = after; + this._logger.debug(this._clientId, this._authority, 'CachedPublicClientApplication update after:', after.length); + + const beforeSet = new Set(before.map(b => b.homeAccountId)); + const afterSet = new Set(after.map(a => a.homeAccountId)); + + const added = after.filter(a => !beforeSet.has(a.homeAccountId)); + const deleted = before.filter(b => !afterSet.has(b.homeAccountId)); + if (added.length > 0 || deleted.length > 0) { + this._accountChangeHandler({ added, deleted }); + this._logger.debug(this._clientId, this._authority, 'CachedPublicClientApplication accounts changed. added, deleted:', added.length, deleted.length); + } + this._logger.debug(this._clientId, this._authority, 'CachedPublicClientApplication update complete'); + } +} diff --git a/extensions/microsoft-authentication/yarn.lock b/extensions/microsoft-authentication/yarn.lock index 703d54dd626..7c5d34e02a8 100644 --- a/extensions/microsoft-authentication/yarn.lock +++ b/extensions/microsoft-authentication/yarn.lock @@ -7,6 +7,20 @@ resolved "https://registry.yarnpkg.com/@azure/ms-rest-azure-env/-/ms-rest-azure-env-2.0.0.tgz#45809f89763a480924e21d3c620cd40866771625" integrity sha512-dG76W7ElfLi+fbTjnZVGj+M9e0BIEJmRxU6fHaUQ12bZBe8EJKYb2GV50YWNaP2uJiVQ5+7nXEVj1VN1UQtaEw== +"@azure/msal-common@14.14.0": + version "14.14.0" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.14.0.tgz#31a015070d5864ebcf9ebb988fcbc5c5536f22d1" + integrity sha512-OxcOk9H1/1fktHh6//VCORgSNJc2dCQObTm6JNmL824Z6iZSO6eFo/Bttxe0hETn9B+cr7gDouTQtsRq3YPuSQ== + +"@azure/msal-node@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.12.0.tgz#57ee6b6011a320046d72dc0828fec46278f2ab2c" + integrity sha512-jmk5Im5KujRA2AcyCb0awA3buV8niSrwXZs+NBJWIvxOz76RvNlusGIqi43A0h45BPUy93Qb+CPdpJn82NFTIg== + dependencies: + "@azure/msal-common" "14.14.0" + jsonwebtoken "^9.0.0" + uuid "^8.3.0" + "@microsoft/1ds-core-js@4.0.3", "@microsoft/1ds-core-js@^4.0.3": version "4.0.3" resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-4.0.3.tgz#c8a92c623745a9595e06558a866658480c33bdf9" @@ -153,6 +167,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -165,6 +184,13 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + form-data@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682" @@ -174,6 +200,74 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +jsonwebtoken@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" + integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== + dependencies: + jws "^3.2.2" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" + integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" + integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + dependencies: + jwa "^1.4.1" + safe-buffer "^5.0.1" + +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg== + +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== + +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + mime-db@1.44.0: version "1.44.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" @@ -186,7 +280,27 @@ mime-types@^2.1.12: dependencies: mime-db "1.44.0" +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +safe-buffer@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +semver@^7.5.4: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== + undici-types@~5.26.4: version "5.26.5" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +uuid@^8.3.0: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==