airbyte-agent-stripe 0.5.28__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 (55) hide show
  1. airbyte_agent_stripe/__init__.py +237 -0
  2. airbyte_agent_stripe/_vendored/__init__.py +1 -0
  3. airbyte_agent_stripe/_vendored/connector_sdk/__init__.py +82 -0
  4. airbyte_agent_stripe/_vendored/connector_sdk/auth_strategies.py +1123 -0
  5. airbyte_agent_stripe/_vendored/connector_sdk/auth_template.py +135 -0
  6. airbyte_agent_stripe/_vendored/connector_sdk/cloud_utils/__init__.py +5 -0
  7. airbyte_agent_stripe/_vendored/connector_sdk/cloud_utils/client.py +213 -0
  8. airbyte_agent_stripe/_vendored/connector_sdk/connector_model_loader.py +957 -0
  9. airbyte_agent_stripe/_vendored/connector_sdk/constants.py +78 -0
  10. airbyte_agent_stripe/_vendored/connector_sdk/exceptions.py +23 -0
  11. airbyte_agent_stripe/_vendored/connector_sdk/executor/__init__.py +31 -0
  12. airbyte_agent_stripe/_vendored/connector_sdk/executor/hosted_executor.py +197 -0
  13. airbyte_agent_stripe/_vendored/connector_sdk/executor/local_executor.py +1524 -0
  14. airbyte_agent_stripe/_vendored/connector_sdk/executor/models.py +190 -0
  15. airbyte_agent_stripe/_vendored/connector_sdk/extensions.py +655 -0
  16. airbyte_agent_stripe/_vendored/connector_sdk/http/__init__.py +37 -0
  17. airbyte_agent_stripe/_vendored/connector_sdk/http/adapters/__init__.py +9 -0
  18. airbyte_agent_stripe/_vendored/connector_sdk/http/adapters/httpx_adapter.py +251 -0
  19. airbyte_agent_stripe/_vendored/connector_sdk/http/config.py +98 -0
  20. airbyte_agent_stripe/_vendored/connector_sdk/http/exceptions.py +119 -0
  21. airbyte_agent_stripe/_vendored/connector_sdk/http/protocols.py +114 -0
  22. airbyte_agent_stripe/_vendored/connector_sdk/http/response.py +102 -0
  23. airbyte_agent_stripe/_vendored/connector_sdk/http_client.py +686 -0
  24. airbyte_agent_stripe/_vendored/connector_sdk/logging/__init__.py +11 -0
  25. airbyte_agent_stripe/_vendored/connector_sdk/logging/logger.py +264 -0
  26. airbyte_agent_stripe/_vendored/connector_sdk/logging/types.py +92 -0
  27. airbyte_agent_stripe/_vendored/connector_sdk/observability/__init__.py +11 -0
  28. airbyte_agent_stripe/_vendored/connector_sdk/observability/models.py +19 -0
  29. airbyte_agent_stripe/_vendored/connector_sdk/observability/redactor.py +81 -0
  30. airbyte_agent_stripe/_vendored/connector_sdk/observability/session.py +94 -0
  31. airbyte_agent_stripe/_vendored/connector_sdk/performance/__init__.py +6 -0
  32. airbyte_agent_stripe/_vendored/connector_sdk/performance/instrumentation.py +57 -0
  33. airbyte_agent_stripe/_vendored/connector_sdk/performance/metrics.py +93 -0
  34. airbyte_agent_stripe/_vendored/connector_sdk/schema/__init__.py +75 -0
  35. airbyte_agent_stripe/_vendored/connector_sdk/schema/base.py +161 -0
  36. airbyte_agent_stripe/_vendored/connector_sdk/schema/components.py +238 -0
  37. airbyte_agent_stripe/_vendored/connector_sdk/schema/connector.py +131 -0
  38. airbyte_agent_stripe/_vendored/connector_sdk/schema/extensions.py +109 -0
  39. airbyte_agent_stripe/_vendored/connector_sdk/schema/operations.py +146 -0
  40. airbyte_agent_stripe/_vendored/connector_sdk/schema/security.py +213 -0
  41. airbyte_agent_stripe/_vendored/connector_sdk/secrets.py +182 -0
  42. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/__init__.py +10 -0
  43. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/config.py +32 -0
  44. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/events.py +58 -0
  45. airbyte_agent_stripe/_vendored/connector_sdk/telemetry/tracker.py +151 -0
  46. airbyte_agent_stripe/_vendored/connector_sdk/types.py +241 -0
  47. airbyte_agent_stripe/_vendored/connector_sdk/utils.py +60 -0
  48. airbyte_agent_stripe/_vendored/connector_sdk/validation.py +822 -0
  49. airbyte_agent_stripe/connector.py +1579 -0
  50. airbyte_agent_stripe/connector_model.py +14869 -0
  51. airbyte_agent_stripe/models.py +2353 -0
  52. airbyte_agent_stripe/types.py +295 -0
  53. airbyte_agent_stripe-0.5.28.dist-info/METADATA +114 -0
  54. airbyte_agent_stripe-0.5.28.dist-info/RECORD +55 -0
  55. airbyte_agent_stripe-0.5.28.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