Tuesday, May 6, 2014

Combining OAuth2 and Phonegap

So you've made a web application. You use OAuth2 for authentication and authorization. Now, you want it to have a mobile client. You don't fancy coding all your mobile apps natively -- at least not yet. You use Phonegap.

Then, the inevitable question: how can my Phonegap app support OAuth? It must be redirected to the OAuth page, and the user must grant consent. But then he's redirected to my original webpage created for the browser!

Here's how I've done it.
I started with a tutorial here.

Step 1: Download Phonegap's In App Browser. (If you have Phonegap 3.0 or above) Do this from your Cordova directory (the one with the .cordova folder. For me, since I am using SenchaCMD, it's under the phonegap folder of the main project directory)

cordova plugin add https://git-wip-us.apache.org/repos/asf/cordova-plugin-inappbrowser.git
Bam, downloaded. Great.

Step 2: Test your In App Browser:

function onBtnClick() {
    var ref = window.open('http://apache.org', '_blank', 'location=yes');
    ref.addEventListener('loadstart', function(event) { alert('start: ' + event.url); });
    ref.addEventListener('loadstop', function(event) { alert('stop: ' + event.url); });
    ref.addEventListener('loaderror', function(event) { alert('error: ' + event.message); });
    ref.addEventListener('exit', function(event) { alert(event.type); });
}
It should work quite well. You get event listeners for stuff. Yay.

Step 3: Direct it to the right URI!
Go to https://developers.google.com/accounts/docs/OAuth2WebServer to figure out what all the GET query parameters are.

Then, go test stuff out at the Google OAuth2 Playground! Here's what I got from the playground thing:

(Yes, I could have used better styling on this. Oops).

========================================================================

Google OAuth 2 Scheme:
Request / Response
HTTP/1.1 302 Found

GET /oauthplayground/?code=4/QrO4XICKVwOcWRoIjcD9hS_ftIjb.0v99D-kGD1cY3oEBd8DOtND9QdaliwI HTTP/1.1
Host: developers.google.com

Exchange Auth Code for Tokens
Request
POST /o/oauth2/token HTTP/1.1
Host: accounts.google.com
Content-length: 250
content-type: application/x-www-form-urlencoded
user-agent: google-oauth-playground
code=4%2FQrO4XICKVwOcWRoIjcD9hS_ftIjb.0v99D-kGD1cY3oEBd8DOtND9QdaliwI&redirect_uri=https%3A%2F%2Fdevelopers.google.com%2Foauthplayground&client_id=407408718192.apps.googleusercontent.com&scope=&client_secret=************&grant_type=authorization_code

Response
HTTP/1.1 200 OK
Alternate-protocol: 443:quic
Content-length: 1071
X-xss-protection: 1; mode=block
X-content-type-options: nosniff
Content-disposition: attachment; filename="json.txt"; filename*=UTF-8''json.txt
Expires: Fri, 01 Jan 1990 00:00:00 GMT
X-google-cache-control: remote-fetch
Server: GSE
Via: HTTP/1.1 GWA
Pragma: no-cache
Cache-control: no-cache, no-store, max-age=0, must-revalidate
Date: Tue, 06 May 2014 19:01:39 GMT
X-frame-options: SAMEORIGIN
Content-type: application/json; charset=utf-8
-content-encoding: gzip
{
  "access_token": "ya29.1.AADtN_UYHaNZzrpYU_fyR98OPAbWCiPN39olWxlWiJiVEXOxsafxy1qu2p6ANDc", 
  "token_type": "Bearer", 
  "expires_in": 3600, 
  "refresh_token": "1/geII4ucPZGDTgn_rfwyVhkUAdtWGzp2H4A5kpO3EAEQ", 
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjZkNTFhZjgwYzQ4NGIwZWZjZTI4Zjk0NjQ1YWQ4YjY0YjA4ZGNhNGEifQ.eyJpc3MiOiJhY2NvdW50cy5nb29nbGUuY29tIiwiaWQiOiIxMDgwNTAzMDY2Njg5Mzg4ODEzNTYiLCJzdWIiOiIxMDgwNTAzMDY2Njg5Mzg4ODEzNTYiLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJlbWFpbCI6ImppYW5ndHNAc3RhbmZvcmQuZWR1IiwiYXRfaGFzaCI6IjBWZjBlVFhUVV9oMDNleFRVSGozR2ciLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXVkIjoiNDA3NDA4NzE4MTkyLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJzdGFuZm9yZC5lZHUiLCJ0b2tlbl9oYXNoIjoiMFZmMGVUWFRVX2gwM2V4VFVIajNHZyIsInZlcmlmaWVkX2VtYWlsIjp0cnVlLCJjaWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJpYXQiOjEzOTk0MDI1OTksImV4cCI6MTM5OTQwNjQ5OX0.vG2RVojWyn0cHl6-PhIoAZIhS5HVUXmOJ-X0nxDvJX4RwwWLv3yGLF4-V1QlfaRRVmqBwZc7PrJx5peGQmFyBKN-6IXRFwsiyJ5WRPlmeY1LuzPGi_KKrzT-S9hAB4Pjgi-3HYyJh01T3E34m59PspPkjzcKt5Fb05NL6g3kGjU"
}



Refresh Request (Optional)
Request
POST /o/oauth2/token HTTP/1.1
Host: accounts.google.com
Content-length: 163
content-type: application/x-www-form-urlencoded
user-agent: google-oauth-playground
client_secret=************&grant_type=refresh_token&refresh_token=1%2FgeII4ucPZGDTgn_rfwyVhkUAdtWGzp2H4A5kpO3EAEQ&client_id=407408718192.apps.googleusercontent.com

Response
HTTP/1.1 200 OK
Alternate-protocol: 443:quic
Content-length: 1002
X-xss-protection: 1; mode=block
X-content-type-options: nosniff
Content-disposition: attachment; filename="json.txt"; filename*=UTF-8''json.txt
Expires: Fri, 01 Jan 1990 00:00:00 GMT
X-google-cache-control: remote-fetch
Server: GSE
Via: HTTP/1.1 GWA
Pragma: no-cache
Cache-control: no-cache, no-store, max-age=0, must-revalidate
Date: Tue, 06 May 2014 19:02:08 GMT
X-frame-options: SAMEORIGIN
Content-type: application/json; charset=utf-8
-content-encoding: gzip
{
  "access_token": "ya29.1.AADtN_UeoAgso1pgdnAtlFnLWtlxA0pfSlKM6EAOBvzoMzTs0v-NcCQHdpUVXfc", 
  "token_type": "Bearer", 
  "expires_in": 3600, 
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjZkNTFhZjgwYzQ4NGIwZWZjZTI4Zjk0NjQ1YWQ4YjY0YjA4ZGNhNGEifQ.eyJpc3MiOiJhY2NvdW50cy5nb29nbGUuY29tIiwiaWQiOiIxMDgwNTAzMDY2Njg5Mzg4ODEzNTYiLCJzdWIiOiIxMDgwNTAzMDY2Njg5Mzg4ODEzNTYiLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJlbWFpbCI6ImppYW5ndHNAc3RhbmZvcmQuZWR1IiwiYXRfaGFzaCI6Im5KS2hKM0dMdjROX1RHX0NReEF6aVEiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXVkIjoiNDA3NDA4NzE4MTkyLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwiaGQiOiJzdGFuZm9yZC5lZHUiLCJ0b2tlbl9oYXNoIjoibkpLaEozR0x2NE5fVEdfQ1F4QXppUSIsInZlcmlmaWVkX2VtYWlsIjp0cnVlLCJjaWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJpYXQiOjEzOTk0MDI2MjgsImV4cCI6MTM5OTQwNjUyOH0.pfWKLrTA3426WErPPoFnx_iYgaDjw4XQRJYG3Bpcl5514T5nDfiYuidgc-h_AHCQah13zLHIjPyQht1TByrXTyebkbLw7HFKmt1JpmSE2h6NI8FBNhj5E950JQTBZL6OYewYnLweKN6BkHdP7vCvcrp6vmddiUgn-tlYR6NrEsE"
}


Configure request to API 
(To: https://www.googleapis.com/oauth2/v2/userinfo)
Request
GET /oauth2/v2/userinfo HTTP/1.1
Host: www.googleapis.com
Content-length: 0
Authorization: Bearer ya29.1.AADtN_UeoAgso1pgdnAtlFnLWtlxA0pfSlKM6EAOBvzoMzTs0v-NcCQHdpUVXfc

Response
HTTP/1.1 200 OK
Content-length: 277
X-xss-protection: 1; mode=block
X-content-type-options: nosniff
Expires: Fri, 01 Jan 1990 00:00:00 GMT
Server: GSE
Pragma: no-cache
Cache-control: no-cache, no-store, max-age=0, must-revalidate
Date: Tue, 06 May 2014 19:03:15 GMT
X-frame-options: SAMEORIGIN
Content-type: application/json; charset=UTF-8
{
  "family_name": "", 
  "name": "", 
  "email": "[your_email_addr]@stanford.edu", 
  "given_name": "", 
  "id": "[long string of numbers here]", 
  "hd": "stanford.edu", 
  "verified_email": true

}

========================================================================



So, this is all nice. It works. All I had to do was implement a method on the server side that
did the Exchange Auth Code for Tokens request, get the response, and do another request to the actual API at https://www.googleapis.com/oauth2/v2/userinfo.

So, in all for the user, they're only redirected to a set of 3 pages. First, the Google Login Page. Then, the grant consent page. Then, Google redirects them to my REST call that does this 2 step dance (get tokens from auth code and call the API). Then, they get a response.

My hack to get the response page to actually be a real page was to return an HTML that redirects the user (for the last time) to my application page. My redirect HTML response also sets a cookie with the auth stuff in it on the browser.

So, everything is super nice and hunky dory until this step. How will my redirect page differentiate between mobile and browser clients? We don't want the client to get redirected to my browser client if they're on mobile.

We can solve this one without too much trouble. Google's OAuth API query parameters let you send a state param, which you can set to be "mobile" when you're on a mobile client. Then, google's redirect will include this as a query parameter to your 2 step dance method. You can use this query param to determine whether or not you redirect to the set of browser pages. This is fine.

But then we have another problem: How do we set a cookie on Phonegap's In App Browser?

This one took some creativity, but here's my hackish solution. The only way (as far as I could tell) that the In App Browser can communicate information to the actual Phonegap app is through some properties of the InAppBrowserEvent object. These properties are:
  • type: the eventname, either loadstartloadstoploaderror, or exit(String)
  • url: the URL that was loaded. (String)
  • code: the error code, only in the case of loaderror(Number)
  • message: the error message, only in the case of loaderror(String)
So, what's the only one that we have a lot of control over? URL!
So, what's the trick?

After you get the token information and all that other jazz from the endpoint of the OAuth process (ie, the return html of your favorite 2 step dance REST API), you redirect to another page which contains all this information in the url. Then, you use JS to tell the window to close itself (which is an event that can be listened for).

Then, you get the InAppBrowserEvent object, you ask for its URL, and bam! You've gotten all the information you need within your Phonegap app.

Hacky, but that's how it goes.

A lot of these last few paragraphs probably makes sense to nobody but me. For anyone who stumbles upon this, it may be no surprise that it was intended to be read by me :)

Cheers
Allan