Skip to content

[Blazor] More auth fixes #20192

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,22 @@ public static IServiceCollection AddMsalAuthentication(this IServiceCollection s
public static IServiceCollection AddMsalAuthentication<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<MsalProviderOptions>> configure)
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
{
services.AddRemoteAuthentication<RemoteAuthenticationState, MsalProviderOptions>(configure);
AddMsalAuthentication<TRemoteAuthenticationState, RemoteUserAccount>(services, configure);
return services;
}

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

return services;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal
{
/// <summary>
/// This is an internal API that supports the Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// infrastructure and not subject to the same compatibility standards as public APIs.
/// It may be changed or removed without notice in any release.
/// </summary>
public interface IAccessTokenProviderAccessor
{
/// <summary>
/// This is an internal API that supports the Microsoft.AspNetCore.Components.WebAssembly.Authentication
/// infrastructure and not subject to the same compatibility standards as public APIs.
/// It may be changed or removed without notice in any release.
/// </summary>
IAccessTokenProvider TokenProvider { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

namespace Microsoft.Extensions.DependencyInjection
{
/// <summary>
/// An interface for configuring remote authentication services.
/// </summary>
/// <typeparam name="TRemoteAuthenticationState">The remote authentication state type.</typeparam>
/// <typeparam name="TAccount">The account type.</typeparam>
public interface IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
{
IServiceCollection Services { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,49 @@ export enum AuthenticationResultStatus {

export interface AuthenticationResult {
status: AuthenticationResultStatus;
state?: any;
state?: unknown;
message?: string;
}

export interface AuthorizeService {
getUser(): Promise<any>;
getUser(): Promise<unknown>;
getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
signIn(state: any): Promise<AuthenticationResult>;
completeSignIn(state: any): Promise<AuthenticationResult>;
signOut(state: any): Promise<AuthenticationResult>;
signIn(state: unknown): Promise<AuthenticationResult>;
completeSignIn(state: unknown): Promise<AuthenticationResult>;
signOut(state: unknown): Promise<AuthenticationResult>;
completeSignOut(url: string): Promise<AuthenticationResult>;
}

class OidcAuthorizeService implements AuthorizeService {
private _userManager: UserManager;

private _intialSilentSignIn: Promise<void> | undefined;
constructor(userManager: UserManager) {
this._userManager = userManager;
}

async trySilentSignIn() {
if (!this._intialSilentSignIn) {
this._intialSilentSignIn = (async () => {
try {
await this._userManager.signinSilent();
} catch (e) {
// It is ok to swallow the exception here.
// The user might not be logged in and in that case it
// is expected for signinSilent to fail and throw
}
})();
}

return this._intialSilentSignIn;
}

async getUser() {
if (window.parent === window && !window.opener && !window.frameElement && this._userManager.settings.redirect_uri &&
!location.href.startsWith(this._userManager.settings.redirect_uri)) {
// If we are not inside a hidden iframe, try authenticating silently.
await AuthenticationService.instance.trySilentSignIn();
}

const user = await this._userManager.getUser();
return user && user.profile;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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());
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -212,32 +234,32 @@ 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;
}
}

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 };
}

private error(message: string) {
return { status: AuthenticationResultStatus.Failure, errorMessage: message };
}

private success(state: any) {
private success(state: unknown) {
return { status: AuthenticationResultStatus.Success, state };
}

Expand All @@ -253,21 +275,57 @@ class OidcAuthorizeService implements AuthorizeService {
export class AuthenticationService {

static _infrastructureKey = 'Microsoft.AspNetCore.Components.WebAssembly.Authentication';
static _initialized : Promise<void>;
static _initialized: Promise<void>;
static instance: OidcAuthorizeService;
static _pendingOperations: { [key: string]: Promise<AuthenticationResult> | undefined } = {}

public static async init(settings: UserManagerSettings & AuthorizeServiceSettings) {
public static init(settings: UserManagerSettings & AuthorizeServiceSettings) {
// Multiple initializations can start concurrently and we want to avoid that.
// In order to do so, we create an initialization promise and the first call to init
// tries to initialize the app and sets up a promise other calls can await on.
if (!AuthenticationService._initialized) {
this._initialized = (async () => {
const userManager = await this.createUserManager(settings);
AuthenticationService._initialized = AuthenticationService.initializeCore(settings);
}

return AuthenticationService._initialized;
}

public static handleCallback() {
return AuthenticationService.initializeCore();
}

private static async initializeCore(settings?: UserManagerSettings & AuthorizeServiceSettings) {
const finalSettings = settings || AuthenticationService.resolveCachedSettings();
if (!settings && finalSettings) {
const userManager = AuthenticationService.createUserManagerCore(finalSettings);

if (window.parent !== window && !window.opener && (window.frameElement && userManager.settings.redirect_uri &&
location.href.startsWith(userManager.settings.redirect_uri))) {
// If we are inside a hidden iframe, try completing the sign in early.
// This prevents loading the blazor app inside a hidden iframe, which speeds up the authentication operations
// and avoids wasting resources (CPU and memory from bootstrapping the Blazor app)
AuthenticationService.instance = new OidcAuthorizeService(userManager);
})();

// This makes sure that if the blazor app has time to load inside the hidden iframe,
// it is not able to perform another auth operation until this operation has completed.
AuthenticationService._initialized = (async (): Promise<void> => {
await AuthenticationService.instance.completeSignIn(location.href);
return;
})();
}
} else if (settings) {
const userManager = await AuthenticationService.createUserManager(settings);
AuthenticationService.instance = new OidcAuthorizeService(userManager);
} else {
// HandleCallback gets called unconditionally, so we do nothing for normal paths.
// Cached settings are only used on handling the redirect_uri path and if the settings are not there
// the app will fallback to the default logic for handling the redirect.
}
}

await this._initialized;
private static resolveCachedSettings(): UserManagerSettings | undefined {
const cachedSettings = window.sessionStorage.getItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`);
return cachedSettings ? JSON.parse(cachedSettings) : undefined;
}

public static getUser() {
Expand All @@ -278,37 +336,46 @@ export class AuthenticationService {
return AuthenticationService.instance.getAccessToken();
}

public static signIn(state: any) {
public static signIn(state: unknown) {
return AuthenticationService.instance.signIn(state);
}

public static completeSignIn(url: string) {
return AuthenticationService.instance.completeSignIn(url);
public static async completeSignIn(url: string) {
let operation = this._pendingOperations[url];
if (!operation) {
operation = AuthenticationService.instance.completeSignIn(url);
await operation;
delete this._pendingOperations[url];
}

return operation;
}

public static signOut(state: any) {
public static signOut(state: unknown) {
return AuthenticationService.instance.signOut(state);
}

public static completeSignOut(url: string) {
return AuthenticationService.instance.completeSignOut(url);
public static async completeSignOut(url: string) {
let operation = this._pendingOperations[url];
if (!operation) {
operation = AuthenticationService.instance.completeSignOut(url);
await operation;
delete this._pendingOperations[url];
}

return operation;
}

private static async createUserManager(settings: OidcAuthorizeServiceSettings): Promise<UserManager> {
let finalSettings: UserManagerSettings;
if (isApiAuthorizationSettings(settings)) {
let response = await fetch(settings.configurationEndpoint);
const response = await fetch(settings.configurationEndpoint);
if (!response.ok) {
throw new Error(`Could not load settings from '${settings.configurationEndpoint}'`);
}

const downloadedSettings = await response.json();

window.sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`, JSON.stringify(settings));

downloadedSettings.automaticSilentRenew = true;
downloadedSettings.includeIdTokenInSilentRenew = true;

finalSettings = downloadedSettings;
} else {
if (!settings.scope) {
Expand All @@ -323,18 +390,24 @@ export class AuthenticationService {
finalSettings = settings;
}

const userManager = new UserManager(finalSettings);
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;
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
{
/// <summary>
/// A user account.
/// </summary>
/// <remarks>
/// The information in this type will be use to produce a <see cref="System.Security.Claims.ClaimsPrincipal"/> for the application.
/// </remarks>
public class RemoteUserAccount
{
/// <summary>
/// Gets or sets properties not explicitly mapped about the user.
/// </summary>
[JsonExtensionData]
public IDictionary<string, object> AdditionalProperties { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public void Configure(RemoteAuthenticationOptions<ApiAuthorizationProviderOption
options.AuthenticationPaths.RemoteRegisterPath ??= "Identity/Account/Register";
options.AuthenticationPaths.RemoteProfilePath ??= "Identity/Account/Manage";
options.UserOptions.ScopeClaim ??= "scope";
options.UserOptions.RoleClaim ??= "role";
options.UserOptions.AuthenticationType ??= _applicationName;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
{
internal class RemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
: IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
where TRemoteAuthenticationState : RemoteAuthenticationState
where TAccount : RemoteUserAccount
{
public RemoteAuthenticationBuilder(IServiceCollection services) => Services = services;

public IServiceCollection Services { get; }
}
}
Loading