openid / AppAuth-JS

JavaScript client SDK for communicating with OAuth 2.0 and OpenID Connect providers.
Apache License 2.0
985 stars 161 forks source link

AppAuth-JS in combination with WebExtensions #8

Closed merijndejonge closed 7 years ago

merijndejonge commented 7 years ago

Hi, I'm developing a web browser extension (using WebExtensions standard) in which a user needs to authenticate using openid-connect. I'm facing all sorts of issues with redirects as part of the authentication flow. The main reason is that I have to open an addition browser (popup) window where the authentication flow is executed. This is because redirects are not supported in the main popup of a browser extension. Now, several browsers will simply close the main popup window if it looses focus, which happens when the authentication window is opened :-( I tried hard to get oidc-client to do the job, but it is not satisfactory.

My question is, will AppAuth-JS help me by supporting (e.g.) the resource-owner flow, such that I can simply pass username/password to my identity provider? Or is there another preferred approach to get openid-connect authentication work in a browser extension? Are there any examples? Is someone willing to help?

Thanks in advance.

iainmcgin commented 7 years ago

We don't know a lot about WebExtensions and their restrictions, unfortunately. We're focusing on the native app cases like using Electron / NW.js for now. The library itself will let you send whatever authorization request parameters you like, so you could conceivably run a resource owner flow within your extension. In the more general case where you'd like to be able to handle a redirect URI for the authorization response, registering a custom scheme may work (though support looks pretty patchy).

Sorry, we don't have a lot of bandwidth to help with this right now, but we'd be interested to hear what you eventually manage to get working if you continue with this approach.

tikurahul commented 7 years ago

Hi. Thanks for the question. I would love to help as one of the goals of the library was to make it possible to work with Chrome and web extensions.

If you need better support for WebExtensions, please reach out. In the meantime I am going to take a look at what the capabilities are for a web extension.

merijndejonge commented 7 years ago

Hi Tikurahul, Thanks for your reply. I'll have a look at it. The problem with the redirect is that a web extensions does not allow its (root) content to be replaced by a redirect. This, I guess, is a security issue. Therefore, afaik, redirect-based authentication flows should always be executed in a separate browser window. Chrome/Opera handle this correctly on Windows, but other browsers I've seen (e.g., FireFox and Edge) will close the main browser popup window when the new browser window for authentication gets the focus. I assume this is a bug, but it seems to be not of very high prio to fix this (see https://bugzilla.mozilla.org/show_bug.cgi?id=1292701).

Therefore I'm looking for an alternative way where no separate browser window for the authentication flow is needed.

Thanks again.

tikurahul commented 7 years ago

You might be able to use a request rewriter for handling the redirect. It's like a foreign fetch to your extension. (https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Intercept_HTTP_requests). The redirect URI can contain a dynamic unique path so as to not clash with a real request. WDYT?

merijndejonge commented 7 years ago

So, if I want to use the resource-owner flow. Can you help me get started? From the existing documentation of AppAuth-JS I find it a bit difficult where to start/

tikurahul commented 7 years ago

Sorry, that things are a little hard to discover. At the moment, AppAuth-JS does not expose any higher level APIs to make the flow very easy. However, you should still be able to make your requests work.

Take a look at index.ts which exposes these primitives.

https://github.com/openid/AppAuth-JS/blob/master/src/app/index.ts#L87 is an optional openid configuration discovery request. If you don't know the authorization endpoints and the token endpoints you can just specify a provider URL for the library to auto-discover these endpoints.

https://github.com/openid/AppAuth-JS/blob/master/src/app/index.ts#L100 makes the actual authorization request. This is where you will need to specify the clientId, redirectUri for the WebExtension and so on. If you notice, the method uses an instance of an AuthorizationRequestHandler to handle the mechanics of the actual authorization request. AppAuth-JS ships with 2 implementations of this interface, one which can handle authorization responses via a client redirect and a version suitable for Node/Electron where one can spin up a local web server. Given you are using the client side redirectUrl approach, you will choose the former.

The AuthorizationRequestHandler notifies you upon completion via the AuthorizationNotifier. So upon completion of authorization requests, you will get a callback here. I have modelled this as a callback as in the future we would like to support things like Custom tabs and System browsers (which run in a separate process).

Once you get the request_code from your server, you can exchange that for a refresh / access token by making a token request like so (https://github.com/openid/AppAuth-JS/blob/master/src/app/index.ts#L118). The TokenResponse type also exposes an isValid() API which tells you if the access token has not expired yet. Note, the library does not handler any persistence of the tokens - so you can choose how you want to do that. In the future, we will make that part easier as well.

If you want to see all of this running on a page that you can test things on, take a look at (https://github.com/openid/AppAuth-JS/blob/master/app/index.html).

Look at the https://github.com/openid/AppAuth-JS#development-workflow to set up the package for testing, and run the npm script npm run-script app if you want to play around with the app locally.

Notes on importing classes

This library uses TypeScript which is what I have linked to. I am also publishing the compiled javascript in the built folder for the npm package.

So imports in index.ts which look like:

import {AuthorizationRequest} from '../authorization_request';

Should turn into something like:

import {AuthorizationRequest} from '@openid/appauth/built/authorization_request'; or require("@openid/appauth/built/authorization_request").AuthorizationRequest; if you are using commonjs modules.

--

Please let me know if you have more questions.

merijndejonge commented 7 years ago

Hi Rahul, Thanks a lot for your detailed description. After a few build issues (e.g., I had to add --glob=src/**.ts to the clang-format command in package.json) I managed to get both demos up-and-running and to even let them connect to my own openid-connect instance (using identity-server4). So I know how to set clientid etc.

My main question remains, though. I need to figure out whether it is possible to have an authentication flow that can run completely in the single popup window of my browser extension. A redirect to a login page of an openid-connect login provider doesn't work in this main popup window (afaik). Ans, as I explained, opening a second browser window from my plugin to execute the authentication flow is not portable across browser.

My understanding is that I then should use the resource-owner flow where I can pass the username and password directly to my identity server, such that no redirect is needed. Do you agree, or is there an alternative?

Despite your explanation, I don't know where to start with getting the resource-owner flow to work, such I can pass username/password directly without a redirect.

Any tips/suggestions?

Thanks again.

tikurahul commented 7 years ago

You should be able to use the standard client redirect flow, where you can have your extension handle the client redirect locally, using the https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Intercept_HTTP_requests technique that I talked about. See if that technique works. Otherwise, you can run service workers + foreign fetch.

merijndejonge commented 7 years ago

Thanks again for your help. Sorry if I misunderstand you. I think I understand how I can use the Intercept_HTTP_requests to get authentication tokens from the redirected url. However, I do not understand how it would help me to bypass the login screen from the identity server. In other words, how would it help me to pass username/password to the identity server?

I'm not (yet) familiar with service workers + foreign fetch.

tikurahul commented 7 years ago

I am not sure if I understand your question. Given that your extension can handle the redirect, you should be able to capture the authorization code via the redirect - and pass it on to the AuthorizationService.

In your case, I think you want support for a grant_type=password. Is that correct ?

merijndejonge commented 7 years ago

Yes, I think what I'm looking for (but I'm not an expert) is the Resource owner password grant type (http://docs.identityserver.io/en/release/topics/grant_types.html).

My goal is not to redirect my browser extension to a login page of the identity provider because that requires that my extension opens another browser window which results in the earlier explained issues. Rather, my extension should offer the user it's own login screen where the user can enter his/her username/password. The extension then submits the username/password to the identity provider and returns authentication tokens on return.

I hope this makes clear what I want. If you can point me in the right direction to achieve this, I would be very happy!

tikurahul commented 7 years ago

So one of the explicit goals of AppAuth-Js is to not require the user to enter their credentials again, and to re-use the IDP's cookie jar or autofill as much as we can. We can only do that when when we redirect to the IDP's login page. For implicit flows, this is no longer true. Which is why currently we don't actually support implicit flows at all.

It should be really easy to add support for implicit flows on your own given the primitives you have. Rather than make a request for a response_type code you can make a request with response_type token.

The result of the authorization request should give you a TokenResponse type. You will need to completely skip AuthorizationService and make the actual request on your own because you no longer need a lot of the infrastructure that AuthorizationService gives you.

For guidance on the request you need to make you can take a look at: https://github.com/openid/AppAuth-JS/blob/master/src/node_support/node_request_handler.ts#L96

Once you have the requestUrl just make a XHR and cast it to a TokenResponse type and you should be good. Do you want to give that a go ?

merijndejonge commented 7 years ago

Thanks again! I will certainly give it a try and I'll keep you updated.

tikurahul commented 7 years ago

Did you manage to get the implicit flow to work ? Let me know if you had other questions.

merijndejonge commented 7 years ago

I'm sorry no time yet :-(

merijndejonge commented 7 years ago

Hi Tikurahul, I wanted to give it a try. I looked at buildRequestUrl as you suggested. This turns out to be a protected method of AuthorizationRequestHandler. Do you propose that I create my own subclass from AuthorizationRequestHandler?

Merijn

merijndejonge commented 7 years ago

It turns out I have to write my own version of buildRequestUrl because the existing one has hard-coded elements that are added to the request url (see requestMap). In particular it always adds a redirect_uri.

Moreover, a url is created with a query string which is then assigned to the location of the browser to trigger the redirect. This is done with the statement: this.locationLike.assign(url);

For ResourceOwnerPassword flow to work, I think the parameters should be posted to the server using an HTML post method. Is this provided in the AppAuth library? If not, could you give me an advise how I could best add this?

tikurahul commented 7 years ago

You are correct. requestMap currently expects a redirectUri to be present. So in your case you can make a change to conditionally add it if one was to be defined.

For the 2nd problem, currently the locationLike type expects the assign method to be present. In your case you can have the implementation of assign make an XHR.

merijndejonge commented 7 years ago

Thanks. What would be the best way to make an XHR?

tikurahul commented 7 years ago

You can use https://developer.mozilla.org/en/docs/Web/API/Fetch_API or https://github.com/openid/AppAuth-JS/blob/master/src/xhr.ts#L25 if you happen to be bundling jquery.ajax in your extension.

merijndejonge commented 7 years ago

Thanks again for answering my questions. It appears to me, though, that I've to build quite some stuff to achieve my goals. This does me wonder what the benefit is of using AppAuth in my case. In other words, compared to simply posting an http form with user name and password and handle the return result myself, are there any benefits for me of using AppAuth instead?

tikurahul commented 7 years ago

Unless you happen to be using OpenID connect service discovery or need to do token exchange, I would say using AppAuth in this particular use case does not get you much. All you need to so is to make an XHR for the implicit flow.

merijndejonge commented 7 years ago

Service discovery is also not a big deal, right? What do you mean by token exchange? I do need to send a token to for authenticating to logged-in user to a service that my app is consuming. Is this hat you mean by token exchange?

tikurahul commented 7 years ago

Yes, in your case if all you care about is an idToken then you should be good to go.

tikurahul commented 7 years ago

Can i go ahead and mark this question answered ?

tikurahul commented 7 years ago

Marking the question as answered.

merijndejonge commented 7 years ago

Sorry, for not responding earlier. Just for the record, I would like to share the code that I've created to get the "password" grant type to work for me. See the code below. It is developed for use with Angular2/4. Logout is not working properly. I don't yet understand why I can't use my identity token to logout. Moreover, I did not yet implement refresh since I also not fully understand how that should work. Any suggestions are welcome.


import { Injectable, Inject } from '@angular/core';
import { Headers, Http, URLSearchParams, Response } from '@angular/http';
import 'rxjs/add/operator/toPromise';

export abstract class IAuthConfig {
    clientId: string;
    clientSecret: string;
    openIdConnectUrl: string;
    scope: string;
    response_type: string;
}

export const OidcConfig: IAuthConfig = {
    clientId: '',
    clientSecret: '',
    openIdConnectUrl: '',
    scope: 'openid ...',
    response_type: 'id_token'
};

// Interface representing a user identity in the app
interface IUserIdentity {
    email: string;
    token: string;
    expires_in: number;
    expires_at: number;
}

// Interface representing use info as return from userinfo_endpoint
interface UserInfo {
    // properties from the userinfo_endpoint
}

interface OidcTokenResponse {
    access_token: string
    expires_in: number
    token_type: string
}
interface OidcServiceConfiguration {
    authorization_endpoint: string
    token_endpoint: string
    userinfo_endpoint: string
    end_session_endpoint: string
}
interface MindYourPassUserInfo {
    email: string;
    MindYourPassUserSalt : string;
}
export abstract class IOpenIdAuthenticationService {
    protected readonly wellKnownPath: string = '.well-known';
    protected readonly openIdCOnfiguration: string = 'openid-configuration';

    protected readonly userName: string = "username";
    protected readonly password: string = "password";
    protected readonly clientId: string = "client_id";
    protected readonly grantType: string = "grant_type";

    protected readonly grantTypePassword: string = "password";

    abstract async login(userName: string, password: string): Promise<IUserIdentity>;
    abstract async logout(userIdentity: IUserIdentity): Promise<Response>;
}

@Injectable()
export class OpenIdAuthenticationService extends IOpenIdAuthenticationService {
    constructor(
        private http: Http,
        @Inject(AuthConfig) private config: IAuthConfig) {
        super();
    }

    async login(userName: string, password: string): Promise<IUserIdentity> {
        var serviceConfiguration = await this.getServiceConfigurationFromServer();
        var tokenResponse = await this.authorizeUser(serviceConfiguration, userName, password);
        var userInfo = await this.getUserInfo(serviceConfiguration, tokenResponse.access_token);

        let now = Date.now() / 1000;

        var userIdentity: IUserIdentity = {
            email: userInfo.email,
            token: tokenResponse.access_token,
            expires_in: tokenResponse.expires_in,
            expires_at: now + tokenResponse.expires_in
        };
        return userIdentity;
    }
    async logout(userIdentity: IUserIdentity): Promise<Response> {
        var serviceConfiguration = await this.getServiceConfigurationFromServer();

        var url = serviceConfiguration.end_session_endpoint;
        let query = new URLSearchParams();
        query.append('id_token_hint', userIdentity.token);

        var headers = new Headers(
            {
                'Content-Type': 'application/x-www-form-urlencoded',
            });

        return await this.http
            .get(`${url}?${query}`, { headers: headers })
            .toPromise();
    }

    private async getUserInfo(
        serviceConfiguration: OidcServiceConfiguration,
        accessToken: string): Promise<MindYourPassUserInfo> {

        var url = serviceConfiguration.userinfo_endpoint;
        var headers = new Headers(
            {
                'Authorization': `Bearer ${accessToken}`
            });
        var response = await this.http.get(url, { headers: headers }).toPromise();
        return response.json() as MindYourPassUserInfo;
    }
    private async authorizeUser(serviceConfiguration: OidcServiceConfiguration,
        userName: string, password: string): Promise<OidcTokenResponse> {
        var url = `${serviceConfiguration.token_endpoint}`;

        let postData = new URLSearchParams();
        postData.append('grant_type', 'password');
        postData.append('username', userName);
        postData.append('password', password);
        postData.append('scope', this.config.scope);
        postData.append('response_type', this.config.response_type);

        var authorizationHeader = btoa(`${this.config.clientId}:${this.config.clientSecret}`);

        var headers = new Headers(
            {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Authorization': `Basic ${authorizationHeader}`
            }
        );

        var response = await this.http
            .post(url, postData, { headers: headers })
            .toPromise();
        return response.json();
    }

    private async getServiceConfigurationFromServer(): Promise<OidcServiceConfiguration> {
        var url = `${this.config.openIdConnectUrl}/${this.wellKnownPath}/${this.openIdCOnfiguration}`;
        var result = await this.http.get(url)
            .toPromise();
        return result.json() as OidcServiceConfiguration;
    }
}