Skip to content

Instantly share code, notes, and snippets.

@Maarten88
Created October 29, 2016 15:17
Show Gist options
  • Save Maarten88/84d06d858429cdc0110cfb471808f068 to your computer and use it in GitHub Desktop.
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
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