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