Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions airbyte_cdk/sources/declarative/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1434,6 +1434,13 @@ definitions:
examples:
- Authorization: "<AUTH_TOKEN>"
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
49 changes: 48 additions & 1 deletion unit_tests/sources/declarative/auth/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }}",
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading