From 3e2ffa4f76ccd5f23a382c7f310ca1ec4ba4f690 Mon Sep 17 00:00:00 2001 From: "zane.hyatt" Date: Tue, 6 Oct 2026 17:01:56 +0000 Subject: [PATCH 1/3] fix(oauth): map Entra AADSTS refresh errors to accurate user-facing guidance --- .../requests_native_auth/abstract_oauth.py | 57 +++++- .../sources/declarative/auth/test_oauth.py | 2 +- .../test_requests_native_auth.py | 175 +++++++++++++++++- 3 files changed, 217 insertions(+), 17 deletions(-) diff --git a/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py b/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py index 8fa300b176..7c4f577810 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py @@ -30,6 +30,35 @@ # Only the code is appended to the user-facing message: it is the entire grouping key, and unlike # the surrounding prose it is identical on every attempt at the same failure. _PROVIDER_ERROR_CODE_PATTERN = re.compile(r"^[A-Za-z]{3,}\d{4,}\b") +_REFRESH_TOKEN_REJECTED_GUIDANCE = ( + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings." +) +# Guidance for Microsoft Entra refresh errors, keyed on the AADSTS code leading `error_description`. +# Replaces the generic guidance above. Fixed strings only: the full message is the failure-summary +# grouping key, so it must be identical on every attempt at the same failure. +_ENTRA_REFRESH_ERROR_GUIDANCE: Mapping[str, str] = { + "AADSTS50078": ( + "The tenant's Microsoft Entra policy requires a new MFA sign-in. Re-authenticate this " + "source to restore syncs. If this recurs on a schedule, the usual cause is an Entra MFA " + 'session setting such as "remember multi-factor authentication on trusted devices", which ' + "the tenant's Entra administrator controls." + ), + "AADSTS70043": ( + "A Microsoft Entra Conditional Access sign-in frequency policy expired the refresh token. " + "Re-authenticate this source to restore syncs. The tenant's Entra administrator sets how " + "often this is required." + ), + "AADSTS700082": ( + "The Microsoft Entra refresh token expired after a period of inactivity. Re-authenticate " + "this source to restore syncs, and keep its connection syncing regularly to avoid a " + "recurrence." + ), + "AADSTS50173": ( + "Microsoft Entra revoked the grant, typically because the authorizing user's password was " + "changed or reset. Re-authenticate this source to restore syncs." + ), +} # Upper bound on the provider-controlled text appended to the user-facing message. Both the # RFC 6749 `error` token and the extracted code come from the provider, so both are still capped. # The longest standard token (`unsupported_grant_type`, 22) plus a 7-digit Entra code is 37 @@ -315,6 +344,19 @@ def _redact_credentials(self, value: str) -> str: def _truncate(value: str, max_length: int) -> str: return value if len(value) <= max_length else value[:max_length] + "..." + @staticmethod + def _extract_provider_error_code( + response_content: Optional[Mapping[str, Any]], + ) -> Optional[str]: + if not response_content: + return None + description = response_content.get("error_description") + if isinstance(description, str): + code_match = _PROVIDER_ERROR_CODE_PATTERN.match(description.strip()) + if code_match: + return code_match.group() + return None + def _build_provider_error_detail( self, response_content: Optional[Mapping[str, Any]] ) -> Optional[str]: @@ -339,11 +381,9 @@ def _build_provider_error_detail( error = response_content.get("error") if isinstance(error, str) and error.strip(): parts.append(" ".join(error.split())) - description = response_content.get("error_description") - if isinstance(description, str): - code_match = _PROVIDER_ERROR_CODE_PATTERN.match(description.strip()) - if code_match: - parts.append(code_match.group()) + provider_error_code = self._extract_provider_error_code(response_content) + if provider_error_code: + parts.append(provider_error_code) if not parts: return None return self._truncate( @@ -465,10 +505,9 @@ def _make_handled_request(self) -> Any: ) error_content = self._parse_error_response_content(e.response) if self._wrap_refresh_token_exception(e, response_content=error_content): - message = ( - "Refresh token was rejected by the OAuth provider (invalid, expired, or " - "already used). Re-authenticate this source's credentials in its connection " - "settings." + provider_error_code = self._extract_provider_error_code(error_content) + message = _ENTRA_REFRESH_ERROR_GUIDANCE.get( + provider_error_code or "", _REFRESH_TOKEN_REJECTED_GUIDANCE ) provider_error_detail = self._build_provider_error_detail(error_content) if provider_error_detail: diff --git a/unit_tests/sources/declarative/auth/test_oauth.py b/unit_tests/sources/declarative/auth/test_oauth.py index 71b8328b33..2eb5871ff5 100644 --- a/unit_tests/sources/declarative/auth/test_oauth.py +++ b/unit_tests/sources/declarative/auth/test_oauth.py @@ -788,7 +788,7 @@ def test_refresh_access_token_rejected_surfaces_provider_error_and_redacts(self, oauth.refresh_access_token() assert exc_info.value.failure_type == FailureType.config_error - assert exc_info.value.message.startswith("Refresh token was rejected by the OAuth provider") + assert exc_info.value.message.startswith("Microsoft Entra revoked the grant") # Only the provider code is surfaced, so the echoed credentials cannot reach the # user-facing message at all -- they are absent by construction, not merely masked. assert exc_info.value.message.endswith("Provider error: invalid_grant: AADSTS50173") diff --git a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py index 923292cc25..bf89dcab73 100644 --- a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py +++ b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -831,7 +831,7 @@ def _entra_style_authenticator(self): trailing_marker = "Correlation ID: 11111111-2222-3333-4444-555555555555" @pytest.mark.parametrize( - "error, error_description, expected_code", + "error, error_description, expected_code, expected_guidance", ( ( # Grant revoked, e.g. the user changed their password: re-authentication is the fix. @@ -840,6 +840,8 @@ def _entra_style_authenticator(self): "token is needed. The user might have changed or reset their password.\r\n" "Trace ID: 00000000-0000-0000-0000-000000000000\r\n" + trailing_marker, "AADSTS50173", + "Microsoft Entra revoked the grant, typically because the authorizing user's " + "password was changed or reset. Re-authenticate this source to restore syncs.", ), ( # Client type / credential misconfiguration: re-authentication will not help. @@ -847,6 +849,8 @@ def _entra_style_authenticator(self): "AADSTS7000218: The request body must contain the following parameter: " "'client_assertion' or 'client_secret'.\r\n" + trailing_marker, "AADSTS7000218", + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings.", ), ( # Conditional Access requires an interactive sign-in. @@ -855,11 +859,13 @@ def _entra_style_authenticator(self): "you moved to a new location, you must use multi-factor authentication to access " "the resource.\r\n" + trailing_marker, "AADSTS50076", + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings.", ), ), ) def test_refresh_access_token_surfaces_provider_error_code( - self, requests_mock, error, error_description, expected_code + self, requests_mock, error, error_description, expected_code, expected_guidance ): """The provider's own diagnostic must reach the logs and the user-facing message.""" oauth = self._entra_style_authenticator() @@ -872,15 +878,11 @@ def test_refresh_access_token_surfaces_provider_error_code( with pytest.raises(AirbyteTracedException) as exc_info: oauth.refresh_access_token() - guidance = ( - "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " - "Re-authenticate this source's credentials in its connection settings." - ) # The full provider response reaches the internal message, which is logged. assert expected_code in exc_info.value.internal_message assert TestOauth2Authenticator.trailing_marker in exc_info.value.internal_message # The actionable guidance still leads the user-facing message ... - assert exc_info.value.message.startswith(guidance) + assert exc_info.value.message.startswith(expected_guidance) # ... followed by a short, single-line provider detail carrying the error code. assert f"Provider error: {error}: {expected_code}" in exc_info.value.message assert "\n" not in exc_info.value.message and "\r" not in exc_info.value.message @@ -1016,6 +1018,165 @@ def _message_for_description(self, requests_mock, error_description): oauth.refresh_access_token() return exc_info.value.message + @pytest.mark.parametrize( + "code, error_description, expected_guidance", + ( + ( + "AADSTS50078", + "AADSTS50078: Due to a change in your security information or because the MFA " + "session is no longer valid, you must use multi-factor authentication.\r\n" + "Trace ID: 01234567-89ab-cdef-0123-456789abcdef\r\n" + "Correlation ID: 11111111-2222-3333-4444-555555555555\r\n" + "Timestamp: 2026-03-14T09:26:53Z", + "The tenant's Microsoft Entra policy requires a new MFA sign-in. Re-authenticate this " + "source to restore syncs. If this recurs on a schedule, the usual cause is an Entra MFA " + 'session setting such as "remember multi-factor authentication on trusted devices", ' + "which the tenant's Entra administrator controls.", + ), + ( + "AADSTS70043", + "AADSTS70043: The refresh token has expired due to sign-in frequency checks by " + "Conditional Access.\r\n" + "Trace ID: 23456789-abcd-ef01-2345-6789abcdef01\r\n" + "Correlation ID: 22222222-3333-4444-5555-666666666666\r\n" + "Timestamp: 2026-04-15T10:27:54Z", + "A Microsoft Entra Conditional Access sign-in frequency policy expired the refresh " + "token. Re-authenticate this source to restore syncs. The tenant's Entra administrator " + "sets how often this is required.", + ), + ( + "AADSTS700082", + "AADSTS700082: The refresh token has expired due to inactivity. The token was issued " + "on 2026-06-11T03:14:07.000Z and was inactive for 90.00:00:00.\r\n" + "Trace ID: 3456789a-bcde-f012-3456-789abcdef012\r\n" + "Correlation ID: 33333333-4444-5555-6666-777777777777\r\n" + "Timestamp: 2026-06-12T11:28:55Z", + "The Microsoft Entra refresh token expired after a period of inactivity. " + "Re-authenticate this source to restore syncs, and keep its connection syncing " + "regularly to avoid a recurrence.", + ), + ( + "AADSTS50173", + "AADSTS50173: The provided grant has expired due to it being revoked, typically after " + "the user changed or reset their password.\r\n" + "Trace ID: 456789ab-cdef-0123-4567-89abcdef0123\r\n" + "Correlation ID: 44444444-5555-6666-7777-888888888888\r\n" + "Timestamp: 2026-07-13T12:29:56Z", + "Microsoft Entra revoked the grant, typically because the authorizing user's password " + "was changed or reset. Re-authenticate this source to restore syncs.", + ), + ), + ids=["AADSTS50078", "AADSTS70043", "AADSTS700082", "AADSTS50173"], + ) + def test_entra_error_code_maps_to_guidance( + self, requests_mock, code, error_description, expected_guidance + ): + oauth = self._entra_style_authenticator() + requests_mock.post( + f"https://{TestOauth2Authenticator.refresh_endpoint}", + status_code=400, + json={"error": "invalid_grant", "error_description": error_description}, + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + oauth.refresh_access_token() + + message = exc_info.value.message + assert message == f"{expected_guidance} Provider error: invalid_grant: {code}" + assert "(invalid, expired, or already used)" not in message + assert exc_info.value.failure_type == FailureType.config_error + assert "Trace ID:" in exc_info.value.internal_message + + @pytest.mark.parametrize( + "error_content, expected_detail", + ( + ( + { + "error": "invalid_grant", + "error_description": ( + "AADSTS50076: Due to a configuration change, MFA is required.\r\n" + "Trace ID: 56789abc-def0-1234-5678-9abcdef01234" + ), + }, + "invalid_grant: AADSTS50076", + ), + ( + { + "error": "invalid_grant", + "error_description": "Token has been expired or revoked.", + }, + "invalid_grant", + ), + ({"error": "invalid_grant"}, "invalid_grant"), + ), + ids=["unknown_entra_code", "non_entra_provider", "no_description"], + ) + def test_refresh_error_message_unchanged_without_mapped_code( + self, requests_mock, error_content, expected_detail + ): + oauth = self._entra_style_authenticator() + requests_mock.post( + f"https://{TestOauth2Authenticator.refresh_endpoint}", + status_code=400, + json=error_content, + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + oauth.refresh_access_token() + + generic = ( + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings." + ) + assert exc_info.value.message == f"{generic} Provider error: {expected_detail}" + + @pytest.mark.parametrize( + "code, first_description, second_description, expected_message", + ( + ( + "AADSTS50078", + "AADSTS50078: MFA session expired.\r\n" + "Trace ID: 6789abcd-ef01-2345-6789-abcdef012345\r\n" + "Correlation ID: 55555555-6666-7777-8888-999999999999\r\n" + "Timestamp: 2026-03-14T09:26:53Z", + "AADSTS50078: MFA session expired.\r\n" + "Trace ID: 789abcde-f012-3456-789a-bcdef0123456\r\n" + "Correlation ID: 66666666-7777-8888-9999-aaaaaaaaaaaa\r\n" + "Timestamp: 2026-08-15T10:27:54Z", + "The tenant's Microsoft Entra policy requires a new MFA sign-in. Re-authenticate this " + "source to restore syncs. If this recurs on a schedule, the usual cause is an Entra MFA " + 'session setting such as "remember multi-factor authentication on trusted devices", ' + "which the tenant's Entra administrator controls. Provider error: invalid_grant: " + "AADSTS50078", + ), + ( + "AADSTS700082", + "AADSTS700082: The refresh token has expired due to inactivity. The token was issued " + "on 2026-06-11T03:14:07.000Z and was inactive for 90.00:00:00.\r\n" + "Trace ID: 89abcdef-0123-4567-89ab-cdef01234567\r\n" + "Correlation ID: 77777777-8888-9999-aaaa-bbbbbbbbbbbb\r\n" + "Timestamp: 2026-06-12T11:28:55Z", + "AADSTS700082: The refresh token has expired due to inactivity. The token was issued " + "on 2026-07-29T22:01:52.000Z and was inactive for 90.00:00:00.\r\n" + "Trace ID: 9abcdef0-1234-5678-9abc-def012345678\r\n" + "Correlation ID: 88888888-9999-aaaa-bbbb-cccccccccccc\r\n" + "Timestamp: 2026-07-30T12:29:56Z", + "The Microsoft Entra refresh token expired after a period of inactivity. " + "Re-authenticate this source to restore syncs, and keep its connection syncing " + "regularly to avoid a recurrence. Provider error: invalid_grant: AADSTS700082", + ), + ), + ids=["AADSTS50078", "AADSTS700082"], + ) + def test_mapped_entra_message_is_deterministic( + self, requests_mock, code, first_description, second_description, expected_message + ): + first = self._message_for_description(requests_mock, first_description) + second = self._message_for_description(requests_mock, second_description) + + assert first == second + assert first == expected_message + # AADSTS700082 embeds the token issue timestamp in the *first sentence* of the description, # so no first-line rule and no character cap can separate it from the stable text. _AADSTS700082 = ( From 84959959a398875370a52d5847579ffc801bfbce Mon Sep 17 00:00:00 2001 From: "zane.hyatt" Date: Thu, 8 Oct 2026 23:44:20 +0000 Subject: [PATCH 2/3] fix(oauth): reword AADSTS50078 guidance to drop the admin-as-fix framing --- .../http/requests_native_auth/abstract_oauth.py | 8 ++++---- .../test_requests_native_auth.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py b/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py index 7c4f577810..2ac3937183 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py @@ -39,10 +39,10 @@ # grouping key, so it must be identical on every attempt at the same failure. _ENTRA_REFRESH_ERROR_GUIDANCE: Mapping[str, str] = { "AADSTS50078": ( - "The tenant's Microsoft Entra policy requires a new MFA sign-in. Re-authenticate this " - "source to restore syncs. If this recurs on a schedule, the usual cause is an Entra MFA " - 'session setting such as "remember multi-factor authentication on trusted devices", which ' - "the tenant's Entra administrator controls." + "Microsoft Entra refused the refresh because the tenant's MFA policy now requires a new " + "MFA sign-in. Re-authenticate this source to restore syncs. This can recur on a fixed " + "schedule set by the tenant's MFA session settings, such as \"remember multi-factor " + 'authentication on trusted devices".' ), "AADSTS70043": ( "A Microsoft Entra Conditional Access sign-in frequency policy expired the refresh token. " diff --git a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py index bf89dcab73..75e8bb7767 100644 --- a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py +++ b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -1028,10 +1028,10 @@ def _message_for_description(self, requests_mock, error_description): "Trace ID: 01234567-89ab-cdef-0123-456789abcdef\r\n" "Correlation ID: 11111111-2222-3333-4444-555555555555\r\n" "Timestamp: 2026-03-14T09:26:53Z", - "The tenant's Microsoft Entra policy requires a new MFA sign-in. Re-authenticate this " - "source to restore syncs. If this recurs on a schedule, the usual cause is an Entra MFA " - 'session setting such as "remember multi-factor authentication on trusted devices", ' - "which the tenant's Entra administrator controls.", + "Microsoft Entra refused the refresh because the tenant's MFA policy now requires a " + "new MFA sign-in. Re-authenticate this source to restore syncs. This can recur on a " + "fixed schedule set by the tenant's MFA session settings, such as \"remember " + 'multi-factor authentication on trusted devices".', ), ( "AADSTS70043", @@ -1143,10 +1143,10 @@ def test_refresh_error_message_unchanged_without_mapped_code( "Trace ID: 789abcde-f012-3456-789a-bcdef0123456\r\n" "Correlation ID: 66666666-7777-8888-9999-aaaaaaaaaaaa\r\n" "Timestamp: 2026-08-15T10:27:54Z", - "The tenant's Microsoft Entra policy requires a new MFA sign-in. Re-authenticate this " - "source to restore syncs. If this recurs on a schedule, the usual cause is an Entra MFA " - 'session setting such as "remember multi-factor authentication on trusted devices", ' - "which the tenant's Entra administrator controls. Provider error: invalid_grant: " + "Microsoft Entra refused the refresh because the tenant's MFA policy now requires a " + "new MFA sign-in. Re-authenticate this source to restore syncs. This can recur on a " + "fixed schedule set by the tenant's MFA session settings, such as \"remember " + 'multi-factor authentication on trusted devices". Provider error: invalid_grant: ' "AADSTS50078", ), ( From 09220fe8c39e54c35f7163d0f126b5d5bcac4ff7 Mon Sep 17 00:00:00 2001 From: "zane.hyatt" Date: Wed, 14 Oct 2026 20:44:59 +0000 Subject: [PATCH 3/3] feat(oauth): add refresh_token_error_messages to OAuthAuthenticator --- airbyte_cdk/sources/declarative/auth/oauth.py | 3 + .../declarative_component_schema.yaml | 9 + .../models/declarative_component_schema.py | 10 + .../parsers/model_to_component_factory.py | 7 + .../requests_native_auth/abstract_oauth.py | 66 ++--- .../http/requests_native_auth/oauth.py | 11 +- .../sources/declarative/auth/test_oauth.py | 34 ++- .../test_model_to_component_factory.py | 111 ++++++++ .../test_concurrent_declarative_source.py | 26 ++ .../test_requests_native_auth.py | 260 ++++++++++++------ 10 files changed, 411 insertions(+), 126 deletions(-) diff --git a/airbyte_cdk/sources/declarative/auth/oauth.py b/airbyte_cdk/sources/declarative/auth/oauth.py index 3b70fe8228..797f478bdc 100644 --- a/airbyte_cdk/sources/declarative/auth/oauth.py +++ b/airbyte_cdk/sources/declarative/auth/oauth.py @@ -51,6 +51,7 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut refresh_token_error_status_codes (Tuple[int, ...]): Status codes to identify refresh token errors in response refresh_token_error_key (str): Key to identify refresh token error in response refresh_token_error_values (Tuple[str, ...]): List of values to check for exception during token refresh process + refresh_token_error_messages (Optional[Mapping[str, str]]): Fixed user-facing messages for refresh token errors """ config: Mapping[str, Any] @@ -81,12 +82,14 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut refresh_token_error_status_codes: Tuple[int, ...] = () refresh_token_error_key: str = "" refresh_token_error_values: Tuple[str, ...] = () + refresh_token_error_messages: Optional[Mapping[str, str]] = None def __post_init__(self, parameters: Mapping[str, Any]) -> None: super().__init__( refresh_token_error_status_codes=self.refresh_token_error_status_codes, refresh_token_error_key=self.refresh_token_error_key, refresh_token_error_values=self.refresh_token_error_values, + refresh_token_error_messages=self.refresh_token_error_messages, ) if self.token_refresh_endpoint is not None: self._token_refresh_endpoint: Optional[InterpolatedString] = InterpolatedString.create( diff --git a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml index 6df783d1db..5c0b86378d 100644 --- a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml +++ b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml @@ -1736,6 +1736,15 @@ definitions: type: string examples: - ["invalid_grant", "invalid_permissions"] + refresh_token_error_messages: + title: Refresh Token Error Messages + description: >- + Fixed user-facing messages for refresh token errors. Keys are matched in order against the provider error code leading error_description (a token such as ABC12345 followed by a word boundary), the whitespace-normalized full error_description, then the OAuth error value; the first match replaces the default re-authentication guidance, and the provider error detail is still appended. Only applies to responses already flagged as config errors by Refresh Token Error Status Codes, Refresh Token Error Key and Refresh Token Error Values (or their defaults); it does not flag any additional responses. Values are used verbatim, without interpolation, so the message is identical on every attempt. + type: object + additionalProperties: + type: string + examples: + - invalid_grant: "The refresh token expired or was revoked. Re-authenticate this source to restore syncs." refresh_token_updater: title: Refresh Token Updater description: When the refresh token updater is defined, new refresh tokens, access tokens and the access token expiry date are written back from the authentication response to the config object. This is important if the refresh token can only used once. diff --git a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py index 0317d52d65..38a911addc 100644 --- a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py +++ b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py @@ -2221,6 +2221,16 @@ class OAuthAuthenticator(BaseModel): examples=[["invalid_grant", "invalid_permissions"]], title="Refresh Token Error Values", ) + refresh_token_error_messages: Optional[Dict[str, str]] = Field( + None, + description="Fixed user-facing messages for refresh token errors. Keys are matched in order against the provider error code leading error_description (a token such as ABC12345 followed by a word boundary), the whitespace-normalized full error_description, then the OAuth error value; the first match replaces the default re-authentication guidance, and the provider error detail is still appended. Only applies to responses already flagged as config errors by Refresh Token Error Status Codes, Refresh Token Error Key and Refresh Token Error Values (or their defaults); it does not flag any additional responses. Values are used verbatim, without interpolation, so the message is identical on every attempt.", + examples=[ + { + "invalid_grant": "The refresh token expired or was revoked. Re-authenticate this source to restore syncs." + } + ], + title="Refresh Token Error Messages", + ) refresh_token_updater: Optional[RefreshTokenUpdater] = Field( None, description="When the refresh token updater is defined, new refresh tokens, access tokens and the access token expiry date are written back from the authentication response to the config object. This is important if the refresh token can only used once.", diff --git a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py index 219e0f70ad..ff28f407aa 100644 --- a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +++ b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py @@ -3194,6 +3194,7 @@ def create_oauth_authenticator( refresh_token_error_status_codes=refresh_token_error_status_codes, refresh_token_error_key=refresh_token_error_key, refresh_token_error_values=refresh_token_error_values, + refresh_token_error_messages=self._get_refresh_token_error_messages(model), ) # ignore type error because fixing it would have a lot of dependencies, revisit later return DeclarativeOauth2Authenticator( # type: ignore @@ -3224,6 +3225,7 @@ def create_oauth_authenticator( refresh_token_error_status_codes=refresh_token_error_status_codes, refresh_token_error_key=refresh_token_error_key, refresh_token_error_values=refresh_token_error_values, + refresh_token_error_messages=self._get_refresh_token_error_messages(model), ) @staticmethod @@ -3274,6 +3276,11 @@ def _get_refresh_token_error_information( # returning default values we think cover most cases return (400,), "error", ("invalid_grant", "invalid_permissions") + @staticmethod + def _get_refresh_token_error_messages(model: OAuthAuthenticatorModel) -> Dict[str, str]: + """Resolve messages separately so they never disable the default gate.""" + return dict(model.refresh_token_error_messages or {}) + @staticmethod def _reject_union_combined_extractor_for_offset_increment( extractor_model: Optional[BaseModel], diff --git a/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py b/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py index 2ac3937183..0ba1430c34 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py @@ -34,34 +34,9 @@ "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " "Re-authenticate this source's credentials in its connection settings." ) -# Guidance for Microsoft Entra refresh errors, keyed on the AADSTS code leading `error_description`. -# Replaces the generic guidance above. Fixed strings only: the full message is the failure-summary -# grouping key, so it must be identical on every attempt at the same failure. -_ENTRA_REFRESH_ERROR_GUIDANCE: Mapping[str, str] = { - "AADSTS50078": ( - "Microsoft Entra refused the refresh because the tenant's MFA policy now requires a new " - "MFA sign-in. Re-authenticate this source to restore syncs. This can recur on a fixed " - "schedule set by the tenant's MFA session settings, such as \"remember multi-factor " - 'authentication on trusted devices".' - ), - "AADSTS70043": ( - "A Microsoft Entra Conditional Access sign-in frequency policy expired the refresh token. " - "Re-authenticate this source to restore syncs. The tenant's Entra administrator sets how " - "often this is required." - ), - "AADSTS700082": ( - "The Microsoft Entra refresh token expired after a period of inactivity. Re-authenticate " - "this source to restore syncs, and keep its connection syncing regularly to avoid a " - "recurrence." - ), - "AADSTS50173": ( - "Microsoft Entra revoked the grant, typically because the authorizing user's password was " - "changed or reset. Re-authenticate this source to restore syncs." - ), -} # Upper bound on the provider-controlled text appended to the user-facing message. Both the # RFC 6749 `error` token and the extracted code come from the provider, so both are still capped. -# The longest standard token (`unsupported_grant_type`, 22) plus a 7-digit Entra code is 37 +# The longest standard token (`unsupported_grant_type`, 22) plus a 7-digit provider code is 37 # characters, so this leaves roughly 2x headroom while still bounding a misbehaving provider. _PROVIDER_ERROR_DETAIL_MAX_LENGTH = 64 # How much of the raw provider response is kept in the internal message, which is logged and is not @@ -97,14 +72,19 @@ def __init__( refresh_token_error_status_codes: Tuple[int, ...] = (), refresh_token_error_key: str = "", refresh_token_error_values: Tuple[str, ...] = (), + refresh_token_error_messages: Optional[Mapping[str, str]] = None, ) -> None: """ If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set, then http errors with such params will be wrapped in AirbyteTracedException. + refresh_token_error_messages maps fixed provider error strings to fixed user-facing messages. """ self._refresh_token_error_status_codes = refresh_token_error_status_codes self._refresh_token_error_key = refresh_token_error_key self._refresh_token_error_values = refresh_token_error_values + self._refresh_token_error_messages: Mapping[str, str] = dict( + refresh_token_error_messages or {} + ) def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest: """Attach the HTTP headers required to authenticate on the HTTP request""" @@ -357,6 +337,21 @@ def _extract_provider_error_code( return code_match.group() return None + def _get_refresh_token_error_message( + self, response_content: Optional[Mapping[str, Any]] + ) -> Optional[str]: + if not self._refresh_token_error_messages or not response_content: + return None + candidates = [self._extract_provider_error_code(response_content)] + for key in ("error_description", "error"): + value = response_content.get(key) + if isinstance(value, str): + candidates.append(" ".join(value.split())) + for candidate in candidates: + if candidate and candidate in self._refresh_token_error_messages: + return self._refresh_token_error_messages[candidate] + return None + def _build_provider_error_detail( self, response_content: Optional[Mapping[str, Any]] ) -> Optional[str]: @@ -366,14 +361,10 @@ def _build_provider_error_detail( Only the standard OAuth 2.0 `error` field (RFC 6749 section 5.2) and the provider error code leading `error_description` are used. Both are stable for a given failure, so the same failure produces a byte-identical message on every attempt and the platform groups them - into a single failure summary. The description prose is deliberately excluded: it is - free-form, and providers embed per-request values in it -- Microsoft Entra's - `AADSTS700082` carries the token issue timestamp in its first sentence -- which would make - the grouping key unbounded. The code alone is what distinguishes a revoked grant - (`AADSTS50173`) from a misconfigured client (`AADSTS7000218`) or a Conditional Access - requirement (`AADSTS50076`). The full response body is preserved in the internal message, - which is logged. Which provider errors reach this path at all is set by the - authenticator's `refresh_token_error_*` configuration. + into a single failure summary. The description prose is deliberately excluded because it + can contain per-request values, which would make the grouping key unbounded. The full + response body is preserved in the internal message, which is logged. Which provider errors + reach this path at all is set by the authenticator's `refresh_token_error_*` configuration. """ if not response_content: return None @@ -505,9 +496,10 @@ def _make_handled_request(self) -> Any: ) error_content = self._parse_error_response_content(e.response) if self._wrap_refresh_token_exception(e, response_content=error_content): - provider_error_code = self._extract_provider_error_code(error_content) - message = _ENTRA_REFRESH_ERROR_GUIDANCE.get( - provider_error_code or "", _REFRESH_TOKEN_REJECTED_GUIDANCE + # Configured messages are fixed connector strings for failure-summary grouping. + message = ( + self._get_refresh_token_error_message(error_content) + or _REFRESH_TOKEN_REJECTED_GUIDANCE ) provider_error_detail = self._build_provider_error_detail(error_content) if provider_error_detail: diff --git a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py index 06643ba82c..9f2a140a2a 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -28,6 +28,7 @@ class Oauth2Authenticator(AbstractOauth2Authenticator): Generates OAuth2.0 access tokens from an OAuth2.0 refresh token and client credentials. The generated access token is attached to each request via the Authorization header. If a connector_config is provided any mutation of it's value in the scope of this class will emit AirbyteControlConnectorConfigMessage. + `refresh_token_error_messages` provides fixed user-facing messages for configured refresh token errors. """ def __init__( @@ -53,6 +54,7 @@ def __init__( refresh_token_error_status_codes: Tuple[int, ...] = (), refresh_token_error_key: str = "", refresh_token_error_values: Tuple[str, ...] = (), + refresh_token_error_messages: Optional[Mapping[str, str]] = None, ) -> None: self._token_refresh_endpoint = token_refresh_endpoint self._client_secret_name = client_secret_name @@ -75,7 +77,10 @@ def __init__( self._token_expiry_is_time_of_expiration = token_expiry_is_time_of_expiration self._access_token = None super().__init__( - refresh_token_error_status_codes, refresh_token_error_key, refresh_token_error_values + refresh_token_error_status_codes, + refresh_token_error_key, + refresh_token_error_values, + refresh_token_error_messages=refresh_token_error_messages, ) def get_token_refresh_endpoint(self) -> str: @@ -158,6 +163,7 @@ class SingleUseRefreshTokenOauth2Authenticator(Oauth2Authenticator): By default, this authenticator expects a connector config with a "credentials" field with the following nested fields: client_id, client_secret, refresh_token. This behavior can be changed by defining custom config path (using dpath paths) in client_id_config_path, client_secret_config_path, refresh_token_config_path constructor arguments. + `refresh_token_error_messages` provides fixed user-facing messages for configured refresh token errors. """ def __init__( @@ -186,6 +192,7 @@ def __init__( refresh_token_error_status_codes: Tuple[int, ...] = (), refresh_token_error_key: str = "", refresh_token_error_values: Tuple[str, ...] = (), + refresh_token_error_messages: Optional[Mapping[str, str]] = None, ) -> None: """ Args: @@ -207,6 +214,7 @@ def __init__( token_expiry_date_format (Optional[str]): Date format of the token expiry date field (set by expires_in_name). If specified the token expiry date is interpreted as time of expiration, as with token_expiry_is_time_of_expiration, otherwise as number of seconds until expiration. token_expiry_is_time_of_expiration bool: set True it if expires_in is returned as time of expiration instead of the number seconds until expiration message_repository (MessageRepository): the message repository used to emit logs on HTTP requests and control message on config update + refresh_token_error_messages (Optional[Mapping[str, str]]): Fixed user-facing messages for refresh token errors """ self._connector_config = connector_config self._client_id: str = self._get_config_value_by_path( @@ -248,6 +256,7 @@ def __init__( refresh_token_error_status_codes=refresh_token_error_status_codes, refresh_token_error_key=refresh_token_error_key, refresh_token_error_values=refresh_token_error_values, + refresh_token_error_messages=refresh_token_error_messages, ) @property diff --git a/unit_tests/sources/declarative/auth/test_oauth.py b/unit_tests/sources/declarative/auth/test_oauth.py index 2eb5871ff5..043bf04c69 100644 --- a/unit_tests/sources/declarative/auth/test_oauth.py +++ b/unit_tests/sources/declarative/auth/test_oauth.py @@ -788,7 +788,9 @@ def test_refresh_access_token_rejected_surfaces_provider_error_and_redacts(self, oauth.refresh_access_token() assert exc_info.value.failure_type == FailureType.config_error - assert exc_info.value.message.startswith("Microsoft Entra revoked the grant") + assert exc_info.value.message.startswith( + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used)" + ) # Only the provider code is surfaced, so the echoed credentials cannot reach the # user-facing message at all -- they are absent by construction, not merely masked. assert exc_info.value.message.endswith("Provider error: invalid_grant: AADSTS50173") @@ -802,6 +804,36 @@ def test_refresh_access_token_rejected_surfaces_provider_error_and_redacts(self, assert parameters["refresh_token"] not in message assert config["client_secret"] not in message + def test_refresh_access_token_rejected_uses_configured_message(self, requests_mock): + oauth = DeclarativeOauth2Authenticator( + token_refresh_endpoint="{{ config['refresh_endpoint'] }}", + client_id="{{ config['client_id'] }}", + client_secret="{{ config['client_secret'] }}", + refresh_token="{{ parameters['refresh_token'] }}", + config=config, + parameters=parameters, + refresh_token_error_status_codes=(400,), + refresh_token_error_key="error", + refresh_token_error_values=("invalid_grant", "invalid_permissions"), + refresh_token_error_messages={"AADSTS50173": "Configured guidance."}, + ) + requests_mock.post( + config["refresh_endpoint"], + status_code=400, + json={ + "error": "invalid_grant", + "error_description": "AADSTS50173: Grant revoked.\r\nTrace ID: x", + }, + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + oauth.refresh_access_token() + + assert exc_info.value.message == ( + "Configured guidance. Provider error: invalid_grant: AADSTS50173" + ) + assert exc_info.value.failure_type == FailureType.config_error + def mock_request(method, url, data, headers, **kwargs): if url == "https://refresh_endpoint.com": diff --git a/unit_tests/sources/declarative/parsers/test_model_to_component_factory.py b/unit_tests/sources/declarative/parsers/test_model_to_component_factory.py index e54f23e8e8..e485a80442 100644 --- a/unit_tests/sources/declarative/parsers/test_model_to_component_factory.py +++ b/unit_tests/sources/declarative/parsers/test_model_to_component_factory.py @@ -794,6 +794,117 @@ def test_single_use_oauth_branch(): assert authenticator._refresh_token_error_status_codes == (400,) assert authenticator._refresh_token_error_key == "error" assert authenticator._refresh_token_error_values == ("invalid_grant",) + assert authenticator._refresh_token_error_messages == {} + + +def _create_oauth_authenticator_from_manifest(content, config=input_config): + parsed_manifest = YamlDeclarativeSource._parse(content) + resolved_manifest = resolver.preprocess_manifest(parsed_manifest) + authenticator_manifest = transformer.propagate_types_and_parameters( + "", resolved_manifest["authenticator"], {} + ) + return factory.create_component( + model_type=OAuthAuthenticatorModel, + component_definition=authenticator_manifest, + config=config, + ) + + +def test_oauth_refresh_token_error_messages_only_keeps_default_gate(requests_mock): + content = """ + authenticator: + type: OAuthAuthenticator + client_id: "some_client_id" + client_secret: "some_client_secret" + token_refresh_endpoint: "https://api.sendgrid.com/v3/auth" + refresh_token: "{{ config['apikey'] }}" + refresh_token_error_messages: + AADSTS50078: "Configured guidance." + """ + + authenticator = _create_oauth_authenticator_from_manifest(content) + + assert isinstance(authenticator, DeclarativeOauth2Authenticator) + assert authenticator._refresh_token_error_status_codes == (400,) + assert authenticator._refresh_token_error_key == "error" + assert authenticator._refresh_token_error_values == ( + "invalid_grant", + "invalid_permissions", + ) + assert authenticator._refresh_token_error_messages == {"AADSTS50078": "Configured guidance."} + + requests_mock.post( + "https://api.sendgrid.com/v3/auth", + status_code=400, + json={ + "error": "invalid_grant", + "error_description": "AADSTS50078: MFA required.\r\nTrace ID: x", + }, + ) + with pytest.raises(AirbyteTracedException) as exc_info: + authenticator.refresh_access_token() + + assert ( + exc_info.value.message == "Configured guidance. Provider error: invalid_grant: AADSTS50078" + ) + assert exc_info.value.failure_type == FailureType.config_error + + +def test_single_use_oauth_with_refresh_token_error_messages_keeps_default_gate(): + single_use_input_config = { + "apikey": "verysecrettoken", + "repos": ["airbyte", "airbyte-cloud"], + "credentials": {"access_token": "access_token", "token_expiry_date": "1970-01-01"}, + } + content = """ + authenticator: + type: OAuthAuthenticator + client_id: "some_client_id" + client_secret: "some_client_secret" + token_refresh_endpoint: "https://api.sendgrid.com/v3/auth" + refresh_token: "{{ config['apikey'] }}" + refresh_token_error_messages: + AADSTS50078: "Configured guidance." + refresh_token_updater: + refresh_token_name: "the_refresh_token" + refresh_token_config_path: + - apikey + """ + + authenticator = _create_oauth_authenticator_from_manifest(content, single_use_input_config) + + assert isinstance(authenticator, SingleUseRefreshTokenOauth2Authenticator) + assert authenticator._refresh_token_error_status_codes == (400,) + assert authenticator._refresh_token_error_key == "error" + assert authenticator._refresh_token_error_values == ( + "invalid_grant", + "invalid_permissions", + ) + assert authenticator._refresh_token_error_messages == {"AADSTS50078": "Configured guidance."} + + +def test_oauth_refresh_token_error_messages_preserves_explicit_gate(): + content = """ + authenticator: + type: OAuthAuthenticator + client_id: "some_client_id" + client_secret: "some_client_secret" + token_refresh_endpoint: "https://api.sendgrid.com/v3/auth" + refresh_token: "{{ config['apikey'] }}" + refresh_token_error_status_codes: [422] + refresh_token_error_key: "errorCode" + refresh_token_error_values: ["refresh_invalid"] + refresh_token_error_messages: + AADSTS50078: "Configured guidance." + """ + + authenticator = _create_oauth_authenticator_from_manifest(content) + + assert isinstance(authenticator, DeclarativeOauth2Authenticator) + assert authenticator._refresh_token_error_status_codes == (422,) + assert authenticator._refresh_token_error_key == "errorCode" + assert authenticator._refresh_token_error_values == ("refresh_invalid",) + assert authenticator._refresh_token_error_messages == {"AADSTS50078": "Configured guidance."} def test_list_based_stream_slicer_with_values_refd(): diff --git a/unit_tests/sources/declarative/test_concurrent_declarative_source.py b/unit_tests/sources/declarative/test_concurrent_declarative_source.py index 686bd54f0b..9d72870b6b 100644 --- a/unit_tests/sources/declarative/test_concurrent_declarative_source.py +++ b/unit_tests/sources/declarative/test_concurrent_declarative_source.py @@ -6894,6 +6894,32 @@ def test_combined_extractor_with_an_empty_extractors_list_fails_schema_validatio ) +def test_refresh_token_error_messages_accepts_string_values_in_manifest(): + manifest = _combined_extractor_manifest({"type": "DpathExtractor", "field_path": ["rows"]}) + manifest["streams"][0]["retriever"]["requester"]["authenticator"] = { + "type": "OAuthAuthenticator", + "refresh_token_error_messages": {"invalid_grant": "Re-authenticate this source."}, + } + + ConcurrentDeclarativeSource( + source_config=manifest, config={}, catalog=create_catalog("lists"), state=None + ) + + +@pytest.mark.parametrize("value", [123, ["message"]]) +def test_refresh_token_error_messages_rejects_non_string_values(value): + manifest = _combined_extractor_manifest({"type": "DpathExtractor", "field_path": ["rows"]}) + manifest["streams"][0]["retriever"]["requester"]["authenticator"] = { + "type": "OAuthAuthenticator", + "refresh_token_error_messages": {"invalid_grant": value}, + } + + with pytest.raises(ValidationError): + ConcurrentDeclarativeSource( + source_config=manifest, config={}, catalog=create_catalog("lists"), state=None + ) + + @pytest.mark.parametrize( "pagination_strategy, page_token_field, page_tokens", [ diff --git a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py index 75e8bb7767..6e1d8cffd6 100644 --- a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py +++ b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -815,7 +815,7 @@ def test_refresh_access_token_wrapped( assert exc_info.value.message.startswith(error_message) assert exc_info.value.failure_type == FailureType.config_error - def _entra_style_authenticator(self): + def _entra_style_authenticator(self, refresh_token_error_messages=None): return Oauth2Authenticator( f"https://{TestOauth2Authenticator.refresh_endpoint}", TestOauth2Authenticator.client_id, @@ -824,6 +824,7 @@ def _entra_style_authenticator(self): refresh_token_error_status_codes=(400,), refresh_token_error_key="error", refresh_token_error_values=("invalid_grant", "invalid_client"), + refresh_token_error_messages=refresh_token_error_messages, ) # Sits on a later line of each provider payload below, like the trace ids and timestamps @@ -840,8 +841,8 @@ def _entra_style_authenticator(self): "token is needed. The user might have changed or reset their password.\r\n" "Trace ID: 00000000-0000-0000-0000-000000000000\r\n" + trailing_marker, "AADSTS50173", - "Microsoft Entra revoked the grant, typically because the authorizing user's " - "password was changed or reset. Re-authenticate this source to restore syncs.", + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings.", ), ( # Client type / credential misconfiguration: re-authentication will not help. @@ -1007,85 +1008,18 @@ def test_refresh_access_token_non_json_body_does_not_raise_new_exception( with pytest.raises(RequestException): oauth.refresh_access_token() - def _message_for_description(self, requests_mock, error_description): - oauth = self._entra_style_authenticator() - requests_mock.post( - f"https://{TestOauth2Authenticator.refresh_endpoint}", - status_code=400, - json={"error": "invalid_grant", "error_description": error_description}, - ) - with pytest.raises(AirbyteTracedException) as exc_info: - oauth.refresh_access_token() - return exc_info.value.message - - @pytest.mark.parametrize( - "code, error_description, expected_guidance", - ( - ( - "AADSTS50078", - "AADSTS50078: Due to a change in your security information or because the MFA " - "session is no longer valid, you must use multi-factor authentication.\r\n" - "Trace ID: 01234567-89ab-cdef-0123-456789abcdef\r\n" - "Correlation ID: 11111111-2222-3333-4444-555555555555\r\n" - "Timestamp: 2026-03-14T09:26:53Z", - "Microsoft Entra refused the refresh because the tenant's MFA policy now requires a " - "new MFA sign-in. Re-authenticate this source to restore syncs. This can recur on a " - "fixed schedule set by the tenant's MFA session settings, such as \"remember " - 'multi-factor authentication on trusted devices".', - ), - ( - "AADSTS70043", - "AADSTS70043: The refresh token has expired due to sign-in frequency checks by " - "Conditional Access.\r\n" - "Trace ID: 23456789-abcd-ef01-2345-6789abcdef01\r\n" - "Correlation ID: 22222222-3333-4444-5555-666666666666\r\n" - "Timestamp: 2026-04-15T10:27:54Z", - "A Microsoft Entra Conditional Access sign-in frequency policy expired the refresh " - "token. Re-authenticate this source to restore syncs. The tenant's Entra administrator " - "sets how often this is required.", - ), - ( - "AADSTS700082", - "AADSTS700082: The refresh token has expired due to inactivity. The token was issued " - "on 2026-06-11T03:14:07.000Z and was inactive for 90.00:00:00.\r\n" - "Trace ID: 3456789a-bcde-f012-3456-789abcdef012\r\n" - "Correlation ID: 33333333-4444-5555-6666-777777777777\r\n" - "Timestamp: 2026-06-12T11:28:55Z", - "The Microsoft Entra refresh token expired after a period of inactivity. " - "Re-authenticate this source to restore syncs, and keep its connection syncing " - "regularly to avoid a recurrence.", - ), - ( - "AADSTS50173", - "AADSTS50173: The provided grant has expired due to it being revoked, typically after " - "the user changed or reset their password.\r\n" - "Trace ID: 456789ab-cdef-0123-4567-89abcdef0123\r\n" - "Correlation ID: 44444444-5555-6666-7777-888888888888\r\n" - "Timestamp: 2026-07-13T12:29:56Z", - "Microsoft Entra revoked the grant, typically because the authorizing user's password " - "was changed or reset. Re-authenticate this source to restore syncs.", - ), - ), - ids=["AADSTS50078", "AADSTS70043", "AADSTS700082", "AADSTS50173"], - ) - def test_entra_error_code_maps_to_guidance( - self, requests_mock, code, error_description, expected_guidance + def _message_for_description( + self, requests_mock, error_description, refresh_token_error_messages=None ): - oauth = self._entra_style_authenticator() + oauth = self._entra_style_authenticator(refresh_token_error_messages) requests_mock.post( f"https://{TestOauth2Authenticator.refresh_endpoint}", status_code=400, json={"error": "invalid_grant", "error_description": error_description}, ) - with pytest.raises(AirbyteTracedException) as exc_info: oauth.refresh_access_token() - - message = exc_info.value.message - assert message == f"{expected_guidance} Provider error: invalid_grant: {code}" - assert "(invalid, expired, or already used)" not in message - assert exc_info.value.failure_type == FailureType.config_error - assert "Trace ID:" in exc_info.value.internal_message + return exc_info.value.message @pytest.mark.parametrize( "error_content, expected_detail", @@ -1111,10 +1045,15 @@ def test_entra_error_code_maps_to_guidance( ), ids=["unknown_entra_code", "non_entra_provider", "no_description"], ) + @pytest.mark.parametrize( + "refresh_token_error_messages", + [None, {}, {"AADSTS50078": "Mapped."}], + ids=["no_map", "empty_map", "non_matching_map"], + ) def test_refresh_error_message_unchanged_without_mapped_code( - self, requests_mock, error_content, expected_detail + self, requests_mock, error_content, expected_detail, refresh_token_error_messages ): - oauth = self._entra_style_authenticator() + oauth = self._entra_style_authenticator(refresh_token_error_messages) requests_mock.post( f"https://{TestOauth2Authenticator.refresh_endpoint}", status_code=400, @@ -1143,11 +1082,7 @@ def test_refresh_error_message_unchanged_without_mapped_code( "Trace ID: 789abcde-f012-3456-789a-bcdef0123456\r\n" "Correlation ID: 66666666-7777-8888-9999-aaaaaaaaaaaa\r\n" "Timestamp: 2026-08-15T10:27:54Z", - "Microsoft Entra refused the refresh because the tenant's MFA policy now requires a " - "new MFA sign-in. Re-authenticate this source to restore syncs. This can recur on a " - "fixed schedule set by the tenant's MFA session settings, such as \"remember " - 'multi-factor authentication on trusted devices". Provider error: invalid_grant: ' - "AADSTS50078", + "Configured guidance for 50078. Provider error: invalid_grant: AADSTS50078", ), ( "AADSTS700082", @@ -1161,22 +1096,145 @@ def test_refresh_error_message_unchanged_without_mapped_code( "Trace ID: 9abcdef0-1234-5678-9abc-def012345678\r\n" "Correlation ID: 88888888-9999-aaaa-bbbb-cccccccccccc\r\n" "Timestamp: 2026-07-30T12:29:56Z", - "The Microsoft Entra refresh token expired after a period of inactivity. " - "Re-authenticate this source to restore syncs, and keep its connection syncing " - "regularly to avoid a recurrence. Provider error: invalid_grant: AADSTS700082", + "Configured guidance for 700082. Provider error: invalid_grant: AADSTS700082", ), ), ids=["AADSTS50078", "AADSTS700082"], ) - def test_mapped_entra_message_is_deterministic( + def test_mapped_message_is_deterministic( self, requests_mock, code, first_description, second_description, expected_message ): - first = self._message_for_description(requests_mock, first_description) - second = self._message_for_description(requests_mock, second_description) + messages = { + "AADSTS50078": "Configured guidance for 50078.", + "AADSTS700082": "Configured guidance for 700082.", + } + first = self._message_for_description(requests_mock, first_description, messages) + second = self._message_for_description(requests_mock, second_description, messages) assert first == second assert first == expected_message + @pytest.mark.parametrize( + "messages, expected", + [ + ( + { + "AADSTS50078": "Code guidance.", + "AADSTS50078: MFA required. Trace ID: x": "Description guidance.", + "invalid_grant": "Error guidance.", + }, + "Code guidance. Provider error: invalid_grant: AADSTS50078", + ), + ( + { + "AADSTS50078: MFA required. Trace ID: x": "Description guidance.", + "invalid_grant": "Error guidance.", + }, + "Description guidance. Provider error: invalid_grant: AADSTS50078", + ), + ( + {"invalid_grant": "Error guidance."}, + "Error guidance. Provider error: invalid_grant: AADSTS50078", + ), + ], + ids=["provider_code_first", "normalized_description_second", "oauth_error_last"], + ) + def test_refresh_error_message_lookup_order(self, requests_mock, messages, expected): + message = self._message_for_description( + requests_mock, + "AADSTS50078: MFA required.\r\nTrace ID: x", + messages, + ) + + assert message == expected + + def test_refresh_error_message_matches_whitespace_normalized_description(self, requests_mock): + message = self._message_for_description( + requests_mock, + "Token has been\r\n expired or revoked.\t", + {"Token has been expired or revoked.": "Configured guidance."}, + ) + + assert message == "Configured guidance. Provider error: invalid_grant" + + def test_refresh_error_message_matches_error_value(self, requests_mock): + message = self._message_for_description( + requests_mock, "No provider code.", {"invalid_grant": "Configured guidance."} + ) + + assert message == "Configured guidance. Provider error: invalid_grant" + + @pytest.mark.parametrize("status_code", [400, 401]) + def test_refresh_error_message_not_applied_outside_gate(self, requests_mock, status_code): + oauth = Oauth2Authenticator( + f"https://{TestOauth2Authenticator.refresh_endpoint}", + TestOauth2Authenticator.client_id, + TestOauth2Authenticator.client_secret, + TestOauth2Authenticator.refresh_token, + refresh_token_error_messages={"invalid_client": "Configured guidance."}, + ) + requests_mock.post( + f"https://{TestOauth2Authenticator.refresh_endpoint}", + status_code=status_code, + json={"error": "invalid_client"}, + ) + + with pytest.raises(requests.HTTPError): + oauth.refresh_access_token() + + def test_refresh_error_message_ignores_non_string_error_fields(self, requests_mock): + oauth = Oauth2Authenticator( + f"https://{TestOauth2Authenticator.refresh_endpoint}", + TestOauth2Authenticator.client_id, + TestOauth2Authenticator.client_secret, + TestOauth2Authenticator.refresh_token, + refresh_token_error_status_codes=(400,), + refresh_token_error_key="error_code", + refresh_token_error_values=("invalid_grant",), + refresh_token_error_messages={"invalid_grant": "Configured guidance."}, + ) + requests_mock.post( + f"https://{TestOauth2Authenticator.refresh_endpoint}", + status_code=400, + json={ + "error_code": "invalid_grant", + "error": 123, + "error_description": {"detail": "not a string"}, + }, + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + oauth.refresh_access_token() + + generic = ( + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings." + ) + assert exc_info.value.message == generic + assert exc_info.value.failure_type == FailureType.config_error + + def test_refresh_error_message_empty_key_or_value_falls_back(self, requests_mock): + message = self._message_for_description( + requests_mock, "", {"": "Ignored.", "invalid_grant": ""} + ) + + generic = ( + "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). " + "Re-authenticate this source's credentials in its connection settings." + ) + assert message == f"{generic} Provider error: invalid_grant" + + def test_abstract_authenticator_positional_init_still_supported(self): + class PositionalInitAuthenticator(Oauth2Authenticator): + def __init__(self): + super(Oauth2Authenticator, self).__init__((400,), "error", ("invalid_grant",)) + + authenticator = PositionalInitAuthenticator() + assert authenticator._refresh_token_error_status_codes == (400,) + assert authenticator._refresh_token_error_key == "error" + assert authenticator._refresh_token_error_values == ("invalid_grant",) + assert authenticator._refresh_token_error_messages == {} + # AADSTS700082 embeds the token issue timestamp in the *first sentence* of the description, # so no first-line rule and no character cap can separate it from the stable text. _AADSTS700082 = ( @@ -1352,6 +1410,34 @@ def test_init(self, connector_config): connector_config["credentials"]["token_expiry_date"] ) + def test_refresh_error_messages_applied_on_single_use(self, connector_config, requests_mock): + authenticator = SingleUseRefreshTokenOauth2Authenticator( + connector_config, + token_refresh_endpoint="https://refresh_endpoint.com", + client_id=connector_config["credentials"]["client_id"], + client_secret=connector_config["credentials"]["client_secret"], + refresh_token_error_status_codes=(400,), + refresh_token_error_key="error", + refresh_token_error_values=("invalid_grant",), + refresh_token_error_messages={"AADSTS50078": "Configured single-use guidance."}, + ) + requests_mock.post( + "https://refresh_endpoint.com", + status_code=400, + json={ + "error": "invalid_grant", + "error_description": "AADSTS50078: MFA required.\r\nTrace ID: x", + }, + ) + + with pytest.raises(AirbyteTracedException) as exc_info: + authenticator.refresh_access_token() + + assert exc_info.value.message == ( + "Configured single-use guidance. Provider error: invalid_grant: AADSTS50078" + ) + assert exc_info.value.failure_type == FailureType.config_error + @pytest.mark.parametrize( "test_name, expires_in_value, expiry_date_format, expected_expiry_date", [