简体   繁体   中英

Blazor WebAssembly + Amazon Cognito

I would like to set up a Blazor client-side app with authentication through AWS Cognito .

When I run the app I'm not redirected to a login page, instead the page says "Authorizing..." for a few seconds, while I get this error in the console:

The loading of “https://blazorapp.auth.eu-central-1.amazoncognito.com/login?…Q&code_challenge_method=S256&prompt=none&response_mode=query” in a frame is denied by “X-Frame-Options“ directive set to “DENY“.
This error page has no error code in its security info
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed.

Then, the default "Hello, world!" index page is shown (even though as I understand it, it should not be visible to an unauthenticated user based on App.razor definition?) . If I click on "Log in", I get the same error in console, but then after a few seconds the Cognito-hosted login page opens, I am able to log in, I am redirected back to my app, and the app shows the authenticated user's info in top right corner, but the console is a little weird again:

info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[2]
      Authorization failed.
info: Microsoft.AspNetCore.Authorization.DefaultAuthorizationService[1]
      Authorization was successful.

Question 1

How can I get rid of these errors and have my app redirect to Cognito login page without ~10s delay?

Question 2

Why is all content in my app visible at all times regardless of whether I'm authenticated or not? It's as if the NotAuthorized node under AuthorizeRouteView in App.razor had no effect at all, unless I am confusing something here

Code:

Program.cs

builder.Services.AddOidcAuthentication(options =>
{
    options.ProviderOptions.Authority = "https://cognito-idp.{aws-region}.amazonaws.com/{cognito-userpoolid}";
    options.ProviderOptions.ClientId = "{cognito-clientid}";
    options.ProviderOptions.ResponseType = "code";
    options.ProviderOptions.RedirectUri = "https://localhost:44306/authentication/login-callback";
    options.ProviderOptions.PostLogoutRedirectUri = "https://localhost:44306/authentication/logout-callback";
});

App.razor (as created from template, no modifications)

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p>You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

I have only modified the call to AddOidcAuthentication in Program.cs myself, all other files were populated by Visual Studio when creating a Blazor WebAssembly App with Individual User Accounts.

I am struggling to get this to work and would greatly appreciate any help on this topic

EDIT:

Following @aguafrommars's answer I have published the website to Amazon S3 using static website hosting with Amazon CloudFront as CDN, however, the behavior of the published app is exactly the same as described local behavior

To expand on the questions:

Question 1 expanded:

When the page says "Authorizing..." I only get the described error in the console, the Cognito hosted UI is not rendered, only when I click on "Log in" I am either redirected (with major delay) to Cognito hosted UI, or authenticated without redirection (if I signed in before), perhaps this GIF will clear things up:

blazor/cognito 奇怪的行为

I might be wrong, but isn't the problem that the Cognito hosted UI is rejecting to be rendered in an iframe ? Can my app redirect to the hosted UI in the first place, like it eventually does? Right now I have to wait while X-Frame-Options error is thrown, click on "Log in", wait while another X-Frame-Options error is thrown, and then finally I'm redirected and the flow succeeds (in the gif the UI doesn't show up because I authenticated before in the session)

Question 2 expanded:

The behavior I want to achieve is that if the user is not authenticated, they cannot see any part of the application, instead they are redirected to Cognito hosted UI and only after they are authenticated they can see anything. I tried to play around with Authorize attribute in MainLayout.razor , but the result is always a blank screen, I would like to provide some code and details but I believe the behavior is impacted by errors described in Question 1 , which is why I would like to sort it out first

I ended up switching from Cognito to Auth0 and upgrading from Api Gateway's RestApi to HttpApi, which includes built in JWT authorizer, and I am very happy with the change. Cognito just had too many problems in the end, but if someone is determined to get it to work check @aguafrommars's comments under the accepted answer.

Response 1:

While the authorizing message is displayed, the app check for a valid authentication and set up auto renew token iframe. If you look at the network log on your browser you'll see requests made by this time.
When the app run in release it's faster.

Response 2:

You need to add authorization on pages you want to protect by adding the Authorize attribute .

@page "/"
@attribute [Authorize]

Had the same problem and switched out to Azure B2C which again resolved the issue. Appears to be a problem with the auth library when linking to AWS Cognito as the auth provider.

Issues raised with MS - https://github.com/dotnet/aspnetcore/issues/22651

I'm answering this issue which was marked as duplicate here...

The reason for the delay is the timeout waiting for the silent login process (which has a 10s timeout I believe) as mentioned here and here

Root cause is that AWS Cognito is not compliant with the OIDC standards. Which results in the "'X-Frame-Options' to 'DENY'" error in the browser console.

Until the Blazor team allow us to turn off the silent signin from code, the solution was to disable silent login as follows:

Download the Blazor Interop files located in the asp.net repo here to a local folder.

Open the local folder using vs code and install typescript, webpack, yarn, etc if not already installed

npm install -g yarn
npm install -g typescript
npm install -g webpack

Then edit the AuthenticationService.ts file as follows (commenting out the silent signin functionality). Sorry for the long code print.

import { UserManager, UserManagerSettings, User } from 'oidc-client'

type Writeable<T> = { -readonly [P in keyof T]: T[P] };

type ExtendedUserManagerSettings = Writeable<UserManagerSettings & AuthorizeServiceSettings>

type OidcAuthorizeServiceSettings = ExtendedUserManagerSettings | ApiAuthorizationSettings;

function isApiAuthorizationSettings(settings: OidcAuthorizeServiceSettings): settings is ApiAuthorizationSettings {
    return settings.hasOwnProperty('configurationEndpoint');
}

interface AuthorizeServiceSettings {
    defaultScopes: string[];
}

interface ApiAuthorizationSettings {
    configurationEndpoint: string;
}

export interface AccessTokenRequestOptions {
    scopes: string[];
    returnUrl: string;
}

export interface AccessTokenResult {
    status: AccessTokenResultStatus;
    token?: AccessToken;
}

export interface AccessToken {
    value: string;
    expires: Date;
    grantedScopes: string[];
}

export enum AccessTokenResultStatus {
    Success = 'success',
    RequiresRedirect = 'requiresRedirect'
}

export enum AuthenticationResultStatus {
    Redirect = 'redirect',
    Success = 'success',
    Failure = 'failure',
    OperationCompleted = 'operationCompleted'
};

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

export interface AuthorizeService {
    getUser(): Promise<unknown>;
    getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult>;
    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;
    }

    async getAccessToken(request?: AccessTokenRequestOptions): Promise<AccessTokenResult> {
        const user = await this._userManager.getUser();
        if (hasValidAccessToken(user) && hasAllScopes(request, user.scopes)) {
            return {
                status: AccessTokenResultStatus.Success,
                token: {
                    grantedScopes: user.scopes,
                    expires: getExpiration(user.expires_in),
                    value: user.access_token
                }
            };
        } else {
            return {
                status: AccessTokenResultStatus.RequiresRedirect
            };
            // try {
            //     const parameters = request && request.scopes ?
            //         { scope: request.scopes.join(' ') } : undefined;

            //     const newUser = await this._userManager.signinSilent(parameters);

            //     return {
            //         status: AccessTokenResultStatus.Success,
            //         token: {
            //             grantedScopes: newUser.scopes,
            //             expires: getExpiration(newUser.expires_in),
            //             value: newUser.access_token
            //         }
            //     };

            // } catch (e) {
            //     return {
            //         status: AccessTokenResultStatus.RequiresRedirect
            //     };
            // }
        }

        function hasValidAccessToken(user: User | null): user is User {
            return !!(user && user.access_token && !user.expired && user.scopes);
        }

        function getExpiration(expiresIn: number) {
            const now = new Date();
            now.setTime(now.getTime() + expiresIn * 1000);
            return now;
        }

        function hasAllScopes(request: AccessTokenRequestOptions | undefined, currentScopes: string[]) {
            const set = new Set(currentScopes);
            if (request && request.scopes) {
                for (const current of request.scopes) {
                    if (!set.has(current)) {
                        return false;
                    }
                }
            }

            return true;
        }
    }

    async signIn(state: unknown) {
        try {
            await this._userManager.clearStaleState();
            await this._userManager.signinRedirect(this.createArguments(state));
            return this.redirect();
        } catch (redirectError) {
            return this.error(this.getExceptionMessage(redirectError));
        }



        // try {
        //     await this._userManager.clearStaleState();
        //     await this._userManager.signinSilent(this.createArguments());
        //     return this.success(state);
        // } catch (silentError) {
        //     try {
        //         await this._userManager.clearStaleState();
        //         await this._userManager.signinRedirect(this.createArguments(state));
        //         return this.redirect();
        //     } catch (redirectError) {
        //         return this.error(this.getExceptionMessage(redirectError));
        //     }
        // }
    }

    async completeSignIn(url: string) {
        const requiresLogin = await this.loginRequired(url);
        const stateExists = await this.stateExists(url);
        try {
            const user = await this._userManager.signinCallback(url);
            if (window.self !== window.top) {
                return this.operationCompleted();
            } else {
                return this.success(user && user.state);
            }
        } catch (error) {
            if (requiresLogin || window.self !== window.top || !stateExists) {
                return this.operationCompleted();
            }

            return this.error('There was an error signing in.');
        }
    }

    async signOut(state: unknown) {
        try {
            if (!(await this._userManager.metadataService.getEndSessionEndpoint())) {
                await this._userManager.removeUser();
                return this.success(state);
            }
            await this._userManager.signoutRedirect(this.createArguments(state));
            return this.redirect();
        } catch (redirectSignOutError) {
            return this.error(this.getExceptionMessage(redirectSignOutError));
        }
    }

    async completeSignOut(url: string) {
        try {
            if (await this.stateExists(url)) {
                const response = await this._userManager.signoutCallback(url);
                return this.success(response && response.state);
            } else {
                return this.operationCompleted();
            }
        } catch (error) {
            return this.error(this.getExceptionMessage(error));
        }
    }

    private getExceptionMessage(error: any) {
        if (isOidcError(error)) {
            return error.error_description;
        } else if (isRegularError(error)) {
            return error.message;
        } else {
            return error.toString();
        }

        function isOidcError(error: any): error is (Oidc.SigninResponse & Oidc.SignoutResponse) {
            return error && error.error_description;
        }

        function isRegularError(error: any): error is Error {
            return error && error.message;
        }
    }

    private async stateExists(url: string) {
        const stateParam = new URLSearchParams(new URL(url).search).get('state');
        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 && this._userManager.settings.stateStore) {
            const error = await this._userManager.settings.stateStore.get(errorParameter);
            return error === 'login_required';
        } else {
            return false;
        }
    }

    private createArguments(state?: unknown) {
        return { useReplaceToNavigate: true, data: state };
    }

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

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

    private redirect() {
        return { status: AuthenticationResultStatus.Redirect };
    }

    private operationCompleted() {
        return { status: AuthenticationResultStatus.OperationCompleted };
    }
}

export class AuthenticationService {

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

    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) {
            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.
        }
    }

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

    public static getUser() {
        return AuthenticationService.instance.getUser();
    }

    public static getAccessToken(options: AccessTokenRequestOptions) {
        return AuthenticationService.instance.getAccessToken(options);
    }

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

    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: unknown) {
        return AuthenticationService.instance.signOut(state);
    }

    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)) {
            const response = await fetch(settings.configurationEndpoint);
            if (!response.ok) {
                throw new Error(`Could not load settings from '${settings.configurationEndpoint}'`);
            }

            const downloadedSettings = await response.json();

            finalSettings = downloadedSettings;
        } else {
            if (!settings.scope) {
                settings.scope = settings.defaultScopes.join(' ');
            }

            if (settings.response_type === null) {
                // If the response type is not set, it gets serialized as null. OIDC-client behaves differently than when the value is undefined, so we explicitly check for a null value and remove the property instead.
                delete settings.response_type;
            }

            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 () => {
            userManager.removeUser();
        });
        return userManager;
    }
}

declare global {
    interface Window { AuthenticationService: AuthenticationService }
}

AuthenticationService.handleCallback();

window.AuthenticationService = AuthenticationService;

Then build the js with

yarn build:release

Once the js file is compiled, copy the AuthenticationService.js file into the /wwwroot directory of your Blazor WASM app.

Then in the index.html file, comment out the MS script and replace with your own:

<!--    <script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>-->
<script src="AuthenticationService.js"></script>

Run your app and Cognito will now be (relatively) instantaneous

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM