airbyte-agent-hubspot 0.15.20__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- airbyte_agent_hubspot/__init__.py +86 -0
- airbyte_agent_hubspot/_vendored/__init__.py +1 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/__init__.py +82 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/auth_strategies.py +1123 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/auth_template.py +135 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/cloud_utils/client.py +213 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/connector_model_loader.py +957 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/constants.py +78 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/exceptions.py +23 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/executor/__init__.py +31 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/executor/hosted_executor.py +197 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/executor/local_executor.py +1504 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/executor/models.py +190 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/extensions.py +655 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/__init__.py +37 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/config.py +98 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/exceptions.py +119 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/protocols.py +114 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http/response.py +102 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/http_client.py +679 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/logging/__init__.py +11 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/logging/logger.py +264 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/logging/types.py +92 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/observability/__init__.py +11 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/observability/models.py +19 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/observability/redactor.py +81 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/observability/session.py +94 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/performance/__init__.py +6 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/performance/instrumentation.py +57 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/performance/metrics.py +93 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/__init__.py +75 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/base.py +161 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/components.py +238 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/connector.py +131 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/extensions.py +109 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/operations.py +146 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/schema/security.py +213 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/secrets.py +182 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/telemetry/__init__.py +10 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/telemetry/config.py +32 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/telemetry/events.py +58 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/telemetry/tracker.py +151 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/types.py +241 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/utils.py +60 -0
- airbyte_agent_hubspot/_vendored/connector_sdk/validation.py +822 -0
- airbyte_agent_hubspot/connector.py +1104 -0
- airbyte_agent_hubspot/connector_model.py +2660 -0
- airbyte_agent_hubspot/models.py +438 -0
- airbyte_agent_hubspot/types.py +217 -0
- airbyte_agent_hubspot-0.15.20.dist-info/METADATA +105 -0
- airbyte_agent_hubspot-0.15.20.dist-info/RECORD +55 -0
- airbyte_agent_hubspot-0.15.20.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
"""Authentication strategy pattern implementation for HTTP client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import logging
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from jinja2 import Template
|
|
13
|
+
|
|
14
|
+
from .secrets import SecretStr
|
|
15
|
+
|
|
16
|
+
from .exceptions import AuthenticationError
|
|
17
|
+
from .types import AuthType
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_secret_value(value: SecretStr | str | None) -> str:
|
|
26
|
+
"""Extract the actual value from SecretStr or return plain string.
|
|
27
|
+
|
|
28
|
+
This utility function handles the common pattern of extracting secret values
|
|
29
|
+
that can be either SecretStr (wrapped) or plain str values.
|
|
30
|
+
|
|
31
|
+
Note:
|
|
32
|
+
Accepts None and returns empty string for convenience when accessing
|
|
33
|
+
optional TypedDict fields. This avoids repetitive None checks in callers
|
|
34
|
+
when building headers/bodies where missing optional fields should use "".
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
value: A SecretStr, plain string, or None
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The unwrapped string value, or empty string if None
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
>>> extract_secret_value(SecretStr("my_secret"))
|
|
44
|
+
'my_secret'
|
|
45
|
+
>>> extract_secret_value("plain_value")
|
|
46
|
+
'plain_value'
|
|
47
|
+
>>> extract_secret_value(None)
|
|
48
|
+
''
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(value, SecretStr):
|
|
51
|
+
return value.get_secret_value()
|
|
52
|
+
return str(value) if value else ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# TypedDict definitions for auth strategy configurations and secrets
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class APIKeyAuthConfig(TypedDict, total=False):
|
|
59
|
+
"""Configuration for API key authentication.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
header: Header name to use (default: "Authorization")
|
|
63
|
+
prefix: Prefix for the header value (default: "Bearer")
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
header: str
|
|
67
|
+
prefix: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class APIKeyAuthSecrets(TypedDict):
|
|
71
|
+
"""Required secrets for API key authentication.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
api_key: The API key credential
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
api_key: SecretStr | str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BearerAuthConfig(TypedDict, total=False):
|
|
81
|
+
"""Configuration for Bearer token authentication.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
header: Header name to use (default: "Authorization")
|
|
85
|
+
prefix: Prefix for the header value (default: "Bearer")
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
header: str
|
|
89
|
+
prefix: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BearerAuthSecrets(TypedDict, total=False):
|
|
93
|
+
"""Required secrets for Bearer authentication.
|
|
94
|
+
|
|
95
|
+
Attributes:
|
|
96
|
+
token: The bearer token (can be SecretStr or plain str, will be converted as needed)
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
token: SecretStr | str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BasicAuthSecrets(TypedDict):
|
|
103
|
+
"""Required secrets for HTTP Basic authentication.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
username: The username credential
|
|
107
|
+
password: The password credential
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
username: SecretStr | str
|
|
111
|
+
password: SecretStr | str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Type aliases for OAuth2 configuration options
|
|
115
|
+
AuthStyle = Literal["basic", "body", "none"]
|
|
116
|
+
BodyFormat = Literal["form", "json"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class OAuth2AuthConfig(TypedDict, total=False):
|
|
120
|
+
"""Configuration for OAuth 2.0 authentication.
|
|
121
|
+
|
|
122
|
+
All fields are optional with sensible defaults. Used to customize OAuth2
|
|
123
|
+
authentication behavior for different APIs.
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
header: Header name to use (default: "Authorization")
|
|
127
|
+
Example: "X-OAuth-Token" for custom header names
|
|
128
|
+
|
|
129
|
+
prefix: Prefix for the header value (default: "Bearer")
|
|
130
|
+
Example: "Token" for APIs that use "Token {access_token}"
|
|
131
|
+
|
|
132
|
+
refresh_url: Token refresh endpoint URL (supports Jinja2 {{templates}})
|
|
133
|
+
Required for token refresh functionality.
|
|
134
|
+
Example: "https://{{subdomain}}.zendesk.com/oauth/tokens"
|
|
135
|
+
If template variables are used but not provided, they render as empty strings.
|
|
136
|
+
|
|
137
|
+
auth_style: How to send client credentials during token refresh
|
|
138
|
+
- "basic": client_id:client_secret in Basic Auth header (RFC 6749 compliant)
|
|
139
|
+
- "body": credentials in request body (default, widely supported)
|
|
140
|
+
- "none": no client credentials sent (public clients)
|
|
141
|
+
Default: "body"
|
|
142
|
+
|
|
143
|
+
body_format: Request body encoding for token refresh
|
|
144
|
+
- "form": application/x-www-form-urlencoded (default, RFC 6749 standard)
|
|
145
|
+
- "json": application/json (some APIs prefer this)
|
|
146
|
+
Default: "form"
|
|
147
|
+
|
|
148
|
+
subdomain: Template variable for multi-tenant APIs (e.g., Zendesk)
|
|
149
|
+
Used in refresh_url templates like "https://{{subdomain}}.example.com"
|
|
150
|
+
If not provided and used in template, renders as empty string.
|
|
151
|
+
|
|
152
|
+
Note: Any config key can be used as a template variable in refresh_url.
|
|
153
|
+
Common patterns: subdomain (Zendesk), shop (Shopify), region (AWS-style APIs).
|
|
154
|
+
|
|
155
|
+
Examples:
|
|
156
|
+
GitHub (simple):
|
|
157
|
+
{"header": "Authorization", "prefix": "Bearer"}
|
|
158
|
+
|
|
159
|
+
Zendesk (with subdomain):
|
|
160
|
+
{
|
|
161
|
+
"refresh_url": "https://{{subdomain}}.zendesk.com/oauth/tokens",
|
|
162
|
+
"subdomain": "mycompany",
|
|
163
|
+
"auth_style": "body"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
Custom API (JSON body, basic auth):
|
|
167
|
+
{
|
|
168
|
+
"refresh_url": "https://api.example.com/token",
|
|
169
|
+
"auth_style": "basic",
|
|
170
|
+
"body_format": "json"
|
|
171
|
+
}
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
header: str
|
|
175
|
+
prefix: str
|
|
176
|
+
refresh_url: str
|
|
177
|
+
auth_style: AuthStyle
|
|
178
|
+
body_format: BodyFormat
|
|
179
|
+
subdomain: str
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class OAuth2AuthSecrets(TypedDict):
|
|
183
|
+
"""Required secrets for OAuth 2.0 authentication.
|
|
184
|
+
|
|
185
|
+
Minimum secrets needed to make authenticated requests. The access_token
|
|
186
|
+
is the only required field for basic OAuth2 authentication.
|
|
187
|
+
|
|
188
|
+
Attributes:
|
|
189
|
+
access_token: The OAuth2 access token (REQUIRED)
|
|
190
|
+
This is the credential used to authenticate API requests.
|
|
191
|
+
Can be either a SecretStr (recommended) or plain string.
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
Basic usage with string:
|
|
195
|
+
{"access_token": "gho_abc123xyz..."}
|
|
196
|
+
|
|
197
|
+
Secure usage with SecretStr:
|
|
198
|
+
{"access_token": SecretStr("gho_abc123xyz...")}
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
access_token: SecretStr | str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class OAuth2RefreshSecrets(OAuth2AuthSecrets, total=False):
|
|
205
|
+
"""Extended OAuth2 secrets including optional refresh-related fields.
|
|
206
|
+
|
|
207
|
+
Inherits the required access_token from OAuth2AuthSecrets and adds
|
|
208
|
+
optional fields needed for automatic token refresh when access_token expires.
|
|
209
|
+
|
|
210
|
+
Note on typing:
|
|
211
|
+
This class uses `total=False` which makes all fields defined HERE optional.
|
|
212
|
+
The inherited `access_token` from OAuth2AuthSecrets remains REQUIRED.
|
|
213
|
+
Optional fields may be absent from the dict entirely (checked via `.get()`).
|
|
214
|
+
TypedDict is a static typing construct only - runtime validation uses `.get()`.
|
|
215
|
+
|
|
216
|
+
Token refresh will be attempted automatically on 401 errors if:
|
|
217
|
+
1. refresh_token is provided
|
|
218
|
+
2. refresh_url is configured in OAuth2AuthConfig
|
|
219
|
+
3. The API returns a 401 Unauthorized response
|
|
220
|
+
|
|
221
|
+
Attributes:
|
|
222
|
+
refresh_token (optional): Token used to obtain new access_token.
|
|
223
|
+
Required for automatic token refresh functionality.
|
|
224
|
+
Some OAuth2 flows (e.g., client_credentials) don't provide this.
|
|
225
|
+
|
|
226
|
+
client_id (optional): OAuth2 client ID for refresh requests.
|
|
227
|
+
Required for most token refresh requests.
|
|
228
|
+
How it's sent depends on auth_style config.
|
|
229
|
+
|
|
230
|
+
client_secret (optional): OAuth2 client secret for refresh requests.
|
|
231
|
+
Required for confidential clients.
|
|
232
|
+
Public clients (mobile apps, SPAs) may not have this.
|
|
233
|
+
|
|
234
|
+
token_type (optional): Token type, defaults to "Bearer".
|
|
235
|
+
Usually "Bearer" per RFC 6750.
|
|
236
|
+
Some APIs use different types like "Token" or "MAC".
|
|
237
|
+
|
|
238
|
+
Examples:
|
|
239
|
+
Full refresh capability:
|
|
240
|
+
{
|
|
241
|
+
"access_token": "eyJhbGc...",
|
|
242
|
+
"refresh_token": "def502...",
|
|
243
|
+
"client_id": "my_client_id",
|
|
244
|
+
"client_secret": SecretStr("my_secret"),
|
|
245
|
+
"token_type": "Bearer"
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Public client (no secret):
|
|
249
|
+
{
|
|
250
|
+
"access_token": "eyJhbGc...",
|
|
251
|
+
"refresh_token": "def502...",
|
|
252
|
+
"client_id": "mobile_app_id"
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
No refresh (access_token only):
|
|
256
|
+
{
|
|
257
|
+
"access_token": "long_lived_token"
|
|
258
|
+
}
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
refresh_token: SecretStr | str
|
|
262
|
+
client_id: SecretStr | str
|
|
263
|
+
client_secret: SecretStr | str
|
|
264
|
+
token_type: str
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class TokenRefreshResult:
|
|
269
|
+
"""Result of an OAuth2 token refresh operation.
|
|
270
|
+
|
|
271
|
+
Attributes:
|
|
272
|
+
tokens: Dictionary containing access_token, refresh_token, token_type
|
|
273
|
+
extracted_values: Optional dictionary of values extracted from token response
|
|
274
|
+
for server variable substitution (e.g., instance_url for Salesforce)
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
tokens: dict[str, str]
|
|
278
|
+
extracted_values: dict[str, str] | None = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class AuthStrategy(ABC):
|
|
282
|
+
"""Abstract base class for authentication strategies."""
|
|
283
|
+
|
|
284
|
+
@abstractmethod
|
|
285
|
+
def inject_auth(
|
|
286
|
+
self,
|
|
287
|
+
headers: dict[str, str],
|
|
288
|
+
config: dict[str, Any],
|
|
289
|
+
secrets: dict[str, SecretStr | str],
|
|
290
|
+
) -> dict[str, str]:
|
|
291
|
+
"""
|
|
292
|
+
Inject authentication credentials into request headers.
|
|
293
|
+
|
|
294
|
+
This method creates a copy of the headers dict and adds authentication.
|
|
295
|
+
The original headers dict is not modified.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
headers: Existing request headers (will not be modified)
|
|
299
|
+
config: Authentication configuration from AuthConfig.config
|
|
300
|
+
secrets: Secret credentials dictionary (SecretStr or plain str values)
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
New headers dictionary with authentication injected (original unchanged)
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
AuthenticationError: If required credentials are missing
|
|
307
|
+
"""
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
@abstractmethod
|
|
311
|
+
def validate_credentials(self, secrets: dict[str, SecretStr | str]) -> None:
|
|
312
|
+
"""
|
|
313
|
+
Validate that required credentials are present.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
secrets: Secret credentials dictionary with SecretStr values
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
AuthenticationError: If required credentials are missing
|
|
320
|
+
"""
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
async def handle_auth_error(
|
|
324
|
+
self,
|
|
325
|
+
status_code: int,
|
|
326
|
+
config: dict[str, Any],
|
|
327
|
+
secrets: dict[str, Any],
|
|
328
|
+
config_values: dict[str, str] | None = None,
|
|
329
|
+
http_client: httpx.AsyncClient | None = None,
|
|
330
|
+
) -> TokenRefreshResult | None:
|
|
331
|
+
"""
|
|
332
|
+
Handle authentication error and attempt recovery (e.g., token refresh).
|
|
333
|
+
|
|
334
|
+
This method is called by HTTPClient when an authentication error occurs.
|
|
335
|
+
Strategies that support credential refresh (like OAuth2) can override this
|
|
336
|
+
to implement their refresh logic.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
status_code: HTTP status code of the auth error (e.g., 401, 403)
|
|
340
|
+
config: Authentication configuration from AuthConfig.config
|
|
341
|
+
secrets: Secret credentials dictionary (may be updated)
|
|
342
|
+
config_values: Non-secret configuration values (e.g., {"subdomain": "mycompany"})
|
|
343
|
+
Used for template variable substitution in refresh URLs.
|
|
344
|
+
http_client: Optional httpx.AsyncClient for making refresh requests.
|
|
345
|
+
If provided, will be reused; otherwise a new client is created.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
TokenRefreshResult with new credentials if refresh successful, None otherwise.
|
|
349
|
+
The tokens dict will be merged into the secrets dict by the caller.
|
|
350
|
+
|
|
351
|
+
Note:
|
|
352
|
+
Default implementation returns None (no refresh capability).
|
|
353
|
+
Strategies with refresh capability should override this method.
|
|
354
|
+
"""
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
async def ensure_credentials(
|
|
358
|
+
self,
|
|
359
|
+
config: dict[str, Any],
|
|
360
|
+
secrets: dict[str, Any],
|
|
361
|
+
config_values: dict[str, str] | None = None,
|
|
362
|
+
http_client: httpx.AsyncClient | None = None,
|
|
363
|
+
) -> TokenRefreshResult | None:
|
|
364
|
+
"""
|
|
365
|
+
Ensure credentials are ready for authentication.
|
|
366
|
+
|
|
367
|
+
This method is called before the first API request to allow
|
|
368
|
+
strategies to proactively obtain credentials (e.g., OAuth2 token refresh
|
|
369
|
+
when starting with only a refresh_token).
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
config: Authentication configuration from AuthConfig.config
|
|
373
|
+
secrets: Secret credentials dictionary (may be updated by caller)
|
|
374
|
+
config_values: Non-secret configuration values for template substitution
|
|
375
|
+
http_client: Optional httpx.AsyncClient for making requests
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
TokenRefreshResult with new/updated credentials if changes were made, None otherwise.
|
|
379
|
+
The tokens dict should be merged into the secrets dict by the caller.
|
|
380
|
+
|
|
381
|
+
Note:
|
|
382
|
+
Default implementation returns None (no initialization needed).
|
|
383
|
+
Strategies like OAuth2 can override this for proactive token refresh.
|
|
384
|
+
"""
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class APIKeyAuthStrategy(AuthStrategy):
|
|
389
|
+
"""Strategy for API key authentication."""
|
|
390
|
+
|
|
391
|
+
def inject_auth(
|
|
392
|
+
self,
|
|
393
|
+
headers: dict[str, str],
|
|
394
|
+
config: APIKeyAuthConfig,
|
|
395
|
+
secrets: APIKeyAuthSecrets,
|
|
396
|
+
) -> dict[str, str]:
|
|
397
|
+
"""Inject API key into headers.
|
|
398
|
+
|
|
399
|
+
Creates a copy of the headers dict with the API key added.
|
|
400
|
+
The original headers dict is not modified.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
headers: Existing request headers (will not be modified)
|
|
404
|
+
config: API key authentication configuration
|
|
405
|
+
secrets: API key credentials
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
New headers dict with API key authentication injected
|
|
409
|
+
"""
|
|
410
|
+
headers = headers.copy()
|
|
411
|
+
|
|
412
|
+
# Get configuration with defaults
|
|
413
|
+
header_name = config.get("header", "Authorization")
|
|
414
|
+
prefix = config.get("prefix", "")
|
|
415
|
+
|
|
416
|
+
# Get API key from secrets
|
|
417
|
+
api_key = secrets.get("api_key")
|
|
418
|
+
if not api_key:
|
|
419
|
+
raise AuthenticationError("Missing 'api_key' in secrets")
|
|
420
|
+
|
|
421
|
+
# Extract secret value (handle both SecretStr and plain str)
|
|
422
|
+
api_key_value = extract_secret_value(api_key)
|
|
423
|
+
|
|
424
|
+
# Inject into headers
|
|
425
|
+
if prefix:
|
|
426
|
+
headers[header_name] = f"{prefix} {api_key_value}"
|
|
427
|
+
else:
|
|
428
|
+
headers[header_name] = api_key_value
|
|
429
|
+
return headers
|
|
430
|
+
|
|
431
|
+
def validate_credentials(self, secrets: APIKeyAuthSecrets) -> None: # type: ignore[override]
|
|
432
|
+
"""Validate API key is present.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
secrets: API key credentials to validate
|
|
436
|
+
"""
|
|
437
|
+
if not secrets.get("api_key"):
|
|
438
|
+
raise AuthenticationError("Missing 'api_key' in secrets")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
class BearerAuthStrategy(AuthStrategy):
|
|
442
|
+
"""Strategy for Bearer token authentication."""
|
|
443
|
+
|
|
444
|
+
def inject_auth(
|
|
445
|
+
self,
|
|
446
|
+
headers: dict[str, str],
|
|
447
|
+
config: BearerAuthConfig,
|
|
448
|
+
secrets: BearerAuthSecrets,
|
|
449
|
+
) -> dict[str, str]:
|
|
450
|
+
"""Inject Bearer token into headers.
|
|
451
|
+
|
|
452
|
+
Creates a copy of the headers dict with the Bearer token added.
|
|
453
|
+
The original headers dict is not modified.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
headers: Existing request headers (will not be modified)
|
|
457
|
+
config: Bearer authentication configuration
|
|
458
|
+
secrets: Bearer token credentials
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
New headers dict with Bearer token authentication injected
|
|
462
|
+
"""
|
|
463
|
+
headers = headers.copy()
|
|
464
|
+
|
|
465
|
+
# Get configuration with defaults
|
|
466
|
+
header_name = config.get("header", "Authorization")
|
|
467
|
+
prefix = config.get("prefix", "Bearer")
|
|
468
|
+
|
|
469
|
+
# Get token from secrets
|
|
470
|
+
token = secrets.get("token")
|
|
471
|
+
if not token:
|
|
472
|
+
raise AuthenticationError("Missing 'token' in secrets")
|
|
473
|
+
|
|
474
|
+
# Extract secret value (handle both SecretStr and plain str)
|
|
475
|
+
token_value = extract_secret_value(token)
|
|
476
|
+
|
|
477
|
+
# Inject into headers
|
|
478
|
+
headers[header_name] = f"{prefix} {token_value}"
|
|
479
|
+
return headers
|
|
480
|
+
|
|
481
|
+
def validate_credentials(self, secrets: BearerAuthSecrets) -> None: # type: ignore[override]
|
|
482
|
+
"""Validate token is present.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
secrets: Bearer token credentials to validate
|
|
486
|
+
"""
|
|
487
|
+
if not secrets.get("token"):
|
|
488
|
+
raise AuthenticationError("Missing 'token' in secrets")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class BasicAuthStrategy(AuthStrategy):
|
|
492
|
+
"""Strategy for HTTP Basic authentication."""
|
|
493
|
+
|
|
494
|
+
def inject_auth(
|
|
495
|
+
self,
|
|
496
|
+
headers: dict[str, str],
|
|
497
|
+
config: dict[str, Any],
|
|
498
|
+
secrets: BasicAuthSecrets,
|
|
499
|
+
) -> dict[str, str]:
|
|
500
|
+
"""Inject Basic auth credentials into Authorization header.
|
|
501
|
+
|
|
502
|
+
Creates a copy of the headers dict with Basic auth added.
|
|
503
|
+
The original headers dict is not modified.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
headers: Existing request headers (will not be modified)
|
|
507
|
+
config: Basic authentication configuration (unused)
|
|
508
|
+
secrets: Basic auth credentials
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
New headers dict with Authorization header added
|
|
512
|
+
"""
|
|
513
|
+
headers = headers.copy()
|
|
514
|
+
|
|
515
|
+
# Validate credentials are present (None check only, empty strings are allowed)
|
|
516
|
+
username = secrets.get("username")
|
|
517
|
+
password = secrets.get("password")
|
|
518
|
+
|
|
519
|
+
if username is None or password is None:
|
|
520
|
+
raise AuthenticationError("Missing 'username' or 'password' in secrets")
|
|
521
|
+
|
|
522
|
+
# Extract secret values (handle both SecretStr and plain str)
|
|
523
|
+
username_value = extract_secret_value(username)
|
|
524
|
+
password_value = extract_secret_value(password)
|
|
525
|
+
|
|
526
|
+
# Inject Basic auth header
|
|
527
|
+
credentials = f"{username_value}:{password_value}"
|
|
528
|
+
encoded = base64.b64encode(credentials.encode()).decode()
|
|
529
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
530
|
+
|
|
531
|
+
return headers
|
|
532
|
+
|
|
533
|
+
def validate_credentials(self, secrets: BasicAuthSecrets) -> None: # type: ignore[override]
|
|
534
|
+
"""Validate username and password are present.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
secrets: Basic auth credentials to validate
|
|
538
|
+
"""
|
|
539
|
+
username = secrets.get("username")
|
|
540
|
+
password = secrets.get("password")
|
|
541
|
+
|
|
542
|
+
if username is None or password is None:
|
|
543
|
+
raise AuthenticationError("Missing 'username' or 'password' in secrets")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class OAuth2AuthStrategy(AuthStrategy):
|
|
547
|
+
"""Strategy for OAuth 2.0 authentication with token refresh support."""
|
|
548
|
+
|
|
549
|
+
def inject_auth(
|
|
550
|
+
self,
|
|
551
|
+
headers: dict[str, str],
|
|
552
|
+
config: OAuth2AuthConfig,
|
|
553
|
+
secrets: OAuth2AuthSecrets,
|
|
554
|
+
) -> dict[str, str]:
|
|
555
|
+
"""Inject OAuth2 access token into headers.
|
|
556
|
+
|
|
557
|
+
Creates a copy of the headers dict with the OAuth2 token added.
|
|
558
|
+
The original headers dict is not modified.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
headers: Existing request headers (will not be modified)
|
|
562
|
+
config: OAuth2 authentication configuration
|
|
563
|
+
secrets: OAuth2 credentials including access_token
|
|
564
|
+
|
|
565
|
+
Returns:
|
|
566
|
+
New headers dict with OAuth2 token authentication injected
|
|
567
|
+
|
|
568
|
+
Raises:
|
|
569
|
+
AuthenticationError: If access_token is missing
|
|
570
|
+
"""
|
|
571
|
+
headers = headers.copy()
|
|
572
|
+
|
|
573
|
+
# Get configuration with defaults
|
|
574
|
+
header_name = config.get("header", "Authorization")
|
|
575
|
+
prefix = config.get("prefix", "Bearer")
|
|
576
|
+
|
|
577
|
+
# Get access token from secrets
|
|
578
|
+
access_token = secrets.get("access_token")
|
|
579
|
+
if not access_token:
|
|
580
|
+
# Provide helpful error for refresh-token-only scenario
|
|
581
|
+
if secrets.get("refresh_token"):
|
|
582
|
+
raise AuthenticationError(
|
|
583
|
+
"Missing 'access_token' in secrets. "
|
|
584
|
+
"When using refresh-token-only mode, ensure HTTPClient.request() "
|
|
585
|
+
"is called (it handles proactive token refresh automatically)."
|
|
586
|
+
)
|
|
587
|
+
raise AuthenticationError("Missing 'access_token' in secrets")
|
|
588
|
+
|
|
589
|
+
# Extract secret value (handle both SecretStr and plain str)
|
|
590
|
+
token_value = extract_secret_value(access_token)
|
|
591
|
+
|
|
592
|
+
# Inject into headers
|
|
593
|
+
headers[header_name] = f"{prefix} {token_value}"
|
|
594
|
+
return headers
|
|
595
|
+
|
|
596
|
+
def validate_credentials(self, secrets: OAuth2AuthSecrets) -> None: # type: ignore[override]
|
|
597
|
+
"""Validate OAuth2 credentials are valid for authentication.
|
|
598
|
+
|
|
599
|
+
Validates that either:
|
|
600
|
+
1. access_token is present, OR
|
|
601
|
+
2. refresh_token is present (for refresh-token-only mode)
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
secrets: OAuth2 credentials to validate
|
|
605
|
+
|
|
606
|
+
Raises:
|
|
607
|
+
AuthenticationError: If neither access_token nor refresh_token is present
|
|
608
|
+
"""
|
|
609
|
+
has_access_token = bool(secrets.get("access_token"))
|
|
610
|
+
has_refresh_token = bool(secrets.get("refresh_token"))
|
|
611
|
+
|
|
612
|
+
if not has_access_token and not has_refresh_token:
|
|
613
|
+
raise AuthenticationError(
|
|
614
|
+
"Missing OAuth2 credentials. Provide either 'access_token' " "or 'refresh_token' (for refresh-token-only mode)."
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
def can_refresh(self, secrets: OAuth2RefreshSecrets) -> bool:
|
|
618
|
+
"""Check if token refresh is possible.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
secrets: OAuth2 credentials (including optional refresh fields)
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
True if refresh_token is available, False otherwise
|
|
625
|
+
"""
|
|
626
|
+
return bool(secrets.get("refresh_token"))
|
|
627
|
+
|
|
628
|
+
async def handle_auth_error(
|
|
629
|
+
self,
|
|
630
|
+
status_code: int,
|
|
631
|
+
config: dict[str, Any],
|
|
632
|
+
secrets: dict[str, Any],
|
|
633
|
+
config_values: dict[str, str] | None = None,
|
|
634
|
+
http_client: httpx.AsyncClient | None = None,
|
|
635
|
+
) -> TokenRefreshResult | None:
|
|
636
|
+
"""
|
|
637
|
+
Handle OAuth2 authentication error by refreshing tokens.
|
|
638
|
+
|
|
639
|
+
This method is called when a 401 error occurs. It attempts to refresh
|
|
640
|
+
the access_token using the refresh_token if available.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
status_code: HTTP status code (only 401 triggers refresh)
|
|
644
|
+
config: OAuth2 authentication configuration
|
|
645
|
+
secrets: OAuth2 credentials including refresh_token
|
|
646
|
+
config_values: Non-secret configuration values for template substitution
|
|
647
|
+
http_client: Optional httpx.AsyncClient for making refresh requests
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
TokenRefreshResult with new tokens if refresh successful, None otherwise.
|
|
651
|
+
|
|
652
|
+
Note:
|
|
653
|
+
Only attempts refresh on 401 (Unauthorized) errors with valid refresh_token.
|
|
654
|
+
Other status codes (403, etc.) return None immediately.
|
|
655
|
+
"""
|
|
656
|
+
# Only handle 401 Unauthorized errors
|
|
657
|
+
if status_code != 401:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
# Check if we have refresh capability
|
|
661
|
+
if not self.can_refresh(secrets): # type: ignore[arg-type]
|
|
662
|
+
return None
|
|
663
|
+
|
|
664
|
+
# Check if refresh_url is configured
|
|
665
|
+
if not config.get("refresh_url"):
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
try:
|
|
669
|
+
# Create a token refresher
|
|
670
|
+
# Pass None for http_client - let refresher create its own
|
|
671
|
+
token_refresher = OAuth2TokenRefresher(None, config_values)
|
|
672
|
+
|
|
673
|
+
# Attempt to refresh the token
|
|
674
|
+
return await token_refresher.refresh_token(
|
|
675
|
+
config=config, # type: ignore[arg-type]
|
|
676
|
+
secrets=secrets, # type: ignore[arg-type]
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
except Exception as e:
|
|
680
|
+
# Token refresh failed - log the error and return None
|
|
681
|
+
# HTTPClient will raise the original auth error
|
|
682
|
+
logger.warning("OAuth2 token refresh failed: %s", str(e))
|
|
683
|
+
return None
|
|
684
|
+
|
|
685
|
+
def needs_proactive_refresh(self, secrets: dict[str, Any]) -> bool:
|
|
686
|
+
"""Check if proactive token refresh is needed.
|
|
687
|
+
|
|
688
|
+
Returns True if:
|
|
689
|
+
- access_token is missing (None, empty, or not present)
|
|
690
|
+
- refresh_token is present
|
|
691
|
+
|
|
692
|
+
This indicates "refresh-token-only" mode where we need to obtain
|
|
693
|
+
an access_token before making API requests.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
secrets: OAuth2 credentials
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
True if proactive refresh should be attempted, False otherwise
|
|
700
|
+
"""
|
|
701
|
+
has_access_token = bool(secrets.get("access_token"))
|
|
702
|
+
has_refresh_token = bool(secrets.get("refresh_token"))
|
|
703
|
+
|
|
704
|
+
return not has_access_token and has_refresh_token
|
|
705
|
+
|
|
706
|
+
async def ensure_credentials(
|
|
707
|
+
self,
|
|
708
|
+
config: dict[str, Any],
|
|
709
|
+
secrets: dict[str, Any],
|
|
710
|
+
config_values: dict[str, str] | None = None,
|
|
711
|
+
http_client: httpx.AsyncClient | None = None,
|
|
712
|
+
) -> TokenRefreshResult | None:
|
|
713
|
+
"""
|
|
714
|
+
Proactively refresh OAuth2 tokens if access_token is missing.
|
|
715
|
+
|
|
716
|
+
Called before the first API request. If access_token is missing
|
|
717
|
+
but refresh credentials are available, attempts to obtain an
|
|
718
|
+
access_token via token refresh.
|
|
719
|
+
|
|
720
|
+
Args:
|
|
721
|
+
config: OAuth2 authentication configuration
|
|
722
|
+
secrets: OAuth2 credentials (may be missing access_token)
|
|
723
|
+
config_values: Non-secret config values for URL templates
|
|
724
|
+
http_client: Optional httpx.AsyncClient for refresh request (unused)
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
TokenRefreshResult with new tokens if refresh successful, None otherwise
|
|
728
|
+
|
|
729
|
+
Raises:
|
|
730
|
+
AuthenticationError: If refresh fails and no access_token available
|
|
731
|
+
"""
|
|
732
|
+
if not self.needs_proactive_refresh(secrets):
|
|
733
|
+
return None
|
|
734
|
+
|
|
735
|
+
# Check if refresh_url is configured
|
|
736
|
+
if not config.get("refresh_url"):
|
|
737
|
+
raise AuthenticationError(
|
|
738
|
+
"Missing 'access_token' in secrets and 'refresh_url' in config. "
|
|
739
|
+
"Either provide access_token or configure refresh_url for "
|
|
740
|
+
"proactive token refresh."
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
try:
|
|
744
|
+
logger.info("Proactively refreshing OAuth2 token (no access_token provided)")
|
|
745
|
+
|
|
746
|
+
# Create token refresher and attempt refresh
|
|
747
|
+
# Pass None for http_client - let refresher create its own
|
|
748
|
+
token_refresher = OAuth2TokenRefresher(None, config_values)
|
|
749
|
+
result = await token_refresher.refresh_token(
|
|
750
|
+
config=config, # type: ignore[arg-type]
|
|
751
|
+
secrets=secrets, # type: ignore[arg-type]
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
logger.info("Proactive token refresh successful")
|
|
755
|
+
return result
|
|
756
|
+
|
|
757
|
+
except Exception as e:
|
|
758
|
+
# Proactive refresh failed - this is fatal since we have no access_token
|
|
759
|
+
msg = f"Failed to obtain access_token via token refresh: {e!s}"
|
|
760
|
+
logger.error(msg)
|
|
761
|
+
raise AuthenticationError(msg) from e
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
class OAuth2TokenRefresher:
|
|
765
|
+
"""Handles OAuth2 token refresh HTTP requests.
|
|
766
|
+
|
|
767
|
+
Separated from OAuth2AuthStrategy to maintain single responsibility
|
|
768
|
+
and make testing easier.
|
|
769
|
+
|
|
770
|
+
Attributes:
|
|
771
|
+
_http_client: Optional httpx.AsyncClient for making HTTP requests.
|
|
772
|
+
If None, creates a new client for each refresh request.
|
|
773
|
+
_config_values: Non-secret configuration values for template substitution.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
# Maximum length for error response text to avoid exposing large bodies
|
|
777
|
+
MAX_ERROR_RESPONSE_LENGTH = 500
|
|
778
|
+
|
|
779
|
+
def __init__(
|
|
780
|
+
self,
|
|
781
|
+
http_client: httpx.AsyncClient | None = None,
|
|
782
|
+
config_values: dict[str, str] | None = None,
|
|
783
|
+
):
|
|
784
|
+
"""Initialize the token refresher.
|
|
785
|
+
|
|
786
|
+
Args:
|
|
787
|
+
http_client: Optional httpx.AsyncClient instance. If provided,
|
|
788
|
+
will be used for token refresh requests. If None,
|
|
789
|
+
a new client will be created for each request.
|
|
790
|
+
config_values: Non-secret configuration values (e.g., {"subdomain": "mycompany"})
|
|
791
|
+
for template variable substitution in refresh URLs.
|
|
792
|
+
"""
|
|
793
|
+
self._http_client = http_client
|
|
794
|
+
self._config_values = config_values or {}
|
|
795
|
+
|
|
796
|
+
async def refresh_token(
|
|
797
|
+
self,
|
|
798
|
+
config: OAuth2AuthConfig,
|
|
799
|
+
secrets: OAuth2RefreshSecrets,
|
|
800
|
+
) -> TokenRefreshResult:
|
|
801
|
+
"""Refresh the OAuth2 access token using the refresh token.
|
|
802
|
+
|
|
803
|
+
This method orchestrates the token refresh flow by:
|
|
804
|
+
1. Validating required configuration and secrets
|
|
805
|
+
2. Building the refresh request (URL, headers, body)
|
|
806
|
+
3. Executing the HTTP request
|
|
807
|
+
4. Parsing and validating the response
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
config: OAuth2 configuration with refresh_url and auth_style
|
|
811
|
+
secrets: OAuth2 credentials including refresh_token and client credentials
|
|
812
|
+
|
|
813
|
+
Returns:
|
|
814
|
+
TokenRefreshResult containing:
|
|
815
|
+
- tokens: dict with access_token, refresh_token (if provided), token_type
|
|
816
|
+
- extracted_values: dict of fields extracted via x-airbyte-token-extract
|
|
817
|
+
|
|
818
|
+
Raises:
|
|
819
|
+
AuthenticationError: If refresh fails or required fields missing
|
|
820
|
+
"""
|
|
821
|
+
self._validate_refresh_requirements(config, secrets)
|
|
822
|
+
|
|
823
|
+
url = self._render_refresh_url(config, secrets)
|
|
824
|
+
headers, body_params = self._build_refresh_request(config, secrets)
|
|
825
|
+
|
|
826
|
+
response = await self._execute_refresh_request(url, headers, body_params, config)
|
|
827
|
+
|
|
828
|
+
# Get token_extract config if present
|
|
829
|
+
token_extract: list[str] | None = config.get("token_extract") # type: ignore[assignment]
|
|
830
|
+
|
|
831
|
+
return self._parse_refresh_response(response, token_extract)
|
|
832
|
+
|
|
833
|
+
def _validate_refresh_requirements(
|
|
834
|
+
self,
|
|
835
|
+
config: OAuth2AuthConfig,
|
|
836
|
+
secrets: OAuth2RefreshSecrets,
|
|
837
|
+
) -> None:
|
|
838
|
+
"""Validate that required fields are present for token refresh.
|
|
839
|
+
|
|
840
|
+
Args:
|
|
841
|
+
config: OAuth2 configuration
|
|
842
|
+
secrets: OAuth2 credentials (must include refresh_token)
|
|
843
|
+
|
|
844
|
+
Raises:
|
|
845
|
+
AuthenticationError: If refresh_token or refresh_url is missing
|
|
846
|
+
"""
|
|
847
|
+
if not secrets.get("refresh_token"):
|
|
848
|
+
raise AuthenticationError("Missing 'refresh_token' in secrets")
|
|
849
|
+
|
|
850
|
+
if not config.get("refresh_url"):
|
|
851
|
+
raise AuthenticationError("Missing 'refresh_url' in config")
|
|
852
|
+
|
|
853
|
+
def _render_refresh_url(
|
|
854
|
+
self,
|
|
855
|
+
config: OAuth2AuthConfig,
|
|
856
|
+
secrets: OAuth2RefreshSecrets,
|
|
857
|
+
) -> str:
|
|
858
|
+
"""Render the refresh URL with template variables.
|
|
859
|
+
|
|
860
|
+
Supports Jinja2 template syntax for dynamic URLs. Template variables can come from:
|
|
861
|
+
1. config_values (non-secret config like subdomain, region, etc.)
|
|
862
|
+
2. config dict (auth configuration)
|
|
863
|
+
3. secrets (client_id only, for convenience)
|
|
864
|
+
|
|
865
|
+
Common template variables:
|
|
866
|
+
- {{subdomain}}: For multi-tenant APIs (Zendesk, Slack, etc.)
|
|
867
|
+
- {{shop}}: For Shopify-style APIs
|
|
868
|
+
- {{region}}: For multi-region APIs
|
|
869
|
+
- {{client_id}}: OAuth2 client ID from secrets
|
|
870
|
+
|
|
871
|
+
Args:
|
|
872
|
+
config: OAuth2 configuration containing refresh_url
|
|
873
|
+
secrets: OAuth2 credentials (client_id may be used in templates)
|
|
874
|
+
|
|
875
|
+
Returns:
|
|
876
|
+
Rendered URL string with variables substituted
|
|
877
|
+
|
|
878
|
+
Examples:
|
|
879
|
+
With config_values={"subdomain": "mycompany"}:
|
|
880
|
+
refresh_url: "https://{{subdomain}}.zendesk.com/oauth/tokens"
|
|
881
|
+
# Returns: "https://mycompany.zendesk.com/oauth/tokens"
|
|
882
|
+
|
|
883
|
+
With config_values={"shop": "my-store"}:
|
|
884
|
+
refresh_url: "https://{{shop}}.myshopify.com/admin/oauth/access_token"
|
|
885
|
+
# Returns: "https://my-store.myshopify.com/admin/oauth/access_token"
|
|
886
|
+
"""
|
|
887
|
+
refresh_url = config["refresh_url"] # Already validated
|
|
888
|
+
|
|
889
|
+
# Build template context with priority: config_values > config > secrets
|
|
890
|
+
template_context = dict(config) # Auth config values
|
|
891
|
+
template_context.update(self._config_values) # Non-secret config (higher priority)
|
|
892
|
+
|
|
893
|
+
# Add commonly needed secret values (but not sensitive tokens)
|
|
894
|
+
template_context["client_id"] = extract_secret_value(secrets.get("client_id", ""))
|
|
895
|
+
|
|
896
|
+
return Template(refresh_url).render(template_context)
|
|
897
|
+
|
|
898
|
+
def _build_refresh_request(
|
|
899
|
+
self,
|
|
900
|
+
config: OAuth2AuthConfig,
|
|
901
|
+
secrets: OAuth2RefreshSecrets,
|
|
902
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
903
|
+
"""Build headers and body for the token refresh request.
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
config: OAuth2 configuration with auth_style
|
|
907
|
+
secrets: OAuth2 credentials (including refresh_token and client credentials)
|
|
908
|
+
|
|
909
|
+
Returns:
|
|
910
|
+
Tuple of (headers dict, body params dict)
|
|
911
|
+
"""
|
|
912
|
+
auth_style = config.get("auth_style", "body")
|
|
913
|
+
|
|
914
|
+
# Extract secret values once
|
|
915
|
+
refresh_token_value = extract_secret_value(
|
|
916
|
+
secrets["refresh_token"] # Already validated
|
|
917
|
+
)
|
|
918
|
+
client_id_value = extract_secret_value(secrets.get("client_id", ""))
|
|
919
|
+
client_secret_value = extract_secret_value(secrets.get("client_secret", ""))
|
|
920
|
+
|
|
921
|
+
# Build base request body
|
|
922
|
+
body_params = {
|
|
923
|
+
"grant_type": "refresh_token",
|
|
924
|
+
"refresh_token": refresh_token_value,
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
# Build headers based on auth style
|
|
928
|
+
headers = self._build_auth_headers(auth_style, client_id_value, client_secret_value)
|
|
929
|
+
|
|
930
|
+
# Add client credentials to body if using body auth style
|
|
931
|
+
if auth_style == "body":
|
|
932
|
+
body_params["client_id"] = client_id_value
|
|
933
|
+
body_params["client_secret"] = client_secret_value
|
|
934
|
+
|
|
935
|
+
return headers, body_params
|
|
936
|
+
|
|
937
|
+
def _build_auth_headers(
|
|
938
|
+
self,
|
|
939
|
+
auth_style: AuthStyle,
|
|
940
|
+
client_id: str,
|
|
941
|
+
client_secret: str,
|
|
942
|
+
) -> dict[str, str]:
|
|
943
|
+
"""Build authentication headers based on the auth style.
|
|
944
|
+
|
|
945
|
+
Args:
|
|
946
|
+
auth_style: One of "basic", "body", or "none"
|
|
947
|
+
client_id: OAuth2 client ID
|
|
948
|
+
client_secret: OAuth2 client secret
|
|
949
|
+
|
|
950
|
+
Returns:
|
|
951
|
+
Dictionary of HTTP headers
|
|
952
|
+
"""
|
|
953
|
+
headers: dict[str, str] = {}
|
|
954
|
+
|
|
955
|
+
if auth_style == "basic":
|
|
956
|
+
# Client credentials in Basic Auth header
|
|
957
|
+
credentials = f"{client_id}:{client_secret}"
|
|
958
|
+
encoded = base64.b64encode(credentials.encode()).decode()
|
|
959
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
960
|
+
# auth_style == "body" or "none": no auth headers needed
|
|
961
|
+
|
|
962
|
+
return headers
|
|
963
|
+
|
|
964
|
+
async def _execute_refresh_request(
|
|
965
|
+
self,
|
|
966
|
+
url: str,
|
|
967
|
+
headers: dict[str, str],
|
|
968
|
+
body_params: dict[str, str],
|
|
969
|
+
config: OAuth2AuthConfig,
|
|
970
|
+
) -> dict[str, Any]:
|
|
971
|
+
"""Execute the HTTP request to refresh the token.
|
|
972
|
+
|
|
973
|
+
Args:
|
|
974
|
+
url: Token refresh endpoint URL
|
|
975
|
+
headers: HTTP headers (may include Authorization)
|
|
976
|
+
body_params: Request body parameters
|
|
977
|
+
config: OAuth2 configuration (for body_format)
|
|
978
|
+
|
|
979
|
+
Returns:
|
|
980
|
+
Parsed JSON response from the token endpoint
|
|
981
|
+
|
|
982
|
+
Raises:
|
|
983
|
+
AuthenticationError: If request fails or returns non-200 status
|
|
984
|
+
"""
|
|
985
|
+
body_format = config.get("body_format", "form")
|
|
986
|
+
|
|
987
|
+
# Use injected client or create a new one
|
|
988
|
+
if self._http_client is not None:
|
|
989
|
+
client = self._http_client
|
|
990
|
+
close_client = False
|
|
991
|
+
else:
|
|
992
|
+
client = httpx.AsyncClient()
|
|
993
|
+
close_client = True
|
|
994
|
+
|
|
995
|
+
try:
|
|
996
|
+
# Set content type and make request based on body format
|
|
997
|
+
if body_format == "json":
|
|
998
|
+
headers["Content-Type"] = "application/json"
|
|
999
|
+
response = await client.post(url, json=body_params, headers=headers)
|
|
1000
|
+
else: # form (default)
|
|
1001
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
1002
|
+
response = await client.post(url, data=body_params, headers=headers)
|
|
1003
|
+
|
|
1004
|
+
# Check for successful response
|
|
1005
|
+
if response.status_code != 200:
|
|
1006
|
+
response_text = response.text[: self.MAX_ERROR_RESPONSE_LENGTH]
|
|
1007
|
+
msg = f"Token refresh failed: {response.status_code} {response_text}"
|
|
1008
|
+
raise AuthenticationError(msg)
|
|
1009
|
+
|
|
1010
|
+
# Parse JSON response
|
|
1011
|
+
try:
|
|
1012
|
+
return response.json()
|
|
1013
|
+
except Exception as e:
|
|
1014
|
+
msg = f"Token refresh response invalid JSON: {str(e)}"
|
|
1015
|
+
raise AuthenticationError(msg)
|
|
1016
|
+
finally:
|
|
1017
|
+
# Only close if we created the client
|
|
1018
|
+
if close_client:
|
|
1019
|
+
await client.aclose()
|
|
1020
|
+
|
|
1021
|
+
def _parse_refresh_response(
|
|
1022
|
+
self,
|
|
1023
|
+
response_data: dict[str, Any],
|
|
1024
|
+
token_extract: list[str] | None = None,
|
|
1025
|
+
) -> TokenRefreshResult:
|
|
1026
|
+
"""Parse and validate the token refresh response.
|
|
1027
|
+
|
|
1028
|
+
Args:
|
|
1029
|
+
response_data: Parsed JSON response from token endpoint
|
|
1030
|
+
token_extract: Optional list of fields to extract from response
|
|
1031
|
+
and use as server variables (e.g., ["instance_url"])
|
|
1032
|
+
|
|
1033
|
+
Returns:
|
|
1034
|
+
TokenRefreshResult with tokens and optional extracted values
|
|
1035
|
+
|
|
1036
|
+
Raises:
|
|
1037
|
+
AuthenticationError: If access_token is missing from response
|
|
1038
|
+
"""
|
|
1039
|
+
new_access_token = response_data.get("access_token")
|
|
1040
|
+
if not new_access_token:
|
|
1041
|
+
msg = "Token refresh response missing 'access_token'"
|
|
1042
|
+
raise AuthenticationError(msg)
|
|
1043
|
+
|
|
1044
|
+
tokens = {
|
|
1045
|
+
"access_token": new_access_token,
|
|
1046
|
+
"token_type": response_data.get("token_type", "Bearer"),
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
# Include new refresh_token if provided by the server
|
|
1050
|
+
if "refresh_token" in response_data:
|
|
1051
|
+
tokens["refresh_token"] = response_data["refresh_token"]
|
|
1052
|
+
|
|
1053
|
+
# Extract fields specified by x-airbyte-token-extract
|
|
1054
|
+
extracted_values: dict[str, str] | None = None
|
|
1055
|
+
if token_extract:
|
|
1056
|
+
extracted = {}
|
|
1057
|
+
for field in token_extract:
|
|
1058
|
+
if field in response_data:
|
|
1059
|
+
value = response_data[field]
|
|
1060
|
+
if isinstance(value, str):
|
|
1061
|
+
extracted[field] = value
|
|
1062
|
+
else:
|
|
1063
|
+
# Convert non-string values to string for server variables
|
|
1064
|
+
extracted[field] = str(value)
|
|
1065
|
+
logger.debug(
|
|
1066
|
+
"Extracted '%s' from token response for server variable",
|
|
1067
|
+
field,
|
|
1068
|
+
)
|
|
1069
|
+
if extracted:
|
|
1070
|
+
extracted_values = extracted
|
|
1071
|
+
|
|
1072
|
+
return TokenRefreshResult(tokens=tokens, extracted_values=extracted_values)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
class AuthStrategyFactory:
|
|
1076
|
+
"""Factory for creating authentication strategies."""
|
|
1077
|
+
|
|
1078
|
+
# Create singleton instances
|
|
1079
|
+
_api_key_strategy = APIKeyAuthStrategy()
|
|
1080
|
+
_bearer_strategy = BearerAuthStrategy()
|
|
1081
|
+
_basic_strategy = BasicAuthStrategy()
|
|
1082
|
+
_oauth2_strategy = OAuth2AuthStrategy()
|
|
1083
|
+
|
|
1084
|
+
# Strategy registry mapping AuthType to strategy instances
|
|
1085
|
+
_strategies: dict[AuthType, AuthStrategy] = {
|
|
1086
|
+
AuthType.API_KEY: _api_key_strategy,
|
|
1087
|
+
AuthType.BEARER: _bearer_strategy,
|
|
1088
|
+
AuthType.BASIC: _basic_strategy,
|
|
1089
|
+
AuthType.OAUTH2: _oauth2_strategy,
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
@classmethod
|
|
1093
|
+
def get_strategy(cls, auth_type: AuthType) -> AuthStrategy:
|
|
1094
|
+
"""
|
|
1095
|
+
Get authentication strategy for the given auth type.
|
|
1096
|
+
|
|
1097
|
+
Args:
|
|
1098
|
+
auth_type: Authentication type from AuthConfig
|
|
1099
|
+
|
|
1100
|
+
Returns:
|
|
1101
|
+
Appropriate AuthStrategy instance
|
|
1102
|
+
|
|
1103
|
+
Raises:
|
|
1104
|
+
AuthenticationError: If auth type is not implemented
|
|
1105
|
+
"""
|
|
1106
|
+
strategy = cls._strategies.get(auth_type)
|
|
1107
|
+
if strategy is None:
|
|
1108
|
+
raise AuthenticationError(
|
|
1109
|
+
f"Authentication type '{auth_type.value}' is not implemented. "
|
|
1110
|
+
f"Supported types: {', '.join(s.value for s in cls._strategies.keys())}"
|
|
1111
|
+
)
|
|
1112
|
+
return strategy
|
|
1113
|
+
|
|
1114
|
+
@classmethod
|
|
1115
|
+
def register_strategy(cls, auth_type: AuthType, strategy: AuthStrategy) -> None:
|
|
1116
|
+
"""
|
|
1117
|
+
Register a custom authentication strategy.
|
|
1118
|
+
|
|
1119
|
+
Args:
|
|
1120
|
+
auth_type: Authentication type to register
|
|
1121
|
+
strategy: Strategy instance to use for this auth type
|
|
1122
|
+
"""
|
|
1123
|
+
cls._strategies[auth_type] = strategy
|