import { ProviderSettings , SecureStorageProvider , Tenant , AADResource , LoginResponse , Deferred , AzureAccount , Logger , MessageDisplayer , ErrorLookup , CachingProvider , RefreshTokenPostData , AuthorizationCodePostData , TokenPostData , AccountKey , StringLookup , DeviceCodeStartPostData , DeviceCodeCheckPostData , AzureAuthType , AccountType , UserInteraction } from '../models' ; import { AzureAuthError } from '../errors/AzureAuthError' ; import { ErrorCodes } from '../errors/errors' ; import axios , { AxiosRequestConfig , AxiosResponse } from 'axios' ; import { AccessToken , Token , TokenClaims , RefreshToken , OAuthTokenResponse } from '../models/auth' ; import * as qs from 'qs' ; import * as url from 'url' ; export abstract class AzureAuth { public static readonly ACCOUNT_VERSION = '2.0' ; protected readonly commonTenant : Tenant = { id : 'common' , displayName : 'common' } ; protected readonly clientId : string ; protected readonly loginEndpointUrl : string ; constructor ( protected readonly providerSettings , protected readonly secureStorage , protected readonly cachingProvider , protected readonly logger , protected readonly messageDisplayer , protected readonly errorLookup , protected readonly userInteraction , protected readonly stringLookup , protected readonly azureAuthType ) { this . clientId = providerSettings . clientId ; this . loginEndpointUrl = providerSettings . loginEndpoint ; } protected abstract async login ( tenant , resource ) ; public async startLogin ( ) { let loginComplete ; try { const result = await this . login ( this . commonTenant , this . providerSettings . resources . windowsManagementResource ) ; loginComplete = result ?. authComplete ; if ( ! result ?. response ) { this . logger . error ( 'Authentication failed' ) ; return undefined ; } const account = await this . hydrateAccount ( result . response . accessToken , result . response . tokenClaims ) ; loginComplete ?. resolve ( ) ; return account ; } catch ( ex ) { if ( ex instanceof AzureAuthError ) { loginComplete ?. reject ( ex . getPrintableString ( ) ) ; throw ex ; } this . logger . error ( ex ) ; return undefined ; } } public getHomeTenant ( account ) { return account . properties . tenants . find ( t => t . tenantCategory === 'Home' ) ?? account . properties . tenants [ 0 ] ?? this . commonTenant ; } public async refreshAccess ( account ) { if ( account . key . accountVersion !== AzureAuth . ACCOUNT_VERSION ) { account . delete = true ; return account ; } try { const tenant = this . getHomeTenant ( account ) ; const tokenResult = await this . getAccountSecurityToken ( account , tenant . id , this . providerSettings . resources . windowsManagementResource ) ; if ( ! tokenResult ) { account . isStale = true ; return account ; } const tokenClaims = this . getTokenClaims ( tokenResult . token ) ; if ( ! tokenClaims ) { account . isStale = true ; return account ; } return await this . hydrateAccount ( tokenResult , tokenClaims ) ; } catch ( ex ) { account . isStale = true ; throw ex ; } } public async hydrateAccount ( token , tokenClaims ) { const tenants = await this . getTenants ( { ... token } ) ; const account = this . createAccount ( tokenClaims , token . key , tenants ) ; return account ; } public async getAccountSecurityToken ( account , tenantId , azureResource ) { if ( account . isStale === true ) { this . logger . log ( 'Account was stale. No tokens being fetched.' ) ; return undefined ; } const tenant = account . properties . tenants . find ( t => t . id === tenantId ) ; if ( ! tenant ) { throw new AzureAuthError ( ErrorCodes . Tenant , this . errorLookup . getTenantNotFoundError ( { tenantId } ) , undefined ) ; } const cachedTokens = await this . getSavedToken ( tenant , azureResource , account . key ) ; if ( cachedTokens ?. accessToken ) { let expiry = Number ( cachedTokens . expiresOn ) ; if ( Number . isNaN ( expiry ) ) { this . logger . log ( 'Expiration time was not defined. This is expected on first launch' ) ; expiry = 0 ; } const currentTime = new Date ( ) . getTime ( ) / 1000 ; let accessToken = cachedTokens . accessToken ; let expiresOn = Number ( cachedTokens . expiresOn ) ; const remainingTime = expiry - currentTime ; const maxTolerance = 2 * 60 ; if ( remainingTime < maxTolerance ) { const result = await this . refreshToken ( tenant , azureResource , cachedTokens . refreshToken ) ; if ( ! result ) { return undefined ; } accessToken = result . accessToken ; expiresOn = Number ( result . expiresOn ) ; } if ( accessToken ) { return { ... accessToken , expiresOn : expiresOn , tokenType : 'Bearer' } ; } } const baseTokens = await this . getSavedToken ( this . commonTenant , this . providerSettings . resources . windowsManagementResource , account . key ) ; if ( ! baseTokens ) { this . logger . error ( 'User had no base tokens for the basic resource registered. This should not happen and indicates something went wrong with the authentication cycle' ) ; account . isStale = true ; throw new AzureAuthError ( ErrorCodes . AuthError , this . errorLookup . getSimpleError ( ErrorCodes . AuthError ) ) ; } const result = await this . refreshToken ( tenant , azureResource , baseTokens . refreshToken ) ; if ( result ?. accessToken ) { return { ... result . accessToken , expiresOn : Number ( result . expiresOn ) , tokenType : 'Bearer' } ; } return undefined ; } param tenant param resource param refreshToken public async refreshToken ( tenant , resource , refreshToken ) { if ( refreshToken ) { const postData = { grant_type : 'refresh_token' , client_id : this . clientId , refresh_token : refreshToken . token , tenant : tenant . id , resource : resource . endpoint } ; return this . getToken ( tenant , resource , postData ) ; } return this . handleInteractionRequired ( tenant , resource ) ; } public async getToken ( tenant , resource , postData ) { const tokenUrl = `${ this . loginEndpointUrl }${ tenant . id }/oauth2/token` ; const response = await this . makePostRequest ( tokenUrl , postData ) ; if ( response . data . error === 'interaction_required' ) { return this . handleInteractionRequired ( tenant , resource ) ; } if ( response . data . error ) { this . logger . error ( 'Response error!' , response . data ) ; throw new AzureAuthError ( ErrorCodes . TokenRetrieval , this . errorLookup . getSimpleError ( ErrorCodes . TokenRetrieval ) ) ; } const accessTokenString = response . data . access_token ; const refreshTokenString = response . data . refresh_token ; const expiresOnString = response . data . expires_on ; return this . getTokenHelper ( tenant , resource , accessTokenString , refreshTokenString , expiresOnString ) ; } public getUserKey ( tokenClaims ) { let userKey ; if ( tokenClaims . idp === 'live.com' ) { userKey = tokenClaims . unique_name ?? tokenClaims . email ?? tokenClaims . sub ; } else { userKey = tokenClaims . home_oid ?? tokenClaims . oid ?? tokenClaims . unique_name ?? tokenClaims . email ?? tokenClaims . sub ; } if ( ! userKey ) { this . logger . pii ( tokenClaims ) ; throw new AzureAuthError ( ErrorCodes . UserKey , this . errorLookup . getSimpleError ( ErrorCodes . UserKey ) ) ; } return userKey ; } public async getTokenHelper ( tenant , resource , accessTokenString , refreshTokenString , expiresOnString ) { if ( ! accessTokenString ) { throw new AzureAuthError ( ErrorCodes . NoAccessTokenReturned , this . errorLookup . getSimpleError ( ErrorCodes . NoAccessTokenReturned ) ) ; } const tokenClaims = this . getTokenClaims ( accessTokenString ) ; if ( ! tokenClaims ) { return undefined ; } const userKey = this . getUserKey ( tokenClaims ) ; if ( ! userKey ) { throw new AzureAuthError ( ErrorCodes . UniqueIdentifier , this . errorLookup . getSimpleError ( ErrorCodes . UniqueIdentifier ) ) ; } const accessToken = { token : accessTokenString , key : userKey } ; let refreshToken = undefined ; if ( refreshTokenString ) { refreshToken = { token : refreshTokenString , key : userKey } ; } const result = { accessToken , refreshToken , tokenClaims , expiresOn : expiresOnString } ; const accountKey = { providerId : this . providerSettings . id , id : userKey } ; await this . saveToken ( tenant , resource , accountKey , result ) ; return result ; } public async getTenants ( token ) { interface TenantResponse { id : string ; tenantId : string ; displayName ? : string ; tenantCategory ? : string ; } const tenantUri = url . resolve ( this . providerSettings . resources . azureManagementResource . endpoint , 'tenants?api-version=2019-11-01' ) ; try { const tenantResponse = await this . makeGetRequest ( tenantUri , token . token ) ; this . logger . pii ( 'getTenants' , tenantResponse . data ) ; const tenants = tenantResponse . data . value . map ( ( tenantInfo ) => { return { id : tenantInfo . tenantId , displayName : tenantInfo . displayName ?? tenantInfo . tenantId , userId : token . key , tenantCategory : tenantInfo . tenantCategory } as Tenant ; } ) ; const homeTenantIndex = tenants . findIndex ( tenant => tenant . tenantCategory === 'Home' ) ; if ( homeTenantIndex >= 0 ) { const homeTenant = tenants . splice ( homeTenantIndex , 1 ) ; tenants . unshift ( homeTenant [ 0 ] ) ; } return tenants ; } catch ( ex ) { throw new AzureAuthError ( ErrorCodes . Tenant , this . errorLookup . getSimpleError ( ErrorCodes . Tenant ) , ex ) ; } } private async saveToken ( tenant , resource , accountKey , { accessToken , refreshToken , expiresOn } ) { if ( ! tenant . id || ! resource . id ) { this . logger . pii ( 'Tenant ID or resource ID was undefined' , tenant , resource ) ; throw new AzureAuthError ( ErrorCodes . AddAccount , this . errorLookup . getSimpleError ( ErrorCodes . AddAccount ) ) ; } try { await this . cachingProvider . set ( `${ accountKey . id }_access_${ resource . id }_${ tenant . id }` , JSON . stringify ( accessToken ) ) ; await this . cachingProvider . set ( `${ accountKey . id }_refresh_${ resource . id }_${ tenant . id }` , JSON . stringify ( refreshToken ) ) ; await this . cachingProvider . set ( `${ accountKey . id }_${ tenant . id }_${ resource . id }` , expiresOn ) ; } catch ( ex ) { this . logger . error ( ex ) ; throw new AzureAuthError ( ErrorCodes . AddAccount , this . errorLookup . getSimpleError ( ErrorCodes . AddAccount ) ) ; } } public async getSavedToken ( tenant , resource , accountKey ) { if ( ! tenant . id || ! resource . id ) { this . logger . pii ( 'Tenant ID or resource ID was undefined' , tenant , resource ) ; throw new AzureAuthError ( ErrorCodes . GetAccount , this . errorLookup . getSimpleError ( ErrorCodes . GetAccount ) ) ; } let accessTokenString ; let refreshTokenString ; let expiresOn ; try { accessTokenString = await this . cachingProvider . get ( `${ accountKey . id }_access_${ resource . id }_${ tenant . id }` ) ; refreshTokenString = await this . cachingProvider . get ( `${ accountKey . id }_refresh_${ resource . id }_${ tenant . id }` ) ; expiresOn = await this . cachingProvider . get ( `${ accountKey . id }_${ tenant . id }_${ resource . id }` ) ; } catch ( ex ) { this . logger . error ( ex ) ; throw new AzureAuthError ( ErrorCodes . GetAccount , this . errorLookup . getSimpleError ( ErrorCodes . GetAccount ) ) ; } try { if ( ! accessTokenString ) { return undefined ; } const accessToken = JSON . parse ( accessTokenString ) ; let refreshToken ; if ( refreshTokenString ) { refreshToken = JSON . parse ( refreshTokenString ) ; } else { return undefined ; } return { accessToken , refreshToken , expiresOn } ; } catch ( ex ) { this . logger . error ( ex ) ; throw new AzureAuthError ( ErrorCodes . ParseAccount , this . errorLookup . getSimpleError ( ErrorCodes . ParseAccount ) ) ; } } public async deleteAccountCache ( accountKey ) { const results = await this . cachingProvider . findCredentials ( accountKey . id ) ; for ( let { account } of results ) { await this . cachingProvider . remove ( account ) ; } } public async handleInteractionRequired ( tenant , resource ) { const shouldOpen = await this . askUserForInteraction ( tenant , resource ) ; if ( shouldOpen ) { const result = await this . login ( tenant , resource ) ; result ?. authComplete ?. resolve ( ) ; return result ?. response ; } return undefined ; } param tenant param resource private async askUserForInteraction ( tenant , resource ) { return this . userInteraction . askForConsent ( this . stringLookup . getInteractionRequiredString ( { tenant , resource } ) ) ; } public createAccount ( tokenClaims , key , tenants ) { let accountType ; if ( tokenClaims ?. idp === 'live.com' ) { accountType = AccountType . Microsoft ; } else { accountType = AccountType . WorkSchool ; } const name = tokenClaims . name ?? tokenClaims . email ?? tokenClaims . unique_name ; const email = tokenClaims . email ?? tokenClaims . unique_name ; let displayName = name ; if ( email ) { displayName = `${ displayName } - ${ email }` ; } const account = { key : { providerId : this . providerSettings . id , id : key , accountVersion : AzureAuth . ACCOUNT_VERSION , } , displayInfo : { accountType , userId : key , displayName , email , name , } , properties : { providerSettings : this . providerSettings , isMsAccount : accountType === AccountType . Microsoft , tenants , azureAuthType : this . azureAuthType } , isStale : false } ; return account ; } public async makePostRequest ( url , postData ) { const config = { headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } , validateStatus : ( ) => true } ; const response = await axios . post ( url , qs . stringify ( postData ) , config ) ; this . logger . pii ( url , postData , response . data ) ; return response ; } private async makeGetRequest ( url , token ) { const config = { headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ token }` } , validateStatus : ( ) => true } ; const response = await axios . get ( url , config ) ; this . logger . pii ( url , response . data ) ; return response ; } protected getTokenClaims ( accessToken ) { try { const split = accessToken . split ( '.' ) ; return JSON . parse ( Buffer . from ( split [ 1 ] , 'base64' ) . toString ( 'binary' ) ) ; } catch ( ex ) { throw new Error ( 'Unable to read token claims: ' + JSON . stringify ( ex ) ) ; } } protected toBase64UrlEncoding ( base64string ) { return base64string . replace ( /=/g , '' ) . replace ( /\+/g , '-' ) . replace ( /\//g , '_' ) ; } public async deleteAllCache ( ) { await this . secureStorage . clear ( ) ; } }