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

Add support for upstream auth with ENV variables #339

Merged
merged 1 commit into from
Sep 28, 2023
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

- Add support for upstream auth with ENV variables ([#339](https://github.com/rubygems/gemstash/pull/339), [@CiTroNaK](https://github.com/CiTroNaK))

## 2.4.0 (2023-09-27)

### Changes
Expand Down
52 changes: 52 additions & 0 deletions docs/gemstash-multiple-sources.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ gem "rubywarrior"
source "http://localhost:9292/upstream/#{CGI.escape("https://my.gem-source.local")}" do
gem "my-gem"
end

source "http://localhost:9292/upstream/my-other.gem-source.local" do
gem "my-other-gem"
end
```

Notice the `CGI.escape` call in the second source. This is important, as
Expand All @@ -57,6 +61,54 @@ you want. The `/upstream` prefix tells Gemstash to use a gem source
other than the default source. You can now bundle with the additional
source.

Notice that the third source doesn't need to be escaped.
This is because the `https://` is used by default when no scheme is set,
and the source URL does not contain any chars that need to be escaped.

## Authentication with Multiple Sources

You can use basic authentication or API keys on sources directly in Gemfile
or using ENV variables on the Gemstash instance.

Example `Gemfile`:
```ruby
# ./Gemfile
require "cgi"
source "http://localhost:9292"

source "http://localhost:9292/upstream/#{CGI.escape("user:[email protected]")}" do
gem "my-gem"
end

source "http://localhost:9292/upstream/#{CGI.escape("[email protected]")}" do
gem "my-other-gem"
end
```

If you set `GEMSTASH_<HOST>` ENV variable with your authentication information,
you can omit it from the `Gemfile`:

```ruby
# ./Gemfile
source "http://localhost:9292"

source "http://localhost:9292/upstream/my.gem-source.local" do
gem "my-gem"
end
```

And run the Gemstash with the credentials set in an ENV variable:

```bash
GEMSTASH_MY__GEM___SOURCE__LOCAL=user:password gemstash start --no-daemonize --config-file config.yml.erb
```

The name of the ENV variable is the uppercase version of the host name,
with all `.` characters replaced with `__`, all `-` with `___` and a `GEMSTASH_` prefix
(it uses the same syntax as [Bundler](https://bundler.io/v2.4/man/bundle-config.1.html#CREDENTIALS-FOR-GEM-SOURCES)).

Example: `my.gem-source.local` => `GEMSTASH_MY__GEM___SOURCE__LOCAL`

## Redirecting

Gemstash supports an alternate mode of specifying your gem sources. If
Expand Down
35 changes: 33 additions & 2 deletions lib/gemstash/upstream.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ class Upstream

attr_reader :user_agent, :uri

def_delegators :@uri, :scheme, :host, :user, :password, :to_s
def_delegators :@uri, :scheme, :host, :to_s

def initialize(upstream, user_agent: nil)
@uri = URI(CGI.unescape(upstream.to_s))
url = CGI.unescape(upstream.to_s)
url = "https://#{url}" unless %r{^https?://}.match?(url)
@uri = URI(url)
@user_agent = user_agent
raise "URL '#{@uri}' is not valid!" unless @uri.to_s&.match?(URI::DEFAULT_PARSER.make_regexp)
end
Expand All @@ -40,12 +42,41 @@ def host_id
@host_id ||= "#{host}_#{hash}"
end

def user
env_auth_user || @uri.user
end

def password
env_auth_pass || @uri.password
end

private

def hash
Digest::MD5.hexdigest(to_s)
end

def env_auth_user
return unless env_auth

env_auth.split(":", 2).first
end

def env_auth_pass
return unless env_auth
return unless env_auth.include?(":")

env_auth.split(":", 2).last
end

def env_auth
@env_auth ||= ENV["GEMSTASH_#{host_for_env}"]
end

def host_for_env
host.upcase.gsub(".", "__").gsub("-", "___")
end

# :nodoc:
class GemName
def initialize(upstream, gem_name)
Expand Down
22 changes: 22 additions & 0 deletions spec/gemstash/http_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,34 @@
it { is_expected.to include("Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") }
end

context "when user:pass auth is set by ENV variable" do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("GEMSTASH_LOCALHOST").and_return("username:password")
end

let(:upstream) { Gemstash::Upstream.new("https://localhost/") }
subject { Gemstash::HTTPClient.for(upstream).client.headers }
it { is_expected.to include("Authorization" => "Basic dXNlcm5hbWU6cGFzc3dvcmQ=") }
end

context "when api_key auth is in the upstream url" do
let(:upstream) { Gemstash::Upstream.new("https://api_key@localhost/") }
subject { Gemstash::HTTPClient.for(upstream).client.headers }
it { is_expected.to include("Authorization" => "Basic YXBpX2tleTo=") }
end

context "when api_key auth is set by ENV variable" do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("GEMSTASH_LOCALHOST").and_return("api_key")
end

let(:upstream) { Gemstash::Upstream.new("https://localhost/") }
subject { Gemstash::HTTPClient.for(upstream).client.headers }
it { is_expected.to include("Authorization" => "Basic YXBpX2tleTo=") }
end

context "when no auth is included in the upstream url" do
let(:upstream) { Gemstash::Upstream.new("https://localhost/") }
subject { Gemstash::HTTPClient.for(upstream).client.headers }
Expand Down
46 changes: 40 additions & 6 deletions spec/gemstash/upstream_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@
expect(upstream_uri.password).to be_nil
end

it "uses HTTPS schema by default" do
upstream_uri = Gemstash::Upstream.new("rubygems.org")
expect(upstream_uri.to_s).to eq("https://rubygems.org")
expect(upstream_uri.host).to eq("rubygems.org")
expect(upstream_uri.scheme).to eq("https")
expect(upstream_uri.url("gems")).to eq("https://rubygems.org/gems")
expect(upstream_uri.user).to be_nil
expect(upstream_uri.password).to be_nil
end

it "supports user:pass url auth in the uri" do
upstream_uri = Gemstash::Upstream.new("https://myuser:[email protected]/")
expect(upstream_uri.user).to eq("myuser")
Expand Down Expand Up @@ -55,12 +65,6 @@
expect(upstream_uri.url("gems", "key=value")).to eq("https://rubygems.org/gems?key=value")
end

it "fails if the uri is not valid" do
expect { Gemstash::Upstream.new("something_that_is_not_an_uri") }.to raise_error(
/URL 'something_that_is_not_an_uri' is not valid/
)
end

it "has a nil user agent if not provided" do
expect(Gemstash::Upstream.new("https://rubygems.org/").user_agent).to be_nil
end
Expand All @@ -70,6 +74,36 @@
user_agent: "my_user_agent").user_agent).to eq("my_user_agent")
end

context "with ENV variables for upstream authentication" do
context "with user and password" do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("GEMSTASH_RUBYGEMS__ORG").and_return("myuser:mypassword")
end

it "users user:pass for auth" do
upstream_uri = Gemstash::Upstream.new("https://rubygems.org/")
expect(upstream_uri.user).to eq("myuser")
expect(upstream_uri.password).to eq("mypassword")
expect(upstream_uri.auth?).to be_truthy
end
end

context "with api key" do
before do
allow(ENV).to receive(:[]).and_call_original
allow(ENV).to receive(:[]).with("GEMSTASH_RUBYGEMS__ORG").and_return("api_key")
end

it "uses api_key for auth" do
upstream_uri = Gemstash::Upstream.new("https://rubygems.org/")
expect(upstream_uri.user).to eq("api_key")
expect(upstream_uri.password).to be_nil
expect(upstream_uri.auth?).to be_truthy
end
end
end

describe ".url" do
let(:server_url) { "https://rubygems.org" }
let(:upstream) { Gemstash::Upstream.new(server_url) }
Expand Down