Skip to content

Commit 725e262

Browse files
aaronsteersdevin-ai-integration[bot]github-code-quality[bot]
authored
feat(cloud): Add CloudCredentials class and bearer token authentication support (#916)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent 9f3c707 commit 725e262

File tree

12 files changed

+825
-104
lines changed

12 files changed

+825
-104
lines changed

airbyte/_util/api_util.py

Lines changed: 218 additions & 79 deletions
Large diffs are not rendered by default.

airbyte/cloud/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353

5454
from typing import TYPE_CHECKING
5555

56+
from airbyte.cloud.client_config import CloudClientConfig
5657
from airbyte.cloud.connections import CloudConnection
5758
from airbyte.cloud.constants import JobStatusEnum
5859
from airbyte.cloud.sync_results import SyncResult
@@ -62,18 +63,20 @@
6263
# Submodules imported here for documentation reasons: https://github.com/mitmproxy/pdoc/issues/757
6364
if TYPE_CHECKING:
6465
# ruff: noqa: TC004
65-
from airbyte.cloud import connections, constants, sync_results, workspaces
66+
from airbyte.cloud import client_config, connections, constants, sync_results, workspaces
6667

6768

6869
__all__ = [
6970
# Submodules
7071
"workspaces",
7172
"connections",
7273
"constants",
74+
"client_config",
7375
"sync_results",
7476
# Classes
7577
"CloudWorkspace",
7678
"CloudConnection",
79+
"CloudClientConfig",
7780
"SyncResult",
7881
# Enums
7982
"JobStatusEnum",

airbyte/cloud/auth.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@
66
from airbyte.secrets.util import get_secret, try_get_secret
77

88

9+
def resolve_cloud_bearer_token(
10+
input_value: str | SecretString | None = None,
11+
/,
12+
) -> SecretString | None:
13+
"""Get the Airbyte Cloud bearer token from the environment.
14+
15+
Unlike other resolve functions, this returns None if no bearer token is found,
16+
since bearer token authentication is optional (client credentials can be used instead).
17+
18+
Args:
19+
input_value: Optional explicit bearer token value. If provided, it will be
20+
returned directly (wrapped in SecretString if needed).
21+
22+
Returns:
23+
The bearer token as a SecretString, or None if not found.
24+
"""
25+
if input_value is not None:
26+
return SecretString(input_value)
27+
28+
result = try_get_secret(constants.CLOUD_BEARER_TOKEN_ENV_VAR, default=None)
29+
if result:
30+
return SecretString(result)
31+
return None
32+
33+
934
def resolve_cloud_client_secret(
1035
input_value: str | SecretString | None = None,
1136
/,

airbyte/cloud/client_config.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Cloud client configuration for Airbyte Cloud API authentication.
3+
4+
This module provides the CloudClientConfig class for managing authentication
5+
credentials and API configuration when connecting to Airbyte Cloud, OSS, or
6+
Enterprise instances.
7+
8+
Two authentication methods are supported (mutually exclusive):
9+
1. OAuth2 client credentials (client_id + client_secret)
10+
2. Bearer token authentication
11+
12+
Example usage with client credentials:
13+
```python
14+
from airbyte.cloud.client_config import CloudClientConfig
15+
16+
config = CloudClientConfig(
17+
client_id="your-client-id",
18+
client_secret="your-client-secret",
19+
)
20+
```
21+
22+
Example usage with bearer token:
23+
```python
24+
from airbyte.cloud.client_config import CloudClientConfig
25+
26+
config = CloudClientConfig(
27+
bearer_token="your-bearer-token",
28+
)
29+
```
30+
31+
Example using environment variables:
32+
```python
33+
from airbyte.cloud.client_config import CloudClientConfig
34+
35+
# Resolves from AIRBYTE_CLOUD_CLIENT_ID, AIRBYTE_CLOUD_CLIENT_SECRET,
36+
# AIRBYTE_CLOUD_BEARER_TOKEN, and AIRBYTE_CLOUD_API_URL environment variables
37+
config = CloudClientConfig.from_env()
38+
```
39+
"""
40+
41+
from __future__ import annotations
42+
43+
from dataclasses import dataclass
44+
45+
from airbyte._util import api_util
46+
from airbyte.cloud.auth import (
47+
resolve_cloud_api_url,
48+
resolve_cloud_bearer_token,
49+
resolve_cloud_client_id,
50+
resolve_cloud_client_secret,
51+
)
52+
from airbyte.exceptions import PyAirbyteInputError
53+
from airbyte.secrets.base import SecretString
54+
55+
56+
@dataclass
57+
class CloudClientConfig:
58+
"""Client configuration for Airbyte Cloud API.
59+
60+
This class encapsulates the authentication and API configuration needed to connect
61+
to Airbyte Cloud, OSS, or Enterprise instances. It supports two mutually
62+
exclusive authentication methods:
63+
64+
1. OAuth2 client credentials flow (client_id + client_secret)
65+
2. Bearer token authentication
66+
67+
Exactly one authentication method must be provided. Providing both or neither
68+
will raise a validation error.
69+
70+
Attributes:
71+
client_id: OAuth2 client ID for client credentials flow.
72+
client_secret: OAuth2 client secret for client credentials flow.
73+
bearer_token: Pre-generated bearer token for direct authentication.
74+
api_root: The API root URL. Defaults to Airbyte Cloud API.
75+
"""
76+
77+
client_id: SecretString | None = None
78+
"""OAuth2 client ID for client credentials authentication."""
79+
80+
client_secret: SecretString | None = None
81+
"""OAuth2 client secret for client credentials authentication."""
82+
83+
bearer_token: SecretString | None = None
84+
"""Bearer token for direct authentication (alternative to client credentials)."""
85+
86+
api_root: str = api_util.CLOUD_API_ROOT
87+
"""The API root URL. Defaults to Airbyte Cloud API."""
88+
89+
def __post_init__(self) -> None:
90+
"""Validate credentials and ensure secrets are properly wrapped."""
91+
# Wrap secrets in SecretString if they aren't already
92+
if self.client_id is not None:
93+
self.client_id = SecretString(self.client_id)
94+
if self.client_secret is not None:
95+
self.client_secret = SecretString(self.client_secret)
96+
if self.bearer_token is not None:
97+
self.bearer_token = SecretString(self.bearer_token)
98+
99+
# Validate mutual exclusivity
100+
has_client_credentials = self.client_id is not None or self.client_secret is not None
101+
has_bearer_token = self.bearer_token is not None
102+
103+
if has_client_credentials and has_bearer_token:
104+
raise PyAirbyteInputError(
105+
message="Cannot use both client credentials and bearer token authentication.",
106+
guidance=(
107+
"Provide either client_id and client_secret together, "
108+
"or bearer_token alone, but not both."
109+
),
110+
)
111+
112+
if has_client_credentials and (self.client_id is None or self.client_secret is None):
113+
# If using client credentials, both must be provided
114+
raise PyAirbyteInputError(
115+
message="Incomplete client credentials.",
116+
guidance=(
117+
"When using client credentials authentication, "
118+
"both client_id and client_secret must be provided."
119+
),
120+
)
121+
122+
if not has_client_credentials and not has_bearer_token:
123+
raise PyAirbyteInputError(
124+
message="No authentication credentials provided.",
125+
guidance=(
126+
"Provide either client_id and client_secret together for OAuth2 "
127+
"client credentials flow, or bearer_token for direct authentication."
128+
),
129+
)
130+
131+
@property
132+
def uses_bearer_token(self) -> bool:
133+
"""Return True if using bearer token authentication."""
134+
return self.bearer_token is not None
135+
136+
@property
137+
def uses_client_credentials(self) -> bool:
138+
"""Return True if using client credentials authentication."""
139+
return self.client_id is not None and self.client_secret is not None
140+
141+
@classmethod
142+
def from_env(
143+
cls,
144+
*,
145+
api_root: str | None = None,
146+
) -> CloudClientConfig:
147+
"""Create CloudClientConfig from environment variables.
148+
149+
This factory method resolves credentials from environment variables,
150+
providing a convenient way to create credentials without explicitly
151+
passing secrets.
152+
153+
Environment variables used:
154+
- `AIRBYTE_CLOUD_CLIENT_ID`: OAuth client ID (for client credentials flow).
155+
- `AIRBYTE_CLOUD_CLIENT_SECRET`: OAuth client secret (for client credentials flow).
156+
- `AIRBYTE_CLOUD_BEARER_TOKEN`: Bearer token (alternative to client credentials).
157+
- `AIRBYTE_CLOUD_API_URL`: Optional. The API root URL (defaults to Airbyte Cloud).
158+
159+
The method will first check for a bearer token. If not found, it will
160+
attempt to use client credentials.
161+
162+
Args:
163+
api_root: The API root URL. If not provided, will be resolved from
164+
the `AIRBYTE_CLOUD_API_URL` environment variable, or default to
165+
the Airbyte Cloud API.
166+
167+
Returns:
168+
A CloudClientConfig instance configured with credentials from the environment.
169+
170+
Raises:
171+
PyAirbyteSecretNotFoundError: If required credentials are not found in
172+
the environment.
173+
"""
174+
resolved_api_root = resolve_cloud_api_url(api_root)
175+
176+
# Try bearer token first
177+
bearer_token = resolve_cloud_bearer_token()
178+
if bearer_token:
179+
return cls(
180+
bearer_token=bearer_token,
181+
api_root=resolved_api_root,
182+
)
183+
184+
# Fall back to client credentials
185+
return cls(
186+
client_id=resolve_cloud_client_id(),
187+
client_secret=resolve_cloud_client_secret(),
188+
api_root=resolved_api_root,
189+
)

airbyte/cloud/connections.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def _fetch_connection_info(self) -> ConnectionResponse:
6262
api_root=self.workspace.api_root,
6363
client_id=self.workspace.client_id,
6464
client_secret=self.workspace.client_secret,
65+
bearer_token=self.workspace.bearer_token,
6566
)
6667

6768
@classmethod
@@ -180,6 +181,7 @@ def run_sync(
180181
workspace_id=self.workspace.workspace_id,
181182
client_id=self.workspace.client_id,
182183
client_secret=self.workspace.client_secret,
184+
bearer_token=self.workspace.bearer_token,
183185
)
184186
sync_result = SyncResult(
185187
workspace=self.workspace,
@@ -242,6 +244,7 @@ def get_previous_sync_logs(
242244
order_by=order_by,
243245
client_id=self.workspace.client_id,
244246
client_secret=self.workspace.client_secret,
247+
bearer_token=self.workspace.bearer_token,
245248
)
246249
return [
247250
SyncResult(
@@ -298,6 +301,7 @@ def get_state_artifacts(self) -> list[dict[str, Any]] | None:
298301
api_root=self.workspace.api_root,
299302
client_id=self.workspace.client_id,
300303
client_secret=self.workspace.client_secret,
304+
bearer_token=self.workspace.bearer_token,
301305
)
302306
if state_response.get("stateType") == "not_set":
303307
return None
@@ -319,6 +323,7 @@ def get_catalog_artifact(self) -> dict[str, Any] | None:
319323
api_root=self.workspace.api_root,
320324
client_id=self.workspace.client_id,
321325
client_secret=self.workspace.client_secret,
326+
bearer_token=self.workspace.bearer_token,
322327
)
323328
return connection_response.get("syncCatalog")
324329

@@ -336,6 +341,7 @@ def rename(self, name: str) -> CloudConnection:
336341
api_root=self.workspace.api_root,
337342
client_id=self.workspace.client_id,
338343
client_secret=self.workspace.client_secret,
344+
bearer_token=self.workspace.bearer_token,
339345
name=name,
340346
)
341347
self._connection_info = updated_response
@@ -355,6 +361,7 @@ def set_table_prefix(self, prefix: str) -> CloudConnection:
355361
api_root=self.workspace.api_root,
356362
client_id=self.workspace.client_id,
357363
client_secret=self.workspace.client_secret,
364+
bearer_token=self.workspace.bearer_token,
358365
prefix=prefix,
359366
)
360367
self._connection_info = updated_response
@@ -379,6 +386,7 @@ def set_selected_streams(self, stream_names: list[str]) -> CloudConnection:
379386
api_root=self.workspace.api_root,
380387
client_id=self.workspace.client_id,
381388
client_secret=self.workspace.client_secret,
389+
bearer_token=self.workspace.bearer_token,
382390
configurations=configurations,
383391
)
384392
self._connection_info = updated_response

0 commit comments

Comments
 (0)