[Blazor] More auth fixes (#20192)

* Introduces customization options for mapping user claims principals.
* Supports login/logout flows extensibility.
* Improves E2E test reliability
* Improves reliability on the AuthenticationService
* Improves the experience by trying to silently log-in users on startup.
* Avoids loading the Blazor application when within a hidden iframe.
This commit is contained in:
Javier Calvarro Nelson 2020-04-04 13:06:25 +02:00 committed by GitHub
parent e67e7a08ca
commit fd9c786165
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1392 additions and 226 deletions

View File

@ -37,7 +37,22 @@ namespace Microsoft.Extensions.DependencyInjection
public static IServiceCollection AddMsalAuthentication<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<MsalProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
{
services.AddRemoteAuthentication<RemoteAuthenticationState, MsalProviderOptions>(configure);
AddMsalAuthentication<TRemoteAuthenticationState, RemoteUserAccount>(services, configure);
return services;
}
/// <summary>
/// Adds authentication using msal.js to Blazor applications.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="configure">The <see cref="Action{RemoteAuthenticationOptions{MsalProviderOptions}}"/> to configure the <see cref="RemoteAuthenticationOptions{MsalProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/>.</returns>
public static IServiceCollection AddMsalAuthentication<TRemoteAuthenticationState, TAccount>(this IServiceCollection services, Action<RemoteAuthenticationOptions<MsalProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TAccount : RemoteUserAccount
{
services.AddRemoteAuthentication<RemoteAuthenticationState, TAccount, MsalProviderOptions>(configure);
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<RemoteAuthenticationOptions<MsalProviderOptions>>, MsalDefaultOptionsConfiguration>());
return services;

View File

@ -0,0 +1,20 @@
// 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.
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal
{
/// <summary>
/// This is an internal API that supports the Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// infrastructure and not subject to the same compatibility standards as public APIs.
/// It may be changed or removed without notice in any release.
/// </summary>
public interface IAccessTokenProviderAccessor
{
/// <summary>
/// This is an internal API that supports the Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// infrastructure and not subject to the same compatibility standards as public APIs.
/// It may be changed or removed without notice in any release.
/// </summary>
IAccessTokenProvider TokenProvider { get; }
}
}

View File

@ -0,0 +1,19 @@
// 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 Microsoft.AspNetCore.Components.WebAssembly.Authentication;
namespace Microsoft.Extensions.DependencyInjection
{
/// <summary>
/// An interface for configuring remote authentication services.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The remote authentication state type.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
public interface IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
{
IServiceCollection Services { get; }
}
}

View File

@ -48,27 +48,49 @@ export enum AuthenticationResultStatus {
export interface AuthenticationResult {
status: AuthenticationResultStatus;
state?: any;
state?: unknown;
message?: string;
}
export interface AuthorizeService {
getUser(): Promise<any>;
getUser(): Promise<unknown>;
getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
signIn(state: any): Promise<AuthenticationResult>;
completeSignIn(state: any): Promise<AuthenticationResult>;
signOut(state: any): Promise<AuthenticationResult>;
signIn(state: unknown): Promise<AuthenticationResult>;
completeSignIn(state: unknown): Promise<AuthenticationResult>;
signOut(state: unknown): Promise<AuthenticationResult>;
completeSignOut(url: string): Promise<AuthenticationResult>;
}
class OidcAuthorizeService implements AuthorizeService {
private _userManager: UserManager;
private _intialSilentSignIn: Promise<void> | undefined;
constructor(userManager: UserManager) {
this._userManager = userManager;
}
async trySilentSignIn() {
if (!this._intialSilentSignIn) {
this._intialSilentSignIn = (async () => {
try {
await this._userManager.signinSilent();
} catch (e) {
// It is ok to swallow the exception here.
// The user might not be logged in and in that case it
// is expected for signinSilent to fail and throw
}
})();
}
return this._intialSilentSignIn;
}
async getUser() {
if (window.parent === window && !window.opener && !window.frameElement && this._userManager.settings.redirect_uri &&
!location.href.startsWith(this._userManager.settings.redirect_uri)) {
// If we are not inside a hidden iframe, try authenticating silently.
await AuthenticationService.instance.trySilentSignIn();
}
const user = await this._userManager.getUser();
return user && user.profile;
}
@ -120,7 +142,7 @@ class OidcAuthorizeService implements AuthorizeService {
function hasAllScopes(request: AccessTokenRequestOptions | undefined, currentScopes: string[]) {
const set = new Set(currentScopes);
if (request && request.scopes) {
for (let current of request.scopes) {
for (const current of request.scopes) {
if (!set.has(current)) {
return false;
}
@ -131,7 +153,7 @@ class OidcAuthorizeService implements AuthorizeService {
}
}
async signIn(state: any) {
async signIn(state: unknown) {
try {
await this._userManager.clearStaleState();
await this._userManager.signinSilent(this.createArguments());
@ -166,7 +188,7 @@ class OidcAuthorizeService implements AuthorizeService {
}
}
async signOut(state: any) {
async signOut(state: unknown) {
try {
if (!(await this._userManager.metadataService.getEndSessionEndpoint())) {
await this._userManager.removeUser();
@ -212,8 +234,8 @@ class OidcAuthorizeService implements AuthorizeService {
private async stateExists(url: string) {
const stateParam = new URLSearchParams(new URL(url).search).get('state');
if (stateParam) {
return await this._userManager.settings.stateStore!.get(stateParam);
if (stateParam && this._userManager.settings.stateStore) {
return await this._userManager.settings.stateStore.get(stateParam);
} else {
return undefined;
}
@ -221,15 +243,15 @@ class OidcAuthorizeService implements AuthorizeService {
private async loginRequired(url: string) {
const errorParameter = new URLSearchParams(new URL(url).search).get('error');
if (errorParameter) {
const error = await this._userManager.settings.stateStore!.get(errorParameter);
if (errorParameter && this._userManager.settings.stateStore) {
const error = await this._userManager.settings.stateStore.get(errorParameter);
return error === 'login_required';
} else {
return false;
}
}
private createArguments(state?: any) {
private createArguments(state?: unknown) {
return { useReplaceToNavigate: true, data: state };
}
@ -237,7 +259,7 @@ class OidcAuthorizeService implements AuthorizeService {
return { status: AuthenticationResultStatus.Failure, errorMessage: message };
}
private success(state: any) {
private success(state: unknown) {
return { status: AuthenticationResultStatus.Success, state };
}
@ -253,21 +275,57 @@ class OidcAuthorizeService implements AuthorizeService {
export class AuthenticationService {
static _infrastructureKey = 'Microsoft.AspNetCore.Components.WebAssembly.Authentication';
static _initialized : Promise<void>;
static _initialized: Promise<void>;
static instance: OidcAuthorizeService;
static _pendingOperations: { [key: string]: Promise<AuthenticationResult> | undefined } = {}
public static async init(settings: UserManagerSettings & AuthorizeServiceSettings) {
public static init(settings: UserManagerSettings & AuthorizeServiceSettings) {
// Multiple initializations can start concurrently and we want to avoid that.
// In order to do so, we create an initialization promise and the first call to init
// tries to initialize the app and sets up a promise other calls can await on.
if (!AuthenticationService._initialized) {
this._initialized = (async () => {
const userManager = await this.createUserManager(settings);
AuthenticationService.instance = new OidcAuthorizeService(userManager);
})();
AuthenticationService._initialized = AuthenticationService.initializeCore(settings);
}
await this._initialized;
return AuthenticationService._initialized;
}
public static handleCallback() {
return AuthenticationService.initializeCore();
}
private static async initializeCore(settings?: UserManagerSettings & AuthorizeServiceSettings) {
const finalSettings = settings || AuthenticationService.resolveCachedSettings();
if (!settings && finalSettings) {
const userManager = AuthenticationService.createUserManagerCore(finalSettings);
if (window.parent !== window && !window.opener && (window.frameElement && userManager.settings.redirect_uri &&
location.href.startsWith(userManager.settings.redirect_uri))) {
// If we are inside a hidden iframe, try completing the sign in early.
// This prevents loading the blazor app inside a hidden iframe, which speeds up the authentication operations
// and avoids wasting resources (CPU and memory from bootstrapping the Blazor app)
AuthenticationService.instance = new OidcAuthorizeService(userManager);
// This makes sure that if the blazor app has time to load inside the hidden iframe,
// it is not able to perform another auth operation until this operation has completed.
AuthenticationService._initialized = (async (): Promise<void> => {
await AuthenticationService.instance.completeSignIn(location.href);
return;
})();
}
} else if (settings) {
const userManager = await AuthenticationService.createUserManager(settings);
AuthenticationService.instance = new OidcAuthorizeService(userManager);
} else {
// HandleCallback gets called unconditionally, so we do nothing for normal paths.
// Cached settings are only used on handling the redirect_uri path and if the settings are not there
// the app will fallback to the default logic for handling the redirect.
}
}
private static resolveCachedSettings(): UserManagerSettings | undefined {
const cachedSettings = window.sessionStorage.getItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`);
return cachedSettings ? JSON.parse(cachedSettings) : undefined;
}
public static getUser() {
@ -278,37 +336,46 @@ export class AuthenticationService {
return AuthenticationService.instance.getAccessToken();
}
public static signIn(state: any) {
public static signIn(state: unknown) {
return AuthenticationService.instance.signIn(state);
}
public static completeSignIn(url: string) {
return AuthenticationService.instance.completeSignIn(url);
public static async completeSignIn(url: string) {
let operation = this._pendingOperations[url];
if (!operation) {
operation = AuthenticationService.instance.completeSignIn(url);
await operation;
delete this._pendingOperations[url];
}
return operation;
}
public static signOut(state: any) {
public static signOut(state: unknown) {
return AuthenticationService.instance.signOut(state);
}
public static completeSignOut(url: string) {
return AuthenticationService.instance.completeSignOut(url);
public static async completeSignOut(url: string) {
let operation = this._pendingOperations[url];
if (!operation) {
operation = AuthenticationService.instance.completeSignOut(url);
await operation;
delete this._pendingOperations[url];
}
return operation;
}
private static async createUserManager(settings: OidcAuthorizeServiceSettings): Promise<UserManager> {
let finalSettings: UserManagerSettings;
if (isApiAuthorizationSettings(settings)) {
let response = await fetch(settings.configurationEndpoint);
const response = await fetch(settings.configurationEndpoint);
if (!response.ok) {
throw new Error(`Could not load settings from '${settings.configurationEndpoint}'`);
}
const downloadedSettings = await response.json();
window.sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`, JSON.stringify(settings));
downloadedSettings.automaticSilentRenew = true;
downloadedSettings.includeIdTokenInSilentRenew = true;
finalSettings = downloadedSettings;
} else {
if (!settings.scope) {
@ -323,18 +390,24 @@ export class AuthenticationService {
finalSettings = settings;
}
window.sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`, JSON.stringify(finalSettings));
return AuthenticationService.createUserManagerCore(finalSettings);
}
private static createUserManagerCore(finalSettings: UserManagerSettings) {
const userManager = new UserManager(finalSettings);
userManager.events.addUserSignedOut(async () => {
await userManager.removeUser();
userManager.removeUser();
});
return userManager;
}
}
declare global {
interface Window { AuthenticationService: AuthenticationService; }
interface Window { AuthenticationService: AuthenticationService }
}
AuthenticationService.handleCallback();
window.AuthenticationService = AuthenticationService;

View File

@ -4,7 +4,7 @@
"build": "npm run build:release",
"build:release": "webpack --mode production --env.production --env.configuration=Release",
"build:debug": "webpack --mode development --env.configuration=Debug",
"watch": "webpack --watch --mode development"
"watch": "webpack --watch --mode development --env.configuration=Debug"
},
"devDependencies": {
"ts-loader": "^6.2.1",

View File

@ -0,0 +1,23 @@
// 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.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
{
/// <summary>
/// A user account.
/// </summary>
/// <remarks>
/// The information in this type will be use to produce a <see cref="System.Security.Claims.ClaimsPrincipal"/> for the application.
/// </remarks>
public class RemoteUserAccount
{
/// <summary>
/// Gets or sets properties not explicitly mapped about the user.
/// </summary>
[JsonExtensionData]
public IDictionary<string, object> AdditionalProperties { get; set; }
}
}

View File

@ -17,6 +17,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
options.AuthenticationPaths.RemoteRegisterPath ??= "Identity/Account/Register";
options.AuthenticationPaths.RemoteProfilePath ??= "Identity/Account/Manage";
options.UserOptions.ScopeClaim ??= "scope";
options.UserOptions.RoleClaim ??= "role";
options.UserOptions.AuthenticationType ??= _applicationName;
}

View File

@ -0,0 +1,17 @@
// 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 Microsoft.Extensions.DependencyInjection;
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
{
internal class RemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
: IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
{
public RemoteAuthenticationBuilder(IServiceCollection services) => Services = services;
public IServiceCollection Services { get; }
}
}

View File

@ -0,0 +1,55 @@
// 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 Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection
{
/// <summary>
/// Extensions for remote authentication services.
/// </summary>
public static class RemoteAuthenticationBuilderExtensions
{
/// <summary>
/// Replaces the existing <see cref="UserFactory{TAccount}"/> with the user factory defined by <typeparamref name="TUserFactory"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The remote authentication state.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
/// <typeparam name="TUserFactory">The new user factory type.</typeparam>
/// <param name="builder">The <see cref="IRemoteAuthenticationBuilder{TRemoteAuthenticationState, TAccount}"/>.</param>
/// <returns>The <see cref="IRemoteAuthenticationBuilder{TRemoteAuthenticationState, TAccount}"/>.</returns>
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddUserFactory<TRemoteAuthenticationState, TAccount, TUserFactory>(
this IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> builder)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TAccount : RemoteUserAccount
where TUserFactory : UserFactory<TAccount>
{
builder.Services.Replace(ServiceDescriptor.Scoped<UserFactory<TAccount>, TUserFactory>());
return builder;
}
/// <summary>
/// Replaces the existing <see cref="UserFactory{Account}"/> with the user factory defined by <typeparamref name="TUserFactory"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The remote authentication state.</typeparam>
/// <typeparam name="TUserFactory">The new user factory type.</typeparam>
/// <param name="builder">The <see cref="IRemoteAuthenticationBuilder{TRemoteAuthenticationState, Account}"/>.</param>
/// <returns>The <see cref="IRemoteAuthenticationBuilder{TRemoteAuthenticationState, Account}"/>.</returns>
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, RemoteUserAccount> AddUserFactory<TRemoteAuthenticationState, TUserFactory>(
this IRemoteAuthenticationBuilder<TRemoteAuthenticationState, RemoteUserAccount> builder)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TUserFactory : UserFactory<RemoteUserAccount> => builder.AddUserFactory<TRemoteAuthenticationState, RemoteUserAccount, TUserFactory>();
/// <summary>
/// Replaces the existing <see cref="UserFactory{TAccount}"/> with the user factory defined by <typeparamref name="TUserFactory"/>.
/// </summary>
/// <typeparam name="TUserFactory">The new user factory type.</typeparam>
/// <param name="builder">The <see cref="IRemoteAuthenticationBuilder{RemoteAuthenticationState, Account}"/>.</param>
/// <returns>The <see cref="IRemoteAuthenticationBuilder{RemoteAuthenticationState, Account}"/>.</returns>
public static IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> AddUserFactory<TUserFactory>(
this IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> builder)
where TUserFactory : UserFactory<RemoteUserAccount> => builder.AddUserFactory<RemoteAuthenticationState, RemoteUserAccount, TUserFactory>();
}
}

View File

@ -225,12 +225,14 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
{
State = AuthenticationState
});
switch (result.Status)
{
case RemoteAuthenticationStatus.Redirect:
break;
case RemoteAuthenticationStatus.Success:
await NavigateToReturnUrl(returnUrl);
await OnLogInSucceeded.InvokeAsync(result.State);
await NavigateToReturnUrl(GetReturnUrl(result.State, returnUrl));
break;
case RemoteAuthenticationStatus.Failure:
var uri = Navigation.ToAbsoluteUri($"{ApplicationPaths.LogInFailedPath}?message={Uri.EscapeDataString(result.ErrorMessage)}").ToString();
@ -253,10 +255,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// is when we are doing a redirect sign in flow.
throw new InvalidOperationException("Should not redirect.");
case RemoteAuthenticationStatus.Success:
if (OnLogInSucceeded.HasDelegate)
{
await OnLogInSucceeded.InvokeAsync(result.State);
}
await OnLogInSucceeded.InvokeAsync(result.State);
await NavigateToReturnUrl(GetReturnUrl(result.State));
break;
case RemoteAuthenticationStatus.OperationCompleted:
@ -292,6 +291,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
case RemoteAuthenticationStatus.Redirect:
break;
case RemoteAuthenticationStatus.Success:
await OnLogOutSucceeded.InvokeAsync(result.State);
await NavigateToReturnUrl(returnUrl);
break;
case RemoteAuthenticationStatus.OperationCompleted:
@ -320,10 +320,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// is when we are doing a redirect sign in flow.
throw new InvalidOperationException("Should not redirect.");
case RemoteAuthenticationStatus.Success:
if (OnLogOutSucceeded.HasDelegate)
{
await OnLogOutSucceeded.InvokeAsync(result.State);
}
await OnLogOutSucceeded.InvokeAsync(result.State);
await NavigateToReturnUrl(GetReturnUrl(result.State, Navigation.ToAbsoluteUri(ApplicationPaths.LogOutSucceededPath).ToString()));
break;
case RemoteAuthenticationStatus.OperationCompleted:

View File

@ -0,0 +1,18 @@
// 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 Microsoft.Extensions.DependencyInjection;
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal
{
internal class AccessTokenProviderAccessor : IAccessTokenProviderAccessor
{
private readonly IServiceProvider _provider;
private IAccessTokenProvider _tokenProvider;
public AccessTokenProviderAccessor(IServiceProvider provider) => _provider = provider;
public IAccessTokenProvider TokenProvider => _tokenProvider ??= _provider.GetRequiredService<IAccessTokenProvider>();
}
}

View File

@ -2,7 +2,6 @@
// 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.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
@ -17,15 +16,15 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The state to preserve across authentication operations.</typeparam>
/// <typeparam name="TProviderOptions">The options to be passed down to the underlying JavaScript library handling the authentication operations.</typeparam>
public class RemoteAuthenticationService<TRemoteAuthenticationState, TProviderOptions> :
public class RemoteAuthenticationService<TRemoteAuthenticationState, TAccount, TProviderOptions> :
AuthenticationStateProvider,
IRemoteAuthenticationService<TRemoteAuthenticationState>,
IAccessTokenProvider
where TRemoteAuthenticationState : RemoteAuthenticationState
where TProviderOptions : new()
where TRemoteAuthenticationState : RemoteAuthenticationState
where TProviderOptions : new()
where TAccount : RemoteUserAccount
{
private static readonly TimeSpan _userCacheRefreshInterval = TimeSpan.FromSeconds(60);
private bool _initialized = false;
// This defaults to 1/1/1970
@ -33,33 +32,42 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
private ClaimsPrincipal _cachedUser = new ClaimsPrincipal(new ClaimsIdentity());
/// <summary>
/// The <see cref="IJSRuntime"/> to use for performing JavaScript interop operations.
/// Gets the <see cref="IJSRuntime"/> to use for performing JavaScript interop operations.
/// </summary>
protected readonly IJSRuntime _jsRuntime;
protected IJSRuntime JsRuntime { get; }
/// <summary>
/// The <see cref="NavigationManager"/> used to compute absolute urls.
/// Gets the <see cref="NavigationManager"/> used to compute absolute urls.
/// </summary>
protected readonly NavigationManager _navigation;
protected NavigationManager Navigation { get; }
/// <summary>
/// The options for the underlying JavaScript library handling the authentication operations.
/// Gets the <see cref="UserFactory{TAccount}"/> to map accounts to <see cref="ClaimsPrincipal"/>.
/// </summary>
protected readonly RemoteAuthenticationOptions<TProviderOptions> _options;
protected UserFactory<TAccount> UserFactory { get; }
/// <summary>
/// Gets the options for the underlying JavaScript library handling the authentication operations.
/// </summary>
protected RemoteAuthenticationOptions<TProviderOptions> Options { get; }
/// <summary>
/// Initializes a new instance of <see cref="RemoteAuthenticationService{TRemoteAuthenticationState, TProviderOptions}"/>.
/// </summary>
/// <param name="jsRuntime">The <see cref="IJSRuntime"/> to use for performing JavaScript interop operations.</param>
/// <param name="options">The options to be passed down to the underlying JavaScript library handling the authentication operations.</param>
/// <param name="navigation">The <see cref="NavigationManager"/> used to generate URLs.</param>
/// <param name="userFactory">The <see cref="UserFactory{TAccount}"/> used to generate the <see cref="ClaimsPrincipal"/> for the user.</param>
public RemoteAuthenticationService(
IJSRuntime jsRuntime,
IOptions<RemoteAuthenticationOptions<TProviderOptions>> options,
NavigationManager navigation)
NavigationManager navigation,
UserFactory<TAccount> userFactory)
{
_jsRuntime = jsRuntime;
_navigation = navigation;
_options = options.Value;
JsRuntime = jsRuntime;
Navigation = navigation;
UserFactory = userFactory;
Options = options.Value;
}
/// <inheritdoc />
@ -70,11 +78,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
RemoteAuthenticationContext<TRemoteAuthenticationState> context)
{
await EnsureAuthService();
var internalResult = await _jsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.signIn", context.State);
var internalResult = await JsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.signIn", context.State);
var result = internalResult.Convert();
if (result.Status == RemoteAuthenticationStatus.Success)
{
UpdateUser(GetUser());
var getUserTask = GetUser();
await getUserTask;
UpdateUser(getUserTask);
}
return result;
@ -85,11 +95,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
RemoteAuthenticationContext<TRemoteAuthenticationState> context)
{
await EnsureAuthService();
var internalResult = await _jsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.completeSignIn", context.Url);
var internalResult = await JsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.completeSignIn", context.Url);
var result = internalResult.Convert();
if (result.Status == RemoteAuthenticationStatus.Success)
{
UpdateUser(GetUser());
var getUserTask = GetUser();
await getUserTask;
UpdateUser(getUserTask);
}
return result;
@ -100,11 +112,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
RemoteAuthenticationContext<TRemoteAuthenticationState> context)
{
await EnsureAuthService();
var internalResult = await _jsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.signOut", context.State);
var internalResult = await JsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.signOut", context.State);
var result = internalResult.Convert();
if (result.Status == RemoteAuthenticationStatus.Success)
{
UpdateUser(GetUser());
var getUserTask = GetUser();
await getUserTask;
UpdateUser(getUserTask);
}
return result;
@ -115,11 +129,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
RemoteAuthenticationContext<TRemoteAuthenticationState> context)
{
await EnsureAuthService();
var internalResult = await _jsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.completeSignOut", context.Url);
var internalResult = await JsRuntime.InvokeAsync<InternalRemoteAuthenticationResult<TRemoteAuthenticationState>>("AuthenticationService.completeSignOut", context.Url);
var result = internalResult.Convert();
if (result.Status == RemoteAuthenticationStatus.Success)
{
UpdateUser(GetUser());
var getUserTask = GetUser();
await getUserTask;
UpdateUser(getUserTask);
}
return result;
@ -129,7 +145,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
public virtual async ValueTask<AccessTokenResult> RequestAccessToken()
{
await EnsureAuthService();
var result = await _jsRuntime.InvokeAsync<InternalAccessTokenResult>("AuthenticationService.getAccessToken");
var result = await JsRuntime.InvokeAsync<InternalAccessTokenResult>("AuthenticationService.getAccessToken");
if (!Enum.TryParse<AccessTokenResultStatus>(result.Status, ignoreCase: true, out var parsedStatus))
{
@ -154,7 +170,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
}
await EnsureAuthService();
var result = await _jsRuntime.InvokeAsync<InternalAccessTokenResult>("AuthenticationService.getAccessToken", options);
var result = await JsRuntime.InvokeAsync<InternalAccessTokenResult>("AuthenticationService.getAccessToken", options);
if (!Enum.TryParse<AccessTokenResultStatus>(result.Status, ignoreCase: true, out var parsedStatus))
{
@ -172,13 +188,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
private Uri GetRedirectUrl(string customReturnUrl)
{
var returnUrl = customReturnUrl != null ? _navigation.ToAbsoluteUri(customReturnUrl).ToString() : null;
var encodedReturnUrl = Uri.EscapeDataString(returnUrl ?? _navigation.Uri);
var redirectUrl = _navigation.ToAbsoluteUri($"{_options.AuthenticationPaths.LogInPath}?returnUrl={encodedReturnUrl}");
var returnUrl = customReturnUrl != null ? Navigation.ToAbsoluteUri(customReturnUrl).ToString() : null;
var encodedReturnUrl = Uri.EscapeDataString(returnUrl ?? Navigation.Uri);
var redirectUrl = Navigation.ToAbsoluteUri($"{Options.AuthenticationPaths.LogInPath}?returnUrl={encodedReturnUrl}");
return redirectUrl;
}
private async ValueTask<ClaimsPrincipal> GetUser(bool useCache = false)
private async Task<ClaimsPrincipal> GetUser(bool useCache = false)
{
var now = DateTimeOffset.Now;
if (useCache && now < _userLastCheck + _userCacheRefreshInterval)
@ -196,56 +212,29 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// Gets the current authenticated used using JavaScript interop.
/// </summary>
/// <returns>A <see cref="Task{ClaimsPrincipal}"/>that will return the current authenticated user when completes.</returns>
protected internal virtual async Task<ClaimsPrincipal> GetAuthenticatedUser()
protected internal virtual async ValueTask<ClaimsPrincipal> GetAuthenticatedUser()
{
await EnsureAuthService();
var user = await _jsRuntime.InvokeAsync<IDictionary<string, object>>("AuthenticationService.getUser");
var account = await JsRuntime.InvokeAsync<TAccount>("AuthenticationService.getUser");
var user = await UserFactory.CreateUserAsync(account, Options.UserOptions);
var identity = user != null ? new ClaimsIdentity(
_options.UserOptions.AuthenticationType,
_options.UserOptions.NameClaim,
_options.UserOptions.RoleClaim) : new ClaimsIdentity();
if (user != null)
{
foreach (var kvp in user)
{
var name = kvp.Key;
var value = kvp.Value;
if (value != null)
{
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
identity.AddClaim(new Claim(name, JsonSerializer.Deserialize<object>(item.GetRawText()).ToString()));
}
}
else
{
identity.AddClaim(new Claim(name, value.ToString()));
}
}
}
}
return new ClaimsPrincipal(identity);
return user;
}
private async ValueTask EnsureAuthService()
{
if (!_initialized)
{
await _jsRuntime.InvokeVoidAsync("AuthenticationService.init", _options.ProviderOptions);
await JsRuntime.InvokeVoidAsync("AuthenticationService.init", Options.ProviderOptions);
_initialized = true;
}
}
private void UpdateUser(ValueTask<ClaimsPrincipal> task)
private void UpdateUser(Task<ClaimsPrincipal> task)
{
NotifyAuthenticationStateChanged(UpdateAuthenticationState(task));
static async Task<AuthenticationState> UpdateAuthenticationState(ValueTask<ClaimsPrincipal> futureUser) => new AuthenticationState(await futureUser);
static async Task<AuthenticationState> UpdateAuthenticationState(Task<ClaimsPrincipal> futureUser) => new AuthenticationState(await futureUser);
}
}

View File

@ -0,0 +1,59 @@
// 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.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
{
/// <summary>
/// Converts a <see cref="TAccount"/> into a <see cref="ClaimsPrincipal"/>.
/// </summary>
/// <typeparam name="TAccount">The account type.</typeparam>
public class UserFactory<TAccount> where TAccount : RemoteUserAccount
{
private readonly IAccessTokenProviderAccessor _accessor;
#pragma warning disable PUB0001 // Pubternal type in public API
public UserFactory(IAccessTokenProviderAccessor accessor) => _accessor = accessor;
/// <summary>
/// Gets or sets the <see cref="IAccessTokenProvider"/>.
/// </summary>
public IAccessTokenProvider TokenProvider => _accessor.TokenProvider;
/// <summary>
/// Converts the <paramref name="account"/> into the final <see cref="ClaimsPrincipal"/>.
/// </summary>
/// <param name="account">The <see cref="TAccount"/>.</param>
/// <param name="options">The <see cref="RemoteAuthenticationUserOptions"/> to configure the <see cref="ClaimsPrincipal"/> with.</param>
/// <returns>A <see cref="ValueTask{TResult}"/>that will contain the <see cref="ClaimsPrincipal"/> user when completed.</returns>
public virtual ValueTask<ClaimsPrincipal> CreateUserAsync(
TAccount account,
RemoteAuthenticationUserOptions options)
{
var identity = account != null ? new ClaimsIdentity(
options.AuthenticationType,
options.NameClaim,
options.RoleClaim) : new ClaimsIdentity();
if (account != null)
{
foreach (var kvp in account.AdditionalProperties)
{
var name = kvp.Key;
var value = kvp.Value;
if (value != null ||
(value is JsonElement element && element.ValueKind != JsonValueKind.Undefined && element.ValueKind != JsonValueKind.Null))
{
identity.AddClaim(new Claim(name, value.ToString()));
}
}
}
return new ValueTask<ClaimsPrincipal>(new ClaimsPrincipal(identity));
}
}
}

View File

@ -21,31 +21,35 @@ namespace Microsoft.Extensions.DependencyInjection
/// <typeparamref name="TRemoteAuthenticationState"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The state to be persisted across authentication operations.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
/// <typeparam name="TProviderOptions">The configuration options of the underlying provider being used for handling the authentication operations.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddRemoteAuthentication<TRemoteAuthenticationState, TProviderOptions>(this IServiceCollection services)
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddRemoteAuthentication<TRemoteAuthenticationState, TAccount, TProviderOptions>(this IServiceCollection services)
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
where TProviderOptions : class, new()
{
services.AddOptions();
services.AddAuthorizationCore();
services.TryAddSingleton<AuthenticationStateProvider, RemoteAuthenticationService<TRemoteAuthenticationState, TProviderOptions>>();
services.TryAddSingleton(sp =>
services.TryAddScoped<AuthenticationStateProvider, RemoteAuthenticationService<TRemoteAuthenticationState, TAccount, TProviderOptions>>();
services.TryAddScoped(sp =>
{
return (IRemoteAuthenticationService<TRemoteAuthenticationState>)sp.GetRequiredService<AuthenticationStateProvider>();
});
services.TryAddSingleton(sp =>
services.TryAddScoped(sp =>
{
return (IAccessTokenProvider)sp.GetRequiredService<AuthenticationStateProvider>();
});
services.TryAddSingleton<IRemoteAuthenticationPathsProvider, DefaultRemoteApplicationPathsProvider<TProviderOptions>>();
services.TryAddScoped<IRemoteAuthenticationPathsProvider, DefaultRemoteApplicationPathsProvider<TProviderOptions>>();
services.TryAddScoped<IAccessTokenProviderAccessor, AccessTokenProviderAccessor>();
services.TryAddScoped<SignOutSessionStateManager>();
services.TryAddSingleton<SignOutSessionStateManager>();
services.TryAddScoped<UserFactory<TAccount>>();
return services;
return new RemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>(services);
}
/// <summary>
@ -53,21 +57,23 @@ namespace Microsoft.Extensions.DependencyInjection
/// <typeparamref name="TRemoteAuthenticationState"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The state to be persisted across authentication operations.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
/// <typeparam name="TProviderOptions">The configuration options of the underlying provider being used for handling the authentication operations.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{TProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddRemoteAuthentication<TRemoteAuthenticationState, TProviderOptions>(this IServiceCollection services, Action<RemoteAuthenticationOptions<TProviderOptions>> configure)
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddRemoteAuthentication<TRemoteAuthenticationState, TAccount, TProviderOptions>(this IServiceCollection services, Action<RemoteAuthenticationOptions<TProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
where TProviderOptions : class, new()
{
services.AddRemoteAuthentication<RemoteAuthenticationState, TProviderOptions>();
services.AddRemoteAuthentication<TRemoteAuthenticationState, TAccount, TProviderOptions>();
if (configure != null)
{
services.Configure(configure);
}
return services;
return new RemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>(services);
}
/// <summary>
@ -76,7 +82,7 @@ namespace Microsoft.Extensions.DependencyInjection
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{TProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, Action<RemoteAuthenticationOptions<OidcProviderOptions>> configure)
public static IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> AddOidcAuthentication(this IServiceCollection services, Action<RemoteAuthenticationOptions<OidcProviderOptions>> configure)
{
return AddOidcAuthentication<RemoteAuthenticationState>(services, configure);
}
@ -88,23 +94,37 @@ namespace Microsoft.Extensions.DependencyInjection
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{TProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddOidcAuthentication<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<OidcProviderOptions>> configure)
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, RemoteUserAccount> AddOidcAuthentication<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<OidcProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
{
return AddOidcAuthentication<TRemoteAuthenticationState, RemoteUserAccount>(services, configure);
}
/// <summary>
/// Adds support for authentication for SPA applications using <see cref="OidcProviderOptions"/> and the <see cref="RemoteAuthenticationState"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{TProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddOidcAuthentication<TRemoteAuthenticationState, TAccount>(this IServiceCollection services, Action<RemoteAuthenticationOptions<OidcProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TAccount : RemoteUserAccount
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<RemoteAuthenticationOptions<OidcProviderOptions>>, DefaultOidcOptionsConfiguration>());
return AddRemoteAuthentication<TRemoteAuthenticationState, OidcProviderOptions>(services, configure);
return AddRemoteAuthentication<TRemoteAuthenticationState, TAccount, OidcProviderOptions>(services, configure);
}
/// <summary>
/// Adds support for authentication for SPA applications using <see cref="ApiAuthorizationProviderOptions"/> and the <see cref="RemoteAuthenticationState"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddApiAuthorization(this IServiceCollection services)
public static IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> AddApiAuthorization(this IServiceCollection services)
{
return AddApiauthorizationCore<RemoteAuthenticationState>(services, configure: null, Assembly.GetCallingAssembly().GetName().Name);
return AddApiauthorizationCore<RemoteAuthenticationState, RemoteUserAccount>(services, configure: null, Assembly.GetCallingAssembly().GetName().Name);
}
/// <summary>
@ -113,10 +133,35 @@ namespace Microsoft.Extensions.DependencyInjection
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddApiAuthorization<TRemoteAuthenticationState>(this IServiceCollection services)
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, RemoteUserAccount> AddApiAuthorization<TRemoteAuthenticationState>(this IServiceCollection services)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
{
return AddApiauthorizationCore<TRemoteAuthenticationState>(services, configure: null, Assembly.GetCallingAssembly().GetName().Name);
return AddApiauthorizationCore<TRemoteAuthenticationState, RemoteUserAccount>(services, configure: null, Assembly.GetCallingAssembly().GetName().Name);
}
/// <summary>
/// Adds support for authentication for SPA applications using <see cref="ApiAuthorizationProviderOptions"/> and the <see cref="RemoteAuthenticationState"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddApiAuthorization<TRemoteAuthenticationState, TAccount>(this IServiceCollection services)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TAccount : RemoteUserAccount
{
return AddApiauthorizationCore<TRemoteAuthenticationState, TAccount>(services, configure: null, Assembly.GetCallingAssembly().GetName().Name);
}
/// <summary>
/// Adds support for authentication for SPA applications using <see cref="ApiAuthorizationProviderOptions"/> and the <see cref="RemoteAuthenticationState"/>.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{ApiAuthorizationProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IRemoteAuthenticationBuilder<RemoteAuthenticationState, RemoteUserAccount> AddApiAuthorization(this IServiceCollection services, Action<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> configure)
{
return AddApiauthorizationCore<RemoteAuthenticationState, RemoteUserAccount>(services, configure, Assembly.GetCallingAssembly().GetName().Name);
}
/// <summary>
@ -126,37 +171,41 @@ namespace Microsoft.Extensions.DependencyInjection
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{ApiAuthorizationProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddApiAuthorization(this IServiceCollection services, Action<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> configure)
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, RemoteUserAccount> AddApiAuthorization<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
{
return AddApiauthorizationCore<RemoteAuthenticationState>(services, configure, Assembly.GetCallingAssembly().GetName().Name);
return AddApiauthorizationCore<TRemoteAuthenticationState, RemoteUserAccount>(services, configure, Assembly.GetCallingAssembly().GetName().Name);
}
/// <summary>
/// Adds support for authentication for SPA applications using <see cref="ApiAuthorizationProviderOptions"/> and the <see cref="RemoteAuthenticationState"/>.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/> to add the services to.</param>
/// <param name="configure">An action that will configure the <see cref="RemoteAuthenticationOptions{ApiAuthorizationProviderOptions}"/>.</param>
/// <returns>The <see cref="IServiceCollection"/> where the services were registered.</returns>
public static IServiceCollection AddApiAuthorization<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> configure)
public static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddApiAuthorization<TRemoteAuthenticationState, TAccount>(this IServiceCollection services, Action<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TAccount : RemoteUserAccount
{
return AddApiauthorizationCore<TRemoteAuthenticationState>(services, configure, Assembly.GetCallingAssembly().GetName().Name);
return AddApiauthorizationCore<TRemoteAuthenticationState, TAccount>(services, configure, Assembly.GetCallingAssembly().GetName().Name);
}
private static IServiceCollection AddApiauthorizationCore<TRemoteAuthenticationState>(
private static IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount> AddApiauthorizationCore<TRemoteAuthenticationState, TAccount>(
IServiceCollection services,
Action<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> configure,
string inferredClientId)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
{
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IPostConfigureOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>, DefaultApiAuthorizationOptionsConfiguration>(_ =>
new DefaultApiAuthorizationOptionsConfiguration(inferredClientId)));
services.AddRemoteAuthentication<TRemoteAuthenticationState, ApiAuthorizationProviderOptions>(configure);
services.AddRemoteAuthentication<TRemoteAuthenticationState, TAccount, ApiAuthorizationProviderOptions>(configure);
return services;
return new RemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>(services);
}
}
}

View File

@ -4,11 +4,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
@ -21,10 +24,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.SignInResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -51,10 +55,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.SignInResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -77,10 +82,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.CompleteSignInResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -107,10 +113,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.CompleteSignInResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -133,10 +140,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.SignOutResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -163,10 +171,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.SignOutResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -189,10 +198,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.CompleteSignOutResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -219,10 +229,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.CompleteSignOutResult = new InternalRemoteAuthenticationResult<RemoteAuthenticationState>
@ -245,10 +256,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.GetAccessTokenResult = new InternalAccessTokenResult
@ -282,10 +294,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.GetAccessTokenResult = new InternalAccessTokenResult
@ -321,10 +334,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
var state = new RemoteAuthenticationState();
testJsRuntime.GetAccessTokenResult = new InternalAccessTokenResult
@ -361,12 +375,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()));
testJsRuntime.GetUserResult = null;
testJsRuntime.GetUserResult = default;
// Act
var result = await runtime.GetAuthenticatedUser();
@ -387,19 +402,22 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions();
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, CoolRoleAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new TestUserFactory(Mock.Of<IAccessTokenProviderAccessor>()));
var serializationOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true };
var serializedUser = JsonSerializer.Serialize(new
var account = new CoolRoleAccount
{
CoolName = "Alfred",
CoolRole = new[] { "admin", "cool", "fantastic" }
}, serializationOptions);
CoolRole = new[] { "admin", "cool", "fantastic" },
AdditionalProperties = new Dictionary<string, object>
{
["CoolName"] = JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize("Alfred"))
}
};
testJsRuntime.GetUserResult = JsonSerializer.Deserialize<IDictionary<string, object>>(serializedUser);
testJsRuntime.GetUserResult = account;
// Act
var result = await runtime.GetAuthenticatedUser();
@ -420,19 +438,22 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Arrange
var testJsRuntime = new TestJsRuntime();
var options = CreateOptions("scope");
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>(
var runtime = new RemoteAuthenticationService<RemoteAuthenticationState, CoolRoleAccount, OidcProviderOptions>(
testJsRuntime,
options,
new TestNavigationManager());
new TestNavigationManager(),
new TestUserFactory(Mock.Of<IAccessTokenProviderAccessor>()));
var serializationOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true };
var serializedUser = JsonSerializer.Serialize(new
var account = new CoolRoleAccount
{
CoolName = "Alfred",
CoolRole = new[] { "admin", "cool", "fantastic" }
}, serializationOptions);
CoolRole = new[] { "admin", "cool", "fantastic" },
AdditionalProperties = new Dictionary<string, object>
{
["CoolName"] = JsonSerializer.Deserialize<JsonElement>(JsonSerializer.Serialize("Alfred")),
}
};
testJsRuntime.GetUserResult = JsonSerializer.Deserialize<IDictionary<string, object>>(serializedUser);
testJsRuntime.GetUserResult = account;
testJsRuntime.GetAccessTokenResult = new InternalAccessTokenResult
{
Status = "success",
@ -509,22 +530,21 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
public InternalAccessTokenResult GetAccessTokenResult { get; set; }
public IDictionary<string, object> GetUserResult { get; set; }
public RemoteUserAccount GetUserResult { get; set; }
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object[] args)
{
PastInvocations.Add((identifier, args));
return new ValueTask<TValue>((TValue)GetInvocationResult<TValue>(identifier));
return new ValueTask<TValue>((TValue)GetInvocationResult(identifier));
}
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object[] args)
{
PastInvocations.Add((identifier, args));
return new ValueTask<TValue>((TValue)GetInvocationResult<TValue>(identifier));
return new ValueTask<TValue>((TValue)GetInvocationResult(identifier));
}
private object GetInvocationResult<TValue>(string identifier)
private object GetInvocationResult(string identifier)
{
switch (identifier)
{
@ -551,6 +571,35 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
}
}
internal class TestUserFactory : UserFactory<CoolRoleAccount>
{
public TestUserFactory(IAccessTokenProviderAccessor accessor) : base(accessor)
{
}
public override async ValueTask<ClaimsPrincipal> CreateUserAsync(
CoolRoleAccount account,
RemoteAuthenticationUserOptions options)
{
var user = await base.CreateUserAsync(account, options);
if (account.CoolRole != null)
{
foreach (var role in account.CoolRole)
{
((ClaimsIdentity)user.Identity).AddClaim(new Claim("CoolRole", role));
}
}
return user;
}
}
internal class CoolRoleAccount : RemoteUserAccount
{
public string[] CoolRole { get; set; }
}
internal class TestNavigationManager : NavigationManager
{
public TestNavigationManager() =>

View File

@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
@ -21,8 +22,8 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
public class RemoteAuthenticatorCoreTests
{
private const string _action = nameof(RemoteAuthenticatorViewCore<RemoteAuthenticationState>.Action);
private const string _onLogInSucceded = nameof(RemoteAuthenticatorViewCore<RemoteAuthenticationState>.OnLogInSucceeded);
private const string _onLogOutSucceeded = nameof(RemoteAuthenticatorViewCore<RemoteAuthenticationState>.OnLogOutSucceeded);
private const string _onLogInSucceded = nameof(RemoteAuthenticatorViewCore<RemoteAuthenticationState>.OnLogInSucceeded);
private const string _onLogOutSucceeded = nameof(RemoteAuthenticatorViewCore<RemoteAuthenticationState>.OnLogOutSucceeded);
[Fact]
public async Task AuthenticationManager_Throws_ForInvalidAction()
@ -242,7 +243,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
var (remoteAuthenticator, renderer, authServiceMock, jsRuntime) = CreateAuthenticationManager(
"https://www.example.com/base/authentication/logout?returnUrl=https://www.example.com/base/");
authServiceMock.GetAuthenticatedUserCallback = () => Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.GetAuthenticatedUserCallback = () => new ValueTask<ClaimsPrincipal>(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult<RemoteAuthenticationState>()
{
@ -269,7 +270,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
var (remoteAuthenticator, renderer, authServiceMock, jsRuntime) = CreateAuthenticationManager(
"https://www.example.com/base/authentication/logout");
authServiceMock.GetAuthenticatedUserCallback = () => Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.GetAuthenticatedUserCallback = () => new ValueTask<ClaimsPrincipal>(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult<RemoteAuthenticationState>()
{
@ -296,7 +297,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
var originalUrl = "https://www.example.com/base/authentication/login?returnUrl=https://www.example.com/base/fetchData";
var (remoteAuthenticator, renderer, authServiceMock, jsRuntime) = CreateAuthenticationManager(originalUrl);
authServiceMock.GetAuthenticatedUserCallback = () => Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.GetAuthenticatedUserCallback = () => new ValueTask<ClaimsPrincipal>(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult<RemoteAuthenticationState>()
{
@ -350,7 +351,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
var (remoteAuthenticator, renderer, authServiceMock, jsRuntime) = CreateAuthenticationManager(
"https://www.example.com/base/authentication/logout?returnUrl=https://www.example.com/base/fetchData");
authServiceMock.GetAuthenticatedUserCallback = () => Task.FromResult(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.GetAuthenticatedUserCallback = () => new ValueTask<ClaimsPrincipal>(new ClaimsPrincipal(new ClaimsIdentity("Test")));
authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult<RemoteAuthenticationState>()
{
@ -660,13 +661,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
}
}
private class TestRemoteAuthenticationService : RemoteAuthenticationService<RemoteAuthenticationState, OidcProviderOptions>
private class TestRemoteAuthenticationService : RemoteAuthenticationService<RemoteAuthenticationState, RemoteUserAccount, OidcProviderOptions>
{
public TestRemoteAuthenticationService(
IJSRuntime jsRuntime,
IOptions<RemoteAuthenticationOptions<OidcProviderOptions>> options,
TestNavigationManager navigationManager) :
base(jsRuntime, options, navigationManager)
base(jsRuntime, options, navigationManager, new UserFactory<RemoteUserAccount>(Mock.Of<IAccessTokenProviderAccessor>()))
{
}
@ -674,13 +675,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
public Func<RemoteAuthenticationContext<RemoteAuthenticationState>, Task<RemoteAuthenticationResult<RemoteAuthenticationState>>> CompleteSignInCallback { get; set; }
public Func<RemoteAuthenticationContext<RemoteAuthenticationState>, Task<RemoteAuthenticationResult<RemoteAuthenticationState>>> SignOutCallback { get; set; }
public Func<RemoteAuthenticationContext<RemoteAuthenticationState>, Task<RemoteAuthenticationResult<RemoteAuthenticationState>>> CompleteSignOutCallback { get; set; }
public Func<Task<ClaimsPrincipal>> GetAuthenticatedUserCallback { get; set; }
public Func<ValueTask<ClaimsPrincipal>> GetAuthenticatedUserCallback { get; set; }
public async override Task<AuthenticationState> GetAuthenticationStateAsync() => new AuthenticationState(await GetAuthenticatedUserCallback());
public override Task<RemoteAuthenticationResult<RemoteAuthenticationState>> CompleteSignInAsync(RemoteAuthenticationContext<RemoteAuthenticationState> context) => CompleteSignInCallback(context);
protected internal override Task<ClaimsPrincipal> GetAuthenticatedUser() => GetAuthenticatedUserCallback();
protected internal override ValueTask<ClaimsPrincipal> GetAuthenticatedUser() => GetAuthenticatedUserCallback();
public override Task<RemoteAuthenticationResult<RemoteAuthenticationState>> CompleteSignOutAsync(RemoteAuthenticationContext<RemoteAuthenticationState> context) => CompleteSignOutCallback(context);

View File

@ -2,13 +2,13 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop.WebAssembly;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
@ -61,7 +61,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
var user = options.Value.UserOptions;
Assert.Equal("Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests", user.AuthenticationType);
Assert.Equal("scope", user.ScopeClaim);
Assert.Null(user.RoleClaim);
Assert.Equal("role", user.RoleClaim);
Assert.Equal("name", user.NameClaim);
Assert.Equal(
@ -69,6 +69,131 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
options.Value.ProviderOptions.ConfigurationEndpoint);
}
[Fact]
public void ApiAuthorizationOptionsConfigurationCallback_GetsCalledOnce()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var calls = 0;
builder.Services.AddApiAuthorization(options =>
{
calls++;
});
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>>();
var user = options.Value.UserOptions;
Assert.Equal("Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests", user.AuthenticationType);
// Make sure that the defaults are applied on this overload
Assert.Equal("role", user.RoleClaim);
Assert.Equal(
"_configuration/Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests",
options.Value.ProviderOptions.ConfigurationEndpoint);
Assert.Equal(1, calls);
}
[Fact]
public void ApiAuthorizationTestAuthenticationState_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var calls = 0;
builder.Services.AddApiAuthorization<TestAuthenticationState>(options => calls++);
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>>();
var user = options.Value.UserOptions;
// Make sure that the defaults are applied on this overload
Assert.Equal("role", user.RoleClaim);
Assert.Equal(
"_configuration/Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests",
options.Value.ProviderOptions.ConfigurationEndpoint);
var authenticationService = host.Services.GetService<IRemoteAuthenticationService<TestAuthenticationState>>();
Assert.NotNull(authenticationService);
Assert.IsType<RemoteAuthenticationService<TestAuthenticationState, RemoteUserAccount, ApiAuthorizationProviderOptions>>(authenticationService);
Assert.Equal(1, calls);
}
[Fact]
public void ApiAuthorizationTestAuthenticationState_NoCallback_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
builder.Services.AddApiAuthorization<TestAuthenticationState>();
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>>();
var user = options.Value.UserOptions;
// Make sure that the defaults are applied on this overload
Assert.Equal("role", user.RoleClaim);
Assert.Equal(
"_configuration/Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests",
options.Value.ProviderOptions.ConfigurationEndpoint);
var authenticationService = host.Services.GetService<IRemoteAuthenticationService<TestAuthenticationState>>();
Assert.NotNull(authenticationService);
Assert.IsType<RemoteAuthenticationService<TestAuthenticationState, RemoteUserAccount, ApiAuthorizationProviderOptions>>(authenticationService);
}
[Fact]
public void ApiAuthorizationCustomAuthenticationStateAndAccount_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var calls = 0;
builder.Services.AddApiAuthorization<TestAuthenticationState, TestAccount>(options => calls++);
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>>();
var user = options.Value.UserOptions;
// Make sure that the defaults are applied on this overload
Assert.Equal("role", user.RoleClaim);
Assert.Equal(
"_configuration/Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests",
options.Value.ProviderOptions.ConfigurationEndpoint);
var authenticationService = host.Services.GetService<IRemoteAuthenticationService<TestAuthenticationState>>();
Assert.NotNull(authenticationService);
Assert.IsType<RemoteAuthenticationService<TestAuthenticationState, TestAccount, ApiAuthorizationProviderOptions>>(authenticationService);
Assert.Equal(1, calls);
}
[Fact]
public void ApiAuthorizationTestAuthenticationStateAndAccount_NoCallback_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
builder.Services.AddApiAuthorization<TestAuthenticationState, TestAccount>();
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>>>();
var user = options.Value.UserOptions;
// Make sure that the defaults are applied on this overload
Assert.Equal("role", user.RoleClaim);
Assert.Equal(
"_configuration/Microsoft.AspNetCore.Components.WebAssembly.Authentication.Tests",
options.Value.ProviderOptions.ConfigurationEndpoint);
var authenticationService = host.Services.GetService<IRemoteAuthenticationService<TestAuthenticationState>>();
Assert.NotNull(authenticationService);
Assert.IsType<RemoteAuthenticationService<TestAuthenticationState, TestAccount, ApiAuthorizationProviderOptions>>(authenticationService);
}
[Fact]
public void ApiAuthorizationOptions_DefaultsCanBeOverriden()
{
@ -236,6 +361,67 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
Assert.Equal("https://www.example.com/base/custom-logout", provider.PostLogoutRedirectUri);
}
[Fact]
public void AddOidc_ConfigurationGetsCalledOnce()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var calls = 0;
builder.Services.AddOidcAuthentication(options => calls++);
builder.Services.Replace(ServiceDescriptor.Singleton(typeof(NavigationManager), new TestNavigationManager()));
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<OidcProviderOptions>>>();
Assert.Equal("name", options.Value.UserOptions.NameClaim);
Assert.Equal(1, calls);
}
[Fact]
public void AddOidc_CustomState_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var calls = 0;
builder.Services.AddOidcAuthentication<TestAuthenticationState>(options => options.ProviderOptions.Authority = (++calls).ToString());
builder.Services.Replace(ServiceDescriptor.Singleton(typeof(NavigationManager), new TestNavigationManager()));
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<OidcProviderOptions>>>();
// Make sure options are applied
Assert.Equal("name", options.Value.UserOptions.NameClaim);
Assert.Equal("1", options.Value.ProviderOptions.Authority);
var authenticationService = host.Services.GetService<IRemoteAuthenticationService<TestAuthenticationState>>();
Assert.NotNull(authenticationService);
Assert.IsType<RemoteAuthenticationService<TestAuthenticationState, RemoteUserAccount, OidcProviderOptions>>(authenticationService);
}
[Fact]
public void AddOidc_CustomStateAndAccount_SetsUpConfiguration()
{
var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker());
var calls = 0;
builder.Services.AddOidcAuthentication<TestAuthenticationState, TestAccount>(options => options.ProviderOptions.Authority = (++calls).ToString());
builder.Services.Replace(ServiceDescriptor.Singleton(typeof(NavigationManager), new TestNavigationManager()));
var host = builder.Build();
var options = host.Services.GetRequiredService<IOptions<RemoteAuthenticationOptions<OidcProviderOptions>>>();
// Make sure options are applied
Assert.Equal("name", options.Value.UserOptions.NameClaim);
Assert.Equal("1", options.Value.ProviderOptions.Authority);
var authenticationService = host.Services.GetService<IRemoteAuthenticationService<TestAuthenticationState>>();
Assert.NotNull(authenticationService);
Assert.IsType<RemoteAuthenticationService<TestAuthenticationState, TestAccount, OidcProviderOptions>>(authenticationService);
}
private class TestNavigationManager : NavigationManager
{
public TestNavigationManager()
@ -245,5 +431,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
protected override void NavigateToCore(string uri, bool forceLoad) => throw new System.NotImplementedException();
}
private class TestAuthenticationState : RemoteAuthenticationState
{
}
private class TestAccount : RemoteUserAccount
{
}
}
}

View File

@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
namespace Wasm.Authentication.Client
{
public class OidcAccount : RemoteUserAccount
{
[JsonPropertyName("amr")]
public string[] AuthenticationMethod { get; set; }
}
}

View File

@ -0,0 +1,47 @@
@page "/admin-settings"
@attribute [Authorize(Roles = "admin")]
@inject IAccessTokenProvider TokenProvider
@inject NavigationManager Navigation
@if(_error == null)
{
<button id="admin-action" @onclick="AdminAction">Perform admin action</button>
}
else if(_error == true)
{
<p>Could not get the access token.</p>
}
else if (_error == false)
{
<p id="admin-success">Successfully perfomed admin action.</p>
}
@code {
private bool? _error;
public async Task AdminAction()
{
var tokenResult = await TokenProvider.RequestAccessToken();
if (tokenResult.TryGetToken(out var token))
{
var client = new HttpClient() { BaseAddress = new Uri(Navigation.BaseUri) };
var request = new HttpRequestMessage(HttpMethod.Post, "Roles/AdminOnly");
request.Headers.Add("Authorization", $"Bearer {token.Value}");
var response = await client.SendAsync(request);
if (response.StatusCode != System.Net.HttpStatusCode.OK)
{
_error = true;
}
else
{
_error = false;
}
}
else
{
_error = true;
}
}
}

View File

@ -1,7 +1,43 @@
@page "/authentication/{action}"
@inject StateService State
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager Navigation
<RemoteAuthenticatorView Action="@Action" />
<RemoteAuthenticatorViewCore TAuthenticationState="RemoteAppState"
AuthenticationState="AppState"
OnLogInSucceeded="CompleteLogin"
Action="@Action" />
@code{
[Parameter] public string Action { get; set; }
public RemoteAppState AppState { get; set; } = new RemoteAppState();
protected override void OnInitialized()
{
if (RemoteAuthenticationActions.IsAction(RemoteAuthenticationActions.LogIn, Action))
{
AppState.State = State.GetCurrentState();
}
base.OnInitialized();
}
public async Task CompleteLogin(RemoteAppState remoteState)
{
if (RemoteAuthenticationActions.IsAction(RemoteAuthenticationActions.LogInCallback, Action))
{
State.RestoreCurrentState(remoteState.State);
}
var userState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = userState.User;
if (user.HasClaim("NewUser", "true"))
{
var originalReturnUrl = remoteState.ReturnUrl;
var preferencesUrl = Navigation.ToAbsoluteUri("preferences");
remoteState.ReturnUrl = $"{preferencesUrl}?returnUrl={Uri.EscapeDataString(originalReturnUrl)}";
}
}
}

View File

@ -1,5 +1,27 @@
@page "/"
@inject StateService State
@inject IJSRuntime JS
<h1>Hello, world!</h1>
Welcome to your new app.
Current state is:
<p id="app-state">@State.GetCurrentState()</p>
<!-- Elements to help testing functionality -->
<p id="test-helpers">
<button id="test-clear-storage" @onclick="ClearStorage">Clear storage</button>
<button id="test-refresh-page" @onclick="TriggerPageRefresh">Refresh page</button>
</p>
@code{
public async Task ClearStorage()
{
await JS.InvokeVoidAsync("sessionStorage.clear");
}
public async Task TriggerPageRefresh()
{
await JS.InvokeVoidAsync("location.reload", true);
}
}

View File

@ -0,0 +1,37 @@
@page "/new-admin"
@attribute [Authorize]
@inject IAccessTokenProvider TokenProvider
@inject NavigationManager Navigation
@if (_error == true)
{
<p>Could not get the access token.</p>
}
else if (_error == false)
{
<p>Successfully added to the admin group.</p>
}
@code {
private bool? _error;
protected override async Task OnInitializedAsync()
{
var tokenResult = await TokenProvider.RequestAccessToken();
if (tokenResult.TryGetToken(out var token))
{
var client = new HttpClient() { BaseAddress = new Uri(Navigation.BaseUri) };
var request = new HttpRequestMessage(HttpMethod.Post, "Roles/MakeAdmin");
request.Headers.Add("Authorization", $"Bearer {token.Value}");
var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode();
}
else
{
_error = true;
}
}
}

View File

@ -0,0 +1,47 @@
@page "/preferences"
@attribute [Authorize]
@using System.Text.Json;
@using System.Text;
@using System.Net.Http.Headers;
@inject NavigationManager Navigation
@inject IAccessTokenProvider AccessTokenProvider
<p>User preferences</p>
<input id="color-preference" type="text" @bind="Color" />
<button id="submit-preference" @onclick="SendPreferences">Send</button>
@code {
public string Color { get; set; }
public async Task SendPreferences()
{
var content = new StringContent(JsonSerializer.Serialize(new UserPreferences { Color = Color }), Encoding.UTF8, "application/json");
var tokenResponse = await AccessTokenProvider.RequestAccessToken();
if (tokenResponse.TryGetToken(out var token))
{
var client = new HttpClient { BaseAddress = new Uri(Navigation.BaseUri) };
var request = new HttpRequestMessage(HttpMethod.Post, "Preferences/AddPreferences");
request.Content = content;
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Value);
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var query = new Uri(Navigation.Uri).Query;
var hasReturnUrl = System.Text.RegularExpressions.Regex.Match(query, ".*?returnUrl=([^&]+).*");
if (hasReturnUrl.Success)
{
var returnUrl = hasReturnUrl.Groups[1];
Navigation.NavigateTo(Uri.UnescapeDataString(returnUrl.Value));
}
}
else
{
Navigation.NavigateTo("/");
}
}
}
}

View File

@ -0,0 +1,62 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;
namespace Wasm.Authentication.Client
{
public class PreferencesUserFactory : UserFactory<OidcAccount>
{
private readonly HttpClient _httpClient;
public PreferencesUserFactory(NavigationManager navigationManager, IAccessTokenProviderAccessor accessor)
: base(accessor)
{
_httpClient = new HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) };
}
public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
OidcAccount account,
RemoteAuthenticationUserOptions options)
{
var initialUser = await base.CreateUserAsync(account, options);
if (initialUser.Identity.IsAuthenticated)
{
foreach (var value in account.AuthenticationMethod)
{
((ClaimsIdentity)initialUser.Identity).AddClaim(new Claim("amr", value));
}
var tokenResponse = await TokenProvider.RequestAccessToken();
if (tokenResponse.TryGetToken(out var token))
{
var request = new HttpRequestMessage(HttpMethod.Get, "Preferences/HasCompletedAdditionalInformation");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Value);
var response = await _httpClient.SendAsync(request);
if (response.StatusCode != HttpStatusCode.OK)
{
throw new InvalidOperationException("Error accessing additional user info.");
}
var hasInfo = JsonSerializer.Deserialize<bool>(await response.Content.ReadAsStringAsync());
if (!hasInfo)
{
// The actual pattern would be to cache this info to avoid constant queries to the server per auth update.
// (By default once every minute)
((ClaimsIdentity)initialUser.Identity).AddClaim(new Claim("NewUser", "true"));
}
}
}
return initialUser;
}
}
}

View File

@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
@ -13,7 +14,10 @@ namespace Wasm.Authentication.Client
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddApiAuthorization();
builder.Services.AddApiAuthorization<RemoteAppState, OidcAccount>()
.AddUserFactory<RemoteAppState, OidcAccount, PreferencesUserFactory>();
builder.Services.AddSingleton<StateService>();
builder.RootComponents.Add<App>("app");

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
namespace Wasm.Authentication.Client
{
public class RemoteAppState : RemoteAuthenticationState
{
public string State { get; set; }
}
}

View File

@ -27,6 +27,17 @@
<span class="oi oi-list-rich" aria-hidden="true"></span> User
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="new-admin">
<span class="oi oi-list-rich" aria-hidden="true"></span> Make admin
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="admin-settings">
<span class="oi oi-list-rich" aria-hidden="true"></span> Settings
</NavLink>
</li>
</ul>
</div>

View File

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Wasm.Authentication.Client
{
public class StateService
{
private string _state;
public string GetCurrentState() => _state ??= Guid.NewGuid().ToString();
public void RestoreCurrentState(string state) => _state = state;
}
}

View File

@ -21,5 +21,4 @@
<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

View File

@ -0,0 +1,54 @@
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Internal;
using Wasm.Authentication.Server.Data;
using Wasm.Authentication.Server.Models;
namespace Wasm.Authentication.Server.Controllers
{
[ApiController]
[Authorize]
public class PreferencesController : ControllerBase
{
private readonly ApplicationDbContext _context;
public PreferencesController(ApplicationDbContext context)
{
_context = context;
}
[HttpGet("[controller]/[action]")]
public IActionResult HasCompletedAdditionalInformation()
{
var id = User.FindFirst(ClaimTypes.NameIdentifier);
if (!_context.UserPreferences.Where(u => u.ApplicationUserId == id.Value).Any())
{
return Ok(false);
}
else
{
return Ok(true);
}
}
[HttpPost("[controller]/[action]")]
public async Task<IActionResult> AddPreferences([FromBody] UserPreference preferences)
{
var id = User.FindFirst(ClaimTypes.NameIdentifier);
if (!_context.UserPreferences.Where(u => u.ApplicationUserId == id.Value).Any())
{
preferences.ApplicationUserId = id.Value;
_context.UserPreferences.Add(preferences);
await _context.SaveChangesAsync();
return Ok();
}
else
{
return BadRequest();
}
}
}
}

View File

@ -0,0 +1,56 @@
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Wasm.Authentication.Server.Models;
namespace Wasm.Authentication.Server.Controllers
{
[Authorize]
public class RolesController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IOptions<IdentityOptions> _options;
public RolesController(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IOptions<IdentityOptions> options)
{
_userManager = userManager;
_roleManager = roleManager;
_options = options;
}
[HttpPost("[controller]/[action]")]
public async Task<IActionResult> MakeAdmin()
{
var admin = await _roleManager.FindByNameAsync("admin");
if (admin == null)
{
await _roleManager.CreateAsync(new IdentityRole { Name = "admin" });
}
var id = User.FindFirst(ClaimTypes.NameIdentifier);
if (id == null)
{
return BadRequest();
}
var currentUser = await _userManager.FindByIdAsync(id.Value);
await _userManager.AddToRoleAsync(currentUser, "admin");
return Ok();
}
[HttpPost("[controller]/[action]")]
[Authorize(Roles = "admin")]
public IActionResult AdminOnly()
{
return Ok();
}
}
}

View File

@ -13,5 +13,21 @@ namespace Wasm.Authentication.Server.Data
IOptions<OperationalStoreOptions> operationalStoreOptions) : base(options, operationalStoreOptions)
{
}
public DbSet<UserPreference> UserPreferences { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<ApplicationUser>().HasOne(u => u.UserPreference);
builder.Entity<UserPreference>()
.Property(u => u.Id).ValueGeneratedOnAdd();
builder.Entity<UserPreference>()
.HasKey(p => p.Id);
}
}
}

View File

@ -9,14 +9,14 @@ using Wasm.Authentication.Server.Data;
namespace Wasm.Authentication.Server.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20200123141439_Initial")]
[Migration("20200324213904_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.0");
.HasAnnotation("ProductVersion", "3.1.2");
modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.DeviceFlowCodes", b =>
{
@ -296,6 +296,26 @@ namespace Wasm.Authentication.Server.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Wasm.Authentication.Server.Models.UserPreference", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("UserPreferences");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -346,6 +366,13 @@ namespace Wasm.Authentication.Server.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Wasm.Authentication.Server.Models.UserPreference", b =>
{
b.HasOne("Wasm.Authentication.Server.Models.ApplicationUser", null)
.WithOne("UserPreference")
.HasForeignKey("Wasm.Authentication.Server.Models.UserPreference", "ApplicationUserId");
});
#pragma warning restore 612, 618
}
}

View File

@ -186,6 +186,25 @@ namespace Wasm.Authentication.Server.Data.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserPreferences",
columns: table => new
{
Id = table.Column<string>(nullable: false),
ApplicationUserId = table.Column<string>(nullable: true),
Color = table.Column<string>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPreferences", x => x.Id);
table.ForeignKey(
name: "FK_UserPreferences_AspNetUsers_ApplicationUserId",
column: x => x.ApplicationUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
@ -243,6 +262,12 @@ namespace Wasm.Authentication.Server.Data.Migrations
name: "IX_PersistedGrants_SubjectId_ClientId_Type",
table: "PersistedGrants",
columns: new[] { "SubjectId", "ClientId", "Type" });
migrationBuilder.CreateIndex(
name: "IX_UserPreferences_ApplicationUserId",
table: "UserPreferences",
column: "ApplicationUserId",
unique: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
@ -268,6 +293,9 @@ namespace Wasm.Authentication.Server.Data.Migrations
migrationBuilder.DropTable(
name: "PersistedGrants");
migrationBuilder.DropTable(
name: "UserPreferences");
migrationBuilder.DropTable(
name: "AspNetRoles");

View File

@ -14,7 +14,7 @@ namespace Wasm.Authentication.Server.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.1.0");
.HasAnnotation("ProductVersion", "3.1.2");
modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.DeviceFlowCodes", b =>
{
@ -294,6 +294,26 @@ namespace Wasm.Authentication.Server.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("Wasm.Authentication.Server.Models.UserPreference", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ApplicationUserId")
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ApplicationUserId")
.IsUnique();
b.ToTable("UserPreferences");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -344,6 +364,13 @@ namespace Wasm.Authentication.Server.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Wasm.Authentication.Server.Models.UserPreference", b =>
{
b.HasOne("Wasm.Authentication.Server.Models.ApplicationUser", null)
.WithOne("UserPreference")
.HasForeignKey("Wasm.Authentication.Server.Models.UserPreference", "ApplicationUserId");
});
#pragma warning restore 612, 618
}
}

View File

@ -8,5 +8,6 @@ namespace Wasm.Authentication.Server.Models
{
public class ApplicationUser : IdentityUser
{
public UserPreference UserPreference { get; set; }
}
}

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Wasm.Authentication.Server.Models
{
public class UserPreference
{
public string Id { get; set; }
public string ApplicationUserId { get; set; }
public string Color { get; set; }
}
}

View File

@ -1,6 +1,9 @@
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@ -27,10 +30,17 @@ namespace Wasm.Authentication.Server
options.UseSqlite(Configuration.GetConnectionString("DefaultConnection")));
services.AddDefaultIdentity<ApplicationUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>(options => {
options.IdentityResources["openid"].UserClaims.Add("role");
options.ApiResources.Single().UserClaims.Add("role");
});
// Need to do this as it maps "role" to ClaimTypes.Role and causes issues
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("role");
services.AddAuthentication()
.AddIdentityServerJwt();

View File

@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI;
using Wasm.Authentication.Server;
using Wasm.Authentication.Server.Data;
@ -28,7 +27,7 @@ using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
public class WebAssemblyAuthenticationTests : ServerTestBase<AspNetSiteServerFixture>, IDisposable
public class WebAssemblyAuthenticationTests : ServerTestBase<AspNetSiteServerFixture>
{
private static readonly SqliteConnection _connection;
@ -55,15 +54,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
.Build();
}
public override Task InitializeAsync() => base.InitializeAsync(Guid.NewGuid().ToString());
protected override void InitializeAsyncCore()
{
Browser.Navigate().GoToUrl("data:");
Navigate("/", noReload: true);
EnsureDatabaseCreated(_serverFixture.Host.Services);
Browser.ExecuteJavaScript("sessionStorage.clear()");
Browser.ExecuteJavaScript("localStorage.clear()");
Browser.Manage().Cookies.DeleteAllCookies();
Browser.Navigate().Refresh();
WaitUntilLoaded();
}
@ -89,6 +85,50 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
ValidateFetchData();
}
[Fact]
public void CanPreserveApplicationState_DuringLogIn()
{
var originalAppState = Browser.Exists(By.Id("app-state")).Text;
var link = By.PartialLinkText("Fetch data");
var page = "/Identity/Account/Login";
ClickAndNavigate(link, page);
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
FirstTimeRegister(userName, password);
ValidateFetchData();
var homeLink = By.PartialLinkText("Home");
var homePage = "/";
ClickAndNavigate(homeLink, homePage);
var restoredAppState = Browser.Exists(By.Id("app-state")).Text;
Assert.Equal(originalAppState, restoredAppState);
}
[Fact]
public void CanShareUserRolesBetweenClientAndServer()
{
ClickAndNavigate(By.PartialLinkText("Log in"), "/Identity/Account/Login");
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
FirstTimeRegister(userName, password);
ClickAndNavigate(By.PartialLinkText("Make admin"), "/new-admin");
ClickAndNavigate(By.PartialLinkText("Settings"), "/admin-settings");
Browser.Exists(By.Id("admin-action")).Click();
Browser.Exists(By.Id("admin-success"));
}
private void ClickAndNavigate(By link, string page)
{
Browser.FindElement(link).Click();
@ -103,6 +143,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
RegisterCore(userName, password);
CompleteProfileDetails();
// Need to navigate to fetch page
Browser.FindElement(By.PartialLinkText("Fetch data")).Click();
@ -133,13 +174,14 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
.OrderBy(o => o.Item1)
.ToArray();
Assert.Equal(4, claims.Length);
Assert.Equal(5, claims.Length);
Assert.Equal(new[]
{
("amr", "pwd"),
("idp", "local"),
("name", userName),
("NewUser", "true"),
("preferred_username", userName)
},
claims);
@ -173,9 +215,9 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
RegisterCore(userName, password);
CompleteProfileDetails();
Browser.Exists(By.PartialLinkText($"Hello, {userName}!")).Click();
Browser.Contains("/Identity/Account/Manage", () => Browser.Url);
ClickAndNavigate(By.PartialLinkText($"Hello, {userName}!"), "/Identity/Account/Manage");
Browser.Navigate().Back();
Browser.Equal("/", () => new Uri(Browser.Url).PathAndQuery);
@ -215,6 +257,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
RegisterCore(userName, password);
CompleteProfileDetails();
ValidateLogout();
}
@ -227,6 +270,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
RegisterCore(userName, password);
CompleteProfileDetails();
ValidateLogout();
@ -252,14 +296,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
var userName = $"{Guid.NewGuid()}@example.com";
var password = $"!Test.Password1$";
RegisterCore(userName, password);
CompleteProfileDetails();
ValidateLoggedIn(userName);
// Clear the existing storage on the page and refresh
Browser.ExecuteJavaScript("sessionStorage.clear()");
Browser.Navigate().Refresh();
Browser.Exists(By.PartialLinkText("Log in"));
Browser.Exists(By.Id("test-clear-storage")).Click();
Browser.Exists(By.Id("test-refresh-page")).Click();
Browser.FindElement(By.PartialLinkText("Log in")).Click();
ValidateLoggedIn(userName);
}
@ -268,6 +311,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
Browser.Navigate().GoToUrl(new Uri(new Uri(Browser.Url), "/authentication/login?returnUrl=https%3A%2F%2Fwww.bing.com").AbsoluteUri);
WaitUntilLoaded(skipHeader: true);
Browser.Exists(By.CssSelector("[style=\"display: block;\"]"));
Assert.NotEmpty(Browser.GetBrowserLogs(LogLevel.Severe));
}
@ -322,6 +366,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
Browser.FindElement(By.PartialLinkText("Register as a new user")).Click();
RegisterCore(userName, password);
CompleteProfileDetails();
}
private void CompleteProfileDetails()
{
Browser.Exists(By.PartialLinkText("Home"));
Browser.Contains("/preferences", () => Browser.Url);
Browser.FindElement(By.Id("color-preference")).SendKeys("Red");
Browser.FindElement(By.Id("submit-preference")).Click();
}
private void RegisterCore(string userName, string password)
@ -410,13 +463,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
}
}
public void Dispose()
{
// Make the tests run faster by navigating back to the home page when we are done
// If we don't, then the next test will reload the whole page before it starts
Browser.FindElement(By.LinkText("Home")).Click();
}
private class JwtPayload
{
[JsonPropertyName("iss")]