From 454c2e792c1672ff9be15824015ba9aac22f9153 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 22:21:52 +0000 Subject: [PATCH 1/6] feat: add refresh_request_query_params to OAuth authenticators Add support for sending OAuth refresh token request parameters as URL query string parameters instead of in the POST body. This is required by OAuth providers like Gong that document their refresh endpoint with grant_type and refresh_token on the URL query string. Changes: - Add get_refresh_request_params() to AbstractOauth2Authenticator - Add build_refresh_request_query_params() that returns params or None - Pass params= to requests.request() in _make_handled_request() - Exclude query param keys from request body to prevent duplication - Add refresh_request_params to Oauth2Authenticator and SingleUseRefreshTokenOauth2Authenticator - Add refresh_request_query_params to declarative schema, Pydantic model, DeclarativeOauth2Authenticator, and model_to_component_factory - Add unit tests covering query params behavior for both native and declarative authenticators Co-Authored-By: bot_apk --- airbyte_cdk/sources/declarative/auth/oauth.py | 8 +++ .../declarative_component_schema.yaml | 8 +++ .../models/declarative_component_schema.py | 11 +++ .../parsers/model_to_component_factory.py | 4 ++ .../requests_native_auth/abstract_oauth.py | 35 ++++++++++ .../http/requests_native_auth/oauth.py | 8 +++ .../sources/declarative/auth/test_oauth.py | 53 ++++++++++++++- .../test_requests_native_auth.py | 68 ++++++++++++++++++- 8 files changed, 193 insertions(+), 2 deletions(-) diff --git a/airbyte_cdk/sources/declarative/auth/oauth.py b/airbyte_cdk/sources/declarative/auth/oauth.py index e1ad84e09e..41e2a793eb 100644 --- a/airbyte_cdk/sources/declarative/auth/oauth.py +++ b/airbyte_cdk/sources/declarative/auth/oauth.py @@ -44,6 +44,7 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut 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 refresh_request_body (Optional[Mapping[str, Any]]): The request body to send in the refresh request refresh_request_headers (Optional[Mapping[str, Any]]): The request headers to send in the refresh request + refresh_request_query_params (Optional[Mapping[str, Any]]): URL query string parameters to send on the refresh request grant_type: The grant_type to request for access_token. If set to refresh_token, the refresh_token parameter has to be provided message_repository (MessageRepository): the message repository used to emit logs on HTTP requests refresh_token_error_status_codes (Tuple[int, ...]): Status codes to identify refresh token errors in response @@ -70,6 +71,7 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut refresh_token_name: Union[InterpolatedString, str] = "refresh_token" refresh_request_body: Optional[Mapping[str, Any]] = None refresh_request_headers: Optional[Mapping[str, Any]] = None + refresh_request_query_params: Optional[Mapping[str, Any]] = None grant_type_name: Union[InterpolatedString, str] = "grant_type" grant_type: Union[InterpolatedString, str] = "refresh_token" message_repository: MessageRepository = NoopMessageRepository() @@ -135,6 +137,9 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: self._refresh_request_headers = InterpolatedMapping( self.refresh_request_headers or {}, parameters=parameters ) + self._refresh_request_query_params = InterpolatedMapping( + self.refresh_request_query_params or {}, parameters=parameters + ) try: if ( isinstance(self.token_expiry_date, (int, str)) @@ -247,6 +252,9 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: return self._refresh_request_headers.eval(self.config) + def get_refresh_request_params(self) -> Mapping[str, Any]: + return self._refresh_request_query_params.eval(self.config) + def get_token_expiry_date(self) -> AirbyteDateTime: if not self._has_access_token_been_initialized(): return AirbyteDateTime.from_datetime(datetime.min) diff --git a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml index 45ecd98dc1..b6806fe78e 100644 --- a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml +++ b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml @@ -1434,6 +1434,14 @@ definitions: examples: - Authorization: "" Content-Type: "application/x-www-form-urlencoded" + refresh_request_query_params: + title: Refresh Request Query Parameters + description: URL query string parameters of the request sent to get a new access token. When set, any matching keys are removed from the request body so the same parameter is not sent twice. Use this for OAuth providers like Gong that document their refresh endpoint with `grant_type` and `refresh_token` on the URL query string instead of the form body. + type: object + additionalProperties: true + examples: + - grant_type: "refresh_token" + refresh_token: "{{ config['credentials']['refresh_token'] }}" scopes: title: Scopes description: List of scopes that should be granted to the access token. diff --git a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py index f14c04da40..bbc472ca39 100644 --- a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py +++ b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py @@ -1939,6 +1939,17 @@ class OAuthAuthenticator(BaseModel): ], title="Refresh Request Headers", ) + refresh_request_query_params: Optional[Dict[str, Any]] = Field( + None, + description="URL query string parameters of the request sent to get a new access token. When set, any matching keys are removed from the request body so the same parameter is not sent twice. Use this for OAuth providers like Gong that document their refresh endpoint with `grant_type` and `refresh_token` on the URL query string instead of the form body.", + examples=[ + { + "grant_type": "refresh_token", + "refresh_token": "{{ config['credentials']['refresh_token'] }}", + } + ], + title="Refresh Request Query Parameters", + ) scopes: Optional[List[str]] = Field( None, description="List of scopes that should be granted to the access token.", 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 8bcb9e2dc7..3c47621f69 100644 --- a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +++ b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py @@ -2872,6 +2872,9 @@ def create_oauth_authenticator( refresh_request_headers=InterpolatedMapping( model.refresh_request_headers or {}, parameters=model.parameters or {} ).eval(config), + refresh_request_params=InterpolatedMapping( + model.refresh_request_query_params or {}, parameters=model.parameters or {} + ).eval(config), scopes=model.scopes, token_expiry_date_format=model.token_expiry_date_format, token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), @@ -2893,6 +2896,7 @@ def create_oauth_authenticator( grant_type=model.grant_type or "refresh_token", refresh_request_body=model.refresh_request_body, refresh_request_headers=model.refresh_request_headers, + refresh_request_query_params=model.refresh_request_query_params, refresh_token_name=model.refresh_token_name or "refresh_token", refresh_token=model.refresh_token, scopes=model.scopes, 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 111583a637..9ff0274f4e 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 @@ -135,6 +135,11 @@ def build_refresh_request_body(self) -> Mapping[str, Any]: refresh_request_headers contains an Authorization header (e.g., Basic auth). This is required by OAuth providers like Gong that expect credentials ONLY in the Authorization header and reject requests that include them in both places. + + Any key that is also present in `build_refresh_request_query_params()` is excluded + from the body so the same parameter is not sent twice (once on the URL and once in + the body). This supports OAuth providers like Gong that expect refresh parameters + on the URL query string. """ # Check if credentials are being sent via Authorization header headers = self.get_refresh_request_headers() @@ -143,6 +148,8 @@ def build_refresh_request_body(self) -> Mapping[str, Any]: # Only include client credentials in body if not already in header include_client_credentials = not credentials_in_header + query_params = self.get_refresh_request_params() or {} + payload: MutableMapping[str, Any] = { self.get_grant_type_name(): self.get_grant_type(), } @@ -163,6 +170,11 @@ def build_refresh_request_body(self) -> Mapping[str, Any]: if key not in payload: payload[key] = val + # Strip any keys that are also being sent as URL query parameters so they are + # not duplicated across the URL and the body. The query params take precedence. + for key in query_params: + payload.pop(key, None) + return payload def build_refresh_request_headers(self) -> Mapping[str, Any] | None: @@ -173,6 +185,18 @@ def build_refresh_request_headers(self) -> Mapping[str, Any] | None: headers = self.get_refresh_request_headers() return headers if headers else None + def build_refresh_request_query_params(self) -> Mapping[str, Any] | None: + """ + Returns the URL query string parameters to set on the refresh request. + + OAuth providers like Gong document their refresh endpoint as a `POST` with the + refresh parameters on the URL query string. Authenticators may override + `get_refresh_request_params()` to opt into this behavior. When set, any matching + keys are removed from the request body by `build_refresh_request_body()`. + """ + params = self.get_refresh_request_params() + return params if params else None + def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]: """ Returns the refresh token and its expiration datetime @@ -275,6 +299,7 @@ def _make_handled_request(self) -> Any: url=self.get_token_refresh_endpoint(), # type: ignore # returns None, if not provided, but str | bytes is expected. data=self.build_refresh_request_body(), headers=self.build_refresh_request_headers(), + params=self.build_refresh_request_query_params(), ) if not response.ok: @@ -555,6 +580,16 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: """Returns the request headers to set on the refresh request""" + def get_refresh_request_params(self) -> Mapping[str, Any]: + """Returns the URL query string parameters to set on the refresh request. + + Defaults to an empty mapping so existing authenticators retain their previous + behavior (no query params on the refresh URL). Subclasses can override this to + send refresh parameters on the URL query string, which is required by OAuth + providers like Gong. + """ + return {} + @abstractmethod def get_grant_type(self) -> str: """Returns grant_type specified for requesting access_token""" 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 a32845a6e4..67cabc8a70 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -45,6 +45,7 @@ def __init__( expires_in_name: str = "expires_in", refresh_request_body: Mapping[str, Any] | None = None, refresh_request_headers: Mapping[str, Any] | None = None, + refresh_request_params: Mapping[str, Any] | None = None, grant_type_name: str = "grant_type", grant_type: str = "refresh_token", token_expiry_is_time_of_expiration: bool = False, @@ -64,6 +65,7 @@ def __init__( self._expires_in_name = expires_in_name self._refresh_request_body = refresh_request_body self._refresh_request_headers = refresh_request_headers + self._refresh_request_params = refresh_request_params self._grant_type_name = grant_type_name self._grant_type = grant_type @@ -111,6 +113,9 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: return self._refresh_request_headers # type: ignore[return-value] + def get_refresh_request_params(self) -> Mapping[str, Any]: + return self._refresh_request_params or {} + def get_grant_type_name(self) -> str: return self._grant_type_name @@ -160,6 +165,7 @@ def __init__( refresh_token_name: str = "refresh_token", refresh_request_body: Mapping[str, Any] | None = None, refresh_request_headers: Mapping[str, Any] | None = None, + refresh_request_params: Mapping[str, Any] | None = None, grant_type_name: str = "grant_type", grant_type: str = "refresh_token", client_id_name: str = "client_id", @@ -186,6 +192,7 @@ def __init__( refresh_token_name (str, optional): Name of the name of the refresh token field, used to parse the refresh token response. Defaults to "refresh_token". refresh_request_body (Mapping[str, Any], optional): Custom key value pair that will be added to the refresh token request body. Defaults to None. refresh_request_headers (Mapping[str, Any], optional): Custom key value pair that will be added to the refresh token request headers. Defaults to None. + refresh_request_params (Mapping[str, Any], optional): Custom key value pair that will be added as URL query string parameters on the refresh request. When set, matching keys are removed from the request body so the same parameter is not sent twice. Defaults to None. grant_type (str, optional): OAuth grant type. Defaults to "refresh_token". client_id (Optional[str]): The client id to authenticate. If not specified, defaults to credentials.client_id in the config object. client_secret (Optional[str]): The client secret to authenticate. If not specified, defaults to credentials.client_secret in the config object. @@ -227,6 +234,7 @@ def __init__( expires_in_name=expires_in_name, refresh_request_body=refresh_request_body, refresh_request_headers=refresh_request_headers, + refresh_request_params=refresh_request_params, grant_type_name=self._grant_type_name, grant_type=grant_type, token_expiry_date_format=token_expiry_date_format, diff --git a/unit_tests/sources/declarative/auth/test_oauth.py b/unit_tests/sources/declarative/auth/test_oauth.py index b41a1b7f13..3dca9e3f58 100644 --- a/unit_tests/sources/declarative/auth/test_oauth.py +++ b/unit_tests/sources/declarative/auth/test_oauth.py @@ -171,6 +171,57 @@ def test_refresh_request_body_includes_credentials_when_no_authorization_header( } assert body == expected + def test_refresh_request_query_params(self): + """When `refresh_request_query_params` is provided on the declarative + OAuth authenticator, the interpolated query params are returned by + `build_refresh_request_query_params()` and matching keys are excluded + from the body. + + This matches the shape required by OAuth providers like Gong that + document their refresh endpoint with `grant_type` and `refresh_token` + on the URL query string instead of the form body. + """ + 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, + token_expiry_date="{{ config['token_expiry_date'] }}", + refresh_request_headers={ + "Authorization": "Basic {{ [config['client_id'], config['client_secret']] | join(':') | base64encode }}", + }, + refresh_request_query_params={ + "grant_type": "refresh_token", + "refresh_token": "{{ parameters['refresh_token'] }}", + }, + parameters=parameters, + grant_type="{{ config['grant_type'] }}", + ) + params = oauth.build_refresh_request_query_params() + assert params == { + "grant_type": "refresh_token", + "refresh_token": "some_refresh_token", + } + body = oauth.build_refresh_request_body() + assert "grant_type" not in body + assert "refresh_token" not in body + + def test_refresh_request_query_params_default_none(self): + """When `refresh_request_query_params` is not set, no query params + are sent on the refresh request. + """ + 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, + token_expiry_date="{{ config['token_expiry_date'] }}", + parameters=parameters, + ) + assert oauth.build_refresh_request_query_params() is None + def test_refresh_with_encode_config_params(self): oauth = DeclarativeOauth2Authenticator( token_refresh_endpoint="{{ config['refresh_endpoint'] }}", @@ -641,7 +692,7 @@ def test_error_handling(self, mocker): assert e.value.errno == 400 -def mock_request(method, url, data, headers): +def mock_request(method, url, data, headers, **kwargs): if url == "https://refresh_endpoint.com": return resp raise Exception( 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 71183b5aad..619efedbf4 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 @@ -205,6 +205,72 @@ def test_refresh_request_headers(self): headers = oauth.build_refresh_request_headers() assert headers is None + def test_refresh_request_query_params(self): + """When `refresh_request_params` is set, the params are returned by + `build_refresh_request_query_params()` and the matching keys are + excluded from the body. + """ + query_params = { + "grant_type": "refresh_token", + "refresh_token": "some_refresh_token", + } + oauth = Oauth2Authenticator( + token_refresh_endpoint="refresh_end", + client_id="some_client_id", + client_secret="some_client_secret", + refresh_token="some_refresh_token", + refresh_request_params=query_params, + refresh_request_headers={"Authorization": "Basic dGVzdA=="}, + ) + built_params = oauth.build_refresh_request_query_params() + assert built_params == query_params + + body = oauth.build_refresh_request_body() + assert "grant_type" not in body + assert "refresh_token" not in body + + def test_refresh_request_query_params_none_returns_none(self): + """When no `refresh_request_params` is set, query params should be None.""" + oauth = Oauth2Authenticator( + token_refresh_endpoint="refresh_end", + client_id="some_client_id", + client_secret="some_client_secret", + refresh_token="some_refresh_token", + ) + assert oauth.build_refresh_request_query_params() is None + + def test_refresh_request_query_params_sent_on_request(self, mocker): + """Verify that URL query params are sent via `params=` kwarg on the + HTTP request when `refresh_request_params` is configured. + """ + query_params = { + "grant_type": "refresh_token", + "refresh_token": "some_refresh_token", + "validity_duration": "600", + } + oauth = Oauth2Authenticator( + token_refresh_endpoint="https://refresh_endpoint.com", + client_id="some_client_id", + client_secret="some_client_secret", + refresh_token="some_refresh_token", + refresh_request_params=query_params, + refresh_request_headers={"Authorization": "Basic dGVzdA=="}, + ) + + resp.status_code = 200 + mocker.patch.object( + resp, "json", return_value={"access_token": "access_token", "expires_in": 1000} + ) + mocked_request = mocker.patch.object( + requests, "request", side_effect=mock_request, autospec=True + ) + token, _ = oauth.refresh_access_token() + assert token == "access_token" + assert mocked_request.call_args.kwargs["params"] == query_params + body = mocked_request.call_args.kwargs["data"] + assert "grant_type" not in body + assert "refresh_token" not in body + def test_refresh_request_body_with_keys_override(self): """ Request body should match given configuration. @@ -781,7 +847,7 @@ def test_refresh_access_token(self, mocker, connector_config): ) -def mock_request(method, url, data, headers): +def mock_request(method, url, data, headers, **kwargs): if url == "https://refresh_endpoint.com": return resp raise Exception( From e145d0bde35faec1bd54ef1570d0232375cab071 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 23:28:59 +0000 Subject: [PATCH 2/6] fix: substitute live refresh_token into query params on every refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `build_refresh_request_query_params()` returned the static mapping provided at construction time. For `SingleUseRefreshTokenOauth2Authenticator` this meant the rotated refresh_token was persisted to config and emitted as a control message, but the next refresh request still carried the original (now-invalidated) token in the URL query string, breaking long-running syncs. `build_refresh_request_body()` already avoids this trap by reading from the dynamic getters on every call. Apply the same pattern here for the refresh token field specifically — other custom params (e.g. user-overridden `grant_type`) are still passed through unchanged. Regression test exercises two back-to-back refreshes against `SingleUseRefreshTokenOauth2Authenticator` and asserts the URL on the second request carries the rotated refresh token. Co-Authored-By: bot_apk --- .../requests_native_auth/abstract_oauth.py | 19 ++++++- .../test_requests_native_auth.py | 56 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) 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 9ff0274f4e..5489039634 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 @@ -193,9 +193,26 @@ def build_refresh_request_query_params(self) -> Mapping[str, Any] | None: refresh parameters on the URL query string. Authenticators may override `get_refresh_request_params()` to opt into this behavior. When set, any matching keys are removed from the request body by `build_refresh_request_body()`. + + If the configured params contain the refresh-token field, the live value from + `get_refresh_token()` is substituted on every call. This matters for + `SingleUseRefreshTokenOauth2Authenticator` (and any other authenticator that + rotates its refresh token between calls): without this substitution, a static + mapping baked in at construction time would carry the original refresh token + on every request, breaking rotation. Other fields are passed through unchanged + so users can still set values like `grant_type` independently. """ params = self.get_refresh_request_params() - return params if params else None + if not params: + return None + + result: MutableMapping[str, Any] = dict(params) + + refresh_token_field_name = self.get_refresh_token_name() + if refresh_token_field_name in result: + result[refresh_token_field_name] = self.get_refresh_token() + + return result def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]: """ 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 619efedbf4..31c22ae638 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 @@ -846,6 +846,62 @@ def test_refresh_access_token(self, mocker, connector_config): "new_refresh_token", ) + def test_refresh_request_query_params_pick_up_rotated_refresh_token( + self, mocker, connector_config + ): + """`SingleUseRefreshTokenOauth2Authenticator` rotates its refresh token + on every refresh. When `refresh_request_params` is configured to send + the refresh token on the URL query string, the URL on the second + refresh request must carry the **rotated** refresh token (the one + returned by the previous refresh), not the original value from the + connector config at construction time. + """ + original_refresh_token = connector_config["credentials"]["refresh_token"] + rotated_refresh_token = "rotated_refresh_token_value" + + query_params = { + "grant_type": "refresh_token", + "refresh_token": original_refresh_token, + } + 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_request_params=query_params, + refresh_request_headers={"Authorization": "Basic dGVzdA=="}, + ) + + resp.status_code = 200 + mocker.patch.object( + resp, + "json", + return_value={ + authenticator.get_access_token_name(): "new_access_token", + authenticator.get_expires_in_name(): 3600, + authenticator.get_refresh_token_name(): rotated_refresh_token, + }, + ) + mocked_request = mocker.patch.object( + requests, "request", side_effect=mock_request, autospec=True + ) + + authenticator.refresh_and_set_access_token() + first_call_params = mocked_request.call_args.kwargs["params"] + assert first_call_params["refresh_token"] == original_refresh_token + assert connector_config["credentials"]["refresh_token"] == rotated_refresh_token, ( + "config should have been mutated to carry the rotated refresh token" + ) + + authenticator.refresh_and_set_access_token() + second_call_params = mocked_request.call_args.kwargs["params"] + assert second_call_params["refresh_token"] == rotated_refresh_token, ( + "second refresh must send the rotated refresh token on the URL, " + "not the stale value baked in at construction time" + ) + second_call_body = mocked_request.call_args.kwargs["data"] + assert "refresh_token" not in second_call_body + def mock_request(method, url, data, headers, **kwargs): if url == "https://refresh_endpoint.com": From bc784863ffc9b94e370d1e89bc903cc65709f64f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:42:09 +0000 Subject: [PATCH 3/6] refactor: rename refresh_request_params kwarg to refresh_request_query_params Per review feedback, rename the Python-level kwarg on the OAuth authenticators to match the manifest field name. The kwarg was added in this PR with a None default, so this is a non-breaking rename: - Oauth2Authenticator and SingleUseRefreshTokenOauth2Authenticator constructor kwarg refresh_request_params -> refresh_request_query_params - get_refresh_request_params() -> get_refresh_request_query_params() - Instance attribute _refresh_request_params -> _refresh_request_query_params - Factory and tests updated to match Co-Authored-By: bot_apk --- airbyte_cdk/sources/declarative/auth/oauth.py | 2 +- .../parsers/model_to_component_factory.py | 2 +- .../http/requests_native_auth/abstract_oauth.py | 10 +++++----- .../streams/http/requests_native_auth/oauth.py | 14 +++++++------- .../test_requests_native_auth.py | 14 +++++++------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/airbyte_cdk/sources/declarative/auth/oauth.py b/airbyte_cdk/sources/declarative/auth/oauth.py index 41e2a793eb..42f0fba1e7 100644 --- a/airbyte_cdk/sources/declarative/auth/oauth.py +++ b/airbyte_cdk/sources/declarative/auth/oauth.py @@ -252,7 +252,7 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: return self._refresh_request_headers.eval(self.config) - def get_refresh_request_params(self) -> Mapping[str, Any]: + def get_refresh_request_query_params(self) -> Mapping[str, Any]: return self._refresh_request_query_params.eval(self.config) def get_token_expiry_date(self) -> AirbyteDateTime: 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 3c47621f69..09658f4051 100644 --- a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +++ b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py @@ -2872,7 +2872,7 @@ def create_oauth_authenticator( refresh_request_headers=InterpolatedMapping( model.refresh_request_headers or {}, parameters=model.parameters or {} ).eval(config), - refresh_request_params=InterpolatedMapping( + refresh_request_query_params=InterpolatedMapping( model.refresh_request_query_params or {}, parameters=model.parameters or {} ).eval(config), scopes=model.scopes, 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 5489039634..6e7af23919 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 @@ -148,7 +148,7 @@ def build_refresh_request_body(self) -> Mapping[str, Any]: # Only include client credentials in body if not already in header include_client_credentials = not credentials_in_header - query_params = self.get_refresh_request_params() or {} + query_params = self.get_refresh_request_query_params() or {} payload: MutableMapping[str, Any] = { self.get_grant_type_name(): self.get_grant_type(), @@ -191,8 +191,8 @@ def build_refresh_request_query_params(self) -> Mapping[str, Any] | None: OAuth providers like Gong document their refresh endpoint as a `POST` with the refresh parameters on the URL query string. Authenticators may override - `get_refresh_request_params()` to opt into this behavior. When set, any matching - keys are removed from the request body by `build_refresh_request_body()`. + `get_refresh_request_query_params()` to opt into this behavior. When set, any + matching keys are removed from the request body by `build_refresh_request_body()`. If the configured params contain the refresh-token field, the live value from `get_refresh_token()` is substituted on every call. This matters for @@ -202,7 +202,7 @@ def build_refresh_request_query_params(self) -> Mapping[str, Any] | None: on every request, breaking rotation. Other fields are passed through unchanged so users can still set values like `grant_type` independently. """ - params = self.get_refresh_request_params() + params = self.get_refresh_request_query_params() if not params: return None @@ -597,7 +597,7 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: """Returns the request headers to set on the refresh request""" - def get_refresh_request_params(self) -> Mapping[str, Any]: + def get_refresh_request_query_params(self) -> Mapping[str, Any]: """Returns the URL query string parameters to set on the refresh request. Defaults to an empty mapping so existing authenticators retain their previous 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 67cabc8a70..f329e8ebc5 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -45,7 +45,7 @@ def __init__( expires_in_name: str = "expires_in", refresh_request_body: Mapping[str, Any] | None = None, refresh_request_headers: Mapping[str, Any] | None = None, - refresh_request_params: Mapping[str, Any] | None = None, + refresh_request_query_params: Mapping[str, Any] | None = None, grant_type_name: str = "grant_type", grant_type: str = "refresh_token", token_expiry_is_time_of_expiration: bool = False, @@ -65,7 +65,7 @@ def __init__( self._expires_in_name = expires_in_name self._refresh_request_body = refresh_request_body self._refresh_request_headers = refresh_request_headers - self._refresh_request_params = refresh_request_params + self._refresh_request_query_params = refresh_request_query_params self._grant_type_name = grant_type_name self._grant_type = grant_type @@ -113,8 +113,8 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: return self._refresh_request_headers # type: ignore[return-value] - def get_refresh_request_params(self) -> Mapping[str, Any]: - return self._refresh_request_params or {} + def get_refresh_request_query_params(self) -> Mapping[str, Any]: + return self._refresh_request_query_params or {} def get_grant_type_name(self) -> str: return self._grant_type_name @@ -165,7 +165,7 @@ def __init__( refresh_token_name: str = "refresh_token", refresh_request_body: Mapping[str, Any] | None = None, refresh_request_headers: Mapping[str, Any] | None = None, - refresh_request_params: Mapping[str, Any] | None = None, + refresh_request_query_params: Mapping[str, Any] | None = None, grant_type_name: str = "grant_type", grant_type: str = "refresh_token", client_id_name: str = "client_id", @@ -192,7 +192,7 @@ def __init__( refresh_token_name (str, optional): Name of the name of the refresh token field, used to parse the refresh token response. Defaults to "refresh_token". refresh_request_body (Mapping[str, Any], optional): Custom key value pair that will be added to the refresh token request body. Defaults to None. refresh_request_headers (Mapping[str, Any], optional): Custom key value pair that will be added to the refresh token request headers. Defaults to None. - refresh_request_params (Mapping[str, Any], optional): Custom key value pair that will be added as URL query string parameters on the refresh request. When set, matching keys are removed from the request body so the same parameter is not sent twice. Defaults to None. + refresh_request_query_params (Mapping[str, Any], optional): Custom key value pair that will be added as URL query string parameters on the refresh request. When set, matching keys are removed from the request body so the same parameter is not sent twice. Defaults to None. grant_type (str, optional): OAuth grant type. Defaults to "refresh_token". client_id (Optional[str]): The client id to authenticate. If not specified, defaults to credentials.client_id in the config object. client_secret (Optional[str]): The client secret to authenticate. If not specified, defaults to credentials.client_secret in the config object. @@ -234,7 +234,7 @@ def __init__( expires_in_name=expires_in_name, refresh_request_body=refresh_request_body, refresh_request_headers=refresh_request_headers, - refresh_request_params=refresh_request_params, + refresh_request_query_params=refresh_request_query_params, grant_type_name=self._grant_type_name, grant_type=grant_type, token_expiry_date_format=token_expiry_date_format, 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 31c22ae638..e0cd6faef7 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 @@ -206,7 +206,7 @@ def test_refresh_request_headers(self): assert headers is None def test_refresh_request_query_params(self): - """When `refresh_request_params` is set, the params are returned by + """When `refresh_request_query_params` is set, the params are returned by `build_refresh_request_query_params()` and the matching keys are excluded from the body. """ @@ -219,7 +219,7 @@ def test_refresh_request_query_params(self): client_id="some_client_id", client_secret="some_client_secret", refresh_token="some_refresh_token", - refresh_request_params=query_params, + refresh_request_query_params=query_params, refresh_request_headers={"Authorization": "Basic dGVzdA=="}, ) built_params = oauth.build_refresh_request_query_params() @@ -230,7 +230,7 @@ def test_refresh_request_query_params(self): assert "refresh_token" not in body def test_refresh_request_query_params_none_returns_none(self): - """When no `refresh_request_params` is set, query params should be None.""" + """When no `refresh_request_query_params` is set, query params should be None.""" oauth = Oauth2Authenticator( token_refresh_endpoint="refresh_end", client_id="some_client_id", @@ -241,7 +241,7 @@ def test_refresh_request_query_params_none_returns_none(self): def test_refresh_request_query_params_sent_on_request(self, mocker): """Verify that URL query params are sent via `params=` kwarg on the - HTTP request when `refresh_request_params` is configured. + HTTP request when `refresh_request_query_params` is configured. """ query_params = { "grant_type": "refresh_token", @@ -253,7 +253,7 @@ def test_refresh_request_query_params_sent_on_request(self, mocker): client_id="some_client_id", client_secret="some_client_secret", refresh_token="some_refresh_token", - refresh_request_params=query_params, + refresh_request_query_params=query_params, refresh_request_headers={"Authorization": "Basic dGVzdA=="}, ) @@ -850,7 +850,7 @@ def test_refresh_request_query_params_pick_up_rotated_refresh_token( self, mocker, connector_config ): """`SingleUseRefreshTokenOauth2Authenticator` rotates its refresh token - on every refresh. When `refresh_request_params` is configured to send + on every refresh. When `refresh_request_query_params` is configured to send the refresh token on the URL query string, the URL on the second refresh request must carry the **rotated** refresh token (the one returned by the previous refresh), not the original value from the @@ -868,7 +868,7 @@ def test_refresh_request_query_params_pick_up_rotated_refresh_token( token_refresh_endpoint="https://refresh_endpoint.com", client_id=connector_config["credentials"]["client_id"], client_secret=connector_config["credentials"]["client_secret"], - refresh_request_params=query_params, + refresh_request_query_params=query_params, refresh_request_headers={"Authorization": "Basic dGVzdA=="}, ) From 53cbd43eddefdf3d3a73c6a76a814d5483c4a61c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:01:19 +0000 Subject: [PATCH 4/6] refactor: replace refresh_request_query_params field with send_refresh_request_as_query_params boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer feedback (https://github.com/airbytehq/airbyte-python-cdk/pull/1041), collapse the user API to a single boolean toggle: - DROP `refresh_request_query_params: Mapping[str, Any]` (kwarg + declarative field). The Mapping forced users to manually re-author standard OAuth refresh args with interpolation strings and made wire shape data-driven instead of policy-driven. - ADD `send_refresh_request_as_query_params: bool = False`. When `True`, the standard refresh args (`grant_type`, `refresh_token`, client credentials unless an `Authorization` header is set, scopes, plus any `refresh_request_body` extras) are emitted on the URL query string and the request body is empty. When `False` (default), behavior is unchanged. Implementation: shared `_build_standard_refresh_args()` helper used by both `build_refresh_request_body()` and `build_refresh_request_query_params()`, gated by `should_send_refresh_request_as_query_params()`. Rotation persistence (the bug the previous design exposed) is now automatic because the body builder already pulls from live getters (`get_refresh_token()`, `get_grant_type()`, etc.) — no separate fix needed. Co-Authored-By: bot_apk --- airbyte_cdk/sources/declarative/auth/oauth.py | 12 +- .../declarative_component_schema.yaml | 13 +-- .../models/declarative_component_schema.py | 15 +-- .../parsers/model_to_component_factory.py | 10 +- .../requests_native_auth/abstract_oauth.py | 110 ++++++++---------- .../http/requests_native_auth/oauth.py | 14 +-- .../sources/declarative/auth/test_oauth.py | 30 +++-- .../test_requests_native_auth.py | 72 ++++++------ 8 files changed, 125 insertions(+), 151 deletions(-) diff --git a/airbyte_cdk/sources/declarative/auth/oauth.py b/airbyte_cdk/sources/declarative/auth/oauth.py index 42f0fba1e7..9f4826edeb 100644 --- a/airbyte_cdk/sources/declarative/auth/oauth.py +++ b/airbyte_cdk/sources/declarative/auth/oauth.py @@ -44,7 +44,7 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut 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 refresh_request_body (Optional[Mapping[str, Any]]): The request body to send in the refresh request refresh_request_headers (Optional[Mapping[str, Any]]): The request headers to send in the refresh request - refresh_request_query_params (Optional[Mapping[str, Any]]): URL query string parameters to send on the refresh request + send_refresh_request_as_query_params (bool): When True, the standard refresh args (`grant_type`, `refresh_token`, client credentials when not in an `Authorization` header, scopes, plus any `refresh_request_body` extras) are sent on the URL query string instead of in the request body, and the body is emitted empty. Use this for OAuth providers like Gong that document their refresh endpoint with refresh args on the URL query string. Defaults to False. grant_type: The grant_type to request for access_token. If set to refresh_token, the refresh_token parameter has to be provided message_repository (MessageRepository): the message repository used to emit logs on HTTP requests refresh_token_error_status_codes (Tuple[int, ...]): Status codes to identify refresh token errors in response @@ -71,7 +71,7 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut refresh_token_name: Union[InterpolatedString, str] = "refresh_token" refresh_request_body: Optional[Mapping[str, Any]] = None refresh_request_headers: Optional[Mapping[str, Any]] = None - refresh_request_query_params: Optional[Mapping[str, Any]] = None + send_refresh_request_as_query_params: bool = False grant_type_name: Union[InterpolatedString, str] = "grant_type" grant_type: Union[InterpolatedString, str] = "refresh_token" message_repository: MessageRepository = NoopMessageRepository() @@ -137,9 +137,7 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: self._refresh_request_headers = InterpolatedMapping( self.refresh_request_headers or {}, parameters=parameters ) - self._refresh_request_query_params = InterpolatedMapping( - self.refresh_request_query_params or {}, parameters=parameters - ) + self._send_refresh_request_as_query_params = self.send_refresh_request_as_query_params try: if ( isinstance(self.token_expiry_date, (int, str)) @@ -252,8 +250,8 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: return self._refresh_request_headers.eval(self.config) - def get_refresh_request_query_params(self) -> Mapping[str, Any]: - return self._refresh_request_query_params.eval(self.config) + def should_send_refresh_request_as_query_params(self) -> bool: + return self._send_refresh_request_as_query_params def get_token_expiry_date(self) -> AirbyteDateTime: if not self._has_access_token_been_initialized(): diff --git a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml index b6806fe78e..b78f098830 100644 --- a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml +++ b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml @@ -1434,14 +1434,13 @@ definitions: examples: - Authorization: "" Content-Type: "application/x-www-form-urlencoded" - refresh_request_query_params: - title: Refresh Request Query Parameters - description: URL query string parameters of the request sent to get a new access token. When set, any matching keys are removed from the request body so the same parameter is not sent twice. Use this for OAuth providers like Gong that document their refresh endpoint with `grant_type` and `refresh_token` on the URL query string instead of the form body. - type: object - additionalProperties: true + send_refresh_request_as_query_params: + title: Send Refresh Request As Query Params + description: When set to true, the standard OAuth refresh args (`grant_type`, `refresh_token`, client credentials when not in an `Authorization` header, scopes, plus any `refresh_request_body` extras) are sent on the URL query string and the request body is emitted empty. Use this for OAuth providers like Gong that document their refresh endpoint with refresh args on the URL query string. + type: boolean + default: false examples: - - grant_type: "refresh_token" - refresh_token: "{{ config['credentials']['refresh_token'] }}" + - true scopes: title: Scopes description: List of scopes that should be granted to the access token. diff --git a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py index bbc472ca39..4c22d38df3 100644 --- a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py +++ b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py @@ -1939,16 +1939,11 @@ class OAuthAuthenticator(BaseModel): ], title="Refresh Request Headers", ) - refresh_request_query_params: Optional[Dict[str, Any]] = Field( - None, - description="URL query string parameters of the request sent to get a new access token. When set, any matching keys are removed from the request body so the same parameter is not sent twice. Use this for OAuth providers like Gong that document their refresh endpoint with `grant_type` and `refresh_token` on the URL query string instead of the form body.", - examples=[ - { - "grant_type": "refresh_token", - "refresh_token": "{{ config['credentials']['refresh_token'] }}", - } - ], - title="Refresh Request Query Parameters", + send_refresh_request_as_query_params: Optional[bool] = Field( + False, + description="When set to true, the standard OAuth refresh args (`grant_type`, `refresh_token`, client credentials when not in an `Authorization` header, scopes, plus any `refresh_request_body` extras) are sent on the URL query string and the request body is emitted empty. Use this for OAuth providers like Gong that document their refresh endpoint with refresh args on the URL query string.", + examples=[True], + title="Send Refresh Request As Query Params", ) scopes: Optional[List[str]] = Field( None, 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 09658f4051..9f16bad613 100644 --- a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +++ b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py @@ -2872,9 +2872,9 @@ def create_oauth_authenticator( refresh_request_headers=InterpolatedMapping( model.refresh_request_headers or {}, parameters=model.parameters or {} ).eval(config), - refresh_request_query_params=InterpolatedMapping( - model.refresh_request_query_params or {}, parameters=model.parameters or {} - ).eval(config), + send_refresh_request_as_query_params=bool( + model.send_refresh_request_as_query_params + ), scopes=model.scopes, token_expiry_date_format=model.token_expiry_date_format, token_expiry_is_time_of_expiration=bool(model.token_expiry_date_format), @@ -2896,7 +2896,9 @@ def create_oauth_authenticator( grant_type=model.grant_type or "refresh_token", refresh_request_body=model.refresh_request_body, refresh_request_headers=model.refresh_request_headers, - refresh_request_query_params=model.refresh_request_query_params, + send_refresh_request_as_query_params=bool( + model.send_refresh_request_as_query_params + ), refresh_token_name=model.refresh_token_name or "refresh_token", refresh_token=model.refresh_token, scopes=model.scopes, 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 6e7af23919..55f5a93533 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 @@ -125,57 +125,58 @@ def token_has_expired(self) -> bool: """Returns True if the token is expired""" return ab_datetime_now() > self.get_token_expiry_date() - def build_refresh_request_body(self) -> Mapping[str, Any]: - """ - Returns the request body to set on the refresh request. + def _build_standard_refresh_args(self) -> MutableMapping[str, Any]: + """Build the standard OAuth refresh args (grant_type, refresh_token, client + credentials, scopes, plus any user-configured `refresh_request_body` extras). - Override to define additional parameters. + Used by both `build_refresh_request_body()` and + `build_refresh_request_query_params()` so the same set of args can be emitted + in either the body or the URL query string depending on + `should_send_refresh_request_as_query_params()`. - Client credentials (client_id and client_secret) are excluded from the body when - refresh_request_headers contains an Authorization header (e.g., Basic auth). - This is required by OAuth providers like Gong that expect credentials ONLY in the - Authorization header and reject requests that include them in both places. - - Any key that is also present in `build_refresh_request_query_params()` is excluded - from the body so the same parameter is not sent twice (once on the URL and once in - the body). This supports OAuth providers like Gong that expect refresh parameters - on the URL query string. + Client credentials (client_id and client_secret) are excluded when + `refresh_request_headers` contains an `Authorization` header (e.g. Basic + auth). This is required by OAuth providers like Gong that expect credentials + ONLY in the Authorization header and reject requests that include them in + both places. """ - # Check if credentials are being sent via Authorization header headers = self.get_refresh_request_headers() credentials_in_header = headers and "Authorization" in headers - - # Only include client credentials in body if not already in header include_client_credentials = not credentials_in_header - query_params = self.get_refresh_request_query_params() or {} - - payload: MutableMapping[str, Any] = { + args: MutableMapping[str, Any] = { self.get_grant_type_name(): self.get_grant_type(), } - # Only include client credentials in body if configured to do so and not in header if include_client_credentials: - payload[self.get_client_id_name()] = self.get_client_id() - payload[self.get_client_secret_name()] = self.get_client_secret() + args[self.get_client_id_name()] = self.get_client_id() + args[self.get_client_secret_name()] = self.get_client_secret() - payload[self.get_refresh_token_name()] = self.get_refresh_token() + args[self.get_refresh_token_name()] = self.get_refresh_token() if self.get_scopes(): - payload["scopes"] = self.get_scopes() + args["scopes"] = self.get_scopes() if self.get_refresh_request_body(): for key, val in self.get_refresh_request_body().items(): - # We defer to existing oauth constructs over custom configured fields - if key not in payload: - payload[key] = val + # Existing oauth args take precedence over custom configured fields. + if key not in args: + args[key] = val + + return args - # Strip any keys that are also being sent as URL query parameters so they are - # not duplicated across the URL and the body. The query params take precedence. - for key in query_params: - payload.pop(key, None) + def build_refresh_request_body(self) -> Mapping[str, Any]: + """Returns the request body to set on the refresh request. - return payload + When `should_send_refresh_request_as_query_params()` is `True`, the standard + refresh args are emitted on the URL query string instead and this method + returns an empty body. This supports OAuth providers like Gong that document + their refresh endpoint as a `POST` with parameters on the URL query string + and an empty body. + """ + if self.should_send_refresh_request_as_query_params(): + return {} + return self._build_standard_refresh_args() def build_refresh_request_headers(self) -> Mapping[str, Any] | None: """ @@ -186,33 +187,19 @@ def build_refresh_request_headers(self) -> Mapping[str, Any] | None: return headers if headers else None def build_refresh_request_query_params(self) -> Mapping[str, Any] | None: - """ - Returns the URL query string parameters to set on the refresh request. + """Returns the URL query string parameters to set on the refresh request. - OAuth providers like Gong document their refresh endpoint as a `POST` with the - refresh parameters on the URL query string. Authenticators may override - `get_refresh_request_query_params()` to opt into this behavior. When set, any - matching keys are removed from the request body by `build_refresh_request_body()`. + When `should_send_refresh_request_as_query_params()` is `True`, the standard + refresh args (grant_type, refresh_token, client credentials, scopes, plus + any user-configured `refresh_request_body` extras) are returned here and + `build_refresh_request_body()` returns an empty body. - If the configured params contain the refresh-token field, the live value from - `get_refresh_token()` is substituted on every call. This matters for - `SingleUseRefreshTokenOauth2Authenticator` (and any other authenticator that - rotates its refresh token between calls): without this substitution, a static - mapping baked in at construction time would carry the original refresh token - on every request, breaking rotation. Other fields are passed through unchanged - so users can still set values like `grant_type` independently. + Returns `None` otherwise so existing authenticators retain their previous + behavior (no query params on the refresh URL). """ - params = self.get_refresh_request_query_params() - if not params: + if not self.should_send_refresh_request_as_query_params(): return None - - result: MutableMapping[str, Any] = dict(params) - - refresh_token_field_name = self.get_refresh_token_name() - if refresh_token_field_name in result: - result[refresh_token_field_name] = self.get_refresh_token() - - return result + return self._build_standard_refresh_args() def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]: """ @@ -597,15 +584,16 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: """Returns the request headers to set on the refresh request""" - def get_refresh_request_query_params(self) -> Mapping[str, Any]: - """Returns the URL query string parameters to set on the refresh request. + def should_send_refresh_request_as_query_params(self) -> bool: + """Returns `True` if the standard refresh args should be sent on the URL + query string instead of in the request body. - Defaults to an empty mapping so existing authenticators retain their previous - behavior (no query params on the refresh URL). Subclasses can override this to - send refresh parameters on the URL query string, which is required by OAuth + Defaults to `False` so existing authenticators retain their previous + behavior (params in body, no query params on the refresh URL). Subclasses + can override this to opt into the URL-query-string shape required by OAuth providers like Gong. """ - return {} + return False @abstractmethod def get_grant_type(self) -> str: 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 f329e8ebc5..485fa9df95 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -45,7 +45,7 @@ def __init__( expires_in_name: str = "expires_in", refresh_request_body: Mapping[str, Any] | None = None, refresh_request_headers: Mapping[str, Any] | None = None, - refresh_request_query_params: Mapping[str, Any] | None = None, + send_refresh_request_as_query_params: bool = False, grant_type_name: str = "grant_type", grant_type: str = "refresh_token", token_expiry_is_time_of_expiration: bool = False, @@ -65,7 +65,7 @@ def __init__( self._expires_in_name = expires_in_name self._refresh_request_body = refresh_request_body self._refresh_request_headers = refresh_request_headers - self._refresh_request_query_params = refresh_request_query_params + self._send_refresh_request_as_query_params = send_refresh_request_as_query_params self._grant_type_name = grant_type_name self._grant_type = grant_type @@ -113,8 +113,8 @@ def get_refresh_request_body(self) -> Mapping[str, Any]: def get_refresh_request_headers(self) -> Mapping[str, Any]: return self._refresh_request_headers # type: ignore[return-value] - def get_refresh_request_query_params(self) -> Mapping[str, Any]: - return self._refresh_request_query_params or {} + def should_send_refresh_request_as_query_params(self) -> bool: + return self._send_refresh_request_as_query_params def get_grant_type_name(self) -> str: return self._grant_type_name @@ -165,7 +165,7 @@ def __init__( refresh_token_name: str = "refresh_token", refresh_request_body: Mapping[str, Any] | None = None, refresh_request_headers: Mapping[str, Any] | None = None, - refresh_request_query_params: Mapping[str, Any] | None = None, + send_refresh_request_as_query_params: bool = False, grant_type_name: str = "grant_type", grant_type: str = "refresh_token", client_id_name: str = "client_id", @@ -192,7 +192,7 @@ def __init__( refresh_token_name (str, optional): Name of the name of the refresh token field, used to parse the refresh token response. Defaults to "refresh_token". refresh_request_body (Mapping[str, Any], optional): Custom key value pair that will be added to the refresh token request body. Defaults to None. refresh_request_headers (Mapping[str, Any], optional): Custom key value pair that will be added to the refresh token request headers. Defaults to None. - refresh_request_query_params (Mapping[str, Any], optional): Custom key value pair that will be added as URL query string parameters on the refresh request. When set, matching keys are removed from the request body so the same parameter is not sent twice. Defaults to None. + send_refresh_request_as_query_params (bool, optional): When True, the standard refresh args (`grant_type`, `refresh_token`, client credentials when not in an `Authorization` header, scopes, plus any `refresh_request_body` extras) are sent on the URL query string and the request body is emitted empty. Use this for OAuth providers like Gong that document their refresh endpoint with refresh args on the URL query string. Defaults to False. grant_type (str, optional): OAuth grant type. Defaults to "refresh_token". client_id (Optional[str]): The client id to authenticate. If not specified, defaults to credentials.client_id in the config object. client_secret (Optional[str]): The client secret to authenticate. If not specified, defaults to credentials.client_secret in the config object. @@ -234,7 +234,7 @@ def __init__( expires_in_name=expires_in_name, refresh_request_body=refresh_request_body, refresh_request_headers=refresh_request_headers, - refresh_request_query_params=refresh_request_query_params, + send_refresh_request_as_query_params=send_refresh_request_as_query_params, grant_type_name=self._grant_type_name, grant_type=grant_type, token_expiry_date_format=token_expiry_date_format, diff --git a/unit_tests/sources/declarative/auth/test_oauth.py b/unit_tests/sources/declarative/auth/test_oauth.py index 3dca9e3f58..906390c90f 100644 --- a/unit_tests/sources/declarative/auth/test_oauth.py +++ b/unit_tests/sources/declarative/auth/test_oauth.py @@ -171,15 +171,15 @@ def test_refresh_request_body_includes_credentials_when_no_authorization_header( } assert body == expected - def test_refresh_request_query_params(self): - """When `refresh_request_query_params` is provided on the declarative - OAuth authenticator, the interpolated query params are returned by - `build_refresh_request_query_params()` and matching keys are excluded - from the body. + def test_send_refresh_request_as_query_params(self): + """When `send_refresh_request_as_query_params=True` on the declarative + OAuth authenticator, the standard refresh args are returned by + `build_refresh_request_query_params()` and `build_refresh_request_body()` + returns an empty body. This matches the shape required by OAuth providers like Gong that - document their refresh endpoint with `grant_type` and `refresh_token` - on the URL query string instead of the form body. + document their refresh endpoint with refresh args on the URL query + string instead of the form body. """ oauth = DeclarativeOauth2Authenticator( token_refresh_endpoint="{{ config['refresh_endpoint'] }}", @@ -191,12 +191,9 @@ def test_refresh_request_query_params(self): refresh_request_headers={ "Authorization": "Basic {{ [config['client_id'], config['client_secret']] | join(':') | base64encode }}", }, - refresh_request_query_params={ - "grant_type": "refresh_token", - "refresh_token": "{{ parameters['refresh_token'] }}", - }, + send_refresh_request_as_query_params=True, parameters=parameters, - grant_type="{{ config['grant_type'] }}", + grant_type="refresh_token", ) params = oauth.build_refresh_request_query_params() assert params == { @@ -204,12 +201,11 @@ def test_refresh_request_query_params(self): "refresh_token": "some_refresh_token", } body = oauth.build_refresh_request_body() - assert "grant_type" not in body - assert "refresh_token" not in body + assert body == {} - def test_refresh_request_query_params_default_none(self): - """When `refresh_request_query_params` is not set, no query params - are sent on the refresh request. + def test_send_refresh_request_as_query_params_default_false(self): + """When `send_refresh_request_as_query_params` is not set, no query + params are sent on the refresh request. """ oauth = DeclarativeOauth2Authenticator( token_refresh_endpoint="{{ config['refresh_endpoint'] }}", 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 e0cd6faef7..f1d5cc0fb4 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 @@ -205,32 +205,33 @@ def test_refresh_request_headers(self): headers = oauth.build_refresh_request_headers() assert headers is None - def test_refresh_request_query_params(self): - """When `refresh_request_query_params` is set, the params are returned by - `build_refresh_request_query_params()` and the matching keys are - excluded from the body. + def test_send_refresh_request_as_query_params(self): + """When `send_refresh_request_as_query_params=True`, the standard + refresh args are returned by `build_refresh_request_query_params()` + and `build_refresh_request_body()` returns an empty body. """ - query_params = { - "grant_type": "refresh_token", - "refresh_token": "some_refresh_token", - } oauth = Oauth2Authenticator( token_refresh_endpoint="refresh_end", client_id="some_client_id", client_secret="some_client_secret", refresh_token="some_refresh_token", - refresh_request_query_params=query_params, + send_refresh_request_as_query_params=True, refresh_request_headers={"Authorization": "Basic dGVzdA=="}, ) - built_params = oauth.build_refresh_request_query_params() - assert built_params == query_params + params = oauth.build_refresh_request_query_params() + assert params == { + "grant_type": "refresh_token", + "refresh_token": "some_refresh_token", + } body = oauth.build_refresh_request_body() - assert "grant_type" not in body - assert "refresh_token" not in body + assert body == {} - def test_refresh_request_query_params_none_returns_none(self): - """When no `refresh_request_query_params` is set, query params should be None.""" + def test_send_refresh_request_as_query_params_default_false(self): + """When `send_refresh_request_as_query_params` is not set (default `False`), + no query params are sent on the refresh URL and the standard refresh + args remain in the body. + """ oauth = Oauth2Authenticator( token_refresh_endpoint="refresh_end", client_id="some_client_id", @@ -238,22 +239,21 @@ def test_refresh_request_query_params_none_returns_none(self): refresh_token="some_refresh_token", ) assert oauth.build_refresh_request_query_params() is None + body = oauth.build_refresh_request_body() + assert body["grant_type"] == "refresh_token" + assert body["refresh_token"] == "some_refresh_token" - def test_refresh_request_query_params_sent_on_request(self, mocker): + def test_send_refresh_request_as_query_params_sent_on_request(self, mocker): """Verify that URL query params are sent via `params=` kwarg on the - HTTP request when `refresh_request_query_params` is configured. + HTTP request when `send_refresh_request_as_query_params=True`, and the + body is emitted empty. """ - query_params = { - "grant_type": "refresh_token", - "refresh_token": "some_refresh_token", - "validity_duration": "600", - } oauth = Oauth2Authenticator( token_refresh_endpoint="https://refresh_endpoint.com", client_id="some_client_id", client_secret="some_client_secret", refresh_token="some_refresh_token", - refresh_request_query_params=query_params, + send_refresh_request_as_query_params=True, refresh_request_headers={"Authorization": "Basic dGVzdA=="}, ) @@ -266,10 +266,11 @@ def test_refresh_request_query_params_sent_on_request(self, mocker): ) token, _ = oauth.refresh_access_token() assert token == "access_token" - assert mocked_request.call_args.kwargs["params"] == query_params - body = mocked_request.call_args.kwargs["data"] - assert "grant_type" not in body - assert "refresh_token" not in body + assert mocked_request.call_args.kwargs["params"] == { + "grant_type": "refresh_token", + "refresh_token": "some_refresh_token", + } + assert mocked_request.call_args.kwargs["data"] == {} def test_refresh_request_body_with_keys_override(self): """ @@ -846,29 +847,24 @@ def test_refresh_access_token(self, mocker, connector_config): "new_refresh_token", ) - def test_refresh_request_query_params_pick_up_rotated_refresh_token( + def test_send_refresh_request_as_query_params_picks_up_rotated_refresh_token( self, mocker, connector_config ): """`SingleUseRefreshTokenOauth2Authenticator` rotates its refresh token - on every refresh. When `refresh_request_query_params` is configured to send - the refresh token on the URL query string, the URL on the second - refresh request must carry the **rotated** refresh token (the one - returned by the previous refresh), not the original value from the - connector config at construction time. + on every refresh. When `send_refresh_request_as_query_params=True`, the + URL on the second refresh request must carry the **rotated** refresh + token (the one returned by the previous refresh), not the original + value from the connector config at construction time. """ original_refresh_token = connector_config["credentials"]["refresh_token"] rotated_refresh_token = "rotated_refresh_token_value" - query_params = { - "grant_type": "refresh_token", - "refresh_token": original_refresh_token, - } 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_request_query_params=query_params, + send_refresh_request_as_query_params=True, refresh_request_headers={"Authorization": "Basic dGVzdA=="}, ) From 63b16500cdaad43da18a9dece981c7b15af79120 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:02:20 +0000 Subject: [PATCH 5/6] style: ruff format Co-Authored-By: bot_apk --- .../sources/declarative/parsers/model_to_component_factory.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 9f16bad613..82798e02e2 100644 --- a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +++ b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py @@ -2896,9 +2896,7 @@ def create_oauth_authenticator( grant_type=model.grant_type or "refresh_token", refresh_request_body=model.refresh_request_body, refresh_request_headers=model.refresh_request_headers, - send_refresh_request_as_query_params=bool( - model.send_refresh_request_as_query_params - ), + send_refresh_request_as_query_params=bool(model.send_refresh_request_as_query_params), refresh_token_name=model.refresh_token_name or "refresh_token", refresh_token=model.refresh_token, scopes=model.scopes, From 947e145110982740d220da4b8bd2fe3961f67273 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:01:27 +0000 Subject: [PATCH 6/6] style: keep payload variable name in _build_standard_refresh_args Reverts an incidental variable rename (payload -> args) so the diff against main stays minimal in the body-building logic. The helper itself is named _build_standard_refresh_args, but the local variable that historically held the body dict stays named payload, matching the convention in the rest of this file. Co-Authored-By: bot_apk --- .../http/requests_native_auth/abstract_oauth.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 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 55f5a93533..7ebb429b76 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 @@ -144,26 +144,26 @@ def _build_standard_refresh_args(self) -> MutableMapping[str, Any]: credentials_in_header = headers and "Authorization" in headers include_client_credentials = not credentials_in_header - args: MutableMapping[str, Any] = { + payload: MutableMapping[str, Any] = { self.get_grant_type_name(): self.get_grant_type(), } if include_client_credentials: - args[self.get_client_id_name()] = self.get_client_id() - args[self.get_client_secret_name()] = self.get_client_secret() + payload[self.get_client_id_name()] = self.get_client_id() + payload[self.get_client_secret_name()] = self.get_client_secret() - args[self.get_refresh_token_name()] = self.get_refresh_token() + payload[self.get_refresh_token_name()] = self.get_refresh_token() if self.get_scopes(): - args["scopes"] = self.get_scopes() + payload["scopes"] = self.get_scopes() if self.get_refresh_request_body(): for key, val in self.get_refresh_request_body().items(): # Existing oauth args take precedence over custom configured fields. - if key not in args: - args[key] = val + if key not in payload: + payload[key] = val - return args + return payload def build_refresh_request_body(self) -> Mapping[str, Any]: """Returns the request body to set on the refresh request.