diff --git a/airbyte_cdk/sources/declarative/auth/oauth.py b/airbyte_cdk/sources/declarative/auth/oauth.py index e1ad84e09e..9f4826edeb 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 + 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 @@ -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 + 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() @@ -135,6 +137,7 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: self._refresh_request_headers = InterpolatedMapping( self.refresh_request_headers 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)) @@ -247,6 +250,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 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(): 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..b78f098830 100644 --- a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml +++ b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml @@ -1434,6 +1434,13 @@ definitions: examples: - Authorization: "" Content-Type: "application/x-www-form-urlencoded" + 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: + - 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 f14c04da40..4c22d38df3 100644 --- a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py +++ b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py @@ -1939,6 +1939,12 @@ class OAuthAuthenticator(BaseModel): ], title="Refresh Request Headers", ) + 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, 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..82798e02e2 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), + 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), @@ -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, + 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 111583a637..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 @@ -125,29 +125,29 @@ 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. + 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 payload: 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() @@ -159,12 +159,25 @@ def build_refresh_request_body(self) -> Mapping[str, Any]: 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 + # Existing oauth args take precedence over custom configured fields. if key not in payload: payload[key] = val return payload + def build_refresh_request_body(self) -> Mapping[str, Any]: + """Returns the request body to set on the refresh request. + + 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: """ Returns the request headers to set on the refresh request @@ -173,6 +186,21 @@ 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. + + 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. + + Returns `None` otherwise so existing authenticators retain their previous + behavior (no query params on the refresh URL). + """ + if not self.should_send_refresh_request_as_query_params(): + return None + return self._build_standard_refresh_args() + def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]: """ Returns the refresh token and its expiration datetime @@ -275,6 +303,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 +584,17 @@ 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 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 `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 False + @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..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,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, + 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, @@ -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._send_refresh_request_as_query_params = send_refresh_request_as_query_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 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 @@ -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, + 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", @@ -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. + 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. @@ -227,6 +234,7 @@ def __init__( expires_in_name=expires_in_name, refresh_request_body=refresh_request_body, refresh_request_headers=refresh_request_headers, + 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 b41a1b7f13..906390c90f 100644 --- a/unit_tests/sources/declarative/auth/test_oauth.py +++ b/unit_tests/sources/declarative/auth/test_oauth.py @@ -171,6 +171,53 @@ def test_refresh_request_body_includes_credentials_when_no_authorization_header( } assert body == expected + 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 refresh args 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 }}", + }, + send_refresh_request_as_query_params=True, + parameters=parameters, + grant_type="refresh_token", + ) + 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 body == {} + + 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'] }}", + 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 +688,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..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,6 +205,73 @@ def test_refresh_request_headers(self): headers = oauth.build_refresh_request_headers() assert headers is None + 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. + """ + oauth = Oauth2Authenticator( + token_refresh_endpoint="refresh_end", + client_id="some_client_id", + client_secret="some_client_secret", + refresh_token="some_refresh_token", + send_refresh_request_as_query_params=True, + refresh_request_headers={"Authorization": "Basic dGVzdA=="}, + ) + 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 body == {} + + 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", + client_secret="some_client_secret", + 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_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 `send_refresh_request_as_query_params=True`, and the + body is emitted empty. + """ + oauth = Oauth2Authenticator( + token_refresh_endpoint="https://refresh_endpoint.com", + client_id="some_client_id", + client_secret="some_client_secret", + refresh_token="some_refresh_token", + send_refresh_request_as_query_params=True, + 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"] == { + "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): """ Request body should match given configuration. @@ -780,8 +847,59 @@ def test_refresh_access_token(self, mocker, connector_config): "new_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 `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" + + 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"], + send_refresh_request_as_query_params=True, + 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): +def mock_request(method, url, data, headers, **kwargs): if url == "https://refresh_endpoint.com": return resp raise Exception(