Commit 10b76ad

mo khan <mo@mokhan.ca>
2026-08-04 02:19:05
fix: keep per-request headers when following a redirect
net-hippie rebuilds the redirected request without them, so a redirect dropped Authorization and the CLI reported a 401 for a valid token. Credentials are not forwarded across origins.
cli
1 parent e2f5c82
Changed files (2)
lib
scim
spec
lib/scim/kit/http.rb
@@ -9,6 +9,8 @@ module Scim
         end
       end
 
+      MAX_REDIRECTS = 3
+
       attr_reader :driver, :retries
 
       def initialize(driver: Http.default_driver, retries: 3)
@@ -23,7 +25,7 @@ module Scim
 
       def fetch(uri, headers: {})
         driver.with_retry(retries: retries) do |client|
-          response = client.get(uri, headers: headers)
+          response = get_following_redirects(client, uri, headers)
           Result.new(response.code.to_i, parse(response.body))
         end
       rescue *Net::Hippie::CONNECTION_ERRORS => error
@@ -33,7 +35,7 @@ module Scim
 
       def self.default_driver
         @default_driver ||= Net::Hippie::Client.new(
-          follow_redirects: 3,
+          follow_redirects: 0,
           headers: headers,
           logger: Scim::Kit.logger,
           open_timeout: 1,
@@ -51,6 +53,30 @@ module Scim
 
       private
 
+      # net-hippie rebuilds the redirected request without the per-request
+      # headers, so follow redirects here to keep them.
+      def get_following_redirects(client, uri, headers, limit: MAX_REDIRECTS)
+        uri = URI.parse(uri.to_s)
+        response = client.get(uri, headers: headers)
+        location = response['location'] if response.is_a?(Net::HTTPRedirection)
+        return response if limit.zero? || location.to_s.empty?
+
+        target = uri.merge(location)
+        get_following_redirects(
+          client, target, forwardable(headers, uri, target), limit: limit - 1
+        )
+      end
+
+      def forwardable(headers, from, to)
+        return headers if origin(from) == origin(to)
+
+        headers.reject { |name, _| name.to_s.casecmp?('authorization') }
+      end
+
+      def origin(uri)
+        [uri.scheme, uri.host, uri.port]
+      end
+
       def parse(body)
         return {} if body.nil?
 
spec/scim/kit/http_spec.rb
@@ -59,5 +59,37 @@ RSpec.describe Scim::Kit::Http do
 
       specify { expect(subject.fetch(uri, headers: { 'X-Test' => 'value' })).to be_ok }
     end
+
+    context 'when the response redirects to the same origin' do
+      let(:credentials) { { 'Authorization' => 'Bearer xyz' } }
+      let(:redirect_uri) { URI.join(uri, '/v2/Users') }
+
+      before do
+        stub_request(:get, uri)
+          .to_return(status: 301, headers: { 'Location' => redirect_uri.to_s })
+        stub_request(:get, redirect_uri)
+          .with(headers: credentials).to_return(status: 200, body: '{}')
+      end
+
+      specify { expect(subject.fetch(uri, headers: credentials)).to be_ok }
+    end
+
+    context 'when the response redirects to another origin' do
+      let(:credentials) { { 'Authorization' => 'Bearer xyz' } }
+      let(:redirect_uri) { URI('https://elsewhere.example.com/Users') }
+
+      before do
+        stub_request(:get, uri)
+          .to_return(status: 301, headers: { 'Location' => redirect_uri.to_s })
+        stub_request(:get, redirect_uri).to_return(status: 200, body: '{}')
+      end
+
+      it 'does not forward the credentials' do
+        subject.fetch(uri, headers: credentials)
+
+        expect(a_request(:get, redirect_uri)
+          .with(headers: credentials)).not_to have_been_made
+      end
+    end
   end
 end