From c7f82f29f8547acd64b94c524c845cb9dd490e04 Mon Sep 17 00:00:00 2001 From: Daniel Azuma Date: Tue, 7 Apr 2020 17:19:29 -0700 Subject: [PATCH] feat: Support for ID token credentials. --- Gemfile | 1 + googleauth.gemspec | 2 +- lib/googleauth/compute_engine.rb | 18 +++++-- lib/googleauth/credentials.rb | 62 ++++++++++++++++++++++--- lib/googleauth/service_account.rb | 5 +- lib/googleauth/signet.rb | 5 +- spec/googleauth/apply_auth_examples.rb | 21 +++++++-- spec/googleauth/compute_engine_spec.rb | 35 ++++++++------ spec/googleauth/service_account_spec.rb | 24 ++++++---- spec/googleauth/signet_spec.rb | 22 ++++++--- spec/googleauth/user_refresh_spec.rb | 2 +- 11 files changed, 145 insertions(+), 52 deletions(-) diff --git a/Gemfile b/Gemfile index 5122923..2ff988b 100755 --- a/Gemfile +++ b/Gemfile @@ -24,4 +24,5 @@ platforms :jruby do end end +gem "faraday", "~> 0.17" gem "gems", "~> 1.2" diff --git a/googleauth.gemspec b/googleauth.gemspec index 34aea2c..2370bd2 100755 --- a/googleauth.gemspec +++ b/googleauth.gemspec @@ -32,6 +32,6 @@ Gem::Specification.new do |gem| gem.add_dependency "memoist", "~> 0.16" gem.add_dependency "multi_json", "~> 1.11" gem.add_dependency "os", ">= 0.9", "< 2.0" - gem.add_dependency "signet", "~> 0.12" + gem.add_dependency "signet", "~> 0.14" gem.add_development_dependency "yard", "~> 0.9" end diff --git a/lib/googleauth/compute_engine.rb b/lib/googleauth/compute_engine.rb index 08ecf66..41c38f3 100644 --- a/lib/googleauth/compute_engine.rb +++ b/lib/googleauth/compute_engine.rb @@ -51,8 +51,10 @@ module Google class GCECredentials < Signet::OAuth2::Client # The IP Address is used in the URIs to speed up failures on non-GCE # systems. - COMPUTE_AUTH_TOKEN_URI = "http://169.254.169.254/computeMetadata/v1/"\ - "instance/service-accounts/default/token".freeze + COMPUTE_AUTH_TOKEN_URI = + "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token".freeze + COMPUTE_ID_TOKEN_URI = + "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/identity".freeze COMPUTE_CHECK_URI = "http://169.254.169.254".freeze class << self @@ -82,12 +84,18 @@ module Google def fetch_access_token options = {} c = options[:connection] || Faraday.default_connection retry_with_error do + uri = target_audience ? COMPUTE_ID_TOKEN_URI : COMPUTE_AUTH_TOKEN_URI + query = target_audience ? { "audience" => target_audience, "format" => "full" } : nil headers = { "Metadata-Flavor" => "Google" } - resp = c.get COMPUTE_AUTH_TOKEN_URI, nil, headers + resp = c.get uri, query, headers case resp.status when 200 - Signet::OAuth2.parse_credentials(resp.body, - resp.headers["content-type"]) + content_type = resp.headers["content-type"] + if content_type == "text/html" + { (target_audience ? "id_token" : "access_token") => resp.body } + else + Signet::OAuth2.parse_credentials resp.body, content_type + end when 404 raise Signet::AuthorizationError, NO_METADATA_SERVER_ERROR else diff --git a/lib/googleauth/credentials.rb b/lib/googleauth/credentials.rb index f49ca15..531f0f5 100644 --- a/lib/googleauth/credentials.rb +++ b/lib/googleauth/credentials.rb @@ -47,6 +47,8 @@ module Google # The default target audience ID to be used when none is provided during initialization. AUDIENCE = "https://oauth2.googleapis.com/token".freeze + @audience = @scope = @target_audience = @env_vars = @paths = nil + ## # The default token credential URI to be used when none is provided during initialization. # The URI is the authorization server's HTTP endpoint capable of issuing tokens and @@ -97,20 +99,25 @@ module Google # A scope is an access range defined by the authorization server. # The scope can be a single value or a list of values. # + # Either {#scope} or {#target_audience}, but not both, should be non-nil. + # If {#scope} is set, this credential will produce access tokens. + # If {#target_audience} is set, this credential will produce ID tokens. + # # @return [String, Array] # def self.scope return @scope unless @scope.nil? - tmp_scope = [] - # Pull in values is the SCOPE constant exists. - tmp_scope << const_get(:SCOPE) if const_defined? :SCOPE - tmp_scope.flatten.uniq + Array(const_get(:SCOPE)).flatten.uniq if const_defined? :SCOPE end ## # Sets the default scope to be used when none is provided during initialization. # + # Either {#scope} or {#target_audience}, but not both, should be non-nil. + # If {#scope} is set, this credential will produce access tokens. + # If {#target_audience} is set, this credential will produce ID tokens. + # # @param [String, Array] new_scope # @return [String, Array] # @@ -119,6 +126,34 @@ module Google @scope = new_scope end + ## + # The default final target audience for ID tokens, to be used when none + # is provided during initialization. + # + # Either {#scope} or {#target_audience}, but not both, should be non-nil. + # If {#scope} is set, this credential will produce access tokens. + # If {#target_audience} is set, this credential will produce ID tokens. + # + # @return [String] + # + def self.target_audience + @target_audience + end + + ## + # Sets the default final target audience for ID tokens, to be used when none + # is provided during initialization. + # + # Either {#scope} or {#target_audience}, but not both, should be non-nil. + # If {#scope} is set, this credential will produce access tokens. + # If {#target_audience} is set, this credential will produce ID tokens. + # + # @param [String] new_target_audience + # + def self.target_audience= new_target_audience + @target_audience = new_target_audience + end + ## # The environment variables to search for credentials. Values can either be a file path to the # credentials file, or the JSON contents of the credentials file. @@ -208,6 +243,9 @@ module Google # @return [String, Array] The scope for this client. A scope is an access range # defined by the authorization server. The scope can be a single value or a list of values. # + # @!attribute [r] target_audience + # @return [String] The final target audience for ID tokens returned by this credential. + # # @!attribute [r] issuer # @return [String] The issuer ID associated with this client. # @@ -220,7 +258,7 @@ module Google # def_delegators :@client, :token_credential_uri, :audience, - :scope, :issuer, :signing_key, :updater_proc + :scope, :issuer, :signing_key, :updater_proc, :target_audience ## # Creates a new Credentials instance with the provided auth credentials, and with the default @@ -319,7 +357,8 @@ module Google # @private Lookup Credentials using Google::Auth.get_application_default. def self.from_application_default options scope = options[:scope] || self.scope - client = Google::Auth.get_application_default scope + auth_opts = { target_audience: options[:target_audience] || target_audience } + client = Google::Auth.get_application_default scope, auth_opts new client, options end @@ -358,11 +397,18 @@ module Google options["token_credential_uri"] ||= self.class.token_credential_uri options["audience"] ||= self.class.audience options["scope"] ||= self.class.scope + options["target_audience"] ||= self.class.target_audience + if !Array(options["scope"]).empty? && options["target_audience"] + raise ArgumentError, "Cannot specify both scope and target_audience" + end + + needs_scope = options["target_audience"].nil? # client options for initializing signet client { token_credential_uri: options["token_credential_uri"], audience: options["audience"], - scope: Array(options["scope"]), + scope: (needs_scope ? Array(options["scope"]) : nil), + target_audience: options["target_audience"], issuer: options["client_email"], signing_key: OpenSSL::PKey::RSA.new(options["private_key"]) } end @@ -376,6 +422,7 @@ module Google def update_from_hash hash, options hash = stringify_hash_keys hash hash["scope"] ||= options[:scope] + hash["target_audience"] ||= options[:target_audience] @project_id ||= (hash["project_id"] || hash["project"]) @quota_project_id ||= hash["quota_project_id"] @client = init_client hash, options @@ -385,6 +432,7 @@ module Google verify_keyfile_exists! path json = JSON.parse ::File.read(path) json["scope"] ||= options[:scope] + json["target_audience"] ||= options[:target_audience] @project_id ||= (json["project_id"] || json["project"]) @quota_project_id ||= json["quota_project_id"] @client = init_client json, options diff --git a/lib/googleauth/service_account.rb b/lib/googleauth/service_account.rb index 3eb4e81..b56bdff 100644 --- a/lib/googleauth/service_account.rb +++ b/lib/googleauth/service_account.rb @@ -58,7 +58,9 @@ module Google # @param json_key_io [IO] an IO from which the JSON key can be read # @param scope [string|array|nil] the scope(s) to access def self.make_creds options = {} - json_key_io, scope = options.values_at :json_key_io, :scope + json_key_io, scope, target_audience = options.values_at :json_key_io, :scope, :target_audience + raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience + if json_key_io private_key, client_email, project_id, quota_project_id = read_json_key json_key_io else @@ -72,6 +74,7 @@ module Google new(token_credential_uri: TOKEN_CRED_URI, audience: TOKEN_CRED_URI, scope: scope, + target_audience: target_audience, issuer: client_email, signing_key: OpenSSL::PKey::RSA.new(private_key), project_id: project_id, diff --git a/lib/googleauth/signet.rb b/lib/googleauth/signet.rb index db0dfa8..d2b33ff 100644 --- a/lib/googleauth/signet.rb +++ b/lib/googleauth/signet.rb @@ -48,8 +48,9 @@ module Signet def apply! a_hash, opts = {} # fetch the access token there is currently not one, or if the client # has expired - fetch_access_token! opts if access_token.nil? || expires_within?(60) - a_hash[AUTH_METADATA_KEY] = "Bearer #{access_token}" + token_type = target_audience ? :id_token : :access_token + fetch_access_token! opts if send(token_type).nil? || expires_within?(60) + a_hash[AUTH_METADATA_KEY] = "Bearer #{send token_type}" end # Returns a clone of a_hash updated with the authentication token diff --git a/spec/googleauth/apply_auth_examples.rb b/spec/googleauth/apply_auth_examples.rb index ee9ac72..cdbdbbc 100644 --- a/spec/googleauth/apply_auth_examples.rb +++ b/spec/googleauth/apply_auth_examples.rb @@ -45,26 +45,37 @@ shared_examples "apply/apply! are OK" do # auth client describe "#fetch_access_token" do let(:token) { "1/abcdef1234567890" } - let :stub do + let :access_stub do make_auth_stubs access_token: token end + let :id_stub do + make_auth_stubs id_token: token + end it "should set access_token to the fetched value" do - stub + access_stub @client.fetch_access_token! expect(@client.access_token).to eq(token) - expect(stub).to have_been_requested + expect(access_stub).to have_been_requested + end + + it "should set id_token to the fetched value" do + skip unless @id_client + id_stub + @id_client.fetch_access_token! + expect(@id_client.id_token).to eq(token) + expect(id_stub).to have_been_requested end it "should notify refresh listeners after updating" do - stub + access_stub expect do |b| @client.on_refresh(&b) @client.fetch_access_token! end.to yield_with_args(have_attributes( access_token: "1/abcdef1234567890" )) - expect(stub).to have_been_requested + expect(access_stub).to have_been_requested end end diff --git a/spec/googleauth/compute_engine_spec.rb b/spec/googleauth/compute_engine_spec.rb index 9416757..58645f5 100644 --- a/spec/googleauth/compute_engine_spec.rb +++ b/spec/googleauth/compute_engine_spec.rb @@ -37,23 +37,32 @@ require "googleauth/compute_engine" require "spec_helper" describe Google::Auth::GCECredentials do - MD_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token".freeze + MD_ACCESS_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token".freeze + MD_ID_URI = "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://pubsub.googleapis.com/&format=full".freeze GCECredentials = Google::Auth::GCECredentials before :example do @client = GCECredentials.new + @id_client = GCECredentials.new target_audience: "https://pubsub.googleapis.com/" end - def make_auth_stubs opts = {} - access_token = opts[:access_token] || "" - body = MultiJson.dump("access_token" => access_token, - "token_type" => "Bearer", - "expires_in" => 3600) - stub_request(:get, MD_URI) - .with(headers: { "Metadata-Flavor" => "Google" }) - .to_return(body: body, - status: 200, - headers: { "Content-Type" => "application/json" }) + def make_auth_stubs opts + if opts[:access_token] + body = MultiJson.dump("access_token" => opts[:access_token], + "token_type" => "Bearer", + "expires_in" => 3600) + stub_request(:get, MD_ACCESS_URI) + .with(headers: { "Metadata-Flavor" => "Google" }) + .to_return(body: body, + status: 200, + headers: { "Content-Type" => "application/json" }) + elsif opts[:id_token] + stub_request(:get, MD_ID_URI) + .with(headers: { "Metadata-Flavor" => "Google" }) + .to_return(body: opts[:id_token], + status: 200, + headers: { "Content-Type" => "text/html" }) + end end it_behaves_like "apply/apply! are OK" @@ -61,7 +70,7 @@ describe Google::Auth::GCECredentials do context "metadata is unavailable" do describe "#fetch_access_token" do it "should fail if the metadata request returns a 404" do - stub = stub_request(:get, MD_URI) + stub = stub_request(:get, MD_ACCESS_URI) .to_return(status: 404, headers: { "Metadata-Flavor" => "Google" }) expect { @client.fetch_access_token! } @@ -70,7 +79,7 @@ describe Google::Auth::GCECredentials do end it "should fail if the metadata request returns an unexpected code" do - stub = stub_request(:get, MD_URI) + stub = stub_request(:get, MD_ACCESS_URI) .to_return(status: 503, headers: { "Metadata-Flavor" => "Google" }) expect { @client.fetch_access_token! } diff --git a/spec/googleauth/service_account_spec.rb b/spec/googleauth/service_account_spec.rb index 9d7444d..e7186c6 100644 --- a/spec/googleauth/service_account_spec.rb +++ b/spec/googleauth/service_account_spec.rb @@ -128,24 +128,28 @@ describe Google::Auth::ServiceAccountCredentials do json_key_io: StringIO.new(cred_json_text), scope: "https://www.googleapis.com/auth/userinfo.profile" ) + @id_client = ServiceAccountCredentials.make_creds( + json_key_io: StringIO.new(cred_json_text), + target_audience: "https://pubsub.googleapis.com/" + ) end - def make_auth_stubs opts = {} - access_token = opts[:access_token] || "" - body = MultiJson.dump("access_token" => access_token, - "token_type" => "Bearer", - "expires_in" => 3600) + def make_auth_stubs opts + body_fields = { "token_type" => "Bearer", "expires_in" => 3600 } + body_fields["access_token"] = opts[:access_token] if opts[:access_token] + body_fields["id_token"] = opts[:id_token] if opts[:id_token] + body = MultiJson.dump body_fields blk = proc do |request| params = Addressable::URI.form_unencode request.body - _claim, _header = JWT.decode(params.assoc("assertion").last, - @key.public_key, true, - algorithm: "RS256") + claim, _header = JWT.decode(params.assoc("assertion").last, + @key.public_key, true, + algorithm: "RS256") + !opts[:id_token] || claim["target_audience"] == "https://pubsub.googleapis.com/" end stub_request(:post, "https://www.googleapis.com/oauth2/v4/token") .with(body: hash_including( "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer" - ), - &blk) + ), &blk) .to_return(body: body, status: 200, headers: { "Content-Type" => "application/json" }) diff --git a/spec/googleauth/signet_spec.rb b/spec/googleauth/signet_spec.rb index 46f05f6..b289f51 100644 --- a/spec/googleauth/signet_spec.rb +++ b/spec/googleauth/signet_spec.rb @@ -47,18 +47,26 @@ describe Signet::OAuth2::Client do audience: "https://oauth2.googleapis.com/token", signing_key: @key ) + @id_client = Signet::OAuth2::Client.new( + token_credential_uri: "https://oauth2.googleapis.com/token", + target_audience: "https://pubsub.googleapis.com/", + issuer: "app@example.com", + audience: "https://oauth2.googleapis.com/token", + signing_key: @key + ) end def make_auth_stubs opts - access_token = opts[:access_token] || "" - body = MultiJson.dump("access_token" => access_token, - "token_type" => "Bearer", - "expires_in" => 3600) + body_fields = { "token_type" => "Bearer", "expires_in" => 3600 } + body_fields["access_token"] = opts[:access_token] if opts[:access_token] + body_fields["id_token"] = opts[:id_token] if opts[:id_token] + body = MultiJson.dump body_fields blk = proc do |request| params = Addressable::URI.form_unencode request.body - _claim, _header = JWT.decode(params.assoc("assertion").last, - @key.public_key, true, - algorithm: "RS256") + claim, _header = JWT.decode(params.assoc("assertion").last, + @key.public_key, true, + algorithm: "RS256") + !opts[:id_token] || claim["target_audience"] == "https://pubsub.googleapis.com/" end with_params = { body: hash_including( "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer" diff --git a/spec/googleauth/user_refresh_spec.rb b/spec/googleauth/user_refresh_spec.rb index 40531bd..f1d6b42 100644 --- a/spec/googleauth/user_refresh_spec.rb +++ b/spec/googleauth/user_refresh_spec.rb @@ -64,7 +64,7 @@ describe Google::Auth::UserRefreshCredentials do ) end - def make_auth_stubs opts = {} + def make_auth_stubs opts access_token = opts[:access_token] || "" body = MultiJson.dump("access_token" => access_token, "token_type" => "Bearer",