From 5fdf83961e7827f6beebf14b72d234be4af25ef0 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Fri, 28 Nov 2025 18:55:21 +0100 Subject: [PATCH 01/21] Add IAM Service OAuth feature flag for JWT authentication Introduces `iam_svc_oauth` feature flag to enable JWT authentication from external IAM service as an alternative to Doorkeeper OAuth. Changes: - Add feature flag `iam_svc_oauth` (disabled by default) - Add `Gitlab::Auth::IamJwt` module with error hierarchy - Add `Gitlab::Auth::IamJwt::Token` class for validated JWTs - Add `Gitlab::Auth::IamJwt::JwksClient` for RS256 key fetching/caching - Add `Auth::IamJwtValidationService` for JWT signature verification - Integrate IAM JWT check into AuthFinders before OAuth - Add IAM JWT attributes to Current class - Add ApplicationSetting fields for IAM service configuration Related to: https://gitlab.com/gitlab-org/gitlab/-/issues/580758 WIP --- app/models/current.rb | 1 + .../auth/iam_jwt_validation_service.rb | 122 ++++++++++++++++++ .../development/iam_svc_oauth.yml | 10 ++ lib/gitlab/auth/auth_finders.rb | 18 ++- lib/gitlab/auth/iam_jwt.rb | 57 ++++++++ lib/gitlab/auth/iam_jwt/jwks_client.rb | 78 +++++++++++ lib/gitlab/auth/iam_jwt/token.rb | 46 +++++++ 7 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 app/services/auth/iam_jwt_validation_service.rb create mode 100644 config/feature_flags/development/iam_svc_oauth.yml create mode 100644 lib/gitlab/auth/iam_jwt.rb create mode 100644 lib/gitlab/auth/iam_jwt/jwks_client.rb create mode 100644 lib/gitlab/auth/iam_jwt/token.rb diff --git a/app/models/current.rb b/app/models/current.rb index 1a922b8ac4f373..cdca5579a759ef 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -17,6 +17,7 @@ def message # watch background jobs need to reset on each job if using attribute :organization, :organization_assigned attribute :token_info + attribute :iam_jwt_scopes, :iam_jwt_token_id attribute :cells_claims_leases diff --git a/app/services/auth/iam_jwt_validation_service.rb b/app/services/auth/iam_jwt_validation_service.rb new file mode 100644 index 00000000000000..9dd0accb3e22a2 --- /dev/null +++ b/app/services/auth/iam_jwt_validation_service.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern + class IamJwtValidationService + include Gitlab::Utils::StrongMemoize + + def initialize(token:) + @token_string = token + @retry_attempted = false + end + + def execute + return error_disabled unless Gitlab::Auth::IamJwt.enabled? + return error_invalid_format unless valid_jwt_format? + + payload = decode_and_verify! + validate_required_claims!(payload) + + token_object = build_token(payload) + + ServiceResponse.success(payload: { token: token_object }) + rescue Gitlab::Auth::IamJwt::TokenExpiredError => e + error_response(e.message, :token_expired) + rescue Gitlab::Auth::IamJwt::JwksFetchError => e + error_response(e.message, :service_unavailable) + rescue Gitlab::Auth::IamJwt::Error => e + error_response(e.message, :invalid_token) + end + + private + + def valid_jwt_format? + @token_string.is_a?(String) && + @token_string.start_with?('ey') && + @token_string.count('.') == 2 + end + + def decode_and_verify! + payload, _ = JWT.decode( + @token_string, + nil, + true, + decode_options + ) + + payload + rescue JWT::ExpiredSignature + raise Gitlab::Auth::IamJwt::TokenExpiredError, 'Token has expired' + rescue JWT::InvalidIssuerError + raise Gitlab::Auth::IamJwt::InvalidIssuerError, 'Invalid token issuer' + rescue JWT::InvalidAudError + raise Gitlab::Auth::IamJwt::InvalidAudienceError, 'Invalid token audience' + rescue JWT::VerificationError => e + handle_verification_error(e) + rescue JWT::DecodeError => e + raise Gitlab::Auth::IamJwt::MalformedTokenError, "Invalid token format: #{e.message}" + end + + def handle_verification_error(error) + if @retry_attempted + raise Gitlab::Auth::IamJwt::InvalidSignatureError, + "Signature verification failed: #{error.message}" + end + + @retry_attempted = true + jwks_client.clear_cache + decode_and_verify! + end + + def decode_options + { + algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, + jwks: jwks_client.fetch_keys, + verify_iss: true, + iss: Gitlab::CurrentSettings.iam_service_issuer, + verify_aud: true, + aud: Gitlab::CurrentSettings.iam_service_audience, + leeway: clock_skew_seconds + } + end + + def validate_required_claims!(payload) + raise Gitlab::Auth::IamJwt::InvalidSubjectError, 'Missing sub claim' if payload['sub'].blank? + raise Gitlab::Auth::IamJwt::MalformedTokenError, 'Missing jti claim' if payload['jti'].blank? + end + + def build_token(payload) + Gitlab::Auth::IamJwt::Token.new( + user_id: payload['sub'], + scopes: payload['scp'] || [], + jti: payload['jti'], + expires_at: Time.zone.at(payload['exp']), + issued_at: Time.zone.at(payload['iat']), + payload: payload + ) + end + + def jwks_client + Gitlab::Auth::IamJwt::JwksClient.new + end + strong_memoize_attr :jwks_client + + def clock_skew_seconds + # Note: suggested by Duo + # TODO: Validate this + # Gitlab::CurrentSettings.iam_service_clock_skew_seconds || 30 + 30 + end + + def error_disabled + ServiceResponse.error(message: 'IAM JWT authentication is disabled', reason: :disabled) + end + + def error_invalid_format + ServiceResponse.error(message: 'Invalid token format', reason: :invalid_format) + end + + def error_response(message, reason) + ServiceResponse.error(message: message, reason: reason) + end + end +end diff --git a/config/feature_flags/development/iam_svc_oauth.yml b/config/feature_flags/development/iam_svc_oauth.yml new file mode 100644 index 00000000000000..6ea45f29f375a8 --- /dev/null +++ b/config/feature_flags/development/iam_svc_oauth.yml @@ -0,0 +1,10 @@ +--- +name: iam_svc_oauth +description: Enable JWT authentication for tokens issued by external IAM service. When enabled, GitLab accepts RS256-signed JWTs from a configured IAM service as an alternative to OAuth tokens for API and Git + authentication. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/XXXXX +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXX +milestone: '17.X' +type: development +group: group::authentication +default_enabled: false diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 30906c07491fc8..849e56e0db3ea8 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -316,7 +316,7 @@ def access_token else # The token can be a PAT or an OAuth (doorkeeper) token begin - find_oauth_access_token + find_iam_jwt_access_token || find_oauth_access_token rescue UnauthorizedError # It is also possible that a PAT is encapsulated in a `Bearer` OAuth token # (e.g. NPM client registry auth). In that case, we rescue UnauthorizedError @@ -326,6 +326,22 @@ def access_token end end + def find_iam_jwt_access_token + return unless Gitlab::Auth::IamJwt.enabled? + + self.current_token = parsed_oauth_token + return unless current_token + return unless Gitlab::Auth::IamJwt.iam_jwt?(current_token) + + result = ::Auth::IamJwtValidationService.new(token: current_token).execute + return unless result.success? + + result.payload[:token] + rescue Gitlab::Auth::IamJwt::Error + nil # Silently fail, let other auth methods try + # TODO: this can be handled better + end + def find_personal_access_token self.current_token = extract_personal_access_token return unless current_token diff --git a/lib/gitlab/auth/iam_jwt.rb b/lib/gitlab/auth/iam_jwt.rb new file mode 100644 index 00000000000000..11ebac083e82fb --- /dev/null +++ b/lib/gitlab/auth/iam_jwt.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module IamJwt + # Base error inherits from existing auth error hierarchy + Error = Class.new(Gitlab::Auth::AuthenticationError) + + # JWT validation errors (401) + TokenExpiredError = Class.new(Error) + InvalidSignatureError = Class.new(Error) + InvalidIssuerError = Class.new(Error) + InvalidAudienceError = Class.new(Error) + MalformedTokenError = Class.new(Error) + MissingClaimError = Class.new(Error) + + # JWKS errors (503) + JwksFetchError = Class.new(Error) + JwksKeyNotFoundError = Class.new(Error) + + # User resolution errors (401) + UserNotFoundError = Class.new(Error) + UserBlockedError = Class.new(Error) + UserInactiveError = Class.new(Error) + + # Scope errors (403) + InsufficientScopeError = Class.new(Error) + + # TODO: Implement more specific errors + + ALLOWED_ALGORITHMS = ['RS256'].freeze + + class << self + def iam_jwt?(token_string) + return false unless token_string.is_a?(String) + return false unless token_string.start_with?('ey') + return false unless token_string.count('.') == 2 + + payload = JWT.decode(token_string, nil, false).first + payload['iss'] == iam_service_issuer # Open question: would it break in cells? + rescue JWT::DecodeError + false + end + + def iam_service_issuer + ## Gitlab::CurrentSettings.iam_service_issuer TODO:to implement + "issuer" + end + + def enabled? + Feature.enabled?(:iam_svc_oauth, :instance) ## && + ## Gitlab::CurrentSettings.iam_service_enabled TODO:to implement + end + end + end + end +end diff --git a/lib/gitlab/auth/iam_jwt/jwks_client.rb b/lib/gitlab/auth/iam_jwt/jwks_client.rb new file mode 100644 index 00000000000000..c6afa289a08d37 --- /dev/null +++ b/lib/gitlab/auth/iam_jwt/jwks_client.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module IamJwt + class JwksClient + # TODO: improve with include Gitlab::Utils::StrongMemoize + + CACHE_KEY = 'iam_jwt:jwks' + JWKS_PATH = '/.well-known/jwks.json' + + # TODO: open_timeout and cache_ttl overengineered? + def initialize( + endpoint: nil, + cache: Rails.cache, + timeout: nil + ) + @endpoint = endpoint || default_endpoint + @cache = cache + @timeout = timeout || default_timeout + end + + def fetch_keys + @cache.fetch(CACHE_KEY, expires_in: default_cache_ttl) do + fetch_from_service + end + end + + def refresh_keys + @cache.delete(CACHE_KEY) + fetch_keys + end + + private + + def fetch_from_service + response = Gitlab::HTTP.get( + @endpoint, + timeout: @timeout, + open_timeout: @open_timeout, + headers: { + 'User-Agent' => 'GitLab-Rails', + 'Accept' => 'application/json' + } + ) + + raise JwksFetchError, "Failed to fetch JWKS: HTTP #{response.code}" unless response.success? + + JWT::JWK::Set.new(response.parsed_response) + rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e + Gitlab::ErrorTracking.track_exception(e) + raise JwksFetchError, "Cannot connect to IAM service: #{e.message}" + rescue JSON::ParserError => e + raise JwksFetchError, "Invalid JWKS response: #{e.message}" + end + + def default_endpoint + # url = Gitlab::CurrentSettings.iam_service_url + url = "http://localhost:8084" + URI.join(url, JWKS_PATH).to_s + end + + def default_timeout + # TODO: Should it be configurable? + # Gitlab::CurrentSettings.iam_service_timeout || 10 + 10 + end + + def default_cache_ttl + # TODO: Should it be configurable? + + # (Gitlab::CurrentSettings.iam_service_jwks_cache_ttl || 3600).seconds + 3600.seconds + end + end + end + end +end diff --git a/lib/gitlab/auth/iam_jwt/token.rb b/lib/gitlab/auth/iam_jwt/token.rb new file mode 100644 index 00000000000000..63a67ea898c588 --- /dev/null +++ b/lib/gitlab/auth/iam_jwt/token.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module IamJwt + class Token + attr_reader :user_id, :scopes, :jti, :expires_at, :issued_at, :payload + + def initialize(user_id:, scopes:, jti:, expires_at:, issued_at:, payload:) + @user_id = user_id + @scopes = Array(scopes).map(&:to_s) + @jti = jti + @expires_at = expires_at + @issued_at = issued_at + @payload = payload + end + + def id + jti + end + + def expired? + # expires_at < Time.current + @payload.blank? + end + + def revoked? + # false # IAM JWTs are stateless + @payload.blank? + end + + def user + @user ||= User.find_by_id(user_id) # -- Token must resolve its user + end + + def reload + self + end + + def to_s + "IamJwt::Token(jti=#{jti})" + end + end + end + end +end -- GitLab From 77d86286089b93dba30bb33689e6be0fc18af232 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Tue, 2 Dec 2025 17:01:26 +0100 Subject: [PATCH 02/21] Refactor IAM JWT validation service and integrate with auth chain - Simplify IamJwtValidationService to resolve user and build token inline - Add iam_jwt_token_check to Git authentication chain - Update Token class to accept user object instead of user_id - Add cache key with issuer hash to JwksClient for config changes - Add authentication success logging with user and token details - Update feature flag with MR number and milestone 18.7 - Add RuboCop exception for find_for_git_client complexity Technical changes: - Rename decode_and_verify! to decode_and_validate! for clarity - Extract resolve_user and extract_scopes methods - Remove build_token method in favor of inline Token.new - Add TODO comments for future improvements (strong_memoize, claim validation) --- .../auth/iam_jwt_validation_service.rb | 97 ++++++++++++++----- .../development/iam_svc_oauth.yml | 4 +- lib/gitlab/auth.rb | 26 +++++ lib/gitlab/auth/auth_finders.rb | 2 +- lib/gitlab/auth/iam_jwt/jwks_client.rb | 7 ++ lib/gitlab/auth/iam_jwt/token.rb | 16 +-- 6 files changed, 111 insertions(+), 41 deletions(-) diff --git a/app/services/auth/iam_jwt_validation_service.rb b/app/services/auth/iam_jwt_validation_service.rb index 9dd0accb3e22a2..1c78e3a145a83e 100644 --- a/app/services/auth/iam_jwt_validation_service.rb +++ b/app/services/auth/iam_jwt_validation_service.rb @@ -13,19 +13,29 @@ def execute return error_disabled unless Gitlab::Auth::IamJwt.enabled? return error_invalid_format unless valid_jwt_format? - payload = decode_and_verify! - validate_required_claims!(payload) - - token_object = build_token(payload) - - ServiceResponse.success(payload: { token: token_object }) - rescue Gitlab::Auth::IamJwt::TokenExpiredError => e - error_response(e.message, :token_expired) + payload = decode_and_validate! + user = resolve_user(payload) + scopes = extract_scopes(payload) + + log_successful_authentication(user, payload) + + # token_object = build_token(payload) + + ServiceResponse.success(payload: { + token: Gitlab::Auth::IamJwt::Token.new( + user: user, + scopes: scopes, + jti: payload['jti'], + expires_at: Time.zone.at(payload['exp']), + payload: payload + ) + }) rescue Gitlab::Auth::IamJwt::JwksFetchError => e error_response(e.message, :service_unavailable) rescue Gitlab::Auth::IamJwt::Error => e error_response(e.message, :invalid_token) end + # TODO: evaluate `rescue Gitlab::Auth::IamJwt::TokenExpiredError` implementation private @@ -35,7 +45,7 @@ def valid_jwt_format? @token_string.count('.') == 2 end - def decode_and_verify! + def decode_and_validate! payload, _ = JWT.decode( @token_string, nil, @@ -64,7 +74,7 @@ def handle_verification_error(error) @retry_attempted = true jwks_client.clear_cache - decode_and_verify! + decode_and_validate! end def decode_options @@ -72,27 +82,51 @@ def decode_options algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, jwks: jwks_client.fetch_keys, verify_iss: true, - iss: Gitlab::CurrentSettings.iam_service_issuer, - verify_aud: true, - aud: Gitlab::CurrentSettings.iam_service_audience, - leeway: clock_skew_seconds + iss: Gitlab::CurrentSettings.iam_service_issuer } end - def validate_required_claims!(payload) - raise Gitlab::Auth::IamJwt::InvalidSubjectError, 'Missing sub claim' if payload['sub'].blank? - raise Gitlab::Auth::IamJwt::MalformedTokenError, 'Missing jti claim' if payload['jti'].blank? + # TODO: Improve decode_options, delegate jwt library to validate required claims + # def decode_options + # { + # algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, + # jwks: jwks_client.fetch_keys, + # required_claims: %w[sub jti exp iat], + # verify_iss: true, + # iss: Gitlab::CurrentSettings.iam_service_issuer, + # verify_aud: true, + # aud: Gitlab::CurrentSettings.iam_service_audience, + # verify_iat: true, + # leeway: clock_skew_seconds + # } + # end + + # TODO: implement strong_memoize_attr + def resolve_user(payload) + user_id = payload['sub'] + user = User.find(user_id) + + raise Gitlab::Auth::IamJwt::UserNotFoundError, 'User not found' unless user + raise Gitlab::Auth::IamJwt::UserBlockedError, 'User is blocked' if user.blocked? + raise Gitlab::Auth::IamJwt::UserInactiveError, 'User is not active' unless user.active? + + user end - def build_token(payload) - Gitlab::Auth::IamJwt::Token.new( - user_id: payload['sub'], - scopes: payload['scp'] || [], - jti: payload['jti'], - expires_at: Time.zone.at(payload['exp']), - issued_at: Time.zone.at(payload['iat']), - payload: payload - ) + # def build_token(payload) + # Gitlab::Auth::IamJwt::Token.new( + # user: user, + # scopes: scopes, + # jti: payload['jti'], + # expires_at: Time.zone.at(payload['exp']), + # issued_at: Time.zone.at(payload['iat']), + # payload: payload + # ) + # end + + def extract_scopes(payload) + scopes = payload['scp'] || [] + scopes.map(&:to_sym) end def jwks_client @@ -107,6 +141,17 @@ def clock_skew_seconds 30 end + def log_successful_authentication(user, payload) + Gitlab::AppLogger.info( + message: 'IAM JWT authentication successful', + user_id: user.id, + username: user.username, + token_jti: payload['jti'], + token_exp: Time.zone.at(payload['exp']), + scopes: payload['scp'] + ) + end + def error_disabled ServiceResponse.error(message: 'IAM JWT authentication is disabled', reason: :disabled) end diff --git a/config/feature_flags/development/iam_svc_oauth.yml b/config/feature_flags/development/iam_svc_oauth.yml index 6ea45f29f375a8..de39905e650af8 100644 --- a/config/feature_flags/development/iam_svc_oauth.yml +++ b/config/feature_flags/development/iam_svc_oauth.yml @@ -2,9 +2,9 @@ name: iam_svc_oauth description: Enable JWT authentication for tokens issued by external IAM service. When enabled, GitLab accepts RS256-signed JWTs from a configured IAM service as an alternative to OAuth tokens for API and Git authentication. -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/XXXXX +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/214565 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXX -milestone: '17.X' +milestone: '18.7' type: development group: group::authentication default_enabled: false diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 1605095a0b0e5e..9f09e8bfcf85f6 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -116,6 +116,7 @@ def omniauth_enabled? Gitlab.config.omniauth.enabled end + # rubocop:disable Metrics/CyclomaticComplexity -- Authentication chain requires multiple checks def find_for_git_client(login, password, project:, request:) raise "Must provide an IP for rate limiting" if request.ip.nil? @@ -130,6 +131,7 @@ def find_for_git_client(login, password, project:, request:) service_request_check(login, password, project) || build_access_token_check(login, password) || lfs_token_check(login, password, project, request) || + iam_jwt_token_check(password, project) || oauth_access_token_check(password) || personal_access_token_check(password, project) || deploy_token_check(login, password, project) || @@ -145,6 +147,7 @@ def find_for_git_client(login, password, project:, request:) # personal access token on failed auth attempts raise Gitlab::Auth::MissingPersonalAccessTokenError end + # rubocop:enable Metrics/CyclomaticComplexity # Find and return a user if the provided password is valid for various # authenticators (OAuth, LDAP, Local Database). @@ -278,6 +281,29 @@ def user_with_password_for_git(login, password, request: nil) Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities) end + # TODO: Consolidate with the rest of implementation + def iam_jwt_token_check(password, project) + return unless Gitlab::Auth::IamJwt.enabled? + return unless password&.start_with?('ey') + return unless Gitlab::Auth::IamJwt.iam_jwt?(password) + + result = ::Auth::IamJwtValidationService.new(token: password).execute + return unless result.success? + + token = result.payload[:token] + user = token.user + + return unless user.can_read_project? + + # return unless user.can?(:access_git) + + abilities = abilities_for_scopes(token.scopes) + + Gitlab::Auth::Result.new(user, project, :iam_jwt, abilities) + rescue Gitlab::Auth::IamJwt::Error + nil + end + def oauth_access_token_check(password) if password.present? token = OauthAccessToken.by_token(password) diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 849e56e0db3ea8..41d41b9f22b2a0 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -314,7 +314,7 @@ def access_token if try(:namespace_inheritable, :authentication) access_token_from_namespace_inheritable else - # The token can be a PAT or an OAuth (doorkeeper) token + # The token can be a IAM JWT, PAT or an OAuth (doorkeeper) token begin find_iam_jwt_access_token || find_oauth_access_token rescue UnauthorizedError diff --git a/lib/gitlab/auth/iam_jwt/jwks_client.rb b/lib/gitlab/auth/iam_jwt/jwks_client.rb index c6afa289a08d37..7d476d0a0017dc 100644 --- a/lib/gitlab/auth/iam_jwt/jwks_client.rb +++ b/lib/gitlab/auth/iam_jwt/jwks_client.rb @@ -72,6 +72,13 @@ def default_cache_ttl # (Gitlab::CurrentSettings.iam_service_jwks_cache_ttl || 3600).seconds 3600.seconds end + + # TODO: potential improvement to include issuer hash in cache key to prevent + # stale cache when configuration changes. + def cache_key + issuer_hash = Digest::SHA256.hexdigest(Gitlab::CurrentSettings.iam_service_issuer)[0..8] + "iam_jwt:jwks:#{issuer_hash}" + end end end end diff --git a/lib/gitlab/auth/iam_jwt/token.rb b/lib/gitlab/auth/iam_jwt/token.rb index 63a67ea898c588..b292c0989bc7a0 100644 --- a/lib/gitlab/auth/iam_jwt/token.rb +++ b/lib/gitlab/auth/iam_jwt/token.rb @@ -4,10 +4,10 @@ module Gitlab module Auth module IamJwt class Token - attr_reader :user_id, :scopes, :jti, :expires_at, :issued_at, :payload + attr_reader :user, :scopes, :jti, :expires_at, :issued_at, :payload - def initialize(user_id:, scopes:, jti:, expires_at:, issued_at:, payload:) - @user_id = user_id + def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) + @user = user # Or used_id? TODO: to check @scopes = Array(scopes).map(&:to_s) @jti = jti @expires_at = expires_at @@ -15,10 +15,6 @@ def initialize(user_id:, scopes:, jti:, expires_at:, issued_at:, payload:) @payload = payload end - def id - jti - end - def expired? # expires_at < Time.current @payload.blank? @@ -29,16 +25,12 @@ def revoked? @payload.blank? end - def user - @user ||= User.find_by_id(user_id) # -- Token must resolve its user - end - def reload self end def to_s - "IamJwt::Token(jti=#{jti})" + "IamJwt::Token(jti: #{jti}, user_id: #{user&.id})" end end end -- GitLab From 71779b68eeee47182f89bf6f2430bbdbcbc49369 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 3 Dec 2025 15:12:35 +0100 Subject: [PATCH 03/21] Small fixes to make the implementation work with IAM --- app/models/current.rb | 1 - app/services/auth/iam_jwt_validation_service.rb | 3 ++- lib/gitlab/auth/iam_jwt.rb | 4 +++- lib/gitlab/auth/iam_jwt/token.rb | 12 ++++++++++-- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/models/current.rb b/app/models/current.rb index cdca5579a759ef..1a922b8ac4f373 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -17,7 +17,6 @@ def message # watch background jobs need to reset on each job if using attribute :organization, :organization_assigned attribute :token_info - attribute :iam_jwt_scopes, :iam_jwt_token_id attribute :cells_claims_leases diff --git a/app/services/auth/iam_jwt_validation_service.rb b/app/services/auth/iam_jwt_validation_service.rb index 1c78e3a145a83e..b611977b050508 100644 --- a/app/services/auth/iam_jwt_validation_service.rb +++ b/app/services/auth/iam_jwt_validation_service.rb @@ -27,6 +27,7 @@ def execute scopes: scopes, jti: payload['jti'], expires_at: Time.zone.at(payload['exp']), + issued_at: Time.zone.at(payload['iat']), payload: payload ) }) @@ -82,7 +83,7 @@ def decode_options algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, jwks: jwks_client.fetch_keys, verify_iss: true, - iss: Gitlab::CurrentSettings.iam_service_issuer + iss: Gitlab::Auth::IamJwt.iam_service_issuer } end diff --git a/lib/gitlab/auth/iam_jwt.rb b/lib/gitlab/auth/iam_jwt.rb index 11ebac083e82fb..a2ba15282c1af1 100644 --- a/lib/gitlab/auth/iam_jwt.rb +++ b/lib/gitlab/auth/iam_jwt.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'gitlab/auth/auth_finders' + module Gitlab module Auth module IamJwt @@ -44,7 +46,7 @@ def iam_jwt?(token_string) def iam_service_issuer ## Gitlab::CurrentSettings.iam_service_issuer TODO:to implement - "issuer" + "http://localhost:8084" end def enabled? diff --git a/lib/gitlab/auth/iam_jwt/token.rb b/lib/gitlab/auth/iam_jwt/token.rb index b292c0989bc7a0..de74a226d8dfe6 100644 --- a/lib/gitlab/auth/iam_jwt/token.rb +++ b/lib/gitlab/auth/iam_jwt/token.rb @@ -17,18 +17,26 @@ def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) def expired? # expires_at < Time.current - @payload.blank? + payload.blank? end def revoked? # false # IAM JWTs are stateless - @payload.blank? + payload.blank? end def reload self end + def id + jti + end + + def has_attribute?(_attribute) + false + end + def to_s "IamJwt::Token(jti: #{jti}, user_id: #{user&.id})" end -- GitLab From 0fc20abb9a8abd96a9d171a8fd8517e53fa969d5 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 3 Dec 2025 17:21:49 +0100 Subject: [PATCH 04/21] Fix authorization for git actions --- lib/gitlab/auth.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 9f09e8bfcf85f6..df70413c49beb4 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -291,15 +291,14 @@ def iam_jwt_token_check(password, project) return unless result.success? token = result.payload[:token] - user = token.user - return unless user.can_read_project? + return unless can_read_project?(token.user, project) # return unless user.can?(:access_git) abilities = abilities_for_scopes(token.scopes) - Gitlab::Auth::Result.new(user, project, :iam_jwt, abilities) + Gitlab::Auth::Result.new(token.user, project, :iam_jwt, abilities) rescue Gitlab::Auth::IamJwt::Error nil end -- GitLab From c3489d2708bd3e1dedaac3bc6437718b1fa59341 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 3 Dec 2025 18:50:26 +0100 Subject: [PATCH 05/21] Introduce iam configuration --- config/gitlab.yml.example | 39 +++++++++++++++++++++++++++++++ config/initializers/1_settings.rb | 13 +++++++++++ lib/gitlab/auth/iam_jwt.rb | 11 +++++---- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 14219b24b5d6af..c9995424a0936b 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -1362,6 +1362,45 @@ production: &base # The client timeout in seconds for the KAS API (used by the GitLab backend) # client_timeout_seconds: 5 + ## IAM Service settings + iam_service: + # Enable IAM JWT authentication + # enabled: false + + # url: gdk.test:8084 + + # Expected JWT issuer (must match 'iss' claim in tokens) + # issuer: 'http://gdk.test:8084' + + # Expected JWT audience (must match 'aud' claim in tokens) + # Set to 'gitlab' or your GitLab instance identifier + # audience: 'gitlab' + + # JWKS endpoint path (relative to url) + # Default: '/.well-known/jwks.json' + # jwks_path: '/.well-known/jwks.json' + + # JWKS cache TTL in seconds (default: 3600 = 1 hour) + # jwks_cache_ttl: 3600 + + # HTTP timeout for JWKS fetch in seconds (default: 10) + # timeout: 10 + + # HTTP open timeout in seconds (default: 5) + # open_timeout: 5 + + # Clock skew tolerance in seconds for JWT validation (default: 30) + # clock_skew_seconds: 30 + + # Allowed JWT signing algorithms (default: ['RS256']) + # allowed_algorithms: ['RS256'] + + # Enable retry on JWKS fetch failures (default: true) + # retry_enabled: true + + # Maximum retry attempts for JWKS fetch (default: 3) + # max_retries: 3 + zoekt: # Files that contain username and password for basic auth for Zoekt # Default is '.gitlab_zoekt_username' and '.gitlab_zoekt_password' in Rails.root diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 5c735e86c0ae69..703a12c54ee8a3 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -1230,6 +1230,19 @@ Settings.gitlab_kas['client_timeout_seconds'] ||= 5 # Settings.gitlab_kas['external_k8s_proxy_url'] ||= 'grpc://localhost:8154' # NOTE: Do not set a default until all distributions have been updated with a correct value +# +# IAM Service +# +Settings['iam_service'] ||= {} +Settings.iam_service['enabled'] ||= false +Settings.iam_service['url'] ||= 'http://gdk.test:8084' +Settings.iam_service['issuer'] ||= Settings.iam_service['url'] +Settings.iam_service['audience'] ||= 'gitlab' +Settings.iam_service['jwks_cache_ttl'] ||= 3600 +Settings.iam_service['timeout'] ||= 10 +Settings.iam_service['open_timeout'] ||= 5 +Settings.iam_service['clock_skew_seconds'] ||= 30 + # # Gitlab Secrets Manager Openbao Integration # diff --git a/lib/gitlab/auth/iam_jwt.rb b/lib/gitlab/auth/iam_jwt.rb index a2ba15282c1af1..6609c562af5c98 100644 --- a/lib/gitlab/auth/iam_jwt.rb +++ b/lib/gitlab/auth/iam_jwt.rb @@ -44,14 +44,17 @@ def iam_jwt?(token_string) false end + def iam_service_url + Gitlab.config.iam_service.url + end + def iam_service_issuer - ## Gitlab::CurrentSettings.iam_service_issuer TODO:to implement - "http://localhost:8084" + Gitlab.config.iam_service.issuer end def enabled? - Feature.enabled?(:iam_svc_oauth, :instance) ## && - ## Gitlab::CurrentSettings.iam_service_enabled TODO:to implement + Feature.enabled?(:iam_svc_oauth, :instance) && + Gitlab.config.iam_service.enabled end end end -- GitLab From 248e555ffb9b420918500ff0400fe324e48feeea Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Thu, 4 Dec 2025 12:35:25 +0100 Subject: [PATCH 06/21] Temp: fix --- lib/gitlab/auth/iam_jwt/jwks_client.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/auth/iam_jwt/jwks_client.rb b/lib/gitlab/auth/iam_jwt/jwks_client.rb index 7d476d0a0017dc..37ef86b5a5a0f7 100644 --- a/lib/gitlab/auth/iam_jwt/jwks_client.rb +++ b/lib/gitlab/auth/iam_jwt/jwks_client.rb @@ -55,8 +55,9 @@ def fetch_from_service end def default_endpoint - # url = Gitlab::CurrentSettings.iam_service_url - url = "http://localhost:8084" + url = Gitlab.config.iam_service.url + raise Gitlab::Auth::IamJwt::ConfigurationError, 'IAM service URL is not configured' if url.nil? + URI.join(url, JWKS_PATH).to_s end -- GitLab From cc426e3a3079d72150bdcc2b6a037f165cd13d3a Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 8 Dec 2025 15:23:15 +0100 Subject: [PATCH 07/21] Refactor IAM JWT to use nested module structure Restructure IAM JWT authentication from Gitlab::Auth::IamJwt to Gitlab::Auth::Iam::Jwt for better organization and extensibility. Changes: - Reorganize modules into iam/jwt/ directory structure - Add parent Iam modules for future IAM-related features - Update all references throughout codebase --- app/services/auth/iam.rb | 7 + .../auth/iam/jwt_validation_service.rb | 166 +++++++++++++++++ .../auth/iam_jwt_validation_service.rb | 168 ------------------ lib/gitlab/auth.rb | 8 +- lib/gitlab/auth/auth_finders.rb | 8 +- lib/gitlab/auth/iam.rb | 9 + lib/gitlab/auth/iam/jwt.rb | 67 +++++++ lib/gitlab/auth/iam/jwt/jwks_client.rb | 89 ++++++++++ lib/gitlab/auth/iam/jwt/token.rb | 48 +++++ lib/gitlab/auth/iam_jwt.rb | 62 ------- lib/gitlab/auth/iam_jwt/jwks_client.rb | 86 --------- lib/gitlab/auth/iam_jwt/token.rb | 46 ----- 12 files changed, 394 insertions(+), 370 deletions(-) create mode 100644 app/services/auth/iam.rb create mode 100644 app/services/auth/iam/jwt_validation_service.rb delete mode 100644 app/services/auth/iam_jwt_validation_service.rb create mode 100644 lib/gitlab/auth/iam.rb create mode 100644 lib/gitlab/auth/iam/jwt.rb create mode 100644 lib/gitlab/auth/iam/jwt/jwks_client.rb create mode 100644 lib/gitlab/auth/iam/jwt/token.rb delete mode 100644 lib/gitlab/auth/iam_jwt.rb delete mode 100644 lib/gitlab/auth/iam_jwt/jwks_client.rb delete mode 100644 lib/gitlab/auth/iam_jwt/token.rb diff --git a/app/services/auth/iam.rb b/app/services/auth/iam.rb new file mode 100644 index 00000000000000..284a40a32b1f59 --- /dev/null +++ b/app/services/auth/iam.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern + module Iam + # Parent module for IAM-related services + end +end diff --git a/app/services/auth/iam/jwt_validation_service.rb b/app/services/auth/iam/jwt_validation_service.rb new file mode 100644 index 00000000000000..1708c4baf2c5e3 --- /dev/null +++ b/app/services/auth/iam/jwt_validation_service.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern + module Iam + class JwtValidationService + include Gitlab::Utils::StrongMemoize + + def initialize(token:) + @token_string = token + @retry_attempted = false + end + + def execute + return error_disabled unless Gitlab::Auth::Iam::Jwt.enabled? + return error_invalid_format unless valid_jwt_format? + + payload = decode_and_validate! + user = resolve_user(payload) + scopes = extract_scopes(payload) + + log_successful_authentication(user, payload) + + ServiceResponse.success(payload: { + token: Gitlab::Auth::Iam::Jwt::Token.new( + user: user, + scopes: scopes, + jti: payload['jti'], + expires_at: Time.zone.at(payload['exp']), + issued_at: Time.zone.at(payload['iat']), + payload: payload + ) + }) + rescue Gitlab::Auth::Iam::Jwt::JwksFetchError => e + error_response(e.message, :service_unavailable) + rescue Gitlab::Auth::Iam::Jwt::Error => e + error_response(e.message, :invalid_token) + end + + private + + def valid_jwt_format? + @token_string.is_a?(String) && + @token_string.start_with?('ey') && + @token_string.count('.') == 2 + end + + def decode_and_validate! + payload, _ = JWT.decode( + @token_string, + nil, + true, + decode_options + ) + + payload + rescue JWT::ExpiredSignature + raise Gitlab::Auth::Iam::Jwt::TokenExpiredError, 'Token has expired' + rescue JWT::InvalidIssuerError + raise Gitlab::Auth::Iam::Jwt::InvalidIssuerError, 'Invalid token issuer' + rescue JWT::InvalidAudError + raise Gitlab::Auth::Iam::Jwt::InvalidAudienceError, 'Invalid token audience' + rescue JWT::VerificationError => e + handle_verification_error(e) + rescue JWT::DecodeError => e + raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format: #{e.message}" + end + + def handle_verification_error(error) + if @retry_attempted + raise Gitlab::Auth::Iam::Jwt::InvalidSignatureError, + "Signature verification failed: #{error.message}" + end + + @retry_attempted = true + jwks_client.clear_cache + decode_and_validate! + end + + def decode_options + { + algorithms: Gitlab::Auth::Iam::Jwt::ALLOWED_ALGORITHMS, + jwks: jwks_client.fetch_keys, + verify_iss: true, + iss: Gitlab::Auth::Iam::Jwt.iam_service_issuer + } + end + + # TODO: Improve decode_options, delegate jwt library to validate required claims + # def decode_options + # { + # algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, + # jwks: jwks_client.fetch_keys, + # required_claims: %w[sub jti exp iat], + # verify_iss: true, + # iss: Gitlab::CurrentSettings.iam_service_issuer, + # verify_aud: true, + # aud: Gitlab::CurrentSettings.iam_service_audience, + # verify_iat: true, + # leeway: clock_skew_seconds + # } + # end + + # TODO: implement strong_memoize_attr + def resolve_user(payload) + user_id = payload['sub'] + user = User.find(user_id) + + raise Gitlab::Auth::Iam::Jwt::UserNotFoundError, 'User not found' unless user + raise Gitlab::Auth::Iam::Jwt::UserBlockedError, 'User is blocked' if user.blocked? + raise Gitlab::Auth::Iam::Jwt::UserInactiveError, 'User is not active' unless user.active? + + user + end + + # def build_token(payload) + # Gitlab::Auth::IamJwt::Token.new( + # user: user, + # scopes: scopes, + # jti: payload['jti'], + # expires_at: Time.zone.at(payload['exp']), + # issued_at: Time.zone.at(payload['iat']), + # payload: payload + # ) + # end + + def extract_scopes(payload) + scopes = payload['scp'] || [] + scopes.map(&:to_sym) + end + + def jwks_client + Gitlab::Auth::Iam::Jwt::JwksClient.new + end + strong_memoize_attr :jwks_client + + def clock_skew_seconds + # Note: suggested by Duo + # TODO: Validate this + 30 + end + + def log_successful_authentication(user, payload) + Gitlab::AppLogger.info( + message: 'IAM JWT authentication successful', + user_id: user.id, + username: user.username, + token_jti: payload['jti'], + token_exp: Time.zone.at(payload['exp']), + scopes: payload['scp'] + ) + end + + def error_disabled + ServiceResponse.error(message: 'IAM JWT authentication is disabled', reason: :disabled) + end + + def error_invalid_format + ServiceResponse.error(message: 'Invalid token format', reason: :invalid_format) + end + + def error_response(message, reason) + ServiceResponse.error(message: message, reason: reason) + end + end + end +end diff --git a/app/services/auth/iam_jwt_validation_service.rb b/app/services/auth/iam_jwt_validation_service.rb deleted file mode 100644 index b611977b050508..00000000000000 --- a/app/services/auth/iam_jwt_validation_service.rb +++ /dev/null @@ -1,168 +0,0 @@ -# frozen_string_literal: true - -module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern - class IamJwtValidationService - include Gitlab::Utils::StrongMemoize - - def initialize(token:) - @token_string = token - @retry_attempted = false - end - - def execute - return error_disabled unless Gitlab::Auth::IamJwt.enabled? - return error_invalid_format unless valid_jwt_format? - - payload = decode_and_validate! - user = resolve_user(payload) - scopes = extract_scopes(payload) - - log_successful_authentication(user, payload) - - # token_object = build_token(payload) - - ServiceResponse.success(payload: { - token: Gitlab::Auth::IamJwt::Token.new( - user: user, - scopes: scopes, - jti: payload['jti'], - expires_at: Time.zone.at(payload['exp']), - issued_at: Time.zone.at(payload['iat']), - payload: payload - ) - }) - rescue Gitlab::Auth::IamJwt::JwksFetchError => e - error_response(e.message, :service_unavailable) - rescue Gitlab::Auth::IamJwt::Error => e - error_response(e.message, :invalid_token) - end - # TODO: evaluate `rescue Gitlab::Auth::IamJwt::TokenExpiredError` implementation - - private - - def valid_jwt_format? - @token_string.is_a?(String) && - @token_string.start_with?('ey') && - @token_string.count('.') == 2 - end - - def decode_and_validate! - payload, _ = JWT.decode( - @token_string, - nil, - true, - decode_options - ) - - payload - rescue JWT::ExpiredSignature - raise Gitlab::Auth::IamJwt::TokenExpiredError, 'Token has expired' - rescue JWT::InvalidIssuerError - raise Gitlab::Auth::IamJwt::InvalidIssuerError, 'Invalid token issuer' - rescue JWT::InvalidAudError - raise Gitlab::Auth::IamJwt::InvalidAudienceError, 'Invalid token audience' - rescue JWT::VerificationError => e - handle_verification_error(e) - rescue JWT::DecodeError => e - raise Gitlab::Auth::IamJwt::MalformedTokenError, "Invalid token format: #{e.message}" - end - - def handle_verification_error(error) - if @retry_attempted - raise Gitlab::Auth::IamJwt::InvalidSignatureError, - "Signature verification failed: #{error.message}" - end - - @retry_attempted = true - jwks_client.clear_cache - decode_and_validate! - end - - def decode_options - { - algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, - jwks: jwks_client.fetch_keys, - verify_iss: true, - iss: Gitlab::Auth::IamJwt.iam_service_issuer - } - end - - # TODO: Improve decode_options, delegate jwt library to validate required claims - # def decode_options - # { - # algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, - # jwks: jwks_client.fetch_keys, - # required_claims: %w[sub jti exp iat], - # verify_iss: true, - # iss: Gitlab::CurrentSettings.iam_service_issuer, - # verify_aud: true, - # aud: Gitlab::CurrentSettings.iam_service_audience, - # verify_iat: true, - # leeway: clock_skew_seconds - # } - # end - - # TODO: implement strong_memoize_attr - def resolve_user(payload) - user_id = payload['sub'] - user = User.find(user_id) - - raise Gitlab::Auth::IamJwt::UserNotFoundError, 'User not found' unless user - raise Gitlab::Auth::IamJwt::UserBlockedError, 'User is blocked' if user.blocked? - raise Gitlab::Auth::IamJwt::UserInactiveError, 'User is not active' unless user.active? - - user - end - - # def build_token(payload) - # Gitlab::Auth::IamJwt::Token.new( - # user: user, - # scopes: scopes, - # jti: payload['jti'], - # expires_at: Time.zone.at(payload['exp']), - # issued_at: Time.zone.at(payload['iat']), - # payload: payload - # ) - # end - - def extract_scopes(payload) - scopes = payload['scp'] || [] - scopes.map(&:to_sym) - end - - def jwks_client - Gitlab::Auth::IamJwt::JwksClient.new - end - strong_memoize_attr :jwks_client - - def clock_skew_seconds - # Note: suggested by Duo - # TODO: Validate this - # Gitlab::CurrentSettings.iam_service_clock_skew_seconds || 30 - 30 - end - - def log_successful_authentication(user, payload) - Gitlab::AppLogger.info( - message: 'IAM JWT authentication successful', - user_id: user.id, - username: user.username, - token_jti: payload['jti'], - token_exp: Time.zone.at(payload['exp']), - scopes: payload['scp'] - ) - end - - def error_disabled - ServiceResponse.error(message: 'IAM JWT authentication is disabled', reason: :disabled) - end - - def error_invalid_format - ServiceResponse.error(message: 'Invalid token format', reason: :invalid_format) - end - - def error_response(message, reason) - ServiceResponse.error(message: message, reason: reason) - end - end -end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index df70413c49beb4..2dbc3540ad1105 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -283,11 +283,11 @@ def user_with_password_for_git(login, password, request: nil) # TODO: Consolidate with the rest of implementation def iam_jwt_token_check(password, project) - return unless Gitlab::Auth::IamJwt.enabled? + return unless Gitlab::Auth::Iam::Jwt.enabled? return unless password&.start_with?('ey') - return unless Gitlab::Auth::IamJwt.iam_jwt?(password) + return unless Gitlab::Auth::Iam::Jwt.iam_jwt?(password) - result = ::Auth::IamJwtValidationService.new(token: password).execute + result = ::Auth::Iam::JwtValidationService.new(token: password).execute return unless result.success? token = result.payload[:token] @@ -299,7 +299,7 @@ def iam_jwt_token_check(password, project) abilities = abilities_for_scopes(token.scopes) Gitlab::Auth::Result.new(token.user, project, :iam_jwt, abilities) - rescue Gitlab::Auth::IamJwt::Error + rescue Gitlab::Auth::Iam::Jwt::Error nil end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 41d41b9f22b2a0..ccd0cf8a863aee 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -327,17 +327,17 @@ def access_token end def find_iam_jwt_access_token - return unless Gitlab::Auth::IamJwt.enabled? + return unless Gitlab::Auth::Iam::Jwt.enabled? self.current_token = parsed_oauth_token return unless current_token - return unless Gitlab::Auth::IamJwt.iam_jwt?(current_token) + return unless Gitlab::Auth::Iam::Jwt.iam_jwt?(current_token) - result = ::Auth::IamJwtValidationService.new(token: current_token).execute + result = ::Auth::Iam::JwtValidationService.new(token: current_token).execute return unless result.success? result.payload[:token] - rescue Gitlab::Auth::IamJwt::Error + rescue Gitlab::Auth::Iam::Jwt::Error nil # Silently fail, let other auth methods try # TODO: this can be handled better end diff --git a/lib/gitlab/auth/iam.rb b/lib/gitlab/auth/iam.rb new file mode 100644 index 00000000000000..432be42585681c --- /dev/null +++ b/lib/gitlab/auth/iam.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module Iam + # Parent module for IAM-related authentication + end + end +end diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb new file mode 100644 index 00000000000000..4c851be4f054bc --- /dev/null +++ b/lib/gitlab/auth/iam/jwt.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'gitlab/auth/auth_finders' + +module Gitlab + module Auth + module Iam + module Jwt + # Base error inherits from existing auth error hierarchy + Error = Class.new(Gitlab::Auth::AuthenticationError) + + # JWT validation errors (401) + TokenExpiredError = Class.new(Error) + InvalidSignatureError = Class.new(Error) + InvalidIssuerError = Class.new(Error) + InvalidAudienceError = Class.new(Error) + MalformedTokenError = Class.new(Error) + MissingClaimError = Class.new(Error) + + # JWKS errors (503) + JwksFetchError = Class.new(Error) + JwksKeyNotFoundError = Class.new(Error) + + # User resolution errors (401) + UserNotFoundError = Class.new(Error) + UserBlockedError = Class.new(Error) + UserInactiveError = Class.new(Error) + + # Scope errors (403) + InsufficientScopeError = Class.new(Error) + + # Configuration errors + ConfigurationError = Class.new(Error) + + # TODO: Implement more specific errors + + ALLOWED_ALGORITHMS = ['RS256'].freeze + + class << self + def iam_jwt?(token_string) + return false unless token_string.is_a?(String) + return false unless token_string.start_with?('ey') + return false unless token_string.count('.') == 2 + + payload = JWT.decode(token_string, nil, false).first + payload['iss'] == iam_service_issuer + rescue JWT::DecodeError + false + end + + def iam_service_url + Gitlab.config.iam_service.url + end + + def iam_service_issuer + Gitlab.config.iam_service.issuer + end + + def enabled? + Feature.enabled?(:iam_svc_oauth, :instance) && + Gitlab.config.iam_service.enabled + end + end + end + end + end +end diff --git a/lib/gitlab/auth/iam/jwt/jwks_client.rb b/lib/gitlab/auth/iam/jwt/jwks_client.rb new file mode 100644 index 00000000000000..8c0195ab23ef1e --- /dev/null +++ b/lib/gitlab/auth/iam/jwt/jwks_client.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module Iam + module Jwt + class JwksClient + include Gitlab::Utils::StrongMemoize + + CACHE_KEY = 'iam_jwt:jwks' + JWKS_PATH = '/.well-known/jwks.json' + + def initialize( + endpoint: nil, + cache: Rails.cache, + timeout: nil + ) + @endpoint = endpoint || default_endpoint + @cache = cache + @timeout = timeout || default_timeout + end + + def fetch_keys + @cache.fetch(CACHE_KEY, expires_in: default_cache_ttl) do + fetch_from_service + end + end + + def refresh_keys + @cache.delete(CACHE_KEY) + fetch_keys + end + + def clear_cache + @cache.delete(CACHE_KEY) + end + + private + + def fetch_from_service + response = Gitlab::HTTP.get( + @endpoint, + timeout: @timeout, + open_timeout: @open_timeout, + headers: { + 'User-Agent' => 'GitLab-Rails', + 'Accept' => 'application/json' + } + ) + + unless response.success? + raise Gitlab::Auth::Iam::Jwt::JwksFetchError, + "Failed to fetch JWKS: HTTP #{response.code}" + end + + JWT::JWK::Set.new(response.parsed_response) + rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e + Gitlab::ErrorTracking.track_exception(e) + raise Gitlab::Auth::Iam::Jwt::JwksFetchError, "Cannot connect to IAM service: #{e.message}" + rescue JSON::ParserError => e + raise Gitlab::Auth::Iam::Jwt::JwksFetchError, "Invalid JWKS response: #{e.message}" + end + + def default_endpoint + url = Gitlab.config.iam_service.url + raise Gitlab::Auth::Iam::Jwt::ConfigurationError, 'IAM service URL is not configured' if url.nil? + + URI.join(url, JWKS_PATH).to_s + end + + def default_timeout + 10 + end + + def default_cache_ttl + 3600.seconds + end + + # TODO: potential improvement to include issuer hash in cache key to prevent + # stale cache when configuration changes + def cache_key + issuer_hash = Digest::SHA256.hexdigest(Gitlab::CurrentSettings.iam_service_issuer)[0..8] + "iam_jwt:jwks:#{issuer_hash}" + end + end + end + end + end +end diff --git a/lib/gitlab/auth/iam/jwt/token.rb b/lib/gitlab/auth/iam/jwt/token.rb new file mode 100644 index 00000000000000..717c262ef7c163 --- /dev/null +++ b/lib/gitlab/auth/iam/jwt/token.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module Iam + module Jwt + class Token + attr_reader :user, :scopes, :jti, :expires_at, :issued_at, :payload + + def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) + @user = user + @scopes = Array(scopes).map(&:to_s) + @jti = jti + @expires_at = expires_at + @issued_at = issued_at + @payload = payload + end + + def expired? + # expires_at < Time.current + payload.blank? + end + + def revoked? + # false # IAM JWTs are stateless + payload.blank? + end + + def reload + self + end + + def id + jti + end + + def has_attribute?(_attribute) + false + end + + def to_s + "Iam::Jwt::Token(jti: #{jti}, user_id: #{user&.id})" + end + end + end + end + end +end diff --git a/lib/gitlab/auth/iam_jwt.rb b/lib/gitlab/auth/iam_jwt.rb deleted file mode 100644 index 6609c562af5c98..00000000000000 --- a/lib/gitlab/auth/iam_jwt.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -require 'gitlab/auth/auth_finders' - -module Gitlab - module Auth - module IamJwt - # Base error inherits from existing auth error hierarchy - Error = Class.new(Gitlab::Auth::AuthenticationError) - - # JWT validation errors (401) - TokenExpiredError = Class.new(Error) - InvalidSignatureError = Class.new(Error) - InvalidIssuerError = Class.new(Error) - InvalidAudienceError = Class.new(Error) - MalformedTokenError = Class.new(Error) - MissingClaimError = Class.new(Error) - - # JWKS errors (503) - JwksFetchError = Class.new(Error) - JwksKeyNotFoundError = Class.new(Error) - - # User resolution errors (401) - UserNotFoundError = Class.new(Error) - UserBlockedError = Class.new(Error) - UserInactiveError = Class.new(Error) - - # Scope errors (403) - InsufficientScopeError = Class.new(Error) - - # TODO: Implement more specific errors - - ALLOWED_ALGORITHMS = ['RS256'].freeze - - class << self - def iam_jwt?(token_string) - return false unless token_string.is_a?(String) - return false unless token_string.start_with?('ey') - return false unless token_string.count('.') == 2 - - payload = JWT.decode(token_string, nil, false).first - payload['iss'] == iam_service_issuer # Open question: would it break in cells? - rescue JWT::DecodeError - false - end - - def iam_service_url - Gitlab.config.iam_service.url - end - - def iam_service_issuer - Gitlab.config.iam_service.issuer - end - - def enabled? - Feature.enabled?(:iam_svc_oauth, :instance) && - Gitlab.config.iam_service.enabled - end - end - end - end -end diff --git a/lib/gitlab/auth/iam_jwt/jwks_client.rb b/lib/gitlab/auth/iam_jwt/jwks_client.rb deleted file mode 100644 index 37ef86b5a5a0f7..00000000000000 --- a/lib/gitlab/auth/iam_jwt/jwks_client.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Auth - module IamJwt - class JwksClient - # TODO: improve with include Gitlab::Utils::StrongMemoize - - CACHE_KEY = 'iam_jwt:jwks' - JWKS_PATH = '/.well-known/jwks.json' - - # TODO: open_timeout and cache_ttl overengineered? - def initialize( - endpoint: nil, - cache: Rails.cache, - timeout: nil - ) - @endpoint = endpoint || default_endpoint - @cache = cache - @timeout = timeout || default_timeout - end - - def fetch_keys - @cache.fetch(CACHE_KEY, expires_in: default_cache_ttl) do - fetch_from_service - end - end - - def refresh_keys - @cache.delete(CACHE_KEY) - fetch_keys - end - - private - - def fetch_from_service - response = Gitlab::HTTP.get( - @endpoint, - timeout: @timeout, - open_timeout: @open_timeout, - headers: { - 'User-Agent' => 'GitLab-Rails', - 'Accept' => 'application/json' - } - ) - - raise JwksFetchError, "Failed to fetch JWKS: HTTP #{response.code}" unless response.success? - - JWT::JWK::Set.new(response.parsed_response) - rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e - Gitlab::ErrorTracking.track_exception(e) - raise JwksFetchError, "Cannot connect to IAM service: #{e.message}" - rescue JSON::ParserError => e - raise JwksFetchError, "Invalid JWKS response: #{e.message}" - end - - def default_endpoint - url = Gitlab.config.iam_service.url - raise Gitlab::Auth::IamJwt::ConfigurationError, 'IAM service URL is not configured' if url.nil? - - URI.join(url, JWKS_PATH).to_s - end - - def default_timeout - # TODO: Should it be configurable? - # Gitlab::CurrentSettings.iam_service_timeout || 10 - 10 - end - - def default_cache_ttl - # TODO: Should it be configurable? - - # (Gitlab::CurrentSettings.iam_service_jwks_cache_ttl || 3600).seconds - 3600.seconds - end - - # TODO: potential improvement to include issuer hash in cache key to prevent - # stale cache when configuration changes. - def cache_key - issuer_hash = Digest::SHA256.hexdigest(Gitlab::CurrentSettings.iam_service_issuer)[0..8] - "iam_jwt:jwks:#{issuer_hash}" - end - end - end - end -end diff --git a/lib/gitlab/auth/iam_jwt/token.rb b/lib/gitlab/auth/iam_jwt/token.rb deleted file mode 100644 index de74a226d8dfe6..00000000000000 --- a/lib/gitlab/auth/iam_jwt/token.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Auth - module IamJwt - class Token - attr_reader :user, :scopes, :jti, :expires_at, :issued_at, :payload - - def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) - @user = user # Or used_id? TODO: to check - @scopes = Array(scopes).map(&:to_s) - @jti = jti - @expires_at = expires_at - @issued_at = issued_at - @payload = payload - end - - def expired? - # expires_at < Time.current - payload.blank? - end - - def revoked? - # false # IAM JWTs are stateless - payload.blank? - end - - def reload - self - end - - def id - jti - end - - def has_attribute?(_attribute) - false - end - - def to_s - "IamJwt::Token(jti: #{jti}, user_id: #{user&.id})" - end - end - end - end -end -- GitLab From 08ff43f1652f6a6d55cb4b8a9e5f3f7759e78ea1 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 8 Dec 2025 15:59:07 +0100 Subject: [PATCH 08/21] Rename JwtValidationService to JwtAuthenticationService Rename Auth::Iam::JwtValidationService to JwtAuthenticationService and related refactoring. --- ..._validation_service.rb => jwt_authentication_service.rb} | 2 +- lib/gitlab/auth.rb | 4 ++-- lib/gitlab/auth/auth_finders.rb | 4 ++-- lib/gitlab/auth/iam/jwt.rb | 6 +++--- lib/gitlab/auth/iam/jwt/jwks_client.rb | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) rename app/services/auth/iam/{jwt_validation_service.rb => jwt_authentication_service.rb} (99%) diff --git a/app/services/auth/iam/jwt_validation_service.rb b/app/services/auth/iam/jwt_authentication_service.rb similarity index 99% rename from app/services/auth/iam/jwt_validation_service.rb rename to app/services/auth/iam/jwt_authentication_service.rb index 1708c4baf2c5e3..d5c528b69522da 100644 --- a/app/services/auth/iam/jwt_validation_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -2,7 +2,7 @@ module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern module Iam - class JwtValidationService + class JwtAuthenticationService include Gitlab::Utils::StrongMemoize def initialize(token:) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 2dbc3540ad1105..f031fb70edc62b 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -285,9 +285,9 @@ def user_with_password_for_git(login, password, request: nil) def iam_jwt_token_check(password, project) return unless Gitlab::Auth::Iam::Jwt.enabled? return unless password&.start_with?('ey') - return unless Gitlab::Auth::Iam::Jwt.iam_jwt?(password) + return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(password) - result = ::Auth::Iam::JwtValidationService.new(token: password).execute + result = ::Auth::Iam::JwtAuthenticationService.new(token: password).execute return unless result.success? token = result.payload[:token] diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index ccd0cf8a863aee..8fb0633f1cbe00 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -331,9 +331,9 @@ def find_iam_jwt_access_token self.current_token = parsed_oauth_token return unless current_token - return unless Gitlab::Auth::Iam::Jwt.iam_jwt?(current_token) + return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(current_token) - result = ::Auth::Iam::JwtValidationService.new(token: current_token).execute + result = ::Auth::Iam::JwtAuthenticationService.new(token: current_token).execute return unless result.success? result.payload[:token] diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index 4c851be4f054bc..0bd7019e6615a6 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -18,8 +18,8 @@ module Jwt MissingClaimError = Class.new(Error) # JWKS errors (503) - JwksFetchError = Class.new(Error) - JwksKeyNotFoundError = Class.new(Error) + JwksFetchFailedError = Class.new(Error) + JwksKeyMissingError = Class.new(Error) # User resolution errors (401) UserNotFoundError = Class.new(Error) @@ -37,7 +37,7 @@ module Jwt ALLOWED_ALGORITHMS = ['RS256'].freeze class << self - def iam_jwt?(token_string) + def issued_by_iam_service?(token_string) return false unless token_string.is_a?(String) return false unless token_string.start_with?('ey') return false unless token_string.count('.') == 2 diff --git a/lib/gitlab/auth/iam/jwt/jwks_client.rb b/lib/gitlab/auth/iam/jwt/jwks_client.rb index 8c0195ab23ef1e..53d29a301ed7da 100644 --- a/lib/gitlab/auth/iam/jwt/jwks_client.rb +++ b/lib/gitlab/auth/iam/jwt/jwks_client.rb @@ -22,12 +22,12 @@ def initialize( def fetch_keys @cache.fetch(CACHE_KEY, expires_in: default_cache_ttl) do - fetch_from_service + fetch_keys_from_service end end def refresh_keys - @cache.delete(CACHE_KEY) + clear_cache fetch_keys end @@ -37,7 +37,7 @@ def clear_cache private - def fetch_from_service + def fetch_keys_from_service response = Gitlab::HTTP.get( @endpoint, timeout: @timeout, -- GitLab From 039c803002b789b3bc834420de568cf45805a498 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 8 Dec 2025 18:03:03 +0100 Subject: [PATCH 09/21] Optimize JWT validation and improve error handling - Add valid_jwt_format? helper to eliminate double JWT decode - Implement proper error logging for IAM JWT authentication failures - Add user state validation in iam_jwt_token_check - Add commented code to handle project bots/service accounts - Fix JwksFetchError naming inconsistency across codebase --- .../auth/iam/jwt_authentication_service.rb | 10 ++----- lib/gitlab/auth.rb | 16 +++++++---- lib/gitlab/auth/auth_finders.rb | 28 ++++++++++++++++--- lib/gitlab/auth/iam/jwt.rb | 12 ++++++-- lib/gitlab/auth/iam/jwt/jwks_client.rb | 6 ++-- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index d5c528b69522da..9da386b826d691 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -12,7 +12,7 @@ def initialize(token:) def execute return error_disabled unless Gitlab::Auth::Iam::Jwt.enabled? - return error_invalid_format unless valid_jwt_format? + return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(@token_string) payload = decode_and_validate! user = resolve_user(payload) @@ -30,7 +30,7 @@ def execute payload: payload ) }) - rescue Gitlab::Auth::Iam::Jwt::JwksFetchError => e + rescue Gitlab::Auth::Iam::Jwt::JwksFetchFailedError => e error_response(e.message, :service_unavailable) rescue Gitlab::Auth::Iam::Jwt::Error => e error_response(e.message, :invalid_token) @@ -38,12 +38,6 @@ def execute private - def valid_jwt_format? - @token_string.is_a?(String) && - @token_string.start_with?('ey') && - @token_string.count('.') == 2 - end - def decode_and_validate! payload, _ = JWT.decode( @token_string, diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index f031fb70edc62b..e76414ec3d22dc 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -284,21 +284,27 @@ def user_with_password_for_git(login, password, request: nil) # TODO: Consolidate with the rest of implementation def iam_jwt_token_check(password, project) return unless Gitlab::Auth::Iam::Jwt.enabled? - return unless password&.start_with?('ey') - return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(password) + return unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(password) result = ::Auth::Iam::JwtAuthenticationService.new(token: password).execute return unless result.success? token = result.payload[:token] + user = token.user - return unless can_read_project?(token.user, project) + # Verify user can authenticate (follows oauth_access_token_check pattern) + return unless user.can_log_in_with_non_expired_password? || valid_composite_identity?(user) - # return unless user.can?(:access_git) + # Open question: Should we handle project bots/service accounts here? + # Following personal_access_token_check pattern, we would check: + # if project && (user.project_bot? || user.service_account?) + # return unless can_read_project?(user, project) + # end + # However, IAM service may not issue tokens for bots yet. To be discussed. abilities = abilities_for_scopes(token.scopes) - Gitlab::Auth::Result.new(token.user, project, :iam_jwt, abilities) + Gitlab::Auth::Result.new(user, project, :iam_jwt, abilities) rescue Gitlab::Auth::Iam::Jwt::Error nil end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 8fb0633f1cbe00..bd89f4deedf71f 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -331,15 +331,35 @@ def find_iam_jwt_access_token self.current_token = parsed_oauth_token return unless current_token - return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(current_token) + return unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(current_token) result = ::Auth::Iam::JwtAuthenticationService.new(token: current_token).execute return unless result.success? result.payload[:token] - rescue Gitlab::Auth::Iam::Jwt::Error - nil # Silently fail, let other auth methods try - # TODO: this can be handled better + # TODO: to discuss, might be too verbose + rescue Gitlab::Auth::Iam::Jwt::JwksFetchFailedError => e + Gitlab::ErrorTracking.track_exception(e) + Gitlab::AuthLogger.warn( + message: 'IAM JWT authentication failed: JWKS fetch error', + error_class: e.class.name, + error_message: e.message + ) + nil + rescue Gitlab::Auth::Iam::Jwt::UserBlockedError, Gitlab::Auth::Iam::Jwt::UserInactiveError => e + Gitlab::AuthLogger.warn( + message: 'IAM JWT authentication failed due to user status', + error_class: e.class.name, + error_message: e.message + ) + nil + rescue Gitlab::Auth::Iam::Jwt::Error => e + Gitlab::AuthLogger.info( + message: 'IAM JWT authentication failed', + error_class: e.class.name, + error_message: e.message + ) + nil end def find_personal_access_token diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index 0bd7019e6615a6..75f7832188d2a7 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -37,10 +37,16 @@ module Jwt ALLOWED_ALGORITHMS = ['RS256'].freeze class << self + # Lightweight format check - does NOT decode JWT + # Used for fast rejection of non-JWT tokens + def valid_jwt_format?(token_string) + token_string.is_a?(String) && + token_string.start_with?('ey') && + token_string.count('.') == 2 + end + def issued_by_iam_service?(token_string) - return false unless token_string.is_a?(String) - return false unless token_string.start_with?('ey') - return false unless token_string.count('.') == 2 + return false unless valid_jwt_format?(token_string) payload = JWT.decode(token_string, nil, false).first payload['iss'] == iam_service_issuer diff --git a/lib/gitlab/auth/iam/jwt/jwks_client.rb b/lib/gitlab/auth/iam/jwt/jwks_client.rb index 53d29a301ed7da..2476c537155203 100644 --- a/lib/gitlab/auth/iam/jwt/jwks_client.rb +++ b/lib/gitlab/auth/iam/jwt/jwks_client.rb @@ -49,16 +49,16 @@ def fetch_keys_from_service ) unless response.success? - raise Gitlab::Auth::Iam::Jwt::JwksFetchError, + raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Failed to fetch JWKS: HTTP #{response.code}" end JWT::JWK::Set.new(response.parsed_response) rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e Gitlab::ErrorTracking.track_exception(e) - raise Gitlab::Auth::Iam::Jwt::JwksFetchError, "Cannot connect to IAM service: #{e.message}" + raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Cannot connect to IAM service: #{e.message}" rescue JSON::ParserError => e - raise Gitlab::Auth::Iam::Jwt::JwksFetchError, "Invalid JWKS response: #{e.message}" + raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Invalid JWKS response: #{e.message}" end def default_endpoint -- GitLab From b71fcc61f2e75c2c235d7893b81b012ac60db38f Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 8 Dec 2025 18:36:57 +0100 Subject: [PATCH 10/21] Change decode_options to validate payload --- .../auth/iam/jwt_authentication_service.rb | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index 9da386b826d691..73e4d014ef20ab 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -55,8 +55,8 @@ def decode_and_validate! raise Gitlab::Auth::Iam::Jwt::InvalidAudienceError, 'Invalid token audience' rescue JWT::VerificationError => e handle_verification_error(e) - rescue JWT::DecodeError => e - raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format: #{e.message}" + rescue JWT::DecodeError + raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format" end def handle_verification_error(error) @@ -70,30 +70,22 @@ def handle_verification_error(error) decode_and_validate! end + # TODO: aud in `Gitlab::Auth::Iam::Jwt`? + # aud: Gitlab.config.iam_service.audience, def decode_options { algorithms: Gitlab::Auth::Iam::Jwt::ALLOWED_ALGORITHMS, jwks: jwks_client.fetch_keys, + required_claims: %w[sub jti exp iat], verify_iss: true, - iss: Gitlab::Auth::Iam::Jwt.iam_service_issuer + iss: Gitlab::Auth::Iam::Jwt.iam_service_issuer, + verify_aud: true, + verify_iat: true, + verify_exp: true, + leeway: clock_skew_seconds } end - # TODO: Improve decode_options, delegate jwt library to validate required claims - # def decode_options - # { - # algorithms: Gitlab::Auth::IamJwt::ALLOWED_ALGORITHMS, - # jwks: jwks_client.fetch_keys, - # required_claims: %w[sub jti exp iat], - # verify_iss: true, - # iss: Gitlab::CurrentSettings.iam_service_issuer, - # verify_aud: true, - # aud: Gitlab::CurrentSettings.iam_service_audience, - # verify_iat: true, - # leeway: clock_skew_seconds - # } - # end - # TODO: implement strong_memoize_attr def resolve_user(payload) user_id = payload['sub'] -- GitLab From 9bcc3289f45ceac0d3f79207bb68602b16a29c02 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Tue, 9 Dec 2025 13:07:37 +0100 Subject: [PATCH 11/21] Add improvements and semplifications Simplify JWKS client to use default values, remove initializer. Implement memoization and better caching strategy for keys --- .../auth/iam/jwt_authentication_service.rb | 2 +- lib/gitlab/auth/iam/jwt/jwks_client.rb | 41 ++++--------------- lib/gitlab/auth/iam/jwt/token.rb | 11 +++-- 3 files changed, 17 insertions(+), 37 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index 73e4d014ef20ab..af3c7ce4649fa4 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -89,7 +89,7 @@ def decode_options # TODO: implement strong_memoize_attr def resolve_user(payload) user_id = payload['sub'] - user = User.find(user_id) + user = User.find_by(id: user_id) raise Gitlab::Auth::Iam::Jwt::UserNotFoundError, 'User not found' unless user raise Gitlab::Auth::Iam::Jwt::UserBlockedError, 'User is blocked' if user.blocked? diff --git a/lib/gitlab/auth/iam/jwt/jwks_client.rb b/lib/gitlab/auth/iam/jwt/jwks_client.rb index 2476c537155203..673c0222656894 100644 --- a/lib/gitlab/auth/iam/jwt/jwks_client.rb +++ b/lib/gitlab/auth/iam/jwt/jwks_client.rb @@ -7,21 +7,10 @@ module Jwt class JwksClient include Gitlab::Utils::StrongMemoize - CACHE_KEY = 'iam_jwt:jwks' JWKS_PATH = '/.well-known/jwks.json' - def initialize( - endpoint: nil, - cache: Rails.cache, - timeout: nil - ) - @endpoint = endpoint || default_endpoint - @cache = cache - @timeout = timeout || default_timeout - end - def fetch_keys - @cache.fetch(CACHE_KEY, expires_in: default_cache_ttl) do + Rails.cache.fetch(cache_key, expires_in: cache_ttl) do fetch_keys_from_service end end @@ -32,21 +21,13 @@ def refresh_keys end def clear_cache - @cache.delete(CACHE_KEY) + Rails.cache.delete(cache_key) end private def fetch_keys_from_service - response = Gitlab::HTTP.get( - @endpoint, - timeout: @timeout, - open_timeout: @open_timeout, - headers: { - 'User-Agent' => 'GitLab-Rails', - 'Accept' => 'application/json' - } - ) + response = Gitlab::HTTP.get(endpoint) unless response.success? raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, @@ -61,27 +42,23 @@ def fetch_keys_from_service raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Invalid JWKS response: #{e.message}" end - def default_endpoint - url = Gitlab.config.iam_service.url + def endpoint + url = Gitlab::Auth::Iam::Jwt.iam_service_url raise Gitlab::Auth::Iam::Jwt::ConfigurationError, 'IAM service URL is not configured' if url.nil? URI.join(url, JWKS_PATH).to_s end + strong_memoize_attr :endpoint - def default_timeout - 10 - end - - def default_cache_ttl + def cache_ttl 3600.seconds end - # TODO: potential improvement to include issuer hash in cache key to prevent - # stale cache when configuration changes def cache_key - issuer_hash = Digest::SHA256.hexdigest(Gitlab::CurrentSettings.iam_service_issuer)[0..8] + issuer_hash = Digest::SHA256.hexdigest(Gitlab::Auth::Iam::Jwt.iam_service_issuer)[0..8] "iam_jwt:jwks:#{issuer_hash}" end + strong_memoize_attr :cache_key end end end diff --git a/lib/gitlab/auth/iam/jwt/token.rb b/lib/gitlab/auth/iam/jwt/token.rb index 717c262ef7c163..0a04787b17deff 100644 --- a/lib/gitlab/auth/iam/jwt/token.rb +++ b/lib/gitlab/auth/iam/jwt/token.rb @@ -17,13 +17,12 @@ def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) end def expired? - # expires_at < Time.current - payload.blank? + expires_at.present? && expires_at < Time.zone.now end def revoked? - # false # IAM JWTs are stateless - payload.blank? + false # IAM JWTs are stateless + # TODO: to be discussed end def reload @@ -38,6 +37,10 @@ def has_attribute?(_attribute) false end + def active? + !expired? && !revoked? + end + def to_s "Iam::Jwt::Token(jti: #{jti}, user_id: #{user&.id})" end -- GitLab From 91038f2880f1f60ab437a3969b1abed742cf2ee6 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Tue, 9 Dec 2025 18:42:44 +0100 Subject: [PATCH 12/21] Refactor to match OAuth pattern Implement User lazy resolution to delegate validation to existing methods and match OAuth pattern --- .../auth/iam/jwt_authentication_service.rb | 45 ++++--------------- lib/gitlab/auth.rb | 3 +- lib/gitlab/auth/auth_finders.rb | 8 ---- lib/gitlab/auth/iam/jwt/jwks_client.rb | 7 +-- lib/gitlab/auth/iam/jwt/token.rb | 25 ++++++++--- 5 files changed, 32 insertions(+), 56 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index af3c7ce4649fa4..dcc2fc2209d28b 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -5,6 +5,9 @@ module Iam class JwtAuthenticationService include Gitlab::Utils::StrongMemoize + # Note: suggested by Duo + CLOCK_SKEW_SECONDS = 30 + def initialize(token:) @token_string = token @retry_attempted = false @@ -15,14 +18,14 @@ def execute return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(@token_string) payload = decode_and_validate! - user = resolve_user(payload) scopes = extract_scopes(payload) + user_id = payload['sub'] - log_successful_authentication(user, payload) + log_successful_authentication(user_id, payload) ServiceResponse.success(payload: { token: Gitlab::Auth::Iam::Jwt::Token.new( - user: user, + user_id: user_id, scopes: scopes, jti: payload['jti'], expires_at: Time.zone.at(payload['exp']), @@ -82,33 +85,10 @@ def decode_options verify_aud: true, verify_iat: true, verify_exp: true, - leeway: clock_skew_seconds + leeway: CLOCK_SKEW_SECONDS } end - # TODO: implement strong_memoize_attr - def resolve_user(payload) - user_id = payload['sub'] - user = User.find_by(id: user_id) - - raise Gitlab::Auth::Iam::Jwt::UserNotFoundError, 'User not found' unless user - raise Gitlab::Auth::Iam::Jwt::UserBlockedError, 'User is blocked' if user.blocked? - raise Gitlab::Auth::Iam::Jwt::UserInactiveError, 'User is not active' unless user.active? - - user - end - - # def build_token(payload) - # Gitlab::Auth::IamJwt::Token.new( - # user: user, - # scopes: scopes, - # jti: payload['jti'], - # expires_at: Time.zone.at(payload['exp']), - # issued_at: Time.zone.at(payload['iat']), - # payload: payload - # ) - # end - def extract_scopes(payload) scopes = payload['scp'] || [] scopes.map(&:to_sym) @@ -119,17 +99,10 @@ def jwks_client end strong_memoize_attr :jwks_client - def clock_skew_seconds - # Note: suggested by Duo - # TODO: Validate this - 30 - end - - def log_successful_authentication(user, payload) + def log_successful_authentication(user_id, payload) Gitlab::AppLogger.info( message: 'IAM JWT authentication successful', - user_id: user.id, - username: user.username, + user_id: user_id, token_jti: payload['jti'], token_exp: Time.zone.at(payload['exp']), scopes: payload['scp'] diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index e76414ec3d22dc..156dd0774e419d 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -281,7 +281,6 @@ def user_with_password_for_git(login, password, request: nil) Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities) end - # TODO: Consolidate with the rest of implementation def iam_jwt_token_check(password, project) return unless Gitlab::Auth::Iam::Jwt.enabled? return unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(password) @@ -292,7 +291,7 @@ def iam_jwt_token_check(password, project) token = result.payload[:token] user = token.user - # Verify user can authenticate (follows oauth_access_token_check pattern) + return unless user return unless user.can_log_in_with_non_expired_password? || valid_composite_identity?(user) # Open question: Should we handle project bots/service accounts here? diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index bd89f4deedf71f..2936cfaa5893d7 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -337,7 +337,6 @@ def find_iam_jwt_access_token return unless result.success? result.payload[:token] - # TODO: to discuss, might be too verbose rescue Gitlab::Auth::Iam::Jwt::JwksFetchFailedError => e Gitlab::ErrorTracking.track_exception(e) Gitlab::AuthLogger.warn( @@ -346,13 +345,6 @@ def find_iam_jwt_access_token error_message: e.message ) nil - rescue Gitlab::Auth::Iam::Jwt::UserBlockedError, Gitlab::Auth::Iam::Jwt::UserInactiveError => e - Gitlab::AuthLogger.warn( - message: 'IAM JWT authentication failed due to user status', - error_class: e.class.name, - error_message: e.message - ) - nil rescue Gitlab::Auth::Iam::Jwt::Error => e Gitlab::AuthLogger.info( message: 'IAM JWT authentication failed', diff --git a/lib/gitlab/auth/iam/jwt/jwks_client.rb b/lib/gitlab/auth/iam/jwt/jwks_client.rb index 673c0222656894..bd1c1c01a818ac 100644 --- a/lib/gitlab/auth/iam/jwt/jwks_client.rb +++ b/lib/gitlab/auth/iam/jwt/jwks_client.rb @@ -8,9 +8,10 @@ class JwksClient include Gitlab::Utils::StrongMemoize JWKS_PATH = '/.well-known/jwks.json' + CACHE_TTL = 3600.seconds def fetch_keys - Rails.cache.fetch(cache_key, expires_in: cache_ttl) do + Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do fetch_keys_from_service end end @@ -50,10 +51,6 @@ def endpoint end strong_memoize_attr :endpoint - def cache_ttl - 3600.seconds - end - def cache_key issuer_hash = Digest::SHA256.hexdigest(Gitlab::Auth::Iam::Jwt.iam_service_issuer)[0..8] "iam_jwt:jwks:#{issuer_hash}" diff --git a/lib/gitlab/auth/iam/jwt/token.rb b/lib/gitlab/auth/iam/jwt/token.rb index 0a04787b17deff..bd519d659d6515 100644 --- a/lib/gitlab/auth/iam/jwt/token.rb +++ b/lib/gitlab/auth/iam/jwt/token.rb @@ -5,10 +5,12 @@ module Auth module Iam module Jwt class Token - attr_reader :user, :scopes, :jti, :expires_at, :issued_at, :payload + include Gitlab::Utils::StrongMemoize - def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) - @user = user + attr_reader :user_id, :scopes, :jti, :expires_at, :issued_at, :payload + + def initialize(user_id:, scopes:, jti:, expires_at:, issued_at:, payload:) + @user_id = user_id @scopes = Array(scopes).map(&:to_s) @jti = jti @expires_at = expires_at @@ -16,8 +18,20 @@ def initialize(user:, scopes:, jti:, expires_at:, issued_at:, payload:) @payload = payload end + # Lazy load user (follows OAuth token association pattern) + # Uses User.id_in to avoid Rubocop CodeReuse/ActiveRecord violation + def user + User.id_in(user_id).first + end + strong_memoize_attr :user + + # For compatibility with AccessTokenValidationService + def resource_owner_id + user_id + end + def expired? - expires_at.present? && expires_at < Time.zone.now + expires_at.present? && expires_at.past? end def revoked? @@ -26,6 +40,7 @@ def revoked? end def reload + clear_memoization(:user) self end @@ -42,7 +57,7 @@ def active? end def to_s - "Iam::Jwt::Token(jti: #{jti}, user_id: #{user&.id})" + "Iam::Jwt::Token(jti: #{jti}, user_id: #{user_id})" end end end -- GitLab From c7d29cca045d69d2d9c7cde8c5e0353fc7f72c53 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 10 Dec 2025 17:45:51 +0100 Subject: [PATCH 13/21] Improve auth_finder for IAM JWT token - Improve the auth_finder flow for IAM JWT token by checking the issuer before validating it. - Improve error handling --- .../auth/iam/jwt_authentication_service.rb | 6 ++- lib/gitlab/auth/auth_finders.rb | 38 ++++++++++--------- lib/gitlab/auth/iam/jwt.rb | 6 +-- 3 files changed, 27 insertions(+), 23 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index dcc2fc2209d28b..88bf504ebc2711 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -5,6 +5,8 @@ module Iam class JwtAuthenticationService include Gitlab::Utils::StrongMemoize + attr_reader :token_string + # Note: suggested by Duo CLOCK_SKEW_SECONDS = 30 @@ -15,7 +17,7 @@ def initialize(token:) def execute return error_disabled unless Gitlab::Auth::Iam::Jwt.enabled? - return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(@token_string) + return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(token_string) payload = decode_and_validate! scopes = extract_scopes(payload) @@ -43,7 +45,7 @@ def execute def decode_and_validate! payload, _ = JWT.decode( - @token_string, + token_string, nil, true, decode_options diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 2936cfaa5893d7..b263f95ee734d5 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -331,27 +331,17 @@ def find_iam_jwt_access_token self.current_token = parsed_oauth_token return unless current_token - return unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(current_token) + + return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(current_token) result = ::Auth::Iam::JwtAuthenticationService.new(token: current_token).execute - return unless result.success? + + unless result.success? + log_iam_jwt_failure(result) + return + end result.payload[:token] - rescue Gitlab::Auth::Iam::Jwt::JwksFetchFailedError => e - Gitlab::ErrorTracking.track_exception(e) - Gitlab::AuthLogger.warn( - message: 'IAM JWT authentication failed: JWKS fetch error', - error_class: e.class.name, - error_message: e.message - ) - nil - rescue Gitlab::Auth::Iam::Jwt::Error => e - Gitlab::AuthLogger.info( - message: 'IAM JWT authentication failed', - error_class: e.class.name, - error_message: e.message - ) - nil end def find_personal_access_token @@ -608,6 +598,20 @@ def find_user_from_dependency_proxy_token def dependency_proxy_request? Gitlab::PathRegex.dependency_proxy_route_regex.match?(current_request.path) end + + def log_iam_jwt_failure(result) + case result.reason + when :service_unavailable, :invalid_token + Gitlab::ErrorTracking.track_exception(e) + else + Gitlab::AuthLogger.info( + message: 'IAM JWT authentication failed', + reason: result.reason, + error_class: e.class.name, + error_message: result.message + ) + end + end end end end diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index 75f7832188d2a7..c79ad105fe893f 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -46,10 +46,8 @@ def valid_jwt_format?(token_string) end def issued_by_iam_service?(token_string) - return false unless valid_jwt_format?(token_string) - - payload = JWT.decode(token_string, nil, false).first - payload['iss'] == iam_service_issuer + unverified_payload, _ = JWT.decode(token_string, nil, false) + unverified_payload['iss'] == iam_service_issuer rescue JWT::DecodeError false end -- GitLab From 0becdc8812ec8715764f7aaec4d1fda713aa8120 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 10 Dec 2025 18:16:32 +0100 Subject: [PATCH 14/21] Add small improvements --- app/services/auth/iam/jwt_authentication_service.rb | 4 ++-- lib/gitlab/auth.rb | 6 ++++-- lib/gitlab/auth/auth_finders.rb | 13 ++++++++++--- lib/gitlab/auth/iam/jwt.rb | 11 ++++++----- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index 88bf504ebc2711..60796682b3e995 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -60,8 +60,8 @@ def decode_and_validate! raise Gitlab::Auth::Iam::Jwt::InvalidAudienceError, 'Invalid token audience' rescue JWT::VerificationError => e handle_verification_error(e) - rescue JWT::DecodeError - raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format" + rescue JWT::DecodeError => e + raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format: #{e.message}" end def handle_verification_error(error) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 156dd0774e419d..ed73d6ac62e3c5 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -283,14 +283,16 @@ def user_with_password_for_git(login, password, request: nil) def iam_jwt_token_check(password, project) return unless Gitlab::Auth::Iam::Jwt.enabled? - return unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(password) + return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(password) result = ::Auth::Iam::JwtAuthenticationService.new(token: password).execute return unless result.success? token = result.payload[:token] - user = token.user + return unless valid_scoped_token?(token, all_available_scopes) + + user = token.user return unless user return unless user.can_log_in_with_non_expired_password? || valid_composite_identity?(user) diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index b263f95ee734d5..a5ddb927a5cfd2 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -601,13 +601,20 @@ def dependency_proxy_request? def log_iam_jwt_failure(result) case result.reason - when :service_unavailable, :invalid_token - Gitlab::ErrorTracking.track_exception(e) + when :service_unavailable + Gitlab::ErrorTracking.track_exception( + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError.new(result.message) + ) + when :invalid_token + Gitlab::AuthLogger.warn( + message: 'IAM JWT authentication failed', + reason: result.reason, + error_message: result.message + ) else Gitlab::AuthLogger.info( message: 'IAM JWT authentication failed', reason: result.reason, - error_class: e.class.name, error_message: result.message ) end diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index c79ad105fe893f..1136142cfac4d5 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -21,11 +21,6 @@ module Jwt JwksFetchFailedError = Class.new(Error) JwksKeyMissingError = Class.new(Error) - # User resolution errors (401) - UserNotFoundError = Class.new(Error) - UserBlockedError = Class.new(Error) - UserInactiveError = Class.new(Error) - # Scope errors (403) InsufficientScopeError = Class.new(Error) @@ -37,6 +32,8 @@ module Jwt ALLOWED_ALGORITHMS = ['RS256'].freeze class << self + include Gitlab::Utils::StrongMemoize + # Lightweight format check - does NOT decode JWT # Used for fast rejection of non-JWT tokens def valid_jwt_format?(token_string) @@ -46,6 +43,8 @@ def valid_jwt_format?(token_string) end def issued_by_iam_service?(token_string) + return false unless valid_jwt_format?(token_string) + unverified_payload, _ = JWT.decode(token_string, nil, false) unverified_payload['iss'] == iam_service_issuer rescue JWT::DecodeError @@ -55,10 +54,12 @@ def issued_by_iam_service?(token_string) def iam_service_url Gitlab.config.iam_service.url end + strong_memoize_attr :iam_service_url def iam_service_issuer Gitlab.config.iam_service.issuer end + strong_memoize_attr :iam_service_issuer def enabled? Feature.enabled?(:iam_svc_oauth, :instance) && -- GitLab From ff35110c0935d7e346df5addebde942dd231c67d Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 10 Dec 2025 18:50:47 +0100 Subject: [PATCH 15/21] Add User-Scoped FF Check in Service --- .../auth/iam/jwt_authentication_service.rb | 29 ++++++++++++++++++- lib/gitlab/auth/auth_finders.rb | 6 ++++ lib/gitlab/auth/iam/jwt.rb | 3 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/auth/iam/jwt_authentication_service.rb index 60796682b3e995..13fdc10c4f2e8b 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/auth/iam/jwt_authentication_service.rb @@ -20,9 +20,18 @@ def execute return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(token_string) payload = decode_and_validate! - scopes = extract_scopes(payload) user_id = payload['sub'] + # WORKAROUND: Check user-scoped feature flag for gradual production rollout + # This ensures we only process IAM JWTs for opted-in users, minimizing + # impact on other users. Remove this once fully rolled out. + # TODO: Remove user-scoped FF check after production validation + return error_feature_disabled unless feature_enabled_for_user?(user_id) + + # TODO: Discuss - should this be before validation? + + scopes = extract_scopes(payload) + log_successful_authentication(user_id, payload) ServiceResponse.success(payload: { @@ -96,6 +105,20 @@ def extract_scopes(payload) scopes.map(&:to_sym) end + # WORKAROUND: Check user-scoped feature flag for gradual production rollout + # This is temporary - allows testing IAM JWT with specific users in production + # without impacting all users. The user lookup here is acceptable because: + # 1. It only happens for valid IAM JWTs (after signature verification) + # 2. It fails fast before creating token object + # 3. It's a temporary workaround for safe production testing + # TODO: Remove this method once IAM JWT is fully rolled out + def feature_enabled_for_user?(user_id) + user = User.find_by(id: user_id) # rubocop:disable CodeReuse/ActiveRecord -- Temporary workaround for safe production testing + return false unless user + + Feature.enabled?(:iam_svc_oauth, user) + end + def jwks_client Gitlab::Auth::Iam::Jwt::JwksClient.new end @@ -119,6 +142,10 @@ def error_invalid_format ServiceResponse.error(message: 'Invalid token format', reason: :invalid_format) end + def error_feature_disabled + ServiceResponse.error(message: 'IAM JWT authentication not enabled for this user', reason: :feature_disabled) + end + def error_response(message, reason) ServiceResponse.error(message: message, reason: reason) end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index a5ddb927a5cfd2..4aa55ad4b3756f 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -611,6 +611,12 @@ def log_iam_jwt_failure(result) reason: result.reason, error_message: result.message ) + when :feature_disabled + # User not opted in - informational only (expected during gradual rollout) + Gitlab::AuthLogger.info( + message: 'IAM JWT authentication not enabled for user', + reason: result.reason + ) else Gitlab::AuthLogger.info( message: 'IAM JWT authentication failed', diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index 1136142cfac4d5..c822a57ae4cb7b 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -62,8 +62,7 @@ def iam_service_issuer strong_memoize_attr :iam_service_issuer def enabled? - Feature.enabled?(:iam_svc_oauth, :instance) && - Gitlab.config.iam_service.enabled + Gitlab.config.iam_service.enabled end end end -- GitLab From df8cd019cb0a5a4e96596fcc018349d522a9998f Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Fri, 12 Dec 2025 12:51:33 +0100 Subject: [PATCH 16/21] Add IAM JWT authentication with initial test coverage - Move JWT authentication service from Auth to Authn namespace - Integrate IAM JWT token check in Gitlab::Auth and AuthFinders - Add factory for IAM JWT tokens - Add JwtHelper for generating real JWT tokens in tests - Add comprehensive specs for JwtAuthenticationService using real JWTs - Add functional tests for AuthFinders#find_iam_jwt_access_token --- app/services/auth/iam.rb | 7 - .../iam/jwt_authentication_service.rb | 2 +- lib/gitlab/auth.rb | 2 +- lib/gitlab/auth/auth_finders.rb | 2 +- spec/factories/authn/iam_jwt_tokens.rb | 49 +++ spec/lib/gitlab/auth/auth_finders_spec.rb | 153 ++++++++ .../iam/jwt_authentication_service_spec.rb | 343 ++++++++++++++++++ spec/support/helpers/authn/iam/jwt_helper.rb | 226 ++++++++++++ 8 files changed, 774 insertions(+), 10 deletions(-) delete mode 100644 app/services/auth/iam.rb rename app/services/{auth => authn}/iam/jwt_authentication_service.rb (98%) create mode 100644 spec/factories/authn/iam_jwt_tokens.rb create mode 100644 spec/services/authn/iam/jwt_authentication_service_spec.rb create mode 100644 spec/support/helpers/authn/iam/jwt_helper.rb diff --git a/app/services/auth/iam.rb b/app/services/auth/iam.rb deleted file mode 100644 index 284a40a32b1f59..00000000000000 --- a/app/services/auth/iam.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern - module Iam - # Parent module for IAM-related services - end -end diff --git a/app/services/auth/iam/jwt_authentication_service.rb b/app/services/authn/iam/jwt_authentication_service.rb similarity index 98% rename from app/services/auth/iam/jwt_authentication_service.rb rename to app/services/authn/iam/jwt_authentication_service.rb index 13fdc10c4f2e8b..481c507e44f97c 100644 --- a/app/services/auth/iam/jwt_authentication_service.rb +++ b/app/services/authn/iam/jwt_authentication_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module Auth # rubocop:disable Gitlab/BoundedContexts -- Auth services follow existing pattern +module Authn module Iam class JwtAuthenticationService include Gitlab::Utils::StrongMemoize diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index ed73d6ac62e3c5..68db0f1f1c2bb7 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -285,7 +285,7 @@ def iam_jwt_token_check(password, project) return unless Gitlab::Auth::Iam::Jwt.enabled? return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(password) - result = ::Auth::Iam::JwtAuthenticationService.new(token: password).execute + result = ::Authn::Iam::JwtAuthenticationService.new(token: password).execute return unless result.success? token = result.payload[:token] diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 4aa55ad4b3756f..41192f280c778a 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -334,7 +334,7 @@ def find_iam_jwt_access_token return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(current_token) - result = ::Auth::Iam::JwtAuthenticationService.new(token: current_token).execute + result = ::Authn::Iam::JwtAuthenticationService.new(token: current_token).execute unless result.success? log_iam_jwt_failure(result) diff --git a/spec/factories/authn/iam_jwt_tokens.rb b/spec/factories/authn/iam_jwt_tokens.rb new file mode 100644 index 00000000000000..10b6e3a872d5ad --- /dev/null +++ b/spec/factories/authn/iam_jwt_tokens.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :iam_jwt_token, class: 'Gitlab::Auth::Iam::Jwt::Token' do + transient do + user { association(:user) } + scopes { ['api'] } + jti { SecureRandom.uuid } + expires_at { 1.hour.from_now } + issued_at { Time.current } + issuer { 'http://localhost:8084' } + private_key { OpenSSL::PKey::RSA.new(2048) } + end + + skip_create + + initialize_with do + payload = { + 'sub' => user.id.to_s, + 'scp' => Array(scopes).map(&:to_s), + 'jti' => jti, + 'exp' => expires_at.is_a?(Integer) ? expires_at : expires_at.to_i, + 'iat' => issued_at.is_a?(Integer) ? issued_at : issued_at.to_i, + 'iss' => issuer + } + + new( + user_id: user.id.to_s, + scopes: scopes, + jti: jti, + expires_at: expires_at, + issued_at: issued_at, + payload: payload + ) + end + + trait :expired do + expires_at { 1.hour.ago } + end + + trait :with_multiple_scopes do + scopes { %w[api read_repository write_repository] } + end + + trait :no_scopes do + scopes { [] } + end + end +end diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 085b3488a2fa69..1e16783d0221dc 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -861,6 +861,159 @@ def set_bearer_token(token) end end + describe '#find_iam_jwt_access_token' do + let_it_be(:iam_jwt_token) { build(:iam_jwt_token, user: user) } + + subject { find_iam_jwt_access_token } + + it 'returns nil if no bearer token' do + expect(subject).to be_nil + end + + context 'when IAM JWT is disabled' do + before do + allow(Gitlab.config.iam_service).to receive(:enabled).and_return(false) + set_bearer_token('any-token') + end + + it 'returns nil' do + expect(subject).to be_nil + end + end + + context 'when IAM JWT is enabled' do + before do + allow(Gitlab.config.iam_service).to receive(:enabled).and_return(true) + stub_feature_flags(iam_svc_oauth: user) + end + + context 'with valid IAM JWT token' do + let(:service_result) { ServiceResponse.success(payload: { token: iam_jwt_token }) } + + before do + set_bearer_token('valid-iam-jwt-token') + allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(true) + allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow(service).to receive(:execute).and_return(service_result) + end + end + + it 'returns IAM JWT token object' do + expect(subject).to eq(iam_jwt_token) + end + + it 'sets current_token' do + subject + + expect(current_token).to eq('valid-iam-jwt-token') + end + + it 'calls authentication service with the token' do + expect(Authn::Iam::JwtAuthenticationService).to receive(:new).with(token: 'valid-iam-jwt-token').and_call_original + + subject + end + end + + context 'when token is not issued by IAM service' do + before do + set_bearer_token(personal_access_token.token) + allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(false) + end + + it 'returns nil' do + expect(subject).to be_nil + end + + it 'does not call authentication service' do + expect(Authn::Iam::JwtAuthenticationService).not_to receive(:new) + + subject + end + end + + context 'when authentication service returns error' do + before do + set_bearer_token('invalid-iam-jwt') + allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(true) + end + + context 'when feature flag is disabled for user' do + let(:service_result) { ServiceResponse.error(message: 'Not enabled', reason: :feature_disabled) } + + before do + allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow(service).to receive(:execute).and_return(service_result) + end + end + + it 'returns nil' do + expect(subject).to be_nil + end + + it 'logs the failure as info' do + expect(Gitlab::AuthLogger).to receive(:info).with( + hash_including( + message: 'IAM JWT authentication not enabled for user', + reason: :feature_disabled + ) + ) + + subject + end + end + + context 'when JWT is invalid' do + let(:service_result) { ServiceResponse.error(message: 'Token has expired', reason: :invalid_token) } + + before do + allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow(service).to receive(:execute).and_return(service_result) + end + end + + it 'returns nil' do + expect(subject).to be_nil + end + + it 'logs the failure as warning' do + expect(Gitlab::AuthLogger).to receive(:warn).with( + hash_including( + message: 'IAM JWT authentication failed', + reason: :invalid_token, + error_message: 'Token has expired' + ) + ) + + subject + end + end + + context 'when JWKS fetch fails' do + let(:service_result) { ServiceResponse.error(message: 'Connection failed', reason: :service_unavailable) } + + before do + allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow(service).to receive(:execute).and_return(service_result) + end + end + + it 'returns nil' do + expect(subject).to be_nil + end + + it 'tracks the exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + instance_of(Gitlab::Auth::Iam::Jwt::JwksFetchFailedError) + ) + + subject + end + end + end + end + end + describe '#find_oauth_access_token' do let_it_be(:oauth_application) { create(:oauth_application, owner: user) } let(:scopes) { 'api' } diff --git a/spec/services/authn/iam/jwt_authentication_service_spec.rb b/spec/services/authn/iam/jwt_authentication_service_spec.rb new file mode 100644 index 00000000000000..21c9391d28bf39 --- /dev/null +++ b/spec/services/authn/iam/jwt_authentication_service_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Authn::Iam::JwtAuthenticationService, :aggregate_failures, feature_category: :system_access do + include Authn::Iam::JwtHelper + + let_it_be(:user) { create(:user) } + + let(:iam_url) { 'https://iam.gitlab.example.com' } + let(:iam_issuer) { 'https://iam.gitlab.example.com' } + let(:iam_key_id) { 'test-key-1' } + let(:private_key) { generate_rsa_key } + + before do + stub_iam_service_config(enabled: true, url: iam_url, issuer: iam_issuer) + stub_iam_jwks_endpoint(private_key.public_key, url: iam_url, kid: iam_key_id) + end + + describe '#execute' do + context 'when IAM JWT is disabled' do + let(:jwt_data) { create_valid_iam_jwt(user: user, issuer: iam_issuer, private_key: private_key, kid: iam_key_id) } + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_iam_service_config(enabled: false, url: iam_url, issuer: iam_issuer) + end + + it 'returns error with disabled reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:disabled) + expect(result.message).to eq('IAM JWT authentication is disabled') + end + end + + context 'when IAM JWT is enabled' do + context 'with invalid token format' do + let(:token_string) { create_malformed_jwt(type: :not_jwt) } + let(:service) { described_class.new(token: token_string) } + + it 'returns error with invalid_format reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_format) + expect(result.message).to eq('Invalid token format') + end + end + + context 'with valid JWT format' do + context 'when feature flag is disabled for user' do + let(:jwt_data) do + create_valid_iam_jwt(user: user, issuer: iam_issuer, private_key: private_key, kid: iam_key_id) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_feature_flags(iam_svc_oauth: false) + end + + it 'returns error with feature_disabled reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:feature_disabled) + expect(result.message).to eq('IAM JWT authentication not enabled for this user') + end + + it 'does not create token object' do + result = service.execute + + expect(result.payload).to eq({}) + end + end + + context 'when feature flag is enabled for user' do + let(:jwt_data) do + create_valid_iam_jwt(user: user, scopes: ['api'], issuer: iam_issuer, private_key: private_key, + kid: iam_key_id) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_feature_flags(iam_svc_oauth: user) + end + + it 'returns success' do + result = service.execute + + expect(result).to be_success + end + + it 'returns token object' do + result = service.execute + token = result.payload[:token] + + expect(token).to be_a(Gitlab::Auth::Iam::Jwt::Token) + expect(token.user_id).to eq(user.id.to_s) + expect(token.scopes).to eq(['api']) + expect(token.jti).to eq(jwt_data[:payload]['jti']) + end + + it 'logs successful authentication' do + expect(Gitlab::AppLogger).to receive(:info).with( + hash_including( + message: 'IAM JWT authentication successful', + user_id: user.id.to_s, + token_jti: jwt_data[:payload]['jti'] + ) + ) + + service.execute + end + end + + context 'when user does not exist' do + let(:jwt_data) do + create_valid_iam_jwt(user: non_existing_record_id, issuer: iam_issuer, private_key: private_key, + kid: iam_key_id) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + it 'returns error with feature_disabled reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:feature_disabled) + end + end + + context 'when JWT is expired' do + let(:jwt_data) do + create_expired_iam_jwt(user: user, expired_at: 2.hours.ago, issuer: iam_issuer, private_key: private_key, + kid: iam_key_id) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + it 'returns error with invalid_token reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to eq('Token has expired') + end + end + + context 'when JWT has invalid issuer' do + let(:invalid_issuer) { 'http://malicious.com' } + let(:jwt_data) do + create_iam_jwt_with_invalid_issuer( + user: user, + issuer: invalid_issuer, + private_key: private_key, + kid: iam_key_id + ) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + it 'returns error with invalid_token reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to eq('Invalid token issuer') + end + end + + context 'when JWT has invalid audience' do + let(:jwt_data) do + create_valid_iam_jwt(user: user, issuer: iam_issuer, private_key: private_key, kid: iam_key_id, + aud: 'wrong-audience') + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + # TODO: This test is currently skipped because the service doesn't add audience validation yet + # The decode_options method sets verify_aud: true but doesn't provide an expected aud value + # Remove skip when audience validation is implemented + it 'returns error with invalid_token reason', skip: 'Audience validation not yet implemented' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to eq('Invalid token audience') + end + end + + context 'when JWT signature verification fails' do + let(:jwt_data) { create_iam_jwt_with_invalid_signature(user: user, issuer: iam_issuer, kid: iam_key_id) } + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_iam_jwks_endpoint(jwt_data[:verification_key].public_key, url: iam_url, kid: iam_key_id) + end + + it 'retries once with cleared cache' do + expect_next_instance_of(Gitlab::Auth::Iam::Jwt::JwksClient) do |client| + expect(client).to receive(:clear_cache).once.and_call_original + end + + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to include('Signature verification failed') + end + end + + context 'when JWT is malformed' do + let(:token_string) { create_malformed_jwt(type: :invalid_base64) } + let(:service) { described_class.new(token: token_string) } + + it 'returns error with invalid_token reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to include('Invalid token format') + end + end + + context 'when JWKS fetch fails' do + let(:jwt_data) do + create_valid_iam_jwt(user: user, issuer: iam_issuer, private_key: private_key, kid: iam_key_id) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_jwks_endpoint_connection_error(url: iam_url, error: Errno::ECONNREFUSED) + end + + it 'returns error with service_unavailable reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:service_unavailable) + expect(result.message).to include('Cannot connect to IAM service: Connection refused') + end + end + + context 'with multiple scopes' do + let(:scopes) { %w[api read_repository write_repository] } + let(:jwt_data) do + create_valid_iam_jwt( + user: user, + scopes: scopes, + issuer: iam_issuer, + private_key: private_key, + kid: iam_key_id + ) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_feature_flags(iam_svc_oauth: user) + end + + it 'extracts all scopes' do + result = service.execute + token = result.payload[:token] + + expect(token.scopes).to contain_exactly('api', 'read_repository', 'write_repository') + end + end + + context 'with no scopes in payload' do + let(:jwt_data) do + create_iam_jwt_missing_claims( + user: user, + missing_claims: ['scp'], + issuer: iam_issuer, + private_key: private_key, + kid: iam_key_id + ) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + before do + stub_feature_flags(iam_svc_oauth: user) + end + + it 'defaults to empty scopes' do + result = service.execute + token = result.payload[:token] + + expect(token.scopes).to eq([]) + end + end + end + end + end + + # WORKAROUND: Check user-scoped feature flag for gradual production rollout + # This is temporary - allows testing IAM JWT with specific users in production + # TODO: Remove this method once IAM JWT is fully rolled out + describe '#feature_enabled_for_user?' do + let(:jwt_data) { create_valid_iam_jwt(user: user, issuer: iam_issuer, private_key: private_key, kid: iam_key_id) } + let(:service) { described_class.new(token: jwt_data[:token]) } + let(:user_id) { user.id } + + subject { service.send(:feature_enabled_for_user?, user_id) } + + context 'when user exists' do + context 'when feature flag is enabled for user' do + before do + stub_feature_flags(iam_svc_oauth: user) + end + + it { is_expected.to be(true) } + end + + context 'when feature flag is disabled for user' do + before do + stub_feature_flags(iam_svc_oauth: false) + end + + it { is_expected.to be(false) } + end + end + + context 'when user does not exist' do + let(:user_id) { non_existing_record_id } + + it { is_expected.to be(false) } + end + + context 'when user_id is nil' do + let(:user_id) { nil } + + it { is_expected.to be(false) } + end + end +end diff --git a/spec/support/helpers/authn/iam/jwt_helper.rb b/spec/support/helpers/authn/iam/jwt_helper.rb new file mode 100644 index 00000000000000..2cf995bfd51a60 --- /dev/null +++ b/spec/support/helpers/authn/iam/jwt_helper.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +module Authn + module Iam + module JwtHelper + DEFAULT_ALGORITHM = 'RS256' + + def generate_rsa_key(key_size: 2048) + OpenSSL::PKey::RSA.new(key_size) + end + + def build_iam_jwt_payload( + user_id:, + issuer:, scopes: ['api'], + jti: SecureRandom.uuid, + expires_at: 1.hour.from_now, + issued_at: Time.current, + **additional_claims + ) + { + 'sub' => user_id.to_s, + 'scp' => Array(scopes).map(&:to_s), + 'jti' => jti, + 'exp' => expires_at.is_a?(Integer) ? expires_at : expires_at.to_i, + 'iat' => issued_at.is_a?(Integer) ? issued_at : issued_at.to_i, + 'iss' => issuer + }.merge(additional_claims.stringify_keys) + end + + def generate_iam_jwt( + payload:, + private_key:, + kid:, algorithm: DEFAULT_ALGORITHM) + headers = { kid: kid } + JWT.encode(payload, private_key, algorithm, headers) + end + + def create_valid_iam_jwt( + user:, + private_key:, issuer:, kid:, scopes: ['api'], + expires_at: 1.hour.from_now, + **additional_claims + ) + user_id = user.is_a?(User) ? user.id : user + + payload = build_iam_jwt_payload( + user_id: user_id, + scopes: scopes, + expires_at: expires_at, + issuer: issuer, + **additional_claims + ) + + token = generate_iam_jwt( + payload: payload, + private_key: private_key, + kid: kid + ) + + { + token: token, + payload: payload, + private_key: private_key, + public_key: private_key.public_key + } + end + + def create_expired_iam_jwt( + user:, + private_key:, issuer:, kid:, expired_at: 1.hour.ago, + scopes: ['api'], + **additional_claims + ) + create_valid_iam_jwt( + user: user, + scopes: scopes, + private_key: private_key, + expires_at: expired_at, + issuer: issuer, + kid: kid, + **additional_claims + ) + end + + def create_iam_jwt_with_invalid_signature( + user:, + issuer:, + kid:, + scopes: ['api'], + **additional_claims + ) + signing_key = generate_rsa_key + result = create_valid_iam_jwt( + user: user, + scopes: scopes, + private_key: signing_key, + issuer: issuer, + kid: kid, + **additional_claims + ) + + verification_key = generate_rsa_key + + { + token: result[:token], + payload: result[:payload], + signing_key: signing_key, + verification_key: verification_key + } + end + + def create_iam_jwt_with_invalid_issuer( + user:, + issuer:, + private_key:, + kid:, + **additional_claims + ) + create_valid_iam_jwt( + user: user, + private_key: private_key, + issuer: issuer, + kid: kid, + **additional_claims + ) + end + + def create_iam_jwt_missing_claims( + user:, + private_key:, issuer:, kid:, missing_claims: ['jti'], + **additional_claims + ) + user_id = user.is_a?(User) ? user.id : user + + payload = build_iam_jwt_payload( + user_id: user_id, + issuer: issuer, + **additional_claims + ) + + missing_claims.each { |claim| payload.delete(claim) } + + token = generate_iam_jwt( + payload: payload, + private_key: private_key, + kid: kid + ) + + { + token: token, + payload: payload, + private_key: private_key, + public_key: private_key.public_key + } + end + + def create_malformed_jwt(type: :missing_segment) + case type + when :missing_segment + 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0' + when :invalid_base64 + 'eyJhbGciOiJSUzI1NiJ9.invalid!!!base64.signature' + when :invalid_json + 'eyJhbGciOiJSUzI1NiJ9.bm90IGpzb24.signature' + when :empty + '' + when :not_jwt + 'this-is-not-a-jwt-token' + else + raise ArgumentError, "Unknown malformation type: #{type}" + end + end + + def create_jwks(public_key, kid:) + jwk = JWT::JWK.new(public_key, { kid: kid, use: 'sig', alg: DEFAULT_ALGORITHM }) + JWT::JWK::Set.new(jwk) + end + + def create_jwks_response(public_key, kid:) + jwk = JWT::JWK.new(public_key, { kid: kid, use: 'sig', alg: DEFAULT_ALGORITHM }) + { keys: [jwk.export] } + end + + def stub_iam_service_config(url:, issuer:, enabled: true) + allow(Gitlab.config.iam_service).to receive_messages(enabled: enabled, url: url, issuer: issuer) + end + + def stub_iam_jwks_endpoint( + public_key, + url:, + kid:, + status: 200 + ) + jwks_url = URI.join(url, '/.well-known/jwks.json').to_s + jwks_response = create_jwks_response(public_key, kid: kid) + + stub_request(:get, jwks_url) + .to_return( + status: status, + body: jwks_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + def stub_jwks_endpoint_error(url:, status: 500, body: 'Internal Server Error') + jwks_url = URI.join(url, '/.well-known/jwks.json').to_s + + stub_request(:get, jwks_url) + .to_return(status: status, body: body) + end + + def stub_jwks_endpoint_connection_error(url:, error: Errno::ECONNREFUSED) + jwks_url = URI.join(url, '/.well-known/jwks.json').to_s + + stub_request(:get, jwks_url) + .to_raise(error) + end + + def decode_jwt_unverified(token) + JWT.decode(token, nil, false).first + rescue JWT::DecodeError => e + raise "Failed to decode JWT: #{e.message}" + end + end + end +end -- GitLab From 330231676d4d8eae3c726ce08ca0c1195f697c5d Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Fri, 12 Dec 2025 18:33:56 +0100 Subject: [PATCH 17/21] Update IAM JWT payload claims Changes the JWT payload format to align with the proposal. The 'sub' claim now uses a prefixed format user:". The 'scp' claim has been replaced with the standard 'scope'. --- .../authn/iam/jwt_authentication_service.rb | 16 ++++-- spec/factories/authn/iam_jwt_tokens.rb | 6 +-- .../iam/jwt_authentication_service_spec.rb | 50 +++++++++++++++++-- spec/support/helpers/authn/iam/jwt_helper.rb | 4 +- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/app/services/authn/iam/jwt_authentication_service.rb b/app/services/authn/iam/jwt_authentication_service.rb index 481c507e44f97c..805c0f99097a72 100644 --- a/app/services/authn/iam/jwt_authentication_service.rb +++ b/app/services/authn/iam/jwt_authentication_service.rb @@ -20,7 +20,7 @@ def execute return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(token_string) payload = decode_and_validate! - user_id = payload['sub'] + user_id = extract_user_id(payload) # WORKAROUND: Check user-scoped feature flag for gradual production rollout # This ensures we only process IAM JWTs for opted-in users, minimizing @@ -101,8 +101,16 @@ def decode_options end def extract_scopes(payload) - scopes = payload['scp'] || [] - scopes.map(&:to_sym) + return [] if payload['scope'].blank? + + Array(payload['scope']).flat_map(&:split).map(&:to_sym) + end + + def extract_user_id(payload) + match = payload['sub'].match(/\Auser:(\d+)\z/) + raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, 'Invalid sub claim format, expected "user:"' unless match + + match[1].to_i end # WORKAROUND: Check user-scoped feature flag for gradual production rollout @@ -130,7 +138,7 @@ def log_successful_authentication(user_id, payload) user_id: user_id, token_jti: payload['jti'], token_exp: Time.zone.at(payload['exp']), - scopes: payload['scp'] + scopes: payload['scope'] ) end diff --git a/spec/factories/authn/iam_jwt_tokens.rb b/spec/factories/authn/iam_jwt_tokens.rb index 10b6e3a872d5ad..a787b030ab7470 100644 --- a/spec/factories/authn/iam_jwt_tokens.rb +++ b/spec/factories/authn/iam_jwt_tokens.rb @@ -16,8 +16,8 @@ initialize_with do payload = { - 'sub' => user.id.to_s, - 'scp' => Array(scopes).map(&:to_s), + 'sub' => "user:#{user.id}", + 'scope' => Array(scopes).map(&:to_s).join(' '), 'jti' => jti, 'exp' => expires_at.is_a?(Integer) ? expires_at : expires_at.to_i, 'iat' => issued_at.is_a?(Integer) ? issued_at : issued_at.to_i, @@ -25,7 +25,7 @@ } new( - user_id: user.id.to_s, + user_id: user.id, scopes: scopes, jti: jti, expires_at: expires_at, diff --git a/spec/services/authn/iam/jwt_authentication_service_spec.rb b/spec/services/authn/iam/jwt_authentication_service_spec.rb index 21c9391d28bf39..3d5bf849dc77fe 100644 --- a/spec/services/authn/iam/jwt_authentication_service_spec.rb +++ b/spec/services/authn/iam/jwt_authentication_service_spec.rb @@ -99,7 +99,7 @@ token = result.payload[:token] expect(token).to be_a(Gitlab::Auth::Iam::Jwt::Token) - expect(token.user_id).to eq(user.id.to_s) + expect(token.user_id).to eq(user.id) expect(token.scopes).to eq(['api']) expect(token.jti).to eq(jwt_data[:payload]['jti']) end @@ -108,7 +108,7 @@ expect(Gitlab::AppLogger).to receive(:info).with( hash_including( message: 'IAM JWT authentication successful', - user_id: user.id.to_s, + user_id: user.id, token_jti: jwt_data[:payload]['jti'] ) ) @@ -276,7 +276,7 @@ let(:jwt_data) do create_iam_jwt_missing_claims( user: user, - missing_claims: ['scp'], + missing_claims: ['scope'], issuer: iam_issuer, private_key: private_key, kid: iam_key_id @@ -296,6 +296,50 @@ expect(token.scopes).to eq([]) end end + + context 'when sub claim has invalid format' do + let(:jwt_data) do + create_valid_iam_jwt( + user: user, + issuer: iam_issuer, + private_key: private_key, + kid: iam_key_id, + sub: 'invalid-format' + ) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + it 'returns error with invalid_token reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to include('Invalid sub claim format') + end + end + + context 'when sub claim is missing user prefix' do + let(:jwt_data) do + create_valid_iam_jwt( + user: user, + issuer: iam_issuer, + private_key: private_key, + kid: iam_key_id, + sub: '12345' + ) + end + + let(:service) { described_class.new(token: jwt_data[:token]) } + + it 'returns error with invalid_token reason' do + result = service.execute + + expect(result).to be_error + expect(result.reason).to eq(:invalid_token) + expect(result.message).to include('Invalid sub claim format') + end + end end end end diff --git a/spec/support/helpers/authn/iam/jwt_helper.rb b/spec/support/helpers/authn/iam/jwt_helper.rb index 2cf995bfd51a60..6c78a53888a06c 100644 --- a/spec/support/helpers/authn/iam/jwt_helper.rb +++ b/spec/support/helpers/authn/iam/jwt_helper.rb @@ -18,8 +18,8 @@ def build_iam_jwt_payload( **additional_claims ) { - 'sub' => user_id.to_s, - 'scp' => Array(scopes).map(&:to_s), + 'sub' => "user:#{user_id}", + 'scope' => Array(scopes).map(&:to_s).join(' '), 'jti' => jti, 'exp' => expires_at.is_a?(Integer) ? expires_at : expires_at.to_i, 'iat' => issued_at.is_a?(Integer) ? issued_at : issued_at.to_i, -- GitLab From f46c67804000ca06b83294a6d0e0eff16cbf614c Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 15 Dec 2025 16:34:14 +0100 Subject: [PATCH 18/21] Refactor IAM JWT authentication naming conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves naming to separate IAM service concerns from JWT format details: - Rename JwtAuthenticationService → AuthenticationService - Move Jwt::Token to AccessToken - Move Jwt::JwksClient to JwksClient - Extract IAM service config from Jwt module to parent Iam module - Update all specs to match new structure --- .../authn/iam/authentication_service.rb | 162 ++++++++++++++++++ lib/gitlab/auth.rb | 6 +- lib/gitlab/auth/auth_finders.rb | 4 +- lib/gitlab/auth/iam.rb | 26 ++- lib/gitlab/auth/iam/access_token.rb | 64 +++++++ lib/gitlab/auth/iam/jwks_client.rb | 61 +++++++ lib/gitlab/auth/iam/jwt.rb | 50 ++---- spec/factories/authn/iam_jwt_tokens.rb | 2 +- spec/lib/gitlab/auth/auth_finders_spec.rb | 12 +- ...spec.rb => authentication_service_spec.rb} | 6 +- 10 files changed, 340 insertions(+), 53 deletions(-) create mode 100644 app/services/authn/iam/authentication_service.rb create mode 100644 lib/gitlab/auth/iam/access_token.rb create mode 100644 lib/gitlab/auth/iam/jwks_client.rb rename spec/services/authn/iam/{jwt_authentication_service_spec.rb => authentication_service_spec.rb} (97%) diff --git a/app/services/authn/iam/authentication_service.rb b/app/services/authn/iam/authentication_service.rb new file mode 100644 index 00000000000000..24e66d482aae5f --- /dev/null +++ b/app/services/authn/iam/authentication_service.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +module Authn + module Iam + class AuthenticationService + include Gitlab::Utils::StrongMemoize + + attr_reader :token_string + + # Note: suggested by Duo + CLOCK_SKEW_SECONDS = 30 + + def initialize(token:) + @token_string = token + @retry_attempted = false + end + + def execute + return error_disabled unless Gitlab::Auth::Iam.enabled? + return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_format?(token_string) + + payload = decode_and_validate! + user_id = extract_user_id(payload) + + # WORKAROUND: Check user-scoped feature flag for gradual production rollout + # This ensures we only process IAM JWTs for opted-in users, minimizing + # impact on other users. Remove this once fully rolled out. + # TODO: Remove user-scoped FF check after production validation + return error_feature_disabled unless feature_enabled_for_user?(user_id) + + # TODO: Discuss - should this be before validation? + + scopes = extract_scopes(payload) + + log_successful_authentication(user_id, payload) + + ServiceResponse.success(payload: { + token: Gitlab::Auth::Iam::AccessToken.new( + user_id: user_id, + scopes: scopes, + jti: payload['jti'], + expires_at: Time.zone.at(payload['exp']), + issued_at: Time.zone.at(payload['iat']), + payload: payload + ) + }) + rescue Gitlab::Auth::Iam::Jwt::JwksFetchFailedError => e + error_response(e.message, :service_unavailable) + rescue Gitlab::Auth::Iam::Error => e + error_response(e.message, :invalid_token) + end + + private + + def decode_and_validate! + payload, _ = JWT.decode( + token_string, + nil, + true, + decode_options + ) + + payload + rescue JWT::ExpiredSignature + raise Gitlab::Auth::Iam::Jwt::TokenExpiredError, 'Token has expired' + rescue JWT::InvalidIssuerError + raise Gitlab::Auth::Iam::Jwt::InvalidIssuerError, 'Invalid token issuer' + rescue JWT::InvalidAudError + raise Gitlab::Auth::Iam::Jwt::InvalidAudienceError, 'Invalid token audience' + rescue JWT::VerificationError => e + handle_verification_error(e) + rescue JWT::DecodeError => e + raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format: #{e.message}" + end + + def handle_verification_error(error) + if @retry_attempted + raise Gitlab::Auth::Iam::Jwt::InvalidSignatureError, + "Signature verification failed: #{error.message}" + end + + @retry_attempted = true + jwks_client.clear_cache + decode_and_validate! + end + + # TODO: aud in `Gitlab::Auth::Iam`? + # aud: Gitlab.config.iam_service.audience, + def decode_options + { + algorithms: Gitlab::Auth::Iam::Jwt::ALLOWED_ALGORITHMS, + jwks: jwks_client.fetch_keys, + required_claims: %w[sub jti exp iat], + verify_iss: true, + iss: Gitlab::Auth::Iam.issuer, + verify_aud: true, + verify_iat: true, + verify_exp: true, + leeway: CLOCK_SKEW_SECONDS + } + end + + def extract_scopes(payload) + return [] if payload['scope'].blank? + + Array(payload['scope']).flat_map(&:split).map(&:to_sym) + end + + def extract_user_id(payload) + match = payload['sub'].match(/\Auser:(\d+)\z/) + raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, 'Invalid sub claim format, expected "user:"' unless match + + match[1].to_i + end + + # WORKAROUND: Check user-scoped feature flag for gradual production rollout + # This is temporary - allows testing IAM JWT with specific users in production + # without impacting all users. The user lookup here is acceptable because: + # 1. It only happens for valid IAM JWTs (after signature verification) + # 2. It fails fast before creating token object + # 3. It's a temporary workaround for safe production testing + # TODO: Remove this method once IAM JWT is fully rolled out + def feature_enabled_for_user?(user_id) + user = User.find_by(id: user_id) # rubocop:disable CodeReuse/ActiveRecord -- Temporary workaround for safe production testing + return false unless user + + Feature.enabled?(:iam_svc_oauth, user) + end + + def jwks_client + Gitlab::Auth::Iam::JwksClient.new + end + strong_memoize_attr :jwks_client + + def log_successful_authentication(user_id, payload) + Gitlab::AppLogger.info( + message: 'IAM JWT authentication successful', + user_id: user_id, + token_jti: payload['jti'], + token_exp: Time.zone.at(payload['exp']), + scopes: payload['scope'] + ) + end + + def error_disabled + ServiceResponse.error(message: 'IAM JWT authentication is disabled', reason: :disabled) + end + + def error_invalid_format + ServiceResponse.error(message: 'Invalid token format', reason: :invalid_format) + end + + def error_feature_disabled + ServiceResponse.error(message: 'IAM JWT authentication not enabled for this user', reason: :feature_disabled) + end + + def error_response(message, reason) + ServiceResponse.error(message: message, reason: reason) + end + end + end +end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 68db0f1f1c2bb7..f629533ea035f9 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -282,10 +282,10 @@ def user_with_password_for_git(login, password, request: nil) end def iam_jwt_token_check(password, project) - return unless Gitlab::Auth::Iam::Jwt.enabled? + return unless Gitlab::Auth::Iam.enabled? return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(password) - result = ::Authn::Iam::JwtAuthenticationService.new(token: password).execute + result = ::Authn::Iam::AuthenticationService.new(token: password).execute return unless result.success? token = result.payload[:token] @@ -306,7 +306,7 @@ def iam_jwt_token_check(password, project) abilities = abilities_for_scopes(token.scopes) Gitlab::Auth::Result.new(user, project, :iam_jwt, abilities) - rescue Gitlab::Auth::Iam::Jwt::Error + rescue Gitlab::Auth::Iam::Error nil end diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index 41192f280c778a..a7426d4bc53a36 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -327,14 +327,14 @@ def access_token end def find_iam_jwt_access_token - return unless Gitlab::Auth::Iam::Jwt.enabled? + return unless Gitlab::Auth::Iam.enabled? self.current_token = parsed_oauth_token return unless current_token return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(current_token) - result = ::Authn::Iam::JwtAuthenticationService.new(token: current_token).execute + result = ::Authn::Iam::AuthenticationService.new(token: current_token).execute unless result.success? log_iam_jwt_failure(result) diff --git a/lib/gitlab/auth/iam.rb b/lib/gitlab/auth/iam.rb index 432be42585681c..ecdbdcfdeeae13 100644 --- a/lib/gitlab/auth/iam.rb +++ b/lib/gitlab/auth/iam.rb @@ -3,7 +3,31 @@ module Gitlab module Auth module Iam - # Parent module for IAM-related authentication + # Base error for IAM authentication (format-agnostic) + Error = Class.new(Gitlab::Auth::AuthenticationError) + + # IAM service errors (format-agnostic) + ConfigurationError = Class.new(Error) + ServiceUnavailableError = Class.new(Error) + + class << self + include Gitlab::Utils::StrongMemoize + + # IAM service configuration (format-agnostic) + def service_url + Gitlab.config.iam_service.url + end + strong_memoize_attr :service_url + + def issuer + Gitlab.config.iam_service.issuer + end + strong_memoize_attr :issuer + + def enabled? + Gitlab.config.iam_service.enabled + end + end end end end diff --git a/lib/gitlab/auth/iam/access_token.rb b/lib/gitlab/auth/iam/access_token.rb new file mode 100644 index 00000000000000..08708fa6ef499d --- /dev/null +++ b/lib/gitlab/auth/iam/access_token.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module Iam + class AccessToken + include Gitlab::Utils::StrongMemoize + + attr_reader :user_id, :scopes, :jti, :expires_at, :issued_at, :payload + + def initialize(user_id:, scopes:, jti:, expires_at:, issued_at:, payload:) + @user_id = user_id + @scopes = Array(scopes).map(&:to_s) + @jti = jti + @expires_at = expires_at + @issued_at = issued_at + @payload = payload + end + + # Lazy load user (follows OAuth token association pattern) + # Uses User.id_in to avoid Rubocop CodeReuse/ActiveRecord violation + def user + User.id_in(user_id).first + end + strong_memoize_attr :user + + # For compatibility with AccessTokenValidationService + def resource_owner_id + user_id + end + + def expired? + expires_at.present? && expires_at.past? + end + + def revoked? + false # IAM JWTs are stateless + # TODO: to be discussed + end + + def reload + clear_memoization(:user) + self + end + + def id + jti + end + + def has_attribute?(_attribute) + false + end + + def active? + !expired? && !revoked? + end + + def to_s + "Iam::AccessToken(jti: #{jti}, user_id: #{user_id})" + end + end + end + end +end diff --git a/lib/gitlab/auth/iam/jwks_client.rb b/lib/gitlab/auth/iam/jwks_client.rb new file mode 100644 index 00000000000000..384ef44d2b9c10 --- /dev/null +++ b/lib/gitlab/auth/iam/jwks_client.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Gitlab + module Auth + module Iam + class JwksClient + include Gitlab::Utils::StrongMemoize + + JWKS_PATH = '/.well-known/jwks.json' + CACHE_TTL = 3600.seconds + + def fetch_keys + Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do + fetch_keys_from_service + end + end + + def refresh_keys + clear_cache + fetch_keys + end + + def clear_cache + Rails.cache.delete(cache_key) + end + + private + + def fetch_keys_from_service + response = Gitlab::HTTP.get(endpoint) + + unless response.success? + raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, + "Failed to fetch JWKS: HTTP #{response.code}" + end + + JWT::JWK::Set.new(response.parsed_response) + rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e + Gitlab::ErrorTracking.track_exception(e) + raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Cannot connect to IAM service: #{e.message}" + rescue JSON::ParserError => e + raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Invalid JWKS response: #{e.message}" + end + + def endpoint + url = Iam.service_url + raise Iam::ConfigurationError, 'IAM service URL is not configured' if url.nil? + + URI.join(url, JWKS_PATH).to_s + end + strong_memoize_attr :endpoint + + def cache_key + issuer_hash = Digest::SHA256.hexdigest(Iam.issuer)[0..8] + "iam:jwks:#{issuer_hash}" + end + strong_memoize_attr :cache_key + end + end + end +end diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index c822a57ae4cb7b..620e7506b4161c 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -6,64 +6,40 @@ module Gitlab module Auth module Iam module Jwt - # Base error inherits from existing auth error hierarchy - Error = Class.new(Gitlab::Auth::AuthenticationError) - - # JWT validation errors (401) - TokenExpiredError = Class.new(Error) - InvalidSignatureError = Class.new(Error) - InvalidIssuerError = Class.new(Error) - InvalidAudienceError = Class.new(Error) - MalformedTokenError = Class.new(Error) - MissingClaimError = Class.new(Error) + # JWT-specific validation errors (401) + TokenExpiredError = Class.new(Iam::Error) + InvalidSignatureError = Class.new(Iam::Error) + InvalidIssuerError = Class.new(Iam::Error) + InvalidAudienceError = Class.new(Iam::Error) + MalformedTokenError = Class.new(Iam::Error) + MissingClaimError = Class.new(Iam::Error) # JWKS errors (503) - JwksFetchFailedError = Class.new(Error) - JwksKeyMissingError = Class.new(Error) + JwksFetchFailedError = Class.new(Iam::Error) + JwksKeyMissingError = Class.new(Iam::Error) # Scope errors (403) - InsufficientScopeError = Class.new(Error) - - # Configuration errors - ConfigurationError = Class.new(Error) - - # TODO: Implement more specific errors + InsufficientScopeError = Class.new(Iam::Error) ALLOWED_ALGORITHMS = ['RS256'].freeze class << self - include Gitlab::Utils::StrongMemoize - # Lightweight format check - does NOT decode JWT # Used for fast rejection of non-JWT tokens - def valid_jwt_format?(token_string) + def valid_format?(token_string) token_string.is_a?(String) && token_string.start_with?('ey') && token_string.count('.') == 2 end def issued_by_iam_service?(token_string) - return false unless valid_jwt_format?(token_string) + return false unless valid_format?(token_string) unverified_payload, _ = JWT.decode(token_string, nil, false) - unverified_payload['iss'] == iam_service_issuer + unverified_payload['iss'] == Iam.issuer rescue JWT::DecodeError false end - - def iam_service_url - Gitlab.config.iam_service.url - end - strong_memoize_attr :iam_service_url - - def iam_service_issuer - Gitlab.config.iam_service.issuer - end - strong_memoize_attr :iam_service_issuer - - def enabled? - Gitlab.config.iam_service.enabled - end end end end diff --git a/spec/factories/authn/iam_jwt_tokens.rb b/spec/factories/authn/iam_jwt_tokens.rb index a787b030ab7470..0e57189b26032b 100644 --- a/spec/factories/authn/iam_jwt_tokens.rb +++ b/spec/factories/authn/iam_jwt_tokens.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true FactoryBot.define do - factory :iam_jwt_token, class: 'Gitlab::Auth::Iam::Jwt::Token' do + factory :iam_jwt_token, class: 'Gitlab::Auth::Iam::AccessToken' do transient do user { association(:user) } scopes { ['api'] } diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 1e16783d0221dc..77b96cb52aebfe 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -893,7 +893,7 @@ def set_bearer_token(token) before do set_bearer_token('valid-iam-jwt-token') allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(true) - allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow_next_instance_of(Authn::Iam::AuthenticationService) do |service| allow(service).to receive(:execute).and_return(service_result) end end @@ -909,7 +909,7 @@ def set_bearer_token(token) end it 'calls authentication service with the token' do - expect(Authn::Iam::JwtAuthenticationService).to receive(:new).with(token: 'valid-iam-jwt-token').and_call_original + expect(Authn::Iam::AuthenticationService).to receive(:new).with(token: 'valid-iam-jwt-token').and_call_original subject end @@ -926,7 +926,7 @@ def set_bearer_token(token) end it 'does not call authentication service' do - expect(Authn::Iam::JwtAuthenticationService).not_to receive(:new) + expect(Authn::Iam::AuthenticationService).not_to receive(:new) subject end @@ -942,7 +942,7 @@ def set_bearer_token(token) let(:service_result) { ServiceResponse.error(message: 'Not enabled', reason: :feature_disabled) } before do - allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow_next_instance_of(Authn::Iam::AuthenticationService) do |service| allow(service).to receive(:execute).and_return(service_result) end end @@ -967,7 +967,7 @@ def set_bearer_token(token) let(:service_result) { ServiceResponse.error(message: 'Token has expired', reason: :invalid_token) } before do - allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow_next_instance_of(Authn::Iam::AuthenticationService) do |service| allow(service).to receive(:execute).and_return(service_result) end end @@ -993,7 +993,7 @@ def set_bearer_token(token) let(:service_result) { ServiceResponse.error(message: 'Connection failed', reason: :service_unavailable) } before do - allow_next_instance_of(Authn::Iam::JwtAuthenticationService) do |service| + allow_next_instance_of(Authn::Iam::AuthenticationService) do |service| allow(service).to receive(:execute).and_return(service_result) end end diff --git a/spec/services/authn/iam/jwt_authentication_service_spec.rb b/spec/services/authn/iam/authentication_service_spec.rb similarity index 97% rename from spec/services/authn/iam/jwt_authentication_service_spec.rb rename to spec/services/authn/iam/authentication_service_spec.rb index 3d5bf849dc77fe..ca615d469c9789 100644 --- a/spec/services/authn/iam/jwt_authentication_service_spec.rb +++ b/spec/services/authn/iam/authentication_service_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Authn::Iam::JwtAuthenticationService, :aggregate_failures, feature_category: :system_access do +RSpec.describe Authn::Iam::AuthenticationService, :aggregate_failures, feature_category: :system_access do include Authn::Iam::JwtHelper let_it_be(:user) { create(:user) } @@ -98,7 +98,7 @@ result = service.execute token = result.payload[:token] - expect(token).to be_a(Gitlab::Auth::Iam::Jwt::Token) + expect(token).to be_a(Gitlab::Auth::Iam::AccessToken) expect(token.user_id).to eq(user.id) expect(token.scopes).to eq(['api']) expect(token.jti).to eq(jwt_data[:payload]['jti']) @@ -201,7 +201,7 @@ end it 'retries once with cleared cache' do - expect_next_instance_of(Gitlab::Auth::Iam::Jwt::JwksClient) do |client| + expect_next_instance_of(Gitlab::Auth::Iam::JwksClient) do |client| expect(client).to receive(:clear_cache).once.and_call_original end -- GitLab From 7d652d284594f3c48ae554d8ed26677b1b74cf1f Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 15 Dec 2025 17:13:33 +0100 Subject: [PATCH 19/21] Better cleanup Remove additional files and fix left over implementations. --- .../authn/iam/jwt_authentication_service.rb | 162 ------------------ lib/gitlab/auth.rb | 2 +- lib/gitlab/auth/auth_finders.rb | 2 +- lib/gitlab/auth/iam/jwt.rb | 2 +- lib/gitlab/auth/iam/jwt/jwks_client.rb | 63 ------- lib/gitlab/auth/iam/jwt/token.rb | 66 ------- spec/lib/gitlab/auth/auth_finders_spec.rb | 6 +- 7 files changed, 6 insertions(+), 297 deletions(-) delete mode 100644 app/services/authn/iam/jwt_authentication_service.rb delete mode 100644 lib/gitlab/auth/iam/jwt/jwks_client.rb delete mode 100644 lib/gitlab/auth/iam/jwt/token.rb diff --git a/app/services/authn/iam/jwt_authentication_service.rb b/app/services/authn/iam/jwt_authentication_service.rb deleted file mode 100644 index 805c0f99097a72..00000000000000 --- a/app/services/authn/iam/jwt_authentication_service.rb +++ /dev/null @@ -1,162 +0,0 @@ -# frozen_string_literal: true - -module Authn - module Iam - class JwtAuthenticationService - include Gitlab::Utils::StrongMemoize - - attr_reader :token_string - - # Note: suggested by Duo - CLOCK_SKEW_SECONDS = 30 - - def initialize(token:) - @token_string = token - @retry_attempted = false - end - - def execute - return error_disabled unless Gitlab::Auth::Iam::Jwt.enabled? - return error_invalid_format unless Gitlab::Auth::Iam::Jwt.valid_jwt_format?(token_string) - - payload = decode_and_validate! - user_id = extract_user_id(payload) - - # WORKAROUND: Check user-scoped feature flag for gradual production rollout - # This ensures we only process IAM JWTs for opted-in users, minimizing - # impact on other users. Remove this once fully rolled out. - # TODO: Remove user-scoped FF check after production validation - return error_feature_disabled unless feature_enabled_for_user?(user_id) - - # TODO: Discuss - should this be before validation? - - scopes = extract_scopes(payload) - - log_successful_authentication(user_id, payload) - - ServiceResponse.success(payload: { - token: Gitlab::Auth::Iam::Jwt::Token.new( - user_id: user_id, - scopes: scopes, - jti: payload['jti'], - expires_at: Time.zone.at(payload['exp']), - issued_at: Time.zone.at(payload['iat']), - payload: payload - ) - }) - rescue Gitlab::Auth::Iam::Jwt::JwksFetchFailedError => e - error_response(e.message, :service_unavailable) - rescue Gitlab::Auth::Iam::Jwt::Error => e - error_response(e.message, :invalid_token) - end - - private - - def decode_and_validate! - payload, _ = JWT.decode( - token_string, - nil, - true, - decode_options - ) - - payload - rescue JWT::ExpiredSignature - raise Gitlab::Auth::Iam::Jwt::TokenExpiredError, 'Token has expired' - rescue JWT::InvalidIssuerError - raise Gitlab::Auth::Iam::Jwt::InvalidIssuerError, 'Invalid token issuer' - rescue JWT::InvalidAudError - raise Gitlab::Auth::Iam::Jwt::InvalidAudienceError, 'Invalid token audience' - rescue JWT::VerificationError => e - handle_verification_error(e) - rescue JWT::DecodeError => e - raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, "Invalid token format: #{e.message}" - end - - def handle_verification_error(error) - if @retry_attempted - raise Gitlab::Auth::Iam::Jwt::InvalidSignatureError, - "Signature verification failed: #{error.message}" - end - - @retry_attempted = true - jwks_client.clear_cache - decode_and_validate! - end - - # TODO: aud in `Gitlab::Auth::Iam::Jwt`? - # aud: Gitlab.config.iam_service.audience, - def decode_options - { - algorithms: Gitlab::Auth::Iam::Jwt::ALLOWED_ALGORITHMS, - jwks: jwks_client.fetch_keys, - required_claims: %w[sub jti exp iat], - verify_iss: true, - iss: Gitlab::Auth::Iam::Jwt.iam_service_issuer, - verify_aud: true, - verify_iat: true, - verify_exp: true, - leeway: CLOCK_SKEW_SECONDS - } - end - - def extract_scopes(payload) - return [] if payload['scope'].blank? - - Array(payload['scope']).flat_map(&:split).map(&:to_sym) - end - - def extract_user_id(payload) - match = payload['sub'].match(/\Auser:(\d+)\z/) - raise Gitlab::Auth::Iam::Jwt::MalformedTokenError, 'Invalid sub claim format, expected "user:"' unless match - - match[1].to_i - end - - # WORKAROUND: Check user-scoped feature flag for gradual production rollout - # This is temporary - allows testing IAM JWT with specific users in production - # without impacting all users. The user lookup here is acceptable because: - # 1. It only happens for valid IAM JWTs (after signature verification) - # 2. It fails fast before creating token object - # 3. It's a temporary workaround for safe production testing - # TODO: Remove this method once IAM JWT is fully rolled out - def feature_enabled_for_user?(user_id) - user = User.find_by(id: user_id) # rubocop:disable CodeReuse/ActiveRecord -- Temporary workaround for safe production testing - return false unless user - - Feature.enabled?(:iam_svc_oauth, user) - end - - def jwks_client - Gitlab::Auth::Iam::Jwt::JwksClient.new - end - strong_memoize_attr :jwks_client - - def log_successful_authentication(user_id, payload) - Gitlab::AppLogger.info( - message: 'IAM JWT authentication successful', - user_id: user_id, - token_jti: payload['jti'], - token_exp: Time.zone.at(payload['exp']), - scopes: payload['scope'] - ) - end - - def error_disabled - ServiceResponse.error(message: 'IAM JWT authentication is disabled', reason: :disabled) - end - - def error_invalid_format - ServiceResponse.error(message: 'Invalid token format', reason: :invalid_format) - end - - def error_feature_disabled - ServiceResponse.error(message: 'IAM JWT authentication not enabled for this user', reason: :feature_disabled) - end - - def error_response(message, reason) - ServiceResponse.error(message: message, reason: reason) - end - end - end -end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index f629533ea035f9..cfa9701d99c7e4 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -283,7 +283,7 @@ def user_with_password_for_git(login, password, request: nil) def iam_jwt_token_check(password, project) return unless Gitlab::Auth::Iam.enabled? - return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(password) + return unless Gitlab::Auth::Iam::Jwt.iam_issued_jwt?(password) result = ::Authn::Iam::AuthenticationService.new(token: password).execute return unless result.success? diff --git a/lib/gitlab/auth/auth_finders.rb b/lib/gitlab/auth/auth_finders.rb index a7426d4bc53a36..c565897ac5f709 100644 --- a/lib/gitlab/auth/auth_finders.rb +++ b/lib/gitlab/auth/auth_finders.rb @@ -332,7 +332,7 @@ def find_iam_jwt_access_token self.current_token = parsed_oauth_token return unless current_token - return unless Gitlab::Auth::Iam::Jwt.issued_by_iam_service?(current_token) + return unless Gitlab::Auth::Iam::Jwt.iam_issued_jwt?(current_token) result = ::Authn::Iam::AuthenticationService.new(token: current_token).execute diff --git a/lib/gitlab/auth/iam/jwt.rb b/lib/gitlab/auth/iam/jwt.rb index 620e7506b4161c..adacf1b7362797 100644 --- a/lib/gitlab/auth/iam/jwt.rb +++ b/lib/gitlab/auth/iam/jwt.rb @@ -32,7 +32,7 @@ def valid_format?(token_string) token_string.count('.') == 2 end - def issued_by_iam_service?(token_string) + def iam_issued_jwt?(token_string) return false unless valid_format?(token_string) unverified_payload, _ = JWT.decode(token_string, nil, false) diff --git a/lib/gitlab/auth/iam/jwt/jwks_client.rb b/lib/gitlab/auth/iam/jwt/jwks_client.rb deleted file mode 100644 index bd1c1c01a818ac..00000000000000 --- a/lib/gitlab/auth/iam/jwt/jwks_client.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Auth - module Iam - module Jwt - class JwksClient - include Gitlab::Utils::StrongMemoize - - JWKS_PATH = '/.well-known/jwks.json' - CACHE_TTL = 3600.seconds - - def fetch_keys - Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do - fetch_keys_from_service - end - end - - def refresh_keys - clear_cache - fetch_keys - end - - def clear_cache - Rails.cache.delete(cache_key) - end - - private - - def fetch_keys_from_service - response = Gitlab::HTTP.get(endpoint) - - unless response.success? - raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, - "Failed to fetch JWKS: HTTP #{response.code}" - end - - JWT::JWK::Set.new(response.parsed_response) - rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout => e - Gitlab::ErrorTracking.track_exception(e) - raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Cannot connect to IAM service: #{e.message}" - rescue JSON::ParserError => e - raise Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, "Invalid JWKS response: #{e.message}" - end - - def endpoint - url = Gitlab::Auth::Iam::Jwt.iam_service_url - raise Gitlab::Auth::Iam::Jwt::ConfigurationError, 'IAM service URL is not configured' if url.nil? - - URI.join(url, JWKS_PATH).to_s - end - strong_memoize_attr :endpoint - - def cache_key - issuer_hash = Digest::SHA256.hexdigest(Gitlab::Auth::Iam::Jwt.iam_service_issuer)[0..8] - "iam_jwt:jwks:#{issuer_hash}" - end - strong_memoize_attr :cache_key - end - end - end - end -end diff --git a/lib/gitlab/auth/iam/jwt/token.rb b/lib/gitlab/auth/iam/jwt/token.rb deleted file mode 100644 index bd519d659d6515..00000000000000 --- a/lib/gitlab/auth/iam/jwt/token.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Auth - module Iam - module Jwt - class Token - include Gitlab::Utils::StrongMemoize - - attr_reader :user_id, :scopes, :jti, :expires_at, :issued_at, :payload - - def initialize(user_id:, scopes:, jti:, expires_at:, issued_at:, payload:) - @user_id = user_id - @scopes = Array(scopes).map(&:to_s) - @jti = jti - @expires_at = expires_at - @issued_at = issued_at - @payload = payload - end - - # Lazy load user (follows OAuth token association pattern) - # Uses User.id_in to avoid Rubocop CodeReuse/ActiveRecord violation - def user - User.id_in(user_id).first - end - strong_memoize_attr :user - - # For compatibility with AccessTokenValidationService - def resource_owner_id - user_id - end - - def expired? - expires_at.present? && expires_at.past? - end - - def revoked? - false # IAM JWTs are stateless - # TODO: to be discussed - end - - def reload - clear_memoization(:user) - self - end - - def id - jti - end - - def has_attribute?(_attribute) - false - end - - def active? - !expired? && !revoked? - end - - def to_s - "Iam::Jwt::Token(jti: #{jti}, user_id: #{user_id})" - end - end - end - end - end -end diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index 77b96cb52aebfe..e411f0bcc0900a 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -892,7 +892,7 @@ def set_bearer_token(token) before do set_bearer_token('valid-iam-jwt-token') - allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(true) + allow(Gitlab::Auth::Iam::Jwt).to receive(:iam_issued_jwt?).and_return(true) allow_next_instance_of(Authn::Iam::AuthenticationService) do |service| allow(service).to receive(:execute).and_return(service_result) end @@ -918,7 +918,7 @@ def set_bearer_token(token) context 'when token is not issued by IAM service' do before do set_bearer_token(personal_access_token.token) - allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(false) + allow(Gitlab::Auth::Iam::Jwt).to receive(:iam_issued_jwt?).and_return(false) end it 'returns nil' do @@ -935,7 +935,7 @@ def set_bearer_token(token) context 'when authentication service returns error' do before do set_bearer_token('invalid-iam-jwt') - allow(Gitlab::Auth::Iam::Jwt).to receive(:issued_by_iam_service?).and_return(true) + allow(Gitlab::Auth::Iam::Jwt).to receive(:iam_issued_jwt?).and_return(true) end context 'when feature flag is disabled for user' do -- GitLab From b6d01da7bfb462d95587680888f91b87e2a21489 Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Mon, 15 Dec 2025 18:02:56 +0100 Subject: [PATCH 20/21] Implement tests for new implementation Implement the new tests: - spec/lib/gitlab/auth/iam/access_token_spec.rb - spec/lib/gitlab/auth/iam/jwks_client_spec.rb - spec/lib/gitlab/auth/iam/jwt_spec.rb - spec/lib/gitlab/auth/iam_spec.rb --- spec/factories/authn/iam_jwt_tokens.rb | 2 +- spec/lib/gitlab/auth/iam/access_token_spec.rb | 148 +++++++++++++++ spec/lib/gitlab/auth/iam/jwks_client_spec.rb | 173 ++++++++++++++++++ spec/lib/gitlab/auth/iam/jwt_spec.rb | 119 ++++++++++++ spec/lib/gitlab/auth/iam_spec.rb | 40 ++++ 5 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 spec/lib/gitlab/auth/iam/access_token_spec.rb create mode 100644 spec/lib/gitlab/auth/iam/jwks_client_spec.rb create mode 100644 spec/lib/gitlab/auth/iam/jwt_spec.rb create mode 100644 spec/lib/gitlab/auth/iam_spec.rb diff --git a/spec/factories/authn/iam_jwt_tokens.rb b/spec/factories/authn/iam_jwt_tokens.rb index 0e57189b26032b..101469b898d1b0 100644 --- a/spec/factories/authn/iam_jwt_tokens.rb +++ b/spec/factories/authn/iam_jwt_tokens.rb @@ -8,7 +8,7 @@ jti { SecureRandom.uuid } expires_at { 1.hour.from_now } issued_at { Time.current } - issuer { 'http://localhost:8084' } + issuer { 'https://iam.example.com' } private_key { OpenSSL::PKey::RSA.new(2048) } end diff --git a/spec/lib/gitlab/auth/iam/access_token_spec.rb b/spec/lib/gitlab/auth/iam/access_token_spec.rb new file mode 100644 index 00000000000000..0fff3e682e047c --- /dev/null +++ b/spec/lib/gitlab/auth/iam/access_token_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::Iam::AccessToken, feature_category: :system_access do + let_it_be(:user) { create(:user) } + + describe '#initialize' do + it 'converts scopes to strings' do + token = described_class.new( + user_id: user.id, + scopes: [:api, :read_repository], + jti: 'test-jti', + expires_at: 1.hour.from_now, + issued_at: Time.current, + payload: {} + ) + + expect(token.scopes).to all(be_a(String)) + end + end + + describe '#user' do + let(:token) { build(:iam_jwt_token, user: user) } + + it 'returns the user' do + expect(token.user).to eq(user) + end + + it 'memoizes the user' do + expect(User).to receive(:id_in).once.and_call_original + + 2.times { token.user } + end + + context 'when user does not exist' do + let(:token) do + described_class.new( + user_id: non_existing_record_id, + scopes: ['api'], + jti: 'test-jti', + expires_at: 1.hour.from_now, + issued_at: Time.current, + payload: {} + ) + end + + it 'returns nil' do + expect(token.user).to be_nil + end + end + end + + describe '#resource_owner_id' do + it 'returns user_id' do + token = build(:iam_jwt_token, user: user) + + expect(token.resource_owner_id).to eq(user.id) + end + end + + describe '#expired?' do + it 'returns false when not expired' do + token = build(:iam_jwt_token) + + expect(token).not_to be_expired + end + + it 'returns true when expired' do + token = build(:iam_jwt_token, :expired) + + expect(token).to be_expired + end + + it 'returns false when expires_at is nil' do + token = build(:iam_jwt_token, expires_at: nil) + + expect(token).not_to be_expired + end + end + + describe '#revoked?' do + it 'always returns false for stateless JWTs' do + token = build(:iam_jwt_token) + + expect(token).not_to be_revoked + end + end + + describe '#active?' do + it 'returns true when not expired' do + token = build(:iam_jwt_token) + + expect(token).to be_active + end + + it 'returns false when expired' do + token = build(:iam_jwt_token, :expired) + + expect(token).not_to be_active + end + end + + describe '#reload' do + let(:token) { build(:iam_jwt_token, user: user) } + + it 'clears memoized user and returns self' do + expect(token.user).to eq(user) + + expect(token.reload).to eq(token) + + expect(User).to receive(:id_in).and_call_original + token.user + end + end + + describe '#id' do + it 'returns jti' do + token = build(:iam_jwt_token, jti: 'unique-jti-123') + + expect(token.id).to eq('unique-jti-123') + end + end + + describe '#has_attribute?' do + it 'always returns false' do + token = build(:iam_jwt_token) + + expect(token.has_attribute?(:anything)).to be(false) + end + end + + describe '#to_s' do + it 'includes jti and user_id' do + token = build(:iam_jwt_token, user: user, jti: 'test-jti') + + expect(token.to_s).to eq("Iam::AccessToken(jti: test-jti, user_id: #{user.id})") + end + end + + describe 'AccessTokenValidationService compatibility' do + it 'responds to required methods' do + token = build(:iam_jwt_token) + + expect(token).to respond_to(:user, :resource_owner_id, :scopes, :expired?, :revoked?, :has_attribute?) + end + end +end diff --git a/spec/lib/gitlab/auth/iam/jwks_client_spec.rb b/spec/lib/gitlab/auth/iam/jwks_client_spec.rb new file mode 100644 index 00000000000000..0b0c988aac5332 --- /dev/null +++ b/spec/lib/gitlab/auth/iam/jwks_client_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::Iam::JwksClient, feature_category: :system_access do + include StubRequests + + let(:iam_service_url) { 'https://iam.example.com' } + let(:iam_service_issuer) { 'https://iam.example.com' } + let(:jwks_endpoint) { "#{iam_service_url}/.well-known/jwks.json" } + let(:jwks_response) do + { + keys: [ + { + kty: 'RSA', + kid: 'test-key-1', + use: 'sig', + n: 'test-n', + e: 'AQAB' + } + ] + } + end + + subject(:client) { described_class.new } + + before do + allow(Gitlab::Auth::Iam).to receive_messages(service_url: iam_service_url, issuer: iam_service_issuer) + end + + describe '#fetch_keys' do + context 'when keys are not cached' do + before do + stub_full_request(jwks_endpoint, method: :get) + .to_return(status: 200, body: jwks_response.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'fetches and caches keys from IAM service' do + expect(Rails.cache).to receive(:fetch).and_call_original + + keys = client.fetch_keys + + expect(keys).to be_a(JWT::JWK::Set) + expect(WebMock).to have_requested(:get, jwks_endpoint) + end + end + + context 'when keys are cached' do + before do + Rails.cache.write(client.send(:cache_key), jwks_response, expires_in: 1.hour) + end + + it 'returns cached keys without HTTP request' do + expect(Gitlab::HTTP).not_to receive(:get) + + expect(client.fetch_keys).to eq(jwks_response) + end + end + + context 'with errors' do + using RSpec::Parameterized::TableSyntax + + where(:error_type, :stub_behavior, :expected_error_message) do + 'HTTP 500' | -> { + stub_full_request(jwks_endpoint, method: :get).to_return(status: 500) + } | /Failed to fetch JWKS: HTTP 500/ + 'HTTP 404' | -> { + stub_full_request(jwks_endpoint, method: :get).to_return(status: 404) + } | /Failed to fetch JWKS: HTTP 404/ + 'Invalid JSON' | -> { + stub_full_request(jwks_endpoint, method: :get).to_return(status: 200, body: 'not json') + } | /Invalid JWKS response/ + 'Connection refused' | -> { + stub_full_request(jwks_endpoint, method: :get).to_raise(Errno::ECONNREFUSED) + } | /Cannot connect to IAM service/ + 'Timeout' | -> { + stub_full_request(jwks_endpoint, method: :get).to_raise(Net::ReadTimeout) + } | /Cannot connect to IAM service/ + end + + with_them do + before do + instance_exec(&stub_behavior) + end + + it 'raises JwksFetchFailedError' do + expect { client.fetch_keys }.to raise_error( + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, + expected_error_message + ) + end + + it 'tracks exception for connection errors' do + if error_type.include?('Connection') || error_type.include?('Timeout') + expect(Gitlab::ErrorTracking).to receive(:track_exception) + end + + expect { client.fetch_keys }.to raise_error(Gitlab::Auth::Iam::Jwt::JwksFetchFailedError) + end + end + end + + context 'when IAM service URL is not configured' do + before do + allow(Gitlab::Auth::Iam).to receive(:service_url).and_return(nil) + end + + it 'raises ConfigurationError' do + expect { client.fetch_keys }.to raise_error( + Gitlab::Auth::Iam::ConfigurationError, + 'IAM service URL is not configured' + ) + end + end + end + + describe '#refresh_keys' do + before do + stub_full_request(jwks_endpoint, method: :get) + .to_return(status: 200, body: jwks_response.to_json) + end + + it 'clears cache and fetches new keys' do + expect(client).to receive(:clear_cache).ordered.and_call_original + expect(client).to receive(:fetch_keys).ordered.and_call_original + + client.refresh_keys + end + end + + describe '#clear_cache' do + it 'deletes cache key' do + cache_key = client.send(:cache_key) + Rails.cache.write(cache_key, 'test') + + client.clear_cache + + expect(Rails.cache.read(cache_key)).to be_nil + end + end + + describe 'memoization' do + it 'memoizes endpoint' do + expect(Gitlab::Auth::Iam).to receive(:service_url).once.and_return(iam_service_url) + + 2.times { client.send(:endpoint) } + end + + it 'memoizes cache_key' do + expect(Digest::SHA256).to receive(:hexdigest).once.and_call_original + + 2.times { client.send(:cache_key) } + end + end + + describe '#cache_key' do + it 'includes issuer hash in key' do + cache_key = client.send(:cache_key) + + expect(cache_key).to match(/^iam:jwks:[a-f0-9]{9}$/) + end + + it 'generates different keys for different issuers' do + allow(Gitlab::Auth::Iam).to receive(:issuer).and_return('http://issuer1') + key1 = described_class.new.send(:cache_key) + + allow(Gitlab::Auth::Iam).to receive(:issuer).and_return('http://issuer2') + key2 = described_class.new.send(:cache_key) + + expect(key1).not_to eq(key2) + end + end +end diff --git a/spec/lib/gitlab/auth/iam/jwt_spec.rb b/spec/lib/gitlab/auth/iam/jwt_spec.rb new file mode 100644 index 00000000000000..2b2b5fc3978f5a --- /dev/null +++ b/spec/lib/gitlab/auth/iam/jwt_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::Iam::Jwt, feature_category: :system_access do + describe '.valid_format?' do + subject { described_class.valid_format?(token_string) } + + context 'with valid JWT format' do + let(:token_string) { 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.signature' } + + it { is_expected.to be(true) } + end + + context 'with token starting with ey but wrong segment count' do + let(:token_string) { 'eyJhbGciOiJSUzI1NiJ9.signature' } + + it { is_expected.to be(false) } + end + + context 'with token not starting with ey' do + let(:token_string) { 'not.a.jwt' } + + it { is_expected.to be(false) } + end + + context 'with OAuth-style token' do + let(:token_string) { 'd311812fb0f89a0e912f955baa2f3f4b530a655065f54c9ab6da06a18ca96cf8' } + + it { is_expected.to be(false) } + end + + context 'with nil token' do + let(:token_string) { nil } + + it { is_expected.to be(false) } + end + + context 'with empty string' do + let(:token_string) { '' } + + it { is_expected.to be(false) } + end + + context 'with non-string value' do + let(:token_string) { 12345 } + + it { is_expected.to be(false) } + end + end + + describe '.iam_issued_jwt?' do + let(:iam_issuer) { 'https://iam.example.com' } + let(:token_string) { 'eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODQifQ.sig' } + + subject { described_class.iam_issued_jwt?(token_string) } + + before do + allow(Gitlab::Auth::Iam).to receive(:issuer).and_return(iam_issuer) + end + + context 'with valid JWT from IAM service' do + before do + allow(JWT).to receive(:decode).with(token_string, nil, false) + .and_return([{ 'iss' => iam_issuer }, {}]) + end + + it { is_expected.to be(true) } + end + + context 'with valid JWT from different issuer' do + before do + allow(JWT).to receive(:decode).with(token_string, nil, false) + .and_return([{ 'iss' => 'http://different-issuer' }, {}]) + end + + it { is_expected.to be(false) } + end + + context 'with invalid JWT format' do + let(:token_string) { 'not-a-jwt' } + + it { is_expected.to be(false) } + end + + context 'with malformed JWT' do + let(:token_string) { 'eyJhbGci.invalid.jwt' } + + before do + allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) + end + + it { is_expected.to be(false) } + end + + context 'with JWT missing issuer claim' do + before do + allow(JWT).to receive(:decode).with(token_string, nil, false) + .and_return([{}, {}]) + end + + it { is_expected.to be(false) } + end + end + + describe 'error classes' do + it 'defines JWT-specific errors inheriting from Iam::Error' do + expect(described_class::TokenExpiredError).to be < Gitlab::Auth::Iam::Error + expect(described_class::InvalidSignatureError).to be < Gitlab::Auth::Iam::Error + expect(described_class::InvalidIssuerError).to be < Gitlab::Auth::Iam::Error + expect(described_class::InvalidAudienceError).to be < Gitlab::Auth::Iam::Error + expect(described_class::MalformedTokenError).to be < Gitlab::Auth::Iam::Error + expect(described_class::MissingClaimError).to be < Gitlab::Auth::Iam::Error + expect(described_class::JwksFetchFailedError).to be < Gitlab::Auth::Iam::Error + expect(described_class::JwksKeyMissingError).to be < Gitlab::Auth::Iam::Error + expect(described_class::InsufficientScopeError).to be < Gitlab::Auth::Iam::Error + end + end +end diff --git a/spec/lib/gitlab/auth/iam_spec.rb b/spec/lib/gitlab/auth/iam_spec.rb new file mode 100644 index 00000000000000..cadc32edd1c2e3 --- /dev/null +++ b/spec/lib/gitlab/auth/iam_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Auth::Iam, feature_category: :system_access do + describe 'configuration methods' do + using RSpec::Parameterized::TableSyntax + + where(:method, :config_method, :test_value) do + :service_url | :url | 'https://iam.example.com' + :issuer | :issuer | 'https://iam.example.com' + :enabled? | :enabled | true + end + + with_them do + it "returns configured #{params[:method]}" do + allow(Gitlab.config.iam_service).to receive(config_method).and_return(test_value) + + expect(described_class.public_send(method)).to eq(test_value) + end + + it "memoizes #{params[:method]}" do + expect(Gitlab.config.iam_service).to receive(config_method).once.and_return(test_value) + + 2.times { described_class.public_send(method) } + end + end + end + + describe 'error hierarchy' do + it 'defines base Error inheriting from AuthenticationError' do + expect(described_class::Error).to be < Gitlab::Auth::AuthenticationError + end + + it 'defines service-level errors' do + expect(described_class::ConfigurationError).to be < described_class::Error + expect(described_class::ServiceUnavailableError).to be < described_class::Error + end + end +end -- GitLab From 55fe57a505cbe420aaad1de69f4130b12d6db21d Mon Sep 17 00:00:00 2001 From: Daniele Bracciani Date: Wed, 17 Dec 2025 17:54:30 +0100 Subject: [PATCH 21/21] Update FF, configuration implementation and tests Update the existing MR with the agreed implemnentation for FF and related configuration. Also, improve tests. --- .../authn/iam/authentication_service.rb | 2 +- .../development/iam_svc_oauth.yml | 10 - config/feature_flags/wip/iam_svc_oauth.yml | 11 + config/gitlab.yml.example | 55 +--- config/initializers/1_settings.rb | 18 +- lib/gitlab/auth/iam.rb | 10 +- spec/initializers/1_settings_spec.rb | 31 ++ spec/lib/gitlab/auth/auth_finders_spec.rb | 4 +- spec/lib/gitlab/auth/iam/access_token_spec.rb | 161 +++++----- spec/lib/gitlab/auth/iam/jwks_client_spec.rb | 224 +++++++------ spec/lib/gitlab/auth/iam/jwt_spec.rb | 120 +++---- spec/lib/gitlab/auth/iam_integration_spec.rb | 295 ++++++++++++++++++ spec/lib/gitlab/auth/iam_spec.rb | 41 ++- .../authn/iam/authentication_service_spec.rb | 11 +- spec/support/helpers/authn/iam/jwt_helper.rb | 2 +- .../shared_examples/iam_authentication.rb | 98 ++++++ 16 files changed, 735 insertions(+), 358 deletions(-) delete mode 100644 config/feature_flags/development/iam_svc_oauth.yml create mode 100644 config/feature_flags/wip/iam_svc_oauth.yml create mode 100644 spec/lib/gitlab/auth/iam_integration_spec.rb create mode 100644 spec/support/shared_examples/iam_authentication.rb diff --git a/app/services/authn/iam/authentication_service.rb b/app/services/authn/iam/authentication_service.rb index 24e66d482aae5f..b633602323f993 100644 --- a/app/services/authn/iam/authentication_service.rb +++ b/app/services/authn/iam/authentication_service.rb @@ -85,7 +85,7 @@ def handle_verification_error(error) end # TODO: aud in `Gitlab::Auth::Iam`? - # aud: Gitlab.config.iam_service.audience, + # aud: Gitlab.config.authn.iam_service.audience, def decode_options { algorithms: Gitlab::Auth::Iam::Jwt::ALLOWED_ALGORITHMS, diff --git a/config/feature_flags/development/iam_svc_oauth.yml b/config/feature_flags/development/iam_svc_oauth.yml deleted file mode 100644 index de39905e650af8..00000000000000 --- a/config/feature_flags/development/iam_svc_oauth.yml +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: iam_svc_oauth -description: Enable JWT authentication for tokens issued by external IAM service. When enabled, GitLab accepts RS256-signed JWTs from a configured IAM service as an alternative to OAuth tokens for API and Git - authentication. -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/214565 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXX -milestone: '18.7' -type: development -group: group::authentication -default_enabled: false diff --git a/config/feature_flags/wip/iam_svc_oauth.yml b/config/feature_flags/wip/iam_svc_oauth.yml new file mode 100644 index 00000000000000..1f2679c0c9f9f1 --- /dev/null +++ b/config/feature_flags/wip/iam_svc_oauth.yml @@ -0,0 +1,11 @@ + +--- +name: iam_svc_oauth +description: Enable OAuth integration with the centralized IAM Service for Protocells. Controls all OAuth/OIDC functionality delegated to the IAM Service including JWT token validation, authorization flows, device authorization, and OIDC discovery endpoints. +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/580758 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/215247 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/582963 +milestone: '18.8' +group: group::authentication +type: wip +default_enabled: false diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index c9995424a0936b..85fdfffc88f27e 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -1253,6 +1253,22 @@ production: &base # is the normal way to deploy Gitaly. token: + ## Authentication settings + authn: + ## IAM Service settings + # The IAM Service handles OAuth flows with external providers and forwards + # authenticated user data to GitLab Rails. + iam_service: + # Enable OAuth access token authentication from IAM Service + # enabled: false + + # IAM Service URL + # url: http://localhost:8084 + + # Expected JWT audience (must match 'aud' claim in tokens) + # Set to 'gitlab-rails' or your GitLab instance identifier + # audience: gitlab-rails + # # 4. Advanced settings # ========================== @@ -1362,45 +1378,6 @@ production: &base # The client timeout in seconds for the KAS API (used by the GitLab backend) # client_timeout_seconds: 5 - ## IAM Service settings - iam_service: - # Enable IAM JWT authentication - # enabled: false - - # url: gdk.test:8084 - - # Expected JWT issuer (must match 'iss' claim in tokens) - # issuer: 'http://gdk.test:8084' - - # Expected JWT audience (must match 'aud' claim in tokens) - # Set to 'gitlab' or your GitLab instance identifier - # audience: 'gitlab' - - # JWKS endpoint path (relative to url) - # Default: '/.well-known/jwks.json' - # jwks_path: '/.well-known/jwks.json' - - # JWKS cache TTL in seconds (default: 3600 = 1 hour) - # jwks_cache_ttl: 3600 - - # HTTP timeout for JWKS fetch in seconds (default: 10) - # timeout: 10 - - # HTTP open timeout in seconds (default: 5) - # open_timeout: 5 - - # Clock skew tolerance in seconds for JWT validation (default: 30) - # clock_skew_seconds: 30 - - # Allowed JWT signing algorithms (default: ['RS256']) - # allowed_algorithms: ['RS256'] - - # Enable retry on JWKS fetch failures (default: true) - # retry_enabled: true - - # Maximum retry attempts for JWKS fetch (default: 3) - # max_retries: 3 - zoekt: # Files that contain username and password for basic auth for Zoekt # Default is '.gitlab_zoekt_username' and '.gitlab_zoekt_password' in Rails.root diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 703a12c54ee8a3..88c646179281eb 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -1230,18 +1230,18 @@ Settings.gitlab_kas['client_timeout_seconds'] ||= 5 # Settings.gitlab_kas['external_k8s_proxy_url'] ||= 'grpc://localhost:8154' # NOTE: Do not set a default until all distributions have been updated with a correct value +# +# Authentication +# +Settings['authn'] ||= {} + # # IAM Service # -Settings['iam_service'] ||= {} -Settings.iam_service['enabled'] ||= false -Settings.iam_service['url'] ||= 'http://gdk.test:8084' -Settings.iam_service['issuer'] ||= Settings.iam_service['url'] -Settings.iam_service['audience'] ||= 'gitlab' -Settings.iam_service['jwks_cache_ttl'] ||= 3600 -Settings.iam_service['timeout'] ||= 10 -Settings.iam_service['open_timeout'] ||= 5 -Settings.iam_service['clock_skew_seconds'] ||= 30 +Settings.authn['iam_service'] ||= {} +Settings.authn.iam_service['enabled'] ||= false +Settings.authn.iam_service['url'] ||= 'http://localhost:8084' +Settings.authn.iam_service['audience'] ||= 'gitlab-rails' # # Gitlab Secrets Manager Openbao Integration diff --git a/lib/gitlab/auth/iam.rb b/lib/gitlab/auth/iam.rb index ecdbdcfdeeae13..5813ba73413395 100644 --- a/lib/gitlab/auth/iam.rb +++ b/lib/gitlab/auth/iam.rb @@ -3,29 +3,27 @@ module Gitlab module Auth module Iam - # Base error for IAM authentication (format-agnostic) Error = Class.new(Gitlab::Auth::AuthenticationError) - # IAM service errors (format-agnostic) ConfigurationError = Class.new(Error) ServiceUnavailableError = Class.new(Error) class << self include Gitlab::Utils::StrongMemoize - # IAM service configuration (format-agnostic) def service_url - Gitlab.config.iam_service.url + Gitlab.config.authn.iam_service.url end strong_memoize_attr :service_url def issuer - Gitlab.config.iam_service.issuer + # Issuer is the same as the service URL + Gitlab.config.authn.iam_service.url end strong_memoize_attr :issuer def enabled? - Gitlab.config.iam_service.enabled + Gitlab.config.authn.iam_service.enabled end end end diff --git a/spec/initializers/1_settings_spec.rb b/spec/initializers/1_settings_spec.rb index c5e20e3d21e18f..0450b95f453e29 100644 --- a/spec/initializers/1_settings_spec.rb +++ b/spec/initializers/1_settings_spec.rb @@ -202,6 +202,37 @@ end end + describe 'IAM Service configuration' do + let(:config) do + { + url: 'http://iam-service-host:9000', + audience: 'custom-audience' + } + end + + context 'with default configuration' do + before do + stub_config(authn: { iam_service: {} }) + load_settings + end + + it { expect(Settings.authn.iam_service.enabled).to be(false) } + it { expect(Settings.authn.iam_service.url).to eq('http://localhost:8084') } + it { expect(Settings.authn.iam_service.audience).to eq('gitlab-rails') } + end + + context 'with custom configuration' do + before do + stub_config(authn: { iam_service: { enabled: true }.merge(config) }) + load_settings + end + + it { expect(Settings.authn.iam_service.enabled).to be(true) } + it { expect(Settings.authn.iam_service.url).to eq(config[:url]) } + it { expect(Settings.authn.iam_service.audience).to eq(config[:audience]) } + end + end + describe 'cron jobs', unless: Gitlab.ee? do let(:expected_jobs) do %w[ diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb index e411f0bcc0900a..17ab189a271e1b 100644 --- a/spec/lib/gitlab/auth/auth_finders_spec.rb +++ b/spec/lib/gitlab/auth/auth_finders_spec.rb @@ -872,7 +872,7 @@ def set_bearer_token(token) context 'when IAM JWT is disabled' do before do - allow(Gitlab.config.iam_service).to receive(:enabled).and_return(false) + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(false) set_bearer_token('any-token') end @@ -883,7 +883,7 @@ def set_bearer_token(token) context 'when IAM JWT is enabled' do before do - allow(Gitlab.config.iam_service).to receive(:enabled).and_return(true) + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(true) stub_feature_flags(iam_svc_oauth: user) end diff --git a/spec/lib/gitlab/auth/iam/access_token_spec.rb b/spec/lib/gitlab/auth/iam/access_token_spec.rb index 0fff3e682e047c..563003bcea84be 100644 --- a/spec/lib/gitlab/auth/iam/access_token_spec.rb +++ b/spec/lib/gitlab/auth/iam/access_token_spec.rb @@ -5,45 +5,47 @@ RSpec.describe Gitlab::Auth::Iam::AccessToken, feature_category: :system_access do let_it_be(:user) { create(:user) } + let(:scopes) { %w[api read_repository] } + let(:expires_at) { 1.hour.from_now } + + subject(:token) { build(:iam_jwt_token, user: user, scopes: scopes, expires_at: expires_at) } + describe '#initialize' do - it 'converts scopes to strings' do - token = described_class.new( - user_id: user.id, - scopes: [:api, :read_repository], - jti: 'test-jti', - expires_at: 1.hour.from_now, - issued_at: Time.current, - payload: {} - ) - - expect(token.scopes).to all(be_a(String)) + it 'sets attributes correctly', :freeze_time do + expect(token.user_id).to eq(user.id) + expect(token.scopes).to eq(%w[api read_repository]) + expect(token.jti).to be_present + expect(token.expires_at).to eq(expires_at) + expect(token.issued_at).to be_within(1.second).of(Time.current) + expect(token.payload).to be_present + end + + context 'with symbol scopes' do + subject(:token) { build(:iam_jwt_token, user: user, scopes: [:api, :read_repository]) } + + it 'converts scopes to strings' do + expect(token.scopes).to eq(%w[api read_repository]) + end end end describe '#user' do - let(:token) { build(:iam_jwt_token, user: user) } - - it 'returns the user' do + it 'returns the user by ID' do expect(token.user).to eq(user) end - it 'memoizes the user' do - expect(User).to receive(:id_in).once.and_call_original + it 'memoizes the result' do + token.user + + expect(User).not_to receive(:id_in) - 2.times { token.user } + token.user end context 'when user does not exist' do - let(:token) do - described_class.new( - user_id: non_existing_record_id, - scopes: ['api'], - jti: 'test-jti', - expires_at: 1.hour.from_now, - issued_at: Time.current, - payload: {} - ) - end + subject(:token) { build(:iam_jwt_token, user: nonexistent_user) } + + let(:nonexistent_user) { build(:user, id: non_existing_record_id) } it 'returns nil' do expect(token.user).to be_nil @@ -52,97 +54,102 @@ end describe '#resource_owner_id' do - it 'returns user_id' do - token = build(:iam_jwt_token, user: user) - + it 'returns the user_id' do expect(token.resource_owner_id).to eq(user.id) end end - describe '#expired?' do - it 'returns false when not expired' do - token = build(:iam_jwt_token) + describe '#expired?', :freeze_time do + context 'when token is not expired' do + let(:expires_at) { 1.hour.from_now } - expect(token).not_to be_expired + it 'returns false' do + expect(token.expired?).to be(false) + end end - it 'returns true when expired' do - token = build(:iam_jwt_token, :expired) + context 'when token is expired' do + let(:expires_at) { 1.hour.ago } - expect(token).to be_expired + it 'returns true' do + expect(token.expired?).to be(true) + end end - it 'returns false when expires_at is nil' do - token = build(:iam_jwt_token, expires_at: nil) + context 'when expires_at is nil' do + let(:expires_at) { nil } - expect(token).not_to be_expired + it 'returns false' do + expect(token.expired?).to be(false) + end end end - describe '#revoked?' do - it 'always returns false for stateless JWTs' do - token = build(:iam_jwt_token) + describe '#active?', :freeze_time do + context 'when token is not expired' do + let(:expires_at) { 1.hour.from_now } - expect(token).not_to be_revoked + it 'returns true' do + expect(token.active?).to be(true) + end end - end - describe '#active?' do - it 'returns true when not expired' do - token = build(:iam_jwt_token) + context 'when token is expired' do + let(:expires_at) { 1.hour.ago } - expect(token).to be_active + it 'returns false' do + expect(token.active?).to be(false) + end end - it 'returns false when expired' do - token = build(:iam_jwt_token, :expired) + context 'when expires_at is nil' do + let(:expires_at) { nil } - expect(token).not_to be_active + it 'returns true' do + expect(token.active?).to be(true) + end end end - describe '#reload' do - let(:token) { build(:iam_jwt_token, user: user) } - - it 'clears memoized user and returns self' do - expect(token.user).to eq(user) - - expect(token.reload).to eq(token) - - expect(User).to receive(:id_in).and_call_original - token.user + describe '#revoked?' do + it 'always returns false for stateless JWTs' do + expect(token.revoked?).to be(false) end end describe '#id' do - it 'returns jti' do - token = build(:iam_jwt_token, jti: 'unique-jti-123') - - expect(token.id).to eq('unique-jti-123') + it 'returns the jti' do + expect(token.id).to eq(token.jti) end end describe '#has_attribute?' do it 'always returns false' do - token = build(:iam_jwt_token) - - expect(token.has_attribute?(:anything)).to be(false) + expect(token.has_attribute?(:any_attribute)).to be(false) end end - describe '#to_s' do - it 'includes jti and user_id' do - token = build(:iam_jwt_token, user: user, jti: 'test-jti') + describe '#reload' do + it 'returns self' do + expect(token.reload).to eq(token) + end + + it 'clears memoized user' do + token.user + + token.reload + + expect(User).to receive(:id_in).with(user.id).and_call_original - expect(token.to_s).to eq("Iam::AccessToken(jti: test-jti, user_id: #{user.id})") + token.user end end - describe 'AccessTokenValidationService compatibility' do - it 'responds to required methods' do - token = build(:iam_jwt_token) + describe '#to_s' do + it 'returns a string representation' do + expected = "Iam::AccessToken(jti: #{token.jti}, user_id: #{user.id})" - expect(token).to respond_to(:user, :resource_owner_id, :scopes, :expired?, :revoked?, :has_attribute?) + expect(token.to_s).to eq(expected) end end end diff --git a/spec/lib/gitlab/auth/iam/jwks_client_spec.rb b/spec/lib/gitlab/auth/iam/jwks_client_spec.rb index 0b0c988aac5332..822a37ece3e772 100644 --- a/spec/lib/gitlab/auth/iam/jwks_client_spec.rb +++ b/spec/lib/gitlab/auth/iam/jwks_client_spec.rb @@ -3,171 +3,199 @@ require 'spec_helper' RSpec.describe Gitlab::Auth::Iam::JwksClient, feature_category: :system_access do - include StubRequests + include_context 'with IAM authentication setup' + + subject(:client) { described_class.new } - let(:iam_service_url) { 'https://iam.example.com' } - let(:iam_service_issuer) { 'https://iam.example.com' } let(:jwks_endpoint) { "#{iam_service_url}/.well-known/jwks.json" } let(:jwks_response) do { - keys: [ + 'keys' => [ { - kty: 'RSA', - kid: 'test-key-1', - use: 'sig', - n: 'test-n', - e: 'AQAB' + 'kty' => 'RSA', + 'kid' => kid, + 'use' => 'sig', + 'n' => 'test-modulus', + 'e' => 'AQAB' } ] } end - subject(:client) { described_class.new } + describe '#fetch_keys' do + let(:http_response) { instance_double(HTTParty::Response, success?: true, parsed_response: jwks_response) } - before do - allow(Gitlab::Auth::Iam).to receive_messages(service_url: iam_service_url, issuer: iam_service_issuer) - end + before do + allow(Gitlab::HTTP).to receive(:get).with(jwks_endpoint).and_return(http_response) + end - describe '#fetch_keys' do - context 'when keys are not cached' do - before do - stub_full_request(jwks_endpoint, method: :get) - .to_return(status: 200, body: jwks_response.to_json, headers: { 'Content-Type' => 'application/json' }) - end + it 'returns a JWT::JWK::Set' do + result = client.fetch_keys - it 'fetches and caches keys from IAM service' do - expect(Rails.cache).to receive(:fetch).and_call_original + expect(result).to be_a(JWT::JWK::Set) + end - keys = client.fetch_keys + it 'parses the JWKS response' do + expect(JWT::JWK::Set).to receive(:new).with(jwks_response).and_call_original - expect(keys).to be_a(JWT::JWK::Set) - expect(WebMock).to have_requested(:get, jwks_endpoint) + client.fetch_keys + end + + it 'caches the result with TTL' do + expect(Rails.cache).to receive(:fetch).with( + client.send(:cache_key), + expires_in: described_class::CACHE_TTL + ).and_call_original + + client.fetch_keys + end + + context 'when HTTP request fails' do + let(:http_response) { instance_double(HTTParty::Response, success?: false, code: 404) } + + it 'raises JwksFetchFailedError with status code' do + expect { client.fetch_keys }.to raise_error( + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, + 'Failed to fetch JWKS: HTTP 404' + ) end end - context 'when keys are cached' do + context 'when network error occurs' do + let(:error) { Errno::ECONNREFUSED.new('Connection refused') } + before do - Rails.cache.write(client.send(:cache_key), jwks_response, expires_in: 1.hour) + allow(Gitlab::HTTP).to receive(:get).and_raise(error) + end + + it 'raises JwksFetchFailedError' do + expect { client.fetch_keys }.to raise_error( + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, + /Cannot connect to IAM service: Connection refused/ + ) end - it 'returns cached keys without HTTP request' do - expect(Gitlab::HTTP).not_to receive(:get) + it 'tracks the exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + an_instance_of(Errno::ECONNREFUSED) + ) - expect(client.fetch_keys).to eq(jwks_response) + expect { client.fetch_keys }.to raise_error(Gitlab::Auth::Iam::Jwt::JwksFetchFailedError) end end - context 'with errors' do - using RSpec::Parameterized::TableSyntax - - where(:error_type, :stub_behavior, :expected_error_message) do - 'HTTP 500' | -> { - stub_full_request(jwks_endpoint, method: :get).to_return(status: 500) - } | /Failed to fetch JWKS: HTTP 500/ - 'HTTP 404' | -> { - stub_full_request(jwks_endpoint, method: :get).to_return(status: 404) - } | /Failed to fetch JWKS: HTTP 404/ - 'Invalid JSON' | -> { - stub_full_request(jwks_endpoint, method: :get).to_return(status: 200, body: 'not json') - } | /Invalid JWKS response/ - 'Connection refused' | -> { - stub_full_request(jwks_endpoint, method: :get).to_raise(Errno::ECONNREFUSED) - } | /Cannot connect to IAM service/ - 'Timeout' | -> { - stub_full_request(jwks_endpoint, method: :get).to_raise(Net::ReadTimeout) - } | /Cannot connect to IAM service/ + context 'when timeout error occurs' do + let(:error) { Net::ReadTimeout.new } + + before do + allow(Gitlab::HTTP).to receive(:get).and_raise(error) end - with_them do - before do - instance_exec(&stub_behavior) - end - - it 'raises JwksFetchFailedError' do - expect { client.fetch_keys }.to raise_error( - Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, - expected_error_message - ) - end - - it 'tracks exception for connection errors' do - if error_type.include?('Connection') || error_type.include?('Timeout') - expect(Gitlab::ErrorTracking).to receive(:track_exception) - end - - expect { client.fetch_keys }.to raise_error(Gitlab::Auth::Iam::Jwt::JwksFetchFailedError) - end + it 'raises JwksFetchFailedError' do + expect { client.fetch_keys }.to raise_error( + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, + /Cannot connect to IAM service/ + ) + end + + it 'tracks the exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + an_instance_of(Net::ReadTimeout) + ) + + expect { client.fetch_keys }.to raise_error(Gitlab::Auth::Iam::Jwt::JwksFetchFailedError) end end - context 'when IAM service URL is not configured' do + context 'when JSON parsing fails' do + let(:error) { JSON::ParserError.new('Invalid JSON') } + before do - allow(Gitlab::Auth::Iam).to receive(:service_url).and_return(nil) + allow(JWT::JWK::Set).to receive(:new).and_raise(error) end - it 'raises ConfigurationError' do + it 'raises JwksFetchFailedError with parse error message' do expect { client.fetch_keys }.to raise_error( - Gitlab::Auth::Iam::ConfigurationError, - 'IAM service URL is not configured' + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError, + 'Invalid JWKS response: Invalid JSON' ) end end end describe '#refresh_keys' do + let(:http_response) { instance_double(HTTParty::Response, success?: true, parsed_response: jwks_response) } + before do - stub_full_request(jwks_endpoint, method: :get) - .to_return(status: 200, body: jwks_response.to_json) + allow(Gitlab::HTTP).to receive(:get).with(jwks_endpoint).and_return(http_response) + end + + it 'clears the cache' do + client.fetch_keys + + expect(Rails.cache).to receive(:delete).with(client.send(:cache_key)) + + client.refresh_keys end - it 'clears cache and fetches new keys' do - expect(client).to receive(:clear_cache).ordered.and_call_original - expect(client).to receive(:fetch_keys).ordered.and_call_original + it 'fetches fresh keys from the endpoint' do + client.fetch_keys + + expect(Gitlab::HTTP).to receive(:get).with(jwks_endpoint).and_return(http_response) client.refresh_keys end end describe '#clear_cache' do - it 'deletes cache key' do - cache_key = client.send(:cache_key) - Rails.cache.write(cache_key, 'test') + it 'deletes the cache entry' do + expect(Rails.cache).to receive(:delete).with(client.send(:cache_key)) client.clear_cache - - expect(Rails.cache.read(cache_key)).to be_nil end end - describe 'memoization' do - it 'memoizes endpoint' do - expect(Gitlab::Auth::Iam).to receive(:service_url).once.and_return(iam_service_url) + describe '#endpoint' do + it 'constructs the correct JWKS endpoint URL' do + expect(client.send(:endpoint)).to eq("#{iam_service_url}/.well-known/jwks.json") + end - 2.times { client.send(:endpoint) } + it 'memoizes the result' do + client.send(:endpoint) + + expect(Gitlab::Auth::Iam).not_to receive(:service_url) + + client.send(:endpoint) end - it 'memoizes cache_key' do - expect(Digest::SHA256).to receive(:hexdigest).once.and_call_original + context 'when service URL is not configured' do + before do + allow(Gitlab::Auth::Iam).to receive(:service_url).and_return(nil) + end - 2.times { client.send(:cache_key) } + it 'raises ConfigurationError' do + expect { client.send(:endpoint) }.to raise_error( + Gitlab::Auth::Iam::ConfigurationError, + 'IAM service URL is not configured' + ) + end end end describe '#cache_key' do - it 'includes issuer hash in key' do - cache_key = client.send(:cache_key) + it 'generates cache key based on issuer hash' do + expected_hash = Digest::SHA256.hexdigest(iam_issuer)[0..8] + expected_key = "iam:jwks:#{expected_hash}" - expect(cache_key).to match(/^iam:jwks:[a-f0-9]{9}$/) + expect(client.send(:cache_key)).to eq(expected_key) end - it 'generates different keys for different issuers' do - allow(Gitlab::Auth::Iam).to receive(:issuer).and_return('http://issuer1') - key1 = described_class.new.send(:cache_key) + it 'memoizes the result' do + client.send(:cache_key) - allow(Gitlab::Auth::Iam).to receive(:issuer).and_return('http://issuer2') - key2 = described_class.new.send(:cache_key) + expect(Gitlab::Auth::Iam).not_to receive(:issuer) - expect(key1).not_to eq(key2) + client.send(:cache_key) end end end diff --git a/spec/lib/gitlab/auth/iam/jwt_spec.rb b/spec/lib/gitlab/auth/iam/jwt_spec.rb index 2b2b5fc3978f5a..ad0cb467dd3400 100644 --- a/spec/lib/gitlab/auth/iam/jwt_spec.rb +++ b/spec/lib/gitlab/auth/iam/jwt_spec.rb @@ -4,116 +4,68 @@ RSpec.describe Gitlab::Auth::Iam::Jwt, feature_category: :system_access do describe '.valid_format?' do - subject { described_class.valid_format?(token_string) } - - context 'with valid JWT format' do - let(:token_string) { 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxIn0.signature' } - - it { is_expected.to be(true) } - end - - context 'with token starting with ey but wrong segment count' do - let(:token_string) { 'eyJhbGciOiJSUzI1NiJ9.signature' } - - it { is_expected.to be(false) } - end - - context 'with token not starting with ey' do - let(:token_string) { 'not.a.jwt' } - - it { is_expected.to be(false) } - end - - context 'with OAuth-style token' do - let(:token_string) { 'd311812fb0f89a0e912f955baa2f3f4b530a655065f54c9ab6da06a18ca96cf8' } - - it { is_expected.to be(false) } - end - - context 'with nil token' do - let(:token_string) { nil } - - it { is_expected.to be(false) } - end - - context 'with empty string' do - let(:token_string) { '' } - - it { is_expected.to be(false) } - end - - context 'with non-string value' do - let(:token_string) { 12345 } - - it { is_expected.to be(false) } - end + include_examples 'IAM JWT format validation' end describe '.iam_issued_jwt?' do - let(:iam_issuer) { 'https://iam.example.com' } - let(:token_string) { 'eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODQifQ.sig' } - - subject { described_class.iam_issued_jwt?(token_string) } + let(:issuer) { 'https://iam.example.com' } + let(:valid_payload) { { 'iss' => issuer, 'sub' => 'user:123' } } + let(:valid_jwt) { JWT.encode(valid_payload, nil, 'none') } before do - allow(Gitlab::Auth::Iam).to receive(:issuer).and_return(iam_issuer) + allow(Gitlab::Auth::Iam).to receive(:issuer).and_return(issuer) end - context 'with valid JWT from IAM service' do - before do - allow(JWT).to receive(:decode).with(token_string, nil, false) - .and_return([{ 'iss' => iam_issuer }, {}]) + context 'with valid JWT format and matching issuer' do + it 'returns true' do + expect(described_class.iam_issued_jwt?(valid_jwt)).to be(true) end - - it { is_expected.to be(true) } end - context 'with valid JWT from different issuer' do - before do - allow(JWT).to receive(:decode).with(token_string, nil, false) - .and_return([{ 'iss' => 'http://different-issuer' }, {}]) - end + context 'with valid JWT format but non-matching issuer' do + let(:different_issuer_payload) { { 'iss' => 'https://other.example.com', 'sub' => 'user:123' } } + let(:different_issuer_jwt) { JWT.encode(different_issuer_payload, nil, 'none') } - it { is_expected.to be(false) } + it 'returns false' do + expect(described_class.iam_issued_jwt?(different_issuer_jwt)).to be(false) + end end context 'with invalid JWT format' do - let(:token_string) { 'not-a-jwt' } - - it { is_expected.to be(false) } + it 'returns false' do + expect(described_class.iam_issued_jwt?('invalid-token')).to be(false) + end end context 'with malformed JWT' do - let(:token_string) { 'eyJhbGci.invalid.jwt' } - - before do - allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) + it 'returns false' do + expect(described_class.iam_issued_jwt?('ey.malformed.jwt')).to be(false) end + end - it { is_expected.to be(false) } + context 'with nil token' do + it 'returns false' do + expect(described_class.iam_issued_jwt?(nil)).to be(false) + end end - context 'with JWT missing issuer claim' do + context 'when JWT decode raises an error' do before do - allow(JWT).to receive(:decode).with(token_string, nil, false) - .and_return([{}, {}]) + allow(JWT).to receive(:decode).and_raise(JWT::DecodeError) end - it { is_expected.to be(false) } + it 'returns false' do + expect(described_class.iam_issued_jwt?(valid_jwt)).to be(false) + end end - end - describe 'error classes' do - it 'defines JWT-specific errors inheriting from Iam::Error' do - expect(described_class::TokenExpiredError).to be < Gitlab::Auth::Iam::Error - expect(described_class::InvalidSignatureError).to be < Gitlab::Auth::Iam::Error - expect(described_class::InvalidIssuerError).to be < Gitlab::Auth::Iam::Error - expect(described_class::InvalidAudienceError).to be < Gitlab::Auth::Iam::Error - expect(described_class::MalformedTokenError).to be < Gitlab::Auth::Iam::Error - expect(described_class::MissingClaimError).to be < Gitlab::Auth::Iam::Error - expect(described_class::JwksFetchFailedError).to be < Gitlab::Auth::Iam::Error - expect(described_class::JwksKeyMissingError).to be < Gitlab::Auth::Iam::Error - expect(described_class::InsufficientScopeError).to be < Gitlab::Auth::Iam::Error + context 'with missing issuer claim' do + let(:no_issuer_payload) { { 'sub' => 'user:123' } } + let(:no_issuer_jwt) { JWT.encode(no_issuer_payload, nil, 'none') } + + it 'returns false' do + expect(described_class.iam_issued_jwt?(no_issuer_jwt)).to be(false) + end end end end diff --git a/spec/lib/gitlab/auth/iam_integration_spec.rb b/spec/lib/gitlab/auth/iam_integration_spec.rb new file mode 100644 index 00000000000000..7132d75c6c87dc --- /dev/null +++ b/spec/lib/gitlab/auth/iam_integration_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'IAM JWT Authentication Integration', :request_store, feature_category: :system_access do + include Gitlab::Auth::AuthFinders + + let_it_be(:user) { create(:user) } + let_it_be(:organization) { create(:organization) } + let_it_be(:project) { create(:project, :private) } + + let(:iam_service_url) { 'https://iam.example.com' } + let(:iam_service_issuer) { 'https://iam.example.com' } + + let(:payload) do + { + 'sub' => "user:#{user.id}", + 'scope' => 'api', + 'jti' => 'test-jti-123', + 'exp' => 1.hour.from_now.to_i, + 'iat' => Time.current.to_i, + 'iss' => iam_service_issuer + } + end + + let(:jwt_token) do + # rubocop:disable Layout/LineLength -- Test JWT token string + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwic2NwIjpbImFwaSJdLCJqdGkiOiJ0ZXN0LWp0aSIsImV4cCI6MTcwMDAwMDAwMCwiaWF0IjoxNjAwMDAwMDAwLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODQifQ.signature' + # rubocop:enable Layout/LineLength + end + + let(:env) { { 'rack.input' => '' } } + let(:request) { ActionDispatch::Request.new(env) } + + before do + allow(Gitlab.config.authn.iam_service).to receive_messages(enabled: true, url: iam_service_url, + issuer: iam_service_issuer) + + stub_feature_flags(iam_svc_oauth: user) + + env['HTTP_AUTHORIZATION'] = "Bearer #{jwt_token}" + env['SCRIPT_NAME'] = '/api/v4/user' + end + + describe 'Gitlab::Auth.iam_jwt_token_check' do + subject(:auth_result) { Gitlab::Auth.send(:iam_jwt_token_check, jwt_token, project) } + + before do + allow(JWT).to receive(:decode).and_return([payload, {}]) + allow_next_instance_of(Gitlab::Auth::Iam::JwksClient) do |client| + allow(client).to receive(:fetch_keys).and_return(double) + end + end + + context 'when IAM JWT is disabled' do + before do + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(false) + end + + it 'returns nil' do + expect(auth_result).to be_nil + end + end + + context 'with valid IAM JWT' do + it 'returns auth result' do + expect(auth_result).to be_a(Gitlab::Auth::Result) + expect(auth_result.actor).to eq(user) + expect(auth_result.project).to eq(project) + expect(auth_result.type).to eq(:iam_jwt) + end + + it 'includes abilities for api scope' do + abilities = auth_result.authentication_abilities + + expect(abilities).to include(:read_project, :download_code, :push_code) + end + + context 'with read_repository scope' do + let(:payload) do + super().merge('scope' => 'read_repository') + end + + it 'includes read abilities only' do + abilities = auth_result.authentication_abilities + + expect(abilities).to include(:download_code) + expect(abilities).not_to include(:push_code) + end + end + end + + context 'when user is blocked' do + before do + user.block! + end + + it 'returns nil' do + expect(auth_result).to be_nil + end + end + + context 'when user does not exist' do + let(:payload) do + super().merge('sub' => "user:#{non_existing_record_id}") + end + + it 'returns nil' do + expect(auth_result).to be_nil + end + end + + context 'when authentication service raises error' do + before do + allow(JWT).to receive(:decode).and_raise(JWT::ExpiredSignature) + end + + it 'returns nil' do + expect(auth_result).to be_nil + end + end + end + + describe '#find_iam_jwt_access_token' do + subject(:iam_token) { find_iam_jwt_access_token } + + before do + allow(JWT).to receive(:decode).and_return([payload, {}]) + allow_next_instance_of(Gitlab::Auth::Iam::JwksClient) do |client| + allow(client).to receive(:fetch_keys).and_return(double) + end + allow(Gitlab::Auth::Iam::Jwt).to receive(:iam_issued_jwt?).and_return(true) + end + + it 'returns IAM JWT token object' do + expect(iam_token).to be_a(Gitlab::Auth::Iam::AccessToken) + expect(iam_token.user_id).to eq(user.id) + end + + it 'sets current_token' do + iam_token + + expect(current_token).to eq(jwt_token) + end + + context 'when IAM JWT is disabled' do + before do + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(false) + end + + it 'returns nil' do + expect(iam_token).to be_nil + end + end + + context 'when token is not issued by IAM service' do + before do + allow(Gitlab::Auth::Iam::Jwt).to receive(:iam_issued_jwt?).and_return(false) + end + + it 'returns nil' do + expect(iam_token).to be_nil + end + end + + context 'when feature flag is disabled for user' do + before do + stub_feature_flags(iam_svc_oauth: false) + end + + it 'returns nil and logs failure' do + expect(Gitlab::AuthLogger).to receive(:info).with( + hash_including( + message: 'IAM JWT authentication not enabled for user', + reason: :feature_disabled + ) + ) + + expect(iam_token).to be_nil + end + end + + context 'when JWT validation fails' do + before do + allow(JWT).to receive(:decode).and_raise(JWT::ExpiredSignature) + end + + it 'returns nil' do + expect(iam_token).to be_nil + end + + it 'logs the failure' do + expect(Gitlab::AuthLogger).to receive(:warn).with( + hash_including( + message: 'IAM JWT authentication failed', + reason: :invalid_token + ) + ) + + iam_token + end + end + + context 'when JWKS fetch fails' do + before do + allow_next_instance_of(Gitlab::Auth::Iam::JwksClient) do |client| + allow(client).to receive(:fetch_keys).and_raise( + Gitlab::Auth::Iam::Jwt::JwksFetchFailedError.new('Connection failed') + ) + end + end + + it 'returns nil' do + expect(iam_token).to be_nil + end + + it 'tracks the exception' do + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + instance_of(Gitlab::Auth::Iam::Jwt::JwksFetchFailedError) + ) + + iam_token + end + end + + context 'when no bearer token present' do + before do + env.delete('HTTP_AUTHORIZATION') + end + + it 'returns nil' do + expect(iam_token).to be_nil + end + end + end + + describe '#access_token' do + subject(:token) { access_token } + + before do + allow(JWT).to receive(:decode).and_return([payload, {}]) + allow_next_instance_of(Gitlab::Auth::Iam::JwksClient) do |client| + allow(client).to receive(:fetch_keys).and_return(double) + end + allow(Gitlab::Auth::Iam::Jwt).to receive(:iam_issued_jwt?).and_return(true) + end + + context 'with valid IAM JWT' do + it 'returns IAM JWT token' do + expect(token).to be_a(Gitlab::Auth::Iam::AccessToken) + end + + it 'memoizes the token' do + expect(find_iam_jwt_access_token).to receive(:itself).once.and_call_original + + 2.times { access_token } + end + end + + context 'when IAM JWT fails, falls through to OAuth' do + let_it_be(:oauth_application) { create(:oauth_application, owner: user) } + let(:oauth_token) do + create(:oauth_access_token, + application_id: oauth_application.id, + resource_owner_id: user.id, + scopes: 'api', + organization_id: organization.id) + end + + before do + stub_feature_flags(iam_svc_oauth: false) + env['HTTP_AUTHORIZATION'] = "Bearer #{oauth_token.plaintext_token}" + end + + it 'returns OAuth token' do + expect(token).to be_a(OauthAccessToken) + expect(token.token).to eq(oauth_token.token) + end + end + + context 'when IAM JWT fails, falls through to PAT' do + let_it_be(:personal_access_token) { create(:personal_access_token, user: user) } + + before do + stub_feature_flags(iam_svc_oauth: false) + env['HTTP_AUTHORIZATION'] = "Bearer #{personal_access_token.token}" + end + + it 'returns personal access token' do + expect(token).to be_a(PersonalAccessToken) + expect(token.token).to eq(personal_access_token.token) + end + end + end +end diff --git a/spec/lib/gitlab/auth/iam_spec.rb b/spec/lib/gitlab/auth/iam_spec.rb index cadc32edd1c2e3..e8e554a0bae91e 100644 --- a/spec/lib/gitlab/auth/iam_spec.rb +++ b/spec/lib/gitlab/auth/iam_spec.rb @@ -3,38 +3,33 @@ require 'spec_helper' RSpec.describe Gitlab::Auth::Iam, feature_category: :system_access do - describe 'configuration methods' do - using RSpec::Parameterized::TableSyntax + describe '.enabled?' do + context 'when IAM service is enabled in config' do + before do + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(true) + end - where(:method, :config_method, :test_value) do - :service_url | :url | 'https://iam.example.com' - :issuer | :issuer | 'https://iam.example.com' - :enabled? | :enabled | true + it 'returns true' do + expect(described_class.enabled?).to be(true) + end end - with_them do - it "returns configured #{params[:method]}" do - allow(Gitlab.config.iam_service).to receive(config_method).and_return(test_value) - - expect(described_class.public_send(method)).to eq(test_value) + context 'when IAM service is disabled in config' do + before do + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(false) end - it "memoizes #{params[:method]}" do - expect(Gitlab.config.iam_service).to receive(config_method).once.and_return(test_value) - - 2.times { described_class.public_send(method) } + it 'returns false' do + expect(described_class.enabled?).to be(false) end end end - describe 'error hierarchy' do - it 'defines base Error inheriting from AuthenticationError' do - expect(described_class::Error).to be < Gitlab::Auth::AuthenticationError - end + describe '.service_url' do + include_examples 'IAM configuration method', :service_url, :url + end - it 'defines service-level errors' do - expect(described_class::ConfigurationError).to be < described_class::Error - expect(described_class::ServiceUnavailableError).to be < described_class::Error - end + describe '.issuer' do + include_examples 'IAM configuration method', :issuer, :url end end diff --git a/spec/services/authn/iam/authentication_service_spec.rb b/spec/services/authn/iam/authentication_service_spec.rb index ca615d469c9789..aab1d258635c89 100644 --- a/spec/services/authn/iam/authentication_service_spec.rb +++ b/spec/services/authn/iam/authentication_service_spec.rb @@ -3,19 +3,14 @@ require 'spec_helper' RSpec.describe Authn::Iam::AuthenticationService, :aggregate_failures, feature_category: :system_access do - include Authn::Iam::JwtHelper + include_context 'with IAM authentication setup' let_it_be(:user) { create(:user) } + # Override shared context values for this spec let(:iam_url) { 'https://iam.gitlab.example.com' } let(:iam_issuer) { 'https://iam.gitlab.example.com' } - let(:iam_key_id) { 'test-key-1' } - let(:private_key) { generate_rsa_key } - - before do - stub_iam_service_config(enabled: true, url: iam_url, issuer: iam_issuer) - stub_iam_jwks_endpoint(private_key.public_key, url: iam_url, kid: iam_key_id) - end + let(:iam_service_url) { iam_url } describe '#execute' do context 'when IAM JWT is disabled' do diff --git a/spec/support/helpers/authn/iam/jwt_helper.rb b/spec/support/helpers/authn/iam/jwt_helper.rb index 6c78a53888a06c..4d85c16860b46f 100644 --- a/spec/support/helpers/authn/iam/jwt_helper.rb +++ b/spec/support/helpers/authn/iam/jwt_helper.rb @@ -182,7 +182,7 @@ def create_jwks_response(public_key, kid:) end def stub_iam_service_config(url:, issuer:, enabled: true) - allow(Gitlab.config.iam_service).to receive_messages(enabled: enabled, url: url, issuer: issuer) + allow(Gitlab.config.authn.iam_service).to receive_messages(enabled: enabled, url: url, issuer: issuer) end def stub_iam_jwks_endpoint( diff --git a/spec/support/shared_examples/iam_authentication.rb b/spec/support/shared_examples/iam_authentication.rb new file mode 100644 index 00000000000000..d356f81a56c442 --- /dev/null +++ b/spec/support/shared_examples/iam_authentication.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with IAM authentication setup' do + include Authn::Iam::JwtHelper + + let(:iam_service_url) { 'https://iam.example.com' } + let(:iam_issuer) { 'https://iam.example.com' } + let(:private_key) { generate_rsa_key } + let(:kid) { 'test-key-1' } + + before do + # Clear memoization cache to ensure test isolation + Gitlab::Auth::Iam.clear_memoization(:service_url) + Gitlab::Auth::Iam.clear_memoization(:issuer) + stub_iam_service_config(enabled: true, url: iam_service_url, issuer: iam_issuer) + stub_iam_jwks_endpoint(private_key.public_key, url: iam_service_url, kid: kid) + end +end + +RSpec.shared_context 'when IAM authentication disabled' do + before do + allow(Gitlab.config.authn.iam_service).to receive(:enabled).and_return(false) + end +end + +RSpec.shared_examples 'with IAM configuration method' do |method_name, config_key| + let(:expected_value) { "https://iam.example.com" } + + before do + # Clear memoization cache to ensure test isolation + described_class.clear_memoization(method_name) + allow(Gitlab.config.authn.iam_service).to receive(config_key).and_return(expected_value) + end + + it "returns the configured #{config_key}" do + expect(described_class.public_send(method_name)).to eq(expected_value) + end + + it 'memoizes the result' do + described_class.public_send(method_name) + expect(Gitlab.config.authn.iam_service).not_to receive(config_key) + described_class.public_send(method_name) + end +end + +RSpec.shared_examples 'token expiration behavior' do + context 'when not expired' do + let(:expires_at) { 1.hour.from_now } + + it 'is not expired' do + expect(subject.expired?).to be(false) + end + + it 'is active' do + expect(subject.active?).to be(true) + end + end + + context 'when expired' do + let(:expires_at) { 1.hour.ago } + + it 'is expired' do + expect(subject.expired?).to be(true) + end + + it 'is not active' do + expect(subject.active?).to be(false) + end + end + + context 'when expires_at is nil' do + let(:expires_at) { nil } + + it 'is not expired' do + expect(subject.expired?).to be(false) + end + + it 'is active' do + expect(subject.active?).to be(true) + end + end +end + +RSpec.shared_examples 'with IAM JWT format validation' do + it 'accepts valid JWT format' do + valid_jwt = 'eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyOjEyMyJ9.signature' + expect(described_class.valid_format?(valid_jwt)).to be(true) + end + + it 'rejects invalid formats' do + expect(described_class.valid_format?('invalid-token')).to be(false) + expect(described_class.valid_format?('only.one-dot')).to be(false) + expect(described_class.valid_format?('too.many.dots.here')).to be(false) + expect(described_class.valid_format?('ab.cd.ef')).to be(false) + expect(described_class.valid_format?(nil)).to be(false) + expect(described_class.valid_format?('')).to be(false) + end +end -- GitLab