Skip to content

Commit

Permalink
feat: Add Ase channel validation. (#6718)
Browse files Browse the repository at this point in the history
* fixUSGovSingleTenant

* Add UT

* AseChannelValidation

* Add UT

* Rollback AuthTenant Property Name

* The Ctor do contains the old ones, Add Ctor to ApiCompatBaseline
  • Loading branch information
fangyangci authored Dec 19, 2023
1 parent 822ae31 commit 744e5ef
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Tokens;

namespace Microsoft.Bot.Connector.Authentication
{
/// <summary>
/// Validates and Examines JWT tokens from the AseChannel.
/// </summary>
[Obsolete("Use `ConfigurationBotFrameworkAuthentication` instead to perform AseChannel validation.", false)]
public static class AseChannelValidation
{
/// <summary>
/// Just used for app service extension v2 (independent app service).
/// </summary>
public const string ChannelId = "AseChannel";

/// <summary>
/// TO BOT FROM AseChannel: Token validation parameters when connecting to a channel.
/// </summary>
public static readonly TokenValidationParameters BetweenBotAndAseChannelTokenValidationParameters =
new TokenValidationParameters()
{
ValidateIssuer = true,

// Audience validation takes place manually in code.
ValidateAudience = false, // lgtm[cs/web/missing-token-validation]
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(5),
RequireSignedTokens = true,
};

private static string _metadataUrl;
private static ICredentialProvider _credentialProvider;
private static HttpClient _authHttpClient = new HttpClient();

/// <summary>
/// Set up user issue/metadataUrl for AseChannel validation.
/// </summary>
/// <param name="configuration">App Configurations, will GetSection MicrosoftAppId/MicrosoftAppTenantId/ChannelService.</param>
public static void Init(IConfiguration configuration)
{
var appId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value;
var tenantId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppTenantIdKey)?.Value;

var channelService = configuration.GetSection("ChannelService")?.Value;

_credentialProvider = new SimpleCredentialProvider(appId, string.Empty);
_metadataUrl = new SimpleChannelProvider(channelService).IsGovernment()
? GovernmentAuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl
: AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl;

var tenantIds = new string[]
{
tenantId,
"f8cdef31-a31e-4b4a-93e4-5f571e91255a", // US Gov MicrosoftServices.onmicrosoft.us
"d6d49420-f39b-4df7-a1dc-d59a935871db" // Public botframework.com
};

var validIssuers = new HashSet<string>();
foreach (var tmpId in tenantIds)
{
validIssuers.Add($"https://sts.windows.net/{tmpId}/"); // Auth Public/US Gov, 1.0 token
validIssuers.Add($"https://login.microsoftonline.com/{tmpId}/v2.0"); // Auth Public, 2.0 token
validIssuers.Add($"https://login.microsoftonline.us/{tmpId}/v2.0"); // Auth for US Gov, 2.0 token
}

BetweenBotAndAseChannelTokenValidationParameters.ValidIssuers = validIssuers;
}

/// <summary>
/// Determines if a request from AseChannel.
/// </summary>
/// <param name="channelId">need to be same with ChannelId.</param>
/// <returns>True, if the token was issued by the AseChannel. Otherwise, false.</returns>
public static bool IsAseChannel(string channelId)
{
return channelId == ChannelId;
}

/// <summary>
/// Validate the incoming Auth Header as a token sent from the AseChannel.
/// </summary>
/// <param name="authHeader">The raw HTTP header in the format: "Bearer [longString]".</param>
/// <param name="httpClient">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.</param>
/// <returns>
/// A valid ClaimsIdentity.
/// </returns>
public static async Task<ClaimsIdentity> AuthenticateAseTokenAsync(
string authHeader,
HttpClient httpClient = default)
{
httpClient = httpClient ?? _authHttpClient;

return await AuthenticateAseTokenAsync(authHeader, httpClient, new AuthenticationConfiguration()).ConfigureAwait(false);
}

/// <summary>
/// Validate the incoming Auth Header as a token sent from the AseChannel.
/// </summary>
/// <param name="authHeader">The raw HTTP header in the format: "Bearer [longString]".</param>
/// <param name="httpClient">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.</param>
/// <param name="authConfig">The authentication configuration.</param>
/// <returns>
/// A valid ClaimsIdentity.
/// </returns>
public static async Task<ClaimsIdentity> AuthenticateAseTokenAsync(string authHeader, HttpClient httpClient, AuthenticationConfiguration authConfig)
{
if (authConfig == null)
{
throw new ArgumentNullException(nameof(authConfig));
}

var tokenExtractor = new JwtTokenExtractor(
httpClient,
BetweenBotAndAseChannelTokenValidationParameters,
_metadataUrl,
AuthenticationConstants.AllowedSigningAlgorithms);

var identity = await tokenExtractor.GetIdentityAsync(authHeader, ChannelId, authConfig.RequiredEndorsements).ConfigureAwait(false);
if (identity == null)
{
// No valid identity. Not Authorized.
throw new UnauthorizedAccessException("Invalid Identity");
}

if (!identity.IsAuthenticated)
{
// The token is in some way invalid. Not Authorized.
throw new UnauthorizedAccessException("Token Not Authenticated");
}

// Now check that the AppID in the claimset matches
// what we're looking for. Note that in a multi-tenant bot, this value
// comes from developer code that may be reaching out to a service, hence the
// Async validation.
Claim versionClaim = identity.Claims.FirstOrDefault(c => c.Type == AuthenticationConstants.VersionClaim);
if (versionClaim == null)
{
throw new UnauthorizedAccessException("'ver' claim is required on AseChannel Tokens.");
}

string tokenVersion = versionClaim.Value;
string appID = string.Empty;

// The AseChannel, depending on Version, sends the AppId via either the
// appid claim (Version 1) or the Authorized Party claim (Version 2).
if (string.IsNullOrWhiteSpace(tokenVersion) || tokenVersion == "1.0")
{
// either no Version or a version of "1.0" means we should look for
// the claim in the "appid" claim.
Claim appIdClaim = identity.Claims.FirstOrDefault(c => c.Type == AuthenticationConstants.AppIdClaim);
if (appIdClaim == null)
{
// No claim around AppID. Not Authorized.
throw new UnauthorizedAccessException("'appid' claim is required on AseChannel Token version '1.0'.");
}

appID = appIdClaim.Value;
}
else if (tokenVersion == "2.0")
{
// AseChannel, "2.0" puts the AppId in the "azp" claim.
Claim appZClaim = identity.Claims.FirstOrDefault(c => c.Type == AuthenticationConstants.AuthorizedParty);
if (appZClaim == null)
{
// No claim around AppID. Not Authorized.
throw new UnauthorizedAccessException("'azp' claim is required on AseChannel Token version '2.0'.");
}

appID = appZClaim.Value;
}
else
{
// Unknown Version. Not Authorized.
throw new UnauthorizedAccessException($"Unknown AseChannel Token version '{tokenVersion}'.");
}

if (!await _credentialProvider.IsValidAppIdAsync(appID).ConfigureAwait(false))
{
await Console.Out.WriteLineAsync(appID).ConfigureAwait(false);
}

return identity;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ public static class AuthenticationConstants
/// </summary>
public const string ToBotFromEmulatorOpenIdMetadataUrl = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration";

/// <summary>
/// TO BOT FROM AseChannel: OpenID metadata document for tokens coming from MSA.
/// </summary>
public const string ToBotFromAseChannelOpenIdMetadataUrl = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration";

/// <summary>
/// TO BOT FROM ENTERPRISE CHANNEL: OpenID metadata document for tokens coming from MSA.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,10 @@ public static class GovernmentAuthenticationConstants
/// TO BOT FROM GOVERNMENT EMULATOR: OpenID metadata document for tokens coming from MSA.
/// </summary>
public const string ToBotFromEmulatorOpenIdMetadataUrl = "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0/.well-known/openid-configuration";

/// <summary>
/// TO BOT FROM GOVERNMENT AseChannel: OpenID metadata document for tokens coming from MSA.
/// </summary>
public const string ToBotFromAseChannelOpenIdMetadataUrl = "https://login.microsoftonline.us/cab8a31a-1906-4287-a0d8-4eef66b95f6e/v2.0/.well-known/openid-configuration";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ internal static bool IsValidTokenFormat(string authHeader)
/// </summary>
private static async Task<ClaimsIdentity> AuthenticateTokenAsync(string authHeader, ICredentialProvider credentials, IChannelProvider channelProvider, string channelId, AuthenticationConfiguration authConfig, string serviceUrl, HttpClient httpClient)
{
if (AseChannelValidation.IsAseChannel(channelId))
{
return await AseChannelValidation.AuthenticateAseTokenAsync(authHeader, httpClient, authConfig).ConfigureAwait(false);
}

if (SkillValidation.IsSkillToken(authHeader))
{
return await SkillValidation.AuthenticateChannelToken(authHeader, credentials, channelProvider, httpClient, channelId, authConfig).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ private async Task JwtTokenValidation_ValidateClaimsAsync(IEnumerable<Claim> cla

private async Task<ClaimsIdentity> JwtTokenValidation_AuthenticateTokenAsync(string authHeader, string channelId, string serviceUrl, CancellationToken cancellationToken)
{
if (AseChannelValidation.IsAseChannel(channelId))
{
return await AseChannelValidation.AuthenticateAseTokenAsync(authHeader).ConfigureAwait(false);
}

if (SkillValidation.IsSkillToken(authHeader))
{
return await SkillValidation_AuthenticateChannelTokenAsync(authHeader, channelId, cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public class ConfigurationBotFrameworkAuthentication : BotFrameworkAuthenticatio
/// <param name="logger">The ILogger instance to use.</param>
public ConfigurationBotFrameworkAuthentication(IConfiguration configuration, ServiceClientCredentialsFactory credentialsFactory = null, AuthenticationConfiguration authConfiguration = null, IHttpClientFactory httpClientFactory = null, ILogger logger = null)
{
AseChannelValidation.Init(configuration);

var channelService = configuration.GetSection("ChannelService")?.Value;
var validateAuthority = configuration.GetSection("ValidateAuthority")?.Value;
var toChannelFromBotLoginUrl = configuration.GetSection("ToChannelFromBotLoginUrl")?.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public async Task JwtTokenExtractor_WithExpiredToken_ShouldThrowUnauthorizedAcce
}
}

private static Task<ClaimsIdentity> BuildExtractorAndValidateToken(X509Certificate2 cert, TokenValidationParameters validationParameters = null, DateTime? expires = null, DateTime? notBefore = null)
private static async Task<ClaimsIdentity> BuildExtractorAndValidateToken(X509Certificate2 cert, TokenValidationParameters validationParameters = null, DateTime? expires = null, DateTime? notBefore = null)
{
// Custom validation parameters that allow us to test the extractor logic
var tokenValidationParams = validationParameters ?? CreateTokenValidationParameters(cert);
Expand All @@ -123,8 +123,7 @@ private static Task<ClaimsIdentity> BuildExtractorAndValidateToken(X509Certifica
AuthenticationConstants.AllowedSigningAlgorithms);

var token = CreateTokenForCertificate(cert, expires, notBefore);

return tokenExtractor.GetIdentityAsync($"Bearer {token}", "test");
return await tokenExtractor.GetIdentityAsync($"Bearer {token}", "test");
}

private static string CreateTokenForCertificate(X509Certificate2 cert, DateTime? expires = null, DateTime? notBefore = null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.Bot.Connector.Authentication;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Configuration;
using Moq;
using Xunit;

Expand Down Expand Up @@ -512,6 +514,59 @@ public async Task ValidateClaimsTest_DoesNotThrow_WhenNotSkillClaim_WithNullVali
await JwtTokenValidation.ValidateClaimsAsync(new AuthenticationConfiguration(), claims);
}

[Fact]
public void ValidationMetadataUrlTest_AseChannel_USGov()
{
var configMock = new Mock<IConfiguration>();
var configSectionMockChannelService = new Mock<IConfigurationSection>();
configSectionMockChannelService.Setup(o => o.Value).Returns(GovernmentAuthenticationConstants.ChannelService);
configMock.Setup(c => c.GetSection("ChannelService")).Returns(configSectionMockChannelService.Object);
AseChannelValidation.Init(configMock.Object);

Assert.Equal(GovernmentAuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl, typeof(AseChannelValidation).GetField("_metadataUrl", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic).GetValue(default));
}

[Fact]
public void ValidationMetadataUrlTest_AseChannel_Public()
{
var configMock = new Mock<IConfiguration>();
var configSectionMock = new Mock<IConfigurationSection>();
configSectionMock.Setup(o => o.Value).Returns(string.Empty);
configMock.Setup(c => c.GetSection("ChannelService")).Returns(configSectionMock.Object);
AseChannelValidation.Init(configMock.Object);

Assert.Equal(AuthenticationConstants.ToBotFromEmulatorOpenIdMetadataUrl, typeof(AseChannelValidation).GetField("_metadataUrl", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic).GetValue(default));
}

[Fact]
public void ValidationIssueUrlTest_AseChannel()
{
var configMock = new Mock<IConfiguration>();
var configSectionMock = new Mock<IConfigurationSection>();
configSectionMock.Setup(o => o.Value).Returns("testTenantId");
configMock.Setup(c => c.GetSection("MicrosoftAppTenantId")).Returns(configSectionMock.Object);
AseChannelValidation.Init(configMock.Object);

var tenantIds = new string[]
{
"testTenantId",
"f8cdef31-a31e-4b4a-93e4-5f571e91255a", // US Gov MicrosoftServices.onmicrosoft.us
"d6d49420-f39b-4df7-a1dc-d59a935871db" // Public botframework.com
};
foreach (var tenantId in tenantIds)
{
Assert.Contains($"https://sts.windows.net/{tenantId}/", AseChannelValidation.BetweenBotAndAseChannelTokenValidationParameters.ValidIssuers);
Assert.Contains($"https://login.microsoftonline.com/{tenantId}/v2.0", AseChannelValidation.BetweenBotAndAseChannelTokenValidationParameters.ValidIssuers);
Assert.Contains($"https://login.microsoftonline.us/{tenantId}/v2.0", AseChannelValidation.BetweenBotAndAseChannelTokenValidationParameters.ValidIssuers);
}
}

[Fact]
public void ValidationChannelIdTest_AseChannel()
{
Assert.True(AseChannelValidation.IsAseChannel("AseChannel"));
}

private async Task JwtTokenValidation_ValidateAuthHeader_WithChannelService_Succeeds(string appId, string pwd, string channelService)
{
string header = $"Bearer {await new MicrosoftAppCredentials(appId, pwd).GetTokenAsync()}";
Expand Down

0 comments on commit 744e5ef

Please sign in to comment.