From 04282264d55fd3f064e3aa2b099b1e2594d59a8a Mon Sep 17 00:00:00 2001 From: Jean-Marc Prieur Date: Sat, 30 Mar 2019 11:54:53 -0700 Subject: [PATCH] Adding suppport for Web APIs in the library --- .../Client/ITokenAcquisition.cs | 11 +++ .../Client/TokenAcquisition.cs | 57 ++++++++++++++ .../Microsoft.Identity.Web.csproj | 2 +- .../WebApiStartupHelpers.cs | 78 +++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 Microsoft.Identity.Web/WebApiStartupHelpers.cs diff --git a/Microsoft.Identity.Web/Client/ITokenAcquisition.cs b/Microsoft.Identity.Web/Client/ITokenAcquisition.cs index 9d2571c8..1ed2de0a 100644 --- a/Microsoft.Identity.Web/Client/ITokenAcquisition.cs +++ b/Microsoft.Identity.Web/Client/ITokenAcquisition.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Http; +using Microsoft.Identity.Client; using System.Collections.Generic; using System.Threading.Tasks; @@ -108,5 +109,15 @@ public interface ITokenAcquisition /// Openidconnect event /// Task RemoveAccount(RedirectContext context); + + /// + /// Used in Web APIs (which therefore cannot have an interaction with the user). + /// Replies to the client through the HttpReponse by sending a 403 (forbidden) and populating wwwAuthenticateHeaders so that + /// the client can trigger an iteraction with the user so that the user consents to more scopes + /// + /// HttpContext + /// Scopes to consent to + /// triggering the challenge + void ReplyForbiddenWithWwwAuthenticateHeader(HttpContext httpContext, IEnumerable scopes, MsalUiRequiredException msalSeviceException); } } \ No newline at end of file diff --git a/Microsoft.Identity.Web/Client/TokenAcquisition.cs b/Microsoft.Identity.Web/Client/TokenAcquisition.cs index 4e55a332..4b75d833 100644 --- a/Microsoft.Identity.Web/Client/TokenAcquisition.cs +++ b/Microsoft.Identity.Web/Client/TokenAcquisition.cs @@ -28,14 +28,17 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; using Microsoft.Identity.Client; using Microsoft.Identity.Client.AppConfig; using Microsoft.Identity.Web.Client.TokenCacheProviders; +using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; using System.Diagnostics; using System.IdentityModel.Tokens.Jwt; using System.Linq; +using System.Net; using System.Security.Claims; using System.Threading.Tasks; @@ -377,5 +380,59 @@ private void AddAccountToCacheFromJwt(IEnumerable scopes, JwtSecurityTok throw; } } + + + /// + /// Used in Web APIs (which therefore cannot have an interaction with the user). + /// Replies to the client through the HttpReponse by sending a 403 (forbidden) and populating wwwAuthenticateHeaders so that + /// the client can trigger an iteraction with the user so that the user consents to more scopes + /// + /// HttpContext + /// Scopes to consent to + /// triggering the challenge + + public void ReplyForbiddenWithWwwAuthenticateHeader(HttpContext httpContext, IEnumerable scopes, MsalUiRequiredException msalSeviceException) + { + // A user interaction is required, but we are in a Web API, and therefore, we need to report back to the client through an wwww-Authenticate header https://tools.ietf.org/html/rfc6750#section-3.1 + string proposedAction = "consent"; + if (msalSeviceException.ErrorCode == MsalUiRequiredException.InvalidGrantError) + { + if (AcceptedTokenVersionIsNotTheSameAsTokenVersion(msalSeviceException)) + { + throw msalSeviceException; + } + } + + IDictionary parameters = new Dictionary() + { + { "clientId", azureAdOptions.ClientId }, + { "claims", msalSeviceException.Claims }, + { "scopes", string.Join(",", scopes) }, + { "proposedAction", proposedAction } + }; + + string parameterString = string.Join(", ", parameters.Select(p => $"{p.Key}=\"{p.Value}\"")); + string scheme = "Bearer"; + StringValues v = new StringValues($"{scheme} {parameterString}"); + + // StringValues v = new StringValues(new string[] { $"Bearer clientId=\"{jwtToken.Audiences.First()}\", claims=\"{ex.Claims}\", scopes=\" {string.Join(",", scopes)}\"" }); + var httpResponse = httpContext.Response; + var headers = httpResponse.Headers; + httpResponse.StatusCode = (int)HttpStatusCode.Forbidden; + if (headers.ContainsKey(HeaderNames.WWWAuthenticate)) + { + headers.Remove(HeaderNames.WWWAuthenticate); + } + headers.Add(HeaderNames.WWWAuthenticate, v); + } + + private static bool AcceptedTokenVersionIsNotTheSameAsTokenVersion(MsalUiRequiredException msalSeviceException) + { + // Normally app developers should not make decisions based on the internal AAD code + // however until the STS sends sub-error codes for this error, this is the only + // way to distinguish the case. + // This is subject to change in the future + return (msalSeviceException.Message.Contains("AADSTS50013")); + } } } \ No newline at end of file diff --git a/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj b/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj index c711bc14..4db16e3d 100644 --- a/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj +++ b/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.2 diff --git a/Microsoft.Identity.Web/WebApiStartupHelpers.cs b/Microsoft.Identity.Web/WebApiStartupHelpers.cs new file mode 100644 index 00000000..be7b0220 --- /dev/null +++ b/Microsoft.Identity.Web/WebApiStartupHelpers.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.AzureAD.UI; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Identity.Web.Client; +using Microsoft.Identity.Web.Resource; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Microsoft.Identity.Web +{ + public static class WebApiStartupHelpers + { + /// + /// Protects the Web API with Microsoft Identity Platform v2.0 (AAD v2.0) + /// This supposes that the configuration files have a section named "AzureAD" + /// + /// Service collection to which to add authentication + /// Configuration + /// + public static IServiceCollection AddProtectWebApiWithMicrosoftIdentityPlatformV2(this IServiceCollection services, IConfiguration configuration) + { + services.AddAuthentication(AzureADDefaults.JwtBearerAuthenticationScheme) + .AddAzureADBearer(options => configuration.Bind("AzureAd", options)); + + services.AddSession(); + + // Added + services.Configure(AzureADDefaults.JwtBearerAuthenticationScheme, options => + { + // This is an Azure AD v2.0 Web API + options.Authority += "/v2.0"; + + // The valid audiences are both the Client ID (options.Audience) and api://{ClientID} + options.TokenValidationParameters.ValidAudiences = new string[] { options.Audience, $"api://{options.Audience}" }; + + // Instead of using the default validation (validating against a single tenant, as we do in line of business apps), + // we inject our own multitenant validation logic (which even accepts both V1 and V2 tokens) + options.TokenValidationParameters.IssuerValidator = AadIssuerValidator.ForAadInstance(options.Authority).ValidateAadIssuer; + + // When an access token for our own Web API is validated, we add it to MSAL.NET's cache so that it can + // be used from the controllers. + options.Events = new JwtBearerEvents(); + + // If you want to debug, or just understand the JwtBearer events, uncomment the following line of code + // options.Events = JwtBearerMiddlewareDiagnostics.Subscribe(options.Events); + }); + + return services; + } + + /// + /// Protects the Web API with Microsoft Identity Platform v2.0 (AAD v2.0) + /// This supposes that the configuration files have a section named "AzureAD" + /// + /// Service collection to which to add authentication + /// Configuration + /// + public static IServiceCollection AddProtectedApiCallsWebApis(this IServiceCollection services, IConfiguration configuration, IEnumerable scopes) + { + services.AddTokenAcquisition(); + services.Configure(AzureADDefaults.JwtBearerAuthenticationScheme, options => + { + options.Events.OnTokenValidated = async context => + { + var tokenAcquisition = context.HttpContext.RequestServices.GetRequiredService(); + context.Success(); + + // Adds the token to the cache, and also handles the incremental consent and claim challenges + tokenAcquisition.AddAccountToCacheFromJwt(context, scopes); + await Task.FromResult(0); + }; + }); + return services; + } + } +} \ No newline at end of file