diff --git a/build/PublishToCoveralls.ps1 b/build/PublishToCoveralls.ps1 index 788cb76af4..8f599aef27 100644 --- a/build/PublishToCoveralls.ps1 +++ b/build/PublishToCoveralls.ps1 @@ -9,7 +9,7 @@ Param( Write-Host Install tools $basePath = (get-item $pathToCoverageFiles ).parent.FullName -$coverageAnalyzer = "C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe" +$coverageAnalyzer = "C:\Program Files (x86)\Microsoft Visual Studio\2019\Enterprise\Team Tools\Dynamic Code Coverage Tools\CodeCoverage.exe" dotnet tool install coveralls.net --version 1.0.0 --tool-path tools $coverageUploader = ".\tools\csmacnz.Coveralls.exe" diff --git a/libraries/Microsoft.Bot.Builder/BotFrameworkAdapter.cs b/libraries/Microsoft.Bot.Builder/BotFrameworkAdapter.cs index 8433a51ce0..2e1806859a 100644 --- a/libraries/Microsoft.Bot.Builder/BotFrameworkAdapter.cs +++ b/libraries/Microsoft.Bot.Builder/BotFrameworkAdapter.cs @@ -54,6 +54,7 @@ public class BotFrameworkAdapter : BotAdapter, IAdapterIntegration, IUserTokenPr private readonly RetryPolicy _connectorClientRetryPolicy; private readonly ILogger _logger; private readonly ConcurrentDictionary _appCredentialMap = new ConcurrentDictionary(); + private readonly AuthenticationConfiguration _authConfiguration; // There is a significant boost in throughput if we reuse a connectorClient // _connectorClients is a cache using [serviceUrl + appId]. @@ -82,12 +83,42 @@ public BotFrameworkAdapter( HttpClient customHttpClient = null, IMiddleware middleware = null, ILogger logger = null) + : this(credentialProvider, new AuthenticationConfiguration(), channelProvider, connectorClientRetryPolicy, customHttpClient, middleware, logger) + { + } + + /// + /// Initializes a new instance of the class, + /// using a credential provider. + /// + /// The credential provider. + /// The authentication configuration. + /// The channel provider. + /// Retry policy for retrying HTTP operations. + /// The HTTP client. + /// The middleware to initially add to the adapter. + /// The ILogger implementation this adapter should use. + /// + /// is null. + /// Use a object to add multiple middleware + /// components in the constructor. Use the method to + /// add additional middleware to the adapter after construction. + /// + public BotFrameworkAdapter( + ICredentialProvider credentialProvider, + AuthenticationConfiguration authConfig, + IChannelProvider channelProvider = null, + RetryPolicy connectorClientRetryPolicy = null, + HttpClient customHttpClient = null, + IMiddleware middleware = null, + ILogger logger = null) { _credentialProvider = credentialProvider ?? throw new ArgumentNullException(nameof(credentialProvider)); _channelProvider = channelProvider; _httpClient = customHttpClient ?? _defaultHttpClient; _connectorClientRetryPolicy = connectorClientRetryPolicy; _logger = logger ?? NullLogger.Instance; + _authConfiguration = authConfig ?? throw new ArgumentNullException(nameof(authConfig)); if (middleware != null) { @@ -211,7 +242,7 @@ public async Task ProcessActivityAsync(string authHeader, Activi { BotAssert.ActivityNotNull(activity); - var claimsIdentity = await JwtTokenValidation.AuthenticateRequest(activity, authHeader, _credentialProvider, _channelProvider, _httpClient).ConfigureAwait(false); + var claimsIdentity = await JwtTokenValidation.AuthenticateRequest(activity, authHeader, _credentialProvider, _channelProvider, _authConfiguration, _httpClient).ConfigureAwait(false); return await ProcessActivityAsync(claimsIdentity, activity, callback, cancellationToken).ConfigureAwait(false); } diff --git a/libraries/Microsoft.Bot.Connector/Authentication/AuthenticationConfiguration.cs b/libraries/Microsoft.Bot.Connector/Authentication/AuthenticationConfiguration.cs new file mode 100644 index 0000000000..a54131481d --- /dev/null +++ b/libraries/Microsoft.Bot.Connector/Authentication/AuthenticationConfiguration.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Connector.Authentication +{ + /// + /// General configuration settings for authentication. + /// + /// + /// Note that this is explicitly a class and not an interface, + /// since interfaces don't support default values, after the initial release any change would break backwards compatibility. + /// + public class AuthenticationConfiguration + { + public string[] RequiredEndorsements { get; set; } = new string[] { }; + } +} diff --git a/libraries/Microsoft.Bot.Connector/Authentication/ChannelValidation.cs b/libraries/Microsoft.Bot.Connector/Authentication/ChannelValidation.cs index 8f368fa64c..596025d858 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/ChannelValidation.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/ChannelValidation.cs @@ -53,13 +53,39 @@ public static class ChannelValidation /// public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, HttpClient httpClient, string channelId) { + return await AuthenticateChannelToken(authHeader, credentials, httpClient, channelId, new AuthenticationConfiguration()).ConfigureAwait(false); + } + + /// + /// Validate the incoming Auth Header as a token sent from the Bot Framework Service. + /// + /// + /// A token issued by the Bot Framework emulator will FAIL this check. + /// + /// The raw HTTP header in the format: "Bearer [longString]". + /// The user defined set of valid credentials, such as the AppId. + /// Authentication of tokens requires calling out to validate Endorsements and related documents. The + /// HttpClient is used for making those calls. Those calls generally require TLS connections, which are expensive to + /// setup and teardown, so a shared HttpClient is recommended. + /// The ID of the channel to validate. + /// The authentication configuration. + /// + /// A valid ClaimsIdentity. + /// + public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, HttpClient httpClient, string channelId, AuthenticationConfiguration authConfig) + { + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + var tokenExtractor = new JwtTokenExtractor( httpClient, ToBotFromChannelTokenValidationParameters, OpenIdMetadataUrl, AuthenticationConstants.AllowedSigningAlgorithms); - var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId); + var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId, authConfig.RequiredEndorsements); if (identity == null) { // No valid identity. Not Authorized. @@ -118,7 +144,29 @@ public static async Task AuthenticateChannelToken(string authHea /// ClaimsIdentity. public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, string serviceUrl, HttpClient httpClient, string channelId) { - var identity = await AuthenticateChannelToken(authHeader, credentials, httpClient, channelId); + return await AuthenticateChannelToken(authHeader, credentials, serviceUrl, httpClient, channelId, new AuthenticationConfiguration()).ConfigureAwait(false); + } + + /// + /// Validate the incoming Auth Header as a token sent from the Bot Framework Service. + /// + /// The raw HTTP header in the format: "Bearer [longString]". + /// The user defined set of valid credentials, such as the AppId. + /// Service url. + /// Authentication of tokens requires calling out to validate Endorsements and related documents. The + /// HttpClient is used for making those calls. Those calls generally require TLS connections, which are expensive to + /// setup and teardown, so a shared HttpClient is recommended. + /// The ID of the channel to validate. + /// The authentication configuration. + /// ClaimsIdentity. + public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, string serviceUrl, HttpClient httpClient, string channelId, AuthenticationConfiguration authConfig) + { + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + + var identity = await AuthenticateChannelToken(authHeader, credentials, httpClient, channelId, authConfig); var serviceUrlClaim = identity.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.ServiceUrlClaim)?.Value; if (string.IsNullOrWhiteSpace(serviceUrlClaim)) diff --git a/libraries/Microsoft.Bot.Connector/Authentication/EmulatorValidation.cs b/libraries/Microsoft.Bot.Connector/Authentication/EmulatorValidation.cs index a800e403e1..7e75663269 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/EmulatorValidation.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/EmulatorValidation.cs @@ -111,6 +111,33 @@ public static bool IsTokenFromEmulator(string authHeader) /// public static async Task AuthenticateEmulatorToken(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, HttpClient httpClient, string channelId) { + return await AuthenticateEmulatorToken(authHeader, credentials, channelProvider, httpClient, channelId, new AuthenticationConfiguration()).ConfigureAwait(false); + } + + /// + /// Validate the incoming Auth Header as a token sent from the Bot Framework Emulator. + /// + /// The raw HTTP header in the format: "Bearer [longString]". + /// The user defined set of valid credentials, such as the AppId. + /// The channelService value that distinguishes public Azure from US Government Azure. + /// Authentication of tokens requires calling out to validate Endorsements and related documents. The + /// HttpClient is used for making those calls. Those calls generally require TLS connections, which are expensive to + /// setup and teardown, so a shared HttpClient is recommended. + /// The ID of the channel to validate. + /// The authentication configuration. + /// + /// A valid ClaimsIdentity. + /// + /// + /// A token issued by the Bot Framework will FAIL this check. Only Emulator tokens will pass. + /// + public static async Task AuthenticateEmulatorToken(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, HttpClient httpClient, string channelId, AuthenticationConfiguration authConfig) + { + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + var openIdMetadataUrl = (channelProvider != null && channelProvider.IsGovernment()) ? GovernmentAuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl : AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl; @@ -121,7 +148,7 @@ public static async Task AuthenticateEmulatorToken(string authHe openIdMetadataUrl, AuthenticationConstants.AllowedSigningAlgorithms); - var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId); + var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId, authConfig.RequiredEndorsements); if (identity == null) { // No valid identity. Not Authorized. diff --git a/libraries/Microsoft.Bot.Connector/Authentication/EndorsementsValidator.cs b/libraries/Microsoft.Bot.Connector/Authentication/EndorsementsValidator.cs index ce2572a289..aaf695698e 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/EndorsementsValidator.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/EndorsementsValidator.cs @@ -12,23 +12,28 @@ namespace Microsoft.Bot.Connector.Authentication public static class EndorsementsValidator { /// - /// Verify that a channel matches the endorsements found on the JWT token. + /// Verify that the specified endorsement exists on the JWT token. Call this method multiple times to validate multiple endorsements. /// For example, if an comes from WebChat, that activity's /// property is set to "webchat" and the signing party /// of the JWT token must have a corresponding endorsement of “Webchat”. /// - /// The ID of the channel to validate, typically extracted from the activity's - /// property, that to which the Activity is affinitized. + /// + /// JWT token signing keys contain endorsements matching the IDs of the channels they are approved to sign for. + /// They also contain keywords representing compliance certifications. This code ensures that a channel ID or compliance + /// certification is present on the signing key used for the request's token. + /// + /// The expected endorsement. Generally the ID of the channel to validate, typically extracted from the activity's + /// property, that to which the Activity is affinitized. Alternatively, it could represent a compliance certification that is required. /// The JWT token’s signing party is permitted to send activities only for /// specific channels. That list, the set of channels the service can sign for, is called the the endorsement list. /// The activity’s MUST be found in the endorsement list, or the incoming /// activity is not considered valid. /// True if the channel ID is found in the endorsements list; otherwise, false. - public static bool Validate(string channelId, HashSet endorsements) + public static bool Validate(string expectedEndorsement, HashSet endorsements) { // If the Activity came in and doesn't have a channel ID then it's making no // assertions as to who endorses it. This means it should pass. - if (string.IsNullOrEmpty(channelId)) + if (string.IsNullOrEmpty(expectedEndorsement)) { return true; } @@ -48,7 +53,7 @@ public static bool Validate(string channelId, HashSet endorsements) // JWTTokenExtractor // Does the set of endorsements match the channelId that was passed in? - var endorsementPresent = endorsements.Contains(channelId); + var endorsementPresent = endorsements.Contains(expectedEndorsement); return endorsementPresent; } } diff --git a/libraries/Microsoft.Bot.Connector/Authentication/EnterpriseChannelValidation.cs b/libraries/Microsoft.Bot.Connector/Authentication/EnterpriseChannelValidation.cs index 38d52dc327..b5bd04de21 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/EnterpriseChannelValidation.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/EnterpriseChannelValidation.cs @@ -42,6 +42,29 @@ public sealed class EnterpriseChannelValidation /// ClaimsIdentity. public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, string serviceUrl, HttpClient httpClient, string channelId) { + return await AuthenticateChannelToken(authHeader, credentials, channelProvider, serviceUrl, httpClient, channelId, new AuthenticationConfiguration()).ConfigureAwait(false); + } + + /// + /// Validate the incoming Auth Header as a token sent from a Bot Framework Channel Service. + /// + /// The raw HTTP header in the format: "Bearer [longString]". + /// The user defined set of valid credentials, such as the AppId. + /// The user defined configuration for the channel. + /// The service url from the request. + /// Authentication of tokens requires calling out to validate Endorsements and related documents. The + /// HttpClient is used for making those calls. Those calls generally require TLS connections, which are expensive to + /// setup and teardown, so a shared HttpClient is recommended. + /// The ID of the channel to validate. + /// The authentication configuration. + /// ClaimsIdentity. + public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, string serviceUrl, HttpClient httpClient, string channelId, AuthenticationConfiguration authConfig) + { + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + var channelService = await channelProvider.GetChannelServiceAsync().ConfigureAwait(false); var tokenExtractor = new JwtTokenExtractor( @@ -50,7 +73,7 @@ public static async Task AuthenticateChannelToken(string authHea string.Format(AuthenticationConstants.ToBotFromEnterpriseChannelOpenIdMetadataUrlFormat, channelService), AuthenticationConstants.AllowedSigningAlgorithms); - var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId).ConfigureAwait(false); + var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId, authConfig.RequiredEndorsements).ConfigureAwait(false); await ValidateIdentity(identity, credentials, serviceUrl).ConfigureAwait(false); diff --git a/libraries/Microsoft.Bot.Connector/Authentication/GovernmentChannelValidation.cs b/libraries/Microsoft.Bot.Connector/Authentication/GovernmentChannelValidation.cs index 50d76b70d1..70e77d6540 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/GovernmentChannelValidation.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/GovernmentChannelValidation.cs @@ -43,13 +43,35 @@ public sealed class GovernmentChannelValidation /// ClaimsIdentity. public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, string serviceUrl, HttpClient httpClient, string channelId) { + return await AuthenticateChannelToken(authHeader, credentials, serviceUrl, httpClient, channelId, new AuthenticationConfiguration()).ConfigureAwait(false); + } + + /// + /// Validate the incoming Auth Header as a token sent from a Bot Framework Government Channel Service. + /// + /// The raw HTTP header in the format: "Bearer [longString]". + /// The user defined set of valid credentials, such as the AppId. + /// The service url from the request. + /// Authentication of tokens requires calling out to validate Endorsements and related documents. The + /// HttpClient is used for making those calls. Those calls generally require TLS connections, which are expensive to + /// setup and teardown, so a shared HttpClient is recommended. + /// The ID of the channel to validate. + /// The authentication configuration. + /// ClaimsIdentity. + public static async Task AuthenticateChannelToken(string authHeader, ICredentialProvider credentials, string serviceUrl, HttpClient httpClient, string channelId, AuthenticationConfiguration authConfig) + { + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + var tokenExtractor = new JwtTokenExtractor( httpClient, ToBotFromGovernmentChannelTokenValidationParameters, OpenIdMetadataUrl, AuthenticationConstants.AllowedSigningAlgorithms); - var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId).ConfigureAwait(false); + var identity = await tokenExtractor.GetIdentityAsync(authHeader, channelId, authConfig.RequiredEndorsements).ConfigureAwait(false); await ValidateIdentity(identity, credentials, serviceUrl).ConfigureAwait(false); diff --git a/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenExtractor.cs b/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenExtractor.cs index d506d0a1e6..01baec684c 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenExtractor.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenExtractor.cs @@ -61,7 +61,11 @@ public class JwtTokenExtractor /// tokenValidationParameters. /// metadataUrl. /// allowedSigningAlgorithms. - public JwtTokenExtractor(HttpClient httpClient, TokenValidationParameters tokenValidationParameters, string metadataUrl, HashSet allowedSigningAlgorithms) + public JwtTokenExtractor( + HttpClient httpClient, + TokenValidationParameters tokenValidationParameters, + string metadataUrl, + HashSet allowedSigningAlgorithms) { // Make our own copy so we can edit it _tokenValidationParameters = tokenValidationParameters.Clone(); @@ -80,7 +84,44 @@ public JwtTokenExtractor(HttpClient httpClient, TokenValidationParameters tokenV }); } + /// + /// Initializes a new instance of the class. + /// Extracts relevant data from JWT Tokens. + /// + /// As part of validating JWT Tokens, endorsements need to be feteched from + /// sources specified by the relevant security URLs. This HttpClient is used to allow for resource + /// pooling around those retrievals. As those resources require TLS sharing the HttpClient is + /// important to overall perfomance. + /// tokenValidationParameters. + /// metadataUrl. + /// allowedSigningAlgorithms. + /// Custom endorsement configuration to be used by the JwtTokenExtractor. + public JwtTokenExtractor( + HttpClient httpClient, + TokenValidationParameters tokenValidationParameters, + string metadataUrl, + HashSet allowedSigningAlgorithms, + ConfigurationManager>> customEndorsementsConfig) + { + // Make our own copy so we can edit it + _tokenValidationParameters = tokenValidationParameters.Clone(); + _tokenValidationParameters.RequireSignedTokens = true; + _allowedSigningAlgorithms = allowedSigningAlgorithms; + + _openIdMetadata = _openIdMetadataCache.GetOrAdd(metadataUrl, key => + { + return new ConfigurationManager(metadataUrl, new OpenIdConnectConfigurationRetriever(), httpClient); + }); + + _endorsementsData = customEndorsementsConfig ?? throw new ArgumentNullException(nameof(customEndorsementsConfig)); + } + public async Task GetIdentityAsync(string authorizationHeader, string channelId) + { + return await GetIdentityAsync(authorizationHeader, channelId, new string[] { }).ConfigureAwait(false); + } + + public async Task GetIdentityAsync(string authorizationHeader, string channelId, string[] requiredEndorsements) { if (authorizationHeader == null) { @@ -90,7 +131,7 @@ public async Task GetIdentityAsync(string authorizationHeader, s string[] parts = authorizationHeader?.Split(' '); if (parts.Length == 2) { - return await GetIdentityAsync(parts[0], parts[1], channelId).ConfigureAwait(false); + return await GetIdentityAsync(parts[0], parts[1], channelId, requiredEndorsements).ConfigureAwait(false); } return null; @@ -98,6 +139,16 @@ public async Task GetIdentityAsync(string authorizationHeader, s public async Task GetIdentityAsync(string scheme, string parameter, string channelId) { + return await GetIdentityAsync(scheme, parameter, channelId, new string[] { }).ConfigureAwait(false); + } + + public async Task GetIdentityAsync(string scheme, string parameter, string channelId, string[] requiredEndorsements) + { + if (requiredEndorsements == null) + { + throw new ArgumentNullException(nameof(requiredEndorsements)); + } + // No header in correct scheme or no token if (scheme != "Bearer" || string.IsNullOrEmpty(parameter)) { @@ -112,7 +163,7 @@ public async Task GetIdentityAsync(string scheme, string paramet try { - var claimsPrincipal = await ValidateTokenAsync(parameter, channelId).ConfigureAwait(false); + var claimsPrincipal = await ValidateTokenAsync(parameter, channelId, requiredEndorsements).ConfigureAwait(false); return claimsPrincipal.Identities.OfType().FirstOrDefault(); } catch (Exception e) @@ -124,7 +175,13 @@ public async Task GetIdentityAsync(string scheme, string paramet private bool HasAllowedIssuer(string jwtToken) { + if (!_tokenValidationParameters.ValidateIssuer) + { + return true; + } + JwtSecurityToken token = new JwtSecurityToken(jwtToken); + if (_tokenValidationParameters.ValidIssuer != null && _tokenValidationParameters.ValidIssuer == token.Issuer) { return true; @@ -140,6 +197,16 @@ private bool HasAllowedIssuer(string jwtToken) private async Task ValidateTokenAsync(string jwtToken, string channelId) { + return await ValidateTokenAsync(jwtToken, channelId, new string[] { }).ConfigureAwait(false); + } + + private async Task ValidateTokenAsync(string jwtToken, string channelId, string[] requiredEndorsements) + { + if (requiredEndorsements == null) + { + throw new ArgumentNullException(nameof(requiredEndorsements)); + } + // _openIdMetadata only does a full refresh when the cache expires every 5 days OpenIdConnectConfiguration config = null; try @@ -161,6 +228,7 @@ private async Task ValidateTokenAsync(string jwtToken, string c _tokenValidationParameters.IssuerSigningKeys = config.SigningKeys; var tokenHandler = new JwtSecurityTokenHandler(); + try { var principal = tokenHandler.ValidateToken(jwtToken, _tokenValidationParameters, out SecurityToken parsedToken); @@ -175,11 +243,22 @@ private async Task ValidateTokenAsync(string jwtToken, string c // below won't run. This is normal. if (!string.IsNullOrEmpty(keyId) && endorsements.TryGetValue(keyId, out var endorsementsForKey)) { + // Verify that channelId is included in endorsements var isEndorsed = EndorsementsValidator.Validate(channelId, endorsementsForKey); + if (!isEndorsed) { throw new UnauthorizedAccessException($"Could not validate endorsement for key: {keyId} with endorsements: {string.Join(",", endorsementsForKey)}"); } + + // Verify that additional endorsements are satisfied. If no additional endorsements are expected, the requirement is satisfied as well + var additionalEndorsementsSatisfied = requiredEndorsements.All( + endorsement => EndorsementsValidator.Validate(endorsement, endorsementsForKey)); + + if (!additionalEndorsementsSatisfied) + { + throw new UnauthorizedAccessException($"Could not validate additional endorsement for key: {keyId} with endorsements: {string.Join(",", endorsementsForKey)}. Expected endorsements: {string.Join(",", requiredEndorsements)}"); + } } if (_allowedSigningAlgorithms != null) diff --git a/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenValidation.cs b/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenValidation.cs index c52157cac7..63d40d79f1 100644 --- a/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenValidation.cs +++ b/libraries/Microsoft.Bot.Connector/Authentication/JwtTokenValidation.cs @@ -32,6 +32,29 @@ public static class JwtTokenValidation /// identity for the request. public static async Task AuthenticateRequest(IActivity activity, string authHeader, ICredentialProvider credentials, IChannelProvider provider, HttpClient httpClient = null) { + return await AuthenticateRequest(activity, authHeader, credentials, provider, new AuthenticationConfiguration(), httpClient); + } + + /// + /// Authenticates the request and add's the activity's + /// to the set of trusted URLs. + /// + /// The activity. + /// The authentication header. + /// The bot's credential provider. + /// The bot's channel service provider. + /// The optional authentication configuration. + /// The HTTP client. + /// A task that represents the work queued to execute. + /// If the task completes successfully, the result contains the claims-based + /// identity for the request. + public static async Task AuthenticateRequest(IActivity activity, string authHeader, ICredentialProvider credentials, IChannelProvider provider, AuthenticationConfiguration authConfig, HttpClient httpClient = null) + { + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + if (string.IsNullOrWhiteSpace(authHeader)) { bool isAuthDisabled = await credentials.IsAuthenticationDisabledAsync().ConfigureAwait(false); @@ -47,7 +70,7 @@ public static async Task AuthenticateRequest(IActivity activity, throw new UnauthorizedAccessException(); } - var claimsIdentity = await ValidateAuthHeader(authHeader, credentials, provider, activity.ChannelId, activity.ServiceUrl, httpClient ?? _httpClient); + var claimsIdentity = await ValidateAuthHeader(authHeader, credentials, provider, activity.ChannelId, authConfig, activity.ServiceUrl, httpClient ?? _httpClient); MicrosoftAppCredentials.TrustServiceUrl(activity.ServiceUrl); @@ -67,12 +90,35 @@ public static async Task AuthenticateRequest(IActivity activity, /// If the task completes successfully, the result contains the claims-based /// identity for the request. public static async Task ValidateAuthHeader(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, string channelId, string serviceUrl = null, HttpClient httpClient = null) + { + return await ValidateAuthHeader(authHeader, credentials, channelProvider, channelId, new AuthenticationConfiguration(), serviceUrl, httpClient).ConfigureAwait(false); + } + + /// + /// Validates the authentication header of an incoming request. + /// + /// The authentication header to validate. + /// The bot's credential provider. + /// The bot's channel service provider. + /// The ID of the channel that sent the request. + /// The authentication configuration. + /// The service URL for the activity. + /// The HTTP client. + /// A task that represents the work queued to execute. + /// If the task completes successfully, the result contains the claims-based + /// identity for the request. + public static async Task ValidateAuthHeader(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, string channelId, AuthenticationConfiguration authConfig, string serviceUrl = null, HttpClient httpClient = null) { if (string.IsNullOrEmpty(authHeader)) { throw new ArgumentNullException(nameof(authHeader)); } + if (authConfig == null) + { + throw new ArgumentNullException(nameof(authConfig)); + } + bool usingEmulator = EmulatorValidation.IsTokenFromEmulator(authHeader); if (usingEmulator) @@ -84,20 +130,20 @@ public static async Task ValidateAuthHeader(string authHeader, I // No empty or null check. Empty can point to issues. Null checks only. if (serviceUrl != null) { - return await ChannelValidation.AuthenticateChannelToken(authHeader, credentials, serviceUrl, httpClient ?? _httpClient, channelId); + return await ChannelValidation.AuthenticateChannelToken(authHeader, credentials, serviceUrl, httpClient ?? _httpClient, channelId, authConfig); } else { - return await ChannelValidation.AuthenticateChannelToken(authHeader, credentials, httpClient ?? _httpClient, channelId); + return await ChannelValidation.AuthenticateChannelToken(authHeader, credentials, httpClient ?? _httpClient, channelId, authConfig); } } else if (channelProvider.IsGovernment()) { - return await GovernmentChannelValidation.AuthenticateChannelToken(authHeader, credentials, serviceUrl, httpClient ?? _httpClient, channelId).ConfigureAwait(false); + return await GovernmentChannelValidation.AuthenticateChannelToken(authHeader, credentials, serviceUrl, httpClient ?? _httpClient, channelId, authConfig).ConfigureAwait(false); } else { - return await EnterpriseChannelValidation.AuthenticateChannelToken(authHeader, credentials, channelProvider, serviceUrl, httpClient ?? _httpClient, channelId).ConfigureAwait(false); + return await EnterpriseChannelValidation.AuthenticateChannelToken(authHeader, credentials, channelProvider, serviceUrl, httpClient ?? _httpClient, channelId, authConfig).ConfigureAwait(false); } } } diff --git a/tests/Microsoft.Bot.Connector.Tests/Authentication/JwtTokenExtractorTests.cs b/tests/Microsoft.Bot.Connector.Tests/Authentication/JwtTokenExtractorTests.cs new file mode 100644 index 0000000000..54b8e1bca0 --- /dev/null +++ b/tests/Microsoft.Bot.Connector.Tests/Authentication/JwtTokenExtractorTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Schema; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Xunit; + +namespace Microsoft.Bot.Connector.Authentication.Tests +{ + public class TestConfigurationRetriever : IConfigurationRetriever>> + { + public readonly Dictionary> EndorsementTable = new Dictionary>(); + + public async Task>> GetConfigurationAsync(string address, IDocumentRetriever retriever, CancellationToken cancel) + { + return EndorsementTable; + } + } + + public class JwtTokenExtractorTests + { + private readonly HttpClient client; + private readonly HttpClient emptyClient; + + private const string keyId = "CtfQC8Le-8NsC7oC2zQkZpcrfOc"; + private const string testChannelName = "testChannel"; + private const string complianceEndorsement = "o365Compliant"; + private const string randomEndorsement = "2112121212"; + + public JwtTokenExtractorTests() + { + ChannelValidation.ToBotFromChannelTokenValidationParameters.ValidateLifetime = false; + ChannelValidation.ToBotFromChannelTokenValidationParameters.ValidateIssuer = false; + + client = new HttpClient + { + BaseAddress = new Uri("https://webchat.botframework.com/"), + }; + emptyClient = new HttpClient(); + } + + [Fact] + public async Task Connector_TokenExtractor_NullRequiredEndorsements_ShouldFail() + { + var configRetriever = new TestConfigurationRetriever(); + + configRetriever.EndorsementTable.Add(keyId, new HashSet() { randomEndorsement, complianceEndorsement, testChannelName}); + await Assert.ThrowsAsync(async () => await RunTestCase(configRetriever)); + } + + [Fact] + public async Task Connector_TokenExtractor_EmptyRequireEndorsements_ShouldValidate() + { + var configRetriever = new TestConfigurationRetriever(); + + configRetriever.EndorsementTable.Add(keyId, new HashSet() { randomEndorsement, complianceEndorsement, testChannelName }); + var claimsIdentity = await RunTestCase(configRetriever, new string[] { }); + Assert.True(claimsIdentity.IsAuthenticated); + } + + [Fact] + public async Task Connector_TokenExtractor_RequiredEndorsementsPresent_ShouldValidate() + { + var configRetriever = new TestConfigurationRetriever(); + + configRetriever.EndorsementTable.Add(keyId, new HashSet() { randomEndorsement, complianceEndorsement, testChannelName }); + var claimsIdentity = await RunTestCase(configRetriever, new string[] { complianceEndorsement }); + Assert.True(claimsIdentity.IsAuthenticated); + } + + [Fact] + public async Task Connector_TokenExtractor_RequiredEndorsementsPartiallyPresent_ShouldNotValidate() + { + var configRetriever = new TestConfigurationRetriever(); + + configRetriever.EndorsementTable.Add(keyId, new HashSet() { randomEndorsement, complianceEndorsement, testChannelName }); + await Assert.ThrowsAsync(async () => await RunTestCase(configRetriever, new string[] { complianceEndorsement, "notSatisfiedEndorsement" })); + } + + private async Task RunTestCase(IConfigurationRetriever>> configRetriever, string[] requiredEndorsements = null) + { + var tokenExtractor = new JwtTokenExtractor( + emptyClient, + EmulatorValidation.ToBotFromEmulatorTokenValidationParameters, + AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl, + AuthenticationConstants.AllowedSigningAlgorithms, + new ConfigurationManager>>("http://test", configRetriever)); + + string header = $"Bearer {await new MicrosoftAppCredentials("2cd87869-38a0-4182-9251-d056e8f0ac24", "2.30Vs3VQLKt974F").GetTokenAsync()}"; + + return await tokenExtractor.GetIdentityAsync(header, "testChannel", requiredEndorsements); + } + } +}