[Blazor][Wasm] Adds HttpMessageHandler to automatically attach tokens to outgoing requests (#20682)
* Adds an authorization handler for integration with HttpClient in different scnearios. * Adds a message handler to streamline calling protected resources on the same base address.
This commit is contained in:
parent
cc0407877f
commit
546b52004c
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,6 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||||
where TAccountClaimsPrincipalFactory : AccountClaimsPrincipalFactory<TAccount>
|
where TAccountClaimsPrincipalFactory : AccountClaimsPrincipalFactory<TAccount>
|
||||||
{
|
{
|
||||||
builder.Services.Replace(ServiceDescriptor.Scoped<AccountClaimsPrincipalFactory<TAccount>, TAccountClaimsPrincipalFactory>());
|
builder.Services.Replace(ServiceDescriptor.Scoped<AccountClaimsPrincipalFactory<TAccount>, TAccountClaimsPrincipalFactory>());
|
||||||
builder.Services.Replace(ServiceDescriptor.Scoped<AccountClaimsPrincipalFactory<TAccount>, TUserFactory>());
|
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +51,5 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||||
public static IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> AddAccountClaimsPrincipalFactory<TAccountClaimsPrincipalFactory>(
|
public static IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> AddAccountClaimsPrincipalFactory<TAccountClaimsPrincipalFactory>(
|
||||||
this IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> builder)
|
this IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> builder)
|
||||||
where TAccountClaimsPrincipalFactory : AccountClaimsPrincipalFactory<RemoteUserAccount> => builder.AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, RemoteUserAccount, TAccountClaimsPrincipalFactory>();
|
where TAccountClaimsPrincipalFactory : AccountClaimsPrincipalFactory<RemoteUserAccount> => builder.AddAccountClaimsPrincipalFactory<RemoteAuthenticationState, RemoteUserAccount, TAccountClaimsPrincipalFactory>();
|
||||||
where TUserFactory : AccountClaimsPrincipalFactory<RemoteUserAccount> => builder.AddUserFactory<RemoteAuthenticationState, RemoteUserAccount, TUserFactory>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,20 +11,17 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
public class AccessTokenResult
|
public class AccessTokenResult
|
||||||
{
|
{
|
||||||
private readonly AccessToken _token;
|
private readonly AccessToken _token;
|
||||||
private readonly NavigationManager _navigation;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of <see cref="AccessTokenResult"/>.
|
/// Initializes a new instance of <see cref="AccessTokenResult"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="status">The status of the result.</param>
|
/// <param name="status">The status of the result.</param>
|
||||||
/// <param name="token">The <see cref="AccessToken"/> in case it was successful.</param>
|
/// <param name="token">The <see cref="AccessToken"/> in case it was successful.</param>
|
||||||
/// <param name="navigation">The <see cref="NavigationManager"/> to perform redirects.</param>
|
|
||||||
/// <param name="redirectUrl">The redirect uri to go to for provisioning the token.</param>
|
/// <param name="redirectUrl">The redirect uri to go to for provisioning the token.</param>
|
||||||
public AccessTokenResult(AccessTokenResultStatus status, AccessToken token, NavigationManager navigation, string redirectUrl)
|
public AccessTokenResult(AccessTokenResultStatus status, AccessToken token, string redirectUrl)
|
||||||
{
|
{
|
||||||
Status = status;
|
Status = status;
|
||||||
_token = token;
|
_token = token;
|
||||||
_navigation = navigation;
|
|
||||||
RedirectUrl = redirectUrl;
|
RedirectUrl = redirectUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,27 +53,5 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Determines whether the token request was successful and makes the <see cref="AccessToken"/> available for use when it is.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="accessToken">The <see cref="AccessToken"/> if the request was successful.</param>
|
|
||||||
/// <param name="redirect">Whether or not to redirect automatically when failing to provision a token.</param>
|
|
||||||
/// <returns><c>true</c> when the token request is successful; <c>false</c> otherwise.</returns>
|
|
||||||
public bool TryGetToken(out AccessToken accessToken, bool redirect)
|
|
||||||
{
|
|
||||||
if (TryGetToken(out accessToken))
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (redirect)
|
|
||||||
{
|
|
||||||
_navigation.NavigateTo(RedirectUrl);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="DelegatingHandler"/> that attaches access tokens to outgoing <see cref="HttpResponseMessage"/> instances.
|
||||||
|
/// Access tokens will only be added when the request URI is within one of the base addresses configured using
|
||||||
|
/// <see cref="ConfigureHandler(IEnumerable{string}, IEnumerable{string}, string)"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthorizationMessageHandler : DelegatingHandler
|
||||||
|
{
|
||||||
|
private readonly IAccessTokenProvider _provider;
|
||||||
|
private readonly NavigationManager _navigation;
|
||||||
|
private AccessToken _lastToken;
|
||||||
|
private AuthenticationHeaderValue _cachedHeader;
|
||||||
|
private Uri[] _authorizedUris;
|
||||||
|
private AccessTokenRequestOptions _tokenOptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="AuthorizationMessageHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The <see cref="IAccessTokenProvider"/> to use for provisioning tokens.</param>
|
||||||
|
/// <param name="navigation">The <see cref="NavigationManager"/> to use for performing redirections.</param>
|
||||||
|
public AuthorizationMessageHandler(
|
||||||
|
IAccessTokenProvider provider,
|
||||||
|
NavigationManager navigation)
|
||||||
|
{
|
||||||
|
_provider = provider;
|
||||||
|
_navigation = navigation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.Now;
|
||||||
|
if (_authorizedUris == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"The '{nameof(AuthorizationMessageHandler)}' is not configured. " +
|
||||||
|
$"Call '{nameof(AuthorizationMessageHandler.ConfigureHandler)}' and provide a list of endpoint urls to attach the token to.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_authorizedUris.Any(uri => uri.IsBaseOf(request.RequestUri)))
|
||||||
|
{
|
||||||
|
if (_lastToken == null || now >= _lastToken.Expires.AddMinutes(-5))
|
||||||
|
{
|
||||||
|
var tokenResult = _tokenOptions != null ?
|
||||||
|
await _provider.RequestAccessToken(_tokenOptions) :
|
||||||
|
await _provider.RequestAccessToken();
|
||||||
|
|
||||||
|
if (tokenResult.TryGetToken(out var token))
|
||||||
|
{
|
||||||
|
_lastToken = token;
|
||||||
|
_cachedHeader = new AuthenticationHeaderValue("Bearer", _lastToken.Value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new AccessTokenNotAvailableException(_navigation, tokenResult, _tokenOptions?.Scopes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't try to handle 401s and retry the request with a new token automatically since that would mean we need to copy the request
|
||||||
|
// headers and buffer the body and we expect that the user instead handles the 401s. (Also, we can't really handle all 401s as we might
|
||||||
|
// not be able to provision a token without user interaction).
|
||||||
|
request.Headers.Authorization = _cachedHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await base.SendAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures this handler to authorize outbound HTTP requests using an access token. The access token is only attached if only attached if at least one of
|
||||||
|
/// <paramref name="authorizedUrls" /> is a base of <see cref="HttpRequestMessage.RequestUri" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="authorizedUrls">The base addresses of endpoint URLs to which the token will be attached.</param>
|
||||||
|
/// <param name="scopes">The list of scopes to use when requesting an access token.</param>
|
||||||
|
/// <param name="returnUrl">The return URL to use in case there is an issue provisioning the token and a redirection to the
|
||||||
|
/// identity provider is necessary.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>This <see cref="AuthorizationMessageHandler"/>.</returns>
|
||||||
|
public AuthorizationMessageHandler ConfigureHandler(
|
||||||
|
IEnumerable<string> authorizedUrls,
|
||||||
|
IEnumerable<string> scopes = null,
|
||||||
|
string returnUrl = null)
|
||||||
|
{
|
||||||
|
if (_authorizedUris != null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Handler already configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorizedUrls == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(authorizedUrls));
|
||||||
|
}
|
||||||
|
|
||||||
|
var uris = authorizedUrls.Select(uri => new Uri(uri, UriKind.Absolute)).ToArray();
|
||||||
|
if (uris.Length == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one URL must be configured.", nameof(authorizedUrls));
|
||||||
|
}
|
||||||
|
|
||||||
|
_authorizedUris = uris;
|
||||||
|
var scopesList = scopes?.ToArray();
|
||||||
|
if (scopesList != null || returnUrl != null)
|
||||||
|
{
|
||||||
|
_tokenOptions = new AccessTokenRequestOptions
|
||||||
|
{
|
||||||
|
Scopes = scopesList,
|
||||||
|
ReturnUrl = returnUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="DelegatingHandler"/> that attaches access tokens to outgoing <see cref="HttpResponseMessage"/> instances.
|
||||||
|
/// Access tokens will only be added when the request URI is within the application's base URI.
|
||||||
|
/// </summary>
|
||||||
|
public class BaseAddressAuthorizationMessageHandler : AuthorizationMessageHandler
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="BaseAddressAuthorizationMessageHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="provider">The <see cref="IAccessTokenProvider"/> to use for requesting tokens.</param>
|
||||||
|
/// <param name="navigationManager">The <see cref="NavigationManager"/> used to compute the base address.</param>
|
||||||
|
public BaseAddressAuthorizationMessageHandler(IAccessTokenProvider provider, NavigationManager navigationManager)
|
||||||
|
: base(provider, navigationManager)
|
||||||
|
{
|
||||||
|
ConfigureHandler(new[] { navigationManager.BaseUri });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="Exception"/> that is thrown when an <see cref="AuthorizationMessageHandler"/> instance
|
||||||
|
/// is not able to provision an access token.
|
||||||
|
/// </summary>
|
||||||
|
public class AccessTokenNotAvailableException : Exception
|
||||||
|
{
|
||||||
|
private readonly NavigationManager _navigation;
|
||||||
|
private readonly AccessTokenResult _tokenResult;
|
||||||
|
|
||||||
|
public AccessTokenNotAvailableException(
|
||||||
|
NavigationManager navigation,
|
||||||
|
AccessTokenResult tokenResult,
|
||||||
|
IEnumerable<string> scopes)
|
||||||
|
: base(message: "Unable to provision an access token for the requested scopes: " +
|
||||||
|
scopes != null ? $"'{string.Join(", ", scopes ?? Array.Empty<string>())}'" : "(default scopes)")
|
||||||
|
{
|
||||||
|
_tokenResult = tokenResult;
|
||||||
|
_navigation = navigation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Redirect() => _navigation.NavigateTo(_tokenResult.RedirectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -159,7 +159,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
result.RedirectUrl = redirectUrl.ToString();
|
result.RedirectUrl = redirectUrl.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AccessTokenResult(parsedStatus, result.Token, Navigation, result.RedirectUrl);
|
return new AccessTokenResult(parsedStatus, result.Token, result.RedirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -184,7 +184,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
result.RedirectUrl = redirectUrl.ToString();
|
result.RedirectUrl = redirectUrl.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new AccessTokenResult(parsedStatus, result.Token, Navigation, result.RedirectUrl);
|
return new AccessTokenResult(parsedStatus, result.Token, result.RedirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Uri GetRedirectUrl(string customReturnUrl)
|
private Uri GetRedirectUrl(string customReturnUrl)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||||
return (IRemoteAuthenticationService<TRemoteAuthenticationState>)sp.GetRequiredService<AuthenticationStateProvider>();
|
return (IRemoteAuthenticationService<TRemoteAuthenticationState>)sp.GetRequiredService<AuthenticationStateProvider>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.TryAddTransient<BaseAddressAuthorizationMessageHandler>();
|
||||||
|
services.TryAddTransient<AuthorizationMessageHandler>();
|
||||||
|
|
||||||
services.TryAddScoped(sp =>
|
services.TryAddScoped(sp =>
|
||||||
{
|
{
|
||||||
return (IAccessTokenProvider)sp.GetRequiredService<AuthenticationStateProvider>();
|
return (IAccessTokenProvider)sp.GetRequiredService<AuthenticationStateProvider>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
|
{
|
||||||
|
public class AuthorizationMessageHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Throws_IfTheListOfAllowedUrlsIsNotConfigured()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var expectedMessage = "The 'AuthorizationMessageHandler' is not configured. " +
|
||||||
|
"Call 'ConfigureHandler' and provide a list of endpoint urls to attach the token to.";
|
||||||
|
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
// Act & Assert
|
||||||
|
|
||||||
|
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => new HttpClient(handler).GetAsync("https://www.example.com"));
|
||||||
|
|
||||||
|
Assert.Equal(expectedMessage, exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DoesNotAttachTokenToRequest_IfNotPresentInListOfAllowedUrls()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
handler.ConfigureHandler(new[] { "https://localhost:5001" });
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
handler.InnerHandler = new TestMessageHandler(response);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://www.example.com");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
tokenProvider.VerifyNoOtherCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestsTokenWithDefaultScopes_WhenNoTokenIsAvailable()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
tokenProvider.Setup(tp => tp.RequestAccessToken())
|
||||||
|
.Returns(new ValueTask<AccessTokenResult>(new AccessTokenResult(AccessTokenResultStatus.Success,
|
||||||
|
new AccessToken
|
||||||
|
{
|
||||||
|
Expires = DateTime.Now.AddHours(1),
|
||||||
|
GrantedScopes = new string[] { "All" },
|
||||||
|
Value = "asdf"
|
||||||
|
},
|
||||||
|
"https://www.example.com")));
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
handler.ConfigureHandler(new[] { "https://localhost:5001" });
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
handler.InnerHandler = new TestMessageHandler(response);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://localhost:5001/weather");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal("asdf", response.RequestMessage.Headers.Authorization.Parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CachesExistingTokenWhenPossible()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
tokenProvider.Setup(tp => tp.RequestAccessToken())
|
||||||
|
.Returns(new ValueTask<AccessTokenResult>(new AccessTokenResult(AccessTokenResultStatus.Success,
|
||||||
|
new AccessToken
|
||||||
|
{
|
||||||
|
Expires = DateTime.Now.AddHours(1),
|
||||||
|
GrantedScopes = new string[] { "All" },
|
||||||
|
Value = "asdf"
|
||||||
|
},
|
||||||
|
"https://www.example.com")));
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
handler.ConfigureHandler(new[] { "https://localhost:5001" });
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
handler.InnerHandler = new TestMessageHandler(response);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://localhost:5001/weather");
|
||||||
|
response.RequestMessage = null;
|
||||||
|
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://localhost:5001/weather");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Single(tokenProvider.Invocations);
|
||||||
|
Assert.Equal("asdf", response.RequestMessage.Headers.Authorization.Parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestNewTokenWhenCurrentTokenIsAboutToExpire()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
tokenProvider.Setup(tp => tp.RequestAccessToken())
|
||||||
|
.Returns(new ValueTask<AccessTokenResult>(new AccessTokenResult(AccessTokenResultStatus.Success,
|
||||||
|
new AccessToken
|
||||||
|
{
|
||||||
|
Expires = DateTime.Now.AddMinutes(3),
|
||||||
|
GrantedScopes = new string[] { "All" },
|
||||||
|
Value = "asdf"
|
||||||
|
},
|
||||||
|
"https://www.example.com")));
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
handler.ConfigureHandler(new[] { "https://localhost:5001" });
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
handler.InnerHandler = new TestMessageHandler(response);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://localhost:5001/weather");
|
||||||
|
response.RequestMessage = null;
|
||||||
|
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://localhost:5001/weather");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(2, tokenProvider.Invocations.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThrowsWhenItCanNotProvisionANewToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
tokenProvider.Setup(tp => tp.RequestAccessToken())
|
||||||
|
.Returns(new ValueTask<AccessTokenResult>(new AccessTokenResult(AccessTokenResultStatus.RequiresRedirect,
|
||||||
|
null,
|
||||||
|
"https://www.example.com")));
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
handler.ConfigureHandler(new[] { "https://localhost:5001" });
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
handler.InnerHandler = new TestMessageHandler(response);
|
||||||
|
|
||||||
|
// Act & assert
|
||||||
|
var exception = await Assert.ThrowsAsync<AccessTokenNotAvailableException>(() => new HttpClient(handler).GetAsync("https://localhost:5001/weather"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UsesCustomScopesAndReturnUrlWhenProvided()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var tokenProvider = new Mock<IAccessTokenProvider>();
|
||||||
|
tokenProvider.Setup(tp => tp.RequestAccessToken(It.IsAny<AccessTokenRequestOptions>()))
|
||||||
|
.Returns(new ValueTask<AccessTokenResult>(new AccessTokenResult(AccessTokenResultStatus.Success,
|
||||||
|
new AccessToken
|
||||||
|
{
|
||||||
|
Expires = DateTime.Now.AddMinutes(3),
|
||||||
|
GrantedScopes = new string[] { "All" },
|
||||||
|
Value = "asdf"
|
||||||
|
},
|
||||||
|
"https://www.example.com/return")));
|
||||||
|
|
||||||
|
var handler = new AuthorizationMessageHandler(tokenProvider.Object, Mock.Of<NavigationManager>());
|
||||||
|
handler.ConfigureHandler(
|
||||||
|
new[] { "https://localhost:5001" },
|
||||||
|
scopes: new[] { "example.read", "example.write" },
|
||||||
|
returnUrl: "https://www.example.com/return");
|
||||||
|
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
handler.InnerHandler = new TestMessageHandler(response);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_ = await new HttpClient(handler).GetAsync("https://localhost:5001/weather");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(1, tokenProvider.Invocations.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class TestMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly HttpResponseMessage _response;
|
||||||
|
|
||||||
|
public TestMessageHandler(HttpResponseMessage response) => _response = response;
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_response.RequestMessage = request;
|
||||||
|
return Task.FromResult(_response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -160,8 +160,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds an <see cref="IConfiguration"/> with keys and values from the set of providers registered in
|
/// Builds an <see cref="IConfiguration"/> with keys and values from the set of registered providers.
|
||||||
/// <see cref="Providers"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>An <see cref="IConfigurationRoot"/> with keys and values from the registered providers.</returns>
|
/// <returns>An <see cref="IConfigurationRoot"/> with keys and values from the registered providers.</returns>
|
||||||
public IConfigurationRoot Build()
|
public IConfigurationRoot Build()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
@page "/fetchdata"
|
@page "/fetchdata"
|
||||||
@using Wasm.Authentication.Shared
|
@using Wasm.Authentication.Shared
|
||||||
|
@implements IDisposable
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject IAccessTokenProvider AuthenticationService
|
@inject WeatherForecastClient WeatherForecast
|
||||||
@inject NavigationManager Navigation
|
|
||||||
|
|
||||||
<h1>Weather forecast</h1>
|
<h1>Weather forecast</h1>
|
||||||
|
|
||||||
<p>This component demonstrates fetching data from the server.</p>
|
<p>This component demonstrates fetching data from the server.</p>
|
||||||
|
|
@ -42,15 +41,18 @@ else
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var httpClient = new HttpClient();
|
try
|
||||||
httpClient.BaseAddress = new Uri(Navigation.BaseUri);
|
|
||||||
|
|
||||||
var tokenResult = await AuthenticationService.RequestAccessToken();
|
|
||||||
|
|
||||||
if (tokenResult.TryGetToken(out var token, redirect: true))
|
|
||||||
{
|
{
|
||||||
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token.Value}");
|
forecasts = await WeatherForecast.GetForecastAsync();
|
||||||
forecasts = await httpClient.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
|
}
|
||||||
|
catch (AccessTokenNotAvailableException exception)
|
||||||
|
{
|
||||||
|
exception.Redirect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
WeatherForecast.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) .NET Foundation. All rights reserved.
|
// Copyright (c) .NET Foundation. All rights reserved.
|
||||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
|
@ -17,6 +19,9 @@ namespace Wasm.Authentication.Client
|
||||||
builder.Services.AddApiAuthorization<RemoteAppState, OidcAccount>()
|
builder.Services.AddApiAuthorization<RemoteAppState, OidcAccount>()
|
||||||
.AddAccountClaimsPrincipalFactory<RemoteAppState, OidcAccount, PreferencesUserFactory>();
|
.AddAccountClaimsPrincipalFactory<RemoteAppState, OidcAccount, PreferencesUserFactory>();
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient<WeatherForecastClient>(client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
|
||||||
|
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<StateService>();
|
builder.Services.AddSingleton<StateService>();
|
||||||
|
|
||||||
builder.RootComponents.Add<App>("app");
|
builder.RootComponents.Add<App>("app");
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<Reference Include="System.Net.Http.Json" />
|
<Reference Include="System.Net.Http.Json" />
|
||||||
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
|
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
|
||||||
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" />
|
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" />
|
||||||
|
<Reference Include="Microsoft.Extensions.Http" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Wasm.Authentication.Shared;
|
||||||
|
|
||||||
|
namespace Wasm.Authentication.Client
|
||||||
|
{
|
||||||
|
public class WeatherForecastClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
public WeatherForecastClient(HttpClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WeatherForecast[]> GetForecastAsync() =>
|
||||||
|
_client.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast", _cts.Token);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_client?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="${MicrosoftAspNetCoreComponentsWebAssemblyDevServerPackageVersion}" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="${MicrosoftAspNetCoreComponentsWebAssemblyDevServerPackageVersion}" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="${MicrosoftAspNetCoreComponentsWebAssemblyAuthenticationPackageVersion}" Condition="'$(IndividualLocalAuth)' == 'true'" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="${MicrosoftAspNetCoreComponentsWebAssemblyAuthenticationPackageVersion}" Condition="'$(IndividualLocalAuth)' == 'true'" />
|
||||||
<PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="${MicrosoftAuthenticationWebAssemblyMsalPackageVersion}" Condition="'$(OrganizationalAuth)' == 'true' OR '$(IndividualB2CAuth)' == 'true'" />
|
<PackageReference Include="Microsoft.Authentication.WebAssembly.Msal" Version="${MicrosoftAuthenticationWebAssemblyMsalPackageVersion}" Condition="'$(OrganizationalAuth)' == 'true' OR '$(IndividualB2CAuth)' == 'true'" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="${MicrosoftExtensionsHttpPackageVersion}" Condition="'$(NoAuth)' != 'true' AND '$(Hosted)' == 'true'" />
|
||||||
<PackageReference Include="System.Net.Http.Json" Version="${SystemNetHttpJsonPackageVersion}" />
|
<PackageReference Include="System.Net.Http.Json" Version="${SystemNetHttpJsonPackageVersion}" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@
|
||||||
MicrosoftEntityFrameworkCoreSqlServerPackageVersion=$(MicrosoftEntityFrameworkCoreSqlServerPackageVersion);
|
MicrosoftEntityFrameworkCoreSqlServerPackageVersion=$(MicrosoftEntityFrameworkCoreSqlServerPackageVersion);
|
||||||
MicrosoftEntityFrameworkCoreSqlitePackageVersion=$(MicrosoftEntityFrameworkCoreSqlitePackageVersion);
|
MicrosoftEntityFrameworkCoreSqlitePackageVersion=$(MicrosoftEntityFrameworkCoreSqlitePackageVersion);
|
||||||
MicrosoftEntityFrameworkCoreToolsPackageVersion=$(MicrosoftEntityFrameworkCoreToolsPackageVersion);
|
MicrosoftEntityFrameworkCoreToolsPackageVersion=$(MicrosoftEntityFrameworkCoreToolsPackageVersion);
|
||||||
|
MicrosoftExtensionsHttpPackageVersion=$(MicrosoftExtensionsHttpPackageVersion);
|
||||||
SystemNetHttpJsonPackageVersion=$(SystemNetHttpJsonPackageVersion)
|
SystemNetHttpJsonPackageVersion=$(SystemNetHttpJsonPackageVersion)
|
||||||
</GeneratedContentProperties>
|
</GeneratedContentProperties>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
@*#if (!NoAuth && Hosted)
|
@*#if (!NoAuth && Hosted)
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||||
@inject IAccessTokenProvider AuthenticationService
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
#endif*@
|
#endif*@
|
||||||
@*#if (Hosted)
|
@*#if (Hosted)
|
||||||
@using ComponentsWebAssembly_CSharp.Shared
|
@using ComponentsWebAssembly_CSharp.Shared
|
||||||
|
|
@ -11,9 +9,7 @@
|
||||||
@*#if (!NoAuth && Hosted)
|
@*#if (!NoAuth && Hosted)
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
#endif*@
|
#endif*@
|
||||||
@*#if (NoAuth || !Hosted)
|
|
||||||
@inject HttpClient Http
|
@inject HttpClient Http
|
||||||
#endif*@
|
|
||||||
|
|
||||||
<h1>Weather forecast</h1>
|
<h1>Weather forecast</h1>
|
||||||
|
|
||||||
|
|
@ -55,17 +51,14 @@ else
|
||||||
{
|
{
|
||||||
@*#if (Hosted)
|
@*#if (Hosted)
|
||||||
@*#if (!NoAuth)
|
@*#if (!NoAuth)
|
||||||
var httpClient = new HttpClient();
|
try
|
||||||
httpClient.BaseAddress = new Uri(Navigation.BaseUri);
|
|
||||||
|
|
||||||
var tokenResult = await AuthenticationService.RequestAccessToken();
|
|
||||||
|
|
||||||
if (tokenResult.TryGetToken(out var token, redirect: true))
|
|
||||||
{
|
{
|
||||||
httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token.Value}");
|
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
|
||||||
forecasts = await httpClient.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
|
}
|
||||||
|
catch (AccessTokenNotAvailableException exception)
|
||||||
|
{
|
||||||
|
exception.Redirect();
|
||||||
}
|
}
|
||||||
|
|
||||||
#else
|
#else
|
||||||
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
|
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
|
||||||
#endif*@
|
#endif*@
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ using System.Net.Http;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
#if (!NoAuth && Hosted)
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
|
||||||
|
#endif
|
||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
|
@ -19,7 +22,18 @@ namespace ComponentsWebAssembly_CSharp
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
builder.RootComponents.Add<App>("app");
|
builder.RootComponents.Add<App>("app");
|
||||||
|
|
||||||
builder.Services.AddSingleton(new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
#if (!Hosted || NoAuth)
|
||||||
|
builder.Services.AddTransient(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||||
|
#else
|
||||||
|
builder.Services.AddHttpClient("ComponentsWebAssembly_CSharp.ServerAPI", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
|
||||||
|
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>();
|
||||||
|
|
||||||
|
// Supply HttpClient instances that include access tokens when making requests to the server project
|
||||||
|
builder.Services.AddTransient(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("ComponentsWebAssembly_CSharp.ServerAPI"));
|
||||||
|
#endif
|
||||||
|
#if(!NoAuth)
|
||||||
|
|
||||||
|
#endif
|
||||||
#if (IndividualLocalAuth)
|
#if (IndividualLocalAuth)
|
||||||
#if (Hosted)
|
#if (Hosted)
|
||||||
builder.Services.AddApiAuthorization();
|
builder.Services.AddApiAuthorization();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue