Created
October 29, 2016 15:17
-
-
Save Maarten88/84d06d858429cdc0110cfb471808f068 to your computer and use it in GitHub Desktop.
DotNetCore BotBuilder Authentication attribute that works with System.IdentityModel.Tokens.Jwt version 5.0.0
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics; | |
using System.Linq; | |
using System.Security.Claims; | |
using System.Threading; | |
using System.Threading.Tasks; | |
using Microsoft.AspNetCore.Http; | |
using Microsoft.AspNetCore.Mvc; | |
using Microsoft.AspNetCore.Mvc.Filters; | |
using Microsoft.AspNetCore.Mvc.WebApiCompatShim; | |
using Microsoft.Bot.Connector; | |
using Newtonsoft.Json.Linq; | |
using Microsoft.IdentityModel.Tokens; | |
using Microsoft.IdentityModel.Protocols; | |
using System.Net.Http; | |
using System.IdentityModel.Tokens.Jwt; | |
using Microsoft.IdentityModel.Protocols.OpenIdConnect; | |
namespace Microsoft.Bot.Connector | |
{ | |
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] | |
public class CoreBotAuthententicationAttribute : Attribute, IAsyncActionFilter | |
{ | |
public MicrosoftAppCredentials MicrosoftAppCredentials { get; } | |
public CoreBotAuthententicationAttribute(MicrosoftAppCredentials appCredentials) | |
{ | |
MicrosoftAppCredentials = appCredentials; | |
} | |
public string MicrosoftAppId { get; set; } | |
public string MicrosoftAppIdSettingName { get; set; } | |
public bool DisableSelfIssuedTokens { get; set; } | |
public virtual string OpenIdConfigurationUrl { get; set; } = JwtConfig.ToBotFromChannelOpenIdMetadataUrl; | |
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) | |
{ | |
MicrosoftAppId = MicrosoftAppId ?? MicrosoftAppCredentials?.MicrosoftAppId ?? string.Empty; | |
if (Debugger.IsAttached && String.IsNullOrEmpty(MicrosoftAppId)) | |
{ | |
// then auth is disabled | |
await next(); | |
return; | |
} | |
var tokenExtractor = new JwtTokenExtractor(JwtConfig.GetToBotFromChannelTokenValidationParameters(MicrosoftAppId), OpenIdConfigurationUrl); | |
var request = context.HttpContext.GetHttpRequestMessage(); | |
var identity = await tokenExtractor.GetIdentityAsync(request); | |
// No identity? If we're allowed to, fall back to MSA | |
// This code path is used by the emulator | |
if (identity == null && !DisableSelfIssuedTokens) | |
{ | |
tokenExtractor = new JwtTokenExtractor(JwtConfig.ToBotFromMSATokenValidationParameters, JwtConfig.ToBotFromMSAOpenIdMetadataUrl); | |
identity = await tokenExtractor.GetIdentityAsync(request); | |
// Check to make sure the app ID in the token is ours | |
if (identity != null) | |
{ | |
// If it doesn't match, throw away the identity | |
if (tokenExtractor.GetBotIdFromClaimsIdentity(identity) != MicrosoftAppId) | |
identity = null; | |
} | |
} | |
// Still no identity? Fail out. | |
if (identity == null) | |
{ | |
var host = request.RequestUri.DnsSafeHost; | |
context.HttpContext.Response.Headers.Add("WWW-Authenticate", $"Bearer realm=\"{host}\""); | |
context.Result = new StatusCodeResult(StatusCodes.Status401Unauthorized); | |
return; | |
} | |
var activity = context.ActionArguments.Select(t => t.Value).OfType<Activity>().FirstOrDefault(); | |
if (activity != null) | |
{ | |
MicrosoftAppCredentials.TrustServiceUrl(activity.ServiceUrl); | |
} | |
else | |
{ | |
// No model binding to activity check if we can find JObject or JArray | |
var obj = context.ActionArguments.Where(t => t.Value is JObject || t.Value is JArray).Select(t => t.Value).FirstOrDefault(); | |
if (obj != null) | |
{ | |
Activity[] activities = (obj is JObject) ? new Activity[] { ((JObject)obj).ToObject<Activity>() } : ((JArray)obj).ToObject<Activity[]>(); | |
foreach (var jActivity in activities) | |
{ | |
if (!string.IsNullOrEmpty(jActivity.ServiceUrl)) | |
{ | |
MicrosoftAppCredentials.TrustServiceUrl(jActivity.ServiceUrl); | |
} | |
} | |
} | |
else | |
{ | |
Trace.TraceWarning("No activity in the Bot Authentication Action Arguments"); | |
} | |
} | |
var principal = new ClaimsPrincipal(identity); | |
Thread.CurrentPrincipal = principal; | |
// Inside of ASP.NET this is required | |
if (context.HttpContext != null) | |
context.HttpContext.User = principal; | |
await next(); | |
} | |
} | |
public static class JwtConfig | |
{ | |
/// <summary> | |
/// TO BOT FROM CHANNEL: OpenID metadata document for tokens coming from MSA | |
/// </summary> | |
public const string ToBotFromChannelOpenIdMetadataUrl = "https://api.aps.skype.com/v1/.well-known/openidconfiguration"; | |
/// <summary> | |
/// TO BOT FROM CHANNEL: Token validation parameters when connecting to a bot | |
/// </summary> | |
public static TokenValidationParameters GetToBotFromChannelTokenValidationParameters(string msaAppId) | |
{ | |
return new TokenValidationParameters() | |
{ | |
ValidateIssuer = true, | |
ValidIssuers = new[] { "https://api.botframework.com" }, | |
ValidateAudience = true, | |
ValidAudiences = new[] { msaAppId }, | |
ValidateLifetime = true, | |
ClockSkew = TimeSpan.FromMinutes(5), | |
RequireSignedTokens = true | |
}; | |
} | |
/// <summary> | |
/// TO BOT FROM MSA: OpenID metadata document for tokens coming from MSA | |
/// </summary> | |
/// <remarks> | |
/// These settings are used to allow access from the Bot Framework Emulator | |
/// </remarks> | |
public const string ToBotFromMSAOpenIdMetadataUrl = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration"; | |
/// <summary> | |
/// TO BOT FROM MSA: Token validation parameters when connecting to a channel | |
/// </summary> | |
/// <remarks> | |
/// These settings are used to allow access from the Bot Framework Emulator | |
/// </remarks> | |
public static readonly TokenValidationParameters ToBotFromMSATokenValidationParameters = new TokenValidationParameters() | |
{ | |
ValidateIssuer = true, | |
ValidIssuers = new[] { "https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/" }, | |
ValidateAudience = true, | |
ValidAudiences = new[] { "https://graph.microsoft.com" }, | |
ValidateLifetime = true, | |
ClockSkew = TimeSpan.FromMinutes(5), | |
RequireSignedTokens = true | |
}; | |
} | |
public class JwtTokenExtractor | |
{ | |
/// <summary> | |
/// Shared of OpenIdConnect configuration managers (one per metadata URL) | |
/// </summary> | |
private static readonly Dictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> _openIdMetadataCache = | |
new Dictionary<string, ConfigurationManager<OpenIdConnectConfiguration>>(); | |
/// <summary> | |
/// Token validation parameters for this instance | |
/// </summary> | |
private readonly TokenValidationParameters _tokenValidationParameters; | |
/// <summary> | |
/// OpenIdConnect configuration manager for this instances | |
/// </summary> | |
private readonly ConfigurationManager<OpenIdConnectConfiguration> _openIdMetadata; | |
public JwtTokenExtractor(TokenValidationParameters tokenValidationParameters, string metadataUrl) | |
{ | |
// Make our own copy so we can edit it | |
_tokenValidationParameters = tokenValidationParameters.Clone(); | |
if (!_openIdMetadataCache.ContainsKey(metadataUrl)) | |
_openIdMetadataCache[metadataUrl] = new ConfigurationManager<OpenIdConnectConfiguration>(metadataUrl, new OpenIdConnectConfigurationRetriever()); | |
_openIdMetadata = _openIdMetadataCache[metadataUrl]; | |
_tokenValidationParameters.ValidateAudience = true; | |
_tokenValidationParameters.RequireSignedTokens = true; | |
} | |
public async Task<ClaimsIdentity> GetIdentityAsync(HttpRequestMessage request) | |
{ | |
if (request.Headers.Authorization != null) | |
return await GetIdentityAsync(request.Headers.Authorization.Scheme, request.Headers.Authorization.Parameter).ConfigureAwait(false); | |
return null; | |
} | |
public async Task<ClaimsIdentity> GetIdentityAsync(string authorizationHeader) | |
{ | |
if (authorizationHeader == null) | |
return null; | |
string[] parts = authorizationHeader?.Split(' '); | |
if (parts.Length == 2) | |
return await GetIdentityAsync(parts[0], parts[1]).ConfigureAwait(false); | |
return null; | |
} | |
public async Task<ClaimsIdentity> GetIdentityAsync(string scheme, string parameter) | |
{ | |
// No header in correct scheme? | |
if (scheme != "Bearer") | |
return null; | |
// Issuer isn't allowed? No need to check signature | |
if (!HasAllowedIssuer(parameter)) | |
return null; | |
try | |
{ | |
ClaimsPrincipal claimsPrincipal = await ValidateTokenAsync(parameter).ConfigureAwait(false); | |
return claimsPrincipal.Identities.OfType<ClaimsIdentity>().FirstOrDefault(); | |
} | |
catch (Exception e) | |
{ | |
Trace.TraceWarning("Invalid token. " + e.ToString()); | |
return null; | |
} | |
} | |
private bool HasAllowedIssuer(string jwtToken) | |
{ | |
JwtSecurityToken token = new JwtSecurityToken(jwtToken); | |
if (_tokenValidationParameters.ValidIssuer != null && _tokenValidationParameters.ValidIssuer == token.Issuer) | |
return true; | |
if ((_tokenValidationParameters.ValidIssuers ?? Enumerable.Empty<string>()).Contains(token.Issuer)) | |
return true; | |
return false; | |
} | |
public string GetBotIdFromClaimsIdentity(ClaimsIdentity identity) | |
{ | |
if (identity == null) | |
return null; | |
Claim botClaim = identity.Claims.FirstOrDefault(c => _tokenValidationParameters.ValidIssuers.Contains(c.Issuer) && c.Type == "appid"); | |
if (botClaim != null) | |
return botClaim.Value; | |
// Fallback for BF-issued tokens | |
botClaim = identity.Claims.FirstOrDefault(c => c.Issuer == "https://api.botframework.com" && c.Type == "aud"); | |
if (botClaim != null) | |
return botClaim.Value; | |
return null; | |
} | |
private async Task<ClaimsPrincipal> ValidateTokenAsync(string jwtToken) | |
{ | |
// _openIdMetadata only does a full refresh when the cache expires every 5 days | |
OpenIdConnectConfiguration config = null; | |
try | |
{ | |
config = await _openIdMetadata.GetConfigurationAsync().ConfigureAwait(false); | |
} | |
catch (Exception e) | |
{ | |
Trace.TraceError($"Error refreshing OpenId configuration: {e}"); | |
// No config? We can't continue | |
if (config == null) | |
throw; | |
} | |
_tokenValidationParameters.IssuerSigningKeys = config.SigningKeys; | |
JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler(); | |
try | |
{ | |
SecurityToken parsedToken; | |
ClaimsPrincipal principal = tokenHandler.ValidateToken(jwtToken, _tokenValidationParameters, out parsedToken); | |
return principal; | |
} | |
catch (SecurityTokenSignatureKeyNotFoundException) | |
{ | |
string keys = string.Join(", ", (((IEnumerable<SecurityKey>)config?.SigningKeys) ?? Enumerable.Empty<SecurityKey>()).Select(k => k.KeyId)); | |
Trace.TraceError("Error finding key for token. Available keys: " + keys); | |
throw; | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment