diff --git a/src/Components/WebAssembly/Authentication.Msal/src/MsalWebAssemblyServiceCollectionExtensions.cs b/src/Components/WebAssembly/Authentication.Msal/src/MsalWebAssemblyServiceCollectionExtensions.cs index ff37a6875c..05e443bc98 100644 --- a/src/Components/WebAssembly/Authentication.Msal/src/MsalWebAssemblyServiceCollectionExtensions.cs +++ b/src/Components/WebAssembly/Authentication.Msal/src/MsalWebAssemblyServiceCollectionExtensions.cs @@ -37,7 +37,22 @@ namespace Microsoft.Extensions.DependencyInjection public static IServiceCollection AddMsalAuthentication(this IServiceCollection services, Action> configure) where TRemoteAuthenticationState : RemoteAuthenticationState, new() { - services.AddRemoteAuthentication(configure); + AddMsalAuthentication(services, configure); + return services; + } + + /// + /// Adds authentication using msal.js to Blazor applications. + /// + /// The type of the remote authentication state. + /// The . + /// The to configure the . + /// The . + public static IServiceCollection AddMsalAuthentication(this IServiceCollection services, Action> configure) + where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TAccount : RemoteUserAccount + { + services.AddRemoteAuthentication(configure); services.TryAddEnumerable(ServiceDescriptor.Singleton>, MsalDefaultOptionsConfiguration>()); return services; diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/IAccessTokenProviderAccessor.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/IAccessTokenProviderAccessor.cs new file mode 100644 index 0000000000..5e20c27cfd --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/IAccessTokenProviderAccessor.cs @@ -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 +{ + /// + /// 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. + /// + public interface IAccessTokenProviderAccessor + { + /// + /// 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. + /// + IAccessTokenProvider TokenProvider { get; } + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/IRemoteAuthenticationBuilder.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/IRemoteAuthenticationBuilder.cs new file mode 100644 index 0000000000..56fd2777ee --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/IRemoteAuthenticationBuilder.cs @@ -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 +{ + /// + /// An interface for configuring remote authentication services. + /// + /// The remote authentication state type. + /// The account type. + public interface IRemoteAuthenticationBuilder + where TRemoteAuthenticationState : RemoteAuthenticationState + where TAccount : RemoteUserAccount + { + IServiceCollection Services { get; } + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/AuthenticationService.ts b/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/AuthenticationService.ts index e5348b6c11..cb1b34ae52 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/AuthenticationService.ts +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/AuthenticationService.ts @@ -48,27 +48,49 @@ export enum AuthenticationResultStatus { export interface AuthenticationResult { status: AuthenticationResultStatus; - state?: any; + state?: unknown; message?: string; } export interface AuthorizeService { - getUser(): Promise; + getUser(): Promise; getAccessToken(request?: AccessTokenRequestOptions): Promise; - signIn(state: any): Promise; - completeSignIn(state: any): Promise; - signOut(state: any): Promise; + signIn(state: unknown): Promise; + completeSignIn(state: unknown): Promise; + signOut(state: unknown): Promise; completeSignOut(url: string): Promise; } class OidcAuthorizeService implements AuthorizeService { private _userManager: UserManager; - + private _intialSilentSignIn: Promise | 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; + static _initialized: Promise; static instance: OidcAuthorizeService; + static _pendingOperations: { [key: string]: Promise | 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 => { + 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 { 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; diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/package.json b/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/package.json index dd40f447ed..da5588d519 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/package.json +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/package.json @@ -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", diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Models/RemoteUserAccount.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/Models/RemoteUserAccount.cs new file mode 100644 index 0000000000..0218aebce3 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Models/RemoteUserAccount.cs @@ -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 +{ + /// + /// A user account. + /// + /// + /// The information in this type will be use to produce a for the application. + /// + public class RemoteUserAccount + { + /// + /// Gets or sets properties not explicitly mapped about the user. + /// + [JsonExtensionData] + public IDictionary AdditionalProperties { get; set; } + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Options/DefaultApiAuthorizationOptionsConfiguration.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/Options/DefaultApiAuthorizationOptionsConfiguration.cs index 0b1f15f89c..a1cd533c1e 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/src/Options/DefaultApiAuthorizationOptionsConfiguration.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Options/DefaultApiAuthorizationOptionsConfiguration.cs @@ -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; } diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticationBuilder.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticationBuilder.cs new file mode 100644 index 0000000000..a321a37d78 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticationBuilder.cs @@ -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 + : IRemoteAuthenticationBuilder + where TRemoteAuthenticationState : RemoteAuthenticationState + where TAccount : RemoteUserAccount + { + public RemoteAuthenticationBuilder(IServiceCollection services) => Services = services; + + public IServiceCollection Services { get; } + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticationBuilderExtensions.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000000..9040a726a7 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticationBuilderExtensions.cs @@ -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 +{ + /// + /// Extensions for remote authentication services. + /// + public static class RemoteAuthenticationBuilderExtensions + { + /// + /// Replaces the existing with the user factory defined by . + /// + /// The remote authentication state. + /// The account type. + /// The new user factory type. + /// The . + /// The . + public static IRemoteAuthenticationBuilder AddUserFactory( + this IRemoteAuthenticationBuilder builder) + where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TAccount : RemoteUserAccount + where TUserFactory : UserFactory + { + builder.Services.Replace(ServiceDescriptor.Scoped, TUserFactory>()); + + return builder; + } + + /// + /// Replaces the existing with the user factory defined by . + /// + /// The remote authentication state. + /// The new user factory type. + /// The . + /// The . + public static IRemoteAuthenticationBuilder AddUserFactory( + this IRemoteAuthenticationBuilder builder) + where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TUserFactory : UserFactory => builder.AddUserFactory(); + + /// + /// Replaces the existing with the user factory defined by . + /// + /// The new user factory type. + /// The . + /// The . + public static IRemoteAuthenticationBuilder AddUserFactory( + this IRemoteAuthenticationBuilder builder) + where TUserFactory : UserFactory => builder.AddUserFactory(); + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs index 72afffd484..35aaba6e02 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/RemoteAuthenticatorViewCore.cs @@ -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: diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/AccessTokenProviderAccessor.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/AccessTokenProviderAccessor.cs new file mode 100644 index 0000000000..5aac060715 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/AccessTokenProviderAccessor.cs @@ -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(); + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/DefaultRemoteApplicationPathsProvider.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/DefaultRemoteApplicationPathsProvider.cs similarity index 100% rename from src/Components/WebAssembly/WebAssembly.Authentication/src/DefaultRemoteApplicationPathsProvider.cs rename to src/Components/WebAssembly/WebAssembly.Authentication/src/Services/DefaultRemoteApplicationPathsProvider.cs diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/RemoteAuthenticationService.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/RemoteAuthenticationService.cs index cf1bfdc5c7..a4c97eaa3f 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/RemoteAuthenticationService.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/RemoteAuthenticationService.cs @@ -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 /// /// The state to preserve across authentication operations. /// The options to be passed down to the underlying JavaScript library handling the authentication operations. - public class RemoteAuthenticationService : + public class RemoteAuthenticationService : AuthenticationStateProvider, IRemoteAuthenticationService, 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()); /// - /// The to use for performing JavaScript interop operations. + /// Gets the to use for performing JavaScript interop operations. /// - protected readonly IJSRuntime _jsRuntime; + protected IJSRuntime JsRuntime { get; } /// - /// The used to compute absolute urls. + /// Gets the used to compute absolute urls. /// - protected readonly NavigationManager _navigation; + protected NavigationManager Navigation { get; } /// - /// The options for the underlying JavaScript library handling the authentication operations. + /// Gets the to map accounts to . /// - protected readonly RemoteAuthenticationOptions _options; + protected UserFactory UserFactory { get; } + + /// + /// Gets the options for the underlying JavaScript library handling the authentication operations. + /// + protected RemoteAuthenticationOptions Options { get; } /// /// Initializes a new instance of . /// /// The to use for performing JavaScript interop operations. /// The options to be passed down to the underlying JavaScript library handling the authentication operations. + /// The used to generate URLs. + /// The used to generate the for the user. public RemoteAuthenticationService( IJSRuntime jsRuntime, IOptions> options, - NavigationManager navigation) + NavigationManager navigation, + UserFactory userFactory) { - _jsRuntime = jsRuntime; - _navigation = navigation; - _options = options.Value; + JsRuntime = jsRuntime; + Navigation = navigation; + UserFactory = userFactory; + Options = options.Value; } /// @@ -70,11 +78,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication RemoteAuthenticationContext context) { await EnsureAuthService(); - var internalResult = await _jsRuntime.InvokeAsync>("AuthenticationService.signIn", context.State); + var internalResult = await JsRuntime.InvokeAsync>("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 context) { await EnsureAuthService(); - var internalResult = await _jsRuntime.InvokeAsync>("AuthenticationService.completeSignIn", context.Url); + var internalResult = await JsRuntime.InvokeAsync>("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 context) { await EnsureAuthService(); - var internalResult = await _jsRuntime.InvokeAsync>("AuthenticationService.signOut", context.State); + var internalResult = await JsRuntime.InvokeAsync>("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 context) { await EnsureAuthService(); - var internalResult = await _jsRuntime.InvokeAsync>("AuthenticationService.completeSignOut", context.Url); + var internalResult = await JsRuntime.InvokeAsync>("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 RequestAccessToken() { await EnsureAuthService(); - var result = await _jsRuntime.InvokeAsync("AuthenticationService.getAccessToken"); + var result = await JsRuntime.InvokeAsync("AuthenticationService.getAccessToken"); if (!Enum.TryParse(result.Status, ignoreCase: true, out var parsedStatus)) { @@ -154,7 +170,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication } await EnsureAuthService(); - var result = await _jsRuntime.InvokeAsync("AuthenticationService.getAccessToken", options); + var result = await JsRuntime.InvokeAsync("AuthenticationService.getAccessToken", options); if (!Enum.TryParse(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 GetUser(bool useCache = false) + private async Task 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. /// /// A that will return the current authenticated user when completes. - protected internal virtual async Task GetAuthenticatedUser() + protected internal virtual async ValueTask GetAuthenticatedUser() { await EnsureAuthService(); - var user = await _jsRuntime.InvokeAsync>("AuthenticationService.getUser"); + var account = await JsRuntime.InvokeAsync("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(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 task) + private void UpdateUser(Task task) { NotifyAuthenticationStateChanged(UpdateAuthenticationState(task)); - static async Task UpdateAuthenticationState(ValueTask futureUser) => new AuthenticationState(await futureUser); + static async Task UpdateAuthenticationState(Task futureUser) => new AuthenticationState(await futureUser); } } diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/UserFactory.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/UserFactory.cs new file mode 100644 index 0000000000..7b46bfa5c2 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/Services/UserFactory.cs @@ -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 +{ + /// + /// Converts a into a . + /// + /// The account type. + public class UserFactory where TAccount : RemoteUserAccount + { + private readonly IAccessTokenProviderAccessor _accessor; + +#pragma warning disable PUB0001 // Pubternal type in public API + public UserFactory(IAccessTokenProviderAccessor accessor) => _accessor = accessor; + + /// + /// Gets or sets the . + /// + public IAccessTokenProvider TokenProvider => _accessor.TokenProvider; + + /// + /// Converts the into the final . + /// + /// The . + /// The to configure the with. + /// A that will contain the user when completed. + public virtual ValueTask 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(new ClaimsPrincipal(identity)); + } + } +} diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/src/WebAssemblyAuthenticationServiceCollectionExtensions.cs b/src/Components/WebAssembly/WebAssembly.Authentication/src/WebAssemblyAuthenticationServiceCollectionExtensions.cs index 352016df5b..0134071a65 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/src/WebAssemblyAuthenticationServiceCollectionExtensions.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/src/WebAssemblyAuthenticationServiceCollectionExtensions.cs @@ -21,31 +21,35 @@ namespace Microsoft.Extensions.DependencyInjection /// . /// /// The state to be persisted across authentication operations. + /// The account type. /// The configuration options of the underlying provider being used for handling the authentication operations. /// The to add the services to. /// The where the services were registered. - public static IServiceCollection AddRemoteAuthentication(this IServiceCollection services) + public static IRemoteAuthenticationBuilder AddRemoteAuthentication(this IServiceCollection services) where TRemoteAuthenticationState : RemoteAuthenticationState + where TAccount : RemoteUserAccount where TProviderOptions : class, new() { services.AddOptions(); services.AddAuthorizationCore(); - services.TryAddSingleton>(); - services.TryAddSingleton(sp => + services.TryAddScoped>(); + services.TryAddScoped(sp => { return (IRemoteAuthenticationService)sp.GetRequiredService(); }); - services.TryAddSingleton(sp => + services.TryAddScoped(sp => { return (IAccessTokenProvider)sp.GetRequiredService(); }); - services.TryAddSingleton>(); + services.TryAddScoped>(); + services.TryAddScoped(); + services.TryAddScoped(); - services.TryAddSingleton(); + services.TryAddScoped>(); - return services; + return new RemoteAuthenticationBuilder(services); } /// @@ -53,21 +57,23 @@ namespace Microsoft.Extensions.DependencyInjection /// . /// /// The state to be persisted across authentication operations. + /// The account type. /// The configuration options of the underlying provider being used for handling the authentication operations. /// The to add the services to. /// An action that will configure the . /// The where the services were registered. - public static IServiceCollection AddRemoteAuthentication(this IServiceCollection services, Action> configure) + public static IRemoteAuthenticationBuilder AddRemoteAuthentication(this IServiceCollection services, Action> configure) where TRemoteAuthenticationState : RemoteAuthenticationState + where TAccount : RemoteUserAccount where TProviderOptions : class, new() { - services.AddRemoteAuthentication(); + services.AddRemoteAuthentication(); if (configure != null) { services.Configure(configure); } - return services; + return new RemoteAuthenticationBuilder(services); } /// @@ -76,7 +82,7 @@ namespace Microsoft.Extensions.DependencyInjection /// The to add the services to. /// An action that will configure the . /// The where the services were registered. - public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, Action> configure) + public static IRemoteAuthenticationBuilder AddOidcAuthentication(this IServiceCollection services, Action> configure) { return AddOidcAuthentication(services, configure); } @@ -88,23 +94,37 @@ namespace Microsoft.Extensions.DependencyInjection /// The to add the services to. /// An action that will configure the . /// The where the services were registered. - public static IServiceCollection AddOidcAuthentication(this IServiceCollection services, Action> configure) + public static IRemoteAuthenticationBuilder AddOidcAuthentication(this IServiceCollection services, Action> configure) where TRemoteAuthenticationState : RemoteAuthenticationState, new() + { + return AddOidcAuthentication(services, configure); + } + + /// + /// Adds support for authentication for SPA applications using and the . + /// + /// The type of the remote authentication state. + /// The account type. + /// The to add the services to. + /// An action that will configure the . + /// The where the services were registered. + public static IRemoteAuthenticationBuilder AddOidcAuthentication(this IServiceCollection services, Action> configure) + where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TAccount : RemoteUserAccount { services.TryAddEnumerable(ServiceDescriptor.Singleton>, DefaultOidcOptionsConfiguration>()); - return AddRemoteAuthentication(services, configure); + return AddRemoteAuthentication(services, configure); } /// /// Adds support for authentication for SPA applications using and the . /// - /// The type of the remote authentication state. /// The to add the services to. /// The where the services were registered. - public static IServiceCollection AddApiAuthorization(this IServiceCollection services) + public static IRemoteAuthenticationBuilder AddApiAuthorization(this IServiceCollection services) { - return AddApiauthorizationCore(services, configure: null, Assembly.GetCallingAssembly().GetName().Name); + return AddApiauthorizationCore(services, configure: null, Assembly.GetCallingAssembly().GetName().Name); } /// @@ -113,10 +133,35 @@ namespace Microsoft.Extensions.DependencyInjection /// The type of the remote authentication state. /// The to add the services to. /// The where the services were registered. - public static IServiceCollection AddApiAuthorization(this IServiceCollection services) + public static IRemoteAuthenticationBuilder AddApiAuthorization(this IServiceCollection services) where TRemoteAuthenticationState : RemoteAuthenticationState, new() { - return AddApiauthorizationCore(services, configure: null, Assembly.GetCallingAssembly().GetName().Name); + return AddApiauthorizationCore(services, configure: null, Assembly.GetCallingAssembly().GetName().Name); + } + + /// + /// Adds support for authentication for SPA applications using and the . + /// + /// The type of the remote authentication state. + /// The account type. + /// The to add the services to. + /// The where the services were registered. + public static IRemoteAuthenticationBuilder AddApiAuthorization(this IServiceCollection services) + where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TAccount : RemoteUserAccount + { + return AddApiauthorizationCore(services, configure: null, Assembly.GetCallingAssembly().GetName().Name); + } + + /// + /// Adds support for authentication for SPA applications using and the . + /// + /// The to add the services to. + /// An action that will configure the . + /// The where the services were registered. + public static IRemoteAuthenticationBuilder AddApiAuthorization(this IServiceCollection services, Action> configure) + { + return AddApiauthorizationCore(services, configure, Assembly.GetCallingAssembly().GetName().Name); } /// @@ -126,37 +171,41 @@ namespace Microsoft.Extensions.DependencyInjection /// The to add the services to. /// An action that will configure the . /// The where the services were registered. - public static IServiceCollection AddApiAuthorization(this IServiceCollection services, Action> configure) + public static IRemoteAuthenticationBuilder AddApiAuthorization(this IServiceCollection services, Action> configure) + where TRemoteAuthenticationState : RemoteAuthenticationState, new() { - return AddApiauthorizationCore(services, configure, Assembly.GetCallingAssembly().GetName().Name); + return AddApiauthorizationCore(services, configure, Assembly.GetCallingAssembly().GetName().Name); } /// /// Adds support for authentication for SPA applications using and the . /// /// The type of the remote authentication state. + /// The account type. /// The to add the services to. /// An action that will configure the . /// The where the services were registered. - public static IServiceCollection AddApiAuthorization(this IServiceCollection services, Action> configure) + public static IRemoteAuthenticationBuilder AddApiAuthorization(this IServiceCollection services, Action> configure) where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TAccount : RemoteUserAccount { - return AddApiauthorizationCore(services, configure, Assembly.GetCallingAssembly().GetName().Name); + return AddApiauthorizationCore(services, configure, Assembly.GetCallingAssembly().GetName().Name); } - private static IServiceCollection AddApiauthorizationCore( + private static IRemoteAuthenticationBuilder AddApiauthorizationCore( IServiceCollection services, Action> configure, string inferredClientId) - where TRemoteAuthenticationState : RemoteAuthenticationState, new() + where TRemoteAuthenticationState : RemoteAuthenticationState + where TAccount : RemoteUserAccount { services.TryAddEnumerable( ServiceDescriptor.Singleton>, DefaultApiAuthorizationOptionsConfiguration>(_ => new DefaultApiAuthorizationOptionsConfiguration(inferredClientId))); - services.AddRemoteAuthentication(configure); + services.AddRemoteAuthentication(configure); - return services; + return new RemoteAuthenticationBuilder(services); } } } diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticationServiceTests.cs b/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticationServiceTests.cs index 7e5bcf7b15..2493b43dd0 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticationServiceTests.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticationServiceTests.cs @@ -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( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.SignInResult = new InternalRemoteAuthenticationResult @@ -51,10 +55,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.SignInResult = new InternalRemoteAuthenticationResult @@ -77,10 +82,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.CompleteSignInResult = new InternalRemoteAuthenticationResult @@ -107,10 +113,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.CompleteSignInResult = new InternalRemoteAuthenticationResult @@ -133,10 +140,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.SignOutResult = new InternalRemoteAuthenticationResult @@ -163,10 +171,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.SignOutResult = new InternalRemoteAuthenticationResult @@ -189,10 +198,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.CompleteSignOutResult = new InternalRemoteAuthenticationResult @@ -219,10 +229,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); var state = new RemoteAuthenticationState(); testJsRuntime.CompleteSignOutResult = new InternalRemoteAuthenticationResult @@ -245,10 +256,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication // Arrange var testJsRuntime = new TestJsRuntime(); var options = CreateOptions(); - var runtime = new RemoteAuthenticationService( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); 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( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); 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( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); 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( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new UserFactory(Mock.Of())); - 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( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new TestUserFactory(Mock.Of())); - 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 + { + ["CoolName"] = JsonSerializer.Deserialize(JsonSerializer.Serialize("Alfred")) + } + }; - testJsRuntime.GetUserResult = JsonSerializer.Deserialize>(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( + var runtime = new RemoteAuthenticationService( testJsRuntime, options, - new TestNavigationManager()); + new TestNavigationManager(), + new TestUserFactory(Mock.Of())); - 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 + { + ["CoolName"] = JsonSerializer.Deserialize(JsonSerializer.Serialize("Alfred")), + } + }; - testJsRuntime.GetUserResult = JsonSerializer.Deserialize>(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 GetUserResult { get; set; } + public RemoteUserAccount GetUserResult { get; set; } public ValueTask InvokeAsync(string identifier, object[] args) { PastInvocations.Add((identifier, args)); - return new ValueTask((TValue)GetInvocationResult(identifier)); + return new ValueTask((TValue)GetInvocationResult(identifier)); } - public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args) { PastInvocations.Add((identifier, args)); - return new ValueTask((TValue)GetInvocationResult(identifier)); + return new ValueTask((TValue)GetInvocationResult(identifier)); } - private object GetInvocationResult(string identifier) + private object GetInvocationResult(string identifier) { switch (identifier) { @@ -551,6 +571,35 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication } } + internal class TestUserFactory : UserFactory + { + public TestUserFactory(IAccessTokenProviderAccessor accessor) : base(accessor) + { + } + + public override async ValueTask 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() => diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs b/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs index 6f793eff79..cb3f6b025e 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/test/RemoteAuthenticatorCoreTests.cs @@ -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.Action); - private const string _onLogInSucceded = nameof(RemoteAuthenticatorViewCore.OnLogInSucceeded); - private const string _onLogOutSucceeded = nameof(RemoteAuthenticatorViewCore.OnLogOutSucceeded); + private const string _onLogInSucceded = nameof(RemoteAuthenticatorViewCore.OnLogInSucceeded); + private const string _onLogOutSucceeded = nameof(RemoteAuthenticatorViewCore.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(new ClaimsPrincipal(new ClaimsIdentity("Test"))); authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult() { @@ -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(new ClaimsPrincipal(new ClaimsIdentity("Test"))); authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult() { @@ -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(new ClaimsPrincipal(new ClaimsIdentity("Test"))); authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult() { @@ -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(new ClaimsPrincipal(new ClaimsIdentity("Test"))); authServiceMock.SignOutCallback = s => Task.FromResult(new RemoteAuthenticationResult() { @@ -660,13 +661,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication } } - private class TestRemoteAuthenticationService : RemoteAuthenticationService + private class TestRemoteAuthenticationService : RemoteAuthenticationService { public TestRemoteAuthenticationService( IJSRuntime jsRuntime, IOptions> options, TestNavigationManager navigationManager) : - base(jsRuntime, options, navigationManager) + base(jsRuntime, options, navigationManager, new UserFactory(Mock.Of())) { } @@ -674,13 +675,13 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication public Func, Task>> CompleteSignInCallback { get; set; } public Func, Task>> SignOutCallback { get; set; } public Func, Task>> CompleteSignOutCallback { get; set; } - public Func> GetAuthenticatedUserCallback { get; set; } + public Func> GetAuthenticatedUserCallback { get; set; } public async override Task GetAuthenticationStateAsync() => new AuthenticationState(await GetAuthenticatedUserCallback()); public override Task> CompleteSignInAsync(RemoteAuthenticationContext context) => CompleteSignInCallback(context); - protected internal override Task GetAuthenticatedUser() => GetAuthenticatedUserCallback(); + protected internal override ValueTask GetAuthenticatedUser() => GetAuthenticatedUserCallback(); public override Task> CompleteSignOutAsync(RemoteAuthenticationContext context) => CompleteSignOutCallback(context); diff --git a/src/Components/WebAssembly/WebAssembly.Authentication/test/WebAssemblyAuthenticationServiceCollectionExtensionsTests.cs b/src/Components/WebAssembly/WebAssembly.Authentication/test/WebAssemblyAuthenticationServiceCollectionExtensionsTests.cs index 6e39e38a67..88fea7c71c 100644 --- a/src/Components/WebAssembly/WebAssembly.Authentication/test/WebAssemblyAuthenticationServiceCollectionExtensionsTests.cs +++ b/src/Components/WebAssembly/WebAssembly.Authentication/test/WebAssemblyAuthenticationServiceCollectionExtensionsTests.cs @@ -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>>(); + + 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(options => calls++); + + var host = builder.Build(); + + var options = host.Services.GetRequiredService>>(); + + 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>(); + Assert.NotNull(authenticationService); + Assert.IsType>(authenticationService); + + Assert.Equal(1, calls); + } + + [Fact] + public void ApiAuthorizationTestAuthenticationState_NoCallback_SetsUpConfiguration() + { + var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); + builder.Services.AddApiAuthorization(); + + var host = builder.Build(); + + var options = host.Services.GetRequiredService>>(); + + 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>(); + Assert.NotNull(authenticationService); + Assert.IsType>(authenticationService); + } + + [Fact] + public void ApiAuthorizationCustomAuthenticationStateAndAccount_SetsUpConfiguration() + { + var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); + var calls = 0; + builder.Services.AddApiAuthorization(options => calls++); + + var host = builder.Build(); + + var options = host.Services.GetRequiredService>>(); + + 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>(); + Assert.NotNull(authenticationService); + Assert.IsType>(authenticationService); + + Assert.Equal(1, calls); + } + + [Fact] + public void ApiAuthorizationTestAuthenticationStateAndAccount_NoCallback_SetsUpConfiguration() + { + var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); + builder.Services.AddApiAuthorization(); + + var host = builder.Build(); + + var options = host.Services.GetRequiredService>>(); + + 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>(); + Assert.NotNull(authenticationService); + Assert.IsType>(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>>(); + 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(options => options.ProviderOptions.Authority = (++calls).ToString()); + builder.Services.Replace(ServiceDescriptor.Singleton(typeof(NavigationManager), new TestNavigationManager())); + + var host = builder.Build(); + + var options = host.Services.GetRequiredService>>(); + // Make sure options are applied + Assert.Equal("name", options.Value.UserOptions.NameClaim); + + Assert.Equal("1", options.Value.ProviderOptions.Authority); + + var authenticationService = host.Services.GetService>(); + Assert.NotNull(authenticationService); + Assert.IsType>(authenticationService); + } + + [Fact] + public void AddOidc_CustomStateAndAccount_SetsUpConfiguration() + { + var builder = new WebAssemblyHostBuilder(new TestWebAssemblyJSRuntimeInvoker()); + var calls = 0; + + builder.Services.AddOidcAuthentication(options => options.ProviderOptions.Authority = (++calls).ToString()); + builder.Services.Replace(ServiceDescriptor.Singleton(typeof(NavigationManager), new TestNavigationManager())); + + var host = builder.Build(); + + var options = host.Services.GetRequiredService>>(); + // Make sure options are applied + Assert.Equal("name", options.Value.UserOptions.NameClaim); + + Assert.Equal("1", options.Value.ProviderOptions.Authority); + + var authenticationService = host.Services.GetService>(); + Assert.NotNull(authenticationService); + Assert.IsType>(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 + { + } } } diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/OidcAccount.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/OidcAccount.cs new file mode 100644 index 0000000000..b6d39ef9e6 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/OidcAccount.cs @@ -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; } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/AdminSettings.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/AdminSettings.razor new file mode 100644 index 0000000000..573072a3ee --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/AdminSettings.razor @@ -0,0 +1,47 @@ +@page "/admin-settings" +@attribute [Authorize(Roles = "admin")] +@inject IAccessTokenProvider TokenProvider +@inject NavigationManager Navigation + +@if(_error == null) +{ + +} +else if(_error == true) +{ +

Could not get the access token.

+} +else if (_error == false) +{ +

Successfully perfomed admin action.

+} + +@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; + } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Authentication.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Authentication.razor index ea86c3d0f0..06bbc96eb4 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Authentication.razor +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Authentication.razor @@ -1,7 +1,43 @@ @page "/authentication/{action}" +@inject StateService State +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject NavigationManager Navigation - + @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)}"; + } + } } diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Index.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Index.razor index e6a33ccd43..1f55ad4144 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Index.razor +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/Index.razor @@ -1,5 +1,27 @@ @page "/" - +@inject StateService State +@inject IJSRuntime JS

Hello, world!

Welcome to your new app. + +Current state is: +

@State.GetCurrentState()

+ + +

+ + +

+ +@code{ + public async Task ClearStorage() + { + await JS.InvokeVoidAsync("sessionStorage.clear"); + } + + public async Task TriggerPageRefresh() + { + await JS.InvokeVoidAsync("location.reload", true); + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/MakeAdmin.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/MakeAdmin.razor new file mode 100644 index 0000000000..e1f23f8532 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/MakeAdmin.razor @@ -0,0 +1,37 @@ +@page "/new-admin" +@attribute [Authorize] +@inject IAccessTokenProvider TokenProvider +@inject NavigationManager Navigation + +@if (_error == true) +{ +

Could not get the access token.

+} +else if (_error == false) +{ +

Successfully added to the admin group.

+} + +@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; + } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/UserPreferences.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/UserPreferences.razor new file mode 100644 index 0000000000..978e1107bb --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Pages/UserPreferences.razor @@ -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 + +

User preferences

+ + + + +@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("/"); + } + } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/PreferencesUserFactory.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/PreferencesUserFactory.cs new file mode 100644 index 0000000000..d1bf275264 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/PreferencesUserFactory.cs @@ -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 + { + private readonly HttpClient _httpClient; + + public PreferencesUserFactory(NavigationManager navigationManager, IAccessTokenProviderAccessor accessor) + : base(accessor) + { + _httpClient = new HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) }; + } + + public async override ValueTask 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(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; + } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Program.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Program.cs index 2f17594129..80af27b672 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Program.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Program.cs @@ -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() + .AddUserFactory(); + + builder.Services.AddSingleton(); builder.RootComponents.Add("app"); diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/RemoteAppState.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/RemoteAppState.cs new file mode 100644 index 0000000000..eee8535069 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/RemoteAppState.cs @@ -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; } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Shared/NavMenu.razor b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Shared/NavMenu.razor index 09b86754ba..b401588333 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Shared/NavMenu.razor +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/Shared/NavMenu.razor @@ -27,6 +27,17 @@ User + + + diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/StateService.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/StateService.cs new file mode 100644 index 0000000000..0906ee85b9 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/StateService.cs @@ -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; + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/wwwroot/index.html b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/wwwroot/index.html index 1797efb1bf..93beb842d5 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/wwwroot/index.html +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Client/wwwroot/index.html @@ -21,5 +21,4 @@ - diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Controllers/PreferencesController.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Controllers/PreferencesController.cs new file mode 100644 index 0000000000..dd9d703e88 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Controllers/PreferencesController.cs @@ -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 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(); + } + } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Controllers/RolesController.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Controllers/RolesController.cs new file mode 100644 index 0000000000..10ec3c59e3 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Controllers/RolesController.cs @@ -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 _userManager; + private readonly RoleManager _roleManager; + private readonly IOptions _options; + + public RolesController( + UserManager userManager, + RoleManager roleManager, + IOptions options) + { + _userManager = userManager; + _roleManager = roleManager; + _options = options; + } + + [HttpPost("[controller]/[action]")] + public async Task 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(); + } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/ApplicationDbContext.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/ApplicationDbContext.cs index 2928589312..dbb7e23513 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/ApplicationDbContext.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/ApplicationDbContext.cs @@ -13,5 +13,21 @@ namespace Wasm.Authentication.Server.Data IOptions operationalStoreOptions) : base(options, operationalStoreOptions) { } + + public DbSet UserPreferences { get; set; } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity().HasOne(u => u.UserPreference); + + builder.Entity() + .Property(u => u.Id).ValueGeneratedOnAdd(); + + builder.Entity() + .HasKey(p => p.Id); + + } } } diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200123141439_Initial.Designer.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200324213904_Initial.Designer.cs similarity index 91% rename from src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200123141439_Initial.Designer.cs rename to src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200324213904_Initial.Designer.cs index 93c34bb59c..5a9de57b6d 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200123141439_Initial.Designer.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200324213904_Initial.Designer.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationUserId") + .HasColumnType("TEXT"); + + b.Property("Color") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", 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 } } diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200123141439_Initial.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200324213904_Initial.cs similarity index 91% rename from src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200123141439_Initial.cs rename to src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200324213904_Initial.cs index e1a5ee6e23..fcafd568d1 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200123141439_Initial.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/20200324213904_Initial.cs @@ -186,6 +186,25 @@ namespace Wasm.Authentication.Server.Data.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "UserPreferences", + columns: table => new + { + Id = table.Column(nullable: false), + ApplicationUserId = table.Column(nullable: true), + Color = table.Column(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"); diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 463ee4ccc1..312d93c9a5 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationUserId") + .HasColumnType("TEXT"); + + b.Property("Color") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", 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 } } diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/ApplicationUser.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/ApplicationUser.cs index 2f1b5f73ab..d770230b53 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/ApplicationUser.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/ApplicationUser.cs @@ -8,5 +8,6 @@ namespace Wasm.Authentication.Server.Models { public class ApplicationUser : IdentityUser { + public UserPreference UserPreference { get; set; } } } diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/UserPreference.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/UserPreference.cs new file mode 100644 index 0000000000..fb386a3272 --- /dev/null +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Models/UserPreference.cs @@ -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; } + } +} diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs index 2a8f10d225..1224ee978f 100644 --- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs +++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs @@ -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(options => options.SignIn.RequireConfirmedAccount = true) + .AddRoles() .AddEntityFrameworkStores(); services.AddIdentityServer() - .AddApiAuthorization(); + .AddApiAuthorization(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(); diff --git a/src/Components/test/E2ETest/Tests/WebAssemblyAuthenticationTests.cs b/src/Components/test/E2ETest/Tests/WebAssemblyAuthenticationTests.cs index 9b9ae07aba..8fce8f9513 100644 --- a/src/Components/test/E2ETest/Tests/WebAssemblyAuthenticationTests.cs +++ b/src/Components/test/E2ETest/Tests/WebAssemblyAuthenticationTests.cs @@ -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, IDisposable + public class WebAssemblyAuthenticationTests : ServerTestBase { 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")]