From 096e20a3acaa6d8242276852d73b6a588a9fa49f Mon Sep 17 00:00:00 2001 From: Petr Hlavicka Date: Thu, 28 Sep 2023 12:21:26 +0200 Subject: [PATCH] Add support for upstream auth with ENV variables --- CHANGELOG.md | 4 +++ docs/gemstash-multiple-sources.7.md | 52 +++++++++++++++++++++++++++++ lib/gemstash/upstream.rb | 35 +++++++++++++++++-- spec/gemstash/http_client_spec.rb | 22 ++++++++++++ spec/gemstash/upstream_spec.rb | 46 +++++++++++++++++++++---- 5 files changed, 151 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db0b7f9..8a521fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gemstash-multiple-sources.7.md b/docs/gemstash-multiple-sources.7.md index 589a0fbf..c49251f7 100644 --- a/docs/gemstash-multiple-sources.7.md +++ b/docs/gemstash-multiple-sources.7.md @@ -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 @@ -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:password@my.gem-source.local")}" do + gem "my-gem" +end + +source "http://localhost:9292/upstream/#{CGI.escape("api_key@my-other.gem-source.local")}" do + gem "my-other-gem" +end +``` + +If you set `GEMSTASH_` 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 diff --git a/lib/gemstash/upstream.rb b/lib/gemstash/upstream.rb index 83750071..05881011 100644 --- a/lib/gemstash/upstream.rb +++ b/lib/gemstash/upstream.rb @@ -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 @@ -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) diff --git a/spec/gemstash/http_client_spec.rb b/spec/gemstash/http_client_spec.rb index d5e872ec..c9750c55 100644 --- a/spec/gemstash/http_client_spec.rb +++ b/spec/gemstash/http_client_spec.rb @@ -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 } diff --git a/spec/gemstash/upstream_spec.rb b/spec/gemstash/upstream_spec.rb index 738d689e..201b71b1 100644 --- a/spec/gemstash/upstream_spec.rb +++ b/spec/gemstash/upstream_spec.rb @@ -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:mypassword@rubygems.org/") expect(upstream_uri.user).to eq("myuser") @@ -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 @@ -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) }