auth0 / auth0-spa-js

Auth0 authentication for Single Page Applications (SPA) with PKCE
MIT License
923 stars 364 forks source link

Allow other API to open popups besides window.open #1321

Open kcarnold opened 5 days ago

kcarnold commented 5 days ago

Checklist

Describe the problem you'd like to have solved

Microsoft Office add-ins require that any popups be opened using a special API instead of window.open. A few unusual constraints of that API are:

  1. The popup must start in the same domain (it can redirect out of it).
  2. There isn't a reliable, cross-platform way to send information to the dialog after it's opened (i.e., you should package everything you need to send into the query string)
  3. Sending data back from the popup also requires a custom API.

Describe the ideal solution

I've done some gymnastics (see below) to make this API work with loginWithPopup, but it would be much easier if the user of the library could provide a custom dialog API. For example, something like:

loginWithPopup({}, {
  popupController: async (popupURL) => {
     const bounceURL = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '') + '/popup.html?redirect=' + encodeURIComponent(popupURL);
    const dialog = await asyncify(Office.context.ui.displayDialogAsync)(bounceURL, OTHER_OPTIONS);
    const tokenReply = await waitForMessageReceived(dialog);
    return tokenReply;
    }
});

where I've imagined asyncified versions of the Office dialog code.

Alternatives and current workarounds

My workaround code currently looks like:

        let dialog: Office.Dialog;

        const processMessage = async (
            args:
                | { message: string; origin: string | undefined }
                | { error: number }
        ) => {
            if ('error' in args) {
                // eslint-disable-next-line no-console
                console.error('Error:', args.error);
                return;
            }
            // eslint-disable-next-line prefer-const
            let messageFromDialog = JSON.parse(args.message);
            dialog.close();

            if (messageFromDialog.status === 'success') {
                // The dialog reported a successful login.
                // eslint-disable-next-line prefer-const
                let token = messageFromDialog.auth0Token;
                // eslint-disable-next-line no-console
                console.log('Login successful.', token);

                // Mock the window message event that auth0-spa-js expects
                // see https://github.com/auth0/auth0-spa-js/blob/f2e566849efa398ca599daf9ebdfbbd62fcb1894/__tests__/utils.test.ts#L234
                let messageEvent = new MessageEvent('message', {
                    data: {
                        type: 'authorization_response',
                        response: {id_token: token}
                    }
                })
                window.dispatchEvent(messageEvent);
            }
            else {
                // eslint-disable-next-line no-console
                console.error('Login failed.', messageFromDialog);
            }
        };

        // Make the href of the popup be a setter so that we can actually launch the dialog with the correct url to begin with
        const mockPopup = {
            location: { 
                set href(url: string) {
                    console.log("Setting location.href to", url);

                    // Set up an Office dialog to do the login flow
                    // height and width are percentages of the size of the screen.
                    // How MS use it: https://github.com/OfficeDev/Office-Add-in-samples/blob/main/Samples/auth/Office-Add-in-Microsoft-Graph-React/utilities/office-apis-helpers.ts#L38

                    // Bounce off /popup.html?redirect=... to get the token
                    let redirect = encodeURIComponent(url);
                    let bounceURL = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '') + '/popup.html?redirect=' + redirect;
                    console.log("Bouncing to", bounceURL);
                    Office.context.ui.displayDialogAsync(
                        bounceURL,
                        { height: 45, width: 55 },
                        function (result) {
                            dialog = result.value;
                            dialog.addEventHandler(
                                Office.EventType.DialogMessageReceived,
                                processMessage
                            );
                        }
                    );
                }
            },
            closed: false,
            close: () => {mockPopup.closed = true},
        };
...
await loginWithPopup({}, {popup: mockPopup});

Additional context

No response

kcarnold commented 2 days ago

Nevermind, the popup approach doesn't actually work here at all because auth0 tries to use a webmessage response, and Office doesn't allow cross-origin messages from dialogs without its special API too.

But I realized that loginWithRedirect is a more robust flow. Our WIP approach:

    if (!isAuthenticated) {
        let dialog: Office.Dialog;

        // Strategy: the popup will pass its redirect-callback data here, so we can pass it on to handleRedirectCallback
        const processMessage = async (
            args:
                | { message: string; origin: string | undefined }
                | { error: number }
        ) => {
            if ('error' in args) {
                console.error('Error:', args.error);
                return;
            }
            let messageFromDialog = JSON.parse(args.message);
            dialog.close();

            if (messageFromDialog.status === 'success') {
                // The dialog reported a successful login.
                handleRedirectCallback(messageFromDialog.urlWithAuthInfo);
            }
            else {
                console.error('Login failed.', messageFromDialog);
            }
        };

    // Actually make a popup using MS dialog API
    // hook the message event from the popup to set close false and get the token
    return (
        <div>
            Login here:
            <button onClick= { async () => {
                // Use this dialog for the Auth0 client library.
                await loginWithRedirect({
                    openUrl: async (url: string) => {
                        const redirect = encodeURIComponent(url);
                        const bounceURL = location.protocol + '//' + location.hostname + (location.port ? ':' + location.port : '') + '/popup.html?redirect=' + redirect;
                        // height and width are percentages of the size of the screen.
                        // How MS use it: https://github.com/OfficeDev/Office-Add-in-samples/blob/main/Samples/auth/Office-Add-in-Microsoft-Graph-React/utilities/office-apis-helpers.ts#L38
                        Office.context.ui.displayDialogAsync(
                            bounceURL,
                            { height: 45, width: 55 },
                            function (result) {
                                dialog = result.value;
                                dialog.addEventHandler(
                                    Office.EventType.DialogMessageReceived,
                                    processMessage
                                );
                            }
                        );
                    }
                });
        }}
                >Log in
            </button>
            </div>
        );
    }

Handling log-out is a different story... we haven't figured that out yet!