Skip to content

Commit d85cb1c

Browse files
fix(oauth): exclude client credentials from body when Authorization header is present and _emit_control_message (#877)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: aldo.gonzalez@airbyte.io <aldo.gonzalez@airbyte.io>
1 parent 1f981a1 commit d85cb1c

File tree

3 files changed

+90
-19
lines changed

3 files changed

+90
-19
lines changed

airbyte_cdk/sources/streams/http/requests_native_auth/abstract_oauth.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,33 @@ def token_has_expired(self) -> bool:
100100

101101
def build_refresh_request_body(self) -> Mapping[str, Any]:
102102
"""
103-
Returns the request body to set on the refresh request
103+
Returns the request body to set on the refresh request.
104104
105-
Override to define additional parameters
105+
Override to define additional parameters.
106+
107+
Client credentials (client_id and client_secret) are excluded from the body when
108+
refresh_request_headers contains an Authorization header (e.g., Basic auth).
109+
This is required by OAuth providers like Gong that expect credentials ONLY in the
110+
Authorization header and reject requests that include them in both places.
106111
"""
112+
# Check if credentials are being sent via Authorization header
113+
headers = self.get_refresh_request_headers()
114+
credentials_in_header = headers and "Authorization" in headers
115+
116+
# Only include client credentials in body if not already in header
117+
include_client_credentials = not credentials_in_header
118+
107119
payload: MutableMapping[str, Any] = {
108120
self.get_grant_type_name(): self.get_grant_type(),
109-
self.get_client_id_name(): self.get_client_id(),
110-
self.get_client_secret_name(): self.get_client_secret(),
111-
self.get_refresh_token_name(): self.get_refresh_token(),
112121
}
113122

123+
# Only include client credentials in body if configured to do so and not in header
124+
if include_client_credentials:
125+
payload[self.get_client_id_name()] = self.get_client_id()
126+
payload[self.get_client_secret_name()] = self.get_client_secret()
127+
128+
payload[self.get_refresh_token_name()] = self.get_refresh_token()
129+
114130
if self.get_scopes():
115131
payload["scopes"] = self.get_scopes()
116132

airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -383,27 +383,24 @@ def _emit_control_message(self) -> None:
383383
"""
384384
Emits a control message based on the connector configuration.
385385
386-
This method checks if the message repository is not a NoopMessageRepository.
387-
If it is not, it emits a message using the message repository. Otherwise,
388-
it falls back to emitting the configuration as an Airbyte control message
389-
directly to the console for backward compatibility.
386+
Control messages for config updates (like refreshed tokens) must be printed directly
387+
to stdout so the platform can process them immediately. The message repository is
388+
also used to queue the message for any additional processing.
390389
391390
Note:
392-
The function `emit_configuration_as_airbyte_control_message` has been deprecated
393-
in favor of the package `airbyte_cdk.sources.message`.
394-
395-
Raises:
396-
TypeError: If the argument types are incorrect.
391+
The function `emit_configuration_as_airbyte_control_message` prints directly to
392+
stdout, which is required for the platform to detect and persist config changes.
397393
"""
398-
# FIXME emit_configuration_as_airbyte_control_message as been deprecated in favor of package airbyte_cdk.sources.message
399-
# Usually, a class shouldn't care about the implementation details but to keep backward compatibility where we print the
400-
# message directly in the console, this is needed
394+
# Always emit to stdout so the platform can process the config update immediately.
395+
# This is critical for single-use refresh tokens where the new token must be persisted
396+
# before subsequent operations try to use the old (now invalid) token.
397+
emit_configuration_as_airbyte_control_message(self._connector_config) # type: ignore[arg-type]
398+
399+
# Also emit to the message repository for any additional processing (e.g., logging)
401400
if not isinstance(self._message_repository, NoopMessageRepository):
402401
self._message_repository.emit_message(
403402
create_connector_config_control_message(self._connector_config) # type: ignore[arg-type]
404403
)
405-
else:
406-
emit_configuration_as_airbyte_control_message(self._connector_config) # type: ignore[arg-type]
407404

408405
@property
409406
def _message_repository(self) -> MessageRepository:

unit_tests/sources/declarative/auth/test_oauth.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,64 @@ def test_refresh_request_headers(self):
111111
headers = oauth.build_refresh_request_headers()
112112
assert headers is None
113113

114+
def test_refresh_request_body_excludes_credentials_when_authorization_header_present(self):
115+
"""
116+
When refresh_request_headers contains an Authorization header (e.g., Basic auth),
117+
client_id and client_secret should be excluded from the request body.
118+
119+
This is required by OAuth providers like Gong that expect credentials ONLY in the
120+
Authorization header and reject requests that include them in both places.
121+
"""
122+
oauth = DeclarativeOauth2Authenticator(
123+
token_refresh_endpoint="{{ config['refresh_endpoint'] }}",
124+
client_id="{{ config['client_id'] }}",
125+
client_secret="{{ config['client_secret'] }}",
126+
refresh_token="{{ parameters['refresh_token'] }}",
127+
config=config,
128+
token_expiry_date="{{ config['token_expiry_date'] }}",
129+
refresh_request_headers={
130+
"Authorization": "Basic {{ [config['client_id'], config['client_secret']] | join(':') | base64encode }}",
131+
"Content-Type": "application/x-www-form-urlencoded",
132+
},
133+
parameters=parameters,
134+
grant_type="{{ config['grant_type'] }}",
135+
)
136+
body = oauth.build_refresh_request_body()
137+
expected = {
138+
"grant_type": "some_grant_type",
139+
"refresh_token": "some_refresh_token",
140+
}
141+
assert body == expected
142+
assert "client_id" not in body
143+
assert "client_secret" not in body
144+
145+
def test_refresh_request_body_includes_credentials_when_no_authorization_header(self):
146+
"""
147+
When refresh_request_headers does NOT contain an Authorization header,
148+
client_id and client_secret should be included in the request body (default behavior).
149+
"""
150+
oauth = DeclarativeOauth2Authenticator(
151+
token_refresh_endpoint="{{ config['refresh_endpoint'] }}",
152+
client_id="{{ config['client_id'] }}",
153+
client_secret="{{ config['client_secret'] }}",
154+
refresh_token="{{ parameters['refresh_token'] }}",
155+
config=config,
156+
token_expiry_date="{{ config['token_expiry_date'] }}",
157+
refresh_request_headers={
158+
"Content-Type": "application/x-www-form-urlencoded",
159+
},
160+
parameters=parameters,
161+
grant_type="{{ config['grant_type'] }}",
162+
)
163+
body = oauth.build_refresh_request_body()
164+
expected = {
165+
"grant_type": "some_grant_type",
166+
"client_id": "some_client_id",
167+
"client_secret": "some_client_secret",
168+
"refresh_token": "some_refresh_token",
169+
}
170+
assert body == expected
171+
114172
def test_refresh_with_encode_config_params(self):
115173
oauth = DeclarativeOauth2Authenticator(
116174
token_refresh_endpoint="{{ config['refresh_endpoint'] }}",

0 commit comments

Comments
 (0)