googleauth 0.8.1 → 0.16.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/.github/CODEOWNERS +7 -0
  3. data/.github/workflows/release.yml +39 -0
  4. data/.kokoro/build.bat +9 -1
  5. data/.kokoro/continuous/linux.cfg +12 -2
  6. data/.kokoro/continuous/osx.cfg +5 -0
  7. data/.kokoro/continuous/post.cfg +30 -0
  8. data/.kokoro/continuous/windows.cfg +27 -1
  9. data/.kokoro/presubmit/linux.cfg +11 -1
  10. data/.kokoro/presubmit/osx.cfg +5 -0
  11. data/.kokoro/presubmit/windows.cfg +27 -1
  12. data/.kokoro/release.cfg +42 -1
  13. data/.kokoro/trampoline.bat +10 -0
  14. data/.repo-metadata.json +5 -0
  15. data/.rubocop.yml +8 -2
  16. data/CHANGELOG.md +94 -20
  17. data/Gemfile +7 -7
  18. data/{COPYING → LICENSE} +0 -0
  19. data/README.md +12 -15
  20. data/Rakefile +48 -5
  21. data/googleauth.gemspec +7 -3
  22. data/integration/helper.rb +31 -0
  23. data/integration/id_tokens/key_source_test.rb +74 -0
  24. data/lib/googleauth.rb +1 -0
  25. data/lib/googleauth/application_default.rb +2 -2
  26. data/lib/googleauth/compute_engine.rb +45 -20
  27. data/lib/googleauth/credentials.rb +445 -71
  28. data/lib/googleauth/credentials_loader.rb +11 -9
  29. data/lib/googleauth/iam.rb +1 -1
  30. data/lib/googleauth/id_tokens.rb +233 -0
  31. data/lib/googleauth/id_tokens/errors.rb +71 -0
  32. data/lib/googleauth/id_tokens/key_sources.rb +396 -0
  33. data/lib/googleauth/id_tokens/verifier.rb +142 -0
  34. data/lib/googleauth/json_key_reader.rb +6 -2
  35. data/lib/googleauth/scope_util.rb +1 -1
  36. data/lib/googleauth/service_account.rb +42 -23
  37. data/lib/googleauth/signet.rb +9 -6
  38. data/lib/googleauth/stores/file_token_store.rb +1 -0
  39. data/lib/googleauth/stores/redis_token_store.rb +1 -0
  40. data/lib/googleauth/user_authorizer.rb +6 -1
  41. data/lib/googleauth/user_refresh.rb +2 -2
  42. data/lib/googleauth/version.rb +1 -1
  43. data/lib/googleauth/web_user_authorizer.rb +16 -14
  44. data/rakelib/devsite_builder.rb +45 -0
  45. data/rakelib/link_checker.rb +64 -0
  46. data/rakelib/repo_metadata.rb +59 -0
  47. data/spec/googleauth/apply_auth_examples.rb +28 -5
  48. data/spec/googleauth/compute_engine_spec.rb +69 -13
  49. data/spec/googleauth/credentials_spec.rb +492 -165
  50. data/spec/googleauth/service_account_spec.rb +31 -16
  51. data/spec/googleauth/signet_spec.rb +46 -7
  52. data/spec/googleauth/user_authorizer_spec.rb +21 -1
  53. data/spec/googleauth/user_refresh_spec.rb +1 -1
  54. data/spec/googleauth/web_user_authorizer_spec.rb +6 -0
  55. data/test/helper.rb +33 -0
  56. data/test/id_tokens/key_sources_test.rb +240 -0
  57. data/test/id_tokens/verifier_test.rb +269 -0
  58. metadata +49 -13
  59. data/.kokoro/windows.sh +0 -4
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2020 Google LLC
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are
7
+ # met:
8
+ #
9
+ # * Redistributions of source code must retain the above copyright
10
+ # notice, this list of conditions and the following disclaimer.
11
+ # * Redistributions in binary form must reproduce the above
12
+ # copyright notice, this list of conditions and the following disclaimer
13
+ # in the documentation and/or other materials provided with the
14
+ # distribution.
15
+ # * Neither the name of Google Inc. nor the names of its
16
+ # contributors may be used to endorse or promote products derived from
17
+ # this software without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22
+ # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23
+ # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25
+ # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26
+ # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27
+ # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
+
31
+ require "jwt"
32
+
33
+ module Google
34
+ module Auth
35
+ module IDTokens
36
+ ##
37
+ # An object that can verify ID tokens.
38
+ #
39
+ # A verifier maintains a set of default settings, including the key
40
+ # source and fields to verify. However, individual verification calls can
41
+ # override any of these settings.
42
+ #
43
+ class Verifier
44
+ ##
45
+ # Create a verifier.
46
+ #
47
+ # @param key_source [key source] The default key source to use. All
48
+ # verification calls must have a key source, so if no default key
49
+ # source is provided here, then calls to {#verify} _must_ provide
50
+ # a key source.
51
+ # @param aud [String,nil] The default audience (`aud`) check, or `nil`
52
+ # for no check.
53
+ # @param azp [String,nil] The default authorized party (`azp`) check,
54
+ # or `nil` for no check.
55
+ # @param iss [String,nil] The default issuer (`iss`) check, or `nil`
56
+ # for no check.
57
+ #
58
+ def initialize key_source: nil,
59
+ aud: nil,
60
+ azp: nil,
61
+ iss: nil
62
+ @key_source = key_source
63
+ @aud = aud
64
+ @azp = azp
65
+ @iss = iss
66
+ end
67
+
68
+ ##
69
+ # Verify the given token.
70
+ #
71
+ # @param token [String] the ID token to verify.
72
+ # @param key_source [key source] If given, override the key source.
73
+ # @param aud [String,nil] If given, override the `aud` check.
74
+ # @param azp [String,nil] If given, override the `azp` check.
75
+ # @param iss [String,nil] If given, override the `iss` check.
76
+ #
77
+ # @return [Hash] the decoded payload, if verification succeeded.
78
+ # @raise [KeySourceError] if the key source failed to obtain public keys
79
+ # @raise [VerificationError] if the token verification failed.
80
+ # Additional data may be available in the error subclass and message.
81
+ #
82
+ def verify token,
83
+ key_source: :default,
84
+ aud: :default,
85
+ azp: :default,
86
+ iss: :default
87
+ key_source = @key_source if key_source == :default
88
+ aud = @aud if aud == :default
89
+ azp = @azp if azp == :default
90
+ iss = @iss if iss == :default
91
+
92
+ raise KeySourceError, "No key sources" unless key_source
93
+ keys = key_source.current_keys
94
+ payload = decode_token token, keys, aud, azp, iss
95
+ unless payload
96
+ keys = key_source.refresh_keys
97
+ payload = decode_token token, keys, aud, azp, iss
98
+ end
99
+ raise SignatureError, "Token not verified as issued by Google" unless payload
100
+ payload
101
+ end
102
+
103
+ private
104
+
105
+ def decode_token token, keys, aud, azp, iss
106
+ payload = nil
107
+ keys.find do |key|
108
+ options = { algorithms: key.algorithm }
109
+ decoded_token = JWT.decode token, key.key, true, options
110
+ payload = decoded_token.first
111
+ rescue JWT::ExpiredSignature
112
+ raise ExpiredTokenError, "Token signature is expired"
113
+ rescue JWT::DecodeError
114
+ nil # Try the next key
115
+ end
116
+
117
+ normalize_and_verify_payload payload, aud, azp, iss
118
+ end
119
+
120
+ def normalize_and_verify_payload payload, aud, azp, iss
121
+ return nil unless payload
122
+
123
+ # Map the legacy "cid" claim to the canonical "azp"
124
+ payload["azp"] ||= payload["cid"] if payload.key? "cid"
125
+
126
+ # Payload content validation
127
+ if aud && (Array(aud) & Array(payload["aud"])).empty?
128
+ raise AudienceMismatchError, "Token aud mismatch: #{payload['aud']}"
129
+ end
130
+ if azp && (Array(azp) & Array(payload["azp"])).empty?
131
+ raise AuthorizedPartyMismatchError, "Token azp mismatch: #{payload['azp']}"
132
+ end
133
+ if iss && (Array(iss) & Array(payload["iss"])).empty?
134
+ raise IssuerMismatchError, "Token iss mismatch: #{payload['iss']}"
135
+ end
136
+
137
+ payload
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -38,8 +38,12 @@ module Google
38
38
  json_key = MultiJson.load json_key_io.read
39
39
  raise "missing client_email" unless json_key.key? "client_email"
40
40
  raise "missing private_key" unless json_key.key? "private_key"
41
- project_id = json_key["project_id"]
42
- [json_key["private_key"], json_key["client_email"], project_id]
41
+ [
42
+ json_key["private_key"],
43
+ json_key["client_email"],
44
+ json_key["project_id"],
45
+ json_key["quota_project_id"]
46
+ ]
43
47
  end
44
48
  end
45
49
  end
@@ -51,7 +51,7 @@ module Google
51
51
  when Array
52
52
  scope
53
53
  when String
54
- scope.split " "
54
+ scope.split
55
55
  else
56
56
  raise "Invalid scope value. Must be string or array"
57
57
  end
@@ -45,34 +45,47 @@ module Google
45
45
  # from credentials from a json key file downloaded from the developer
46
46
  # console (via 'Generate new Json Key').
47
47
  #
48
- # cf [Application Default Credentials](http://goo.gl/mkAHpZ)
48
+ # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
49
49
  class ServiceAccountCredentials < Signet::OAuth2::Client
50
50
  TOKEN_CRED_URI = "https://www.googleapis.com/oauth2/v4/token".freeze
51
51
  extend CredentialsLoader
52
52
  extend JsonKeyReader
53
53
  attr_reader :project_id
54
+ attr_reader :quota_project_id
55
+
56
+ def enable_self_signed_jwt?
57
+ @enable_self_signed_jwt
58
+ end
54
59
 
55
60
  # Creates a ServiceAccountCredentials.
56
61
  #
57
62
  # @param json_key_io [IO] an IO from which the JSON key can be read
58
63
  # @param scope [string|array|nil] the scope(s) to access
59
64
  def self.make_creds options = {}
60
- json_key_io, scope = options.values_at :json_key_io, :scope
65
+ json_key_io, scope, enable_self_signed_jwt, target_audience, audience, token_credential_uri =
66
+ options.values_at :json_key_io, :scope, :enable_self_signed_jwt, :target_audience,
67
+ :audience, :token_credential_uri
68
+ raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience
69
+
61
70
  if json_key_io
62
- private_key, client_email, project_id = read_json_key json_key_io
71
+ private_key, client_email, project_id, quota_project_id = read_json_key json_key_io
63
72
  else
64
73
  private_key = unescape ENV[CredentialsLoader::PRIVATE_KEY_VAR]
65
74
  client_email = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
66
75
  project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
76
+ quota_project_id = nil
67
77
  end
68
78
  project_id ||= CredentialsLoader.load_gcloud_project_id
69
79
 
70
- new(token_credential_uri: TOKEN_CRED_URI,
71
- audience: TOKEN_CRED_URI,
72
- scope: scope,
73
- issuer: client_email,
74
- signing_key: OpenSSL::PKey::RSA.new(private_key),
75
- project_id: project_id)
80
+ new(token_credential_uri: token_credential_uri || TOKEN_CRED_URI,
81
+ audience: audience || TOKEN_CRED_URI,
82
+ scope: scope,
83
+ enable_self_signed_jwt: enable_self_signed_jwt,
84
+ target_audience: target_audience,
85
+ issuer: client_email,
86
+ signing_key: OpenSSL::PKey::RSA.new(private_key),
87
+ project_id: project_id,
88
+ quota_project_id: quota_project_id)
76
89
  .configure_connection(options)
77
90
  end
78
91
 
@@ -87,30 +100,34 @@ module Google
87
100
 
88
101
  def initialize options = {}
89
102
  @project_id = options[:project_id]
103
+ @quota_project_id = options[:quota_project_id]
104
+ @enable_self_signed_jwt = options[:enable_self_signed_jwt] ? true : false
90
105
  super options
91
106
  end
92
107
 
93
- # Extends the base class.
94
- #
95
- # If scope(s) is not set, it creates a transient
96
- # ServiceAccountJwtHeaderCredentials instance and uses that to
97
- # authenticate instead.
108
+ # Extends the base class to use a transient
109
+ # ServiceAccountJwtHeaderCredentials for certain cases.
98
110
  def apply! a_hash, opts = {}
99
- # Use the base implementation if scopes are set
100
- unless scope.nil?
111
+ # Use a self-singed JWT if there's no information that can be used to
112
+ # obtain an OAuth token, OR if there are scopes but also an assertion
113
+ # that they are default scopes that shouldn't be used to fetch a token.
114
+ if target_audience.nil? && (scope.nil? || enable_self_signed_jwt?)
115
+ apply_self_signed_jwt! a_hash
116
+ else
101
117
  super
102
- return
103
118
  end
119
+ end
120
+
121
+ private
104
122
 
123
+ def apply_self_signed_jwt! a_hash
105
124
  # Use the ServiceAccountJwtHeaderCredentials using the same cred values
106
- # if no scopes are set.
107
125
  cred_json = {
108
126
  private_key: @signing_key.to_s,
109
127
  client_email: @issuer
110
128
  }
111
- alt_clz = ServiceAccountJwtHeaderCredentials
112
129
  key_io = StringIO.new MultiJson.dump(cred_json)
113
- alt = alt_clz.make_creds json_key_io: key_io
130
+ alt = ServiceAccountJwtHeaderCredentials.make_creds json_key_io: key_io
114
131
  alt.apply! a_hash
115
132
  end
116
133
  end
@@ -123,7 +140,7 @@ module Google
123
140
  # console (via 'Generate new Json Key'). It is not part of any OAuth2
124
141
  # flow, rather it creates a JWT and sends that as a credential.
125
142
  #
126
- # cf [Application Default Credentials](http://goo.gl/mkAHpZ)
143
+ # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
127
144
  class ServiceAccountJwtHeaderCredentials
128
145
  JWT_AUD_URI_KEY = :jwt_aud_uri
129
146
  AUTH_METADATA_KEY = Signet::OAuth2::AUTH_METADATA_KEY
@@ -133,6 +150,7 @@ module Google
133
150
  extend CredentialsLoader
134
151
  extend JsonKeyReader
135
152
  attr_reader :project_id
153
+ attr_reader :quota_project_id
136
154
 
137
155
  # make_creds proxies the construction of a credentials instance
138
156
  #
@@ -151,12 +169,13 @@ module Google
151
169
  def initialize options = {}
152
170
  json_key_io = options[:json_key_io]
153
171
  if json_key_io
154
- @private_key, @issuer, @project_id =
172
+ @private_key, @issuer, @project_id, @quota_project_id =
155
173
  self.class.read_json_key json_key_io
156
174
  else
157
175
  @private_key = ENV[CredentialsLoader::PRIVATE_KEY_VAR]
158
176
  @issuer = ENV[CredentialsLoader::CLIENT_EMAIL_VAR]
159
177
  @project_id = ENV[CredentialsLoader::PROJECT_ID_VAR]
178
+ @quota_project_id = nil
160
179
  end
161
180
  @project_id ||= CredentialsLoader.load_gcloud_project_id
162
181
  @signing_key = OpenSSL::PKey::RSA.new @private_key
@@ -184,7 +203,7 @@ module Google
184
203
  # Returns a reference to the #apply method, suitable for passing as
185
204
  # a closure
186
205
  def updater_proc
187
- lambda(&method(:apply))
206
+ proc { |a_hash, opts = {}| apply a_hash, opts }
188
207
  end
189
208
 
190
209
  protected
@@ -48,8 +48,9 @@ module Signet
48
48
  def apply! a_hash, opts = {}
49
49
  # fetch the access token there is currently not one, or if the client
50
50
  # has expired
51
- fetch_access_token! opts if access_token.nil? || expires_within?(60)
52
- a_hash[AUTH_METADATA_KEY] = "Bearer #{access_token}"
51
+ token_type = target_audience ? :id_token : :access_token
52
+ fetch_access_token! opts if send(token_type).nil? || expires_within?(60)
53
+ a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}"
53
54
  end
54
55
 
55
56
  # Returns a clone of a_hash updated with the authentication token
@@ -62,11 +63,11 @@ module Signet
62
63
  # Returns a reference to the #apply method, suitable for passing as
63
64
  # a closure
64
65
  def updater_proc
65
- lambda(&method(:apply))
66
+ proc { |a_hash, opts = {}| apply a_hash, opts }
66
67
  end
67
68
 
68
69
  def on_refresh &block
69
- @refresh_listeners ||= []
70
+ @refresh_listeners = [] unless defined? @refresh_listeners
70
71
  @refresh_listeners << block
71
72
  end
72
73
 
@@ -76,13 +77,15 @@ module Signet
76
77
  connection = build_default_connection
77
78
  options = options.merge connection: connection if connection
78
79
  end
79
- info = orig_fetch_access_token! options
80
+ info = retry_with_error do
81
+ orig_fetch_access_token! options
82
+ end
80
83
  notify_refresh_listeners
81
84
  info
82
85
  end
83
86
 
84
87
  def notify_refresh_listeners
85
- listeners = @refresh_listeners || []
88
+ listeners = defined?(@refresh_listeners) ? @refresh_listeners : []
86
89
  listeners.each do |block|
87
90
  block.call self
88
91
  end
@@ -40,6 +40,7 @@ module Google
40
40
  # @param [String, File] file
41
41
  # Path to storage file
42
42
  def initialize options = {}
43
+ super()
43
44
  path = options[:file]
44
45
  @store = YAML::Store.new path
45
46
  end
@@ -49,6 +49,7 @@ module Google
49
49
  # the options passed through. You may include any other keys accepted
50
50
  # by `Redis.new`
51
51
  def initialize options = {}
52
+ super()
52
53
  redis = options.delete :redis
53
54
  prefix = options.delete :prefix
54
55
  @redis = case redis
@@ -271,10 +271,15 @@ module Google
271
271
  # @return [String]
272
272
  # Redirect URI
273
273
  def redirect_uri_for base_url
274
- return @callback_uri unless URI(@callback_uri).scheme.nil?
274
+ return @callback_uri if uri_is_postmessage?(@callback_uri) || !URI(@callback_uri).scheme.nil?
275
275
  raise format(MISSING_ABSOLUTE_URL_ERROR, @callback_uri) if base_url.nil? || URI(base_url).scheme.nil?
276
276
  URI.join(base_url, @callback_uri).to_s
277
277
  end
278
+
279
+ # Check if URI is Google's postmessage flow (not a valid redirect_uri by spec, but allowed)
280
+ def uri_is_postmessage? uri
281
+ uri.to_s.casecmp("postmessage").zero?
282
+ end
278
283
  end
279
284
  end
280
285
  end
@@ -44,7 +44,7 @@ module Google
44
44
  # 'gcloud auth login' saves a file with these contents in well known
45
45
  # location
46
46
  #
47
- # cf [Application Default Credentials](http://goo.gl/mkAHpZ)
47
+ # cf [Application Default Credentials](https://cloud.google.com/docs/authentication/production)
48
48
  class UserRefreshCredentials < Signet::OAuth2::Client
49
49
  TOKEN_CRED_URI = "https://oauth2.googleapis.com/token".freeze
50
50
  AUTHORIZATION_URI = "https://accounts.google.com/o/oauth2/auth".freeze
@@ -79,7 +79,7 @@ module Google
79
79
  # JSON key.
80
80
  def self.read_json_key json_key_io
81
81
  json_key = MultiJson.load json_key_io.read
82
- wanted = %w[client_id client_secret refresh_token]
82
+ wanted = ["client_id", "client_secret", "refresh_token"]
83
83
  wanted.each do |key|
84
84
  raise "the json is missing the #{key} field" unless json_key.key? key
85
85
  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.8.1".freeze
34
+ VERSION = "0.16.0".freeze
35
35
  end
36
36
  end
@@ -58,12 +58,9 @@ module Google
58
58
  # end
59
59
  #
60
60
  # Instead of implementing the callback directly, applications are
61
- # encouraged to use {Google::Auth::Web::AuthCallbackApp} instead.
61
+ # encouraged to use {Google::Auth::WebUserAuthorizer::CallbackApp} instead.
62
62
  #
63
- # For rails apps, see {Google::Auth::ControllerHelpers}
64
- #
65
- # @see {Google::Auth::AuthCallbackApp}
66
- # @see {Google::Auth::ControllerHelpers}
63
+ # @see CallbackApp
67
64
  # @note Requires sessions are enabled
68
65
  class WebUserAuthorizer < Google::Auth::UserAuthorizer
69
66
  STATE_PARAM = "state".freeze
@@ -154,6 +151,8 @@ module Google
154
151
  # @param [String, Array<String>] scope
155
152
  # Authorization scope to request. Overrides the instance scopes if
156
153
  # not nil.
154
+ # @param [Hash] state
155
+ # Optional key-values to be returned to the oauth callback.
157
156
  # @return [String]
158
157
  # Authorization url
159
158
  def get_authorization_url options = {}
@@ -162,22 +161,25 @@ module Google
162
161
  raise NIL_REQUEST_ERROR if request.nil?
163
162
  raise NIL_SESSION_ERROR if request.session.nil?
164
163
 
164
+ state = options[:state] || {}
165
+
165
166
  redirect_to = options[:redirect_to] || request.url
166
167
  request.session[XSRF_KEY] = SecureRandom.base64
167
- options[:state] = MultiJson.dump(
168
- SESSION_ID_KEY => request.session[XSRF_KEY],
169
- CURRENT_URI_KEY => redirect_to
170
- )
168
+ options[:state] = MultiJson.dump(state.merge(
169
+ SESSION_ID_KEY => request.session[XSRF_KEY],
170
+ CURRENT_URI_KEY => redirect_to
171
+ ))
171
172
  options[:base_url] = request.url
172
173
  super options
173
174
  end
174
175
 
175
- # Fetch stored credentials for the user.
176
+ # Fetch stored credentials for the user from the given request session.
176
177
  #
177
178
  # @param [String] user_id
178
179
  # Unique ID of the user for loading/storing credentials.
179
180
  # @param [Rack::Request] request
180
- # Current request
181
+ # Current request. Optional. If omitted, this will attempt to fall back
182
+ # on the base class behavior of reading from the token store.
181
183
  # @param [Array<String>, String] scope
182
184
  # If specified, only returns credentials that have all the \
183
185
  # requested scopes
@@ -186,8 +188,8 @@ module Google
186
188
  # @raise [Signet::AuthorizationError]
187
189
  # May raise an error if an authorization code is present in the session
188
190
  # and exchange of the code fails
189
- def get_credentials user_id, request, scope = nil
190
- if request.session.key? CALLBACK_STATE_KEY
191
+ def get_credentials user_id, request = nil, scope = nil
192
+ if request&.session&.key? CALLBACK_STATE_KEY
191
193
  # Note - in theory, no need to check required scope as this is
192
194
  # expected to be called immediately after a return from authorization
193
195
  state_json = request.session.delete CALLBACK_STATE_KEY
@@ -256,7 +258,7 @@ module Google
256
258
  # Google::Auth::WebUserAuthorizer::CallbackApp.call(env)
257
259
  # end
258
260
  #
259
- # @see {Google::Auth::WebUserAuthorizer}
261
+ # @see Google::Auth::WebUserAuthorizer
260
262
  class CallbackApp
261
263
  LOCATION_HEADER = "Location".freeze
262
264
  REDIR_STATUS = 302