googleauth 0.11.0 → 0.15.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.
@@ -0,0 +1,144 @@
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
+ begin
109
+ options = { algorithms: key.algorithm }
110
+ decoded_token = JWT.decode token, key.key, true, options
111
+ payload = decoded_token.first
112
+ rescue JWT::ExpiredSignature
113
+ raise ExpiredTokenError, "Token signature is expired"
114
+ rescue JWT::DecodeError
115
+ nil # Try the next key
116
+ end
117
+ end
118
+
119
+ normalize_and_verify_payload payload, aud, azp, iss
120
+ end
121
+
122
+ def normalize_and_verify_payload payload, aud, azp, iss
123
+ return nil unless payload
124
+
125
+ # Map the legacy "cid" claim to the canonical "azp"
126
+ payload["azp"] ||= payload["cid"] if payload.key? "cid"
127
+
128
+ # Payload content validation
129
+ if aud && (Array(aud) & Array(payload["aud"])).empty?
130
+ raise AudienceMismatchError, "Token aud mismatch: #{payload['aud']}"
131
+ end
132
+ if azp && (Array(azp) & Array(payload["azp"])).empty?
133
+ raise AuthorizedPartyMismatchError, "Token azp mismatch: #{payload['azp']}"
134
+ end
135
+ if iss && (Array(iss) & Array(payload["iss"])).empty?
136
+ raise IssuerMismatchError, "Token iss mismatch: #{payload['iss']}"
137
+ end
138
+
139
+ payload
140
+ end
141
+ end
142
+ end
143
+ end
144
+ 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,28 +51,41 @@ module Google
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
@@ -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
@@ -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
@@ -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.11.0".freeze
34
+ VERSION = "0.15.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
@@ -261,7 +258,7 @@ module Google
261
258
  # Google::Auth::WebUserAuthorizer::CallbackApp.call(env)
262
259
  # end
263
260
  #
264
- # @see {Google::Auth::WebUserAuthorizer}
261
+ # @see Google::Auth::WebUserAuthorizer
265
262
  class CallbackApp
266
263
  LOCATION_HEADER = "Location".freeze
267
264
  REDIR_STATUS = 302
@@ -45,26 +45,37 @@ shared_examples "apply/apply! are OK" do
45
45
  # auth client
46
46
  describe "#fetch_access_token" do
47
47
  let(:token) { "1/abcdef1234567890" }
48
- let :stub do
48
+ let :access_stub do
49
49
  make_auth_stubs access_token: token
50
50
  end
51
+ let :id_stub do
52
+ make_auth_stubs id_token: token
53
+ end
51
54
 
52
55
  it "should set access_token to the fetched value" do
53
- stub
56
+ access_stub
54
57
  @client.fetch_access_token!
55
58
  expect(@client.access_token).to eq(token)
56
- expect(stub).to have_been_requested
59
+ expect(access_stub).to have_been_requested
60
+ end
61
+
62
+ it "should set id_token to the fetched value" do
63
+ skip unless @id_client
64
+ id_stub
65
+ @id_client.fetch_access_token!
66
+ expect(@id_client.id_token).to eq(token)
67
+ expect(id_stub).to have_been_requested
57
68
  end
58
69
 
59
70
  it "should notify refresh listeners after updating" do
60
- stub
71
+ access_stub
61
72
  expect do |b|
62
73
  @client.on_refresh(&b)
63
74
  @client.fetch_access_token!
64
75
  end.to yield_with_args(have_attributes(
65
76
  access_token: "1/abcdef1234567890"
66
77
  ))
67
- expect(stub).to have_been_requested
78
+ expect(access_stub).to have_been_requested
68
79
  end
69
80
  end
70
81
 
@@ -79,6 +90,18 @@ shared_examples "apply/apply! are OK" do
79
90
  expect(md).to eq(want)
80
91
  expect(stub).to have_been_requested
81
92
  end
93
+
94
+ it "should update the target hash with fetched ID token" do
95
+ skip unless @id_client
96
+ token = "1/abcdef1234567890"
97
+ stub = make_auth_stubs id_token: token
98
+
99
+ md = { foo: "bar" }
100
+ @id_client.apply! md
101
+ want = { :foo => "bar", auth_key => "Bearer #{token}" }
102
+ expect(md).to eq(want)
103
+ expect(stub).to have_been_requested
104
+ end
82
105
  end
83
106
 
84
107
  describe "updater_proc" do
@@ -37,31 +37,52 @@ require "googleauth/compute_engine"
37
37
  require "spec_helper"
38
38
 
39
39
  describe Google::Auth::GCECredentials do
40
- MD_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token".freeze
40
+ MD_ACCESS_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token".freeze
41
+ MD_ID_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://pubsub.googleapis.com/&format=full".freeze
41
42
  GCECredentials = Google::Auth::GCECredentials
42
43
 
43
44
  before :example do
44
45
  @client = GCECredentials.new
46
+ @id_client = GCECredentials.new target_audience: "https://pubsub.googleapis.com/"
45
47
  end
46
48
 
47
- def make_auth_stubs opts = {}
48
- access_token = opts[:access_token] || ""
49
- body = MultiJson.dump("access_token" => access_token,
50
- "token_type" => "Bearer",
51
- "expires_in" => 3600)
52
- stub_request(:get, MD_URI)
53
- .with(headers: { "Metadata-Flavor" => "Google" })
54
- .to_return(body: body,
55
- status: 200,
56
- headers: { "Content-Type" => "application/json" })
49
+ def make_auth_stubs opts
50
+ if opts[:access_token]
51
+ body = MultiJson.dump("access_token" => opts[:access_token],
52
+ "token_type" => "Bearer",
53
+ "expires_in" => 3600)
54
+
55
+ uri = MD_ACCESS_URI
56
+ uri += "?scopes=#{Array(opts[:scope]).join ','}" if opts[:scope]
57
+
58
+ stub_request(:get, uri)
59
+ .with(headers: { "Metadata-Flavor" => "Google" })
60
+ .to_return(body: body,
61
+ status: 200,
62
+ headers: { "Content-Type" => "application/json" })
63
+ elsif opts[:id_token]
64
+ stub_request(:get, MD_ID_URI)
65
+ .with(headers: { "Metadata-Flavor" => "Google" })
66
+ .to_return(body: opts[:id_token],
67
+ status: 200,
68
+ headers: { "Content-Type" => "text/html" })
69
+ end
57
70
  end
58
71
 
59
72
  it_behaves_like "apply/apply! are OK"
60
73
 
61
74
  context "metadata is unavailable" do
62
75
  describe "#fetch_access_token" do
76
+ it "should pass scopes when requesting an access token" do
77
+ scopes = ["https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/bigtable.data"]
78
+ stub = make_auth_stubs access_token: "1/abcdef1234567890", scope: scopes
79
+ @client = GCECredentials.new(scope: scopes)
80
+ @client.fetch_access_token!
81
+ expect(stub).to have_been_requested
82
+ end
83
+
63
84
  it "should fail if the metadata request returns a 404" do
64
- stub = stub_request(:get, MD_URI)
85
+ stub = stub_request(:get, MD_ACCESS_URI)
65
86
  .to_return(status: 404,
66
87
  headers: { "Metadata-Flavor" => "Google" })
67
88
  expect { @client.fetch_access_token! }
@@ -69,8 +90,26 @@ describe Google::Auth::GCECredentials do
69
90
  expect(stub).to have_been_requested
70
91
  end
71
92
 
93
+ it "should fail if the metadata request returns a 403" do
94
+ stub = stub_request(:get, MD_ACCESS_URI)
95
+ .to_return(status: 403,
96
+ headers: { "Metadata-Flavor" => "Google" })
97
+ expect { @client.fetch_access_token! }
98
+ .to raise_error Signet::AuthorizationError
99
+ expect(stub).to have_been_requested.times(6)
100
+ end
101
+
102
+ it "should fail if the metadata request returns a 500" do
103
+ stub = stub_request(:get, MD_ACCESS_URI)
104
+ .to_return(status: 500,
105
+ headers: { "Metadata-Flavor" => "Google" })
106
+ expect { @client.fetch_access_token! }
107
+ .to raise_error Signet::AuthorizationError
108
+ expect(stub).to have_been_requested.times(6)
109
+ end
110
+
72
111
  it "should fail if the metadata request returns an unexpected code" do
73
- stub = stub_request(:get, MD_URI)
112
+ stub = stub_request(:get, MD_ACCESS_URI)
74
113
  .to_return(status: 503,
75
114
  headers: { "Metadata-Flavor" => "Google" })
76
115
  expect { @client.fetch_access_token! }
@@ -121,5 +160,19 @@ describe Google::Auth::GCECredentials do
121
160
  expect(GCECredentials.on_gce?({}, true)).to eq(false)
122
161
  expect(stub).to have_been_requested
123
162
  end
163
+
164
+ it "should honor GCE_METADATA_HOST environment variable" do
165
+ ENV["GCE_METADATA_HOST"] = "mymetadata.example.com"
166
+ begin
167
+ stub = stub_request(:get, "http://mymetadata.example.com")
168
+ .with(headers: { "Metadata-Flavor" => "Google" })
169
+ .to_return(status: 200,
170
+ headers: { "Metadata-Flavor" => "Google" })
171
+ expect(GCECredentials.on_gce?({}, true)).to eq(true)
172
+ expect(stub).to have_been_requested
173
+ ensure
174
+ ENV.delete "GCE_METADATA_HOST"
175
+ end
176
+ end
124
177
  end
125
178
  end