Skip to content

Commit 313db66

Browse files
feat(cdk): add api_token interpolation support to SessionTokenAuthenticator (#885)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 6bb6c97 commit 313db66

6 files changed

Lines changed: 152 additions & 8 deletions

File tree

airbyte_cdk/sources/declarative/auth/token_provider.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ def _refresh(self) -> None:
7171

7272
@dataclass
7373
class InterpolatedStringTokenProvider(TokenProvider):
74+
"""Provides a token by interpolating a string with config values."""
75+
7476
config: Config
7577
api_token: Union[InterpolatedString, str]
7678
parameters: Mapping[str, Any]
@@ -80,3 +82,24 @@ def __post_init__(self) -> None:
8082

8183
def get_token(self) -> str:
8284
return str(self._token.eval(self.config))
85+
86+
87+
@dataclass
88+
class InterpolatedSessionTokenProvider(TokenProvider):
89+
"""Provides a token by interpolating a template with the session token.
90+
91+
This allows flexible token formatting, such as "Token {{ session_token }}"
92+
for Django REST Framework APIs that expect "Authorization: Token <value>".
93+
"""
94+
95+
config: Config
96+
api_token: Union[InterpolatedString, str]
97+
session_token_provider: TokenProvider
98+
parameters: Mapping[str, Any]
99+
100+
def __post_init__(self) -> None:
101+
self._token_template = InterpolatedString.create(self.api_token, parameters=self.parameters)
102+
103+
def get_token(self) -> str:
104+
session_token = self.session_token_provider.get_token()
105+
return str(self._token_template.eval(self.config, session_token=session_token))

airbyte_cdk/sources/declarative/declarative_component_schema.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,6 +2074,18 @@ definitions:
20742074
field_name: Authorization
20752075
- inject_into: request_parameter
20762076
field_name: authKey
2077+
api_token:
2078+
title: API Token Template
2079+
description: 'A template for the token value to inject. Use {{ session_token }} to reference the session token. For example, use "Token {{ session_token }}" for APIs that expect "Authorization: Token <token>".'
2080+
type: string
2081+
default: "{{ session_token }}"
2082+
interpolation_context:
2083+
- config
2084+
- session_token
2085+
examples:
2086+
- "{{ session_token }}"
2087+
- "Token {{ session_token }}"
2088+
- "Bearer {{ session_token }}"
20772089
SessionTokenRequestBearerAuthenticator:
20782090
title: Bearer Authenticator
20792091
description: Authenticator for requests using the session token as a standard bearer token.

airbyte_cdk/sources/declarative/models/declarative_component_schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2057,6 +2057,16 @@ class SessionTokenRequestApiKeyAuthenticator(BaseModel):
20572057
],
20582058
title="Inject API Key Into Outgoing HTTP Request",
20592059
)
2060+
api_token: Optional[str] = Field(
2061+
"{{ session_token }}",
2062+
description='A template for the token value to inject. Use {{ session_token }} to reference the session token. For example, use "Token {{ session_token }}" for APIs that expect "Authorization: Token <token>".',
2063+
examples=[
2064+
"{{ session_token }}",
2065+
"Token {{ session_token }}",
2066+
"Bearer {{ session_token }}",
2067+
],
2068+
title="API Token Template",
2069+
)
20602070

20612071

20622072
class JsonSchemaPropertySelector(BaseModel):

airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
LegacySessionTokenAuthenticator,
6868
)
6969
from airbyte_cdk.sources.declarative.auth.token_provider import (
70+
InterpolatedSessionTokenProvider,
7071
InterpolatedStringTokenProvider,
7172
SessionTokenProvider,
7273
TokenProvider,
@@ -1169,14 +1170,24 @@ def create_session_token_authenticator(
11691170
token_provider=token_provider,
11701171
)
11711172
else:
1173+
# Get the api_token template if specified, default to just the session token
1174+
api_token_template = (
1175+
getattr(model.request_authentication, "api_token", None) or "{{ session_token }}"
1176+
)
1177+
final_token_provider: TokenProvider = InterpolatedSessionTokenProvider(
1178+
config=config,
1179+
api_token=api_token_template,
1180+
session_token_provider=token_provider,
1181+
parameters=model.parameters or {},
1182+
)
11721183
return self.create_api_key_authenticator(
11731184
ApiKeyAuthenticatorModel(
11741185
type="ApiKeyAuthenticator",
11751186
api_token="",
11761187
inject_into=model.request_authentication.inject_into,
11771188
), # type: ignore # $parameters and headers default to None
11781189
config=config,
1179-
token_provider=token_provider,
1190+
token_provider=final_token_provider,
11801191
)
11811192

11821193
@staticmethod

unit_tests/sources/declarative/auth/test_token_provider.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from isodate import parse_duration
1010

1111
from airbyte_cdk.sources.declarative.auth.token_provider import (
12+
InterpolatedSessionTokenProvider,
1213
InterpolatedStringTokenProvider,
1314
SessionTokenProvider,
1415
)
@@ -79,3 +80,29 @@ def test_session_token_provider_ignored_response():
7980
provider.login_requester.send_request.return_value = None
8081
with pytest.raises(ReadException):
8182
provider.get_token()
83+
84+
85+
@pytest.mark.parametrize(
86+
"api_token_template,expected_token",
87+
[
88+
pytest.param("Token {{ session_token }}", "Token my_token", id="token_prefix"),
89+
pytest.param("Bearer {{ session_token }}", "Bearer my_token", id="bearer_prefix"),
90+
pytest.param("{{ session_token }}", "my_token", id="just_session_token"),
91+
pytest.param("Custom-{{ session_token }}", "Custom-my_token", id="custom_prefix"),
92+
pytest.param(
93+
"realm=xyz, token={{ session_token }}",
94+
"realm=xyz, token=my_token",
95+
id="complex_format",
96+
),
97+
],
98+
)
99+
def test_interpolated_session_token_provider(api_token_template, expected_token):
100+
"""Test that InterpolatedSessionTokenProvider correctly interpolates session token."""
101+
underlying_provider = create_session_token_provider()
102+
interpolated_provider = InterpolatedSessionTokenProvider(
103+
config={"some_config": "value"},
104+
api_token=api_token_template,
105+
session_token_provider=underlying_provider,
106+
parameters={},
107+
)
108+
assert interpolated_provider.get_token() == expected_token

unit_tests/sources/declarative/parsers/test_model_to_component_factory.py

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
BearerAuthenticator,
4343
LegacySessionTokenAuthenticator,
4444
)
45-
from airbyte_cdk.sources.declarative.auth.token_provider import SessionTokenProvider
45+
from airbyte_cdk.sources.declarative.auth.token_provider import (
46+
InterpolatedSessionTokenProvider,
47+
SessionTokenProvider,
48+
)
4649
from airbyte_cdk.sources.declarative.checks import CheckStream
4750
from airbyte_cdk.sources.declarative.concurrency_level import ConcurrencyLevel
4851
from airbyte_cdk.sources.declarative.datetime.min_max_datetime import MinMaxDatetime
@@ -1841,20 +1844,78 @@ def test_create_request_with_session_authenticator():
18411844
)
18421845

18431846
assert isinstance(selector.authenticator, ApiKeyAuthenticator)
1844-
assert isinstance(selector.authenticator.token_provider, SessionTokenProvider)
1845-
assert selector.authenticator.token_provider.session_token_path == ["id"]
1846-
assert isinstance(selector.authenticator.token_provider.login_requester, HttpRequester)
1847-
assert selector.authenticator.token_provider.session_token_path == ["id"]
1847+
# Default behavior wraps with InterpolatedSessionTokenProvider using "{{ session_token }}"
1848+
assert isinstance(selector.authenticator.token_provider, InterpolatedSessionTokenProvider)
1849+
assert selector.authenticator.token_provider.api_token == "{{ session_token }}"
1850+
session_token_provider = selector.authenticator.token_provider.session_token_provider
1851+
assert isinstance(session_token_provider, SessionTokenProvider)
1852+
assert session_token_provider.session_token_path == ["id"]
1853+
assert isinstance(session_token_provider.login_requester, HttpRequester)
18481854
assert (
1849-
selector.authenticator.token_provider.login_requester._url_base.eval(input_config)
1855+
session_token_provider.login_requester._url_base.eval(input_config)
18501856
== "https://api.sendgrid.com"
18511857
)
1852-
assert selector.authenticator.token_provider.login_requester.get_request_body_json() == {
1858+
assert session_token_provider.login_requester.get_request_body_json() == {
18531859
"username": "lists",
18541860
"password": "verysecrettoken",
18551861
}
18561862

18571863

1864+
def test_create_request_with_session_authenticator_with_api_token_template():
1865+
"""Test that api_token wraps the token provider with InterpolatedSessionTokenProvider."""
1866+
content = """
1867+
requester:
1868+
type: HttpRequester
1869+
path: "/v3/marketing/lists"
1870+
$parameters:
1871+
name: 'lists'
1872+
url_base: "https://api.sendgrid.com"
1873+
authenticator:
1874+
type: SessionTokenAuthenticator
1875+
decoder:
1876+
type: JsonDecoder
1877+
expiration_duration: P10D
1878+
login_requester:
1879+
path: /session
1880+
type: HttpRequester
1881+
url_base: 'https://api.sendgrid.com'
1882+
http_method: POST
1883+
request_body_json:
1884+
password: '{{ config.apikey }}'
1885+
username: '{{ parameters.name }}'
1886+
session_token_path:
1887+
- id
1888+
request_authentication:
1889+
type: ApiKey
1890+
inject_into:
1891+
type: RequestOption
1892+
field_name: Authorization
1893+
inject_into: header
1894+
api_token: "Token {{ session_token }}"
1895+
"""
1896+
name = "name"
1897+
parsed_manifest = YamlDeclarativeSource._parse(content)
1898+
resolved_manifest = resolver.preprocess_manifest(parsed_manifest)
1899+
requester_manifest = transformer.propagate_types_and_parameters(
1900+
"", resolved_manifest["requester"], {}
1901+
)
1902+
1903+
selector = factory.create_component(
1904+
model_type=HttpRequesterModel,
1905+
component_definition=requester_manifest,
1906+
config=input_config,
1907+
name=name,
1908+
decoder=None,
1909+
)
1910+
1911+
assert isinstance(selector.authenticator, ApiKeyAuthenticator)
1912+
assert isinstance(selector.authenticator.token_provider, InterpolatedSessionTokenProvider)
1913+
assert selector.authenticator.token_provider.api_token == "Token {{ session_token }}"
1914+
assert isinstance(
1915+
selector.authenticator.token_provider.session_token_provider, SessionTokenProvider
1916+
)
1917+
1918+
18581919
def test_given_composite_error_handler_does_not_match_response_then_fallback_on_default_error_handler(
18591920
requests_mock,
18601921
):

0 commit comments

Comments
 (0)