diff --git a/airbyte_cdk/sources/declarative/auth/oauth.py b/airbyte_cdk/sources/declarative/auth/oauth.py index e1ad84e09..9035f8c73 100644 --- a/airbyte_cdk/sources/declarative/auth/oauth.py +++ b/airbyte_cdk/sources/declarative/auth/oauth.py @@ -68,6 +68,7 @@ class DeclarativeOauth2Authenticator(AbstractOauth2Authenticator, DeclarativeAut client_secret_name: Union[InterpolatedString, str] = "client_secret" expires_in_name: Union[InterpolatedString, str] = "expires_in" refresh_token_name: Union[InterpolatedString, str] = "refresh_token" + scopes_name: Union[InterpolatedString, str] = "scope" refresh_request_body: Optional[Mapping[str, Any]] = None refresh_request_headers: Optional[Mapping[str, Any]] = None grant_type_name: Union[InterpolatedString, str] = "grant_type" @@ -108,6 +109,7 @@ def __post_init__(self, parameters: Mapping[str, Any]) -> None: self._refresh_token_name = InterpolatedString.create( self.refresh_token_name, parameters=parameters ) + self._scopes_name = InterpolatedString.create(self.scopes_name, parameters=parameters) if self.refresh_token is not None: self._refresh_token: Optional[InterpolatedString] = InterpolatedString.create( self.refresh_token, parameters=parameters @@ -229,6 +231,9 @@ def get_refresh_token(self) -> Optional[str]: def get_scopes(self) -> List[str]: return self.scopes or [] + def get_scopes_name(self) -> str: + return self._scopes_name.eval(self.config) # type: ignore # eval returns a string in this context + def get_access_token_name(self) -> str: return self.access_token_name.eval(self.config) # type: ignore # eval returns a string in this context diff --git a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml index 977fff2d5..a90d5f31c 100644 --- a/airbyte_cdk/sources/declarative/declarative_component_schema.yaml +++ b/airbyte_cdk/sources/declarative/declarative_component_schema.yaml @@ -1429,6 +1429,14 @@ definitions: "crm.objects.contacts.read", "crm.schema.contacts.read", ] + scopes_name: + title: Scopes Property Name + description: The name of the property to use for scopes in the token refresh request body. Per RFC 6749, the default is "scope" (singular). + type: string + default: "scope" + examples: + - scope + - scopes token_expiry_date: title: Token Expiry Date description: The access token expiry date. diff --git a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py index 3fccc600f..4511b7f74 100644 --- a/airbyte_cdk/sources/declarative/models/declarative_component_schema.py +++ b/airbyte_cdk/sources/declarative/models/declarative_component_schema.py @@ -1,5 +1,3 @@ -# Copyright (c) 2025 Airbyte, Inc., all rights reserved. - # generated by datamodel-codegen: # filename: declarative_component_schema.yaml @@ -1898,6 +1896,12 @@ class OAuthAuthenticator(BaseModel): examples=[["crm.list.read", "crm.objects.contacts.read", "crm.schema.contacts.read"]], title="Scopes", ) + scopes_name: Optional[str] = Field( + "scope", + description='The name of the property to use for scopes in the token refresh request body. Per RFC 6749, the default is "scope" (singular).', + examples=["scope", "scopes"], + title="Scopes Property Name", + ) token_expiry_date: Optional[str] = Field( None, description="The access token expiry date.", 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 2bd7d268d..f53ef4bd3 100644 --- a/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py +++ b/airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py @@ -2818,6 +2818,9 @@ def create_oauth_authenticator( refresh_request_headers=InterpolatedMapping( model.refresh_request_headers or {}, parameters=model.parameters or {} ).eval(config), + scopes_name=InterpolatedString.create( + model.scopes_name or "scope", 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), @@ -2841,6 +2844,7 @@ def create_oauth_authenticator( refresh_request_headers=model.refresh_request_headers, refresh_token_name=model.refresh_token_name or "refresh_token", refresh_token=model.refresh_token, + scopes_name=model.scopes_name or "scope", scopes=model.scopes, token_expiry_date=model.token_expiry_date, token_expiry_date_format=model.token_expiry_date_format, 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 3b4aa9844..e9c32fe9a 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 @@ -128,7 +128,7 @@ def build_refresh_request_body(self) -> Mapping[str, Any]: payload[self.get_refresh_token_name()] = self.get_refresh_token() if self.get_scopes(): - payload["scopes"] = self.get_scopes() + payload[self.get_scopes_name()] = self.get_scopes() if self.get_refresh_request_body(): for key, val in self.get_refresh_request_body().items(): @@ -484,6 +484,10 @@ def get_refresh_token(self) -> Optional[str]: def get_scopes(self) -> List[str]: """List of requested scopes""" + @abstractmethod + def get_scopes_name(self) -> str: + """The name of the property to use for scopes in the token request""" + @abstractmethod def get_token_expiry_date(self) -> AirbyteDateTime: """Expiration date of the 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 a2932c294..b9c21527b 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -38,6 +38,7 @@ def __init__( client_id_name: str = "client_id", client_secret_name: str = "client_secret", refresh_token_name: str = "refresh_token", + scopes_name: str = "scope", scopes: List[str] | None = None, token_expiry_date: AirbyteDateTime | None = None, token_expiry_date_format: str | None = None, @@ -59,6 +60,7 @@ def __init__( self._client_id = client_id self._refresh_token_name = refresh_token_name self._refresh_token = refresh_token + self._scopes_name = scopes_name self._scopes = scopes self._access_token_name = access_token_name self._expires_in_name = expires_in_name @@ -102,6 +104,9 @@ def get_access_token_name(self) -> str: def get_scopes(self) -> list[str]: return self._scopes # type: ignore[return-value] + def get_scopes_name(self) -> str: + return self._scopes_name + def get_expires_in_name(self) -> str: return self._expires_in_name @@ -154,6 +159,7 @@ def __init__( self, connector_config: Mapping[str, Any], token_refresh_endpoint: str, + scopes_name: str = "scope", scopes: List[str] | None = None, access_token_name: str = "access_token", expires_in_name: str = "expires_in", @@ -221,6 +227,7 @@ def __init__( client_secret=self._client_secret, refresh_token=self.get_refresh_token(), refresh_token_name=self._refresh_token_name, + scopes_name=scopes_name, scopes=scopes, token_expiry_date=self.get_token_expiry_date(), access_token_name=access_token_name, diff --git a/unit_tests/sources/declarative/auth/test_oauth.py b/unit_tests/sources/declarative/auth/test_oauth.py index e5e15a035..32ac4982a 100644 --- a/unit_tests/sources/declarative/auth/test_oauth.py +++ b/unit_tests/sources/declarative/auth/test_oauth.py @@ -58,7 +58,7 @@ def test_refresh_request_body(self): refresh_request_body={ "custom_field": "{{ config['custom_field'] }}", "another_field": "{{ config['another_field'] }}", - "scopes": ["no_override"], + "scope": ["no_override"], }, parameters=parameters, grant_type="{{ config['grant_type'] }}", @@ -69,7 +69,7 @@ def test_refresh_request_body(self): "client_id": "some_client_id", "client_secret": "some_client_secret", "refresh_token": "some_refresh_token", - "scopes": scopes, + "scope": scopes, "custom_field": "in_outbound_request", "another_field": "exists_in_body", } 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 dbfc0ac86..d2a0daa1c 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 @@ -156,7 +156,7 @@ def test_refresh_request_body(self): refresh_request_body={ "custom_field": "in_outbound_request", "another_field": "exists_in_body", - "scopes": ["no_override"], + "scope": ["no_override"], }, ) body = oauth.build_refresh_request_body() @@ -165,7 +165,7 @@ def test_refresh_request_body(self): "client_id": "some_client_id", "client_secret": "some_client_secret", "refresh_token": "some_refresh_token", - "scopes": scopes, + "scope": scopes, "custom_field": "in_outbound_request", "another_field": "exists_in_body", } @@ -216,6 +216,7 @@ def test_refresh_request_body_with_keys_override(self): client_secret="some_client_secret", refresh_token_name="custom_refresh_token_key", refresh_token="some_refresh_token", + scopes_name="custom_scopes_key", scopes=["scope1", "scope2"], token_expiry_date=ab_datetime_now() + timedelta(days=3), grant_type_name="custom_grant_type", @@ -223,7 +224,7 @@ def test_refresh_request_body_with_keys_override(self): refresh_request_body={ "custom_field": "in_outbound_request", "another_field": "exists_in_body", - "scopes": ["no_override"], + "custom_scopes_key": ["no_override"], }, ) body = oauth.build_refresh_request_body() @@ -232,7 +233,7 @@ def test_refresh_request_body_with_keys_override(self): "custom_client_id_key": "some_client_id", "custom_client_secret_key": "some_client_secret", "custom_refresh_token_key": "some_refresh_token", - "scopes": scopes, + "custom_scopes_key": scopes, "custom_field": "in_outbound_request", "another_field": "exists_in_body", }