googleauth 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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