2024-09-11 02:37:36 +00:00
/ * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
* 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 , ServerError } 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 { ScopeData } from '../common/scopeData' ;
2024-09-24 04:46:08 +00:00
import { EventBufferer } from '../common/event' ;
2024-09-11 02:37:36 +00:00
const redirectUri = 'https://vscode.dev/redirect' ;
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 ;
2024-09-24 04:46:08 +00:00
private readonly _eventBufferer = new EventBufferer ( ) ;
2024-09-11 02:37:36 +00:00
/ * *
* Event to signal a change in authentication sessions for this provider .
* /
private readonly _onDidChangeSessionsEmitter = new EventEmitter < AuthenticationProviderAuthenticationSessionsChangeEvent > ( ) ;
/ * *
* 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 ,
2024-09-24 04:46:08 +00:00
this . _env . name
2024-09-11 02:37:36 +00:00
) ;
2024-09-24 04:46:08 +00:00
const accountChangeEvent = this . _eventBufferer . wrapEvent (
this . _publicClientManager . onDidAccountsChange ,
( last , newEvent ) = > {
if ( ! last ) {
return newEvent ;
}
const mergedEvent = {
added : [ . . . ( last . added ? ? [ ] ) , . . . ( newEvent . added ? ? [ ] ) ] ,
deleted : [ . . . ( last . deleted ? ? [ ] ) , . . . ( newEvent . deleted ? ? [ ] ) ] ,
changed : [ . . . ( last . changed ? ? [ ] ) , . . . ( newEvent . changed ? ? [ ] ) ]
} ;
const dedupedEvent = {
added : Array.from ( new Map ( mergedEvent . added . map ( item = > [ item . username , item ] ) ) . values ( ) ) ,
deleted : Array.from ( new Map ( mergedEvent . deleted . map ( item = > [ item . username , item ] ) ) . values ( ) ) ,
changed : Array.from ( new Map ( mergedEvent . changed . map ( item = > [ item . username , item ] ) ) . values ( ) )
} ;
2024-09-11 02:37:36 +00:00
2024-09-24 04:46:08 +00:00
return dedupedEvent ;
} ,
{ added : new Array < AccountInfo > ( ) , deleted : new Array < AccountInfo > ( ) , changed : new Array < AccountInfo > ( ) }
) ( e = > this . _handleAccountChange ( e ) ) ;
this . _disposables . push (
this . _onDidChangeSessionsEmitter ,
this . _publicClientManager ,
accountChangeEvent
) ;
2024-09-11 02:37:36 +00:00
}
async initialize ( ) : Promise < void > {
2024-09-24 04:46:08 +00:00
await this . _eventBufferer . bufferEventsAsync ( ( ) = > this . _publicClientManager . initialize ( ) ) ;
2024-09-11 02:37:36 +00:00
// 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
* /
2024-09-24 04:46:08 +00:00
private _handleAccountChange ( { added , changed , deleted } : { added : AccountInfo [ ] ; changed : AccountInfo [ ] ; deleted : AccountInfo [ ] } ) {
this . _logger . debug ( ` [_handleAccountChange] added: ${ added . length } , changed: ${ changed . length } , deleted: ${ deleted . length } ` ) ;
this . _onDidChangeSessionsEmitter . fire ( {
added : added.map ( this . sessionFromAccountInfo ) ,
changed : changed.map ( this . sessionFromAccountInfo ) ,
removed : deleted.map ( this . sessionFromAccountInfo )
2024-09-11 02:37:36 +00:00
} ) ;
}
//#region AuthenticationProvider methods
async getSessions ( scopes : string [ ] | undefined , options? : AuthenticationGetSessionOptions ) : Promise < AuthenticationSession [ ] > {
2024-09-24 04:46:08 +00:00
const askingForAll = scopes === undefined ;
2024-09-11 02:37:36 +00:00
const scopeData = new ScopeData ( scopes ) ;
2024-09-24 04:46:08 +00:00
// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
this . _logger . info ( '[getSessions]' , askingForAll ? '[all]' : ` [ ${ scopeData . scopeStr } ] ` , 'starting' ) ;
2024-09-11 02:37:36 +00:00
2024-09-24 04:46:08 +00:00
// This branch only gets called by Core for sign out purposes and initial population of the account menu. Since we are
// living in a world where a "session" from Core's perspective is an account, we return 1 session per account.
// See the large comment on `onDidChangeSessions` for more information.
if ( askingForAll ) {
const allSessionsForAccounts = new Map < string , AuthenticationSession > ( ) ;
2024-09-11 02:37:36 +00:00
for ( const cachedPca of this . _publicClientManager . getAll ( ) ) {
2024-09-24 04:46:08 +00:00
for ( const account of cachedPca . accounts ) {
if ( allSessionsForAccounts . has ( account . homeAccountId ) ) {
continue ;
}
allSessionsForAccounts . set ( account . homeAccountId , this . sessionFromAccountInfo ( account ) ) ;
}
2024-09-11 02:37:36 +00:00
}
2024-09-24 04:46:08 +00:00
const allSessions = Array . from ( allSessionsForAccounts . values ( ) ) ;
this . _logger . info ( '[getSessions] [all]' , ` returned ${ allSessions . length } session(s) ` ) ;
2024-09-11 02:37:36 +00:00
return allSessions ;
}
const cachedPca = await this . getOrCreatePublicClientApplication ( scopeData . clientId , scopeData . tenant ) ;
const sessions = await this . getAllSessionsForPca ( cachedPca , scopeData . originalScopes , scopeData . scopesToSend , options ? . account ) ;
2024-09-24 04:46:08 +00:00
this . _logger . info ( ` [getSessions] [ ${ scopeData . scopeStr } ] returned ${ sessions . length } session(s) ` ) ;
2024-09-11 02:37:36 +00:00
return sessions ;
}
async createSession ( scopes : readonly string [ ] ) : Promise < AuthenticationSession > {
const scopeData = new ScopeData ( scopes ) ;
// Do NOT use `scopes` beyond this place in the code. Use `scopeData` instead.
2024-09-24 04:46:08 +00:00
this . _logger . info ( '[createSession]' , ` [ ${ scopeData . scopeStr } ] ` , 'starting' ) ;
2024-09-11 02:37:36 +00:00
const cachedPca = await this . getOrCreatePublicClientApplication ( scopeData . clientId , scopeData . tenant ) ;
2024-09-24 04:46:08 +00:00
let result : AuthenticationResult | undefined ;
// Currently, the http://localhost redirect URI is only in the AzureCloud environment... even though I did make the change in the SovereignCloud environments...
// TODO: Remove this check when the change is in all environments.
let useLoopBack = this . _env !== Environment . AzureCloud && scopeData . clientId === 'aebc6443-996d-45c2-90f0-388ff96faa56' ;
if ( ! useLoopBack ) {
try {
result = await cachedPca . acquireTokenInteractive ( {
openBrowser : async ( url : string ) = > { await env . openExternal ( Uri . parse ( url ) ) ; } ,
scopes : scopeData.scopesToSend ,
// The logic for rendering one or the other of these templates is in the
// template itself, so we pass the same one for both.
successTemplate : loopbackTemplate ,
errorTemplate : loopbackTemplate
} ) ;
} 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 ;
}
}
// This error comes from the backend and is likely not due to the user's machine
// failing to open a port or something local that would require us to try the
// URL handler loopback client.
if ( e instanceof ServerError ) {
2024-09-11 02:37:36 +00:00
this . _telemetryReporter . sendLoginFailedEvent ( ) ;
throw e ;
}
2024-09-24 04:46:08 +00:00
// The user wants to try the loopback client or we got an error likely due to spinning up the server
useLoopBack = true ;
2024-09-11 02:37:36 +00:00
}
2024-09-24 04:46:08 +00:00
}
if ( useLoopBack ) {
const loopbackClient = new UriHandlerLoopbackClient ( this . _uriHandler , redirectUri , this . _logger ) ;
2024-09-11 02:37:36 +00:00
try {
result = await cachedPca . acquireTokenInteractive ( {
openBrowser : ( url : string ) = > loopbackClient . openBrowser ( url ) ,
scopes : scopeData.scopesToSend ,
loopbackClient
} ) ;
} catch ( e ) {
this . _telemetryReporter . sendLoginFailedEvent ( ) ;
throw e ;
}
}
2024-09-24 04:46:08 +00:00
if ( ! result ) {
this . _telemetryReporter . sendLoginFailedEvent ( ) ;
throw new Error ( 'No result returned from MSAL' ) ;
}
const session = this . sessionFromAuthenticationResult ( result , scopeData . originalScopes ) ;
2024-09-11 02:37:36 +00:00
this . _telemetryReporter . sendLoginEvent ( session . scopes ) ;
2024-09-24 04:46:08 +00:00
this . _logger . info ( '[createSession]' , ` [ ${ scopeData . scopeStr } ] ` , 'returned session' ) ;
// This is the only scenario in which we need to fire the _onDidChangeSessionsEmitter out of band...
// the badge flow (when the client passes no options in to getSession) will only remove a badge if a session
// was created that _matches the scopes_ that that badge requests. See `onDidChangeSessions` for more info.
// TODO: This should really be fixed in Core.
2024-09-11 02:37:36 +00:00
this . _onDidChangeSessionsEmitter . fire ( { added : [ session ] , changed : [ ] , removed : [ ] } ) ;
return session ;
}
async removeSession ( sessionId : string ) : Promise < void > {
this . _logger . info ( '[removeSession]' , sessionId , 'starting' ) ;
2024-09-24 04:46:08 +00:00
const promises = new Array < Promise < void > > ( ) ;
2024-09-11 02:37:36 +00:00
for ( const cachedPca of this . _publicClientManager . getAll ( ) ) {
const accounts = cachedPca . accounts ;
for ( const account of accounts ) {
if ( account . homeAccountId === sessionId ) {
this . _telemetryReporter . sendLogoutEvent ( ) ;
2024-09-24 04:46:08 +00:00
promises . push ( cachedPca . removeAccount ( account ) ) ;
this . _logger . info ( ` [removeSession] [ ${ sessionId } ] [ ${ cachedPca . clientId } ] [ ${ cachedPca . authority } ] removing session... ` ) ;
2024-09-11 02:37:36 +00:00
}
}
}
2024-09-24 04:46:08 +00:00
if ( ! promises . length ) {
this . _logger . info ( '[removeSession]' , sessionId , 'session not found' ) ;
return ;
}
const results = await Promise . allSettled ( promises ) ;
for ( const result of results ) {
if ( result . status === 'rejected' ) {
this . _telemetryReporter . sendLogoutFailedEvent ( ) ;
this . _logger . error ( '[removeSession]' , sessionId , 'error removing session' , result . reason ) ;
}
}
this . _logger . info ( '[removeSession]' , sessionId , ` attempted to remove ${ promises . length } sessions ` ) ;
2024-09-11 02:37:36 +00:00
}
//#endregion
private async getOrCreatePublicClientApplication ( clientId : string , tenant : string ) : Promise < ICachedPublicClientApplication > {
const authority = new URL ( tenant , this . _env . activeDirectoryEndpointUrl ) . toString ( ) ;
return await this . _publicClientManager . getOrCreate ( clientId , authority ) ;
}
private async getAllSessionsForPca (
cachedPca : ICachedPublicClientApplication ,
originalScopes : readonly string [ ] ,
scopesToSend : string [ ] ,
accountFilter? : AuthenticationSessionAccountInformation
) : Promise < AuthenticationSession [ ] > {
const accounts = accountFilter
? cachedPca . accounts . filter ( a = > a . homeAccountId === accountFilter . id )
: cachedPca . accounts ;
const sessions : AuthenticationSession [ ] = [ ] ;
2024-09-24 04:46:08 +00:00
return this . _eventBufferer . bufferEventsAsync ( async ( ) = > {
for ( const account of accounts ) {
try {
const result = await cachedPca . acquireTokenSilent ( { account , scopes : scopesToSend , redirectUri } ) ;
sessions . push ( this . sessionFromAuthenticationResult ( result , originalScopes ) ) ;
} catch ( e ) {
// If we can't get a token silently, the account is probably in a bad state so we should skip it
// MSAL will log this already, so we don't need to log it again
continue ;
}
2024-09-11 02:37:36 +00:00
}
2024-09-24 04:46:08 +00:00
return sessions ;
} ) ;
2024-09-11 02:37:36 +00:00
}
2024-09-24 04:46:08 +00:00
private sessionFromAuthenticationResult ( result : AuthenticationResult , scopes : readonly string [ ] ) : AuthenticationSession & { idToken : string } {
2024-09-11 02:37:36 +00:00
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
} ;
}
2024-09-24 04:46:08 +00:00
private sessionFromAccountInfo ( account : AccountInfo ) : AuthenticationSession {
return {
accessToken : '1234' ,
id : account.homeAccountId ,
scopes : [ ] ,
account : {
id : account.homeAccountId ,
label : account.username
} ,
idToken : account.idToken ,
} ;
}
2024-09-11 02:37:36 +00:00
}