diff --git a/commons/swagger/openapi-entreprise.yaml b/commons/swagger/openapi-entreprise.yaml index d39ae0b6b..81609263e 100644 --- a/commons/swagger/openapi-entreprise.yaml +++ b/commons/swagger/openapi-entreprise.yaml @@ -20885,6 +20885,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -21633,6 +21646,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -23092,6 +23118,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -24608,6 +24647,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -26109,6 +26161,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -27667,6 +27732,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -29166,6 +29244,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -30722,6 +30813,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -32174,6 +32278,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -33683,6 +33800,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -34210,6 +34340,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -35150,6 +35293,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -36124,6 +36280,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -37088,6 +37257,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': @@ -38080,6 +38262,19 @@ paths: n''a pas pu être établie : certificat invalide ou expiré, ou échec de la négociation TLS. L''équipe technique a été notifiée de cette erreur pour investigation.' + erreur_d_authentification_aupres_du_fournisseur_de_donnees_01006: + value: + errors: + - code: '01006' + title: Erreur d'authentification auprès du fournisseur de données + detail: L'authentification auprès du fournisseur de données + 'INSEE' a échoué + source: + meta: + provider: INSEE + summary: Erreur d'authentification auprès du fournisseur de données + description: L'authentification auprès du fournisseur de données + 'INSEE' a échoué schema: "$ref": "#/components/schemas/Error" '504': diff --git a/siade/app/interactors/insee/authenticate.rb b/siade/app/interactors/insee/authenticate.rb index e190a129a..a1eeda69d 100644 --- a/siade/app/interactors/insee/authenticate.rb +++ b/siade/app/interactors/insee/authenticate.rb @@ -1,49 +1,229 @@ -class INSEE::Authenticate < AbstractGetToken +class INSEE::Authenticate < MakeRequest::Post + raises ProviderAuthenticationError + CACHE_KEY = :'insee/authenticate' + GUARD_CACHE_NAMESPACE = 'insee'.freeze + LOCK_CACHE_KEY = 'auth_lock'.freeze + FAILURE_CACHE_KEY = 'auth_failed'.freeze + LOCK_TTL = 90.seconds + LOCK_WAIT = 0.5 + FAILURE_TTL = 30.minutes + TOKEN_EXPIRATION_MARGIN = 10 + INVALID_GRANT_HTTP_CODES = [400, 401].freeze + TRANSIENT_HTTP_CODES = [408, 429].freeze + + def self.invalidate_token_cache!(rejected_token) + return unless published_token == rejected_token - def self.invalidate_token_cache! EncryptedCache.write(CACHE_KEY, nil) end - protected + def self.published_token + outside_the_request_cache { EncryptedCache.read(CACHE_KEY) } + end - def client_url - Siade.credentials[:insee_oauth_url] + def self.outside_the_request_cache(&) + Rails.cache.with_local_cache(&) end - def access_token(response) - JSON.parse(response.body)['access_token'] + def self.clear_guards! + Rails.cache.delete(LOCK_CACHE_KEY) + Rails.cache.delete(FAILURE_CACHE_KEY, namespace: GUARD_CACHE_NAMESPACE) end - def expires_in(response) - JSON.parse(response.body)['expires_in'] + def call + return if use_mocked_data? + + context.token = published_token || authenticate! end - private + protected + + def request_uri + URI(Siade.credentials[:insee_oauth_url]) + end def form_data { - client_id:, - client_secret:, + client_id: Siade.credentials[:insee_sirene_client_id], + client_secret: Siade.credentials[:insee_sirene_client_secret], grant_type: 'password', - username:, - password: + username: Siade.credentials[:insee_apim_username], + password: @password } end - def client_id - Siade.credentials[:insee_sirene_client_id] + private + + def authenticate! + fail_with_temporary_error! if recently_failed? + + case acquire_lock! + when true then token_under_lock + when false then token_from_concurrent_authentication + else token_from_candidates + end + end + + def token_under_lock + published_token || token_from_candidates_unless_held_back + ensure + release_lock! + end + + def token_from_candidates_unless_held_back + fail_with_temporary_error! if recently_failed? + + token_from_candidates + end + + def token_from_concurrent_authentication + sleep(LOCK_WAIT) + + published_token || fail_with_temporary_error! + end + + def token_from_candidates + candidates = INSEE::PasswordDerivation.candidates + + token_from(candidates) || + token_from_password_renewed_meanwhile(candidates) || + fail_with_authentication_error! + end + + def token_from(candidates) + candidates.each do |candidate| + @password = candidate + + response = api_call_with_error_handling + payload = parsed_body(response) + + return store_token(payload) if token_granted?(response, payload) + + fail_with_temporary_error! if transient?(response) + next if invalid_grant?(response, payload) + + fail_with_oauth_rejection! if client_error?(response) + fail_with_temporary_error! + end + + nil + end + + def token_from_password_renewed_meanwhile(candidates) + return if candidates.last == INSEE::PasswordDerivation.current_password + + token_from([INSEE::PasswordDerivation.current_password]) + end + + def token_granted?(response, payload) + response.code.to_i == 200 && payload['access_token'].present? end - def client_secret - Siade.credentials[:insee_sirene_client_secret] + def invalid_grant?(response, payload) + INVALID_GRANT_HTTP_CODES.include?(response.code.to_i) && + payload['error'] == 'invalid_grant' end - def username - Siade.credentials[:insee_apim_username] + def transient?(response) + TRANSIENT_HTTP_CODES.include?(response.code.to_i) + end + + def client_error?(response) + response.code.to_i.between?(400, 499) + end + + def parsed_body(response) + JSON.parse(response.body.to_s) + rescue JSON::ParserError + {} + end + + def store_token(payload) + token = payload['access_token'] + + EncryptedCache.write( + CACHE_KEY, + token, + expires_in: [payload['expires_in'].to_i - TOKEN_EXPIRATION_MARGIN, 1].max + ) + + token + end + + def published_token + self.class.published_token + end + + def outside_the_request_cache(&) + self.class.outside_the_request_cache(&) + end + + def recently_failed? + outside_the_request_cache { guard_read(FAILURE_CACHE_KEY) }.present? + end + + def acquire_lock! + @lock_owner = SecureRandom.uuid + + Rails.cache.write(LOCK_CACHE_KEY, @lock_owner, expires_in: LOCK_TTL, unless_exist: true) + end + + def release_lock! + return unless outside_the_request_cache { Rails.cache.read(LOCK_CACHE_KEY) } == @lock_owner + + Rails.cache.delete(LOCK_CACHE_KEY) + end + + def guard_read(key) + Rails.cache.read(key, namespace: GUARD_CACHE_NAMESPACE) + end + + def guard_write(key, value, expires_in:, unless_exist: false) + Rails.cache.write(key, value, namespace: GUARD_CACHE_NAMESPACE, expires_in:, unless_exist:) + end + + def fail_with_temporary_error! + error = ProviderTemporaryError.new( + context.provider_name, + "Erreur d'authentification temporaire auprès de l'INSEE, merci de réessayer votre appel" + ) + error.add_meta(retry_in: 10) + + context.errors << error + context.fail! + end + + def fail_with_oauth_rejection! + record_authentication_failure!( + 'INSEE refused the OAuth exchange itself: client credentials revoked or account locked' + ) + end + + def fail_with_authentication_error! + record_authentication_failure!( + 'INSEE authentication failed on every candidate: password desynchronized or account locked' + ) + end + + def record_authentication_failure!(message) + guard_write(FAILURE_CACHE_KEY, true, expires_in: FAILURE_TTL) + + track_authentication_failure!(message) + + context.errors << ProviderAuthenticationError.new(context.provider_name) + context.fail! end - def password - INSEE::PasswordDerivation.current_password + def track_authentication_failure!(message) + MonitoringService.instance.track_with_added_context( + 'error', + message, + { + period: INSEE::PasswordDerivation.current_period, + bypassed: INSEE::PasswordDerivation.bypassed?, + candidates_count: INSEE::PasswordDerivation.candidates.size + } + ) end end diff --git a/siade/app/interactors/insee/make_request.rb b/siade/app/interactors/insee/make_request.rb index 86f139153..c4baf8091 100644 --- a/siade/app/interactors/insee/make_request.rb +++ b/siade/app/interactors/insee/make_request.rb @@ -1,11 +1,7 @@ class INSEE::MakeRequest < MakeRequest::Get - ROTATION_LOCK_KEY = 'insee/password_rotation_lock'.freeze - ROTATION_LOCK_TTL = 30 - def call super - rotate_password_if_needed! retry_with_new_token! if should_retry_with_new_token? end @@ -48,78 +44,28 @@ def token_expired_response? def retry_with_new_token! context.token_refresh_attempted = true - fresh_token = EncryptedCache.read(INSEE::Authenticate::CACHE_KEY) + fresh_token = INSEE::Authenticate.published_token if fresh_token && fresh_token != context.token context.token = fresh_token else - authenticate_with_retries! + reauthenticate! end api_call_with_error_handling fail_with_temporary_auth_error! if token_expired_response? end - def authenticate_with_retries! - INSEE::Authenticate.invalidate_token_cache! - - max_auth_attempts = 5 - - max_auth_attempts.times do |attempt| - auth_context = INSEE::Authenticate.call(provider_name: context.provider_name) + def reauthenticate! + INSEE::Authenticate.invalidate_token_cache!(context.token) - if auth_context.success? - context.token = auth_context.token - break - end + auth_context = INSEE::Authenticate.call(provider_name: context.provider_name) - fail_with_temporary_auth_error! if attempt == max_auth_attempts - 1 - sleep(0.2) + unless auth_context.success? + context.errors.concat(auth_context.errors) + context.fail! end - end - - def rotate_password_if_needed! - return unless password_rotation_needed? - - return unless acquire_rotation_lock! - - renew_context = INSEE::RenewPassword.call( - token: context.token, - old_password: INSEE::PasswordDerivation.previous_password, - new_password: INSEE::PasswordDerivation.current_password, - provider_name: context.provider_name - ) - - INSEE::Authenticate.invalidate_token_cache! if renew_context.success? - end - - def password_rotation_needed? - return false if INSEE::PasswordDerivation.bypassed? - return false if context.token.blank? - return false if INSEE::PasswordDerivation.current_period < INSEE::PasswordDerivation::DERIVATION_START - - pwd_changed_period = pwd_changed_period_from_token - return false if pwd_changed_period.nil? - - pwd_changed_period < INSEE::PasswordDerivation.current_period - end - - def pwd_changed_period_from_token - payload = JWT.decode(context.token, nil, false).first - pwd_changed_time = payload['pwdChangedTime'] - return if pwd_changed_time.nil? - - date = Time.zone.parse(pwd_changed_time).to_date - INSEE::PasswordDerivation.send(:period_for, date) - rescue JWT::DecodeError - nil - end - - def acquire_rotation_lock! - rotation_redis.set(ROTATION_LOCK_KEY, Process.pid, nx: true, ex: ROTATION_LOCK_TTL) - end - def rotation_redis - @rotation_redis ||= RedisService.new + context.token = auth_context.token end def fail_with_temporary_auth_error! diff --git a/siade/app/interactors/insee/renew_password.rb b/siade/app/interactors/insee/renew_password.rb deleted file mode 100644 index f05d74966..000000000 --- a/siade/app/interactors/insee/renew_password.rb +++ /dev/null @@ -1,57 +0,0 @@ -class INSEE::RenewPassword < MakeRequest::Post - def call - super - - fail_with_authentication_error! unless renewal_accepted? - end - - protected - - def request_uri - URI("#{base_uri}/#{sirene_base_path}/renouvellement") - end - - def request_params - { - oldPassword: context.old_password, - newPassword: context.new_password - } - end - - def extra_headers(request) - request['Authorization'] = "Bearer #{context.token}" - super - end - - def base_uri - Siade.credentials[:insee_sirene_url] - end - - def sirene_base_path - 'api-sirene/prive/3.11' - end - - private - - def renewal_accepted? - context.response&.code&.to_i == 200 - end - - def fail_with_authentication_error! - track_renewal_rejected! - - context.errors << ProviderAuthenticationError.new(context.provider_name) - context.fail! - end - - def track_renewal_rejected! - MonitoringService.instance.track_with_added_context( - 'error', - 'Fail to rotate INSEE password', - { - http_response_code: context.response&.code, - http_response_body: context.response&.body - } - ) - end -end diff --git a/siade/app/services/insee/password_derivation.rb b/siade/app/services/insee/password_derivation.rb index 13d0d9355..e667c2e95 100644 --- a/siade/app/services/insee/password_derivation.rb +++ b/siade/app/services/insee/password_derivation.rb @@ -1,9 +1,13 @@ require 'openssl' class INSEE::PasswordDerivation - DERIVATION_START = '2026-09'.freeze + class MissingBypassPasswordError < StandardError; end + + DERIVATION_START = '2026-11'.freeze BIMESTER_MONTHS = [1, 3, 5, 7, 9, 11].freeze BYPASS_CREDENTIAL_KEY = :insee_apim_password_bypass + STATIC_CREDENTIAL_KEY = :insee_apim_password + DERIVATION_KEY_CREDENTIAL_KEY = :insee_apim_password_derivation_key PASSWORD_LENGTH = 16 CHAR_GUARANTEES = { /[A-Z]/ => 'A', @@ -12,19 +16,30 @@ class INSEE::PasswordDerivation /[^a-zA-Z0-9]/ => '#' }.freeze + def self.credentials + Siade.credentials + end + def self.bypassed? - Siade.credentials.key?(BYPASS_CREDENTIAL_KEY) + credentials.key?(BYPASS_CREDENTIAL_KEY) end - def self.current_password - return bypass_password if bypassed? + def self.candidates + return [bypass_password, current_password].uniq if bypassed? + + [current_password, previous_password].uniq + end + def self.bypass_password + credentials[BYPASS_CREDENTIAL_KEY].presence || + fail(MissingBypassPasswordError, "credential '#{BYPASS_CREDENTIAL_KEY}' is present but empty") + end + + def self.current_password password_for(current_period) end def self.previous_password - return bypass_password if bypassed? - password_for(previous_period) end @@ -44,12 +59,8 @@ def self.previous_period end end - def self.bypass_password - Siade.credentials[BYPASS_CREDENTIAL_KEY] - end - def self.password_for(period) - return Siade.credentials[:insee_apim_password] if period < DERIVATION_START + return credentials[STATIC_CREDENTIAL_KEY] if period < DERIVATION_START derive(period) end @@ -67,16 +78,28 @@ def self.period_for(date) def self.format_password(hmac_bytes) chars = Base64.urlsafe_encode64(hmac_bytes, padding: false)[0, PASSWORD_LENGTH].chars - CHAR_GUARANTEES.each_with_index do |(pattern, fallback), idx| - chars[idx] = fallback unless chars.any? { |c| c.match?(pattern) } + CHAR_GUARANTEES.each do |pattern, fallback| + next if chars.any? { |char| char.match?(pattern) } + + chars[expendable_index(chars)] = fallback end chars.join end + def self.expendable_index(chars) + chars.index do |char| + chars.many? { |other| char_class(other) == char_class(char) } + end + end + + def self.char_class(char) + CHAR_GUARANTEES.keys.find { |pattern| char.match?(pattern) } + end + def self.secret - Siade.credentials[:insee_apim_password_derivation_key] + credentials[DERIVATION_KEY_CREDENTIAL_KEY] end - private_class_method :bypass_password, :password_for, :derive, :period_for, :format_password, :secret + private_class_method :credentials, :password_for, :derive, :period_for, :format_password, :expendable_index, :char_class, :secret end diff --git a/siade/spec/interactors/insee/authenticate_spec.rb b/siade/spec/interactors/insee/authenticate_spec.rb index 265331686..afa14bc82 100644 --- a/siade/spec/interactors/insee/authenticate_spec.rb +++ b/siade/spec/interactors/insee/authenticate_spec.rb @@ -1,44 +1,640 @@ RSpec.describe INSEE::Authenticate, type: :interactor do - subject(:retrieve_token) { described_class.call } + def guard_write(key, value, **) + Rails.cache.write(key, value, namespace: described_class::GUARD_CACHE_NAMESPACE, **) + end + + def guard_read(key) + Rails.cache.read(key, namespace: described_class::GUARD_CACHE_NAMESPACE) + end + + def lock_write(value, **) + Rails.cache.write(described_class::LOCK_CACHE_KEY, value, **) + end + + def lock_read + Rails.cache.read(described_class::LOCK_CACHE_KEY) + end + + def arm_the_failure_guard + guard_write(described_class::FAILURE_CACHE_KEY, true, expires_in: described_class::FAILURE_TTL) + end + + def from_another_process(&) + Rails.cache.with_local_cache(&) + end + + def publish_the_other_thread_token + EncryptedCache.write(described_class::CACHE_KEY, 'token-from-the-other-thread', expires_in: 1.hour) + end + + subject(:retrieve_token) { described_class.call(provider_name: 'INSEE') } + + let(:insee_oauth_url) { Siade.credentials[:insee_oauth_url] } + let(:token) { 'a-fresh-insee-token' } - context 'when the token is not stored in redis', vcr: { cassette_name: 'insee/token' } do - let(:token) { 'anonymized-insee-token-12345678-abcd-efgh-ijkl-9876543210fe' } - let(:expires_in) { 598_077 } + def stub_oauth(*responses) + stub_request(:post, /#{insee_oauth_url}/).to_return(*responses) + end + + def granted_response(access_token: 'a-fresh-insee-token', expires_in: 598_077) + { + status: 200, + body: { access_token:, expires_in: }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + def invalid_grant_response + { + status: 401, + body: { error: 'invalid_grant', error_description: 'Invalid user credentials' }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + context 'when the token is not stored in cache', vcr: { cassette_name: 'insee/token' } do it { is_expected.to be_a_success } its(:errors) { is_expected.to be_blank } - its(:token) { is_expected.to eq token } + its(:token) { is_expected.to eq 'anonymized-insee-token-12345678-abcd-efgh-ijkl-9876543210fe' } it 'calls INSEE API' do retrieve_token - expect(WebMock).to have_requested(:post, /#{Siade.credentials[:insee_oauth_url]}/) + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/) end it 'stores the new token retrieved from INSEE API in cache' do expect { retrieve_token - }.to change { EncryptedCache.read('insee/authenticate') }.to(token) + }.to change { EncryptedCache.read(described_class::CACHE_KEY) } + .to('anonymized-insee-token-12345678-abcd-efgh-ijkl-9876543210fe') end end context 'when the token is stored in cache' do - let(:token) { '1234567890' } + before { EncryptedCache.write(described_class::CACHE_KEY, 'cached-token') } + + it { is_expected.to be_a_success } + + its(:token) { is_expected.to eq 'cached-token' } + + it 'does not call INSEE API' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + end + context 'when the first candidate is rejected with invalid_grant' do before do - EncryptedCache.write('insee/authenticate', token) + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(invalid_grant_response, granted_response) end + after { Timecop.return } + it { is_expected.to be_a_success } - it 'does not call INSEE API' do + its(:token) { is_expected.to eq token } + + it 'tries each candidate exactly once' do retrieve_token - expect(WebMock).not_to have_requested(:post, /#{Siade.credentials[:insee_oauth_url]}/) + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).twice end - its(:errors) { is_expected.to be_blank } + it 'sends the previous password as second candidate' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/) + .with(body: hash_including('password' => INSEE::PasswordDerivation.previous_password)) + end + end + + context 'when INSEE is unavailable' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 503, body: '') + end + + after { Timecop.return } + + it { is_expected.to be_a_failure } + + it 'does not try the second candidate' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + + it 'does not remember the failure' do + retrieve_token + + expect(guard_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + end + + context 'when INSEE rate limits the OAuth exchange' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 429, body: '', headers: { 'Retry-After' => '2' }) + end + + after { Timecop.return } + + it { is_expected.to be_a_failure } + + it 'fails with a temporary error' do + expect(retrieve_token.errors.first).to be_a(ProviderTemporaryError) + end + + it 'does not try the second candidate' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + + it 'does not remember the failure' do + retrieve_token + + expect(guard_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + end + + context 'when INSEE answers with a request timeout' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 408, body: '') + end + + after { Timecop.return } + + it 'fails with a temporary error' do + expect(retrieve_token.errors.first).to be_a(ProviderTemporaryError) + end + + it 'does not remember the failure' do + retrieve_token + + expect(guard_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + end + + context 'when INSEE answers with a non JSON body' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 200, body: 'gateway') + end + + after { Timecop.return } + + it { is_expected.to be_a_failure } + + it 'fails with a temporary error' do + expect(retrieve_token.errors.first).to be_a(ProviderTemporaryError) + end + + it 'does not try the second candidate' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + end + + context 'when INSEE times out' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_request(:post, /#{insee_oauth_url}/).to_timeout + end + + after { Timecop.return } + + it { is_expected.to be_a_failure } + + it 'does not try the second candidate' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + end + + context 'when every candidate is rejected with invalid_grant' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(invalid_grant_response) + allow(MonitoringService.instance).to receive(:track_with_added_context) + end + + after { Timecop.return } + + it { is_expected.to be_a_failure } + + it 'fails with a ProviderAuthenticationError' do + expect(retrieve_token.errors.first).to be_a(ProviderAuthenticationError) + end + + it 'tries each candidate then the current password once more' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).times(3) + end + + it 'alerts on both hypotheses' do + retrieve_token + + expect(MonitoringService.instance).to have_received(:track_with_added_context).with( + 'error', + 'INSEE authentication failed on every candidate: password desynchronized or account locked', + hash_including(period: '2027-01') + ) + end + + it 'remembers the failure for 30 minutes' do + retrieve_token + + expect(guard_read(described_class::FAILURE_CACHE_KEY)).to be(true) + end + + it 'does not call INSEE again while the failure is remembered' do + retrieve_token + described_class.call(provider_name: 'INSEE') + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).times(3) + end + + it 'fails temporarily while the failure is remembered' do + retrieve_token + + expect(described_class.call(provider_name: 'INSEE').errors.first).to be_a(ProviderTemporaryError) + end + end + + context 'when a rotation renewed the password while the candidates were tried' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(invalid_grant_response, invalid_grant_response, granted_response) + allow(MonitoringService.instance).to receive(:track_with_added_context) + end + + after { Timecop.return } + + it { is_expected.to be_a_success } + its(:token) { is_expected.to eq token } + + it 'does not remember a failure' do + retrieve_token + + expect(guard_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + + it 'does not alert' do + retrieve_token + + expect(MonitoringService.instance).not_to have_received(:track_with_added_context) + end + end + + context 'when another authentication armed the failure guard before the lock was free' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(granted_response) + + allow(Rails.cache).to receive(:write).and_wrap_original do |original, *args, **options| + arm_the_failure_guard if args.first == described_class::LOCK_CACHE_KEY + + original.call(*args, **options) + end + end + + after { Timecop.return } + + it 'fails with a temporary error' do + expect(retrieve_token.errors.first).to be_a(ProviderTemporaryError) + end + + it 'spends none of the attempts the guard was meant to save' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + + it 'releases the single flight lock' do + retrieve_token + + expect(lock_read).to be_nil + end + end + + describe 'within a request local cache' do + around { |example| Rails.cache.with_local_cache { example.run } } + + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(granted_response) + end + + after { Timecop.return } + + context 'when another process published the token this request already missed' do + before do + EncryptedCache.read(described_class::CACHE_KEY) + + from_another_process do + EncryptedCache.write(described_class::CACHE_KEY, 'token-from-another-process', expires_in: 1.hour) + end + + lock_write('another-request', expires_in: described_class::LOCK_TTL) + stub_const("#{described_class}::LOCK_WAIT", 0) + end + + it { is_expected.to be_a_success } + + its(:token) { is_expected.to eq 'token-from-another-process' } + + it 'does not call INSEE API' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + end + + context 'when another process armed the failure guard this request already missed' do + before do + allow(Rails.cache).to receive(:write).and_wrap_original do |original, *args, **options| + from_another_process { arm_the_failure_guard } if args.first == described_class::LOCK_CACHE_KEY + + original.call(*args, **options) + end + end + + it 'fails with a temporary error' do + expect(retrieve_token.errors.first).to be_a(ProviderTemporaryError) + end + + it 'spends none of the attempts the guard was meant to save' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + end + + context 'when the lock was taken over while authenticating' do + before do + stub_request(:post, /#{insee_oauth_url}/).to_return do + from_another_process { lock_write('the-successor', expires_in: described_class::LOCK_TTL) } + + granted_response + end + end + + it 'leaves the successor lock alone' do + retrieve_token + + expect(from_another_process { lock_read }).to eq('the-successor') + end + end + + context 'when another process invalidated the token this request already read' do + before do + EncryptedCache.write(described_class::CACHE_KEY, 'rejected-token', expires_in: 1.hour) + EncryptedCache.read(described_class::CACHE_KEY) + + from_another_process { EncryptedCache.write(described_class::CACHE_KEY, nil) } + + described_class.invalidate_token_cache!('rejected-token') + end + + its(:token) { is_expected.to eq token } + + it 'does not hand back the token it just tried to invalidate' do + expect(retrieve_token.token).not_to eq('rejected-token') + end + + it 'authenticates again' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/) + end + end + + describe '.invalidate_token_cache!' do + it 'keeps the token another process published after this request read the one it rejects' do + EncryptedCache.write(described_class::CACHE_KEY, 'rejected-token', expires_in: 1.hour) + EncryptedCache.read(described_class::CACHE_KEY) + + from_another_process do + EncryptedCache.write(described_class::CACHE_KEY, 'refreshed-token', expires_in: 1.hour) + end + + described_class.invalidate_token_cache!('rejected-token') + + expect(described_class.published_token).to eq('refreshed-token') + end + end + end + + describe 'single flight' do + before do + lock_write(true, expires_in: described_class::LOCK_TTL) + + stub_const("#{described_class}::LOCK_WAIT", 0) + + stub_oauth(granted_response) + end + + context 'when another thread published its token meanwhile' do + before { EncryptedCache.write(described_class::CACHE_KEY, 'token-from-the-other-thread') } + + it { is_expected.to be_a_success } + + its(:token) { is_expected.to eq 'token-from-the-other-thread' } + + it 'does not call INSEE API' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + end + + context 'when another thread publishes its token while this one waits' do + before do + allow(Rails.cache).to receive(:write).and_wrap_original do |original, *args, **options| + publish_the_other_thread_token if args.first == described_class::LOCK_CACHE_KEY + + original.call(*args, **options) + end + end + + it { is_expected.to be_a_success } + + its(:token) { is_expected.to eq 'token-from-the-other-thread' } + + it 'does not call INSEE API' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + end + + context 'when the other thread published nothing' do + it { is_expected.to be_a_failure } + + it 'fails temporarily instead of burning an attempt' do + expect(retrieve_token.errors.first).to be_a(ProviderTemporaryError) + end + + it 'does not call INSEE API' do + retrieve_token + + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) + end + end + end + + describe 'lock release' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(granted_response) + end + + after { Timecop.return } + + it 'releases the lock on success' do + retrieve_token + + expect(lock_read).to be_nil + end + + context 'when the authentication fails' do + before { stub_oauth(status: 503, body: '') } + + it 'releases the lock too' do + retrieve_token + + expect(lock_read).to be_nil + end + end + + context 'when the OAuth exchange is in flight' do + let(:observed) { {} } + + before do + stub_request(:post, /#{insee_oauth_url}/).to_return do + observed[:beside_token] = lock_read + observed[:in_guard_namespace] = guard_read(described_class::LOCK_CACHE_KEY) + + granted_response + end + end + + it 'holds the lock where the token lives' do + retrieve_token + + expect(observed[:beside_token]).to be_present + end + + it 'keeps it out of the namespace the failure guard shares' do + retrieve_token + + expect(observed[:in_guard_namespace]).to be_nil + end + end + + context 'when the lock was taken over while authenticating' do + before do + stub_request(:post, /#{insee_oauth_url}/).to_return do + lock_write('the-successor', expires_in: described_class::LOCK_TTL) + + granted_response + end + end + + it 'leaves the successor lock alone' do + retrieve_token + + expect(lock_read).to eq('the-successor') + end + end + end + + context 'when INSEE refuses the OAuth exchange itself' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth( + status: 400, + body: { error: 'invalid_client' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + after { Timecop.return } + + it { is_expected.to be_a_failure } + + it 'stops after the first candidate' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + + it 'holds back the next authentications' do + retrieve_token + + expect(guard_read(described_class::FAILURE_CACHE_KEY)).to be(true) + end + end + + context 'when the cache is unavailable' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + allow(Rails.cache).to receive_messages(read: nil, write: nil, delete: false) + + stub_oauth(granted_response) + end + + after { Timecop.return } + + it { is_expected.to be_a_success } + + its(:token) { is_expected.to eq token } + + it 'costs a single OAuth call' do + retrieve_token + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + end + + describe '.invalidate_token_cache!' do + it 'drops the token the provider rejected' do + EncryptedCache.write(described_class::CACHE_KEY, 'rejected-token') + + described_class.invalidate_token_cache!('rejected-token') + + expect(EncryptedCache.read(described_class::CACHE_KEY)).to be_nil + end + + it 'keeps a token a sibling thread published in the meantime' do + EncryptedCache.write(described_class::CACHE_KEY, 'refreshed-token') + + described_class.invalidate_token_cache!('rejected-token') + + expect(EncryptedCache.read(described_class::CACHE_KEY)).to eq('refreshed-token') + end end end diff --git a/siade/spec/interactors/insee/make_request_spec.rb b/siade/spec/interactors/insee/make_request_spec.rb index a4f915f5b..f8ea064c8 100644 --- a/siade/spec/interactors/insee/make_request_spec.rb +++ b/siade/spec/interactors/insee/make_request_spec.rb @@ -6,22 +6,22 @@ let(:new_token) { 'refreshed_token' } let(:insee_sirene_url) { Siade.credentials[:insee_sirene_url] } let(:insee_oauth_url) { Siade.credentials[:insee_oauth_url] } + let(:renew_url) { %r{#{insee_sirene_url}/api-sirene/prive/3.11/renouvellement} } - def jwt_token_with_pwd_changed_time(time_string) - payload = { 'pwdChangedTime' => time_string } - JWT.encode(payload, nil, 'none') + def stub_expired_token_response + stub_request(:get, /#{insee_sirene_url}/) + .with(headers: { 'Authorization' => "Bearer #{token}" }) + .to_return(status: 401, body: '{"header":{"statut":401,"message":"Jeton invalide ou jeton expiré"}}') end describe 'retry on 401 token expired' do before do - EncryptedCache.write('insee/authenticate', token) + EncryptedCache.write(INSEE::Authenticate::CACHE_KEY, token) end context 'when first request returns 401 and retry succeeds' do before do - stub_request(:get, /#{insee_sirene_url}/) - .with(headers: { 'Authorization' => "Bearer #{token}" }) - .to_return(status: 401, body: '{"header":{"statut":401,"message":"Jeton invalide ou jeton expiré"}}') + stub_expired_token_response stub_request(:post, /#{insee_oauth_url}/) .to_return( @@ -37,10 +37,6 @@ def jwt_token_with_pwd_changed_time(time_string) it { is_expected.to be_a_success } - it 'invalidates the cached token' do - expect { make_request }.to change { EncryptedCache.read('insee/authenticate') }.from(token) - end - it 'retries with the new token' do make_request @@ -48,6 +44,12 @@ def jwt_token_with_pwd_changed_time(time_string) .with(headers: { 'Authorization' => "Bearer #{new_token}" }) end + it 'authenticates only once' do + make_request + + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).once + end + its(:response) { is_expected.to be_a(Net::HTTPOK) } end @@ -88,20 +90,11 @@ def jwt_token_with_pwd_changed_time(time_string) end end - context 'when first request returns 401 and OAuth authentication fails temporarily' do - let(:created_instances) { [] } - + context 'when first request returns 401 and every password candidate is rejected' do before do - allow(INSEE::UniteLegale::MakeRequest).to receive(:new).and_wrap_original do |method, *args| - method.call(*args).tap do |instance| - allow(instance).to receive(:sleep) - created_instances << instance - end - end + Timecop.freeze(Date.new(2027, 1, 15)) - stub_request(:get, /#{insee_sirene_url}/) - .with(headers: { 'Authorization' => "Bearer #{token}" }) - .to_return(status: 401, body: '{"header":{"statut":401,"message":"Jeton invalide ou jeton expiré"}}') + stub_expired_token_response stub_request(:post, /#{insee_oauth_url}/) .to_return( @@ -109,51 +102,39 @@ def jwt_token_with_pwd_changed_time(time_string) body: { error: 'invalid_grant', error_description: 'Invalid user credentials' }.to_json, headers: { 'Content-Type' => 'application/json' } ) - end - it { is_expected.to be_a_failure } + allow(MonitoringService.instance).to receive(:track_with_added_context) + end - it 'retries authentication 5 times before giving up' do - make_request + after { Timecop.return } - expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).times(5) - end + it { is_expected.to be_a_failure } - it 'sleeps between auth retries' do + it 'tries each candidate then the current password once more' do make_request - expect(created_instances.last).to have_received(:sleep).with(0.2).exactly(4).times + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).times(3) end - it 'fails with a ProviderTemporaryError' do - expect(make_request.errors.first).to be_a(ProviderTemporaryError) + it 'fails with a ProviderAuthenticationError' do + expect(make_request.errors.first).to be_a(ProviderAuthenticationError) end - it 'does not retry the API call after auth failure' do + it 'does not retry the API call after the authentication failure' do make_request expect(WebMock).to have_requested(:get, /#{insee_sirene_url}/).once end end - context 'when first request returns 401 and OAuth fails then succeeds' do - let(:created_instances) { [] } - + context 'when first request returns 401 and the first password candidate is rejected' do before do - allow(INSEE::UniteLegale::MakeRequest).to receive(:new).and_wrap_original do |method, *args| - method.call(*args).tap do |instance| - allow(instance).to receive(:sleep) - created_instances << instance - end - end + Timecop.freeze(Date.new(2027, 1, 15)) - stub_request(:get, /#{insee_sirene_url}/) - .with(headers: { 'Authorization' => "Bearer #{token}" }) - .to_return(status: 401, body: '{"header":{"statut":401,"message":"Jeton invalide ou jeton expiré"}}') + stub_expired_token_response stub_request(:post, /#{insee_oauth_url}/) .to_return( - { status: 401, body: { error: 'invalid_grant' }.to_json, headers: { 'Content-Type' => 'application/json' } }, { status: 401, body: { error: 'invalid_grant' }.to_json, headers: { 'Content-Type' => 'application/json' } }, { status: 200, body: { access_token: new_token, expires_in: 3600 }.to_json, headers: { 'Content-Type' => 'application/json' } } ) @@ -163,18 +144,14 @@ def jwt_token_with_pwd_changed_time(time_string) .to_return(status: 200, body: '{"uniteLegale":{}}') end - it { is_expected.to be_a_success } - - it 'retries authentication until it succeeds' do - make_request + after { Timecop.return } - expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).times(3) - end + it { is_expected.to be_a_success } - it 'sleeps between failed auth attempts' do + it 'falls back on the second candidate' do make_request - expect(created_instances.last).to have_received(:sleep).with(0.2).twice + expect(WebMock).to have_requested(:post, /#{insee_oauth_url}/).twice end end @@ -193,7 +170,7 @@ def jwt_token_with_pwd_changed_time(time_string) end it 'keeps the cached token' do - expect { make_request }.not_to(change { EncryptedCache.read('insee/authenticate') }) + expect { make_request }.not_to(change { EncryptedCache.read(INSEE::Authenticate::CACHE_KEY) }) end end @@ -201,9 +178,7 @@ def jwt_token_with_pwd_changed_time(time_string) let(:token_from_other_thread) { 'token_from_other_thread' } before do - stub_request(:get, /#{insee_sirene_url}/) - .with(headers: { 'Authorization' => "Bearer #{token}" }) - .to_return(status: 401, body: '{"header":{"statut":401,"message":"Jeton invalide ou jeton expiré"}}') + stub_expired_token_response stub_request(:get, /#{insee_sirene_url}/) .with(headers: { 'Authorization' => "Bearer #{token_from_other_thread}" }) @@ -227,161 +202,52 @@ def jwt_token_with_pwd_changed_time(time_string) .with(headers: { 'Authorization' => "Bearer #{token_from_other_thread}" }) end end - end - - describe 'password rotation' do - let(:renew_url) { %r{#{insee_sirene_url}/api-sirene/prive/3.11/renouvellement} } - - before do - EncryptedCache.write('insee/authenticate', token) - end - - after do - Timecop.return - RedisService.new.del(INSEE::MakeRequest::ROTATION_LOCK_KEY) - end - - context 'when pwdChangedTime is in a previous bimester' do - let(:token) { jwt_token_with_pwd_changed_time('2026-08-15T10:00:00Z') } - - before do - Timecop.freeze(Date.new(2026, 9, 15)) - - stub_request(:get, /#{insee_sirene_url}/) - .to_return(status: 200, body: '{"uniteLegale":{}}') - - stub_request(:post, renew_url) - .to_return(status: 200, body: '{}') - end - - it 'calls the renewal API' do - make_request - - expect(WebMock).to have_requested(:post, renew_url) - end - - it 'sends old and new passwords' do - make_request - - expect(WebMock).to have_requested(:post, renew_url) - .with(body: { - oldPassword: INSEE::PasswordDerivation.previous_password, - newPassword: INSEE::PasswordDerivation.current_password - }.to_json) - end - - it 'invalidates the token cache so next auth gets a fresh JWT' do - make_request - - expect(EncryptedCache.read('insee/authenticate')).to be_nil - end - end - - context 'when the renewal API rejects the old password (400)' do - let(:token) { jwt_token_with_pwd_changed_time('2026-08-15T10:00:00Z') } - - before do - Timecop.freeze(Date.new(2026, 9, 15)) - EncryptedCache.write('insee/authenticate', token) - - stub_request(:get, /#{insee_sirene_url}/) - .to_return(status: 200, body: '{"uniteLegale":{}}') - - stub_request(:post, renew_url) - .to_return(status: 400, body: '{"message":"Ancien mot de passe incorrect"}') - end - - it 'does not invalidate the token cache' do - make_request - expect(EncryptedCache.read('insee/authenticate')).to eq(token) - end - end + context 'when another process refreshed the token within a request local cache' do + let(:token_from_other_thread) { 'token_from_other_thread' } - context 'when pwdChangedTime is in the current bimester' do - let(:token) { jwt_token_with_pwd_changed_time('2026-09-10T10:00:00Z') } + around { |example| Rails.cache.with_local_cache { example.run } } before do - Timecop.freeze(Date.new(2026, 9, 15)) + stub_expired_token_response stub_request(:get, /#{insee_sirene_url}/) + .with(headers: { 'Authorization' => "Bearer #{token_from_other_thread}" }) .to_return(status: 200, body: '{"uniteLegale":{}}') - end - it 'does not call the renewal API' do - make_request + EncryptedCache.read(INSEE::Authenticate::CACHE_KEY) - expect(WebMock).not_to have_requested(:post, renew_url) + Rails.cache.with_local_cache do + EncryptedCache.write(INSEE::Authenticate::CACHE_KEY, token_from_other_thread) + end end - end - context 'when the token is not a valid JWT' do - let(:token) { 'not-a-jwt' } - - before do - stub_request(:get, /#{insee_sirene_url}/) - .to_return(status: 200, body: '{"uniteLegale":{}}') - end + it { is_expected.to be_a_success } - it 'does not attempt rotation' do + it 'uses the fresh token from cache without OAuth call' do make_request - expect(WebMock).not_to have_requested(:post, renew_url) + expect(WebMock).not_to have_requested(:post, /#{insee_oauth_url}/) end end + end - context 'when the rotation lock is already held' do - let(:token) { jwt_token_with_pwd_changed_time('2026-08-15T10:00:00Z') } - - before do - Timecop.freeze(Date.new(2026, 9, 15)) - - RedisService.new.set( - INSEE::MakeRequest::ROTATION_LOCK_KEY, - true, - nx: true, - ex: INSEE::MakeRequest::ROTATION_LOCK_TTL - ) - - stub_request(:get, /#{insee_sirene_url}/) - .to_return(status: 200, body: '{"uniteLegale":{}}') - end - - after do - RedisService.new.del(INSEE::MakeRequest::ROTATION_LOCK_KEY) - end + describe 'password rotation' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) - it 'does not call the renewal API' do - make_request + EncryptedCache.write(INSEE::Authenticate::CACHE_KEY, token) - expect(WebMock).not_to have_requested(:post, renew_url) - end + stub_request(:get, /#{insee_sirene_url}/) + .to_return(status: 200, body: '{"uniteLegale":{}}') end - context 'when the bypass credential is set' do - let(:token) { jwt_token_with_pwd_changed_time('2026-08-15T10:00:00Z') } - - before do - Timecop.freeze(Date.new(2026, 9, 15)) - Siade.credentials[INSEE::PasswordDerivation::BYPASS_CREDENTIAL_KEY] = 'ByPass#Password1' - - stub_request(:get, /#{insee_sirene_url}/) - .to_return(status: 200, body: '{"uniteLegale":{}}') - end - - after { Siade.credentials.delete(INSEE::PasswordDerivation::BYPASS_CREDENTIAL_KEY) } - - it 'does not call the renewal API' do - make_request + after { Timecop.return } - expect(WebMock).not_to have_requested(:post, renew_url) - end + it 'never renews the password from the request path' do + make_request - it 'does not take the rotation lock' do - make_request - - expect(RedisService.new.get(INSEE::MakeRequest::ROTATION_LOCK_KEY)).to be_nil - end + expect(WebMock).not_to have_requested(:post, renew_url) end end end diff --git a/siade/spec/interactors/insee/renew_password_spec.rb b/siade/spec/interactors/insee/renew_password_spec.rb deleted file mode 100644 index a1199a258..000000000 --- a/siade/spec/interactors/insee/renew_password_spec.rb +++ /dev/null @@ -1,86 +0,0 @@ -RSpec.describe INSEE::RenewPassword, type: :interactor do - subject(:renew) do - described_class.call( - token:, - old_password:, - new_password:, - provider_name: 'insee' - ) - end - - let(:token) { 'valid-bearer-token' } - let(:old_password) { 'OldP4ssword!xyz' } - let(:new_password) { 'NewP4ssword!abc' } - let(:insee_sirene_url) { Siade.credentials[:insee_sirene_url] } - let(:renew_url) { %r{#{insee_sirene_url}/api-sirene/prive/3.11/renouvellement} } - - context 'when the renewal succeeds' do - before do - stub_request(:post, renew_url) - .with( - headers: { 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json' }, - body: { oldPassword: old_password, newPassword: new_password }.to_json - ) - .to_return(status: 200, body: '{}') - end - - it { is_expected.to be_a_success } - - it 'sends a POST with correct body' do - renew - - expect(WebMock).to have_requested(:post, renew_url) - .with(body: { oldPassword: old_password, newPassword: new_password }.to_json) - end - - it 'sends the Authorization header' do - renew - - expect(WebMock).to have_requested(:post, renew_url) - .with(headers: { 'Authorization' => "Bearer #{token}" }) - end - end - - context 'when the old password is incorrect (400)' do - before do - stub_request(:post, renew_url) - .to_return(status: 400, body: '{"message":"Ancien mot de passe incorrect"}') - end - - it { is_expected.to be_a_failure } - - it 'returns the 400 response' do - expect(renew.response.code).to eq('400') - end - - it 'fails with a ProviderAuthenticationError' do - expect(renew.errors.first).to be_a(ProviderAuthenticationError) - end - - it 'raises a Sentry alert' do - expect(MonitoringService.instance).to receive(:track_with_added_context).with( - 'error', - 'Fail to rotate INSEE password', - { - http_response_code: '400', - http_response_body: '{"message":"Ancien mot de passe incorrect"}' - } - ) - - renew - end - end - - context 'when the new password is invalid (400)' do - before do - stub_request(:post, renew_url) - .to_return(status: 400, body: '{"message":"Le mot de passe ne respecte pas les règles"}') - end - - it { is_expected.to be_a_failure } - - it 'returns the 400 response' do - expect(renew.response.code).to eq('400') - end - end -end diff --git a/siade/spec/services/insee/password_derivation_spec.rb b/siade/spec/services/insee/password_derivation_spec.rb index 3d19bdec1..e64c32dc5 100644 --- a/siade/spec/services/insee/password_derivation_spec.rb +++ b/siade/spec/services/insee/password_derivation_spec.rb @@ -1,7 +1,9 @@ RSpec.describe INSEE::PasswordDerivation do - describe '.current_period' do - after { Timecop.return } + after { Timecop.return } + + let(:static_password) { Siade.credentials[described_class::STATIC_CREDENTIAL_KEY] } + describe '.current_period' do it 'returns the bimester seed for January' do Timecop.freeze(Date.new(2026, 1, 15)) expect(described_class.current_period).to eq('2026-01') @@ -29,8 +31,6 @@ end describe '.previous_period' do - after { Timecop.return } - it 'returns the previous bimester for March (-> jan)' do Timecop.freeze(Date.new(2026, 3, 15)) expect(described_class.previous_period).to eq('2026-01') @@ -52,74 +52,118 @@ end end - describe '.current_password' do - after { Timecop.return } + describe 'known vectors, duplicated identically in the site application' do + before { Siade.credentials[described_class::DERIVATION_KEY_CREDENTIAL_KEY] = 'known-vector-derivation-key' } - context 'when before DERIVATION_START (2026-09)' do - it 'returns the static credential' do - Timecop.freeze(Date.new(2026, 7, 1)) - expect(described_class.current_password).to eq(Siade.credentials[:insee_apim_password]) - end + after { Siade.credentials.delete(described_class::DERIVATION_KEY_CREDENTIAL_KEY) } + + it 'derives the expected password for 2026-11' do + Timecop.freeze(Date.new(2026, 11, 15)) + expect(described_class.current_password).to eq('2AiKRY3mRq0NERC_') end - context 'when at DERIVATION_START' do - it 'returns a derived password' do - Timecop.freeze(Date.new(2026, 9, 1)) - expect(described_class.current_password).not_to eq(Siade.credentials[:insee_apim_password]) - end + it 'derives the expected password for 2027-01' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.current_password).to eq('s-ughRpOLNf6dL7E') + end + + it 'derives the expected password for 2027-11, whose raw encoding holds no digit' do + Timecop.freeze(Date.new(2027, 11, 15)) + expect(described_class.current_password).to eq('#Ih0vOaURQyCOMFv') + end + + it 'only uses special characters allowed by INSEE' do + Timecop.freeze(Date.new(2026, 11, 15)) + expect(described_class.current_password).to match(/\A[a-zA-Z0-9\-_#]+\z/) end - context 'when after DERIVATION_START' do - it 'returns a derived password' do - Timecop.freeze(Date.new(2027, 1, 15)) - expect(described_class.current_password).not_to eq(Siade.credentials[:insee_apim_password]) + it 'keeps every character class INSEE requires on every period of the next century' do + offenders = (2026..2126).flat_map do |year| + described_class::BIMESTER_MONTHS.filter_map do |month| + Timecop.freeze(Date.new(year, month, 15)) + next if described_class.current_period < described_class::DERIVATION_START + + password = described_class.current_password + + password unless described_class::CHAR_GUARANTEES.keys.all? { |pattern| password.match?(pattern) } + end end + + expect(offenders).to be_empty + end + end + + describe '.current_password' do + it 'returns the static credential before DERIVATION_START' do + Timecop.freeze(Date.new(2026, 10, 31)) + expect(described_class.current_password).to eq(static_password) + end + + it 'returns a derived password at DERIVATION_START' do + Timecop.freeze(Date.new(2026, 11, 1)) + expect(described_class.current_password).not_to eq(static_password) + end + + it 'returns a derived password after DERIVATION_START' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.current_password).not_to eq(static_password) end end describe '.previous_password' do - after { Timecop.return } + it 'returns the static credential when the previous period is before DERIVATION_START' do + Timecop.freeze(Date.new(2026, 11, 15)) + expect(described_class.previous_password).to eq(static_password) + end - context 'when previous period is before DERIVATION_START' do - it 'returns the static credential' do - Timecop.freeze(Date.new(2026, 9, 1)) - expect(described_class.previous_password).to eq(Siade.credentials[:insee_apim_password]) - end + it 'returns a derived password when the previous period is at or after DERIVATION_START' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.previous_password).not_to eq(static_password) + end + end + + describe '.candidates' do + it 'holds the single static password before DERIVATION_START' do + Timecop.freeze(Date.new(2026, 9, 15)) + expect(described_class.candidates).to eq([static_password]) end - context 'when previous period is at or after DERIVATION_START' do - it 'returns a derived password' do - Timecop.freeze(Date.new(2026, 11, 1)) - expect(described_class.previous_password).not_to eq(Siade.credentials[:insee_apim_password]) - end + it 'holds the current then the previous password after DERIVATION_START' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.candidates).to eq( + [described_class.current_password, described_class.previous_password] + ) + end + + it 'holds two distinct passwords on the first day of derivation' do + Timecop.freeze(Date.new(2026, 11, 1)) + expect(described_class.candidates).to eq([described_class.current_password, static_password]) end end describe 'determinism' do - after { Timecop.return } + it 'produces the same password for the whole bimester' do + Timecop.freeze(Date.new(2026, 11, 1)) + first_day = described_class.current_password + + Timecop.freeze(Date.new(2026, 12, 20)) - it 'produces the same password for the same period' do - Timecop.freeze(Date.new(2026, 10, 1)) - first_call = described_class.current_password - second_call = described_class.current_password - expect(first_call).to eq(second_call) + expect(described_class.current_password).to eq(first_day) end it 'produces different passwords for different periods' do - Timecop.freeze(Date.new(2026, 9, 1)) - pwd_sep = described_class.current_password - Timecop.freeze(Date.new(2026, 11, 1)) pwd_nov = described_class.current_password - expect(pwd_sep).not_to eq(pwd_nov) + Timecop.freeze(Date.new(2027, 1, 1)) + pwd_jan = described_class.current_password + + expect(pwd_nov).not_to eq(pwd_jan) end end describe 'password format' do - after { Timecop.return } - - before { Timecop.freeze(Date.new(2026, 9, 1)) } + before { Timecop.freeze(Date.new(2026, 11, 1)) } it 'is 16 characters long' do expect(described_class.current_password.length).to eq(16) @@ -147,23 +191,28 @@ before { Siade.credentials[described_class::BYPASS_CREDENTIAL_KEY] = bypass_password } - after do - Siade.credentials.delete(described_class::BYPASS_CREDENTIAL_KEY) - Timecop.return - end + after { Siade.credentials.delete(described_class::BYPASS_CREDENTIAL_KEY) } it 'is bypassed' do expect(described_class).to be_bypassed end - it 'returns the bypass password whatever the period' do + it 'tries the bypass password first, then the derived current one' do Timecop.freeze(Date.new(2027, 1, 15)) - expect(described_class.current_password).to eq(bypass_password) + expect(described_class.candidates).to eq([bypass_password, described_class.current_password]) end - it 'returns the bypass password as previous password too' do + it 'does not alter the derived passwords' do Timecop.freeze(Date.new(2027, 1, 15)) - expect(described_class.previous_password).to eq(bypass_password) + expect(described_class.current_password).not_to eq(bypass_password) + end + + context 'when the bypass credential is empty' do + let(:bypass_password) { '' } + + it 'raises a configuration error' do + expect { described_class.candidates }.to raise_error(described_class::MissingBypassPasswordError) + end end end diff --git a/siade/spec/spec_helper.rb b/siade/spec/spec_helper.rb index 0780993d7..e4eb77f41 100644 --- a/siade/spec/spec_helper.rb +++ b/siade/spec/spec_helper.rb @@ -71,6 +71,7 @@ config.before do Rails.cache.clear + INSEE::Authenticate.clear_guards! end # If true, the base class of anonymous controllers will be inferred diff --git a/site/.rubocop.yml b/site/.rubocop.yml index 07ef90c2a..c82bd3c75 100644 --- a/site/.rubocop.yml +++ b/site/.rubocop.yml @@ -99,6 +99,8 @@ RSpec/SpecFilePathFormat: Exclude: - "spec/**/*hubee*" - "spec/**/*formulaire_qf*" + - "spec/**/*insee_api_authentication*" + - "spec/**/*insee_oauth_exchange*" Naming/VariableNumber: Enabled: false diff --git a/site/app/clients/insee_api_authentication.rb b/site/app/clients/insee_api_authentication.rb index 2f8508e0b..d81754c97 100644 --- a/site/app/clients/insee_api_authentication.rb +++ b/site/app/clients/insee_api_authentication.rb @@ -1,60 +1,166 @@ -# frozen_string_literal: true +class INSEEAPIAuthentication + class TemporaryError < StandardError; end + class AuthenticationError < StandardError; end -class INSEEAPIAuthentication < AbstractINSEEAPIClient - def access_token - http_connection.post( - 'https://auth.insee.net/auth/realms/apim-gravitee/protocol/openid-connect/token', - { - 'grant_type' => 'password', - 'client_id' => client_id, - 'client_secret' => client_secret, - 'username' => username, - 'password' => password - }.to_query - ).body['access_token'] - end - - protected - - def http_connection - super do |conn| - conn.request :retry, retry_options - conn.headers['Content-Type'] = 'application/x-www-form-urlencoded' + CACHE_NAMESPACE = 'insee'.freeze + TOKEN_CACHE_KEY = 'authenticate'.freeze + LOCK_CACHE_KEY = 'auth_lock'.freeze + FAILURE_CACHE_KEY = 'auth_failed'.freeze + LOCK_TTL = 30.seconds + LOCK_WAIT = 0.5 + FAILURE_TTL = 30.minutes + TOKEN_EXPIRATION_MARGIN = 10 + HELD_BACK_MESSAGE = 'INSEE authentication recently failed on every candidate'.freeze + REFUSED_EXCHANGE_MESSAGE = 'INSEE refused the OAuth exchange itself: client credentials revoked or account locked'.freeze + EVERY_CANDIDATE_MESSAGE = 'INSEE authentication failed on every candidate: password desynchronized or account locked'.freeze + + def self.invalidate_token_cache!(rejected_token) + return unless published_token == rejected_token + + Rails.cache.delete(TOKEN_CACHE_KEY, namespace: CACHE_NAMESPACE) + end + + def self.published_token + outside_the_request_cache { Rails.cache.read(TOKEN_CACHE_KEY, namespace: CACHE_NAMESPACE) } + end + + def self.outside_the_request_cache(&) + Rails.cache.with_local_cache(&) + end + + def self.clear_guards! + [LOCK_CACHE_KEY, FAILURE_CACHE_KEY].each do |key| + Rails.cache.delete(key, namespace: CACHE_NAMESPACE) end end - def retry_options - { - max: 5, - interval: 0.05, - interval_randomness: 0.5, - backoff_factor: 2, - exceptions: [ - Faraday::ConnectionFailed, - Faraday::TimeoutError, - Faraday::ParsingError, - Faraday::ClientError, - Faraday::ServerError, - Faraday::UnauthorizedError - ] - } + delegate :attempt, to: :exchange + + def access_token + published_token || authenticate! + end + + def recently_failed? + outside_the_request_cache { cache_read(FAILURE_CACHE_KEY) }.present? + end + + def record_authentication_failure!(message) + cache_write(FAILURE_CACHE_KEY, true, expires_in: FAILURE_TTL) + + MonitoringService.instance.track( + message, + level: :error, + context: { + period: INSEE::PasswordDerivation.current_period, + bypassed: INSEE::PasswordDerivation.bypassed?, + candidates_count: INSEE::PasswordDerivation.candidates.size + } + ) end private - def client_id - AdminApientreprise.credentials[:insee_client_id] + def authenticate! + raise TemporaryError, HELD_BACK_MESSAGE if recently_failed? + + case acquire_lock! + when true then token_under_lock + when false then token_from_concurrent_authentication + else token_from_candidates + end + end + + def token_under_lock + published_token || token_from_candidates_unless_held_back + ensure + release_lock! + end + + def token_from_candidates_unless_held_back + raise TemporaryError, HELD_BACK_MESSAGE if recently_failed? + + token_from_candidates + end + + def token_from_concurrent_authentication + sleep(LOCK_WAIT) + + published_token || raise(TemporaryError, 'another INSEE authentication is already in flight') + end + + def token_from_candidates + candidates = INSEE::PasswordDerivation.candidates + + token_from(candidates) || + token_from_password_renewed_meanwhile(candidates) || + fail_on_rejection!(EVERY_CANDIDATE_MESSAGE, 'INSEE rejected every password candidate') + end + + def token_from(candidates) + candidates.each do |candidate| + result = attempt(candidate) + + return store_token(result) if result.status == :granted + next if result.status == :invalid_grant + + fail_on_rejection!(REFUSED_EXCHANGE_MESSAGE, 'INSEE refused the OAuth exchange') if result.status == :rejected + raise TemporaryError, 'INSEE OAuth is unavailable' + end + + nil + end + + def token_from_password_renewed_meanwhile(candidates) + return if candidates.last == INSEE::PasswordDerivation.current_password + + token_from([INSEE::PasswordDerivation.current_password]) + end + + def store_token(attempt) + cache_write( + TOKEN_CACHE_KEY, + attempt.token, + expires_in: [attempt.expires_in.to_i - TOKEN_EXPIRATION_MARGIN, 1].max + ) + + attempt.token end - def client_secret - AdminApientreprise.credentials[:insee_client_secret] + def published_token + self.class.published_token end - def username - AdminApientreprise.credentials[:insee_username] + def outside_the_request_cache(&) + self.class.outside_the_request_cache(&) + end + + def acquire_lock! + @lock_owner = SecureRandom.uuid + + cache_write(LOCK_CACHE_KEY, @lock_owner, expires_in: LOCK_TTL, unless_exist: true) + end + + def release_lock! + return unless outside_the_request_cache { cache_read(LOCK_CACHE_KEY) } == @lock_owner + + Rails.cache.delete(LOCK_CACHE_KEY, namespace: CACHE_NAMESPACE) + end + + def cache_read(key) + Rails.cache.read(key, namespace: CACHE_NAMESPACE) + end + + def cache_write(key, value, expires_in:, unless_exist: false) + Rails.cache.write(key, value, namespace: CACHE_NAMESPACE, expires_in:, unless_exist:) + end + + def fail_on_rejection!(alert, error) + record_authentication_failure!(alert) + + raise AuthenticationError, error end - def password - AdminApientreprise.credentials[:insee_password] + def exchange + @exchange ||= INSEEOAuthExchange.new end end diff --git a/site/app/clients/insee_oauth_exchange.rb b/site/app/clients/insee_oauth_exchange.rb new file mode 100644 index 000000000..58a205393 --- /dev/null +++ b/site/app/clients/insee_oauth_exchange.rb @@ -0,0 +1,71 @@ +class INSEEOAuthExchange + Attempt = Data.define(:status, :token, :expires_in) + + OAUTH_URL = 'https://auth.insee.net/auth/realms/apim-gravitee/protocol/openid-connect/token'.freeze + TIMEOUT = 5 + INVALID_GRANT_HTTP_STATUSES = [400, 401].freeze + TRANSIENT_HTTP_STATUSES = [408, 429].freeze + + def attempt(password) + response = post_credentials(password) + payload = parsed_body(response) + + return granted_attempt(payload) if granted?(response, payload) + + rejected_attempt(rejection_status(response, payload)) + rescue Faraday::Error + rejected_attempt(:unavailable) + end + + private + + def post_credentials(password) + http_connection.post( + OAUTH_URL, + { + 'grant_type' => 'password', + 'client_id' => AdminApientreprise.credentials[:insee_client_id], + 'client_secret' => AdminApientreprise.credentials[:insee_client_secret], + 'username' => AdminApientreprise.credentials[:insee_username], + 'password' => password + }.to_query + ) + end + + def granted?(response, payload) + response.status == 200 && payload['access_token'].present? + end + + def rejection_status(response, payload) + return :unavailable if TRANSIENT_HTTP_STATUSES.include?(response.status) + return :invalid_grant if invalid_grant?(response, payload) + return :rejected if response.status.between?(400, 499) + + :unavailable + end + + def invalid_grant?(response, payload) + INVALID_GRANT_HTTP_STATUSES.include?(response.status) && payload['error'] == 'invalid_grant' + end + + def granted_attempt(payload) + Attempt.new(status: :granted, token: payload['access_token'], expires_in: payload['expires_in']) + end + + def rejected_attempt(status) + Attempt.new(status:, token: nil, expires_in: nil) + end + + def parsed_body(response) + JSON.parse(response.body.to_s) + rescue JSON::ParserError + {} + end + + def http_connection + @http_connection ||= Faraday.new do |conn| + conn.options.timeout = TIMEOUT + conn.headers['Content-Type'] = 'application/x-www-form-urlencoded' + end + end +end diff --git a/site/app/clients/insee_password_renewal.rb b/site/app/clients/insee_password_renewal.rb new file mode 100644 index 000000000..ec66234d3 --- /dev/null +++ b/site/app/clients/insee_password_renewal.rb @@ -0,0 +1,20 @@ +class INSEEPasswordRenewal + RENEWAL_URL = 'https://api.insee.fr/api-sirene/prive/3.11/renouvellement'.freeze + TIMEOUT = 5 + + def renew(token:, old_password:, new_password:) + http_connection.post(RENEWAL_URL) do |request| + request.headers['Authorization'] = "Bearer #{token}" + request.headers['Content-Type'] = 'application/json' + request.body = { oldPassword: old_password, newPassword: new_password }.to_json + end + end + + private + + def http_connection + @http_connection ||= Faraday.new do |conn| + conn.options.timeout = TIMEOUT + end + end +end diff --git a/site/app/clients/insee_sirene_api_client.rb b/site/app/clients/insee_sirene_api_client.rb index 9ec0d949c..ff8b4ad14 100644 --- a/site/app/clients/insee_sirene_api_client.rb +++ b/site/app/clients/insee_sirene_api_client.rb @@ -2,9 +2,11 @@ class INSEESireneAPIClient < AbstractINSEEAPIClient class EntityNotFoundError < StandardError; end def etablissement(siret:) - http_connection.get( - "https://api.insee.fr/api-sirene/prive/3.11/siret/#{siret}" - ).body + retrying_once_with_a_fresh_token do + http_connection.get( + "https://api.insee.fr/api-sirene/prive/3.11/siret/#{siret}" + ).body + end rescue Faraday::ResourceNotFound => e raise EntityNotFoundError, "Etablissement with SIRET #{siret} not found: #{e.message}" end @@ -13,7 +15,21 @@ def etablissement(siret:) def http_connection super do |conn| - conn.request :authorization, 'Bearer', -> { INSEEAPIAuthentication.new.access_token } + conn.request :authorization, 'Bearer', -> { bearer_token } end end + + private + + def bearer_token + @bearer_token = INSEEAPIAuthentication.new.access_token + end + + def retrying_once_with_a_fresh_token + yield + rescue Faraday::UnauthorizedError + INSEEAPIAuthentication.invalidate_token_cache!(@bearer_token) + + yield + end end diff --git a/site/app/jobs/application_job.rb b/site/app/jobs/application_job.rb index d394c3d10..095ec198c 100644 --- a/site/app/jobs/application_job.rb +++ b/site/app/jobs/application_job.rb @@ -4,4 +4,11 @@ class ApplicationJob < ActiveJob::Base # Most jobs are safe to ignore if the underlying records are no longer available # discard_on ActiveJob::DeserializationError + + private + + def frontal_production? + Rails.env.production? && + ENV['FRONTAL'] == 'true' + end end diff --git a/site/app/jobs/healthcheck_job.rb b/site/app/jobs/healthcheck_job.rb index 3fe6fe162..c5b920d51 100644 --- a/site/app/jobs/healthcheck_job.rb +++ b/site/app/jobs/healthcheck_job.rb @@ -14,11 +14,6 @@ def perform private - def frontal_production? - Rails.env.production? && - ENV['FRONTAL'] == 'true' - end - def healthcheck_uri @healthcheck_uri ||= URI(healthcheck_url) end diff --git a/site/app/jobs/insee_password_rotation_job.rb b/site/app/jobs/insee_password_rotation_job.rb new file mode 100644 index 000000000..3e36f912f --- /dev/null +++ b/site/app/jobs/insee_password_rotation_job.rb @@ -0,0 +1,28 @@ +class INSEEPasswordRotationJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + + CONCURRENCY_KEY = 'insee_password_rotation'.freeze + + good_job_control_concurrency_with(total_limit: 1, key: CONCURRENCY_KEY) + + def perform + return unless frontal_production? + return if INSEE::PasswordDerivation.bypassed? + return if INSEE::PasswordDerivation.current_period < INSEE::PasswordDerivation::DERIVATION_START + return if rotation.held_back? + + notify(:info, 'INSEE password rotated') if rotation.rotate! == :renewed + rescue INSEE::PasswordRotation::UnavailableError => e + notify(:warning, 'INSEE password rotation skipped', { exception_message: e.message }) + end + + private + + def notify(level, message, context = {}) + MonitoringService.instance.track(message, level:, context:) + end + + def rotation + @rotation ||= INSEE::PasswordRotation.new + end +end diff --git a/site/app/jobs/update_organization_insee_payload_job.rb b/site/app/jobs/update_organization_insee_payload_job.rb index a8593716c..c0d7f3a0e 100644 --- a/site/app/jobs/update_organization_insee_payload_job.rb +++ b/site/app/jobs/update_organization_insee_payload_job.rb @@ -3,6 +3,7 @@ class UpdateOrganizationINSEEPayloadJob < ApplicationJob retry_on Faraday::ServerError, wait: :polynomially_longer, attempts: Float::INFINITY retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: Float::INFINITY + retry_on INSEEAPIAuthentication::TemporaryError, wait: :polynomially_longer, attempts: 10 def perform(organization_id) @organization = Organization.find(organization_id) diff --git a/site/app/services/insee/password_derivation.rb b/site/app/services/insee/password_derivation.rb new file mode 100644 index 000000000..959838824 --- /dev/null +++ b/site/app/services/insee/password_derivation.rb @@ -0,0 +1,105 @@ +require 'openssl' + +class INSEE::PasswordDerivation + class MissingBypassPasswordError < StandardError; end + + DERIVATION_START = '2026-11'.freeze + BIMESTER_MONTHS = [1, 3, 5, 7, 9, 11].freeze + BYPASS_CREDENTIAL_KEY = :insee_password_bypass + STATIC_CREDENTIAL_KEY = :insee_password + DERIVATION_KEY_CREDENTIAL_KEY = :insee_password_derivation_key + PASSWORD_LENGTH = 16 + CHAR_GUARANTEES = { + /[A-Z]/ => 'A', + /[a-z]/ => 'a', + /[0-9]/ => '0', + /[^a-zA-Z0-9]/ => '#' + }.freeze + + def self.credentials + AdminApientreprise.credentials + end + + def self.bypassed? + credentials.key?(BYPASS_CREDENTIAL_KEY) + end + + def self.candidates + return [bypass_password, current_password].uniq if bypassed? + + [current_password, previous_password].uniq + end + + def self.bypass_password + credentials[BYPASS_CREDENTIAL_KEY].presence || + fail(MissingBypassPasswordError, "credential '#{BYPASS_CREDENTIAL_KEY}' is present but empty") + end + + def self.current_password + password_for(current_period) + end + + def self.previous_password + password_for(previous_period) + end + + def self.current_period + period_for(Time.zone.today) + end + + def self.previous_period + date = Time.zone.today + month = BIMESTER_MONTHS.rfind { |m| m <= date.month } + + if month == BIMESTER_MONTHS.first + format('%d-%02d', year: date.year - 1, month: BIMESTER_MONTHS.last) + else + idx = BIMESTER_MONTHS.index(month) + format('%d-%02d', year: date.year, month: BIMESTER_MONTHS[idx - 1]) + end + end + + def self.password_for(period) + return credentials[STATIC_CREDENTIAL_KEY] if period < DERIVATION_START + + derive(period) + end + + def self.derive(period) + hmac = OpenSSL::HMAC.digest('SHA256', secret, period) + format_password(hmac) + end + + def self.period_for(date) + month = BIMESTER_MONTHS.rfind { |m| m <= date.month } + format('%d-%02d', year: date.year, month:) + end + + def self.format_password(hmac_bytes) + chars = Base64.urlsafe_encode64(hmac_bytes, padding: false)[0, PASSWORD_LENGTH].chars + + CHAR_GUARANTEES.each do |pattern, fallback| + next if chars.any? { |char| char.match?(pattern) } + + chars[expendable_index(chars)] = fallback + end + + chars.join + end + + def self.expendable_index(chars) + chars.index do |char| + chars.many? { |other| char_class(other) == char_class(char) } + end + end + + def self.char_class(char) + CHAR_GUARANTEES.keys.find { |pattern| char.match?(pattern) } + end + + def self.secret + credentials[DERIVATION_KEY_CREDENTIAL_KEY] + end + + private_class_method :credentials, :password_for, :derive, :period_for, :format_password, :expendable_index, :char_class, :secret +end diff --git a/site/app/services/insee/password_rotation.rb b/site/app/services/insee/password_rotation.rb new file mode 100644 index 000000000..e548d69bb --- /dev/null +++ b/site/app/services/insee/password_rotation.rb @@ -0,0 +1,74 @@ +class INSEE::PasswordRotation + class UnavailableError < StandardError; end + class BypassNotInUseError < StandardError; end + class DerivationNotStartedError < StandardError; end + + DESYNCHRONIZED_MESSAGE = 'INSEE password desynchronized: neither the expected nor the fallback password authenticates'.freeze + + def initialize(authentication: INSEEAPIAuthentication.new, renewal: INSEEPasswordRenewal.new) + @authentication = authentication + @renewal = renewal + end + + def held_back? + @authentication.recently_failed? + end + + def rotate! + converge_on_current_password(from: INSEE::PasswordDerivation.previous_password) + end + + def exit_bypass! + raise BypassNotInUseError, 'no bypass credential is configured' unless INSEE::PasswordDerivation.bypassed? + raise DerivationNotStartedError, "derivation opens on #{INSEE::PasswordDerivation::DERIVATION_START}" if before_derivation_window? + + converge_on_current_password(from: INSEE::PasswordDerivation.bypass_password) + end + + private + + def converge_on_current_password(from:) + return :already_current if probe(current_password).status == :granted + + fallback = probe(from) + + return renew(old_password: from, token: fallback.token) if fallback.status == :granted + + @authentication.record_authentication_failure!(DESYNCHRONIZED_MESSAGE) + + :desynchronized + end + + def probe(password) + attempt = @authentication.attempt(password) + + fail_on_refusal! if attempt.status == :rejected + raise UnavailableError, 'INSEE OAuth is unavailable' if attempt.status == :unavailable + + attempt + end + + def fail_on_refusal! + @authentication.record_authentication_failure!(INSEEAPIAuthentication::REFUSED_EXCHANGE_MESSAGE) + + raise UnavailableError, INSEEAPIAuthentication::REFUSED_EXCHANGE_MESSAGE + end + + def renew(old_password:, token:) + response = @renewal.renew(token:, old_password:, new_password: current_password) + + return :renewed if response.status == 200 + + raise UnavailableError, "INSEE rejected the renewal (HTTP #{response.status}): #{response.body}" + rescue Faraday::Error => e + raise UnavailableError, "INSEE password renewal did not reach INSEE: #{e.message}" + end + + def before_derivation_window? + INSEE::PasswordDerivation.current_period < INSEE::PasswordDerivation::DERIVATION_START + end + + def current_password + @current_password ||= INSEE::PasswordDerivation.current_password + end +end diff --git a/site/config/changelogs.yml b/site/config/changelogs.yml index 95157ed8b..49a908235 100644 --- a/site/config/changelogs.yml +++ b/site/config/changelogs.yml @@ -1,3 +1,11 @@ +- date: 2026-09-07 + scope: api_entreprise + title: "APIs Sirene INSEE : nouveau code d'erreur `01006` en cas d'échec d'authentification" + description: | + Les [APIs Sirene de l'INSEE](<%= endpoints_path %>) peuvent désormais renvoyer une erreur **502 de code `01006`** lorsque l'authentification d'API Entreprise auprès de l'INSEE échoue. Ce cas était jusqu'ici masqué derrière l'erreur temporaire `01011`. + + Le distinguer permet de ne plus proposer de réessayer un appel qui échouera à l'identique : contrairement à `01011`, `01006` signale un problème de credentials côté fournisseur, que seule une intervention de l'équipe résout. Les autres codes d'erreur des APIs Sirene sont inchangés. + - date: 2026-08-25 scope: api_particulier title: "CNOUS étudiant boursier : `campaignYear` accepte la campagne en cours et à venir" diff --git a/site/config/schedule.yml b/site/config/schedule.yml index b25679dec..d39dcc7cd 100644 --- a/site/config/schedule.yml +++ b/site/config/schedule.yml @@ -6,7 +6,7 @@ development: cron: '0 2 * * 1' class: 'WeeklyChangelogDigestJob' -production: &deployed +deployed: &deployed token_expiration_notice: cron: '0 */4 * * *' class: 'TokenExpirationNoticeJob' @@ -17,5 +17,11 @@ production: &deployed cron: '0 2 * * 1' class: 'WeeklyChangelogDigestJob' +production: + <<: *deployed + insee_password_rotation: + cron: '5 0 * * * Europe/Paris' + class: 'INSEEPasswordRotationJob' + staging: *deployed sandbox: *deployed diff --git a/site/docs/rotation_mot_de_passe_insee.md b/site/docs/rotation_mot_de_passe_insee.md new file mode 100644 index 000000000..0a32bb223 --- /dev/null +++ b/site/docs/rotation_mot_de_passe_insee.md @@ -0,0 +1,176 @@ +# Rotation du mot de passe INSEE + +Le compte INSEE est partagé par `site/` et `siade/`. Son mot de passe n'est plus +une valeur figée dans les credentials : il est dérivé du bimestre courant, et +renouvelé auprès de l'INSEE par un job quotidien. + +## Dérivation + +`INSEE::PasswordDerivation` calcule `HMAC-SHA256(secret, période)`, la période +étant le bimestre courant (`2027-01`, `2027-03`, ...). Chaque application +authentifie en essayant deux candidats dans l'ordre : le mot de passe de la +période courante, puis celui de la période précédente. Un décalage entre le +renouvellement et le déploiement ne coupe donc jamais l'accès. + +Avant `DERIVATION_START`, `password_for` renvoie le mot de passe statique des +credentials : la dérivation n'est pas encore en vigueur. + +## Rotation quotidienne + +`INSEEPasswordRotationJob` tourne à 00h05 (Europe/Paris), en production +uniquement (`config/schedule.yml`), sur la machine frontale. Il délègue à +`INSEE::PasswordRotation#rotate!` : + +1. sonder le mot de passe de la période courante — s'il est accepté, l'INSEE est + déjà à jour, il n'y a rien à faire ; +2. sinon sonder celui de la période précédente — s'il est accepté, appeler + l'endpoint de renouvellement pour passer à la période courante ; +3. si aucun des deux n'est accepté, armer le garde-fou et alerter. + +## Sortie du bypass + +Le credential `insee_password_bypass`, quand il est présent, prend la tête des +candidats : les applications authentifient avec lui et la dérivation est en +sommeil. Il sert à reprendre la main quand l'INSEE détient un mot de passe que +les applications ne savent plus calculer. + +Pour rendre le compte à la dérivation, depuis une console Rails sur la machine +frontale de production : + +```ruby +INSEE::PasswordRotation.new.exit_bypass! +``` + +L'opération est la même machine à états que la rotation quotidienne, avec le +mot de passe de bypass comme repli. Elle est rejouable : si un timeout a masqué +un renouvellement qui était passé côté INSEE, le second appel constate que le +mot de passe dérivé authentifie déjà et renvoie `:already_current` sans +rappeler l'endpoint de renouvellement. + +Elle refuse de tourner dans deux cas : + +- `BypassNotInUseError` — aucun credential de bypass n'est configuré ; +- `DerivationNotStartedError` — `DERIVATION_START` n'est pas atteint. Sans ce + garde-fou, l'opération reposerait sur le compte le mot de passe *statique*, + c'est-à-dire exactement celui que le bypass servait à contourner. + +Une fois `:renewed` obtenu, retirer `insee_password_bypass` des credentials des +**deux** applications et déployer. Tant que le credential est présent, chaque +authentification dépense un grant refusé avant de tomber sur le bon candidat. + +## Indisponibilité passagère contre refus franc + +Un timeout, un 408, un 429 ou un 5xx ne disent rien du mot de passe : ils +interrompent l'authentification sur une erreur temporaire, sans essayer le +candidat suivant ni armer le garde-fou. Seul un refus franc l'arme — un 4xx qui +n'est pas un `invalid_grant`, ou un `invalid_grant` sur tous les candidats. +Confondre les deux transformerait un `Retry-After` de quelques secondes en 30 +minutes de coupure et en alerte de credentials cassés. + +## Renouvellement concurrent d'une authentification + +Le renouvellement quotidien peut tomber entre deux tentatives : le mot de passe +courant est refusé avant, le précédent l'est après, et l'authentification +conclut à tort à une désynchronisation. Avant de conclure, elle resonde donc une +dernière fois le mot de passe de la période courante — sauf s'il était déjà le +dernier candidat essayé, auquel cas rien n'a pu changer entre-temps. Les deux +applications ne partageant pas leur cache (voir plus bas), aucun verrou ne peut +les coordonner : la vérification a posteriori est le seul recours. + +## Garde-fou après un échec + +Quand aucun candidat n'authentifie, ou quand l'INSEE refuse l'échange OAuth +lui-même, l'application mémorise l'échec pendant 30 minutes +(`FAILURE_CACHE_KEY`) et cesse d'appeler l'INSEE : le compte se verrouille au +bout de quelques échecs consécutifs, et il ne faut pas les dépenser en boucle. +Le garde-fou est relu une seconde fois une fois le verrou de single-flight +obtenu : deux requêtes parties ensemble le trouvent toutes deux vide, et sans +cette relecture la seconde dépenserait ses tentatives sur un compte que la +première vient de constater refusé. + +Ce garde-fou survit à la correction du problème. Une fois les credentials +réparés, le libérer depuis une console Rails plutôt que d'attendre l'expiration : + +```ruby +INSEEAPIAuthentication.clear_guards! # site +INSEE::Authenticate.clear_guards! # siade +``` + +`rake cache:clear` ne suffit pas : il est limité au préfixe de cache de +l'application, alors que ces clés sont volontairement en dehors. La même +commande libère aussi un verrou d'authentification laissé par un process mort +en cours de route. + +## Cloisonnement des caches entre les deux applications + +`site/` et `siade/` **ne partagent pas leur Redis**. Chaque application a son +propre store, et son `config.cache_store` porte un namespace qui lui est propre +(`admin_api_entreprise_cache__` d'un côté, +`siade_cache__` de l'autre). + +Ce cloisonnement a une conséquence à connaître : les deux applications ne +partagent **ni le token, ni le verrou d'authentification, ni le garde-fou +d'échec**. Chacune dépense donc son propre budget de tentatives contre le compte +INSEE commun. C'est la raison pour laquelle chaque application s'arrête après +ses candidats et arme son garde-fou dès le premier refus franc de l'INSEE : +le compte se verrouille au bout de quelques échecs consécutifs, et il n'existe +aucun compteur partagé pour l'en empêcher. + +Attention au piège de l'option `namespace:` passée à l'appel : elle *remplace* +le namespace du store, elle ne s'y ajoute pas. `Rails.cache.write(k, v, +namespace: 'insee')` écrit donc `insee:` à la racine de la base Redis, hors +du préfixe de l'application et hors du timestamp de boot. + +C'est voulu pour le garde-fou d'échec, qui doit survivre à un déploiement : +un redémarrage ne doit pas relancer les tentatives contre un compte qu'on vient +de constater verrouillé. Ce n'est en revanche pas voulu pour le verrou de +single-flight, qui n'a de sens que pour les process qui voient le même token : +un verrou visible par toute la flotte alors que le token ne l'est que par le +groupe de process issu d'un même boot ferait échouer les perdants sans qu'ils +puissent jamais lire le token du gagnant. Le verrou vit donc là où vit le token. + +## Cache local à la requête + +`Rails.cache` est enveloppé par `ActiveSupport::Cache::Strategy::LocalCache`, +que Rails insère dans la pile Rack (`railties/lib/rails/application/bootstrap.rb`). +Le temps d'une requête HTTP, chaque clé lue est mémoïsée — **y compris les +absences** : `LocalStore#fetch_entry` fait `@data.fetch(key) { @data[key] = yield }`, +donc un `nil` relu ne repart jamais vers Redis. + +Or toutes les relectures de l'authentification existent précisément pour +observer ce que d'*autres* process ont publié. Mémoïsées, elles sont mortes : +le perdant du single-flight ne verrait jamais le token du gagnant, la relecture +du garde-fou ne verrait jamais l'échec qu'un voisin vient d'enregistrer, la +vérification d'appartenance du verrou relirait notre propre écriture et +supprimerait le verrou d'un successeur, et l'invalidation comparerait au token +qu'on vient de se faire refuser plutôt qu'à celui réellement en cache. + +Ces lectures passent donc par `outside_the_request_cache`, qui ouvre un +`with_local_cache` imbriqué : le store temporaire est vide, la lecture repart +vers Redis, et le cache de la requête n'est pas pollué au passage. + +Le token n'a pas d'autre lecture. On avait d'abord gardé la mémoïsation pour +le premier accès, au motif qu'il n'observe personne — c'est faux dès qu'une +invalidation renonce à supprimer. Si un autre process a retiré le token +pendant l'appel fournisseur, le garde de l'invalidation constate que le cache +partagé ne le contient plus et ne supprime rien, laissant intacte la copie +locale de la requête ; la réauthentification la relit et réessaie avec le +token qu'on vient justement de se faire refuser. Le token est un état +partagé : il se lit là où il vit. + +En développement, où le cache est un `null_store`, `LocalCache` est la seule +mémoire du store : une lecture qui le contourne ne rend donc jamais rien et le +token n'est plus réutilisé d'un appel à l'autre. Sans conséquence, le +développement passant par les mocks fournisseurs. + +Le piège se rejoue dans les tests, à l'envers. Aucun middleware Rack ne tourne +en spec, donc rien n'est mémoïsé et le défaut est invisible. Les specs +concernées ouvrent explicitement un `with_local_cache`, et simulent un autre +process en écrivant dans un `with_local_cache` imbriqué, invisible du store de +la requête. + +Cette couverture n'existe que côté `siade/`, dont le store de test est le +`redis_cache_store` de `application.rb`. Celui de `site/` est un +`memory_store`, et `LocalCache` ne se branche que sur `read_serialized_entry` / +`write_serialized_entry`, que `MemoryStore` n'utilise pas : le cache local y est +inerte, le piège n'y est pas reproductible. diff --git a/site/spec/clients/insee_api_authentication_spec.rb b/site/spec/clients/insee_api_authentication_spec.rb new file mode 100644 index 000000000..b96f70f43 --- /dev/null +++ b/site/spec/clients/insee_api_authentication_spec.rb @@ -0,0 +1,534 @@ +RSpec.describe INSEEAPIAuthentication do + subject(:authentication) { described_class.new } + + let(:token) { 'a-fresh-insee-token' } + + def insee_cache_write(key, value, **) + Rails.cache.write(key, value, namespace: described_class::CACHE_NAMESPACE, **) + end + + def insee_cache_read(key) + Rails.cache.read(key, namespace: described_class::CACHE_NAMESPACE) + end + + def arm_the_failure_guard + insee_cache_write(described_class::FAILURE_CACHE_KEY, true, expires_in: described_class::FAILURE_TTL) + end + + def stub_oauth(*responses) + stub_request(:post, INSEEOAuthExchange::OAUTH_URL).to_return(*responses) + end + + def granted_response(access_token: 'a-fresh-insee-token', expires_in: 598_077) + { + status: 200, + body: { access_token:, expires_in: }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + def invalid_grant_response + { + status: 401, + body: { error: 'invalid_grant', error_description: 'Invalid user credentials' }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + describe '#access_token' do + context 'when a token is cached' do + before do + insee_cache_write(described_class::TOKEN_CACHE_KEY, 'cached-token') + stub_oauth(granted_response) + end + + it 'returns it' do + expect(authentication.access_token).to eq('cached-token') + end + + it 'does not call INSEE' do + authentication.access_token + + expect(WebMock).not_to have_requested(:post, INSEEOAuthExchange::OAUTH_URL) + end + end + + context 'when the first candidate is granted' do + before { stub_oauth(granted_response) } + + it 'returns the token' do + expect(authentication.access_token).to eq(token) + end + + it 'caches it until it expires' do + authentication.access_token + + expect(insee_cache_read(described_class::TOKEN_CACHE_KEY)).to eq(token) + end + + it 'calls INSEE once' do + authentication.access_token + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + + it 'releases the single flight lock' do + authentication.access_token + + expect(insee_cache_read(described_class::LOCK_CACHE_KEY)).to be_nil + end + end + + context 'when the first candidate is rejected with invalid_grant' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(invalid_grant_response, granted_response) + end + + after { Timecop.return } + + it 'falls back on the second candidate' do + expect(authentication.access_token).to eq(token) + end + + it 'tries each candidate exactly once' do + authentication.access_token + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).twice + end + + it 'sends the previous password as second candidate' do + authentication.access_token + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL) + .with(body: hash_including('password' => INSEE::PasswordDerivation.previous_password)) + end + end + + context 'when INSEE is unavailable' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 503, body: '') + end + + after { Timecop.return } + + it 'raises a temporary error' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'does not try the second candidate' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + + it 'does not remember the failure' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(insee_cache_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + + it 'releases the single flight lock' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(insee_cache_read(described_class::LOCK_CACHE_KEY)).to be_nil + end + end + + context 'when INSEE rate limits the OAuth exchange' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 429, body: '', headers: { 'Retry-After' => '2' }) + end + + after { Timecop.return } + + it 'raises a temporary error' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'does not try the second candidate' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + + it 'does not remember the failure' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(insee_cache_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + end + + context 'when INSEE answers with a request timeout' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 408, body: '') + end + + after { Timecop.return } + + it 'raises a temporary error' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'does not remember the failure' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(insee_cache_read(described_class::FAILURE_CACHE_KEY)).to be_nil + end + end + + context 'when INSEE answers with a non JSON body' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(status: 200, body: 'gateway') + end + + after { Timecop.return } + + it 'raises a temporary error' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'does not try the second candidate' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + end + + context 'when INSEE times out' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_request(:post, INSEEOAuthExchange::OAUTH_URL).to_timeout + end + + after { Timecop.return } + + it 'raises a temporary error' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'does not try the second candidate' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + end + + context 'when every candidate is rejected with invalid_grant' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(invalid_grant_response) + allow(MonitoringService.instance).to receive(:track) + end + + after { Timecop.return } + + it 'raises an authentication error' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + end + + it 'tries each candidate then the current password once more' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).times(3) + end + + it 'alerts on both hypotheses' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + + expect(MonitoringService.instance).to have_received(:track).with( + 'INSEE authentication failed on every candidate: password desynchronized or account locked', + level: :error, + context: hash_including(period: '2027-01') + ) + end + + it 'remembers the failure' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + + expect(authentication).to be_recently_failed + end + + it 'does not call INSEE again while the failure is remembered' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + expect { described_class.new.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).times(3) + end + end + + context 'when a rotation renewed the password while the candidates were tried' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(invalid_grant_response, invalid_grant_response, granted_response) + allow(MonitoringService.instance).to receive(:track) + end + + after { Timecop.return } + + it 'authenticates with the password INSEE now holds' do + expect(authentication.access_token).to eq(token) + end + + it 'does not remember a failure' do + authentication.access_token + + expect(authentication).not_to be_recently_failed + end + + it 'does not alert' do + authentication.access_token + + expect(MonitoringService.instance).not_to have_received(:track) + end + end + + context 'when another authentication armed the failure guard before the lock was free' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + stub_oauth(granted_response) + + allow(Rails.cache).to receive(:write).and_wrap_original do |original, *args, **options| + arm_the_failure_guard if args.first == described_class::LOCK_CACHE_KEY + + original.call(*args, **options) + end + end + + after { Timecop.return } + + it 'raises a temporary error' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'spends none of the attempts the guard was meant to save' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).not_to have_requested(:post, INSEEOAuthExchange::OAUTH_URL) + end + + it 'releases the single flight lock' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(insee_cache_read(described_class::LOCK_CACHE_KEY)).to be_nil + end + end + + describe 'single flight' do + before do + insee_cache_write(described_class::LOCK_CACHE_KEY, true, expires_in: described_class::LOCK_TTL) + + stub_const("#{described_class}::LOCK_WAIT", 0) + + stub_oauth(granted_response) + end + + context 'when another thread published its token meanwhile' do + before { insee_cache_write(described_class::TOKEN_CACHE_KEY, 'token-from-the-other-thread') } + + it 'returns that token' do + expect(authentication.access_token).to eq('token-from-the-other-thread') + end + + it 'does not call INSEE' do + authentication.access_token + + expect(WebMock).not_to have_requested(:post, INSEEOAuthExchange::OAUTH_URL) + end + end + + context 'when another thread publishes its token while this one waits' do + before do + allow(Rails.cache).to receive(:write).and_wrap_original do |original, *args, **options| + insee_cache_write(described_class::TOKEN_CACHE_KEY, 'token-from-the-other-thread') if args.first == described_class::LOCK_CACHE_KEY + + original.call(*args, **options) + end + end + + it 'returns that token' do + expect(authentication.access_token).to eq('token-from-the-other-thread') + end + + it 'does not call INSEE' do + authentication.access_token + + expect(WebMock).not_to have_requested(:post, INSEEOAuthExchange::OAUTH_URL) + end + end + + context 'when the other thread published nothing' do + it 'raises a temporary error instead of burning an attempt' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + end + + it 'does not call INSEE' do + expect { authentication.access_token }.to raise_error(described_class::TemporaryError) + + expect(WebMock).not_to have_requested(:post, INSEEOAuthExchange::OAUTH_URL) + end + end + end + + context 'when INSEE refuses the OAuth exchange itself' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + allow(MonitoringService.instance).to receive(:track) + + stub_oauth(status: 400, body: { error: 'invalid_client' }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + after { Timecop.return } + + it 'raises an authentication error' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + end + + it 'stops after the first candidate' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + + it 'holds back the next authentications' do + expect { authentication.access_token }.to raise_error(described_class::AuthenticationError) + + expect(insee_cache_read(described_class::FAILURE_CACHE_KEY)).to be(true) + end + end + + describe 'when the cache is unavailable' do + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + allow(Rails.cache).to receive_messages(read: nil, write: nil, delete: false) + + stub_oauth(granted_response) + end + + after { Timecop.return } + + it 'authenticates instead of waiting on a lock nobody holds' do + expect(authentication.access_token).to eq(token) + end + + it 'costs a single OAuth call' do + authentication.access_token + + expect(WebMock).to have_requested(:post, INSEEOAuthExchange::OAUTH_URL).once + end + end + + describe 'lock acquisition' do + it 'tells a taken lock from an unreachable cache' do + expect(authentication.send(:acquire_lock!)).to be(true) + expect(authentication.send(:acquire_lock!)).to be(false) + end + end + + describe 'lock ownership' do + before do + stub_request(:post, INSEEOAuthExchange::OAUTH_URL).to_return do + insee_cache_write(described_class::LOCK_CACHE_KEY, 'the-successor', expires_in: described_class::LOCK_TTL) + + granted_response + end + end + + it 'leaves alone a lock taken over while it was authenticating' do + authentication.access_token + + expect(insee_cache_read(described_class::LOCK_CACHE_KEY)).to eq('the-successor') + end + end + end + + describe '.invalidate_token_cache!' do + it 'drops the token the provider rejected' do + insee_cache_write(described_class::TOKEN_CACHE_KEY, 'cached-token') + + described_class.invalidate_token_cache!('cached-token') + + expect(insee_cache_read(described_class::TOKEN_CACHE_KEY)).to be_nil + end + + it 'keeps a token another request refreshed in the meantime' do + insee_cache_write(described_class::TOKEN_CACHE_KEY, 'refreshed-token') + + described_class.invalidate_token_cache!('revoked-token') + + expect(insee_cache_read(described_class::TOKEN_CACHE_KEY)).to eq('refreshed-token') + end + end + + describe '.clear_guards!' do + it 'releases the held back authentications' do + insee_cache_write(described_class::FAILURE_CACHE_KEY, true, expires_in: described_class::FAILURE_TTL) + + described_class.clear_guards! + + expect(authentication).not_to be_recently_failed + end + + it 'releases a lock left behind by a process that died mid authentication' do + insee_cache_write(described_class::LOCK_CACHE_KEY, 'the-departed', expires_in: described_class::LOCK_TTL) + + described_class.clear_guards! + + expect(insee_cache_read(described_class::LOCK_CACHE_KEY)).to be_nil + end + end + + describe '#record_authentication_failure!' do + before { allow(MonitoringService.instance).to receive(:track) } + + it 'holds back the next authentications' do + authentication.record_authentication_failure!('desynchronized') + + expect(authentication).to be_recently_failed + end + + it 'alerts' do + authentication.record_authentication_failure!('desynchronized') + + expect(MonitoringService.instance).to have_received(:track).with('desynchronized', level: :error, context: hash_including(:period)) + end + end + + describe '#attempt' do + it 'ignores the cached token' do + insee_cache_write(described_class::TOKEN_CACHE_KEY, 'cached-token') + stub_oauth(granted_response) + + expect(authentication.attempt('SomeP4ssword!').token).to eq(token) + end + + it 'does not cache the token it obtains' do + stub_oauth(granted_response) + + authentication.attempt('SomeP4ssword!') + + expect(insee_cache_read(described_class::TOKEN_CACHE_KEY)).to be_nil + end + + it 'reports a rejected password' do + stub_oauth(invalid_grant_response) + + expect(authentication.attempt('SomeP4ssword!').status).to eq(:invalid_grant) + end + end +end diff --git a/site/spec/clients/insee_oauth_exchange_spec.rb b/site/spec/clients/insee_oauth_exchange_spec.rb new file mode 100644 index 000000000..792a37fe7 --- /dev/null +++ b/site/spec/clients/insee_oauth_exchange_spec.rb @@ -0,0 +1,69 @@ +RSpec.describe INSEEOAuthExchange do + subject(:exchange) { described_class.new } + + def stub_oauth(**response) + stub_request(:post, described_class::OAUTH_URL).to_return(**response) + end + + def json_response(status, body) + { status:, body: body.to_json, headers: { 'Content-Type' => 'application/json' } } + end + + describe '#attempt' do + it 'returns the granted token' do + stub_oauth(**json_response(200, { access_token: 'a-fresh-insee-token', expires_in: 598_077 })) + + expect(exchange.attempt('SomeP4ssword!')).to have_attributes(status: :granted, token: 'a-fresh-insee-token', expires_in: 598_077) + end + + it 'sends the password INSEE expects' do + stub_oauth(**json_response(200, { access_token: 'a-fresh-insee-token', expires_in: 598_077 })) + + exchange.attempt('SomeP4ssword!') + + expect(WebMock).to have_requested(:post, described_class::OAUTH_URL).with(body: hash_including('password' => 'SomeP4ssword!')) + end + + it 'reports a rejected password' do + stub_oauth(**json_response(401, { error: 'invalid_grant', error_description: 'Invalid user credentials' })) + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:invalid_grant) + end + + it 'tells a refused OAuth exchange from a rejected password' do + stub_oauth(**json_response(400, { error: 'invalid_client' })) + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:rejected) + end + + it 'reports an unavailable OAuth' do + stub_oauth(status: 500, body: '') + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:unavailable) + end + + it 'reports a rate limiting as unavailable rather than rejected' do + stub_oauth(status: 429, body: '', headers: { 'Retry-After' => '2' }) + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:unavailable) + end + + it 'reports a request timeout as unavailable rather than rejected' do + stub_oauth(status: 408, body: '') + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:unavailable) + end + + it 'reports a connection timeout as unavailable' do + stub_request(:post, described_class::OAUTH_URL).to_timeout + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:unavailable) + end + + it 'reports a non JSON body as unavailable' do + stub_oauth(status: 200, body: 'gateway') + + expect(exchange.attempt('SomeP4ssword!').status).to eq(:unavailable) + end + end +end diff --git a/site/spec/clients/insee_password_renewal_spec.rb b/site/spec/clients/insee_password_renewal_spec.rb new file mode 100644 index 000000000..525a4eacf --- /dev/null +++ b/site/spec/clients/insee_password_renewal_spec.rb @@ -0,0 +1,48 @@ +RSpec.describe INSEEPasswordRenewal do + subject(:renewal) do + described_class.new.renew( + token: 'a-valid-bearer-token', + old_password: 'OldP4ssword-xyz', + new_password: 'NewP4ssword-abc' + ) + end + + context 'when INSEE accepts the renewal' do + before { stub_request(:post, described_class::RENEWAL_URL).to_return(status: 200, body: '{}') } + + it 'returns the response' do + expect(renewal.status).to eq(200) + end + + it 'sends both passwords' do + renewal + + expect(WebMock).to have_requested(:post, described_class::RENEWAL_URL) + .with(body: { oldPassword: 'OldP4ssword-xyz', newPassword: 'NewP4ssword-abc' }.to_json) + end + + it 'sends the bearer token' do + renewal + + expect(WebMock).to have_requested(:post, described_class::RENEWAL_URL) + .with(headers: { 'Authorization' => 'Bearer a-valid-bearer-token' }) + end + end + + context 'when INSEE rejects the renewal' do + before do + stub_request(:post, described_class::RENEWAL_URL) + .to_return(status: 400, body: '{"message":"Ancien mot de passe incorrect"}') + end + + it 'returns the response instead of raising' do + expect(renewal.status).to eq(400) + end + + it 'calls INSEE only once' do + renewal + + expect(WebMock).to have_requested(:post, described_class::RENEWAL_URL).once + end + end +end diff --git a/site/spec/clients/insee_sirene_api_client_spec.rb b/site/spec/clients/insee_sirene_api_client_spec.rb index 29860a6aa..78f400962 100644 --- a/site/spec/clients/insee_sirene_api_client_spec.rb +++ b/site/spec/clients/insee_sirene_api_client_spec.rb @@ -26,6 +26,38 @@ end end + context 'when the cached token has been revoked' do + before do + allow(INSEEAPIAuthentication).to receive(:invalidate_token_cache!) + + stub_request(:get, "https://api.insee.fr/api-sirene/prive/3.11/siret/#{siret}") + .to_return({ status: 401, body: '' }, + { status: 200, headers: { 'Content-Type' => 'application/json' }, body: { ok: true }.to_json }) + end + + it 'invalidates the token it sent' do + etablissement_payload + + expect(INSEEAPIAuthentication).to have_received(:invalidate_token_cache!).with('access_token') + end + + it 'retries the request once' do + expect(etablissement_payload).to eq({ 'ok' => true }) + end + end + + context 'when the token is rejected twice' do + before do + allow(INSEEAPIAuthentication).to receive(:invalidate_token_cache!) + + stub_request(:get, "https://api.insee.fr/api-sirene/prive/3.11/siret/#{siret}").to_return(status: 401, body: '') + end + + it 'gives up' do + expect { etablissement_payload }.to raise_error(Faraday::UnauthorizedError) + end + end + context 'when API returns something else than 200' do before do stub_request(:get, "https://api.insee.fr/api-sirene/prive/3.11/siret/#{siret}").to_return( diff --git a/site/spec/jobs/insee_password_rotation_job_spec.rb b/site/spec/jobs/insee_password_rotation_job_spec.rb new file mode 100644 index 000000000..37fc8020f --- /dev/null +++ b/site/spec/jobs/insee_password_rotation_job_spec.rb @@ -0,0 +1,102 @@ +RSpec.describe INSEEPasswordRotationJob do + subject(:rotate) { described_class.perform_now } + + let(:rotation) { instance_double(INSEE::PasswordRotation, held_back?: false, rotate!: :already_current) } + let(:bypass_password) { 'ByPass-Password1' } + + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + allow(Rails.env).to receive(:production?).and_return(true) + ENV['FRONTAL'] = 'true' + + allow(INSEE::PasswordRotation).to receive(:new).and_return(rotation) + allow(MonitoringService.instance).to receive(:track) + end + + after do + Timecop.return + + ENV['FRONTAL'] = 'true' + + AdminApientreprise.credentials.delete(INSEE::PasswordDerivation::BYPASS_CREDENTIAL_KEY) + end + + describe 'guards' do + it 'does nothing outside of the frontal machine' do + ENV['FRONTAL'] = 'false' + + rotate + + expect(rotation).not_to have_received(:rotate!) + end + + it 'does nothing outside of production' do + allow(Rails.env).to receive(:production?).and_return(false) + + rotate + + expect(rotation).not_to have_received(:rotate!) + end + + it 'does nothing while the bypass password is in use' do + AdminApientreprise.credentials[INSEE::PasswordDerivation::BYPASS_CREDENTIAL_KEY] = bypass_password + + rotate + + expect(rotation).not_to have_received(:rotate!) + end + + it 'does nothing before the derivation window opens' do + Timecop.freeze(Date.new(2026, 9, 7)) + + rotate + + expect(rotation).not_to have_received(:rotate!) + end + + it 'does nothing while an authentication failure is held' do + allow(rotation).to receive(:held_back?).and_return(true) + + rotate + + expect(rotation).not_to have_received(:rotate!) + end + end + + context 'when the rotation renews the password' do + before { allow(rotation).to receive(:rotate!).and_return(:renewed) } + + it 'reports it' do + rotate + + expect(MonitoringService.instance).to have_received(:track).with('INSEE password rotated', level: :info, context: {}) + end + end + + context 'when INSEE already holds the current password' do + it 'stays silent' do + rotate + + expect(MonitoringService.instance).not_to have_received(:track) + end + end + + context 'when the rotation cannot conclude' do + before do + allow(rotation).to receive(:rotate!).and_raise( + INSEE::PasswordRotation::UnavailableError, 'INSEE OAuth is unavailable' + ) + end + + it 'reports the skip instead of failing the job' do + rotate + + expect(MonitoringService.instance).to have_received(:track).with( + 'INSEE password rotation skipped', + level: :warning, + context: { exception_message: 'INSEE OAuth is unavailable' } + ) + end + end +end diff --git a/site/spec/jobs/update_organization_insee_payload_job_spec.rb b/site/spec/jobs/update_organization_insee_payload_job_spec.rb index 427e62b19..90dff13f7 100644 --- a/site/spec/jobs/update_organization_insee_payload_job_spec.rb +++ b/site/spec/jobs/update_organization_insee_payload_job_spec.rb @@ -34,6 +34,18 @@ context 'when organization has not been recently updated for his INSEE payload' do let(:organization) { create(:organization, last_insee_payload_updated_at: 42.days.ago) } + context 'when INSEE authentication is temporarily unavailable' do + before do + allow(insee_sirene_api_client).to receive(:etablissement) + .and_raise(INSEEAPIAuthentication::TemporaryError) + end + + it 'retries later instead of losing the payload' do + expect { described_class.perform_now(organization_id) } + .to have_enqueued_job(described_class).with(organization_id) + end + end + it 'calls the API' do expect(insee_sirene_api_client).to receive(:etablissement).with(siret: organization.siret) diff --git a/site/spec/services/insee/password_derivation_spec.rb b/site/spec/services/insee/password_derivation_spec.rb new file mode 100644 index 000000000..35cf6e04b --- /dev/null +++ b/site/spec/services/insee/password_derivation_spec.rb @@ -0,0 +1,224 @@ +RSpec.describe INSEE::PasswordDerivation do + after { Timecop.return } + + let(:static_password) { AdminApientreprise.credentials[described_class::STATIC_CREDENTIAL_KEY] } + + describe '.current_period' do + it 'returns the bimester seed for January' do + Timecop.freeze(Date.new(2026, 1, 15)) + expect(described_class.current_period).to eq('2026-01') + end + + it 'returns the bimester seed for February (still jan-feb bimester)' do + Timecop.freeze(Date.new(2026, 2, 28)) + expect(described_class.current_period).to eq('2026-01') + end + + it 'returns the bimester seed for March' do + Timecop.freeze(Date.new(2026, 3, 1)) + expect(described_class.current_period).to eq('2026-03') + end + + it 'returns the bimester seed for September' do + Timecop.freeze(Date.new(2026, 9, 10)) + expect(described_class.current_period).to eq('2026-09') + end + + it 'returns the bimester seed for December' do + Timecop.freeze(Date.new(2026, 12, 31)) + expect(described_class.current_period).to eq('2026-11') + end + end + + describe '.previous_period' do + it 'returns the previous bimester for March (-> jan)' do + Timecop.freeze(Date.new(2026, 3, 15)) + expect(described_class.previous_period).to eq('2026-01') + end + + it 'returns the previous bimester for September (-> jul)' do + Timecop.freeze(Date.new(2026, 9, 10)) + expect(described_class.previous_period).to eq('2026-07') + end + + it 'wraps to previous year for January (-> nov of previous year)' do + Timecop.freeze(Date.new(2027, 1, 5)) + expect(described_class.previous_period).to eq('2026-11') + end + + it 'wraps to previous year for February (still jan bimester -> nov of previous year)' do + Timecop.freeze(Date.new(2027, 2, 10)) + expect(described_class.previous_period).to eq('2026-11') + end + end + + describe 'known vectors, duplicated identically in the siade application' do + before { AdminApientreprise.credentials[described_class::DERIVATION_KEY_CREDENTIAL_KEY] = 'known-vector-derivation-key' } + + after { AdminApientreprise.credentials.delete(described_class::DERIVATION_KEY_CREDENTIAL_KEY) } + + it 'derives the expected password for 2026-11' do + Timecop.freeze(Date.new(2026, 11, 15)) + expect(described_class.current_password).to eq('2AiKRY3mRq0NERC_') + end + + it 'derives the expected password for 2027-01' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.current_password).to eq('s-ughRpOLNf6dL7E') + end + + it 'derives the expected password for 2027-11, whose raw encoding holds no digit' do + Timecop.freeze(Date.new(2027, 11, 15)) + expect(described_class.current_password).to eq('#Ih0vOaURQyCOMFv') + end + + it 'only uses special characters allowed by INSEE' do + Timecop.freeze(Date.new(2026, 11, 15)) + expect(described_class.current_password).to match(/\A[a-zA-Z0-9\-_#]+\z/) + end + + it 'keeps every character class INSEE requires on every period of the next century' do + offenders = (2026..2126).flat_map do |year| + described_class::BIMESTER_MONTHS.filter_map do |month| + Timecop.freeze(Date.new(year, month, 15)) + next if described_class.current_period < described_class::DERIVATION_START + + password = described_class.current_password + + password unless described_class::CHAR_GUARANTEES.keys.all? { |pattern| password.match?(pattern) } + end + end + + expect(offenders).to be_empty + end + end + + describe '.current_password' do + it 'returns the static credential before DERIVATION_START' do + Timecop.freeze(Date.new(2026, 10, 31)) + expect(described_class.current_password).to eq(static_password) + end + + it 'returns a derived password at DERIVATION_START' do + Timecop.freeze(Date.new(2026, 11, 1)) + expect(described_class.current_password).not_to eq(static_password) + end + + it 'returns a derived password after DERIVATION_START' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.current_password).not_to eq(static_password) + end + end + + describe '.previous_password' do + it 'returns the static credential when the previous period is before DERIVATION_START' do + Timecop.freeze(Date.new(2026, 11, 15)) + expect(described_class.previous_password).to eq(static_password) + end + + it 'returns a derived password when the previous period is at or after DERIVATION_START' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.previous_password).not_to eq(static_password) + end + end + + describe '.candidates' do + it 'holds the single static password before DERIVATION_START' do + Timecop.freeze(Date.new(2026, 9, 15)) + expect(described_class.candidates).to eq([static_password]) + end + + it 'holds the current then the previous password after DERIVATION_START' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.candidates).to eq( + [described_class.current_password, described_class.previous_password] + ) + end + + it 'holds two distinct passwords on the first day of derivation' do + Timecop.freeze(Date.new(2026, 11, 1)) + expect(described_class.candidates).to eq([described_class.current_password, static_password]) + end + end + + describe 'determinism' do + it 'produces the same password for the whole bimester' do + Timecop.freeze(Date.new(2026, 11, 1)) + first_day = described_class.current_password + + Timecop.freeze(Date.new(2026, 12, 20)) + + expect(described_class.current_password).to eq(first_day) + end + + it 'produces different passwords for different periods' do + Timecop.freeze(Date.new(2026, 11, 1)) + pwd_nov = described_class.current_password + + Timecop.freeze(Date.new(2027, 1, 1)) + pwd_jan = described_class.current_password + + expect(pwd_nov).not_to eq(pwd_jan) + end + end + + describe 'password format' do + before { Timecop.freeze(Date.new(2026, 11, 1)) } + + it 'is 16 characters long' do + expect(described_class.current_password.length).to eq(16) + end + + it 'contains at least one uppercase letter' do + expect(described_class.current_password).to match(/[A-Z]/) + end + + it 'contains at least one lowercase letter' do + expect(described_class.current_password).to match(/[a-z]/) + end + + it 'contains at least one digit' do + expect(described_class.current_password).to match(/[0-9]/) + end + + it 'contains at least one special character' do + expect(described_class.current_password).to match(/[^a-zA-Z0-9]/) + end + end + + describe 'bypass credential' do + let(:bypass_password) { 'ByPass#Password1' } + + before { AdminApientreprise.credentials[described_class::BYPASS_CREDENTIAL_KEY] = bypass_password } + + after { AdminApientreprise.credentials.delete(described_class::BYPASS_CREDENTIAL_KEY) } + + it 'is bypassed' do + expect(described_class).to be_bypassed + end + + it 'tries the bypass password first, then the derived current one' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.candidates).to eq([bypass_password, described_class.current_password]) + end + + it 'does not alter the derived passwords' do + Timecop.freeze(Date.new(2027, 1, 15)) + expect(described_class.current_password).not_to eq(bypass_password) + end + + context 'when the bypass credential is empty' do + let(:bypass_password) { '' } + + it 'raises a configuration error' do + expect { described_class.candidates }.to raise_error(described_class::MissingBypassPasswordError) + end + end + end + + describe '.bypassed?' do + it 'is false when the credential is absent' do + expect(described_class).not_to be_bypassed + end + end +end diff --git a/site/spec/services/insee/password_rotation_spec.rb b/site/spec/services/insee/password_rotation_spec.rb new file mode 100644 index 000000000..f70d43df7 --- /dev/null +++ b/site/spec/services/insee/password_rotation_spec.rb @@ -0,0 +1,236 @@ +RSpec.describe INSEE::PasswordRotation do + subject(:rotation) { described_class.new } + + let(:oauth_url) { INSEEOAuthExchange::OAUTH_URL } + let(:renewal_url) { INSEEPasswordRenewal::RENEWAL_URL } + let(:bypass_password) { 'ByPass-Password1' } + let(:current_password) { INSEE::PasswordDerivation.current_password } + let(:previous_password) { INSEE::PasswordDerivation.previous_password } + + def stub_oauth_for(password, response) + stub_request(:post, oauth_url) + .with(body: hash_including('password' => password)) + .to_return(response) + end + + def granted_response + { + status: 200, + body: { access_token: 'a-fresh-insee-token', expires_in: 598_077 }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + def invalid_grant_response + { + status: 401, + body: { error: 'invalid_grant' }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + end + + def use_bypass_credential + AdminApientreprise.credentials[INSEE::PasswordDerivation::BYPASS_CREDENTIAL_KEY] = bypass_password + end + + before do + Timecop.freeze(Date.new(2027, 1, 15)) + + allow(MonitoringService.instance).to receive(:track) + end + + after do + Timecop.return + + AdminApientreprise.credentials.delete(INSEE::PasswordDerivation::BYPASS_CREDENTIAL_KEY) + end + + describe '#rotate!' do + context 'when INSEE already holds the current password' do + before do + stub_oauth_for(current_password, granted_response) + stub_request(:post, renewal_url) + end + + it { expect(rotation.rotate!).to eq(:already_current) } + + it 'costs a single OAuth call' do + rotation.rotate! + + expect(WebMock).to have_requested(:post, oauth_url).once + end + + it 'renews nothing' do + rotation.rotate! + + expect(WebMock).not_to have_requested(:post, renewal_url) + end + end + + context 'when INSEE still holds the previous password' do + before do + stub_oauth_for(current_password, invalid_grant_response) + stub_oauth_for(previous_password, granted_response) + stub_request(:post, renewal_url).to_return(status: 200, body: '{}') + end + + it { expect(rotation.rotate!).to eq(:renewed) } + + it 'renews the previous password into the current one' do + rotation.rotate! + + expect(WebMock).to have_requested(:post, renewal_url).once + .with(body: { oldPassword: previous_password, newPassword: current_password }.to_json) + end + end + + context 'when INSEE holds neither password' do + before do + stub_request(:post, oauth_url).to_return(invalid_grant_response) + stub_request(:post, renewal_url) + end + + it { expect(rotation.rotate!).to eq(:desynchronized) } + + it 'holds back the applicative authentications' do + rotation.rotate! + + expect(INSEEAPIAuthentication.new).to be_recently_failed + end + + it 'costs two failed authentications at most' do + rotation.rotate! + + expect(WebMock).to have_requested(:post, oauth_url).twice + end + + it 'renews nothing' do + rotation.rotate! + + expect(WebMock).not_to have_requested(:post, renewal_url) + end + end + + context 'when OAuth is unavailable' do + before { stub_request(:post, oauth_url).to_return(status: 503, body: '') } + + it 'reports it as unavailable' do + expect { rotation.rotate! }.to raise_error(described_class::UnavailableError, /unavailable/) + end + + it 'leaves the applicative authentications alone' do + expect { rotation.rotate! }.to raise_error(described_class::UnavailableError) + + expect(INSEEAPIAuthentication.new).not_to be_recently_failed + end + end + + context 'when INSEE refuses the OAuth exchange itself' do + before do + stub_request(:post, oauth_url).to_return( + status: 400, + body: { error: 'invalid_client' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + it 'reports it' do + expect { rotation.rotate! }.to raise_error(described_class::UnavailableError, /client credentials/) + end + + it 'holds back the applicative authentications' do + expect { rotation.rotate! }.to raise_error(described_class::UnavailableError) + + expect(INSEEAPIAuthentication.new).to be_recently_failed + end + end + + context 'when INSEE rejects the renewal' do + before do + stub_oauth_for(current_password, invalid_grant_response) + stub_oauth_for(previous_password, granted_response) + stub_request(:post, renewal_url).to_return(status: 400, body: '{"message":"nope"}') + end + + it 'reports the provider answer' do + expect { rotation.rotate! }.to raise_error(described_class::UnavailableError, /HTTP 400.*nope/) + end + end + + context 'when the renewal does not reach INSEE' do + before do + stub_oauth_for(current_password, invalid_grant_response) + stub_oauth_for(previous_password, granted_response) + stub_request(:post, renewal_url).to_timeout + end + + it 'reports it instead of raising a bare Faraday error' do + expect { rotation.rotate! }.to raise_error(described_class::UnavailableError, /did not reach INSEE/) + end + end + end + + describe '#exit_bypass!' do + context 'when no bypass credential is configured' do + it 'refuses to run' do + expect { rotation.exit_bypass! }.to raise_error(described_class::BypassNotInUseError) + end + end + + context 'when the derivation window has not opened yet' do + before do + Timecop.freeze(Date.new(2026, 9, 7)) + + use_bypass_credential + + stub_request(:post, renewal_url) + end + + it 'refuses to run' do + expect { rotation.exit_bypass! }.to raise_error(described_class::DerivationNotStartedError) + end + + it 'renews nothing' do + expect { rotation.exit_bypass! }.to raise_error(described_class::DerivationNotStartedError) + + expect(WebMock).not_to have_requested(:post, renewal_url) + end + end + + context 'when INSEE holds the bypass password' do + before do + use_bypass_credential + + stub_oauth_for(current_password, invalid_grant_response) + stub_oauth_for(bypass_password, granted_response) + stub_request(:post, renewal_url).to_return(status: 200, body: '{}') + end + + it { expect(rotation.exit_bypass!).to eq(:renewed) } + + it 'renews the bypass password into the derived one' do + rotation.exit_bypass! + + expect(WebMock).to have_requested(:post, renewal_url).once + .with(body: { oldPassword: bypass_password, newPassword: current_password }.to_json) + end + end + + context 'when a previous run already renewed' do + before do + use_bypass_credential + + stub_oauth_for(current_password, granted_response) + stub_request(:post, renewal_url) + end + + it { expect(rotation.exit_bypass!).to eq(:already_current) } + + it 'does not renew a second time' do + rotation.exit_bypass! + + expect(WebMock).not_to have_requested(:post, renewal_url) + end + end + end +end