Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support 'auth when you only have CLI' once again #6

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/pr-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Run Tests

on:
pull_request:
branches:
- main

jobs:
# Test on code-dot-org Ruby version
test_3_0_5:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.5
bundler-cache: true

- name: Install gems
run: bundle install

- name: Run tests
run: bundle exec rake test

#Test on latest Ruby
test_3_3:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3
bundler-cache: true

- name: Install gems
run: bundle install

- name: Run tests
run: bundle exec rake test
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.0.5
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM ruby:3.0.5

WORKDIR /app

# Copy bare minimum files to install gems
COPY Gemfile aws-google.gemspec /app/
COPY lib /app/lib
RUN bundle install
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ Or install it yourself as:
Visit the [Google API Console](https://console.developers.google.com/) to create/obtain [OAuth 2.0 Client ID credentials](https://support.google.com/cloud/answer/6158849) (client ID and client secret) for an application in your Google account.

### Create an AWS IAM Role
Create an AWS IAM Role with the desired IAM policies attached, and a ['trust policy'](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html#term_trust-policy) ([`AssumeRolePolicyDocument`](https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html)) allowing the [`sts:AssumeRoleWithWebIdentity`](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html) action with [Web Identity Federation condition keys](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html#condition-keys-wif) authorizing
Create an AWS IAM Role with the desired IAM policies attached, and a ['trust policy'][1] ([`AssumeRolePolicyDocument`][2]) allowing the [`sts:AssumeRoleWithWebIdentity`][3] action with [Web Identity Federation condition keys][4] authorizing
your Google Client ID (`accounts.google.com:aud`) and a specific set of Google Account IDs (`accounts.google.com:sub`):

[1]: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_terms-and-concepts.html#term_trust-policy "IAM Trust Policy"
[2]: https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateRole.html "Create Role API"
[3]: https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html "Assume Role With Identity API"
[4]: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html#condition-keys-wif "IAM Condition Keys"

```json
{
"Version": "2012-10-17",
Expand All @@ -53,6 +58,7 @@ your Google Client ID (`accounts.google.com:aud`) and a specific set of Google A

### Method 1: `Aws::Google`
In your Ruby code, construct an `Aws::Google` object by passing the AWS `role_arn`, Google `client_id` and `client_secret`, either as constructor arguments or via the `Aws::Google.config` global defaults:

```ruby
require 'aws/google'

Expand Down Expand Up @@ -87,9 +93,22 @@ The extra `credential_process` config line tells AWS to [Source Credentials with

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
Prerequisites:

* Ruby 3.0.5

You can have Ruby installed locally, or use Docker and mount this repository into a Ruby container. By using Docker you can avoid conflicts with differing Ruby versions or other installed gems. To run and 'bash' into a Ruby container, install Docker and run the following. See [docker-compose.yml](docker-compose.yml) for details.

```
docker compose build
docker compose run ruby
```

With either option, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run `bundle exec rake install`.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).

## Contributing

Expand Down
21 changes: 11 additions & 10 deletions aws-google.gemspec
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
lib = File.expand_path('../lib', __FILE__)
lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'aws/google/version'

Gem::Specification.new do |spec|
spec.required_ruby_version = '>= 3.0.5'
spec.name = 'aws-google'
spec.version = Aws::Google::VERSION
spec.authors = ['Will Jordan']
Expand All @@ -21,14 +22,14 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

spec.add_dependency 'aws-sdk-core', '~> 3.130'
spec.add_dependency 'google-apis-core'
spec.add_dependency 'launchy', '~> 2'
spec.add_dependency 'aws-sdk-core', '~> 3.211.0'
spec.add_dependency 'google-apis-core', '~> 0.15.1'
spec.add_dependency 'launchy', '~> 3.0.1'

spec.add_development_dependency 'activesupport', '~> 5'
spec.add_development_dependency 'minitest', '~> 5.14.2'
spec.add_development_dependency 'mocha', '~> 1.5'
spec.add_development_dependency 'rake', '~> 12'
spec.add_development_dependency 'timecop', '~> 0.8'
spec.add_development_dependency 'webmock', '~> 3.3'
spec.add_development_dependency 'activesupport', '~> 6.1.7.8'
spec.add_development_dependency 'minitest', '~> 5.25.1'
spec.add_development_dependency 'mocha', '~> 2.4.5'
spec.add_development_dependency 'rake', '~> 13.2.1'
spec.add_development_dependency 'timecop', '~> 0.9.10'
spec.add_development_dependency 'webmock', '3.24.0'
end
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: '3'
services:
ruby:
build: .
volumes:
- .:/app
working_dir: /app
command: bash
55 changes: 18 additions & 37 deletions lib/aws/google.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class << self
# @option options [String] :online if `true` only a temporary access token will be provided,
# a long-lived refresh token will not be created and stored on the filesystem.
# @option options [String] :port port for local server to listen on to capture oauth browser redirect.
# Defaults to 1234. Set to nil or 0 to use an out-of-band authentication process.
# Defaults to 1234.
# @option options [String] :client_id Google client ID
# @option options [String] :client_secret Google client secret
def initialize(options = {})
Expand Down Expand Up @@ -98,18 +98,7 @@ def google_oauth
credentials.tap(&storage.method(:write_credentials))
end

def silence_output
outs = [$stdout, $stderr]
clones = outs.map(&:clone)
outs.each { |io| io.reopen '/dev/null'}
yield
ensure
outs.each_with_index { |io, i| io.reopen(clones[i]) }
end

def get_oauth_code(client, options)
raise 'fallback' unless @port && [email protected]?

require 'launchy'
require 'webrick'
code = nil
Expand All @@ -120,35 +109,27 @@ def get_oauth_code(client, options)
)
server.mount_proc '/' do |req, res|
code = req.query['code']
res.status = 202
res.body = 'Login successful, you may close this browser window.'
if code
res.status = 202
res.body = 'Login successful, you may close this browser window.'
else
res.status = 500
res.body = "Authentication failed. Received a request to http://localhost:#{@port} that should complete Google OAuth flow, but no code was received."
end
server.stop
end
trap('INT') { server.shutdown }
client.redirect_uri = "http://localhost:#{@port}"
silence_output do
launchy = Launchy.open(client.authorization_uri(options).to_s)
server_thread = Thread.new do
begin
server.start
ensure server.shutdown
end
end
while server_thread.alive?
raise 'fallback' if !launchy.alive? && !launchy.value.success?

sleep 0.1
end
client.redirect_uri = "http://localhost:#{@port}"
Launchy.open(client.authorization_uri(options).to_s) do |exception|
puts "Couldn't open browser, please authenticate with Google using this link:"
puts client.authorization_uri(options).to_s
puts
puts "Note: link must be opened on this computer, as Google will redirect to #{client.redirect_uri} to complete authentication."
end
code || raise('fallback')
rescue StandardError
trap('INT', 'DEFAULT')
# Fallback to out-of-band authentication if browser launch failed.
client.redirect_uri = 'oob'
return ENV['OAUTH_CODE'] if ENV['OAUTH_CODE']

raise RuntimeError, 'Open the following URL in a browser to get a code,' \
"export to $OAUTH_CODE and rerun:\n#{client.authorization_uri(options)}", []

server.start

code or raise 'Failed to get OAuth code from Google'
end

def refresh
Expand Down
27 changes: 18 additions & 9 deletions lib/aws/google/cached_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,31 @@ def initialize(options = {})
end

def refresh_if_near_expiration
if near_expiration?(SYNC_EXPIRATION_LENGTH)
@mutex.synchronize do
if near_expiration?(SYNC_EXPIRATION_LENGTH)
refresh
write_credentials
end
return unless near_expiration?(SYNC_EXPIRATION_LENGTH)

@mutex.synchronize do
if near_expiration?(SYNC_EXPIRATION_LENGTH)
refresh
write_credentials
end
end
end

# Write credentials and expiration to AWS credentials file.
def write_credentials
# AWS CLI is needed because writing AWS credentials is not supported by the AWS Ruby SDK.
# Ensure the AWS CLI is available before attempting to write credentials.
return unless system('which aws >/dev/null 2>&1')
Aws::SharedCredentials::KEY_MAP.transform_values(&@credentials.method(:send)).
merge(expiration: @expiration).each do |key, value|

# Manually map the credentials to the keys used by AWS CLI
credentials_map = {
'aws_access_key_id' => @credentials.access_key_id,
'aws_secret_access_key' => @credentials.secret_access_key,
'aws_session_token' => @credentials.session_token,
'expiration' => @expiration
}

# Use the AWS CLI to set the credentials in the session profile
credentials_map.each do |key, value|
system("aws configure set #{key} #{value} --profile #{@session_profile}")
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/aws/google/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
module Aws
class Google
VERSION = '0.2.0'.freeze
VERSION = '0.2.2'.freeze
end
end
11 changes: 6 additions & 5 deletions test/aws/google_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@
it 'refreshes expired Google auth token credentials' do
m = mock
m.stubs(:refresh!)
m.stubs(:id_token).
returns(JWT.encode({ email: 'email', exp: Time.now.to_i - 1 }, '')).
then.returns(JWT.encode({ email: 'email' }, ''))
m.stubs(:id_token)
.returns(JWT.encode({ email: 'email', exp: Time.now.to_i - 1 }, ''))
.then.returns(JWT.encode({ email: 'email' }, ''))
Google::Auth.stubs(:get_application_default).returns(m)

system.times(5)
Expand All @@ -108,6 +108,7 @@
expiration = provider.expiration
_(expiration).must_equal(provider.expiration)
Timecop.travel(1.5.hours.from_now) do
provider.refresh!
_(expiration).wont_equal(provider.expiration)
end
end
Expand All @@ -124,7 +125,7 @@
Aws::Google.any_instance.expects(:refresh).never
Aws::Google.new(config).credentials
end

it 'uses config defaults for new AWS clients' do
Aws::Google.stubs(:config).returns(config)
@oauth_default.once
Expand Down Expand Up @@ -204,7 +205,7 @@
Aws::Google.new(config).credentials
end
end

describe 'no shared config' do
before do
Aws.shared_config.fresh(
Expand Down
Loading