Skip to content

Commit

Permalink
feat: Service accounts apply a self-signed JWT if scopes are marked a…
Browse files Browse the repository at this point in the history
…s default
  • Loading branch information
dazuma committed Jan 25, 2021
1 parent 4fa4720 commit d22acb8
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 69 deletions.
9 changes: 7 additions & 2 deletions lib/googleauth/credentials.rb
Expand Up @@ -75,7 +75,7 @@ module Auth
# creds2 = SubCredentials.default
# creds2.scope # => ["http://example.com/sub_scope"]
#
class Credentials
class Credentials # rubocop:disable Metrics/ClassLength
##
# The default token credential URI to be used when none is provided during initialization.
TOKEN_CREDENTIAL_URI = "https://oauth2.googleapis.com/token".freeze
Expand Down Expand Up @@ -426,7 +426,12 @@ def self.from_default_paths options
# @private Lookup Credentials using Google::Auth.get_application_default.
def self.from_application_default options
scope = options[:scope] || self.scope
auth_opts = { target_audience: options[:target_audience] || target_audience }
auth_opts = {
token_credential_uri: options[:token_credential_uri] || token_credential_uri,
audience: options[:audience] || audience,
target_audience: options[:target_audience] || target_audience,
enable_self_signed_jwt: options[:enable_self_signed_jwt] && options[:scope].nil?
}
client = Google::Auth.get_application_default scope, auth_opts
new client, options
end
Expand Down
50 changes: 30 additions & 20 deletions lib/googleauth/service_account.rb
Expand Up @@ -53,12 +53,18 @@ class ServiceAccountCredentials < Signet::OAuth2::Client
attr_reader :project_id
attr_reader :quota_project_id

def enable_self_signed_jwt?
@enable_self_signed_jwt
end

# Creates a ServiceAccountCredentials.
#
# @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, target_audience = options.values_at :json_key_io, :scope, :target_audience
json_key_io, scope, enable_self_signed_jwt, target_audience, audience, token_credential_uri =
options.values_at :json_key_io, :scope, :enable_self_signed_jwt, :target_audience,
:audience, :token_credential_uri
raise ArgumentError, "Cannot specify both scope and target_audience" if scope && target_audience

if json_key_io
Expand All @@ -71,14 +77,15 @@ def self.make_creds options = {}
end
project_id ||= CredentialsLoader.load_gcloud_project_id

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,
quota_project_id: quota_project_id)
new(token_credential_uri: token_credential_uri || TOKEN_CRED_URI,
audience: audience || TOKEN_CRED_URI,
scope: scope,
enable_self_signed_jwt: enable_self_signed_jwt,
target_audience: target_audience,
issuer: client_email,
signing_key: OpenSSL::PKey::RSA.new(private_key),
project_id: project_id,
quota_project_id: quota_project_id)
.configure_connection(options)
end

Expand All @@ -94,30 +101,33 @@ def self.unescape str
def initialize options = {}
@project_id = options[:project_id]
@quota_project_id = options[:quota_project_id]
@enable_self_signed_jwt = options[:enable_self_signed_jwt] ? true : false
super options
end

# Extends the base class.
#
# If scope(s) is not set, it creates a transient
# ServiceAccountJwtHeaderCredentials instance and uses that to
# authenticate instead.
# Extends the base class to use a transient
# ServiceAccountJwtHeaderCredentials for certain cases.
def apply! a_hash, opts = {}
# Use the base implementation if scopes are set
unless scope.nil? && target_audience.nil?
# Use a self-singed JWT if there's no information that can be used to
# obtain an OAuth token, OR if there are scopes but also an assertion
# that they are default scopes that shouldn't be used to fetch a token.
if target_audience.nil? && (scope.nil? || enable_self_signed_jwt?)
apply_self_signed_jwt! a_hash
else
super
return
end
end

private

def apply_self_signed_jwt! a_hash
# Use the ServiceAccountJwtHeaderCredentials using the same cred values
# if no scopes are set.
cred_json = {
private_key: @signing_key.to_s,
client_email: @issuer
}
alt_clz = ServiceAccountJwtHeaderCredentials
key_io = StringIO.new MultiJson.dump(cred_json)
alt = alt_clz.make_creds json_key_io: key_io
alt = ServiceAccountJwtHeaderCredentials.make_creds json_key_io: key_io
alt.apply! a_hash
end
end
Expand Down
148 changes: 101 additions & 47 deletions spec/googleauth/credentials_spec.rb
Expand Up @@ -46,37 +46,37 @@
}
end

it "uses a default scope" do
def mock_signet
mocked_signet = double "Signet::OAuth2::Client"
allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet)
allow(mocked_signet).to receive(:fetch_access_token!).and_return(true)
allow(mocked_signet).to receive(:client_id)
allow(Signet::OAuth2::Client).to receive(:new) do |options|
yield options if block_given?
mocked_signet
end
mocked_signet
end

it "uses a default scope" do
mock_signet do |options|
expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token")
expect(options[:audience]).to eq("https://oauth2.googleapis.com/token")
expect(options[:scope]).to eq([])
expect(options[:issuer]).to eq(default_keyfile_hash["client_email"])
expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA)

mocked_signet
end

Google::Auth::Credentials.new default_keyfile_hash
end

it "uses a custom scope" do
mocked_signet = double "Signet::OAuth2::Client"
allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet)
allow(mocked_signet).to receive(:fetch_access_token!).and_return(true)
allow(mocked_signet).to receive(:client_id)
allow(Signet::OAuth2::Client).to receive(:new) do |options|
mock_signet do |options|
expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token")
expect(options[:audience]).to eq("https://oauth2.googleapis.com/token")
expect(options[:scope]).to eq(["http://example.com/scope"])
expect(options[:issuer]).to eq(default_keyfile_hash["client_email"])
expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA)

mocked_signet
end

Google::Auth::Credentials.new default_keyfile_hash, scope: "http://example.com/scope"
Expand Down Expand Up @@ -246,26 +246,18 @@ class TestCredentials5 < Google::Auth::Credentials
allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil }
allow(::File).to receive(:file?).with("~/default/path/to/file.txt") { false }

mocked_signet = double "Signet::OAuth2::Client"
allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet)
allow(mocked_signet).to receive(:fetch_access_token!).and_return(true)
allow(mocked_signet).to receive(:client_id)
allow(Google::Auth).to receive(:get_application_default) do |scope|
mocked_signet = mock_signet

allow(Google::Auth).to receive(:get_application_default) do |scope, options|
expect(scope).to eq([TestCredentials5::SCOPE])
expect(options[:enable_self_signed_jwt]).to be_nil
expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token")
expect(options[:audience]).to eq("https://oauth2.googleapis.com/token")

# This should really be a Signet::OAuth2::Client object,
# but mocking is making that difficult, so return a valid hash instead.
default_keyfile_hash
end
allow(Signet::OAuth2::Client).to receive(:new) do |options|
expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token")
expect(options[:audience]).to eq("https://oauth2.googleapis.com/token")
expect(options[:scope]).to eq(["http://example.com/scope"])
expect(options[:issuer]).to eq(default_keyfile_hash["client_email"])
expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA)

mocked_signet
end

creds = TestCredentials5.default
expect(creds).to be_a_kind_of(TestCredentials5)
Expand Down Expand Up @@ -446,7 +438,7 @@ class TestCredentials14 < Google::Auth::Credentials
expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"])
end

it "subclasses that find no matches default to Google::Auth.get_application_default" do
it "subclasses that find no matches default to Google::Auth.get_application_default with self-signed jwt enabled" do
class TestCredentials15 < Google::Auth::Credentials
self.scope = "http://example.com/scope"
self.env_vars = %w[PATH_ENV_DUMMY JSON_ENV_DUMMY]
Expand All @@ -459,54 +451,116 @@ class TestCredentials15 < Google::Auth::Credentials
allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil }
allow(::File).to receive(:file?).with("~/default/path/to/file.txt") { false }

mocked_signet = double "Signet::OAuth2::Client"
allow(mocked_signet).to receive(:configure_connection).and_return(mocked_signet)
allow(mocked_signet).to receive(:fetch_access_token!).and_return(true)
allow(mocked_signet).to receive(:client_id)
allow(Google::Auth).to receive(:get_application_default) do |scope|
mocked_signet = mock_signet

allow(Google::Auth).to receive(:get_application_default) do |scope, options|
expect(scope).to eq(TestCredentials15.scope)
expect(options[:enable_self_signed_jwt]).to eq(true)
expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token")
expect(options[:audience]).to eq("https://oauth2.googleapis.com/token")

# This should really be a Signet::OAuth2::Client object,
# but mocking is making that difficult, so return a valid hash instead.
default_keyfile_hash
end
allow(Signet::OAuth2::Client).to receive(:new) do |options|

creds = TestCredentials15.default enable_self_signed_jwt: true
expect(creds).to be_a_kind_of(TestCredentials15)
expect(creds.client).to eq(mocked_signet)
expect(creds.project_id).to eq(default_keyfile_hash["project_id"])
expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"])
end

it "subclasses that find no matches default to Google::Auth.get_application_default with self-signed jwt disabled" do
class TestCredentials16 < Google::Auth::Credentials
self.scope = "http://example.com/scope"
self.env_vars = %w[PATH_ENV_DUMMY JSON_ENV_DUMMY]
self.paths = ["~/default/path/to/file.txt"]
end

allow(::ENV).to receive(:[]).with("GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS") { "true" }
allow(::ENV).to receive(:[]).with("PATH_ENV_DUMMY") { "/fake/path/to/file.txt" }
allow(::File).to receive(:file?).with("/fake/path/to/file.txt") { false }
allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil }
allow(::File).to receive(:file?).with("~/default/path/to/file.txt") { false }

mocked_signet = mock_signet

allow(Google::Auth).to receive(:get_application_default) do |scope, options|
expect(scope).to eq(TestCredentials16.scope)
expect(options[:enable_self_signed_jwt]).to be_nil
expect(options[:token_credential_uri]).to eq("https://oauth2.googleapis.com/token")
expect(options[:audience]).to eq("https://oauth2.googleapis.com/token")
expect(options[:scope]).to eq(["http://example.com/scope"])
expect(options[:issuer]).to eq(default_keyfile_hash["client_email"])
expect(options[:signing_key]).to be_a_kind_of(OpenSSL::PKey::RSA)

mocked_signet
# This should really be a Signet::OAuth2::Client object,
# but mocking is making that difficult, so return a valid hash instead.
default_keyfile_hash
end

creds = TestCredentials15.default
expect(creds).to be_a_kind_of(TestCredentials15)
creds = TestCredentials16.default
expect(creds).to be_a_kind_of(TestCredentials16)
expect(creds.client).to eq(mocked_signet)
expect(creds.project_id).to eq(default_keyfile_hash["project_id"])
expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"])
end

it "subclasses that find no matches default to Google::Auth.get_application_default with custom values" do
scope2 = "http://example.com/scope2"

class TestCredentials17 < Google::Auth::Credentials
self.scope = "http://example.com/scope"
self.env_vars = %w[PATH_ENV_DUMMY JSON_ENV_DUMMY]
self.paths = ["~/default/path/to/file.txt"]
self.token_credential_uri = "https://example.com/token2"
self.audience = "https://example.com/token3"
end

allow(::ENV).to receive(:[]).with("GOOGLE_AUTH_SUPPRESS_CREDENTIALS_WARNINGS") { "true" }
allow(::ENV).to receive(:[]).with("PATH_ENV_DUMMY") { "/fake/path/to/file.txt" }
allow(::File).to receive(:file?).with("/fake/path/to/file.txt") { false }
allow(::ENV).to receive(:[]).with("JSON_ENV_DUMMY") { nil }
allow(::File).to receive(:file?).with("~/default/path/to/file.txt") { false }

mocked_signet = mock_signet

allow(Google::Auth).to receive(:get_application_default) do |scope, options|
expect(scope).to eq(scope2)
expect(options[:enable_self_signed_jwt]).to eq(false)
expect(options[:token_credential_uri]).to eq("https://example.com/token2")
expect(options[:audience]).to eq("https://example.com/token3")

# This should really be a Signet::OAuth2::Client object,
# but mocking is making that difficult, so return a valid hash instead.
default_keyfile_hash
end

creds = TestCredentials17.default scope: scope2, enable_self_signed_jwt: true
expect(creds).to be_a_kind_of(TestCredentials17)
expect(creds.client).to eq(mocked_signet)
expect(creds.project_id).to eq(default_keyfile_hash["project_id"])
expect(creds.quota_project_id).to eq(default_keyfile_hash["quota_project_id"])
end

it "subclasses delegate up the class hierarchy" do
class TestCredentials16 < Google::Auth::Credentials
class TestCredentials18 < Google::Auth::Credentials
self.scope = "http://example.com/scope"
self.target_audience = "https://example.com/target_audience"
self.env_vars = ["TEST_PATH", "TEST_JSON_VARS"]
self.paths = ["~/default/path/to/file.txt"]
end

class TestCredentials17 < TestCredentials16
class TestCredentials19 < TestCredentials18
end

expect(TestCredentials17.scope).to eq(["http://example.com/scope"])
expect(TestCredentials17.target_audience).to eq("https://example.com/target_audience")
expect(TestCredentials17.env_vars).to eq(["TEST_PATH", "TEST_JSON_VARS"])
expect(TestCredentials17.paths).to eq(["~/default/path/to/file.txt"])
expect(TestCredentials19.scope).to eq(["http://example.com/scope"])
expect(TestCredentials19.target_audience).to eq("https://example.com/target_audience")
expect(TestCredentials19.env_vars).to eq(["TEST_PATH", "TEST_JSON_VARS"])
expect(TestCredentials19.paths).to eq(["~/default/path/to/file.txt"])

TestCredentials17.token_credential_uri = "https://example.com/token2"
expect(TestCredentials17.token_credential_uri).to eq("https://example.com/token2")
TestCredentials17.token_credential_uri = nil
expect(TestCredentials17.token_credential_uri).to eq("https://oauth2.googleapis.com/token")
TestCredentials19.token_credential_uri = "https://example.com/token2"
expect(TestCredentials19.token_credential_uri).to eq("https://example.com/token2")
TestCredentials19.token_credential_uri = nil
expect(TestCredentials19.token_credential_uri).to eq("https://oauth2.googleapis.com/token")
end
end

Expand Down
8 changes: 8 additions & 0 deletions spec/googleauth/service_account_spec.rb
Expand Up @@ -169,6 +169,14 @@ def cred_json_text
it_behaves_like "jwt header auth"
end

context "when enable_self_signed_jwt is set" do
before :example do
@client.instance_variable_set(:@enable_self_signed_jwt, true)
end

it_behaves_like "jwt header auth"
end

describe "#from_env" do
before :example do
@var_name = ENV_VAR
Expand Down

0 comments on commit d22acb8

Please sign in to comment.