diff --git a/content/guides/basics-of-authentication.md b/content/guides/basics-of-authentication.md index a3160596ba..d8f6f5d2ac 100644 --- a/content/guides/basics-of-authentication.md +++ b/content/guides/basics-of-authentication.md @@ -11,12 +11,12 @@ In this section, we're going to focus on the basics of authentication. Specifica we're going to create a Ruby server (using [Sinatra][Sinatra]) that implements the [web flow][webflow] of an application in several different ways. -Note: you can download the complete source code for this project [from the platform-samples repo](https://github.com/github/platform-samples/tree/master/api/ruby/basics-of-authentication). +Note: you can download the complete source code for this project +[from the platform-samples repo][platform samples]. ## Registering your app -First, you'll need to [register your -application](https://github.com/settings/applications/new). Every +First, you'll need to [register your application][new oauth app]. Every registered OAuth application is assigned a unique Client ID and Client Secret. The Client Secret should not be shared! That includes checking the string into your repository. @@ -36,6 +36,7 @@ Now, let's start filling out our simple server. Create a file called _server.rb_ #!ruby require 'sinatra' require 'rest-client' + require 'json' CLIENT_ID = ENV['GH_BASIC_CLIENT_ID'] CLIENT_SECRET = ENV['GH_BASIC_SECRET_ID'] @@ -44,7 +45,8 @@ Now, let's start filling out our simple server. Create a file called _server.rb_ erb :index, :locals => {:client_id => CLIENT_ID} end -Your client ID and client secret keys come from [your application's configuration page](https://github.com/settings/applications). You should **never, _ever_** store these values in +Your client ID and client secret keys come from [your application's configuration +page][app settings]. You should **never, _ever_** store these values in GitHub--or any other public place, for that matter. We recommend storing them as [environment variables][about env vars]--which is exactly what we've done here. @@ -55,18 +57,27 @@ Next, in _views/index.erb_, paste this content: -

Well, hello there!

-

We're going to now talk to the GitHub API. Ready? Click here to begin!

-

If that link doesn't work, remember to provide your own Client ID!

+

+ Well, hello there! +

+

+ We're going to now talk to the GitHub API. Ready? + Click here to begin! +

+

+ If that link doesn't work, remember to provide your own Client ID! +

(If you're unfamiliar with how Sinatra works, we recommend [reading the Sinatra guide][Sinatra guide].) -Obviously, you'll want to change `` to match your actual Client ID. +Also, notice that the URL uses the `scope` query parameter to define the +[scopes][oauth scopes] requested by the application. For our application, we're +requesting `user:email` scope for reading private email addresses. Navigate your browser to `http://localhost:4567`. After clicking on the link, you -should be taken to GitHub, and presented with a dialog that looks something like this: +should be taken to GitHub, and presented with a dialog that looks something like this: ![](/images/oauth_prompt.png) If you trust yourself, click **Authorize App**. Wuh-oh! Sinatra spits out a @@ -83,16 +94,17 @@ In _server.rb_, add a route to specify what the callback should do: #!ruby get '/callback' do # get temporary GitHub code... - session_code = request.env['rack.request.query_hash']["code"] + session_code = request.env['rack.request.query_hash']['code'] + # ... and POST it back to GitHub - result = RestClient.post("https://github.com/login/oauth/access_token", + result = RestClient.post('https://github.com/login/oauth/access_token', {:client_id => CLIENT_ID, :client_secret => CLIENT_SECRET, - :code => session_code - },{ - :accept => :json - }) - access_token = JSON.parse(result)["access_token"] + :code => session_code}, + :accept => :json) + + # extract the token and granted scopes + access_token = JSON.parse(result)['access_token'] end After a successful app authentication, GitHub provides a temporary `code` value. @@ -101,20 +113,79 @@ To simplify our GET and POST HTTP requests, we're using the [rest-client][REST C Note that you'll probably never access the API through REST. For a more serious application, you should probably use [a library written in the language of your choice][libraries]. +### Checking granted scopes + +In the future, users will be able to [edit the scopes you requested][edit scopes post], +and your application might be granted less access than you originally asked for. +So, before making any requests with the token, you should check the scopes that +were granted for the token by the user. + +The scopes that were granted are returned as a part of the response from +exchanging a token. + + #!ruby + # check if we were granted user:email scope + scopes = JSON.parse(result)['scope'].split(',') + has_user_email_scope = scopes.include? 'user:email' + +In our application, we're using `scopes.include?` to check if we were granted +the `user:email` scope needed for fetching the authenticated user's private +email addresses. Had the application asked for other scopes, we would have +checked for those as well. + +Also, since there's a hierarchical relationship between scopes, you should +check that you were granted the lowest level of required scopes. For example, +if the application had asked for `user` scope, it might have been granted only +`user:email` scope. In that case, the application wouldn't have been granted +what it asked for, but the granted scopes would have still been sufficient. + +Checking for scopes only before making requests is not enough since it's posible +that users will change the scopes in between your check and the actual request. +In case that happens, API calls you expected to succeed might fail with a `404` +or `401` status, or return a different subset of information. + +To help you gracefully handle these situations, all API responses for requests +made with valid tokens also contain an [`X-OAuth-Scopes` header][oauth scopes]. +This header contains the list of scopes of the token that was used to make the +request. In addition to that, the Authorization API provides an endpoint to +[check a token for validity][check token valid]. +Use this information to detect changes in token scopes, and inform your users of +changes in available application functionality. + +### Making authenticated requests + At last, with this access token, you'll be able to make authenticated requests as the logged in user: #!ruby - auth_result = RestClient.get("https://api.github.com/user", {:params => {:access_token => access_token}}) + # fetch user information + auth_result = JSON.parse(RestClient.get('https://api.github.com/user', + {:params => {:access_token => access_token}})) + + # if the user authorized it, fetch private emails + if has_user_email_scope + auth_result['private_emails'] = + JSON.parse(RestClient.get('https://api.github.com/user/emails', + {:params => {:access_token => access_token}})) - erb :basic, :locals => {:auth_result => auth_result} + erb :basic, :locals => auth_result We can do whatever we want with our results. In this case, we'll just dump them straight into _basic.erb_: #!html+erb -

Okay, here's a JSON dump:

+

Hello, <%= login %>!

+

+ <% if !email.empty? %> It looks like your public email address is <%= email %>. + <% else %> It looks like you don't have a public email. That's cool. + <% end %> +

-

Hello, <%= login %>! It looks like you're <%= hire_status %>.

+ <% if defined? private_emails %> + With your permission, we were also able to dig up your private email addresses: + <%= private_emails.join(', ') %> + <% else %> + Also, you're a bit secretive about your private email addresses. + <% end %>

## Implementing "persistent" authentication @@ -129,91 +200,103 @@ GitHub, they should be able to access this application? Hold on to your hat, because _that's exactly what we're going to do_. Our little server above is rather simple. In order to wedge in some intelligent -authentication, we're going to switch over to implementing [a Rack layer][rack guide] -into our Sinatra app. On top of that, we're going to be using a middleware called -[sinatra-auth-github][sinatra auth github] (which was written by a GitHubber). +authentication, we're going to switch over to using sessions for storing tokens. This will make authentication transparent to the user. -After you run `gem install sinatra_auth_github`, create a file called _advanced_server.rb_, -and paste these lines into it: +Also, since we're persisting scopes within the session, we'll need to +handle cases when the user updates the scopes after we checked them, or revokes +the token. To do that, we'll use a `rescue` block and check that the first API +call succeeded, which verifies that the token is still valid. After that, we'll +check the `X-OAuth-Scopes` response header to verify that the user hasn't revoked +the `user:email` scope. + +Create a file called _advanced_server.rb_, and paste these lines into it: #!ruby - require 'sinatra/auth/github' - require 'rest-client' + require 'sinatra' + require 'rest_client' + require 'json' - module Example - class MyBasicApp < Sinatra::Base - # !!! DO NOT EVER USE HARD-CODED VALUES IN A REAL APP !!! - # Instead, set and test environment variables, like below - # if ENV['GITHUB_CLIENT_ID'] && ENV['GITHUB_CLIENT_SECRET'] - # CLIENT_ID = ENV['GITHUB_CLIENT_ID'] - # CLIENT_SECRET = ENV['GITHUB_CLIENT_SECRET'] - # end - - CLIENT_ID = ENV['GH_BASIC_CLIENT_ID'] - CLIENT_SECRET = ENV['GH_BASIC_SECRET_ID'] - - enable :sessions - - set :github_options, { - :scopes => "user", - :secret => CLIENT_SECRET, - :client_id => CLIENT_ID, - :callback_url => "/callback" - } - - register Sinatra::Auth::Github - - get '/' do - if !authenticated? - authenticate! - else - access_token = github_user["token"] - auth_result = RestClient.get("https://api.github.com/user", {:params => {:access_token => access_token, :accept => :json}, - :accept => :json}) - - auth_result = JSON.parse(auth_result) - - erb :advanced, :locals => {:login => auth_result["login"], - :hire_status => auth_result["hireable"] ? "hireable" : "not hireable"} - end + # !!! DO NOT EVER USE HARD-CODED VALUES IN A REAL APP !!! + # Instead, set and test environment variables, like below + # if ENV['GITHUB_CLIENT_ID'] && ENV['GITHUB_CLIENT_SECRET'] + # CLIENT_ID = ENV['GITHUB_CLIENT_ID'] + # CLIENT_SECRET = ENV['GITHUB_CLIENT_SECRET'] + # end + + CLIENT_ID = ENV['GH_BASIC_CLIENT_ID'] + CLIENT_SECRET = ENV['GH_BASIC_SECRET_ID'] + + use Rack::Session::Cookie, :secret => rand.to_s() + + def authenticated? + session[:access_token] + end + + def authenticate! + erb :index, :locals => {:client_id => CLIENT_ID} + end + + get '/' do + if !authenticated? + authenticate! + else + access_token = session[:access_token] + scopes = [] + + begin + auth_result = RestClient.get('https://api.github.com/user', + {:params => {:access_token => access_token}, + :accept => :json}) + rescue => e + # request didn't succeed because the token was revoked so we + # invalidate the token stored in the session and render the + # index page so that the user can start the OAuth flow again + + session[:access_token] = nil + return authenticate! end - get '/callback' do - if authenticated? - redirect "/" - else - authenticate! - end + # the request succeeded, so we check the list of current scopes + if auth_result.headers.include? :x_oauth_scopes + scopes = auth_result.headers[:x_oauth_scopes].split(', ') end + + auth_result = JSON.parse(auth_result) + + if scopes.include? 'user:email' + auth_result['private_emails'] = + JSON.parse(RestClient.get('https://api.github.com/user/emails', + {:params => {:access_token => access_token}, + :accept => :json})) + end + + erb :advanced, :locals => auth_result end end -Much of the code should look familiar. For example, we're still using `RestClient.get` -to call out to the GitHub API, and we're still passing our results to be rendered -in an ERB template (this time, it's called `advanced.erb`). Some of the other -details--like turning our app into a class that inherits from `Sinatra::Base`--are a result -of inheriting from `sinatra/auth/github`, which is written as [a Sinatra extension][sinatra extension]. + get '/callback' do + session_code = request.env['rack.request.query_hash']['code'] -Also, we now have a `github_user` object, which comes from `sinatra-auth-github`. The -`token` key represents the same `access_token` we used during our simple server. + result = RestClient.post('https://github.com/login/oauth/access_token', + {:client_id => CLIENT_ID, + :client_secret => CLIENT_SECRET, + :code => session_code}, + :accept => :json) -`sinatra-auth-github` comes with quite a few options that you can customize. Here, -we're establishing them through the `:github_options` symbol. Passing your client ID -and client secret, and calling `register Sinatra::Auth::Github`, is everything you need -to simplify your authentication. + session[:access_token] = JSON.parse(result)['access_token'] -We must also create a _config.ru_ config file, which Rack will use for its configuration -options: + redirect '/' + end - #!ruby - ENV['RACK_ENV'] ||= 'development' - require "rubygems" - require "bundler/setup" - require File.expand_path(File.join(File.dirname(__FILE__), 'advanced_server')) +Much of the code should look familiar. For example, we're still using `RestClient.get` +to call out to the GitHub API, and we're still passing our results to be rendered +in an ERB template (this time, it's called `advanced.erb`). - run Example::MyBasicApp +Also, we now have the `authenticated?` method which checks if the user is already +authenticated. If not, the `authenticate!` method is called, which performs the +OAuth flow and updates the session with the granted token and scopes. Next, create a file in _views_ called _advanced.erb_, and paste this markup into it: @@ -222,21 +305,34 @@ Next, create a file in _views_ called _advanced.erb_, and paste this markup into -

Well, well, well, <%= login %>! It looks like you're still <%= hire_status %>!

+

Well, well, well, <%= login %>!

+

+ <% if !email.empty? %> It looks like your public email address is <%= email %>. + <% else %> It looks like you don't have a public email. That's cool. + <% end %> +

+

+ <% if defined? private_emails %> + With your permission, we were also able to dig up your private email addresses: + <%= private_emails.join(', ') %> + <% else %> + Also, you're a bit secretive about your private email addresses. + <% end %> +

-From the command line, call `rackup -p 4567`, which starts up your -Rack server on port `4567`--the same port we used when we had a simple Sinatra app. -When you navigate to `http://localhost:4567`, the app calls `authenticate!`--another -internal `sinatra-auth-github` method--which redirects you to `/callback`. `/callback` -then sends us back to `/`, and since we've been authenticated, renders _advanced.erb_. +From the command line, call `ruby advanced_server.rb`, which starts up your +server on port `4567` -- the same port we used when we had a simple Sinatra app. +When you navigate to `http://localhost:4567`, the app calls `authenticate!` +which redirects you to `/callback`. `/callback` then sends us back to `/`, +and since we've been authenticated, renders _advanced.erb_. We could completely simplify this roundtrip routing by simply changing our callback URL in GitHub to `/`. But, since both _server.rb_ and _advanced.rb_ are relying on the same callback URL, we've got to do a little bit of wonkiness to make it work. -Also, if we had never authorized this Rack application to access our GitHub data, +Also, if we had never authorized this application to access our GitHub data, we would've seen the same confirmation dialog from earlier pop-up and warn us. If you'd like, you can play around with [yet another Sinatra-GitHub auth example][sinatra auth github test] @@ -248,7 +344,10 @@ available as a separate project. [Sinatra guide]: http://sinatra-book.gittr.com/#hello_world_application [REST Client]: https://github.com/archiloque/rest-client [libraries]: /libraries/ -[rack guide]: http://en.wikipedia.org/wiki/Rack_(web_server_interface) -[sinatra auth github]: https://github.com/atmos/sinatra_auth_github -[sinatra extension]: http://www.sinatrarb.com/extensions.html [sinatra auth github test]: https://github.com/atmos/sinatra-auth-github-test +[oauth scopes]: /v3/oauth/#scopes +[edit scopes post]: /changes/2013-10-04-oauth-changes-coming/ +[check token valid]: /v3/oauth/#check-an-authorization +[platform samples]: https://github.com/github/platform-samples/tree/master/api/ruby/basics-of-authentication +[new oauth app]: https://github.com/settings/applications/new +[app settings]: https://github.com/settings/applications