remotestorage / remotestorage.js

⬡ JavaScript client library for integrating remoteStorage in apps
https://remotestoragejs.readthedocs.io
MIT License
2.32k stars 141 forks source link

Support alternative to InAppBrowser for Cordova apps #1089

Open galfert opened 6 years ago

galfert commented 6 years ago

I found this article, which suggests to use SafariViewController instead of InAppBrowser.

Unlike the name would suggest, SafariViewController is not for iOS only, but also supports Android, using Chrome custom tabs.

The article lists several reasons not to use InAppBrowser anymore:

  1. Google has now deprecated the use of webviews to request oAuth tokens;
  2. InAppBrowser uses UIWebView on iOS and is incompatible with the more recent WKWebView Engine, i.e. when WKWebView is enabled, the InAppBrowser cannot be automatically closed at the end of the flow. A fix as yet to be released.
  3. But more importantly, the InAppBrowser breaks the single sign-on paradigm because it does not remember user credentials, which is a nightmare when MFA is enabled.

I'm not sure though if you can also intercept the redirect to extract the token from the URL, like we are doing it now. The article suggests to use a custom URL scheme for your app, so the redirect page can extract the token and pass it to the app.

What do you think? Is it worth looking into?

EvansMatthew97 commented 6 years ago

Any news on this? I saw you were getting issues with Google Drive in #1099.

I think it's definitely worth looking into.

Android isn't yet stable, which is a concern. Looks good enough though. Chrome custom tabs are considerably faster, too, which is a plus.

I think now might be a good time to consider an API that allows you to implement your own OAuth process (in addition to the default process). I've just had to implement an OAuth function for Electron using electron.remote.BrowserWindow, because window.open doesn't work in Electron. It looks pretty much identical to the code in the Authorise() function aside from opening the window and intercepting the token url (so lots of repetition).

galfert commented 6 years ago

I haven't looked into this one yet. But I managed to solve the Google Drive issue (see https://github.com/remotestorage/remotestorage.js/pull/1099#issuecomment-346601946).

Would you mind sharing the changes you had to make? It would help to determine how a changed API would have to look like.

EvansMatthew97 commented 6 years ago

My solution to this isn't perfect, but here's what I've been doing:

The only thing that really needs to be changed is the implementation of the browser window popup. For example Electron.js cannot use window.open(...'_blank'...) to open up a pop up. Instead it has to use electron.remote.BrowserWindow.

BrowserWindow

The BrowserWindow class is an abstract

export abstract class BrowserWindow {

    protected listeners : any[] = [];

    /**
     * Creates the browser window, shows it, and handles detecting the correct URL
     */
    public abstract show(url : string);
    /**
     * Closes the browser window
     */
    public abstract close();

    /**
     * Add callback function to the list of listeners
     * @param fn Function that will be called with fn(url) on url change
     */
    public onUrlChange(fn) {
        this.listeners.push(fn);
    }

    /**
     * Emit the changes of the url
     */
    protected emitUrlChange(url) {
        for (let listener of this.listeners) {
            listener(url);
        }
    }

};

This is then extended for each platform I use.

Electron.js

class ElectronBrowserWindow extends BrowserWindow {
    private handle : any;

    public show(url : string) {
        this.handle = new electron.remote.BrowserWindow({
            width: 800,
            height: 800,
            webPreferences: {
                nodeIntegration: false
            }
        });

        this.handle.loadURL(url);

        this.handle.webContents.on('did-get-response-details', (e, status, newURL, originalURL, httpResponseCode, requestMethod, referrer, headers, resourceType) => {
            console.log(newURL);
            if (newURL.indexOf(cordovaRedirectUri) === 0) {
                this.emitUrlChange(newURL);
            }
        });
    }

    public close() {
        this.handle.close();
    }

};

Cordova

class CordovaBrowserWindow extends BrowserWindow {
    private handle : InAppBrowserObject;
    private iab : InAppBrowser;

    constructor() {
        super();

        this.iab = new InAppBrowser();
    }

    public show(url : string) {
        this.handle = this.iab.create(url);

        this.handle.on('loadstart')
            .subscribe((event) => {
                console.log(event);
                if (event.url.indexOf(cordovaRedirectUri) === 0) {
                    this.emitUrlChange(event.url);
                }
            });
    }

    public close() {
        this.handle.close();
    }

}

OAuthConnector

This class basically just does what remotestorage.js is already doing, just using my own implementation.

export const cordovaRedirectUri = 'https://YOUR_URL';

/**
 * Abstract class for performing RemoteStorage.js OAuth cross-
 * platform.
 */
export abstract class OAuthConnector {

    private remoteStorage : RemoteStorage;

    private services = {
        dropbox: {
            authUrl:  'https://www.dropbox.com/oauth2/authorize',
            scopeUrl: '',
        },
        googledrive: {
            authUrl: 'https://accounts.google.com/o/oauth2/auth',
            scopeUrl: 'https://www.googleapis.com/auth/drive'
        }
    };

    constructor(remoteStorage) {
        this.remoteStorage = remoteStorage;
    }

    public async connect(service) {
        this.remoteStorage.setBackend(service);

        let authWin : BrowserWindow = this.createBrowserWindow();

        let url = this.buildUrl(service);
        authWin.show(url);

        authWin.onUrlChange((newURL) => {
            let token = this.getResponseTokenFromUrl(newURL);

            this.remoteStorage.remote.configure({
                token: token
            });

            authWin.close();
        });
    }

    protected abstract createBrowserWindow() : BrowserWindow;

    /**
     * Builds the OAuth URL for the given service
     * @param service The service for which to build the OAuth URL
     */
    private buildUrl(service) {
        let api = this.remoteStorage.apiKeys[service];
        let apiKey = api.appKey || api.clientId;

        let url = this.services[service].authUrl;
        url += '?response_type=token'
        url += '&client_id=' + encodeURIComponent(apiKey);
        url += '&redirect_uri=' + encodeURIComponent(cordovaRedirectUri);
        url += '&scope=' + encodeURIComponent(this.services[service].scopeUrl);
        url += '&state';

        return url;
    }

    /**
     * Extracts the OAuth response token from the given url
     * @param url The URL
     */
    private getResponseTokenFromUrl(url) {
        // get the GET params part of the URL
        let urlParams = url.split('#')[1].split('&');

        let responseParams = {};

        for (let param of urlParams) {
            let paramSplit = param.split('=');

            let val = undefined;
            if (paramSplit.length == 2) {
            val = paramSplit[1];
            }

            responseParams[paramSplit[0]] = val;
        }

        return responseParams['access_token'];
    }
};

Electron implementation:

declare let electron;

export class ElectronConnector extends OAuthConnector {
    protected createBrowserWindow() : BrowserWindow {
        return new ElectronBrowserWindow();
    }
};

And the Cordova one:

import { InAppBrowser, InAppBrowserObject } from "@ionic-native/in-app-browser";

export class CordovaConnector extends OAuthConnector {
    protected createBrowserWindow() : BrowserWindow {
        return new CordovaBrowserWindow();
    }
};

Usage

This is how it is used inside my "authenticate()" function:

if (this.electronPlatform.isElectron()) {
  // add electron connector and connect
  let connector = new ElectronConnector(this.remoteStorage);
  connector.connect(service);
}

This could definitely be refactored a whole lot so just the BrowserWindow classes are needed and would be injected into a concrete OAuthConnector class, and can definitely make it a bit more DRY, but this is what I'm using at the moment.