diff --git a/Authentication/AadAuthenticationFactory.cs b/Authentication/AadAuthenticationFactory.cs index d7fd13c..43fd85b 100644 --- a/Authentication/AadAuthenticationFactory.cs +++ b/Authentication/AadAuthenticationFactory.cs @@ -1,6 +1,7 @@ using Microsoft.Identity.Client; using System; using System.Linq; +using System.Net; using System.Net.Http; using System.Reflection; using System.Security; @@ -37,11 +38,7 @@ public class AadAuthenticationFactory public string[] Scopes {get {return _scopes;}} private readonly string[] _scopes; - /// - /// Authentication mode for public client flows - /// - public AuthenticationMode AuthMode { get {return _authMode;}} - private readonly AuthenticationMode _authMode; + //type of auth flow to use private readonly AuthenticationFlow _flow; /// @@ -50,14 +47,21 @@ public class AadAuthenticationFactory public string UserName { get { return _userNameHint; } } private readonly string _userNameHint; + /// + /// Password for ROPC flow + /// + private readonly SecureString _resourceOwnerPassword; + private readonly IPublicClientApplication _publicClientApplication; private readonly IConfidentialClientApplication _confidentialClientApplication; private readonly ManagedIdentityClientApplication _managedIdentityClientApplication; + private readonly string _defaultClientId = "1950a258-227b-4e31-a9cf-717495945fc2"; + /// /// Creates factory that supporrts Public client flows with Interactive or DeviceCode authentication /// /// DNS name or Id of tenant that authenticates user - /// ClientId to use + /// ClientId to use. If not specified, clientId of Azure Powershell is used /// List of scopes that clients asks for /// AAD endpoint that will handle the authentication. /// Type of public client flow to use @@ -70,7 +74,11 @@ public AadAuthenticationFactory( AuthenticationMode authenticationMode = AuthenticationMode.Interactive, string userNameHint = null) { - _clientId = clientId; + if (string.IsNullOrWhiteSpace(clientId)) + _clientId = _defaultClientId; + else + _clientId = clientId; + _loginApi = loginApi; _scopes = scopes; _userNameHint = userNameHint; @@ -149,33 +157,69 @@ public AadAuthenticationFactory( var builder = ConfidentialClientApplicationBuilder.Create(_clientId) .WithCertificate(clientCertificate) - .WithAuthority($"{_loginApi}/{tenantId}"); + .WithAuthority($"{_loginApi}/{tenantId}") + .WithHttpClientFactory(new GcMsalHttpClientFactory()); _confidentialClientApplication = builder.Build(); } /// - /// Creates factory that supports ManagedIdentity authentication + /// Creates factory that supports UserAssignedIdentity authentication with provided client id /// - /// Required scopes to obtain. Currently obtains all assigned scopes for first resource in the array of scopes. - public AadAuthenticationFactory(string[] scopes) + /// AppId of User Assigned Identity + /// Required scopes to obtain. Currently obtains all assigned scopes for first resource in the array. + public AadAuthenticationFactory(string clientId, string[] scopes) { _scopes = scopes; - _managedIdentityClientApplication = new ManagedIdentityClientApplication(new GcMsalHttpClientFactory()); - _flow = AuthenticationFlow.ManagedIdentity; + if (!string.IsNullOrWhiteSpace(clientId)) + { + _clientId = clientId; + } + else + { + _clientId=null; + } + _managedIdentityClientApplication = new ManagedIdentityClientApplication(new GcMsalHttpClientFactory(), _clientId); + _flow = AuthenticationFlow.UserAssignedIdentity; } /// - /// Creates factory that supports UserAssignedIdentity authentication with provided client id + /// Creates factory that supporrts Public client ROPC flow /// - /// AppId of User Assigned Identity - /// Required scopes to obtain. Currently obtains all assigned scopes for first resource in the array. - public AadAuthenticationFactory(string clientId, string[] scopes) + /// DNS name or Id of tenant that authenticates user + /// ClientId to use + /// List of scopes that clients asks for + /// AAD endpoint that will handle the authentication. + /// Resource owner username and password + /// Resource owner password + public AadAuthenticationFactory( + string tenantId, + string clientId, + string[] scopes, + string userName, + SecureString password, + string loginApi = "https://login.microsoftonline.com" + ) { + if (string.IsNullOrWhiteSpace(clientId)) + _clientId = _defaultClientId; + else + _clientId = clientId; + + _loginApi = loginApi; _scopes = scopes; - _clientId = clientId; - _managedIdentityClientApplication = new ManagedIdentityClientApplication(new GcMsalHttpClientFactory(), clientId); - _flow = AuthenticationFlow.UserAssignedIdentity; + _userNameHint = userName; + _resourceOwnerPassword = password; + _tenantId = tenantId; + + _flow = AuthenticationFlow.ResourceOwnerPassword; + + var builder = PublicClientApplicationBuilder.Create(_clientId) + .WithDefaultRedirectUri() + .WithAuthority($"{_loginApi}/{tenantId}") + .WithHttpClientFactory(new GcMsalHttpClientFactory()); + + _publicClientApplication = builder.Build(); } /// @@ -184,10 +228,12 @@ public AadAuthenticationFactory(string clientId, string[] scopes) /// /// Authentication result object either returned fropm MSAL libraries, or - for ManagedIdentity - constructed from Managed Identity endpoint response, as returned by cref="ManagedIdentityClientApplication.ApiVersion" version of endpoint /// Throws if unsupported authentication mode or flow detected - public async Task AuthenticateAsync() + public async Task AuthenticateAsync(string[] requiredScopes = null) { using CancellationTokenSource cts = new(TimeSpan.FromMinutes(2)); AuthenticationResult result; + if (null == requiredScopes) + requiredScopes = _scopes; switch(_flow) { case AuthenticationFlow.PublicClientWithWia: @@ -200,7 +246,7 @@ public async Task AuthenticateAsync() account = accounts.Where(x => string.Compare(x.Username, _userNameHint, true) == 0).FirstOrDefault(); if (null!=account) { - result = await _publicClientApplication.AcquireTokenSilent(_scopes, account) + result = await _publicClientApplication.AcquireTokenSilent(requiredScopes, account) .ExecuteAsync(); } else @@ -222,12 +268,12 @@ public async Task AuthenticateAsync() account = accounts.Where(x => string.Compare(x.Username, _userNameHint, true) == 0).FirstOrDefault(); try { - result = await _publicClientApplication.AcquireTokenSilent(_scopes, account) + result = await _publicClientApplication.AcquireTokenSilent(requiredScopes, account) .ExecuteAsync(cts.Token); } catch (MsalUiRequiredException) { - result = await _publicClientApplication.AcquireTokenInteractive(_scopes).ExecuteAsync(cts.Token); + result = await _publicClientApplication.AcquireTokenInteractive(requiredScopes).ExecuteAsync(cts.Token); } return result; } @@ -241,12 +287,12 @@ public async Task AuthenticateAsync() account = accounts.Where(x => string.Compare(x.Username, _userNameHint, true) == 0).FirstOrDefault(); try { - result = await _publicClientApplication.AcquireTokenSilent(_scopes, account) + result = await _publicClientApplication.AcquireTokenSilent(requiredScopes, account) .ExecuteAsync(cts.Token); } catch (MsalUiRequiredException) { - result = await _publicClientApplication.AcquireTokenWithDeviceCode(_scopes, callback => + result = await _publicClientApplication.AcquireTokenWithDeviceCode(requiredScopes, callback => { Console.WriteLine(callback.Message); return Task.FromResult(0); @@ -255,12 +301,34 @@ public async Task AuthenticateAsync() return result; } case AuthenticationFlow.ConfidentialClient: - return await _confidentialClientApplication.AcquireTokenForClient(_scopes).ExecuteAsync(cts.Token); + return await _confidentialClientApplication.AcquireTokenForClient(requiredScopes).ExecuteAsync(cts.Token); + //System Managed identity case AuthenticationFlow.ManagedIdentity: - return await _managedIdentityClientApplication.AcquireTokenForClientAsync(_scopes, cts.Token); + return await _managedIdentityClientApplication.AcquireTokenForClientAsync(requiredScopes, cts.Token); + //User managed identity case AuthenticationFlow.UserAssignedIdentity: - return await _managedIdentityClientApplication.AcquireTokenForClientAsync(_scopes, cts.Token); + return await _managedIdentityClientApplication.AcquireTokenForClientAsync(requiredScopes, cts.Token); + //ROPC flow + case AuthenticationFlow.ResourceOwnerPassword: + { + var accounts = await _publicClientApplication.GetAccountsAsync(); + IAccount account; + if (string.IsNullOrWhiteSpace(_userNameHint)) + account = accounts.FirstOrDefault(); + else + account = accounts.Where(x => string.Compare(x.Username, _userNameHint, true) == 0).FirstOrDefault(); + try + { + result = await _publicClientApplication.AcquireTokenSilent(requiredScopes, account) + .ExecuteAsync(cts.Token); + } + catch (MsalUiRequiredException) + { + result = await _publicClientApplication.AcquireTokenByUsernamePassword(requiredScopes, _userNameHint, _resourceOwnerPassword).ExecuteAsync(cts.Token); + } + return result; + } } throw new ArgumentException($"Unsupported authentication flow: {_flow}"); diff --git a/Authentication/Authentication.csproj b/Authentication/Authentication.csproj index 8884aca..8ccc0e8 100644 --- a/Authentication/Authentication.csproj +++ b/Authentication/Authentication.csproj @@ -7,7 +7,7 @@ netstandard2.0 GreyCorbel.Identity.Authentication GreyCorbel.Identity.Authentication - 1.1.1 + 1.2.0 Jiri Formacek GreyCorbel Solutions Unified AAD Authentication client library for Public, Confidential and ManagedIdentity client authentication diff --git a/Authentication/Enums.cs b/Authentication/Enums.cs index 88a018b..1562979 100644 --- a/Authentication/Enums.cs +++ b/Authentication/Enums.cs @@ -29,6 +29,7 @@ enum AuthenticationFlow PublicClientWithWia, ConfidentialClient, ManagedIdentity, - UserAssignedIdentity + UserAssignedIdentity, + ResourceOwnerPassword } } \ No newline at end of file diff --git a/Authentication/ManagedIdentityClientApplication.cs b/Authentication/ManagedIdentityClientApplication.cs index fb5c20b..1d803d0 100644 --- a/Authentication/ManagedIdentityClientApplication.cs +++ b/Authentication/ManagedIdentityClientApplication.cs @@ -16,10 +16,9 @@ class ManagedIdentityClientApplication:TokenProvider { ITokenProvider _tokenProvider = null; - AuthenticationResult _cachedToken = null; + Dictionary _cachedTokens = new Dictionary(StringComparer.InvariantCultureIgnoreCase); private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); - public ManagedIdentityClientApplication(IMsalHttpClientFactory factory, string clientId = null) :base(factory, clientId) { @@ -34,19 +33,23 @@ public ManagedIdentityClientApplication(IMsalHttpClientFactory factory, string c public override async Task AcquireTokenForClientAsync(string[] scopes, CancellationToken cancellationToken) { await _lock.WaitAsync().ConfigureAwait(false); - try { - if (null == _cachedToken || _cachedToken.ExpiresOn.UtcDateTime < DateTime.UtcNow.AddSeconds(-_ticketOverlapSeconds)) + string resource = ScopeHelper.ScopeToResource(scopes); + if(! _cachedTokens.ContainsKey(resource) || _cachedTokens[resource].ExpiresOn.UtcDateTime < DateTime.UtcNow.AddSeconds(-_ticketOverlapSeconds)) { if (null != _tokenProvider) { - _cachedToken = await _tokenProvider.AcquireTokenForClientAsync(scopes, cancellationToken).ConfigureAwait(false); + _cachedTokens[resource] = await _tokenProvider.AcquireTokenForClientAsync(scopes, cancellationToken).ConfigureAwait(false); } else throw new InvalidOperationException("Token provider not initialized"); } - return _cachedToken; + return _cachedTokens[resource]; + } + catch(Exception ex) + { + throw ex; } finally { diff --git a/Authentication/TokenProviders/AppServiceTokenProvider.cs b/Authentication/TokenProviders/AppServiceTokenProvider.cs index b7949d8..4d09cf4 100644 --- a/Authentication/TokenProviders/AppServiceTokenProvider.cs +++ b/Authentication/TokenProviders/AppServiceTokenProvider.cs @@ -39,7 +39,7 @@ public override async Task AcquireTokenForClientAsync(stri HttpRequestMessage CreateRequestMessage(string[] scopes) { - using HttpRequestMessage message = new HttpRequestMessage(); + HttpRequestMessage message = new HttpRequestMessage(); message.Method = HttpMethod.Get; StringBuilder sb= new StringBuilder(IdentityEndpoint); message.Headers.Add(SecretHeaderName, IdentityHeader); diff --git a/Authentication/TokenProviders/VMIdentityTokenProvider.cs b/Authentication/TokenProviders/VMIdentityTokenProvider.cs index 1af864d..5e276ff 100644 --- a/Authentication/TokenProviders/VMIdentityTokenProvider.cs +++ b/Authentication/TokenProviders/VMIdentityTokenProvider.cs @@ -40,7 +40,7 @@ public override async Task AcquireTokenForClientAsync(stri HttpRequestMessage CreateRequestMessage(string[] scopes) { - using HttpRequestMessage message = new HttpRequestMessage(); + HttpRequestMessage message = new HttpRequestMessage(); message.Method = HttpMethod.Get; StringBuilder sb = new StringBuilder("http://169.254.169.254/metadata/identity/oauth2/token"); diff --git a/Module/AadAuthenticationFactory/AadAuthenticationFactory.psd1 b/Module/AadAuthenticationFactory/AadAuthenticationFactory.psd1 index 413964f..52cd447 100644 Binary files a/Module/AadAuthenticationFactory/AadAuthenticationFactory.psd1 and b/Module/AadAuthenticationFactory/AadAuthenticationFactory.psd1 differ diff --git a/Module/AadAuthenticationFactory/AadAuthenticationFactory.psm1 b/Module/AadAuthenticationFactory/AadAuthenticationFactory.psm1 index 1e37b22..9d91c2b 100644 --- a/Module/AadAuthenticationFactory/AadAuthenticationFactory.psm1 +++ b/Module/AadAuthenticationFactory/AadAuthenticationFactory.psm1 @@ -25,15 +25,16 @@ This command returns AAD authentication factory for Public client auth flow with [Parameter(Mandatory,ParameterSetName = 'ConfidentialClientWithSecret')] [Parameter(Mandatory,ParameterSetName = 'ConfidentialClientWithCertificate')] [Parameter(Mandatory,ParameterSetName = 'PublicClient')] + [Parameter(Mandatory,ParameterSetName = 'ResourceOwnerPasssword')] [string] #Id of tenant where to autenticate the user. Can be tenant id, or any registerd DNS domain $TenantId, [Parameter()] [string] - #ClientId of application that gets token to CosmosDB. - #Default: well-known clientId for Azure PowerShell - it already has pre-configured Delegated permission to access CosmosDB resource - $ClientId = $script:DefaultClientId, + #ClientId of application that gets token + #Default: well-known clientId for Azure PowerShell + $ClientId, [Parameter(Mandatory)] [string[]] @@ -46,6 +47,13 @@ This command returns AAD authentication factory for Public client auth flow with #Used to get access as application rather than as calling user $ClientSecret, + [Parameter(ParameterSetName = 'ResourceOwnerPasssword')] + [pscredential] + #Resource Owner username and password + #Used to get access as user + #Note: Does not work for federated authentication + $ResourceOwnerCredential, + [Parameter(ParameterSetName = 'ConfidentialClientWithCertificate')] [System.Security.Cryptography.X509Certificates.X509Certificate2] #Authentication certificate for ClientID @@ -55,20 +63,23 @@ This command returns AAD authentication factory for Public client auth flow with [Parameter(ParameterSetName = 'ConfidentialClientWithSecret')] [Parameter(ParameterSetName = 'ConfidentialClientWithCertificate')] [Parameter(ParameterSetName = 'PublicClient')] + [Parameter(ParameterSetName = 'ResourceOwnerPasssword')] [string] #AAD auth endpoint #Default: endpoint for public cloud $LoginApi = 'https://login.microsoftonline.com', [Parameter(Mandatory, ParameterSetName = 'PublicClient')] - [ValidateSet('Interactive', 'DeviceCode')] + [ValidateSet('Interactive', 'DeviceCode', 'WIA')] [string] - #How to authenticate client - via web view or via device code flow + #How to authenticate client - via web view, via device code flow, or via Windows Integrated Auth + #Used in public client flows $AuthMode, [Parameter(ParameterSetName = 'PublicClient')] [string] #Username hint for authentication UI + #Optional $UserNameHint, [Parameter(ParameterSetName = 'MSI')] @@ -94,14 +105,11 @@ This command returns AAD authentication factory for Public client auth flow with break; } 'MSI' { - if([string]::IsNullOrEmpty($ClientId) -or $ClientId -eq $script:DefaultClientId) - { - $script:AadLastCreatedFactory = new-object GreyCorbel.Identity.Authentication.AadAuthenticationFactory($RequiredScopes) - } - else - { - $script:AadLastCreatedFactory = new-object GreyCorbel.Identity.Authentication.AadAuthenticationFactory($ClientId, $RequiredScopes) - } + $script:AadLastCreatedFactory = new-object GreyCorbel.Identity.Authentication.AadAuthenticationFactory($ClientId, $RequiredScopes) + break; + } + 'ResourceOwnerPasssword' { + $script:AadLastCreatedFactory = new-object GreyCorbel.Identity.Authentication.AadAuthenticationFactory($tenantId, $ClientId, $RequiredScopes, $ResourceOwnerCredential.UserName, $ResourceOwnerCredential.Password, $LoginApi) break; } } @@ -136,13 +144,18 @@ Command creates authentication factory and retrieves AAD token from it [Parameter(ValueFromPipeline)] [GreyCorbel.Identity.Authentication.AadAuthenticationFactory] #AAD authentication factory created via New-AadAuthenticationFactory - $Factory = $script:AadLastCreatedFactory + $Factory = $script:AadLastCreatedFactory, + [Parameter()] + #Scopes to be returned in the token. + #If not specified, returns scopes provided when creating the factory + [string[]]$Scopes = $null ) process { try { - $task = $factory.AuthenticateAsync() + #I don't know how to support Ctrl+Break + $task = $factory.AuthenticateAsync($scopes) $task.GetAwaiter().GetResult() } catch [System.OperationCanceledException] { @@ -196,6 +209,7 @@ Command creates authentication factory, asks it to issue token for EventGrid and IsValid = $false } + #validate the result using published keys $endpoint = $result.Payload.iss.Replace('/v2.0','/') $signingKeys = Invoke-RestMethod -Method Get -Uri "$($endpoint)discovery/keys" @@ -288,7 +302,6 @@ function Init Add-Type -Path "$PSScriptRoot\Shared\netstandard2.0\GreyCorbel.Identity.Authentication.dll" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $script:DefaultClientId = '1950a258-227b-4e31-a9cf-717495945fc2' } } #endregion diff --git a/Module/AadAuthenticationFactory/Readme.md b/Module/AadAuthenticationFactory/Readme.md index 3a729e9..bff6c9a 100644 --- a/Module/AadAuthenticationFactory/Readme.md +++ b/Module/AadAuthenticationFactory/Readme.md @@ -12,7 +12,7 @@ Module comes with commands: |Get-AAdToken|Tells the factory to create a token. Factory returns cached token, if available, and takes care of token renewals silently whenever possible, after tokens expire| |Test-AadToken|Parses Access token or Id token and validates it against published keys. Provides PowerShell way of showing token content as available in http://jwt.ms| -Module is meant to provide instant authentication services to other modules relying on AAD authentication just make dependency on AadAuthenticationFactory module inother module and use it to get tokens for resources as you need. This is demonstrated by CosmosLite module in this repo. +Module is meant to provide instant authentication services to other modules relying on AAD authentication just make dependency on AadAuthenticationFactory module in other module and use it to get tokens for resources as you need. This is demonstrated by CosmosLite module in this repo. # Examples @@ -71,3 +71,14 @@ This sample assumes that code runs in environment supporting Azure Managed ident $azConfigFactory = New-AadAuthenticationfactory -RequiredScopes 'https://azconfig.io/.default' -UseManagedIdentity -ClientId '3a174b1e-7b2a-4f21-a326-90365ff741cf' Get-AadToken | Select-object -expandProperty AccessToken | Test-AadToken | select-object -expandProperty payload ``` + +## Resource Owner Password Credential flow +This sample uses ROPC to get token to access Graph API + +```powershell +$creds = Get-Credential +$graphFactory = New-AadAuthenticationFactory -TenantId 'mytenant.com' -ClientId $graphApiClientId -ResourceOwnerCredential $creds -RequiredScopes 'https://graph.microsoft.com/.default' +$graphToken = Get-AadToken -Factory $graphFactory + +``` + diff --git a/Module/AadAuthenticationFactory/Shared/netstandard2.0/GreyCorbel.Identity.Authentication.xml b/Module/AadAuthenticationFactory/Shared/netstandard2.0/GreyCorbel.Identity.Authentication.xml index b367510..bb4b51c 100644 --- a/Module/AadAuthenticationFactory/Shared/netstandard2.0/GreyCorbel.Identity.Authentication.xml +++ b/Module/AadAuthenticationFactory/Shared/netstandard2.0/GreyCorbel.Identity.Authentication.xml @@ -29,14 +29,14 @@ Scopes the factory asks for when asking for tokens - + - Authentication mode for public client flows + UserName hint to use in authentication flows to help select proper user. Useful in case multiple accounts are logged in. - + - UserName hint to use in authentication flows to help select proper user. Useful in case multiple accounts are logged in. + Password for ROPC flow @@ -44,7 +44,7 @@ Creates factory that supporrts Public client flows with Interactive or DeviceCode authentication DNS name or Id of tenant that authenticates user - ClientId to use + ClientId to use. If not specified, clientId of Azure Powershell is used List of scopes that clients asks for AAD endpoint that will handle the authentication. Type of public client flow to use @@ -70,12 +70,6 @@ Scopes application asks for AAD endpoint URL for special instance of AAD (/e.g. US Gov) - - - Creates factory that supports ManagedIdentity authentication - - Required scopes to obtain. Currently obtains all assigned scopes for first resource in the array of scopes. - Creates factory that supports UserAssignedIdentity authentication with provided client id @@ -83,7 +77,18 @@ AppId of User Assigned Identity Required scopes to obtain. Currently obtains all assigned scopes for first resource in the array. - + + + Creates factory that supporrts Public client ROPC flow + + DNS name or Id of tenant that authenticates user + ClientId to use + List of scopes that clients asks for + AAD endpoint that will handle the authentication. + Resource owner username and password + Resource owner password + + Returns authentication result Microsoft says we should not instantiate directly - but how to achieve unified experience of caller without being able to return it? @@ -106,6 +111,11 @@ DeviceCode flow with authentication performed with code on different device + + + Windows Integrated Authentication - supported on machines joined to AD, or hybrid joined + + Type of client we use for auth diff --git a/Module/CosmosLite/CosmosLite.psd1 b/Module/CosmosLite/CosmosLite.psd1 index d95f523..a405160 100644 Binary files a/Module/CosmosLite/CosmosLite.psd1 and b/Module/CosmosLite/CosmosLite.psd1 differ diff --git a/Module/CosmosLite/CosmosLite.psm1 b/Module/CosmosLite/CosmosLite.psm1 index 5cf492f..505c081 100644 --- a/Module/CosmosLite/CosmosLite.psm1 +++ b/Module/CosmosLite/CosmosLite.psm1 @@ -456,7 +456,7 @@ This command replaces field 'content' in root of the document with ID '123' and $rq.Uri = new-object System.Uri($uri) $rq.Payload = @{ operations = $Updates - } | ConvertTo-Json + } | ConvertTo-Json -Depth 99 $rq.ContentType = 'application/json_patch+json' ProcessRequestWithRetryInternal -rq $rq } @@ -833,7 +833,7 @@ function FormatCosmosResponseInternal $retVal.Data = ($s | ConvertFrom-Json -ErrorAction Stop) } catch { - throw new-object System.FormatException("InvalidPayloadReceived: $s") + throw new-object System.FormatException("InvalidJsonPayloadReceived. Error: $($_.Exception.Message)`nPayload: $s") } } return $retVal