googleauth 0.4.2 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -29,6 +29,7 @@
29
29
 
30
30
  require 'googleauth/signet'
31
31
  require 'googleauth/credentials_loader'
32
+ require 'googleauth/scope_util'
32
33
  require 'multi_json'
33
34
 
34
35
  module Google
@@ -46,8 +47,30 @@ module Google
46
47
  # cf [Application Default Credentials](http://goo.gl/mkAHpZ)
47
48
  class UserRefreshCredentials < Signet::OAuth2::Client
48
49
  TOKEN_CRED_URI = 'https://www.googleapis.com/oauth2/v3/token'
50
+ AUTHORIZATION_URI = 'https://accounts.google.com/o/oauth2/auth'
51
+ REVOKE_TOKEN_URI = 'https://accounts.google.com/o/oauth2/revoke'
49
52
  extend CredentialsLoader
50
53
 
54
+ # Create a UserRefreshCredentials.
55
+ #
56
+ # @param json_key_io [IO] an IO from which the JSON key can be read
57
+ # @param scope [string|array|nil] the scope(s) to access
58
+ def self.make_creds(options = {})
59
+ json_key_io, scope = options.values_at(:json_key_io, :scope)
60
+ user_creds = read_json_key(json_key_io) if json_key_io
61
+ user_creds ||= {
62
+ 'client_id' => ENV[CredentialsLoader::CLIENT_ID_VAR],
63
+ 'client_secret' => ENV[CredentialsLoader::CLIENT_SECRET_VAR],
64
+ 'refresh_token' => ENV[CredentialsLoader::REFRESH_TOKEN_VAR]
65
+ }
66
+
67
+ new(token_credential_uri: TOKEN_CRED_URI,
68
+ client_id: user_creds['client_id'],
69
+ client_secret: user_creds['client_secret'],
70
+ refresh_token: user_creds['refresh_token'],
71
+ scope: scope)
72
+ end
73
+
51
74
  # Reads the client_id, client_secret and refresh_token fields from the
52
75
  # JSON key.
53
76
  def self.read_json_key(json_key_io)
@@ -59,24 +82,38 @@ module Google
59
82
  json_key
60
83
  end
61
84
 
62
- # Initializes a UserRefreshCredentials.
63
- #
64
- # @param json_key_io [IO] an IO from which the JSON key can be read
65
- # @param scope [string|array|nil] the scope(s) to access
66
85
  def initialize(options = {})
67
- json_key_io, scope = options.values_at(:json_key_io, :scope)
68
- user_creds = self.class.read_json_key(json_key_io) if json_key_io
69
- user_creds ||= {
70
- 'client_id' => ENV[CredentialsLoader::CLIENT_ID_VAR],
71
- 'client_secret' => ENV[CredentialsLoader::CLIENT_SECRET_VAR],
72
- 'refresh_token' => ENV[CredentialsLoader::REFRESH_TOKEN_VAR]
73
- }
86
+ options ||= {}
87
+ options[:token_credential_uri] ||= TOKEN_CRED_URI
88
+ options[:authorization_uri] ||= AUTHORIZATION_URI
89
+ super(options)
90
+ end
91
+
92
+ # Revokes the credential
93
+ def revoke!(options = {})
94
+ c = options[:connection] || Faraday.default_connection
95
+ resp = c.get(REVOKE_TOKEN_URI, token: refresh_token || access_token)
96
+ case resp.status
97
+ when 200
98
+ self.access_token = nil
99
+ self.refresh_token = nil
100
+ self.expires_at = 0
101
+ else
102
+ fail(Signet::AuthorizationError,
103
+ "Unexpected error code #{resp.status}")
104
+ end
105
+ end
74
106
 
75
- super(token_credential_uri: TOKEN_CRED_URI,
76
- client_id: user_creds['client_id'],
77
- client_secret: user_creds['client_secret'],
78
- refresh_token: user_creds['refresh_token'],
79
- scope: scope)
107
+ # Verifies that a credential grants the requested scope
108
+ #
109
+ # @param [Array<String>, String] required_scope
110
+ # Scope to verify
111
+ # @return [Boolean]
112
+ # True if scope is granted
113
+ def includes_scope?(required_scope)
114
+ missing_scope = Google::Auth::ScopeUtil.normalize(required_scope) -
115
+ Google::Auth::ScopeUtil.normalize(scope)
116
+ missing_scope.empty?
80
117
  end
81
118
  end
82
119
  end
@@ -31,6 +31,6 @@ module Google
31
31
  # Module Auth provides classes that provide Google-specific authorization
32
32
  # used to access Google APIs.
33
33
  module Auth
34
- VERSION = '0.4.2'
34
+ VERSION = '0.5.0'
35
35
  end
36
36
  end
@@ -0,0 +1,289 @@
1
+ # Copyright 2014, Google Inc.
2
+ # All rights reserved.
3
+ #
4
+ # Redistribution and use in source and binary forms, with or without
5
+ # modification, are permitted provided that the following conditions are
6
+ # met:
7
+ #
8
+ # * Redistributions of source code must retain the above copyright
9
+ # notice, this list of conditions and the following disclaimer.
10
+ # * Redistributions in binary form must reproduce the above
11
+ # copyright notice, this list of conditions and the following disclaimer
12
+ # in the documentation and/or other materials provided with the
13
+ # distribution.
14
+ # * Neither the name of Google Inc. nor the names of its
15
+ # contributors may be used to endorse or promote products derived from
16
+ # this software without specific prior written permission.
17
+ #
18
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21
+ # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22
+ # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24
+ # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25
+ # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26
+ # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29
+
30
+ require 'multi_json'
31
+ require 'googleauth/signet'
32
+ require 'googleauth/user_authorizer'
33
+ require 'googleauth/user_refresh'
34
+ require 'securerandom'
35
+
36
+ module Google
37
+ module Auth
38
+ # Varation on {Google::Auth::UserAuthorizer} adapted for Rack based
39
+ # web applications.
40
+ #
41
+ # Example usage:
42
+ #
43
+ # get('/') do
44
+ # user_id = request.session['user_email']
45
+ # credentials = authorizer.get_credentials(user_id, request)
46
+ # if credentials.nil?
47
+ # redirect authorizer.get_authorization_url(user_id: user_id,
48
+ # request: request)
49
+ # end
50
+ # # Credentials are valid, can call APIs
51
+ # ...
52
+ # end
53
+ #
54
+ # get('/oauth2callback') do
55
+ # url = Google::Auth::WebUserAuthorizer.handle_auth_callback_deferred(
56
+ # request)
57
+ # redirect url
58
+ # end
59
+ #
60
+ # Instead of implementing the callback directly, applications are
61
+ # encouraged to use {Google::Auth::Web::AuthCallbackApp} instead.
62
+ #
63
+ # For rails apps, see {Google::Auth::ControllerHelpers}
64
+ #
65
+ # @see {Google::Auth::AuthCallbackApp}
66
+ # @see {Google::Auth::ControllerHelpers}
67
+ # @note Requires sessions are enabled
68
+ class WebUserAuthorizer < Google::Auth::UserAuthorizer
69
+ STATE_PARAM = 'state'
70
+ AUTH_CODE_KEY = 'code'
71
+ ERROR_CODE_KEY = 'error'
72
+ SESSION_ID_KEY = 'session_id'
73
+ CALLBACK_STATE_KEY = 'g-auth-callback'
74
+ CURRENT_URI_KEY = 'current_uri'
75
+ XSRF_KEY = 'g-xsrf-token'
76
+ SCOPE_KEY = 'scope'
77
+
78
+ NIL_REQUEST_ERROR = 'Request is required.'
79
+ NIL_SESSION_ERROR = 'Sessions must be enabled'
80
+ MISSING_AUTH_CODE_ERROR = 'Missing authorization code in request'
81
+ AUTHORIZATION_ERROR = 'Authorization error: %s'
82
+ INVALID_STATE_TOKEN_ERROR = 'State token does not match expected value'
83
+
84
+ class << self
85
+ attr_accessor :default
86
+ end
87
+
88
+ # Handle the result of the oauth callback. This version defers the
89
+ # exchange of the code by temporarily stashing the results in the user's
90
+ # session. This allows apps to use the generic
91
+ # {Google::Auth::WebUserAuthorizer::CallbackApp} handler for the callback
92
+ # without any additional customization.
93
+ #
94
+ # Apps that wish to handle the callback directly should use
95
+ # {#handle_auth_callback} instead.
96
+ #
97
+ # @param [Rack::Request] request
98
+ # Current request
99
+ def self.handle_auth_callback_deferred(request)
100
+ callback_state, redirect_uri = extract_callback_state(request)
101
+ request.session[CALLBACK_STATE_KEY] = MultiJson.dump(callback_state)
102
+ redirect_uri
103
+ end
104
+
105
+ # Initialize the authorizer
106
+ #
107
+ # @param [Google::Auth::ClientID] client_id
108
+ # Configured ID & secret for this application
109
+ # @param [String, Array<String>] scope
110
+ # Authorization scope to request
111
+ # @param [Google::Auth::Stores::TokenStore] token_store
112
+ # Backing storage for persisting user credentials
113
+ # @param [String] callback_uri
114
+ # URL (either absolute or relative) of the auth callback. Defaults
115
+ # to '/oauth2callback'
116
+ def initialize(client_id, scope, token_store, callback_uri = nil)
117
+ super(client_id, scope, token_store, callback_uri)
118
+ end
119
+
120
+ # Handle the result of the oauth callback. Exchanges the authorization
121
+ # code from the request and persists to storage.
122
+ #
123
+ # @param [String] user_id
124
+ # Unique ID of the user for loading/storing credentials.
125
+ # @param [Rack::Request] request
126
+ # Current request
127
+ # @return (Google::Auth::UserRefreshCredentials, String)
128
+ # credentials & next URL to redirect to
129
+ def handle_auth_callback(user_id, request)
130
+ callback_state, redirect_uri = WebUserAuthorizer.extract_callback_state(
131
+ request)
132
+ WebUserAuthorizer.validate_callback_state(callback_state, request)
133
+ credentials = get_and_store_credentials_from_code(
134
+ user_id: user_id,
135
+ code: callback_state[AUTH_CODE_KEY],
136
+ scope: callback_state[SCOPE_KEY],
137
+ base_url: request.url)
138
+ [credentials, redirect_uri]
139
+ end
140
+
141
+ # Build the URL for requesting authorization.
142
+ #
143
+ # @param [String] login_hint
144
+ # Login hint if need to authorize a specific account. Should be a
145
+ # user's email address or unique profile ID.
146
+ # @param [Rack::Request] request
147
+ # Current request
148
+ # @param [String] redirect_to
149
+ # Optional URL to proceed to after authorization complete. Defaults to
150
+ # the current URL.
151
+ # @param [String, Array<String>] scope
152
+ # Authorization scope to request. Overrides the instance scopes if
153
+ # not nil.
154
+ # @return [String]
155
+ # Authorization url
156
+ def get_authorization_url(options = {})
157
+ options = options.dup
158
+ request = options[:request]
159
+ fail NIL_REQUEST_ERROR if request.nil?
160
+ fail NIL_SESSION_ERROR if request.session.nil?
161
+
162
+ redirect_to = options[:redirect_to] || request.url
163
+ request.session[XSRF_KEY] = SecureRandom.base64
164
+ options[:state] = MultiJson.dump(
165
+ SESSION_ID_KEY => request.session[XSRF_KEY],
166
+ CURRENT_URI_KEY => redirect_to)
167
+ options[:base_url] = request.url
168
+ super(options)
169
+ end
170
+
171
+ # Fetch stored credentials for the user.
172
+ #
173
+ # @param [String] user_id
174
+ # Unique ID of the user for loading/storing credentials.
175
+ # @param [Rack::Request] request
176
+ # Current request
177
+ # @param [Array<String>, String] scope
178
+ # If specified, only returns credentials that have all the \
179
+ # requested scopes
180
+ # @return [Google::Auth::UserRefreshCredentials]
181
+ # Stored credentials, nil if none present
182
+ # @raise [Signet::AuthorizationError]
183
+ # May raise an error if an authorization code is present in the session
184
+ # and exchange of the code fails
185
+ def get_credentials(user_id, request, scope = nil)
186
+ if request.session.key?(CALLBACK_STATE_KEY)
187
+ # Note - in theory, no need to check required scope as this is
188
+ # expected to be called immediately after a return from authorization
189
+ state_json = request.session.delete(CALLBACK_STATE_KEY)
190
+ callback_state = MultiJson.load(state_json)
191
+ WebUserAuthorizer.validate_callback_state(callback_state, request)
192
+ get_and_store_credentials_from_code(
193
+ user_id: user_id,
194
+ code: callback_state[AUTH_CODE_KEY],
195
+ scope: callback_state[SCOPE_KEY],
196
+ base_url: request.url)
197
+ else
198
+ super(user_id, scope)
199
+ end
200
+ end
201
+
202
+ def self.extract_callback_state(request)
203
+ state = MultiJson.load(request[STATE_PARAM] || '{}')
204
+ redirect_uri = state[CURRENT_URI_KEY]
205
+ callback_state = {
206
+ AUTH_CODE_KEY => request[AUTH_CODE_KEY],
207
+ ERROR_CODE_KEY => request[ERROR_CODE_KEY],
208
+ SESSION_ID_KEY => state[SESSION_ID_KEY],
209
+ SCOPE_KEY => request[SCOPE_KEY]
210
+ }
211
+ [callback_state, redirect_uri]
212
+ end
213
+
214
+ # Verifies the results of an authorization callback
215
+ #
216
+ # @param [Hash] state
217
+ # Callback state
218
+ # @option state [String] AUTH_CODE_KEY
219
+ # The authorization code
220
+ # @option state [String] ERROR_CODE_KEY
221
+ # Error message if failed
222
+ # @param [Rack::Request] request
223
+ # Current request
224
+ def self.validate_callback_state(state, request)
225
+ if state[AUTH_CODE_KEY].nil?
226
+ fail Signet::AuthorizationError, MISSING_AUTH_CODE_ERROR
227
+ elsif state[ERROR_CODE_KEY]
228
+ fail Signet::AuthorizationError,
229
+ sprintf(AUTHORIZATION_ERROR, state[ERROR_CODE_KEY])
230
+ elsif request.session[XSRF_KEY] != state[SESSION_ID_KEY]
231
+ fail Signet::AuthorizationError, INVALID_STATE_TOKEN_ERROR
232
+ end
233
+ end
234
+
235
+ # Small Rack app which acts as the default callback handler for the app.
236
+ #
237
+ # To configure in Rails, add to routes.rb:
238
+ #
239
+ # match '/oauth2callback',
240
+ # to: Google::Auth::WebUserAuthorizer::CallbackApp,
241
+ # via: :all
242
+ #
243
+ # With Rackup, add to config.ru:
244
+ #
245
+ # map '/oauth2callback' do
246
+ # run Google::Auth::WebUserAuthorizer::CallbackApp
247
+ # end
248
+ #
249
+ # Or in a classic Sinatra app:
250
+ #
251
+ # get('/oauth2callback') do
252
+ # Google::Auth::WebUserAuthorizer::CallbackApp.call(env)
253
+ # end
254
+ #
255
+ # @see {Google::Auth::WebUserAuthorizer}
256
+ class CallbackApp
257
+ LOCATION_HEADER = 'Location'
258
+ REDIR_STATUS = 302
259
+ ERROR_STATUS = 500
260
+
261
+ # Handle a rack request. Simply stores the results the authorization
262
+ # in the session temporarily and redirects back to to the previously
263
+ # saved redirect URL. Credentials can be later retrieved by calling.
264
+ # {Google::Auth::Web::WebUserAuthorizer#get_credentials}
265
+ #
266
+ # See {Google::Auth::Web::WebUserAuthorizer#get_authorization_uri}
267
+ # for how to initiate authorization requests.
268
+ #
269
+ # @param [Hash] env
270
+ # Rack environment
271
+ # @return [Array]
272
+ # HTTP response
273
+ def self.call(env)
274
+ request = Rack::Request.new(env)
275
+ return_url = WebUserAuthorizer.handle_auth_callback_deferred(request)
276
+ if return_url
277
+ [REDIR_STATUS, { LOCATION_HEADER => return_url }, []]
278
+ else
279
+ [ERROR_STATUS, {}, ['No return URL is present in the request.']]
280
+ end
281
+ end
282
+
283
+ def call(env)
284
+ self.class.call(env)
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
@@ -34,18 +34,6 @@ $LOAD_PATH.uniq!
34
34
  require 'faraday'
35
35
  require 'spec_helper'
36
36
 
37
- def build_json_response(payload)
38
- [200,
39
- { 'Content-Type' => 'application/json; charset=utf-8' },
40
- MultiJson.dump(payload)]
41
- end
42
-
43
- def build_access_token_json(token)
44
- build_json_response('access_token' => token,
45
- 'token_type' => 'Bearer',
46
- 'expires_in' => 3600)
47
- end
48
-
49
37
  shared_examples 'apply/apply! are OK' do
50
38
  let(:auth_key) { :Authorization }
51
39
 
@@ -56,112 +44,102 @@ shared_examples 'apply/apply! are OK' do
56
44
  # @make_auth_stubs, which should stub out the expected http behaviour of the
57
45
  # auth client
58
46
  describe '#fetch_access_token' do
59
- it 'should set access_token to the fetched value' do
60
- token = '1/abcdef1234567890'
61
- stubs = make_auth_stubs access_token: token
62
- c = Faraday.new do |b|
63
- b.adapter(:test, stubs)
64
- end
47
+ let(:token) { '1/abcdef1234567890' }
48
+ let(:stub) do
49
+ make_auth_stubs access_token: token
50
+ end
65
51
 
66
- @client.fetch_access_token!(connection: c)
52
+ it 'should set access_token to the fetched value' do
53
+ stub
54
+ @client.fetch_access_token!
67
55
  expect(@client.access_token).to eq(token)
68
- stubs.verify_stubbed_calls
56
+ expect(stub).to have_been_requested
57
+ end
58
+
59
+ it 'should notify refresh listeners after updating' do
60
+ stub
61
+ expect do |b|
62
+ @client.on_refresh(&b)
63
+ @client.fetch_access_token!
64
+ end.to yield_with_args(have_attributes(
65
+ access_token: '1/abcdef1234567890'))
66
+ expect(stub).to have_been_requested
69
67
  end
70
68
  end
71
69
 
72
70
  describe '#apply!' do
73
71
  it 'should update the target hash with fetched access token' do
74
72
  token = '1/abcdef1234567890'
75
- stubs = make_auth_stubs access_token: token
76
- c = Faraday.new do |b|
77
- b.adapter(:test, stubs)
78
- end
73
+ stub = make_auth_stubs access_token: token
79
74
 
80
75
  md = { foo: 'bar' }
81
- @client.apply!(md, connection: c)
76
+ @client.apply!(md)
82
77
  want = { :foo => 'bar', auth_key => "Bearer #{token}" }
83
78
  expect(md).to eq(want)
84
- stubs.verify_stubbed_calls
79
+ expect(stub).to have_been_requested
85
80
  end
86
81
  end
87
82
 
88
83
  describe 'updater_proc' do
89
84
  it 'should provide a proc that updates a hash with the access token' do
90
85
  token = '1/abcdef1234567890'
91
- stubs = make_auth_stubs access_token: token
92
- c = Faraday.new do |b|
93
- b.adapter(:test, stubs)
94
- end
95
-
86
+ stub = make_auth_stubs access_token: token
96
87
  md = { foo: 'bar' }
97
88
  the_proc = @client.updater_proc
98
- got = the_proc.call(md, connection: c)
89
+ got = the_proc.call(md)
99
90
  want = { :foo => 'bar', auth_key => "Bearer #{token}" }
100
91
  expect(got).to eq(want)
101
- stubs.verify_stubbed_calls
92
+ expect(stub).to have_been_requested
102
93
  end
103
94
  end
104
95
 
105
96
  describe '#apply' do
106
97
  it 'should not update the original hash with the access token' do
107
98
  token = '1/abcdef1234567890'
108
- stubs = make_auth_stubs access_token: token
109
- c = Faraday.new do |b|
110
- b.adapter(:test, stubs)
111
- end
99
+ stub = make_auth_stubs access_token: token
112
100
 
113
101
  md = { foo: 'bar' }
114
- @client.apply(md, connection: c)
102
+ @client.apply(md)
115
103
  want = { foo: 'bar' }
116
104
  expect(md).to eq(want)
117
- stubs.verify_stubbed_calls
105
+ expect(stub).to have_been_requested
118
106
  end
119
107
 
120
108
  it 'should add the token to the returned hash' do
121
109
  token = '1/abcdef1234567890'
122
- stubs = make_auth_stubs access_token: token
123
- c = Faraday.new do |b|
124
- b.adapter(:test, stubs)
125
- end
110
+ stub = make_auth_stubs access_token: token
126
111
 
127
112
  md = { foo: 'bar' }
128
- got = @client.apply(md, connection: c)
113
+ got = @client.apply(md)
129
114
  want = { :foo => 'bar', auth_key => "Bearer #{token}" }
130
115
  expect(got).to eq(want)
131
- stubs.verify_stubbed_calls
116
+ expect(stub).to have_been_requested
132
117
  end
133
118
 
134
119
  it 'should not fetch a new token if the current is not expired' do
135
120
  token = '1/abcdef1234567890'
136
- stubs = make_auth_stubs access_token: token
137
- c = Faraday.new do |b|
138
- b.adapter(:test, stubs)
139
- end
121
+ stub = make_auth_stubs access_token: token
140
122
 
141
123
  n = 5 # arbitrary
142
124
  n.times do |_t|
143
125
  md = { foo: 'bar' }
144
- got = @client.apply(md, connection: c)
126
+ got = @client.apply(md)
145
127
  want = { :foo => 'bar', auth_key => "Bearer #{token}" }
146
128
  expect(got).to eq(want)
147
129
  end
148
- stubs.verify_stubbed_calls
130
+ expect(stub).to have_been_requested
149
131
  end
150
132
 
151
133
  it 'should fetch a new token if the current one is expired' do
152
134
  token_1 = '1/abcdef1234567890'
153
- token_2 = '2/abcdef1234567890'
135
+ token_2 = '2/abcdef1234567891'
154
136
 
155
137
  [token_1, token_2].each do |t|
156
- stubs = make_auth_stubs access_token: t
157
- c = Faraday.new do |b|
158
- b.adapter(:test, stubs)
159
- end
138
+ make_auth_stubs access_token: t
160
139
  md = { foo: 'bar' }
161
- got = @client.apply(md, connection: c)
140
+ got = @client.apply(md)
162
141
  want = { :foo => 'bar', auth_key => "Bearer #{t}" }
163
142
  expect(got).to eq(want)
164
- stubs.verify_stubbed_calls
165
143
  @client.expires_at -= 3601 # default is to expire in 1hr
166
144
  end
167
145
  end