diff --git a/src/Microsoft.Identity.Web/Constants/Constants.cs b/src/Microsoft.Identity.Web/Constants/Constants.cs index 377475bb6..13fc02503 100644 --- a/src/Microsoft.Identity.Web/Constants/Constants.cs +++ b/src/Microsoft.Identity.Web/Constants/Constants.cs @@ -59,6 +59,7 @@ internal static class Constants public const string LoginHint = "loginHint"; public const string DomainHint = "domainHint"; public const string Authorization = "Authorization"; + public const string ApplicationJson = "application/json"; // Blazor challenge URI public const string BlazorChallengeUri = "MicrosoftIdentity/Account/Challenge?redirectUri="; diff --git a/src/Microsoft.Identity.Web/Constants/IDWebErrorMessage.cs b/src/Microsoft.Identity.Web/Constants/IDWebErrorMessage.cs index 5c6eb766f..31d9805eb 100644 --- a/src/Microsoft.Identity.Web/Constants/IDWebErrorMessage.cs +++ b/src/Microsoft.Identity.Web/Constants/IDWebErrorMessage.cs @@ -11,6 +11,7 @@ internal static class IDWebErrorMessage // General IDW10000 = "IDW10000:" public const string HttpContextIsNull = "IDW10001: HttpContext is null. "; public const string HttpContextAndHttpResponseAreNull = "IDW10002: Current HttpContext and HttpResponse argument are null. Pass an HttpResponse argument. "; + public const string InvalidHttpStatusCodeInResponse = "IDW10003: Invalid status code in the HttpResponseMessage. Status code: {0}, Error message: {1} ."; // Configuration IDW10100 = "IDW10100:" public const string ProvideEitherScopeKeySectionOrScopes = "IDW10101: Either provide the '{0}' or the '{1}' to the 'AuthorizeForScopes'. "; @@ -22,7 +23,8 @@ internal static class IDWebErrorMessage "For instance, in the appsettings.json file. "; public const string BothClientSecretAndCertificateProvided = "IDW10105: Both client secret and client certificate, " + "cannot be included in the configuration of the web app when calling a web API. "; - public const string ConfigurationOptionRequired = "The '{0}' option must be provided. "; + public const string ConfigurationOptionRequired = "IDW10106: The '{0}' option must be provided. "; + public const string ScopesNotConfiguredInConfigurationOrViaDelegate = "IDW10107: Scopes need to be passed-in either by configuration or by the delegate overriding it. "; // Authorization IDW10200 = "IDW10200:" public const string NeitherScopeOrRolesClaimFoundInToken = "IDW10201: Neither scope or roles claim was found in the bearer token. "; diff --git a/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApi.cs b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApi.cs new file mode 100644 index 000000000..d449913d3 --- /dev/null +++ b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApi.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; + +namespace Microsoft.Identity.Web +{ + /// + /// Implementation for the downstream web API. + /// + public class DownstreamWebApi : IDownstreamWebApi + { + private readonly ITokenAcquisition _tokenAcquisition; + private readonly HttpClient _httpClient; + private readonly IOptionsMonitor _namedDownstreamWebApiOptions; + private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }; + + /// + /// Constructor. + /// + /// Token acquisition service. + /// Named options provider. + /// HTTP client. + public DownstreamWebApi( + ITokenAcquisition tokenAcquisition, + IOptionsMonitor namedDownstreamWebApiOptions, + HttpClient httpClient) + { + _tokenAcquisition = tokenAcquisition; + _namedDownstreamWebApiOptions = namedDownstreamWebApiOptions; + _httpClient = httpClient; + } + + /// + public async Task CallWebApiForUserAsync( + string optionsInstanceName, + Action? calledDownstreamApiOptionsOverride, + ClaimsPrincipal? user, + StringContent? requestContent) + { + DownstreamWebApiOptions effectiveOptions = MergeOptions(optionsInstanceName, calledDownstreamApiOptionsOverride); + + if (string.IsNullOrEmpty(effectiveOptions.Scopes)) + { + throw new ArgumentException(IDWebErrorMessage.ScopesNotConfiguredInConfigurationOrViaDelegate); + } + + string accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync( + effectiveOptions.GetScopes(), + effectiveOptions.Tenant) + .ConfigureAwait(false); + + HttpResponseMessage response; + using (HttpRequestMessage httpRequestMessage = new HttpRequestMessage( + effectiveOptions.HttpMethod, + effectiveOptions.GetApiUrl())) + { + if (requestContent != null) + { + httpRequestMessage.Content = requestContent; + } + + httpRequestMessage.Headers.Add( + Constants.Authorization, + string.Format( + CultureInfo.InvariantCulture, + "{0} {1}", + Constants.Bearer, + accessToken)); + response = await _httpClient.SendAsync(httpRequestMessage).ConfigureAwait(false); + } + + return response; + } + + /// + /// Merge the options from configuration and override from caller. + /// + /// Named configuration. + /// Delegate to override the configuration. + internal /* for tests */ DownstreamWebApiOptions MergeOptions( + string optionsInstanceName, + Action? calledApiOptionsOverride) + { + // Gets the options from configuration (or default value) + DownstreamWebApiOptions options; + if (optionsInstanceName != null) + { + options = _namedDownstreamWebApiOptions.Get(optionsInstanceName); + } + else + { + options = _namedDownstreamWebApiOptions.CurrentValue; + } + + DownstreamWebApiOptions clonedOptions = options.Clone(); + calledApiOptionsOverride?.Invoke(clonedOptions); + return clonedOptions; + } + + /// + public async Task CallWebApiForUserAsync( + string optionsInstanceName, + TInput input, + Action? downstreamWebApiOptionsOverride = null, + ClaimsPrincipal? user = null) + where TOutput : class + { + StringContent? jsoncontent; + if (input != null) + { + var jsonRequest = JsonSerializer.Serialize(input); + jsoncontent = new StringContent(jsonRequest, Encoding.UTF8, Constants.ApplicationJson); + } + else + { + jsoncontent = null; + } + + HttpResponseMessage response = await CallWebApiForUserAsync( + optionsInstanceName, + downstreamWebApiOptionsOverride, + user, + jsoncontent).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.OK) + { + string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + TOutput? output = JsonSerializer.Deserialize(content, _jsonOptions); + return output; + } + else + { + string error = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + throw new HttpRequestException(string.Format( + CultureInfo.InvariantCulture, + IDWebErrorMessage.InvalidHttpStatusCodeInResponse, + response.StatusCode, + error)); + } + } + + /// + public async Task CallWebApiForAppAsync( + string optionsInstanceName, + Action? downstreamApiOptionsOverride = null, + StringContent? requestContent = null) + { + DownstreamWebApiOptions effectiveOptions = MergeOptions(optionsInstanceName, downstreamApiOptionsOverride); + + if (effectiveOptions.Scopes == null) + { + throw new ArgumentException(IDWebErrorMessage.ScopesNotConfiguredInConfigurationOrViaDelegate); + } + + string accessToken = await _tokenAcquisition.GetAccessTokenForAppAsync( + effectiveOptions.Scopes, + effectiveOptions.Tenant) + .ConfigureAwait(false); + + HttpResponseMessage response; + using (HttpRequestMessage httpRequestMessage = new HttpRequestMessage( + effectiveOptions.HttpMethod, + effectiveOptions.GetApiUrl())) + { + httpRequestMessage.Headers.Add( + Constants.Authorization, + string.Format( + CultureInfo.InvariantCulture, + "{0} {1}", + Constants.Bearer, + accessToken)); + response = await _httpClient.SendAsync(httpRequestMessage).ConfigureAwait(false); + } + + return response; + } + } +} diff --git a/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApiExtensions.cs b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApiExtensions.cs new file mode 100644 index 000000000..a46c85bb8 --- /dev/null +++ b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApiExtensions.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Identity.Web +{ + /// + /// Extension methods to support downstream web API services. + /// + public static class DownstreamWebApiServiceExtensions + { + /// + /// Adds a named downstream web API service related to a specific configuration section. + /// + /// Builder. + /// Name of the configuration for the service. + /// This is the name used when calling the service from controller/pages. + /// Configuration. + /// The builder for chaining. + public static MicrosoftIdentityAppCallsWebApiAuthenticationBuilder AddDownstreamWebApiService( + this MicrosoftIdentityAppCallsWebApiAuthenticationBuilder builder, + string serviceName, + IConfiguration configuration) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.Configure(serviceName, configuration); + builder.Services.AddHttpClient(); + return builder; + } + + /// + /// Adds a named downstream web API service initialized with delegates. + /// + /// Builder. + /// Name of the configuration for the service. + /// This is the name which will be used when calling the service from controller/pages. + /// Action to configure the options. + /// The builder for chaining. + public static MicrosoftIdentityAppCallsWebApiAuthenticationBuilder AddDownstreamWebApiService( + this MicrosoftIdentityAppCallsWebApiAuthenticationBuilder builder, + string serviceName, + Action configureOptions) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.Configure(serviceName, configureOptions); + + // https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests + builder.Services.AddHttpClient(); + builder.Services.Configure(serviceName, configureOptions); + return builder; + } + } +} diff --git a/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApiOptions.cs b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApiOptions.cs new file mode 100644 index 000000000..eb9e3f154 --- /dev/null +++ b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/DownstreamWebApiOptions.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net.Http; + +namespace Microsoft.Identity.Web +{ + /// + /// Options passed-in to call downstream web APIs. To call Microsoft Graph, see rather + /// . + /// + public class DownstreamWebApiOptions + { + /// + /// Base URL for the called downstream web API. For instance "https://graph.microsoft.com/beta/".. + /// + public string BaseUrl { get; set; } = "https://graph.microsoft.com/v1.0"; + + /// + /// Path relative to the (for instance "me"). + /// + public string RelativePath { get; set; } = string.Empty; + + /// + /// Space separated scopes required to call the downstream web API. + /// For instance "user.read mail.read". + /// + public string? Scopes { get; set; } = null; + + /// + /// [Optional] tenant ID. This is used for specific scenarios where + /// the application needs to call a downstream web API on behalf of a user in several tenants. + /// It would mostly be used from code, not from the configuration. + /// + public string? Tenant { get; set; } = null; + + /// + /// [Optional]. User flow (in the case of a B2C downstream web API). If not + /// specified, the B2C downstream web API will be called with the default user flow from + /// . + /// + public string? UserFlow { get; set; } = null; + + /// + /// HTTP method used to call this downstream web API (by default Get). + /// + public HttpMethod HttpMethod { get; set; } = HttpMethod.Get; + + /// + /// Clone the options (to be able to override them). + /// + /// A clone of the options. + public DownstreamWebApiOptions Clone() + { + return new DownstreamWebApiOptions + { + BaseUrl = BaseUrl, + RelativePath = RelativePath, + Scopes = Scopes, + Tenant = Tenant, + UserFlow = UserFlow, + HttpMethod = HttpMethod, + }; + } + + /// + /// Return the downstream web API URL. + /// + /// URL of the downstream web API. +#pragma warning disable CA1055 // Uri return values should not be strings + public string GetApiUrl() +#pragma warning restore CA1055 // Uri return values should not be strings + { + return BaseUrl?.TrimEnd('/') + $"/{RelativePath}"; + } + + /// + /// Returns the scopes. + /// + /// Scopes. + public string[] GetScopes() + { + return string.IsNullOrWhiteSpace(Scopes) ? new string[0] : Scopes.Split(' '); + } + } +} diff --git a/src/Microsoft.Identity.Web/DownstreamWebApiSupport/IDownstreamWebApi.cs b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/IDownstreamWebApi.cs new file mode 100644 index 000000000..2c940a224 --- /dev/null +++ b/src/Microsoft.Identity.Web/DownstreamWebApiSupport/IDownstreamWebApi.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Net.Http; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Microsoft.Identity.Web +{ + /// + /// Interface used to call a downstream web API, for instance from controllers. + /// + public interface IDownstreamWebApi + { + /// + /// Calls the downstream web API for the user, based on a description of the + /// downstream web API in the configuration. + /// + /// Name of the service describing the downstream web API. There can + /// be several configuration named sections mapped to a , + /// each for one downstream web API. You can pass-in null, but in that case + /// need to be set. + /// Overrides the options proposed in the configuration described + /// by . + /// [Optional] Claims representing a user. This is useful platforms like Blazor + /// or Azure Signal R where the HttpContext is not available. In other platforms, the library + /// will find the user from the HttpContext. + /// HTTP context in the case where is + /// , , . + /// An that the application will process. + public Task CallWebApiForUserAsync( + string serviceName, + Action? calledDownstreamWebApiOptionsOverride = null, + ClaimsPrincipal? user = null, + StringContent? content = null); + + /// + /// Calls a downstream web API consuming JSON with some data and returns data. + /// + /// Input type. + /// Output type. + /// Name of the service describing the downstream web API. There can + /// be several configuration named sections mapped to a , + /// each for one downstream web API. You can pass-in null, but in that case + /// need to be set. + /// Input parameter to the downstream web API. + /// Overrides the options proposed in the configuration described + /// by . + /// [Optional] Claims representing a user. This is useful in platforms like Blazor + /// or Azure Signal R where the HttpContext is not available. In other platforms, the library + /// will find the user from the HttpContext. + /// The value returned by the downstream web API. + /// + /// A list method that returns an IEnumerable<Todo>>. + /// + /// public async Task<IEnumerable<Todo>> GetAsync() + /// { + /// return await _downstreamWebApi.CallWebApiForUserAsync<object, IEnumerable<Todo>>( + /// ServiceName, + /// null, + /// options => + /// { + /// options.RelativePath = $"api/todolist"; + /// }); + /// } + /// + /// + /// Example of editing. + /// + /// public async Task<Todo> EditAsync(Todo todo) + /// { + /// return await _downstreamWebApi.CallWebApiForUserAsync<Todo, Todo>( + /// ServiceName, + /// todo, + /// options => + /// { + /// options.HttpMethod = HttpMethod.Patch; + /// options.RelativePath = $"api/todolist/{todo.Id}"; + /// }); + /// } + /// + /// + public Task CallWebApiForUserAsync( + string serviceName, + TInput input, + Action? downstreamWebApiOptionsOverride = null, + ClaimsPrincipal? user = null) + where TOutput : class; + + /// + /// Calls the downstream web API for the app, with the required scopes. + /// + /// Name of the service describing the downstream web API. There can + /// be several configuration named sections mapped to a , + /// each for one downstream web API. You can pass-in null, but in that case + /// need to be set. + /// Overrides the options proposed in the configuration described + /// by . + /// HTTP content in the case where is + /// , , . + /// An that the application will process. + public Task CallWebApiForAppAsync( + string serviceName, + Action? downstreamWebApiOptionsOverride = null, + StringContent? content = null); + } +} diff --git a/src/Microsoft.Identity.Web/GlobalSuppressions.cs b/src/Microsoft.Identity.Web/GlobalSuppressions.cs new file mode 100644 index 000000000..be01584b6 --- /dev/null +++ b/src/Microsoft.Identity.Web/GlobalSuppressions.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Design", "CA1055:Uri return values should not be strings", Justification = "We want a string.", Scope = "member", Target = "~M:Microsoft.Identity.Web.CalledApiOptions.GetApiUrl~System.String")] diff --git a/src/Microsoft.Identity.Web/Microsoft.Identity.Web.xml b/src/Microsoft.Identity.Web/Microsoft.Identity.Web.xml index ab6114de2..ef89ca7e8 100644 --- a/src/Microsoft.Identity.Web/Microsoft.Identity.Web.xml +++ b/src/Microsoft.Identity.Web/Microsoft.Identity.Web.xml @@ -530,6 +530,204 @@ True, if the user agent does not allow "SameSite=None" cookie; otherwise, false. + + + Implementation for the downstream web API. + + + + + Constructor. + + Token acquisition service. + Named options provider. + HTTP client. + + + + + + + Merge the options from configuration and override from caller. + + Named configuration. + Delegate to override the configuration. + + + + + + + + + + Extension methods to support downstream web API services. + + + + + Adds a named downstream web API service related to a specific configuration section. + + Builder. + Name of the configuration for the service. + This is the name used when calling the service from controller/pages. + Configuration. + The builder for chaining. + + + + Adds a named downstream web API service initialized with delegates. + + Builder. + Name of the configuration for the service. + This is the name which will be used when calling the service from controller/pages. + Action to configure the options. + The builder for chaining. + + + + Options passed-in to call downstream web APIs. To call Microsoft Graph, see rather + . + + + + + Base URL for the called downstream web API. For instance "https://graph.microsoft.com/beta/".. + + + + + Path relative to the (for instance "me"). + + + + + Space separated scopes required to call the downstream web API. + For instance "user.read mail.read". + + + + + [Optional] tenant ID. This is used for specific scenarios where + the application needs to call a downstream web API on behalf of a user in several tenants. + It would mostly be used from code, not from the configuration. + + + + + [Optional]. User flow (in the case of a B2C downstream web API). If not + specified, the B2C downstream web API will be called with the default user flow from + . + + + + + HTTP method used to call this downstream web API (by default Get). + + + + + Clone the options (to be able to override them). + + A clone of the options. + + + + Return the downstream web API URL. + + URL of the downstream web API. + + + + Returns the scopes. + + Scopes. + + + + Interface used to call a downstream web API, for instance from controllers. + + + + + Calls the downstream web API for the user, based on a description of the + downstream web API in the configuration. + + Name of the service describing the downstream web API. There can + be several configuration named sections mapped to a , + each for one downstream web API. You can pass-in null, but in that case + need to be set. + Overrides the options proposed in the configuration described + by . + [Optional] Claims representing a user. This is useful platforms like Blazor + or Azure Signal R where the HttpContext is not available. In other platforms, the library + will find the user from the HttpContext. + HTTP context in the case where is + , , . + An that the application will process. + + + + Calls a downstream web API consuming JSON with some data and returns data. + + Input type. + Output type. + Name of the service describing the downstream web API. There can + be several configuration named sections mapped to a , + each for one downstream web API. You can pass-in null, but in that case + need to be set. + Input parameter to the downstream web API. + Overrides the options proposed in the configuration described + by . + [Optional] Claims representing a user. This is useful in platforms like Blazor + or Azure Signal R where the HttpContext is not available. In other platforms, the library + will find the user from the HttpContext. + The value returned by the downstream web API. + + A list method that returns an IEnumerable<Todo>>. + + public async Task<IEnumerable<Todo>> GetAsync() + { + return await _downstreamWebApi.CallWebApiForUserAsync<object, IEnumerable<Todo>>( + ServiceName, + null, + options => + { + options.RelativePath = $"api/todolist"; + }); + } + + + Example of editing. + + public async Task<Todo> EditAsync(Todo todo) + { + return await _downstreamWebApi.CallWebApiForUserAsync<Todo, Todo>( + ServiceName, + todo, + options => + { + options.HttpMethod = HttpMethod.Patch; + options.RelativePath = $"api/todolist/{todo.Id}"; + }); + } + + + + + + Calls the downstream web API for the app, with the required scopes. + + Name of the service describing the downstream web API. There can + be several configuration named sections mapped to a , + each for one downstream web API. You can pass-in null, but in that case + need to be set. + Overrides the options proposed in the configuration described + by . + HTTP content in the case where is + , , . + An that the application will process. + Extension methods. @@ -747,30 +945,30 @@ - Extensions methods on a MicrososoftAppCallingWebApiAuthenticationBuilder builder + Extensions methods on a MicrosoftIdentityAppCallingWebApiAuthenticationBuilder builder to add support to call Microsoft Graph. - Add support to calls Microsoft graph. From a named option and a configuration section. + Add support to call Microsoft Graph. From a named option and a configuration section. Builder. - Configuraiton section. + Configuration section. The builder to chain. - Add support to calls Microsoft graph. From a base graph Url and a default scope. + Add support to call Microsoft Graph. From a base Graph URL and a default scope. Builder. Named instance of option. - Configuraiton section. + Configuration section. The builder to chain. - Add support to calls Microsoft graph. From a named options and a configuraiton method. + Add support to call Microsoft Graph. From a named options and a configuration method. Builder. Method to configure the options. @@ -778,7 +976,7 @@ - Authentication provider based on MSAL.NET. + Authentication provider based on ITokenAcquisition. diff --git a/tests/BlazorServerCallsGraph/Pages/CallWebApi.razor b/tests/BlazorServerCallsGraph/Pages/CallWebApi.razor index 6b06cde64..27e7b6892 100644 --- a/tests/BlazorServerCallsGraph/Pages/CallWebApi.razor +++ b/tests/BlazorServerCallsGraph/Pages/CallWebApi.razor @@ -20,13 +20,25 @@ else } @code { + private HttpResponseMessage response; private string apiResult; protected override async Task OnInitializedAsync() { try { - apiResult = await downstreamAPI.CallWebApiAsync("me"); + response = await downstreamAPI.CallWebApiForUserAsync( + "CalledApi", + options => options.RelativePath = "me"); + + if (response.StatusCode == System.Net.HttpStatusCode.OK) + { + apiResult = await response.Content.ReadAsStringAsync(); + } + else + { + apiResult = "Failed to call the web API"; + } } catch (Exception ex) { diff --git a/tests/BlazorServerCallsGraph/Services/DownstreamWebApi.cs b/tests/BlazorServerCallsGraph/Services/DownstreamWebApi.cs deleted file mode 100644 index 26646081f..000000000 --- a/tests/BlazorServerCallsGraph/Services/DownstreamWebApi.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Identity.Web; - -namespace blazor -{ - public interface IDownstreamWebApi - { - Task CallWebApiAsync(string relativeEndpoint = "", string[] requiredScopes = null); - } - - public static class DownstreamWebApiExtensions - { - public static void AddDownstreamWebApiService(this IServiceCollection services, IConfiguration configuration) - { - // https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests - services.AddHttpClient(); - } - } - - public class DownstreamWebApi : IDownstreamWebApi - { - private readonly ITokenAcquisition _tokenAcquisition; - - private readonly IConfiguration _configuration; - - private readonly HttpClient _httpClient; - - public DownstreamWebApi( - ITokenAcquisition tokenAcquisition, - IConfiguration configuration, - HttpClient httpClient) - { - _tokenAcquisition = tokenAcquisition; - _configuration = configuration; - _httpClient = httpClient; - } - - /// - /// Calls the Web API with the required scopes - /// - /// [Optional] Scopes required to call the Web API. If - /// not specified, uses scopes from the configuration - /// Endpoint relative to the CalledApiUrl configuration - /// A JSON string representing the result of calling the Web API - public async Task CallWebApiAsync(string relativeEndpoint = "", string[] requiredScopes = null) - { - string[] scopes = requiredScopes ?? _configuration["CalledApi:CalledApiScopes"]?.Split(' '); - string apiUrl = (_configuration["CalledApi:CalledApiUrl"] as string)?.TrimEnd('/') + $"/{relativeEndpoint}"; - - string accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes); - HttpRequestMessage httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, apiUrl); - httpRequestMessage.Headers.Add("Authorization", $"bearer {accessToken}"); - - string apiResult; - var response = await _httpClient.SendAsync(httpRequestMessage); - if (response.StatusCode == HttpStatusCode.OK) - { - apiResult = await response.Content.ReadAsStringAsync(); - } - else - { - apiResult = $"Error calling the API '{apiUrl}'"; - } - - return apiResult; - } - } -} diff --git a/tests/BlazorServerCallsGraph/Startup.cs b/tests/BlazorServerCallsGraph/Startup.cs index 70772ff4e..89e365e5e 100644 --- a/tests/BlazorServerCallsGraph/Startup.cs +++ b/tests/BlazorServerCallsGraph/Startup.cs @@ -30,9 +30,9 @@ public void ConfigureServices(IServiceCollection services) .AddMicrosoftIdentityWebApp(Configuration, "AzureAd") .EnableTokenAcquisitionToCallDownstreamApi() .AddMicrosoftGraphServiceClient(Configuration.GetSection("GraphBeta")) + .AddDownstreamWebApiService("CalledApi", Configuration.GetSection("CalledApi")) .AddInMemoryTokenCaches(); - services.AddDownstreamWebApiService(Configuration); services.AddControllersWithViews() .AddMicrosoftIdentityUI(); diff --git a/tests/BlazorServerCallsGraph/appsettings.json b/tests/BlazorServerCallsGraph/appsettings.json index ea0cda6c5..bb607fb60 100644 --- a/tests/BlazorServerCallsGraph/appsettings.json +++ b/tests/BlazorServerCallsGraph/appsettings.json @@ -22,15 +22,15 @@ App ID URI of a legacy v1 Web application Applications are registered in the https:portal.azure.com portal. */ - "CalledApiScopes": "user.read", - "CalledApiUrl": "https://graph.microsoft.com/beta/" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - }, - "AllowedHosts": "*" + "Scopes": "user.read", + "BaseUrl": "https://graph.microsoft.com/v1.0/" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*" } diff --git a/tests/WebAppCallsWebApiCallsGraph/Client/Controllers/TodoListController.cs b/tests/WebAppCallsWebApiCallsGraph/Client/Controllers/TodoListController.cs index 2c86cdc47..653b22f74 100644 --- a/tests/WebAppCallsWebApiCallsGraph/Client/Controllers/TodoListController.cs +++ b/tests/WebAppCallsWebApiCallsGraph/Client/Controllers/TodoListController.cs @@ -1,35 +1,54 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Identity.Web; +using System.Collections.Generic; +using System.Net.Http; using System.Threading.Tasks; -using TodoListClient.Services; using TodoListService.Models; namespace TodoListClient.Controllers { + [Authorize] + [AuthorizeForScopes(ScopeKeySection = "TodoList:Scopes")] public class TodoListController : Controller { - private ITodoListService _todoListService; + private IDownstreamWebApi _downstreamWebApi; + private const string ServiceName = "TodoList"; - public TodoListController(ITodoListService todoListService) + public TodoListController(IDownstreamWebApi downstreamWebApi) { - _todoListService = todoListService; + _downstreamWebApi = downstreamWebApi; } // GET: TodoList - [AuthorizeForScopes(ScopeKeySection = "TodoList:TodoListScope")] public async Task Index() { - return View(await _todoListService.GetAsync()); + var value = await _downstreamWebApi.CallWebApiForUserAsync>( + ServiceName, + null, + options => + { + options.RelativePath = "api/todolist"; + }); + return View(value); } // GET: TodoList/Details/5 public async Task Details(int id) { - return View(await _todoListService.GetAsync(id)); + var value = await _downstreamWebApi.CallWebApiForUserAsync( + ServiceName, + null, + options => + { + options.HttpMethod = HttpMethod.Get; + options.RelativePath = $"api/todolist/{id}"; + }); + return View(value); } // GET: TodoList/Create @@ -44,14 +63,28 @@ public ActionResult Create() [ValidateAntiForgeryToken] public async Task Create([Bind("Title,Owner")] Todo todo) { - await _todoListService.AddAsync(todo); + await _downstreamWebApi.CallWebApiForUserAsync( + ServiceName, + todo, + options => + { + options.HttpMethod = HttpMethod.Post; + options.RelativePath = "api/todolist"; + }); return RedirectToAction("Index"); } // GET: TodoList/Edit/5 public async Task Edit(int id) { - Todo todo = await _todoListService.GetAsync(id); + Todo todo = await _downstreamWebApi.CallWebApiForUserAsync( + ServiceName, + null, + options => + { + options.HttpMethod = HttpMethod.Get; + options.RelativePath = $"api/todolist/{id}"; + }); if (todo == null) { @@ -66,14 +99,28 @@ public async Task Edit(int id) [ValidateAntiForgeryToken] public async Task Edit(int id, [Bind("Id,Title,Owner")] Todo todo) { - await _todoListService.EditAsync(todo); + await _downstreamWebApi.CallWebApiForUserAsync( + ServiceName, + todo, + options => + { + options.HttpMethod = HttpMethod.Patch; + options.RelativePath = $"api/todolist/{todo.Id}"; + }); return RedirectToAction("Index"); } // GET: TodoList/Delete/5 public async Task Delete(int id) { - Todo todo = await _todoListService.GetAsync(id); + Todo todo = await _downstreamWebApi.CallWebApiForUserAsync( + ServiceName, + null, + options => + { + options.HttpMethod = HttpMethod.Get; + options.RelativePath = $"api/todolist/{id}"; + }); if (todo == null) { @@ -88,8 +135,14 @@ public async Task Delete(int id) [ValidateAntiForgeryToken] public async Task Delete(int id, [Bind("Id,Title,Owner")] Todo todo) { - await _todoListService.DeleteAsync(id); + await _downstreamWebApi.CallWebApiForUserAsync( + ServiceName, + options => + { + options.HttpMethod = HttpMethod.Delete; + options.RelativePath = $"api/todolist/{id}"; + }); return RedirectToAction("Index"); } } -} \ No newline at end of file +} diff --git a/tests/WebAppCallsWebApiCallsGraph/Client/Services/ITodoListService.cs b/tests/WebAppCallsWebApiCallsGraph/Client/Services/ITodoListService.cs deleted file mode 100644 index 84a924cf1..000000000 --- a/tests/WebAppCallsWebApiCallsGraph/Client/Services/ITodoListService.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Collections.Generic; -using System.Threading.Tasks; -using TodoListService.Models; - -namespace TodoListClient.Services -{ - public interface ITodoListService - { - Task> GetAsync(); - - Task GetAsync(int id); - - Task DeleteAsync(int id); - - Task AddAsync(Todo todo); - - Task EditAsync(Todo todo); - } -} diff --git a/tests/WebAppCallsWebApiCallsGraph/Client/Services/TodoListService.cs b/tests/WebAppCallsWebApiCallsGraph/Client/Services/TodoListService.cs deleted file mode 100644 index 68008ddfd..000000000 --- a/tests/WebAppCallsWebApiCallsGraph/Client/Services/TodoListService.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using System.Collections.Generic; -using System.Diagnostics; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Identity.Web; -using TodoListService.Models; - -namespace TodoListClient.Services -{ - public static class TodoListServiceExtensions - { - public static void AddTodoListService(this IServiceCollection services, IConfiguration configuration) - { - // https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests - services.AddHttpClient(); - } - } - - /// - /// - public class TodoListService : ITodoListService - { - private readonly IHttpContextAccessor _contextAccessor; - private readonly HttpClient _httpClient; - private readonly string _TodoListScope = string.Empty; - private readonly string _TodoListBaseAddress = string.Empty; - private readonly ITokenAcquisition _tokenAcquisition; - private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - public TodoListService(ITokenAcquisition tokenAcquisition, HttpClient httpClient, IConfiguration configuration, IHttpContextAccessor contextAccessor) - { - _httpClient = httpClient; - _tokenAcquisition = tokenAcquisition; - _contextAccessor = contextAccessor; - _TodoListScope = configuration["TodoList:TodoListScope"]; - _TodoListBaseAddress = configuration["TodoList:TodoListBaseAddress"]; - } - - public async Task AddAsync(Todo todo) - { - var httpRequestMessage = await PrepareAuthenticatedClient( - $"{ _TodoListBaseAddress}/api/todolist", - HttpMethod.Post); - - var jsonRequest = JsonSerializer.Serialize(todo); - var jsoncontent = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); - httpRequestMessage.Content = jsoncontent; - - var response = await _httpClient.SendAsync(httpRequestMessage); - - if (response.StatusCode == HttpStatusCode.OK) - { - var content = await response.Content.ReadAsStringAsync(); - todo = JsonSerializer.Deserialize(content, _jsonOptions); - - return todo; - } - - throw new HttpRequestException($"Invalid status code in the HttpResponseMessage: {response.StatusCode}."); - } - - public async Task DeleteAsync(int id) - { - var httpRequestMessage = await PrepareAuthenticatedClient( - $"{ _TodoListBaseAddress}/api/todolist/{id}", - HttpMethod.Delete); - - var response = await _httpClient.SendAsync(httpRequestMessage); - - if (response.StatusCode == HttpStatusCode.OK) - { - return; - } - - throw new HttpRequestException($"Invalid status code in the HttpResponseMessage: {response.StatusCode}."); - } - - public async Task EditAsync(Todo todo) - { - var httpRequestMessage = await PrepareAuthenticatedClient( - $"{ _TodoListBaseAddress}/api/todolist/{todo.Id}", - HttpMethod.Patch); - - var jsonRequest = JsonSerializer.Serialize(todo); - var jsoncontent = new StringContent(jsonRequest, Encoding.UTF8, "application/json-patch+json"); - - httpRequestMessage.Content = jsoncontent; - var response = await _httpClient.SendAsync(httpRequestMessage); - - if (response.StatusCode == HttpStatusCode.OK) - { - var content = await response.Content.ReadAsStringAsync(); - todo = JsonSerializer.Deserialize(content, _jsonOptions); - - return todo; - } - - throw new HttpRequestException($"Invalid status code in the HttpResponseMessage: {response.StatusCode}."); - } - - public async Task> GetAsync() - { - var httpRequestMessage = await PrepareAuthenticatedClient( - $"{ _TodoListBaseAddress}/api/todolist", - HttpMethod.Get); - var response = await _httpClient.SendAsync(httpRequestMessage); - var content = await response.Content.ReadAsStringAsync(); - - if (response.StatusCode == HttpStatusCode.OK) - { - IEnumerable todolist = JsonSerializer.Deserialize>(content, _jsonOptions); - return todolist; - } - throw new HttpRequestException($"Invalid status code in the HttpResponseMessage: {response.StatusCode}. Cause: {content}"); - } - - public async Task GetAsync(int id) - { - var httpRequestMessage = await PrepareAuthenticatedClient( - $"{ _TodoListBaseAddress}/api/todolist/{id}", - HttpMethod.Get); - var response = await _httpClient.SendAsync(httpRequestMessage); - if (response.StatusCode == HttpStatusCode.OK) - { - var content = await response.Content.ReadAsStringAsync(); - Todo todo = JsonSerializer.Deserialize(content, _jsonOptions); - - return todo; - } - - throw new HttpRequestException($"Invalid status code in the HttpResponseMessage: {response.StatusCode}."); - } - - private async Task PrepareAuthenticatedClient( - string url, - HttpMethod httpMethod) - { - var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { _TodoListScope }); - Debug.WriteLine($"access token-{accessToken}"); - HttpRequestMessage httpRequestMessage = new HttpRequestMessage(httpMethod, url); - httpRequestMessage.Headers.Add("Authorization", $"bearer {accessToken}"); - httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - return httpRequestMessage; - } - } -} diff --git a/tests/WebAppCallsWebApiCallsGraph/Client/Startup.cs b/tests/WebAppCallsWebApiCallsGraph/Client/Startup.cs index 4bd54969d..0d7ab1cfd 100644 --- a/tests/WebAppCallsWebApiCallsGraph/Client/Startup.cs +++ b/tests/WebAppCallsWebApiCallsGraph/Client/Startup.cs @@ -10,7 +10,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Identity.Web; -using TodoListClient.Services; using Microsoft.Extensions.Hosting; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Identity.Web.UI; @@ -45,10 +44,8 @@ public void ConfigureServices(IServiceCollection services) services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme) .AddMicrosoftIdentityWebApp(Configuration, "AzureAd") .EnableTokenAcquisitionToCallDownstreamApi() - .AddInMemoryTokenCaches(); - - // Add APIs - services.AddTodoListService(Configuration); + .AddDownstreamWebApiService("TodoList", Configuration.GetSection("TodoList")) + .AddInMemoryTokenCaches(); services.AddControllersWithViews(options => { diff --git a/tests/WebAppCallsWebApiCallsGraph/Client/appsettings.json b/tests/WebAppCallsWebApiCallsGraph/Client/appsettings.json index 497e32707..81da6e45e 100644 --- a/tests/WebAppCallsWebApiCallsGraph/Client/appsettings.json +++ b/tests/WebAppCallsWebApiCallsGraph/Client/appsettings.json @@ -24,8 +24,8 @@ - a scope corresponding to a V1 application (for instance /user_impersonation, where is the clientId of a V1 application, created in the https://portal.azure.com portal. */ - "TodoListScope": "api://a4c2469b-cf84-4145-8f5f-cb7bacf814bc/access_as_user", - "TodoListBaseAddress": "https://localhost:44351" + "Scopes": "api://a4c2469b-cf84-4145-8f5f-cb7bacf814bc/access_as_user", + "BaseUrl": "https://localhost:44351" }, "Logging": {