From 1d17cd66a6221231b90c832484ac9081361f1bc9 Mon Sep 17 00:00:00 2001 From: Matthias Kaeppler Date: Tue, 12 Nov 2024 12:30:38 +0100 Subject: [PATCH 1/4] Cloud Connector: Extract new JWT implementation Adds a new JWT type, to be extracted into a library later on. This change is behind a feature flag. --- .../cloud_connector_jwt_replace.yml | 9 ++ .../self_signed/available_service_data.rb | 33 +++++-- .../gitlab/cloud_connector/json_web_token.rb | 57 ++++++++++++ .../available_service_data_spec.rb | 57 ++++++++++-- .../cloud_connector/json_web_token_spec.rb | 86 +++++++++++++++++++ 5 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml create mode 100644 ee/lib/gitlab/cloud_connector/json_web_token.rb create mode 100644 ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb diff --git a/ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml b/ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml new file mode 100644 index 00000000000000..5664a699f45126 --- /dev/null +++ b/ee/config/feature_flags/gitlab_com_derisk/cloud_connector_jwt_replace.yml @@ -0,0 +1,9 @@ +--- +name: cloud_connector_jwt_replace +feature_issue_url: https://gitlab.com/gitlab-org/cloud-connector/gitlab-cloud-connector/-/issues/30 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/172378 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/503739 +milestone: '17.7' +group: group::cloud connector +type: gitlab_com_derisk +default_enabled: false diff --git a/ee/lib/cloud_connector/self_signed/available_service_data.rb b/ee/lib/cloud_connector/self_signed/available_service_data.rb index 61a0c4385cd9c1..aa3a957fb493d2 100644 --- a/ee/lib/cloud_connector/self_signed/available_service_data.rb +++ b/ee/lib/cloud_connector/self_signed/available_service_data.rb @@ -12,20 +12,41 @@ def initialize(name, cut_off_date, bundled_with, backend) @bundled_with = bundled_with @backend = backend + @key = load_signing_key end override :access_token def access_token(resource = nil, extra_claims: {}) - ::Gitlab::CloudConnector::SelfIssuedToken.new( - audience: backend, - subject: Gitlab::CurrentSettings.uuid, - scopes: scopes_for(resource), - extra_claims: extra_claims - ).encoded + if Feature.enabled?(:cloud_connector_jwt_replace, Group.find_by_path_or_name('gitlab-org')) + ::Gitlab::CloudConnector::JSONWebToken.new( + issuer: Doorkeeper::OpenidConnect.configuration.issuer, + audience: backend, + subject: Gitlab::CurrentSettings.uuid, + realm: Gitlab::CloudConnector.gitlab_realm, + scopes: scopes_for(resource), + ttl: 1.hour, + extra_claims: extra_claims + ).encode(@key) + else + ::Gitlab::CloudConnector::SelfIssuedToken.new( + audience: backend, + subject: Gitlab::CurrentSettings.uuid, + scopes: scopes_for(resource), + extra_claims: extra_claims + ).encoded + end end private + def load_signing_key + key_data = Rails.application.credentials.openid_connect_signing_key + + raise 'Cloud Connector: no key found' unless key_data + + ::JWT::JWK.new(OpenSSL::PKey::RSA.new(key_data)) + end + def scopes_for(resource) free_access? ? allowed_scopes_during_free_access : allowed_scopes_from_purchased_bundles_for(resource) end diff --git a/ee/lib/gitlab/cloud_connector/json_web_token.rb b/ee/lib/gitlab/cloud_connector/json_web_token.rb new file mode 100644 index 00000000000000..13401ef8cc5539 --- /dev/null +++ b/ee/lib/gitlab/cloud_connector/json_web_token.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Gitlab + module CloudConnector + class JSONWebToken + SIGNING_ALGORITHM = 'RS256' + NOT_BEFORE_TIME = 5.seconds.to_i + + attr_reader :issued_at, :expires_at + + def initialize(issuer:, audience:, subject:, realm:, scopes:, ttl:, extra_claims: {}) + @id = SecureRandom.uuid + @audience = audience + @subject = subject + @issuer = issuer + @issued_at = Time.current.to_i + @not_before = @issued_at - NOT_BEFORE_TIME + @expires_at = (@issued_at + ttl).to_i + @realm = realm + @scopes = scopes + @extra_claims = extra_claims + end + + # jwk: + # The key (pair) as an instance of JWT::JWK. + # + # Returns a signed and Base64-encoded JSON Web Token string, to be + # written to the HTTP Authorization header field. + def encode(jwk) + header_fields = { typ: 'JWT', kid: jwk.kid } + + JWT.encode(payload, jwk.signing_key, SIGNING_ALGORITHM, header_fields) + end + + def payload + { + jti: @id, + aud: @audience, + sub: @subject, + iss: @issuer, + iat: @issued_at, + nbf: @not_before, + exp: @expires_at + }.merge(cloud_connector_claims) + end + + private + + def cloud_connector_claims + { + gitlab_realm: @realm, + scopes: @scopes + }.merge(@extra_claims) + end + end + end +end diff --git a/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb b/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb index 3a8d4341649614..ac0e2c8821b5a1 100644 --- a/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb +++ b/ee/spec/lib/cloud_connector/self_signed/available_service_data_spec.rb @@ -15,24 +15,71 @@ let(:duo_pro_scopes) { dc_unit_primitives + [:duo_chat_up3] } let(:duo_extra_scopes) { dc_unit_primitives + [:duo_chat_up4] } let(:bundled_with) { { "duo_pro" => duo_pro_scopes, "duo_extra" => duo_extra_scopes } } + + let(:issuer) { 'gitlab.com' } + let(:instance_id) { 'instance-uuid' } + let(:gitlab_realm) { 'saas' } + let(:ttl) { 1.hour } let(:extra_claims) { {} } - let(:expected_token) do - instance_double('Gitlab::CloudConnector::SelfIssuedToken', encoded: encoded_token_string) - end subject(:access_token) { available_service_data.access_token(resource) } shared_examples 'issue a token with scopes' do + let(:expected_token) do + instance_double('Gitlab::CloudConnector::JSONWebToken') + end + + before do + allow(Doorkeeper::OpenidConnect.configuration).to receive(:issuer).and_return(issuer) + allow(Gitlab::CurrentSettings).to receive(:uuid).and_return(instance_id) + allow(Gitlab::CloudConnector).to receive(:gitlab_realm).and_return(gitlab_realm) + end + it 'returns the constructed token' do - expect(Gitlab::CloudConnector::SelfIssuedToken).to receive(:new).with( + expect(Gitlab::CloudConnector::JSONWebToken).to receive(:new).with( + issuer: issuer, audience: backend, - subject: Gitlab::CurrentSettings.uuid, + subject: instance_id, + realm: gitlab_realm, scopes: scopes, + ttl: ttl, extra_claims: extra_claims ).and_return(expected_token) + expect(expected_token).to receive(:encode).with(instance_of(::JWT::JWK::RSA)).and_return(encoded_token_string) expect(access_token).to eq(encoded_token_string) end + + context 'when cloud_connector_jwt_replace is disabled' do + before do + stub_feature_flags(cloud_connector_jwt_replace: false) + end + + let(:expected_token) do + instance_double('Gitlab::CloudConnector::SelfIssuedToken', encoded: encoded_token_string) + end + + it 'returns the constructed token' do + expect(Gitlab::CloudConnector::SelfIssuedToken).to receive(:new).with( + audience: backend, + subject: Gitlab::CurrentSettings.uuid, + scopes: scopes, + extra_claims: extra_claims + ).and_return(expected_token) + + expect(access_token).to eq(encoded_token_string) + end + end + end + + context 'when signing key is missing' do + before do + allow(Rails.application.credentials).to receive(:openid_connect_signing_key).and_return(nil) + end + + it 'raises NoSigningKeyError' do + expect { access_token }.to raise_error(StandardError, 'Cloud Connector: no key found') + end end context 'with free access' do diff --git a/ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb b/ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb new file mode 100644 index 00000000000000..a3ede2a75f5b66 --- /dev/null +++ b/ee/spec/lib/gitlab/cloud_connector/json_web_token_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::CloudConnector::JSONWebToken, feature_category: :cloud_connector do + let(:extra_claims) { {} } + + let(:expected_issuer) { 'gitlab.com' } + let(:expected_audience) { 'gitlab-ai-gateway' } + let(:expected_subject) { 'ABC-123' } + let(:expected_realm) { 'saas' } + let(:expected_scopes) { [:code_suggestions] } + let(:expected_ttl) { 10.minutes } + + subject(:token) do + described_class.new( + issuer: expected_issuer, + audience: expected_audience, + subject: expected_subject, + realm: expected_realm, + scopes: expected_scopes, + ttl: expected_ttl, + extra_claims: extra_claims + ) + end + + describe '#payload' do + subject(:payload) { token.payload } + + it 'has expected values', :freeze_time, :aggregate_failures do + now = Time.current.to_i + + # standard claims + expect(payload[:iss]).to eq(expected_issuer) + expect(payload[:aud]).to eq(expected_audience) + expect(payload[:sub]).to eq(expected_subject) + expect(payload[:iat]).to eq(now) + expect(payload[:nbf]).to eq(now - 5.seconds) + expect(payload[:exp]).to eq(now + 10.minutes) + + # cloud connector specific claims + expect(payload[:gitlab_realm]).to eq(expected_realm) + expect(payload[:scopes]).to eq(expected_scopes) + end + + context 'when passing extra claims' do + let(:extra_claims) { { custom: 123 } } + + it 'includes them in payload' do + expect(payload[:custom]).to eq(123) + end + end + end + + describe '#encode' do + let(:rsa_key) { ::JWT::JWK.new(OpenSSL::PKey::RSA.new(2048)) } + + subject(:encoded_token) { token.encode(rsa_key) } + + it 'encodes token instance to string' do + expect(encoded_token).to be_instance_of(String) + end + + it 'decodes successfully with public key', :aggregate_failures, :freeze_time do + now = Time.current.to_i + payload, header = JWT.decode(encoded_token, rsa_key.public_key, true, { algorithm: 'RS256' }) + + expect(header).to match( + "alg" => "RS256", + "typ" => "JWT", + "kid" => be_instance_of(String) + ) + expect(payload).to match( + "jti" => be_instance_of(String), + "aud" => expected_audience, + "sub" => expected_subject, + "iss" => expected_issuer, + "iat" => now.to_i, + "nbf" => (now - 5.seconds).to_i, + "exp" => (now + 10.minutes).to_i, + "gitlab_realm" => expected_realm, + "scopes" => ["code_suggestions"] + ) + end + end +end -- GitLab From 1842efd614bfef7a7274da78f6c295afdb3af732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20K=C3=A4ppler?= Date: Fri, 15 Nov 2024 06:19:25 +0000 Subject: [PATCH 2/4] Factor out group lookup into private method --- .../cloud_connector/self_signed/available_service_data.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ee/lib/cloud_connector/self_signed/available_service_data.rb b/ee/lib/cloud_connector/self_signed/available_service_data.rb index aa3a957fb493d2..040484f00976e0 100644 --- a/ee/lib/cloud_connector/self_signed/available_service_data.rb +++ b/ee/lib/cloud_connector/self_signed/available_service_data.rb @@ -17,7 +17,7 @@ def initialize(name, cut_off_date, bundled_with, backend) override :access_token def access_token(resource = nil, extra_claims: {}) - if Feature.enabled?(:cloud_connector_jwt_replace, Group.find_by_path_or_name('gitlab-org')) + if Feature.enabled?(:cloud_connector_jwt_replace, gitlab_org_group) ::Gitlab::CloudConnector::JSONWebToken.new( issuer: Doorkeeper::OpenidConnect.configuration.issuer, audience: backend, @@ -39,6 +39,10 @@ def access_token(resource = nil, extra_claims: {}) private + def gitlab_org_group + @gitlab_org_group ||= Group.find_by_path_or_name('gitlab-org') + end + def load_signing_key key_data = Rails.application.credentials.openid_connect_signing_key -- GitLab From d3b2a84a92ec3293b539e79bce52df129a15c055 Mon Sep 17 00:00:00 2001 From: Matthias Kaeppler Date: Mon, 18 Nov 2024 10:47:33 +0100 Subject: [PATCH 3/4] Remove remaining uses of SelfIssuedToken --- .../requests/api/internal/ai/x_ray/scan_spec.rb | 8 ++++---- .../requests/api/internal/observability_spec.rb | 16 +--------------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb b/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb index cc2a21f9d15dca..52dd5601dcc81d 100644 --- a/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb +++ b/ee/spec/requests/api/internal/ai/x_ray/scan_spec.rb @@ -194,8 +194,8 @@ end before do - allow_next_instance_of(::Gitlab::CloudConnector::SelfIssuedToken) do |token| - allow(token).to receive(:encoded).and_return(ai_gateway_token) + allow_next_instance_of(::Gitlab::CloudConnector::JSONWebToken) do |token| + allow(token).to receive(:encode).and_return(ai_gateway_token) end end @@ -422,8 +422,8 @@ def request end before do - allow_next_instance_of(::Gitlab::CloudConnector::SelfIssuedToken) do |token| - allow(token).to receive(:encoded).and_return(ai_gateway_token) + allow_next_instance_of(::Gitlab::CloudConnector::JSONWebToken) do |token| + allow(token).to receive(:encode).and_return(ai_gateway_token) end end diff --git a/ee/spec/requests/api/internal/observability_spec.rb b/ee/spec/requests/api/internal/observability_spec.rb index 16272e449a445f..3bfc35d2ac22ce 100644 --- a/ee/spec/requests/api/internal/observability_spec.rb +++ b/ee/spec/requests/api/internal/observability_spec.rb @@ -98,21 +98,7 @@ def full_path { extra_claims: { gitlab_namespace_id: namespace.id.to_s } }).and_return(gob_token) end - context 'when instance is saas', :saas do - let(:gitlab_realm) { "saas" } - - before do - allow_next_instance_of(::Gitlab::CloudConnector::SelfIssuedToken) do |token| - allow(token).to receive(:encoded).and_return(gob_token) - end - end - - it_behaves_like 'success' - end - - context 'when instance is self-managed' do - it_behaves_like 'success' - end + it_behaves_like 'success' context 'without workhorse internal header' do let(:headers) { {} } -- GitLab From 0469e23fcd577360c626e3d3324428fea06621da Mon Sep 17 00:00:00 2001 From: Matthias Kaeppler Date: Mon, 18 Nov 2024 11:00:13 +0100 Subject: [PATCH 4/4] Remove trailing whitespace --- ee/lib/cloud_connector/self_signed/available_service_data.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/lib/cloud_connector/self_signed/available_service_data.rb b/ee/lib/cloud_connector/self_signed/available_service_data.rb index 040484f00976e0..a43776f8b33681 100644 --- a/ee/lib/cloud_connector/self_signed/available_service_data.rb +++ b/ee/lib/cloud_connector/self_signed/available_service_data.rb @@ -42,7 +42,7 @@ def access_token(resource = nil, extra_claims: {}) def gitlab_org_group @gitlab_org_group ||= Group.find_by_path_or_name('gitlab-org') end - + def load_signing_key key_data = Rails.application.credentials.openid_connect_signing_key -- GitLab