Skip to content

Commit fd9c786

Browse files
authored
[Blazor] More auth fixes (#20192)
* Introduces customization options for mapping user claims principals. * Supports login/logout flows extensibility. * Improves E2E test reliability * Improves reliability on the AuthenticationService * Improves the experience by trying to silently log-in users on startup. * Avoids loading the Blazor application when within a hidden iframe.
1 parent e67e7a0 commit fd9c786

40 files changed

+1390
-224
lines changed

src/Components/WebAssembly/Authentication.Msal/src/MsalWebAssemblyServiceCollectionExtensions.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,22 @@ public static IServiceCollection AddMsalAuthentication(this IServiceCollection s
3737
public static IServiceCollection AddMsalAuthentication<TRemoteAuthenticationState>(this IServiceCollection services, Action<RemoteAuthenticationOptions<MsalProviderOptions>> configure)
3838
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
3939
{
40-
services.AddRemoteAuthentication<RemoteAuthenticationState, MsalProviderOptions>(configure);
40+
AddMsalAuthentication<TRemoteAuthenticationState, RemoteUserAccount>(services, configure);
41+
return services;
42+
}
43+
44+
/// <summary>
45+
/// Adds authentication using msal.js to Blazor applications.
46+
/// </summary>
47+
/// <typeparam name="TRemoteAuthenticationState">The type of the remote authentication state.</typeparam>
48+
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
49+
/// <param name="configure">The <see cref="Action{RemoteAuthenticationOptions{MsalProviderOptions}}"/> to configure the <see cref="RemoteAuthenticationOptions{MsalProviderOptions}"/>.</param>
50+
/// <returns>The <see cref="IServiceCollection"/>.</returns>
51+
public static IServiceCollection AddMsalAuthentication<TRemoteAuthenticationState, TAccount>(this IServiceCollection services, Action<RemoteAuthenticationOptions<MsalProviderOptions>> configure)
52+
where TRemoteAuthenticationState : RemoteAuthenticationState, new()
53+
where TAccount : RemoteUserAccount
54+
{
55+
services.AddRemoteAuthentication<RemoteAuthenticationState, TAccount, MsalProviderOptions>(configure);
4156
services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<RemoteAuthenticationOptions<MsalProviderOptions>>, MsalDefaultOptionsConfiguration>());
4257

4358
return services;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal
5+
{
6+
/// <summary>
7+
/// This is an internal API that supports the Microsoft.AspNetCore.Components.WebAssembly.Authentication
8+
/// infrastructure and not subject to the same compatibility standards as public APIs.
9+
/// It may be changed or removed without notice in any release.
10+
/// </summary>
11+
public interface IAccessTokenProviderAccessor
12+
{
13+
/// <summary>
14+
/// This is an internal API that supports the Microsoft.AspNetCore.Components.WebAssembly.Authentication
15+
/// infrastructure and not subject to the same compatibility standards as public APIs.
16+
/// It may be changed or removed without notice in any release.
17+
/// </summary>
18+
IAccessTokenProvider TokenProvider { get; }
19+
}
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
5+
6+
namespace Microsoft.Extensions.DependencyInjection
7+
{
8+
/// <summary>
9+
/// An interface for configuring remote authentication services.
10+
/// </summary>
11+
/// <typeparam name="TRemoteAuthenticationState">The remote authentication state type.</typeparam>
12+
/// <typeparam name="TAccount">The account type.</typeparam>
13+
public interface IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
14+
where TRemoteAuthenticationState : RemoteAuthenticationState
15+
where TAccount : RemoteUserAccount
16+
{
17+
IServiceCollection Services { get; }
18+
}
19+
}

src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/AuthenticationService.ts

Lines changed: 110 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -48,27 +48,49 @@ export enum AuthenticationResultStatus {
4848

4949
export interface AuthenticationResult {
5050
status: AuthenticationResultStatus;
51-
state?: any;
51+
state?: unknown;
5252
message?: string;
5353
}
5454

5555
export interface AuthorizeService {
56-
getUser(): Promise<any>;
56+
getUser(): Promise<unknown>;
5757
getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
58-
signIn(state: any): Promise<AuthenticationResult>;
59-
completeSignIn(state: any): Promise<AuthenticationResult>;
60-
signOut(state: any): Promise<AuthenticationResult>;
58+
signIn(state: unknown): Promise<AuthenticationResult>;
59+
completeSignIn(state: unknown): Promise<AuthenticationResult>;
60+
signOut(state: unknown): Promise<AuthenticationResult>;
6161
completeSignOut(url: string): Promise<AuthenticationResult>;
6262
}
6363

6464
class OidcAuthorizeService implements AuthorizeService {
6565
private _userManager: UserManager;
66-
66+
private _intialSilentSignIn: Promise<void> | undefined;
6767
constructor(userManager: UserManager) {
6868
this._userManager = userManager;
6969
}
7070

71+
async trySilentSignIn() {
72+
if (!this._intialSilentSignIn) {
73+
this._intialSilentSignIn = (async () => {
74+
try {
75+
await this._userManager.signinSilent();
76+
} catch (e) {
77+
// It is ok to swallow the exception here.
78+
// The user might not be logged in and in that case it
79+
// is expected for signinSilent to fail and throw
80+
}
81+
})();
82+
}
83+
84+
return this._intialSilentSignIn;
85+
}
86+
7187
async getUser() {
88+
if (window.parent === window && !window.opener && !window.frameElement && this._userManager.settings.redirect_uri &&
89+
!location.href.startsWith(this._userManager.settings.redirect_uri)) {
90+
// If we are not inside a hidden iframe, try authenticating silently.
91+
await AuthenticationService.instance.trySilentSignIn();
92+
}
93+
7294
const user = await this._userManager.getUser();
7395
return user && user.profile;
7496
}
@@ -120,7 +142,7 @@ class OidcAuthorizeService implements AuthorizeService {
120142
function hasAllScopes(request: AccessTokenRequestOptions | undefined, currentScopes: string[]) {
121143
const set = new Set(currentScopes);
122144
if (request && request.scopes) {
123-
for (let current of request.scopes) {
145+
for (const current of request.scopes) {
124146
if (!set.has(current)) {
125147
return false;
126148
}
@@ -131,7 +153,7 @@ class OidcAuthorizeService implements AuthorizeService {
131153
}
132154
}
133155

134-
async signIn(state: any) {
156+
async signIn(state: unknown) {
135157
try {
136158
await this._userManager.clearStaleState();
137159
await this._userManager.signinSilent(this.createArguments());
@@ -166,7 +188,7 @@ class OidcAuthorizeService implements AuthorizeService {
166188
}
167189
}
168190

169-
async signOut(state: any) {
191+
async signOut(state: unknown) {
170192
try {
171193
if (!(await this._userManager.metadataService.getEndSessionEndpoint())) {
172194
await this._userManager.removeUser();
@@ -212,32 +234,32 @@ class OidcAuthorizeService implements AuthorizeService {
212234

213235
private async stateExists(url: string) {
214236
const stateParam = new URLSearchParams(new URL(url).search).get('state');
215-
if (stateParam) {
216-
return await this._userManager.settings.stateStore!.get(stateParam);
237+
if (stateParam && this._userManager.settings.stateStore) {
238+
return await this._userManager.settings.stateStore.get(stateParam);
217239
} else {
218240
return undefined;
219241
}
220242
}
221243

222244
private async loginRequired(url: string) {
223245
const errorParameter = new URLSearchParams(new URL(url).search).get('error');
224-
if (errorParameter) {
225-
const error = await this._userManager.settings.stateStore!.get(errorParameter);
246+
if (errorParameter && this._userManager.settings.stateStore) {
247+
const error = await this._userManager.settings.stateStore.get(errorParameter);
226248
return error === 'login_required';
227249
} else {
228250
return false;
229251
}
230252
}
231253

232-
private createArguments(state?: any) {
254+
private createArguments(state?: unknown) {
233255
return { useReplaceToNavigate: true, data: state };
234256
}
235257

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

240-
private success(state: any) {
262+
private success(state: unknown) {
241263
return { status: AuthenticationResultStatus.Success, state };
242264
}
243265

@@ -253,21 +275,57 @@ class OidcAuthorizeService implements AuthorizeService {
253275
export class AuthenticationService {
254276

255277
static _infrastructureKey = 'Microsoft.AspNetCore.Components.WebAssembly.Authentication';
256-
static _initialized : Promise<void>;
278+
static _initialized: Promise<void>;
257279
static instance: OidcAuthorizeService;
280+
static _pendingOperations: { [key: string]: Promise<AuthenticationResult> | undefined } = {}
258281

259-
public static async init(settings: UserManagerSettings & AuthorizeServiceSettings) {
282+
public static init(settings: UserManagerSettings & AuthorizeServiceSettings) {
260283
// Multiple initializations can start concurrently and we want to avoid that.
261284
// In order to do so, we create an initialization promise and the first call to init
262285
// tries to initialize the app and sets up a promise other calls can await on.
263286
if (!AuthenticationService._initialized) {
264-
this._initialized = (async () => {
265-
const userManager = await this.createUserManager(settings);
287+
AuthenticationService._initialized = AuthenticationService.initializeCore(settings);
288+
}
289+
290+
return AuthenticationService._initialized;
291+
}
292+
293+
public static handleCallback() {
294+
return AuthenticationService.initializeCore();
295+
}
296+
297+
private static async initializeCore(settings?: UserManagerSettings & AuthorizeServiceSettings) {
298+
const finalSettings = settings || AuthenticationService.resolveCachedSettings();
299+
if (!settings && finalSettings) {
300+
const userManager = AuthenticationService.createUserManagerCore(finalSettings);
301+
302+
if (window.parent !== window && !window.opener && (window.frameElement && userManager.settings.redirect_uri &&
303+
location.href.startsWith(userManager.settings.redirect_uri))) {
304+
// If we are inside a hidden iframe, try completing the sign in early.
305+
// This prevents loading the blazor app inside a hidden iframe, which speeds up the authentication operations
306+
// and avoids wasting resources (CPU and memory from bootstrapping the Blazor app)
266307
AuthenticationService.instance = new OidcAuthorizeService(userManager);
267-
})();
308+
309+
// This makes sure that if the blazor app has time to load inside the hidden iframe,
310+
// it is not able to perform another auth operation until this operation has completed.
311+
AuthenticationService._initialized = (async (): Promise<void> => {
312+
await AuthenticationService.instance.completeSignIn(location.href);
313+
return;
314+
})();
315+
}
316+
} else if (settings) {
317+
const userManager = await AuthenticationService.createUserManager(settings);
318+
AuthenticationService.instance = new OidcAuthorizeService(userManager);
319+
} else {
320+
// HandleCallback gets called unconditionally, so we do nothing for normal paths.
321+
// Cached settings are only used on handling the redirect_uri path and if the settings are not there
322+
// the app will fallback to the default logic for handling the redirect.
268323
}
324+
}
269325

270-
await this._initialized;
326+
private static resolveCachedSettings(): UserManagerSettings | undefined {
327+
const cachedSettings = window.sessionStorage.getItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`);
328+
return cachedSettings ? JSON.parse(cachedSettings) : undefined;
271329
}
272330

273331
public static getUser() {
@@ -278,37 +336,46 @@ export class AuthenticationService {
278336
return AuthenticationService.instance.getAccessToken();
279337
}
280338

281-
public static signIn(state: any) {
339+
public static signIn(state: unknown) {
282340
return AuthenticationService.instance.signIn(state);
283341
}
284342

285-
public static completeSignIn(url: string) {
286-
return AuthenticationService.instance.completeSignIn(url);
343+
public static async completeSignIn(url: string) {
344+
let operation = this._pendingOperations[url];
345+
if (!operation) {
346+
operation = AuthenticationService.instance.completeSignIn(url);
347+
await operation;
348+
delete this._pendingOperations[url];
349+
}
350+
351+
return operation;
287352
}
288353

289-
public static signOut(state: any) {
354+
public static signOut(state: unknown) {
290355
return AuthenticationService.instance.signOut(state);
291356
}
292357

293-
public static completeSignOut(url: string) {
294-
return AuthenticationService.instance.completeSignOut(url);
358+
public static async completeSignOut(url: string) {
359+
let operation = this._pendingOperations[url];
360+
if (!operation) {
361+
operation = AuthenticationService.instance.completeSignOut(url);
362+
await operation;
363+
delete this._pendingOperations[url];
364+
}
365+
366+
return operation;
295367
}
296368

297369
private static async createUserManager(settings: OidcAuthorizeServiceSettings): Promise<UserManager> {
298370
let finalSettings: UserManagerSettings;
299371
if (isApiAuthorizationSettings(settings)) {
300-
let response = await fetch(settings.configurationEndpoint);
372+
const response = await fetch(settings.configurationEndpoint);
301373
if (!response.ok) {
302374
throw new Error(`Could not load settings from '${settings.configurationEndpoint}'`);
303375
}
304376

305377
const downloadedSettings = await response.json();
306378

307-
window.sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`, JSON.stringify(settings));
308-
309-
downloadedSettings.automaticSilentRenew = true;
310-
downloadedSettings.includeIdTokenInSilentRenew = true;
311-
312379
finalSettings = downloadedSettings;
313380
} else {
314381
if (!settings.scope) {
@@ -323,18 +390,24 @@ export class AuthenticationService {
323390
finalSettings = settings;
324391
}
325392

326-
const userManager = new UserManager(finalSettings);
393+
window.sessionStorage.setItem(`${AuthenticationService._infrastructureKey}.CachedAuthSettings`, JSON.stringify(finalSettings));
394+
395+
return AuthenticationService.createUserManagerCore(finalSettings);
396+
}
327397

398+
private static createUserManagerCore(finalSettings: UserManagerSettings) {
399+
const userManager = new UserManager(finalSettings);
328400
userManager.events.addUserSignedOut(async () => {
329-
await userManager.removeUser();
401+
userManager.removeUser();
330402
});
331-
332403
return userManager;
333404
}
334405
}
335406

336407
declare global {
337-
interface Window { AuthenticationService: AuthenticationService; }
408+
interface Window { AuthenticationService: AuthenticationService }
338409
}
339410

411+
AuthenticationService.handleCallback();
412+
340413
window.AuthenticationService = AuthenticationService;

src/Components/WebAssembly/WebAssembly.Authentication/src/Interop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"build": "npm run build:release",
55
"build:release": "webpack --mode production --env.production --env.configuration=Release",
66
"build:debug": "webpack --mode development --env.configuration=Debug",
7-
"watch": "webpack --watch --mode development"
7+
"watch": "webpack --watch --mode development --env.configuration=Debug"
88
},
99
"devDependencies": {
1010
"ts-loader": "^6.2.1",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Collections.Generic;
5+
using System.Text.Json.Serialization;
6+
7+
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
8+
{
9+
/// <summary>
10+
/// A user account.
11+
/// </summary>
12+
/// <remarks>
13+
/// The information in this type will be use to produce a <see cref="System.Security.Claims.ClaimsPrincipal"/> for the application.
14+
/// </remarks>
15+
public class RemoteUserAccount
16+
{
17+
/// <summary>
18+
/// Gets or sets properties not explicitly mapped about the user.
19+
/// </summary>
20+
[JsonExtensionData]
21+
public IDictionary<string, object> AdditionalProperties { get; set; }
22+
}
23+
}

src/Components/WebAssembly/WebAssembly.Authentication/src/Options/DefaultApiAuthorizationOptionsConfiguration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public void Configure(RemoteAuthenticationOptions<ApiAuthorizationProviderOption
1717
options.AuthenticationPaths.RemoteRegisterPath ??= "Identity/Account/Register";
1818
options.AuthenticationPaths.RemoteProfilePath ??= "Identity/Account/Manage";
1919
options.UserOptions.ScopeClaim ??= "scope";
20+
options.UserOptions.RoleClaim ??= "role";
2021
options.UserOptions.AuthenticationType ??= _applicationName;
2122
}
2223

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.Extensions.DependencyInjection;
5+
6+
namespace Microsoft.AspNetCore.Components.WebAssembly.Authentication
7+
{
8+
internal class RemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
9+
: IRemoteAuthenticationBuilder<TRemoteAuthenticationState, TAccount>
10+
where TRemoteAuthenticationState : RemoteAuthenticationState
11+
where TAccount : RemoteUserAccount
12+
{
13+
public RemoteAuthenticationBuilder(IServiceCollection services) => Services = services;
14+
15+
public IServiceCollection Services { get; }
16+
}
17+
}

0 commit comments

Comments
 (0)