48

I am currently experimenting with OAuth2 to develop a mobile application built entirely in JavaScript that talks to a CakePHP API. Take a look at the following code to see how my app currently looks (please note that this is an experiment, hence the messy code, and lack of structure in areas, etc..)

var access_token,
     refresh_token;

var App = {
    init: function() {
        $(document).ready(function(){
            Users.checkAuthenticated();
        });
    }(),
    splash: function() {
        var contentLogin = '<input id="Username" type="text"> <input id="Password" type="password"> <button id="login">Log in</button>';
        $('#app').html(contentLogin);
    },
    home: function() {
        var contentHome = '<h1>Welcome</h1> <a id="logout">Log out</a>';
        $('#app').html(contentHome);
    }
};

var Users = {
    init: function(){
        $(document).ready(function() {
            $('#login').live('click', function(e){
                e.preventDefault();
                Users.login();
            });
            $('#logout').live('click', function(e){
                e.preventDefault();
                Users.logout();
            });
        });
    }(),
    checkAuthenticated: function() {
        access_token = window.localStorage.getItem('access_token');
        if( access_token == null ) {
            App.splash();
        }
        else {
            Users.checkTokenValid(access_token);
        }
    },
    checkTokenValid: function(access_token){

        $.ajax({
            type: 'GET',
            url: 'http://domain.example/api/oauth/userinfo',
            data: {
                access_token: access_token
            },
            dataType: 'jsonp',
            success: function(data) {
                console.log('success');
                if( data.error ) {
                    refresh_token = window.localStorage.getItem('refresh_token');
                     if( refresh_token == null ) {
                         App.splash();
                     } else {
                         Users.refreshToken(refresh_token);
                    }
                } else {
                    App.home();
                }
            },
            error: function(a,b,c) {
                console.log('error');
                console.log(a,b,c);
                refresh_token = window.localStorage.getItem('refresh_token');
                 if( refresh_token == null ) {
                     App.splash();
                 } else {
                     Users.refreshToken(refresh_token);
                }
            }
        });

    },
    refreshToken: function(refreshToken){

        $.ajax({
            type: 'GET',
            url: 'http://domain.example/api/oauth/token',
            data: {
                grant_type: 'refresh_token',
                refresh_token: refreshToken,
                client_id: 'NTEzN2FjNzZlYzU4ZGM2'
            },
            dataType: 'jsonp',
            success: function(data) {
                if( data.error ) {
                    alert(data.error);
                } else {
                    window.localStorage.setItem('access_token', data.access_token);
                    window.localStorage.setItem('refresh_token', data.refresh_token);
                    access_token = window.localStorage.getItem('access_token');
                    refresh_token = window.localStorage.getItem('refresh_token');
                    App.home();
                }
            },
            error: function(a,b,c) {
                console.log(a,b,c);
            }
        });

    },
    login: function() {
        $.ajax({
            type: 'GET',
            url: 'http://domain.example/api/oauth/token',
            data: {
                grant_type: 'password',
                username: $('#Username').val(),
                password: $('#Password').val(),
                client_id: 'NTEzN2FjNzZlYzU4ZGM2'
            },
            dataType: 'jsonp',
            success: function(data) {
                if( data.error ) {
                    alert(data.error);
                } else {
                    window.localStorage.setItem('access_token', data.access_token);
                    window.localStorage.setItem('refresh_token', data.refresh_token);
                    access_token = window.localStorage.getItem('access_token');
                    refresh_token = window.localStorage.getItem('refresh_token');
                    App.home();
                }
            },
            error: function(a,b,c) {
                console.log(a,b,c);
            }
        });
    },
    logout: function() {
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        access_token = window.localStorage.getItem('access_token');
        refresh_token = window.localStorage.getItem('refresh_token');
        App.splash();
    }
};

I have a number of questions relating to my implementation of OAuth:

  1. Apparently storing the access_token in localStorage is bad practice and I should instead be using cookies. Can anyone explain why? As this isn't anymore secure or less secure as far as I can tell, as the cookie data wouldn't be encrypted.

    According to this question: Local Storage vs Cookies storing the data in localStorage is ONLY available on the client-side anyways and doesn't do any HTTP request unlike cookies, so seems more secure to me, or least doesn't seem to have any issues as far as I can tell!**

  2. Relating to question 1, use of a cookie for expiration time, would equally be pointless to me, as if you look at the code, a request is made on app start to get the user info, which would return an error if it had expired on the server end, and require a refresh_token. So not sure of benefits of having expiry times on BOTH client and server, when the server one is what really matters.

  3. How do I get a refresh token, without A, storing it with the original access_token to use later, and B) also storing a client_id? I've been told this is a security issue, but how can I use these later, but protect them in a JS-only app? Again see the code above to see how I have implemented this so far.

Stephen Ostermiller
  • 23,933
  • 14
  • 88
  • 109
Cameron
  • 27,963
  • 100
  • 281
  • 483

3 Answers3

83

It looks like you're using the Resource Owner Password Credentials OAuth 2.0 flow e.g. submitting username/pass to get back both an access token and refresh token.

  • The access token CAN be exposed in JavaScript, the risks of the access token being exposed somehow are mitigated by its short lifetime.
  • The refresh token SHOULD NOT be exposed to client-side JavaScript. It's used to get more access tokens (as you're doing above) but if an attacker was able to get the refresh token they'd be able to get more access tokens at will until such time as the OAuth server revoked the authorization of the client for which the refresh token was issued.

With that background in mind, let me address your questions:

  1. Either a cookie or localstorage will give you local persistence across page refreshes. Storing the access token in local storage gives you a little more protection against CSRF attacks as it will not be automatically sent to the server like a cookie will. Your client-side JavaScript will need to pull it out of localstorage and transmit it on each request. I'm working on an OAuth 2 app and because it's a single page approach I do neither; instead I just keep it in memory.
  2. I agree... if you're storing in a cookie it's just for the persistence not for expiration, the server is going to respond with an error when the token expires. The only reason I can think you might create a cookie with an expiration is so that you can detect whether it has expired WITHOUT first making a request and waiting for an error response. Of course you could do the same thing with local storage by saving that known expiration time.
  3. This is the crux of the whole question I believe... "How do I get a refresh token, without A, storing it with the original access_token to use later, and B) also storing a client_id". Unfortunately you really can't... As noted in that introductory comment, having the refresh token client side negates the security provided by the access token's limited lifespan. What I'm doing in my app (where I'm not using any persistent server-side session state) is the following:
  • The user submits username and password to the server
  • The server then forwards the username and password to the OAuth endpoint, in your example above http://domain.example/api/oauth/token, and receives both the access token and refresh token.
  • The server encrypts the refresh token and sets it in a cookie (should be HTTP Only)
  • The server responds with the access token ONLY in clear text (in a JSON response) AND the encrypted HTTP only cookie
  • client-side JavaScript can now read and use the access token (store in local storage or whatever
  • When the access token expires, the client submits a request to the server (not the OAuth server but the server hosting the app) for a new token
  • The server, receives the encrypted HTTP only cookie it created, decrypts it to get the refresh token, requests a new access token and finally returns the new access token in the response.

Admittedly, this does violate the "JS-Only" constraint you were looking for. However, a) again you really should NOT have a refresh token in JavaScript and b) it requires pretty minimal server-side logic at login/logout and no persistent server-side storage.

Note on CSRF: As noted in the comments, this solution doesn't address Cross-site Request Forgery; see the OWASP CSRF Prevention Cheat Sheet for further ideas on addressing these forms of attacks.

Another alternative is simply to not request the refresh token at all (not sure if that's an option with the OAuth 2 implementation you're dealing with; the refresh token is optional per the spec) and continually re-authenticate when it expires.

Stephen Ostermiller
  • 23,933
  • 14
  • 88
  • 109
jandersen
  • 3,551
  • 22
  • 23
  • 4
    Wouldn't a refresh token in an http only cookie buy the app some protection against XSS but expose it to CSRF? If I can trick the user to clicking a malicious link that then posts a form, I now have the cookie with an encrypted refresh token. With that cookie, wouldn't I be able to go to the endpoint in your app (second to last step) and obtain an access token? – tstojecki Jun 28 '14 at 17:31
  • Good comment Tom and thanks for pointing it out to any others reviewing this question. CSRF could still be an issue and there are [many approaches](https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet) to dealing with it. – jandersen Jun 30 '14 at 19:36
  • I really like this approach, and I intend to use it in my current application. But there's something I do not quite understand. Why would you encrypt the Refresh Token?. I mean, if someone gets access to it, he can use it as normal, as it will be decrypted on the server. – Matias Cicero Oct 01 '14 at 18:17
  • @MatiCicero yep, similar to the previous comment, the encryption of the refresh token on it's own won't protect you against a man in the middle or CSRF. We're just keeping the refresh token from being in the clear on the client. You'd want to combine this with an SSL connection and other standard web security practices which are beyond the scope of this answer. – jandersen Oct 02 '14 at 18:50
  • 1
    "b) it requires pretty minimal server-side logic" You'd also have to add server-side logout. Normally using just access tokens, you could simply delete your reference to the access token in the client, in order for the user to log out. Since you cannot delete a httpOnly cookie from the client, you would also have to implement a logout endpoint on the server to delete the httpOnly cookie, storing the refresh token. It is not a huge deal more logic added to the server-side, but still something to keep in mind. – Nikolaj Dam Larsen Oct 04 '14 at 11:17
  • @Impero thanks for clarifying. Yes both login/logout need some server-side logic and that was part of my solution when using this approach but I've updated my answer to indicate where the server-side logic is needed. – jandersen Oct 06 '14 at 18:26
  • @Tom Could you please elaborate on your example. A CSRF attack makes the browser perform an action against the domain that served the app in the first place. How can that ever be used to extract the app's cookies to an attacker? – Lasse Christiansen Nov 20 '14 at 13:55
  • @lasse-christiansen-sw-lasse This is more of an issue with authentication cookies in general which increase your exposure to CSRF. In the above scenario the http cookie contains the refresh token that when presented to the server exchanges it for an access token. An attacker could send the user a page that does two silent form posts when loaded. The first post (target of the form set to a hidden iframe so a response can be read) carries the cookie and returns an access token as a result. With that, the second post hits the api end point with the bearer token in the header. – tstojecki Feb 10 '15 at 20:46
  • This is a great answer, thank you. Two improvements might be to also store the `access_token` in the HttpOnly/encrypted cookie, so it couldn't be stolen via XSS (even if it's short-lived, there is a vulnerability window). Also as mentioned in the above comments, be sure to add CSRF-prevention such as requiring requests provide an `X-Requested-With` header. – Steve Kehlet Mar 16 '15 at 18:18
  • @SteveKehlet thanks for the note. I have just updated the main answer to call out CSRF as a separate concern as it is an important topic. Storing the `access_token` in the HTTP Only cookie wouldn't allow the app to use it in calling a separate API which seems to be the OP's goal. – jandersen Mar 19 '15 at 06:40
  • 14
    I don't understand how encrypting the refresh_token provides any additional security, given that your server will happily decrypt it, forward it and return the new access token to the user. If someone gets hold of that persistent encrypted cookie somehow, what stops them using your server to get access tokens for the API indefinitely? Do you think it's worth the extra complication of having to go via your server just to avoid having the refresh token in the clear? – TomW Apr 07 '15 at 08:37
  • 1
    @TomW have a look at [CSRF Prevention](http://en.wikipedia.org/wiki/Cross-site_request_forgery#Prevention) for some ideas on how to further validate requests at the server in the case someone has somehow stolen the `httpOnly` cookie. The [OAuth 2 Spec](https://tools.ietf.org/html/rfc6749#section-10.4) states "Refresh tokens MUST be kept confidential in transit and storage, and shared only among the authorization server and the client to whom the refresh tokens were issued." That's the intent of the encryption; in this case the _client_ is the web server that requested the refresh token. – jandersen Apr 08 '15 at 05:09
  • Great answer. Helped me a lot. Maybe you can help on the CSRF topic here: http://stackoverflow.com/q/30619782/498298 – hansmaad Jun 05 '15 at 06:19
  • @Tom "If I can trick the user to clicking a malicious link that then posts a form, I now have the cookie with an encrypted refresh token." I don't understand how CSRF could be used by an attacker to retrieve the encrypted refresh token. (MITM—yes.) Based on my understanding of CSRF, the most the attacker could do in this case is use a victim's access token to issue requests on the victim's behalf; but they couldn't retrieve anything. What am I missing? – Stephen Watkins Jun 19 '16 at 17:00
3

The only way to be fully secure is to not store the access tokens client side. Anyone with (physical) access to your browser could obtain your token.

  1. Your assessment of neither being a great solution is accurate.

  2. Using expiration times would be your best if you are limited to only client side development. It wouldn't require your users to re-authenticate with Oauth as frequently, and guarantee that the token wouldn't live forever. Still not the most secure.

  3. Getting a new token would require performing the Oauth workflow to obtain a fresh token. The client_id is tied to a specific domain for Oauth to function.

The most secure method for retaining Oauth tokens would be a server side implementation.

Stephen Ostermiller
  • 23,933
  • 14
  • 88
  • 109
rsnickell
  • 1,262
  • 1
  • 9
  • 6
  • Not sure how to get around storing the token, as it's needed to make requests and it can be seen the Web Inspector under Network Requests. BUT the access token is only valid for 1 hour, after which the user must either re-authenticate or a refresh token is used if available. So I can't see it being a huge security issue... but I'd be interested to see how client-only apps get around this. – Cameron Aug 21 '13 at 16:12
  • 2
    I think you'll find that the "client-only apps" still leverage some server side proxy to be secure. See this article as one example: http://derek.io/blog/2010/how-to-secure-oauth-in-javascript/ – rsnickell Aug 22 '13 at 13:49
1

For pure client side only approach, if you have a chance, try to use "Implicit Flow" rather then "Resource owner flow". You do not receive refresh token as a part of the response.

  1. When user access page JavaScript checks for access_token in localStorage and checks it expires_in
  2. If missing or expired then application opens new tab and redirects user to the login page, after successful login user is redirected back with access token which is handled client side only and preserved in local storage with redirect page
  3. The main page might have polling mechanism on the access token in local storage and as soon the user logged in (redirect page saves token to storage) page process normally.

In the above approach the access token should be long living (e.g. 1 year). If there is a concern with long living token you can use following trick.

  1. When user access page JavaScript checks for access_token in localStorage and checks it expires_in
  2. If missing or expired then application opens hidden iframe and tries to login user. Usually auth website has a user cookie and stores grant to the client website, therefore login happens automatically and script inside iframe will populate token into storage
  3. The client's main page sets polling mechanism on access_token and timeout. If during this short period the access_token is not populated into storage, it means that we need to open new tab and set normal Implicit flow in motion
Community
  • 1
  • 1