airbyte_cdk.sources.streams.http.requests_native_auth.abstract_oauth

  1#
  2# Copyright (c) 2023 Airbyte, Inc., all rights reserved.
  3#
  4
  5import logging
  6import re
  7import threading
  8from abc import abstractmethod
  9from datetime import datetime, timedelta
 10from json import JSONDecodeError
 11from typing import Any, List, Mapping, MutableMapping, Optional, Tuple, Union
 12
 13import backoff
 14import requests
 15from requests.auth import AuthBase
 16
 17from airbyte_cdk.models import FailureType, Level
 18from airbyte_cdk.sources.http_logger import format_http_message
 19from airbyte_cdk.sources.message import MessageRepository, NoopMessageRepository
 20from airbyte_cdk.utils import AirbyteTracedException
 21from airbyte_cdk.utils.airbyte_secrets_utils import add_to_secrets, filter_secrets
 22from airbyte_cdk.utils.datetime_helpers import AirbyteDateTime, ab_datetime_now, ab_datetime_parse
 23
 24from ..exceptions import DefaultBackoffException
 25
 26logger = logging.getLogger("airbyte")
 27_NOOP_MESSAGE_REPOSITORY = NoopMessageRepository()
 28
 29# Provider error codes that lead `error_description`, e.g. Microsoft Entra's `AADSTS50173`.
 30# Only the code is appended to the user-facing message: it is the entire grouping key, and unlike
 31# the surrounding prose it is identical on every attempt at the same failure.
 32_PROVIDER_ERROR_CODE_PATTERN = re.compile(r"^[A-Za-z]{3,}\d{4,}\b")
 33_REFRESH_TOKEN_REJECTED_GUIDANCE = (
 34    "Refresh token was rejected by the OAuth provider (invalid, expired, or already used). "
 35    "Re-authenticate this source's credentials in its connection settings."
 36)
 37# Upper bound on the provider-controlled text appended to the user-facing message. Both the
 38# RFC 6749 `error` token and the extracted code come from the provider, so both are still capped.
 39# The longest standard token (`unsupported_grant_type`, 22) plus a 7-digit provider code is 37
 40# characters, so this leaves roughly 2x headroom while still bounding a misbehaving provider.
 41_PROVIDER_ERROR_DETAIL_MAX_LENGTH = 64
 42# How much of the raw provider response is kept in the internal message, which is logged and is not
 43# shown to the user.
 44_PROVIDER_ERROR_RESPONSE_MAX_LENGTH = 1000
 45
 46
 47class ResponseKeysMaxRecurtionReached(AirbyteTracedException):
 48    """
 49    Raised when the max level of recursion is reached, when trying to
 50    find-and-get the target key, during the `_make_handled_request`
 51    """
 52
 53
 54class AbstractOauth2Authenticator(AuthBase):
 55    """
 56    Abstract class for an OAuth authenticators that implements the OAuth token refresh flow. The authenticator
 57    is designed to generically perform the refresh flow without regard to how config fields are get/set by
 58    delegating that behavior to the classes implementing the interface.
 59    """
 60
 61    _NO_STREAM_NAME = None
 62
 63    # Class-level lock to prevent concurrent token refresh across multiple authenticator instances.
 64    # This is necessary because multiple streams may share the same OAuth credentials (refresh token)
 65    # through the connector config. Without this lock, concurrent refresh attempts can cause race
 66    # conditions where one stream successfully refreshes the token while others fail because the
 67    # refresh token has been invalidated (especially for single-use refresh tokens).
 68    _token_refresh_lock = threading.RLock()
 69
 70    def __init__(
 71        self,
 72        refresh_token_error_status_codes: Tuple[int, ...] = (),
 73        refresh_token_error_key: str = "",
 74        refresh_token_error_values: Tuple[str, ...] = (),
 75        refresh_token_error_messages: Optional[Mapping[str, str]] = None,
 76    ) -> None:
 77        """
 78        If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set,
 79        then http errors with such params will be wrapped in AirbyteTracedException.
 80        refresh_token_error_messages maps fixed provider error strings to fixed user-facing messages.
 81        """
 82        self._refresh_token_error_status_codes = refresh_token_error_status_codes
 83        self._refresh_token_error_key = refresh_token_error_key
 84        self._refresh_token_error_values = refresh_token_error_values
 85        self._refresh_token_error_messages: Mapping[str, str] = dict(
 86            refresh_token_error_messages or {}
 87        )
 88
 89    def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
 90        """Attach the HTTP headers required to authenticate on the HTTP request"""
 91        request.headers.update(self.get_auth_header())
 92        return request
 93
 94    @property
 95    def _is_access_token_flow(self) -> bool:
 96        return self.get_token_refresh_endpoint() is None and self.access_token is not None
 97
 98    @property
 99    def token_expiry_is_time_of_expiration(self) -> bool:
100        """
101        Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
102        """
103
104        return False
105
106    @property
107    def token_expiry_date_format(self) -> Optional[str]:
108        """
109        Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
110        """
111
112        return None
113
114    def get_auth_header(self) -> Mapping[str, Any]:
115        """HTTP header to set on the requests"""
116        token = self.access_token if self._is_access_token_flow else self.get_access_token()
117        return {"Authorization": f"Bearer {token}"}
118
119    def get_access_token(self) -> str:
120        """
121        Returns the access token.
122
123        This method uses double-checked locking to ensure thread-safe token refresh.
124        When multiple threads (streams) detect an expired token simultaneously, only one
125        will perform the refresh while others wait. After acquiring the lock, the token
126        expiry is re-checked to avoid redundant refresh attempts.
127        """
128        if self.token_has_expired():
129            with self._token_refresh_lock:
130                # Double-check after acquiring lock - another thread may have already refreshed
131                if self.token_has_expired():
132                    self.refresh_and_set_access_token()
133
134        return self.access_token
135
136    def _current_access_token_or_none(self) -> Optional[str]:
137        """The current access token, or None when the implementation has none to report
138        (e.g., a declarative authenticator whose token has not been initialized yet)."""
139        try:
140            return self.access_token
141        except Exception:
142            return None
143
144    def refresh_and_set_access_token(self) -> None:
145        """Force refresh the access token and update internal state.
146
147        Refreshes regardless of expiry, serialized on the class-level refresh lock. If another
148        thread using this same authenticator instance replaced the access token while this one
149        waited for the lock, the refresh is skipped and the request is retried with that token.
150        `SingleUseRefreshTokenOauth2Authenticator` reads `access_token` from the connector config
151        shared by all stream instances, so the early return also covers separate instances there.
152        Only per-instance token authenticators (base and declarative) are limited to same-instance
153        detection; the Authorization-header check in `HttpClient._handle_error_resolution` covers
154        the rest by skipping the forced refresh when the rejected request's token was already
155        replaced.
156        Subclasses may override this to handle additional state updates (e.g., persisting new
157        refresh tokens).
158        """
159        token_before_waiting = self._current_access_token_or_none()
160        with self._token_refresh_lock:
161            if self._current_access_token_or_none() != token_before_waiting:
162                return
163            token, expires_in = self.refresh_access_token()
164            self.access_token = token
165            self.set_token_expiry_date(expires_in)
166
167    def token_has_expired(self) -> bool:
168        """Returns True if the token is expired"""
169        return ab_datetime_now() > self.get_token_expiry_date()
170
171    def _build_standard_refresh_args(self) -> MutableMapping[str, Any]:
172        """Build the standard OAuth refresh args (grant_type, refresh_token, client
173        credentials, scopes, plus any user-configured `refresh_request_body` extras).
174
175        Used by both `build_refresh_request_body()` and
176        `build_refresh_request_query_params()` so the same set of args can be emitted
177        in either the body or the URL query string depending on
178        `should_send_refresh_request_as_query_params()`.
179
180        Client credentials (client_id and client_secret) are excluded when
181        `refresh_request_headers` contains an `Authorization` header (e.g. Basic
182        auth). This is required by OAuth providers like Gong that expect credentials
183        ONLY in the Authorization header and reject requests that include them in
184        both places.
185        """
186        headers = self.get_refresh_request_headers()
187        credentials_in_header = headers and "Authorization" in headers
188        include_client_credentials = not credentials_in_header
189
190        payload: MutableMapping[str, Any] = {
191            self.get_grant_type_name(): self.get_grant_type(),
192        }
193
194        if include_client_credentials:
195            payload[self.get_client_id_name()] = self.get_client_id()
196            payload[self.get_client_secret_name()] = self.get_client_secret()
197
198        payload[self.get_refresh_token_name()] = self.get_refresh_token()
199
200        if self.get_scopes():
201            payload["scopes"] = self.get_scopes()
202
203        if self.get_refresh_request_body():
204            for key, val in self.get_refresh_request_body().items():
205                # Existing oauth args take precedence over custom configured fields.
206                if key not in payload:
207                    payload[key] = val
208
209        return payload
210
211    def build_refresh_request_body(self) -> Mapping[str, Any]:
212        """Returns the request body to set on the refresh request.
213
214        When `should_send_refresh_request_as_query_params()` is `True`, the standard
215        refresh args are emitted on the URL query string instead and this method
216        returns an empty body. This supports OAuth providers like Gong that document
217        their refresh endpoint as a `POST` with parameters on the URL query string
218        and an empty body.
219        """
220        if self.should_send_refresh_request_as_query_params():
221            return {}
222        return self._build_standard_refresh_args()
223
224    def build_refresh_request_headers(self) -> Mapping[str, Any] | None:
225        """
226        Returns the request headers to set on the refresh request
227
228        """
229        headers = self.get_refresh_request_headers()
230        return headers if headers else None
231
232    def build_refresh_request_query_params(self) -> Mapping[str, Any] | None:
233        """Returns the URL query string parameters to set on the refresh request.
234
235        When `should_send_refresh_request_as_query_params()` is `True`, the standard
236        refresh args (grant_type, refresh_token, client credentials, scopes, plus
237        any user-configured `refresh_request_body` extras) are returned here and
238        `build_refresh_request_body()` returns an empty body.
239
240        Returns `None` otherwise so existing authenticators retain their previous
241        behavior (no query params on the refresh URL).
242        """
243        if not self.should_send_refresh_request_as_query_params():
244            return None
245        return self._build_standard_refresh_args()
246
247    def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
248        """
249        Returns the refresh token and its expiration datetime
250
251        :return: a tuple of (access_token, token_lifespan)
252        """
253        try:
254            response_json = self._make_handled_request()
255        except (
256            requests.exceptions.ConnectionError,
257            requests.exceptions.ConnectTimeout,
258            requests.exceptions.ReadTimeout,
259        ) as e:
260            raise AirbyteTracedException(
261                message="OAuth access token refresh request failed due to a network error.",
262                internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
263                failure_type=FailureType.transient_error,
264            ) from e
265        self._ensure_access_token_in_response(response_json)
266
267        return (
268            self._extract_access_token(response_json),
269            self._extract_token_expiry_date(response_json),
270        )
271
272    # ----------------
273    # PRIVATE METHODS
274    # ----------------
275
276    def _default_token_expiry_date(self) -> AirbyteDateTime:
277        """
278        Returns the default token expiry date
279        """
280        # 1 hour was chosen as a middle ground to avoid unnecessary frequent refreshes and token expiration
281        default_token_expiry_duration_hours = 1  # 1 hour
282        return ab_datetime_now() + timedelta(hours=default_token_expiry_duration_hours)
283
284    @staticmethod
285    def _parse_error_response_content(
286        response: Optional[requests.Response],
287    ) -> Optional[Mapping[str, Any]]:
288        """
289        Best-effort parse of an error response body as a JSON object.
290
291        Returns `None` when the response is missing, empty, not valid JSON, or not a JSON object,
292        so that callers can degrade gracefully instead of raising a new exception while they are
293        already handling an error.
294        """
295        if response is None:
296            return None
297        try:
298            content = response.json()
299        except (JSONDecodeError, ValueError):
300            return None
301        return content if isinstance(content, Mapping) else None
302
303    def _redact_credentials(self, value: str) -> str:
304        """
305        Redact credential material from a string before it is logged or surfaced to the user.
306
307        Only response bodies are passed here, so request headers (including `Authorization`) are
308        never echoed. On top of the config secrets already tracked by the CDK, the authenticator's
309        own refresh token and client secret are redacted explicitly, in case a provider echoes the
310        submitted credentials back in its error payload.
311        """
312        redacted = filter_secrets(value)
313        for get_credential in (self.get_refresh_token, self.get_client_secret):
314            try:
315                credential = get_credential()
316            except Exception:
317                # Never let redaction itself fail the error path we are already in.
318                continue
319            if credential and isinstance(credential, str):
320                redacted = redacted.replace(credential, "****")
321        return redacted
322
323    @staticmethod
324    def _truncate(value: str, max_length: int) -> str:
325        return value if len(value) <= max_length else value[:max_length] + "..."
326
327    @staticmethod
328    def _extract_provider_error_code(
329        response_content: Optional[Mapping[str, Any]],
330    ) -> Optional[str]:
331        if not response_content:
332            return None
333        description = response_content.get("error_description")
334        if isinstance(description, str):
335            code_match = _PROVIDER_ERROR_CODE_PATTERN.match(description.strip())
336            if code_match:
337                return code_match.group()
338        return None
339
340    def _get_refresh_token_error_message(
341        self, response_content: Optional[Mapping[str, Any]]
342    ) -> Optional[str]:
343        if not self._refresh_token_error_messages or not response_content:
344            return None
345        candidates = [self._extract_provider_error_code(response_content)]
346        for key in ("error_description", "error"):
347            value = response_content.get(key)
348            if isinstance(value, str):
349                candidates.append(" ".join(value.split()))
350        for candidate in candidates:
351            if candidate and candidate in self._refresh_token_error_messages:
352                return self._refresh_token_error_messages[candidate]
353        return None
354
355    def _build_provider_error_detail(
356        self, response_content: Optional[Mapping[str, Any]]
357    ) -> Optional[str]:
358        """
359        Build a short, deterministic provider error detail for the user-facing message.
360
361        Only the standard OAuth 2.0 `error` field (RFC 6749 section 5.2) and the provider error
362        code leading `error_description` are used. Both are stable for a given failure, so the same
363        failure produces a byte-identical message on every attempt and the platform groups them
364        into a single failure summary. The description prose is deliberately excluded because it
365        can contain per-request values, which would make the grouping key unbounded. The full
366        response body is preserved in the internal message, which is logged. Which provider errors
367        reach this path at all is set by the authenticator's `refresh_token_error_*` configuration.
368        """
369        if not response_content:
370            return None
371        parts = []
372        error = response_content.get("error")
373        if isinstance(error, str) and error.strip():
374            parts.append(" ".join(error.split()))
375        provider_error_code = self._extract_provider_error_code(response_content)
376        if provider_error_code:
377            parts.append(provider_error_code)
378        if not parts:
379            return None
380        return self._truncate(
381            self._redact_credentials(": ".join(parts)), _PROVIDER_ERROR_DETAIL_MAX_LENGTH
382        )
383
384    def _build_provider_response_info(self, exception: requests.exceptions.RequestException) -> str:
385        """
386        Build the full provider response detail for the internal message, which goes to the logs.
387        """
388        if exception.response is None:
389            return self._redact_credentials(str(exception))
390        body = self._truncate(
391            self._redact_credentials(exception.response.text),
392            _PROVIDER_ERROR_RESPONSE_MAX_LENGTH,
393        )
394        return f"HTTP {exception.response.status_code}: {body}"
395
396    def _wrap_refresh_token_exception(
397        self,
398        exception: requests.exceptions.RequestException,
399        response_content: Optional[Mapping[str, Any]] = None,
400    ) -> bool:
401        """
402        Wraps and handles exceptions that occur during the refresh token process.
403
404        This method checks if the provided exception is related to a refresh token error
405        by examining the response status code and specific error content.
406
407        Args:
408            exception (requests.exceptions.RequestException): The exception raised during the request.
409            response_content (Optional[Mapping[str, Any]]): The already-parsed response body, when
410                the caller has one, so the body is not parsed twice. Parsed on demand otherwise.
411
412        Returns:
413            bool: True if the exception is related to a refresh token error, False otherwise.
414        """
415        if exception.response is None:
416            return False
417        exception_content = (
418            response_content
419            if response_content is not None
420            else self._parse_error_response_content(exception.response)
421        )
422        if exception_content is None:
423            return False
424        return (
425            exception.response.status_code in self._refresh_token_error_status_codes
426            and exception_content.get(self._refresh_token_error_key)
427            in self._refresh_token_error_values
428        )
429
430    @backoff.on_exception(
431        backoff.expo,
432        (
433            DefaultBackoffException,
434            requests.exceptions.ConnectionError,
435            requests.exceptions.ConnectTimeout,
436            requests.exceptions.ReadTimeout,
437        ),
438        on_backoff=lambda details: logger.info(
439            f"Caught retryable error after {details['tries']} tries. Waiting {details['wait']} seconds then retrying..."
440        ),
441        max_time=300,
442    )
443    def _make_handled_request(self) -> Any:
444        """
445        Makes a handled HTTP request to refresh an OAuth token.
446
447        This method sends a POST request to the token refresh endpoint with the necessary
448        headers and body to obtain a new access token. It handles various exceptions that
449        may occur during the request and logs the response for troubleshooting purposes.
450
451        Returns:
452            Mapping[str, Any]: The JSON response from the token refresh endpoint.
453
454        Raises:
455            DefaultBackoffException: If the response status code is 429 (Too Many Requests)
456                                     or any 5xx server error.
457            AirbyteTracedException: If the refresh token is invalid or expired, prompting
458                                    re-authentication.
459            Exception: For any other exceptions that occur during the request.
460        """
461        try:
462            response = requests.request(
463                method="POST",
464                url=self.get_token_refresh_endpoint(),  # type: ignore # returns None, if not provided, but str | bytes is expected.
465                data=self.build_refresh_request_body(),
466                headers=self.build_refresh_request_headers(),
467                params=self.build_refresh_request_query_params(),
468            )
469
470            if not response.ok:
471                # log the response even if the request failed for troubleshooting purposes
472                self._log_response(response)
473                response.raise_for_status()
474
475            response_json = response.json()
476
477            try:
478                # extract the access token and add to secrets to avoid logging the raw value
479                access_key = self._extract_access_token(response_json)
480                if access_key:
481                    add_to_secrets(access_key)
482            except ResponseKeysMaxRecurtionReached as e:
483                # could not find the access token in the response, so do nothing
484                pass
485
486            self._log_response(response)
487
488            return response_json
489        except requests.exceptions.RequestException as e:
490            if e.response is not None:
491                if e.response.status_code == 429 or e.response.status_code >= 500:
492                    raise DefaultBackoffException(
493                        request=e.response.request,
494                        response=e.response,
495                        failure_type=FailureType.transient_error,
496                    )
497            error_content = self._parse_error_response_content(e.response)
498            if self._wrap_refresh_token_exception(e, response_content=error_content):
499                # Configured messages are fixed connector strings for failure-summary grouping.
500                message = (
501                    self._get_refresh_token_error_message(error_content)
502                    or _REFRESH_TOKEN_REJECTED_GUIDANCE
503                )
504                provider_error_detail = self._build_provider_error_detail(error_content)
505                if provider_error_detail:
506                    # The provider's own diagnostic is what tells apart otherwise identical-looking
507                    # failures (revoked grant vs. misconfigured client vs. Conditional Access), so
508                    # a short form of it is appended after the actionable guidance.
509                    message = f"{message} Provider error: {provider_error_detail}"
510                raise AirbyteTracedException(
511                    internal_message=(
512                        "Refresh token rejected by the OAuth token endpoint. "
513                        f"{self._build_provider_response_info(e)}"
514                    ),
515                    message=message,
516                    failure_type=FailureType.config_error,
517                ) from e
518            raise
519        except Exception as e:
520            raise AirbyteTracedException(
521                message="OAuth access token refresh request failed.",
522                internal_message=f"Unexpected error during OAuth token refresh: {e}",
523                failure_type=FailureType.system_error,
524            ) from e
525
526    def _ensure_access_token_in_response(self, response_data: Mapping[str, Any]) -> None:
527        """
528        Ensures that the access token is present in the response data.
529
530        This method attempts to extract the access token from the provided response data.
531        If the access token is not found, it raises an exception indicating that the token
532        refresh API response was missing the access token.
533
534        Args:
535            response_data (Mapping[str, Any]): The response data from which to extract the access token.
536
537        Raises:
538            Exception: If the access token is not found in the response data.
539            ResponseKeysMaxRecurtionReached: If the maximum recursion depth is reached while extracting the access token.
540        """
541        try:
542            access_key = self._extract_access_token(response_data)
543            if not access_key:
544                raise Exception(
545                    f"Token refresh API response was missing access token {self.get_access_token_name()}"
546                )
547        except ResponseKeysMaxRecurtionReached as e:
548            raise e
549
550    def _parse_token_expiration_date(self, value: Union[str, int]) -> AirbyteDateTime:
551        """
552        Parse a string or integer token expiration date into a datetime object. A datetime, which
553        `refresh_access_token` returns since 6.45.5, is passed through for setters written earlier.
554
555        :return: expiration datetime
556        """
557        if isinstance(value, datetime):
558            return AirbyteDateTime.from_datetime(value)
559        if self.token_expiry_is_time_of_expiration:
560            if not self.token_expiry_date_format:
561                raise ValueError(
562                    f"Invalid token expiry date format {self.token_expiry_date_format}; a string representing the format is required."
563                )
564            try:
565                return ab_datetime_parse(str(value))
566            except ValueError as e:
567                raise ValueError(f"Invalid token expiry date format: {e}")
568        else:
569            try:
570                # Only accept numeric values (as int/float/string) when no format specified
571                seconds = int(float(str(value)))
572                return ab_datetime_now() + timedelta(seconds=seconds)
573            except (ValueError, TypeError):
574                raise ValueError(
575                    f"Invalid expires_in value: {value}. Expected number of seconds when no format specified."
576                )
577
578    def _extract_access_token(self, response_data: Mapping[str, Any]) -> Any:
579        """
580        Extracts the access token from the given response data.
581
582        Args:
583            response_data (Mapping[str, Any]): The response data from which to extract the access token.
584
585        Returns:
586            str: The extracted access token.
587        """
588        return self._find_and_get_value_from_response(response_data, self.get_access_token_name())
589
590    def _extract_refresh_token(self, response_data: Mapping[str, Any]) -> Any:
591        """
592        Extracts the refresh token from the given response data.
593
594        Args:
595            response_data (Mapping[str, Any]): The response data from which to extract the refresh token.
596
597        Returns:
598            str: The extracted refresh token.
599        """
600        return self._find_and_get_value_from_response(response_data, self.get_refresh_token_name())
601
602    def _extract_token_expiry_date(self, response_data: Mapping[str, Any]) -> AirbyteDateTime:
603        """
604        Extracts the token_expiry_date, like `expires_in` or `expires_at`, etc from the given response data.
605
606        If the token_expiry_date is not found, it will return an existing token expiry date if set, or a default token expiry date.
607
608        Args:
609            response_data (Mapping[str, Any]): The response data from which to extract the token_expiry_date.
610
611        Returns:
612            The extracted token_expiry_date or None if not found.
613        """
614        expires_in = self._find_and_get_value_from_response(
615            response_data, self.get_expires_in_name()
616        )
617        if expires_in is not None:
618            return self._parse_token_expiration_date(expires_in)
619
620        # expires_in is None
621        existing_expiry_date = self.get_token_expiry_date()
622        if existing_expiry_date and not self.token_has_expired():
623            return existing_expiry_date
624
625        return self._default_token_expiry_date()
626
627    def _find_and_get_value_from_response(
628        self,
629        response_data: Mapping[str, Any],
630        key_name: str,
631        max_depth: int = 5,
632        current_depth: int = 0,
633    ) -> Any:
634        """
635        Recursively searches for a specified key in a nested dictionary or list and returns its value if found.
636
637        Args:
638            response_data (Mapping[str, Any]): The response data to search through, which can be a dictionary or a list.
639            key_name (str): The key to search for in the response data.
640            max_depth (int, optional): The maximum depth to search for the key to avoid infinite recursion. Defaults to 5.
641            current_depth (int, optional): The current depth of the recursion. Defaults to 0.
642
643        Returns:
644            Any: The value associated with the specified key if found, otherwise None.
645
646        Raises:
647            AirbyteTracedException: If the maximum recursion depth is reached without finding the key.
648        """
649        if current_depth > max_depth:
650            # this is needed to avoid an inf loop, possible with a very deep nesting observed.
651            message = f"The maximum level of recursion is reached. Couldn't find the specified `{key_name}` in the response."
652            raise ResponseKeysMaxRecurtionReached(
653                internal_message=message, message=message, failure_type=FailureType.config_error
654            )
655
656        if isinstance(response_data, dict):
657            # get from the root level
658            if key_name in response_data:
659                return response_data[key_name]
660
661            # get from the nested object
662            for _, value in response_data.items():
663                result = self._find_and_get_value_from_response(
664                    value, key_name, max_depth, current_depth + 1
665                )
666                if result is not None:
667                    return result
668
669        # get from the nested array object
670        elif isinstance(response_data, list):
671            for item in response_data:
672                result = self._find_and_get_value_from_response(
673                    item, key_name, max_depth, current_depth + 1
674                )
675                if result is not None:
676                    return result
677
678        return None
679
680    @property
681    def _message_repository(self) -> Optional[MessageRepository]:
682        """
683        The implementation can define a message_repository if it wants debugging logs for HTTP requests
684        """
685        return _NOOP_MESSAGE_REPOSITORY
686
687    def _log_response(self, response: requests.Response) -> None:
688        """
689        Logs the HTTP response using the message repository if it is available.
690
691        Args:
692            response (requests.Response): The HTTP response to log.
693        """
694        if self._message_repository:
695            self._message_repository.log_message(
696                Level.DEBUG,
697                lambda: format_http_message(
698                    response,
699                    "Refresh token",
700                    "Obtains access token",
701                    self._NO_STREAM_NAME,
702                    is_auxiliary=True,
703                    type="AUTH",
704                ),
705            )
706
707    # ----------------
708    # ABSTR METHODS
709    # ----------------
710
711    @abstractmethod
712    def get_token_refresh_endpoint(self) -> Optional[str]:
713        """Returns the endpoint to refresh the access token"""
714
715    @abstractmethod
716    def get_client_id_name(self) -> str:
717        """The client id name to authenticate"""
718
719    @abstractmethod
720    def get_client_id(self) -> str:
721        """The client id to authenticate"""
722
723    @abstractmethod
724    def get_client_secret_name(self) -> str:
725        """The client secret name to authenticate"""
726
727    @abstractmethod
728    def get_client_secret(self) -> str:
729        """The client secret to authenticate"""
730
731    @abstractmethod
732    def get_refresh_token_name(self) -> str:
733        """The refresh token name to authenticate"""
734
735    @abstractmethod
736    def get_refresh_token(self) -> Optional[str]:
737        """The token used to refresh the access token when it expires"""
738
739    @abstractmethod
740    def get_scopes(self) -> List[str]:
741        """List of requested scopes"""
742
743    @abstractmethod
744    def get_token_expiry_date(self) -> AirbyteDateTime:
745        """Expiration date of the access token"""
746
747    @abstractmethod
748    def set_token_expiry_date(self, value: AirbyteDateTime) -> None:
749        """Setter for access token expiration date"""
750
751    @abstractmethod
752    def get_access_token_name(self) -> str:
753        """Field to extract access token from in the response"""
754
755    @abstractmethod
756    def get_expires_in_name(self) -> str:
757        """Returns the expires_in field name"""
758
759    @abstractmethod
760    def get_refresh_request_body(self) -> Mapping[str, Any]:
761        """Returns the request body to set on the refresh request"""
762
763    @abstractmethod
764    def get_refresh_request_headers(self) -> Mapping[str, Any]:
765        """Returns the request headers to set on the refresh request"""
766
767    def should_send_refresh_request_as_query_params(self) -> bool:
768        """Returns `True` if the standard refresh args should be sent on the URL
769        query string instead of in the request body.
770
771        Defaults to `False` so existing authenticators retain their previous
772        behavior (params in body, no query params on the refresh URL). Subclasses
773        can override this to opt into the URL-query-string shape required by OAuth
774        providers like Gong.
775        """
776        return False
777
778    @abstractmethod
779    def get_grant_type(self) -> str:
780        """Returns grant_type specified for requesting access_token"""
781
782    @abstractmethod
783    def get_grant_type_name(self) -> str:
784        """Returns grant_type specified name for requesting access_token"""
785
786    @property
787    @abstractmethod
788    def access_token(self) -> str:
789        """Returns the access token"""
790
791    @access_token.setter
792    @abstractmethod
793    def access_token(self, value: str) -> str:
794        """Setter for the access token"""
logger = <Logger airbyte (INFO)>
class ResponseKeysMaxRecurtionReached(airbyte_cdk.utils.traced_exception.AirbyteTracedException):
48class ResponseKeysMaxRecurtionReached(AirbyteTracedException):
49    """
50    Raised when the max level of recursion is reached, when trying to
51    find-and-get the target key, during the `_make_handled_request`
52    """

Raised when the max level of recursion is reached, when trying to find-and-get the target key, during the _make_handled_request

class AbstractOauth2Authenticator(requests.auth.AuthBase):
 55class AbstractOauth2Authenticator(AuthBase):
 56    """
 57    Abstract class for an OAuth authenticators that implements the OAuth token refresh flow. The authenticator
 58    is designed to generically perform the refresh flow without regard to how config fields are get/set by
 59    delegating that behavior to the classes implementing the interface.
 60    """
 61
 62    _NO_STREAM_NAME = None
 63
 64    # Class-level lock to prevent concurrent token refresh across multiple authenticator instances.
 65    # This is necessary because multiple streams may share the same OAuth credentials (refresh token)
 66    # through the connector config. Without this lock, concurrent refresh attempts can cause race
 67    # conditions where one stream successfully refreshes the token while others fail because the
 68    # refresh token has been invalidated (especially for single-use refresh tokens).
 69    _token_refresh_lock = threading.RLock()
 70
 71    def __init__(
 72        self,
 73        refresh_token_error_status_codes: Tuple[int, ...] = (),
 74        refresh_token_error_key: str = "",
 75        refresh_token_error_values: Tuple[str, ...] = (),
 76        refresh_token_error_messages: Optional[Mapping[str, str]] = None,
 77    ) -> None:
 78        """
 79        If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set,
 80        then http errors with such params will be wrapped in AirbyteTracedException.
 81        refresh_token_error_messages maps fixed provider error strings to fixed user-facing messages.
 82        """
 83        self._refresh_token_error_status_codes = refresh_token_error_status_codes
 84        self._refresh_token_error_key = refresh_token_error_key
 85        self._refresh_token_error_values = refresh_token_error_values
 86        self._refresh_token_error_messages: Mapping[str, str] = dict(
 87            refresh_token_error_messages or {}
 88        )
 89
 90    def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
 91        """Attach the HTTP headers required to authenticate on the HTTP request"""
 92        request.headers.update(self.get_auth_header())
 93        return request
 94
 95    @property
 96    def _is_access_token_flow(self) -> bool:
 97        return self.get_token_refresh_endpoint() is None and self.access_token is not None
 98
 99    @property
100    def token_expiry_is_time_of_expiration(self) -> bool:
101        """
102        Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
103        """
104
105        return False
106
107    @property
108    def token_expiry_date_format(self) -> Optional[str]:
109        """
110        Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
111        """
112
113        return None
114
115    def get_auth_header(self) -> Mapping[str, Any]:
116        """HTTP header to set on the requests"""
117        token = self.access_token if self._is_access_token_flow else self.get_access_token()
118        return {"Authorization": f"Bearer {token}"}
119
120    def get_access_token(self) -> str:
121        """
122        Returns the access token.
123
124        This method uses double-checked locking to ensure thread-safe token refresh.
125        When multiple threads (streams) detect an expired token simultaneously, only one
126        will perform the refresh while others wait. After acquiring the lock, the token
127        expiry is re-checked to avoid redundant refresh attempts.
128        """
129        if self.token_has_expired():
130            with self._token_refresh_lock:
131                # Double-check after acquiring lock - another thread may have already refreshed
132                if self.token_has_expired():
133                    self.refresh_and_set_access_token()
134
135        return self.access_token
136
137    def _current_access_token_or_none(self) -> Optional[str]:
138        """The current access token, or None when the implementation has none to report
139        (e.g., a declarative authenticator whose token has not been initialized yet)."""
140        try:
141            return self.access_token
142        except Exception:
143            return None
144
145    def refresh_and_set_access_token(self) -> None:
146        """Force refresh the access token and update internal state.
147
148        Refreshes regardless of expiry, serialized on the class-level refresh lock. If another
149        thread using this same authenticator instance replaced the access token while this one
150        waited for the lock, the refresh is skipped and the request is retried with that token.
151        `SingleUseRefreshTokenOauth2Authenticator` reads `access_token` from the connector config
152        shared by all stream instances, so the early return also covers separate instances there.
153        Only per-instance token authenticators (base and declarative) are limited to same-instance
154        detection; the Authorization-header check in `HttpClient._handle_error_resolution` covers
155        the rest by skipping the forced refresh when the rejected request's token was already
156        replaced.
157        Subclasses may override this to handle additional state updates (e.g., persisting new
158        refresh tokens).
159        """
160        token_before_waiting = self._current_access_token_or_none()
161        with self._token_refresh_lock:
162            if self._current_access_token_or_none() != token_before_waiting:
163                return
164            token, expires_in = self.refresh_access_token()
165            self.access_token = token
166            self.set_token_expiry_date(expires_in)
167
168    def token_has_expired(self) -> bool:
169        """Returns True if the token is expired"""
170        return ab_datetime_now() > self.get_token_expiry_date()
171
172    def _build_standard_refresh_args(self) -> MutableMapping[str, Any]:
173        """Build the standard OAuth refresh args (grant_type, refresh_token, client
174        credentials, scopes, plus any user-configured `refresh_request_body` extras).
175
176        Used by both `build_refresh_request_body()` and
177        `build_refresh_request_query_params()` so the same set of args can be emitted
178        in either the body or the URL query string depending on
179        `should_send_refresh_request_as_query_params()`.
180
181        Client credentials (client_id and client_secret) are excluded when
182        `refresh_request_headers` contains an `Authorization` header (e.g. Basic
183        auth). This is required by OAuth providers like Gong that expect credentials
184        ONLY in the Authorization header and reject requests that include them in
185        both places.
186        """
187        headers = self.get_refresh_request_headers()
188        credentials_in_header = headers and "Authorization" in headers
189        include_client_credentials = not credentials_in_header
190
191        payload: MutableMapping[str, Any] = {
192            self.get_grant_type_name(): self.get_grant_type(),
193        }
194
195        if include_client_credentials:
196            payload[self.get_client_id_name()] = self.get_client_id()
197            payload[self.get_client_secret_name()] = self.get_client_secret()
198
199        payload[self.get_refresh_token_name()] = self.get_refresh_token()
200
201        if self.get_scopes():
202            payload["scopes"] = self.get_scopes()
203
204        if self.get_refresh_request_body():
205            for key, val in self.get_refresh_request_body().items():
206                # Existing oauth args take precedence over custom configured fields.
207                if key not in payload:
208                    payload[key] = val
209
210        return payload
211
212    def build_refresh_request_body(self) -> Mapping[str, Any]:
213        """Returns the request body to set on the refresh request.
214
215        When `should_send_refresh_request_as_query_params()` is `True`, the standard
216        refresh args are emitted on the URL query string instead and this method
217        returns an empty body. This supports OAuth providers like Gong that document
218        their refresh endpoint as a `POST` with parameters on the URL query string
219        and an empty body.
220        """
221        if self.should_send_refresh_request_as_query_params():
222            return {}
223        return self._build_standard_refresh_args()
224
225    def build_refresh_request_headers(self) -> Mapping[str, Any] | None:
226        """
227        Returns the request headers to set on the refresh request
228
229        """
230        headers = self.get_refresh_request_headers()
231        return headers if headers else None
232
233    def build_refresh_request_query_params(self) -> Mapping[str, Any] | None:
234        """Returns the URL query string parameters to set on the refresh request.
235
236        When `should_send_refresh_request_as_query_params()` is `True`, the standard
237        refresh args (grant_type, refresh_token, client credentials, scopes, plus
238        any user-configured `refresh_request_body` extras) are returned here and
239        `build_refresh_request_body()` returns an empty body.
240
241        Returns `None` otherwise so existing authenticators retain their previous
242        behavior (no query params on the refresh URL).
243        """
244        if not self.should_send_refresh_request_as_query_params():
245            return None
246        return self._build_standard_refresh_args()
247
248    def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
249        """
250        Returns the refresh token and its expiration datetime
251
252        :return: a tuple of (access_token, token_lifespan)
253        """
254        try:
255            response_json = self._make_handled_request()
256        except (
257            requests.exceptions.ConnectionError,
258            requests.exceptions.ConnectTimeout,
259            requests.exceptions.ReadTimeout,
260        ) as e:
261            raise AirbyteTracedException(
262                message="OAuth access token refresh request failed due to a network error.",
263                internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
264                failure_type=FailureType.transient_error,
265            ) from e
266        self._ensure_access_token_in_response(response_json)
267
268        return (
269            self._extract_access_token(response_json),
270            self._extract_token_expiry_date(response_json),
271        )
272
273    # ----------------
274    # PRIVATE METHODS
275    # ----------------
276
277    def _default_token_expiry_date(self) -> AirbyteDateTime:
278        """
279        Returns the default token expiry date
280        """
281        # 1 hour was chosen as a middle ground to avoid unnecessary frequent refreshes and token expiration
282        default_token_expiry_duration_hours = 1  # 1 hour
283        return ab_datetime_now() + timedelta(hours=default_token_expiry_duration_hours)
284
285    @staticmethod
286    def _parse_error_response_content(
287        response: Optional[requests.Response],
288    ) -> Optional[Mapping[str, Any]]:
289        """
290        Best-effort parse of an error response body as a JSON object.
291
292        Returns `None` when the response is missing, empty, not valid JSON, or not a JSON object,
293        so that callers can degrade gracefully instead of raising a new exception while they are
294        already handling an error.
295        """
296        if response is None:
297            return None
298        try:
299            content = response.json()
300        except (JSONDecodeError, ValueError):
301            return None
302        return content if isinstance(content, Mapping) else None
303
304    def _redact_credentials(self, value: str) -> str:
305        """
306        Redact credential material from a string before it is logged or surfaced to the user.
307
308        Only response bodies are passed here, so request headers (including `Authorization`) are
309        never echoed. On top of the config secrets already tracked by the CDK, the authenticator's
310        own refresh token and client secret are redacted explicitly, in case a provider echoes the
311        submitted credentials back in its error payload.
312        """
313        redacted = filter_secrets(value)
314        for get_credential in (self.get_refresh_token, self.get_client_secret):
315            try:
316                credential = get_credential()
317            except Exception:
318                # Never let redaction itself fail the error path we are already in.
319                continue
320            if credential and isinstance(credential, str):
321                redacted = redacted.replace(credential, "****")
322        return redacted
323
324    @staticmethod
325    def _truncate(value: str, max_length: int) -> str:
326        return value if len(value) <= max_length else value[:max_length] + "..."
327
328    @staticmethod
329    def _extract_provider_error_code(
330        response_content: Optional[Mapping[str, Any]],
331    ) -> Optional[str]:
332        if not response_content:
333            return None
334        description = response_content.get("error_description")
335        if isinstance(description, str):
336            code_match = _PROVIDER_ERROR_CODE_PATTERN.match(description.strip())
337            if code_match:
338                return code_match.group()
339        return None
340
341    def _get_refresh_token_error_message(
342        self, response_content: Optional[Mapping[str, Any]]
343    ) -> Optional[str]:
344        if not self._refresh_token_error_messages or not response_content:
345            return None
346        candidates = [self._extract_provider_error_code(response_content)]
347        for key in ("error_description", "error"):
348            value = response_content.get(key)
349            if isinstance(value, str):
350                candidates.append(" ".join(value.split()))
351        for candidate in candidates:
352            if candidate and candidate in self._refresh_token_error_messages:
353                return self._refresh_token_error_messages[candidate]
354        return None
355
356    def _build_provider_error_detail(
357        self, response_content: Optional[Mapping[str, Any]]
358    ) -> Optional[str]:
359        """
360        Build a short, deterministic provider error detail for the user-facing message.
361
362        Only the standard OAuth 2.0 `error` field (RFC 6749 section 5.2) and the provider error
363        code leading `error_description` are used. Both are stable for a given failure, so the same
364        failure produces a byte-identical message on every attempt and the platform groups them
365        into a single failure summary. The description prose is deliberately excluded because it
366        can contain per-request values, which would make the grouping key unbounded. The full
367        response body is preserved in the internal message, which is logged. Which provider errors
368        reach this path at all is set by the authenticator's `refresh_token_error_*` configuration.
369        """
370        if not response_content:
371            return None
372        parts = []
373        error = response_content.get("error")
374        if isinstance(error, str) and error.strip():
375            parts.append(" ".join(error.split()))
376        provider_error_code = self._extract_provider_error_code(response_content)
377        if provider_error_code:
378            parts.append(provider_error_code)
379        if not parts:
380            return None
381        return self._truncate(
382            self._redact_credentials(": ".join(parts)), _PROVIDER_ERROR_DETAIL_MAX_LENGTH
383        )
384
385    def _build_provider_response_info(self, exception: requests.exceptions.RequestException) -> str:
386        """
387        Build the full provider response detail for the internal message, which goes to the logs.
388        """
389        if exception.response is None:
390            return self._redact_credentials(str(exception))
391        body = self._truncate(
392            self._redact_credentials(exception.response.text),
393            _PROVIDER_ERROR_RESPONSE_MAX_LENGTH,
394        )
395        return f"HTTP {exception.response.status_code}: {body}"
396
397    def _wrap_refresh_token_exception(
398        self,
399        exception: requests.exceptions.RequestException,
400        response_content: Optional[Mapping[str, Any]] = None,
401    ) -> bool:
402        """
403        Wraps and handles exceptions that occur during the refresh token process.
404
405        This method checks if the provided exception is related to a refresh token error
406        by examining the response status code and specific error content.
407
408        Args:
409            exception (requests.exceptions.RequestException): The exception raised during the request.
410            response_content (Optional[Mapping[str, Any]]): The already-parsed response body, when
411                the caller has one, so the body is not parsed twice. Parsed on demand otherwise.
412
413        Returns:
414            bool: True if the exception is related to a refresh token error, False otherwise.
415        """
416        if exception.response is None:
417            return False
418        exception_content = (
419            response_content
420            if response_content is not None
421            else self._parse_error_response_content(exception.response)
422        )
423        if exception_content is None:
424            return False
425        return (
426            exception.response.status_code in self._refresh_token_error_status_codes
427            and exception_content.get(self._refresh_token_error_key)
428            in self._refresh_token_error_values
429        )
430
431    @backoff.on_exception(
432        backoff.expo,
433        (
434            DefaultBackoffException,
435            requests.exceptions.ConnectionError,
436            requests.exceptions.ConnectTimeout,
437            requests.exceptions.ReadTimeout,
438        ),
439        on_backoff=lambda details: logger.info(
440            f"Caught retryable error after {details['tries']} tries. Waiting {details['wait']} seconds then retrying..."
441        ),
442        max_time=300,
443    )
444    def _make_handled_request(self) -> Any:
445        """
446        Makes a handled HTTP request to refresh an OAuth token.
447
448        This method sends a POST request to the token refresh endpoint with the necessary
449        headers and body to obtain a new access token. It handles various exceptions that
450        may occur during the request and logs the response for troubleshooting purposes.
451
452        Returns:
453            Mapping[str, Any]: The JSON response from the token refresh endpoint.
454
455        Raises:
456            DefaultBackoffException: If the response status code is 429 (Too Many Requests)
457                                     or any 5xx server error.
458            AirbyteTracedException: If the refresh token is invalid or expired, prompting
459                                    re-authentication.
460            Exception: For any other exceptions that occur during the request.
461        """
462        try:
463            response = requests.request(
464                method="POST",
465                url=self.get_token_refresh_endpoint(),  # type: ignore # returns None, if not provided, but str | bytes is expected.
466                data=self.build_refresh_request_body(),
467                headers=self.build_refresh_request_headers(),
468                params=self.build_refresh_request_query_params(),
469            )
470
471            if not response.ok:
472                # log the response even if the request failed for troubleshooting purposes
473                self._log_response(response)
474                response.raise_for_status()
475
476            response_json = response.json()
477
478            try:
479                # extract the access token and add to secrets to avoid logging the raw value
480                access_key = self._extract_access_token(response_json)
481                if access_key:
482                    add_to_secrets(access_key)
483            except ResponseKeysMaxRecurtionReached as e:
484                # could not find the access token in the response, so do nothing
485                pass
486
487            self._log_response(response)
488
489            return response_json
490        except requests.exceptions.RequestException as e:
491            if e.response is not None:
492                if e.response.status_code == 429 or e.response.status_code >= 500:
493                    raise DefaultBackoffException(
494                        request=e.response.request,
495                        response=e.response,
496                        failure_type=FailureType.transient_error,
497                    )
498            error_content = self._parse_error_response_content(e.response)
499            if self._wrap_refresh_token_exception(e, response_content=error_content):
500                # Configured messages are fixed connector strings for failure-summary grouping.
501                message = (
502                    self._get_refresh_token_error_message(error_content)
503                    or _REFRESH_TOKEN_REJECTED_GUIDANCE
504                )
505                provider_error_detail = self._build_provider_error_detail(error_content)
506                if provider_error_detail:
507                    # The provider's own diagnostic is what tells apart otherwise identical-looking
508                    # failures (revoked grant vs. misconfigured client vs. Conditional Access), so
509                    # a short form of it is appended after the actionable guidance.
510                    message = f"{message} Provider error: {provider_error_detail}"
511                raise AirbyteTracedException(
512                    internal_message=(
513                        "Refresh token rejected by the OAuth token endpoint. "
514                        f"{self._build_provider_response_info(e)}"
515                    ),
516                    message=message,
517                    failure_type=FailureType.config_error,
518                ) from e
519            raise
520        except Exception as e:
521            raise AirbyteTracedException(
522                message="OAuth access token refresh request failed.",
523                internal_message=f"Unexpected error during OAuth token refresh: {e}",
524                failure_type=FailureType.system_error,
525            ) from e
526
527    def _ensure_access_token_in_response(self, response_data: Mapping[str, Any]) -> None:
528        """
529        Ensures that the access token is present in the response data.
530
531        This method attempts to extract the access token from the provided response data.
532        If the access token is not found, it raises an exception indicating that the token
533        refresh API response was missing the access token.
534
535        Args:
536            response_data (Mapping[str, Any]): The response data from which to extract the access token.
537
538        Raises:
539            Exception: If the access token is not found in the response data.
540            ResponseKeysMaxRecurtionReached: If the maximum recursion depth is reached while extracting the access token.
541        """
542        try:
543            access_key = self._extract_access_token(response_data)
544            if not access_key:
545                raise Exception(
546                    f"Token refresh API response was missing access token {self.get_access_token_name()}"
547                )
548        except ResponseKeysMaxRecurtionReached as e:
549            raise e
550
551    def _parse_token_expiration_date(self, value: Union[str, int]) -> AirbyteDateTime:
552        """
553        Parse a string or integer token expiration date into a datetime object. A datetime, which
554        `refresh_access_token` returns since 6.45.5, is passed through for setters written earlier.
555
556        :return: expiration datetime
557        """
558        if isinstance(value, datetime):
559            return AirbyteDateTime.from_datetime(value)
560        if self.token_expiry_is_time_of_expiration:
561            if not self.token_expiry_date_format:
562                raise ValueError(
563                    f"Invalid token expiry date format {self.token_expiry_date_format}; a string representing the format is required."
564                )
565            try:
566                return ab_datetime_parse(str(value))
567            except ValueError as e:
568                raise ValueError(f"Invalid token expiry date format: {e}")
569        else:
570            try:
571                # Only accept numeric values (as int/float/string) when no format specified
572                seconds = int(float(str(value)))
573                return ab_datetime_now() + timedelta(seconds=seconds)
574            except (ValueError, TypeError):
575                raise ValueError(
576                    f"Invalid expires_in value: {value}. Expected number of seconds when no format specified."
577                )
578
579    def _extract_access_token(self, response_data: Mapping[str, Any]) -> Any:
580        """
581        Extracts the access token from the given response data.
582
583        Args:
584            response_data (Mapping[str, Any]): The response data from which to extract the access token.
585
586        Returns:
587            str: The extracted access token.
588        """
589        return self._find_and_get_value_from_response(response_data, self.get_access_token_name())
590
591    def _extract_refresh_token(self, response_data: Mapping[str, Any]) -> Any:
592        """
593        Extracts the refresh token from the given response data.
594
595        Args:
596            response_data (Mapping[str, Any]): The response data from which to extract the refresh token.
597
598        Returns:
599            str: The extracted refresh token.
600        """
601        return self._find_and_get_value_from_response(response_data, self.get_refresh_token_name())
602
603    def _extract_token_expiry_date(self, response_data: Mapping[str, Any]) -> AirbyteDateTime:
604        """
605        Extracts the token_expiry_date, like `expires_in` or `expires_at`, etc from the given response data.
606
607        If the token_expiry_date is not found, it will return an existing token expiry date if set, or a default token expiry date.
608
609        Args:
610            response_data (Mapping[str, Any]): The response data from which to extract the token_expiry_date.
611
612        Returns:
613            The extracted token_expiry_date or None if not found.
614        """
615        expires_in = self._find_and_get_value_from_response(
616            response_data, self.get_expires_in_name()
617        )
618        if expires_in is not None:
619            return self._parse_token_expiration_date(expires_in)
620
621        # expires_in is None
622        existing_expiry_date = self.get_token_expiry_date()
623        if existing_expiry_date and not self.token_has_expired():
624            return existing_expiry_date
625
626        return self._default_token_expiry_date()
627
628    def _find_and_get_value_from_response(
629        self,
630        response_data: Mapping[str, Any],
631        key_name: str,
632        max_depth: int = 5,
633        current_depth: int = 0,
634    ) -> Any:
635        """
636        Recursively searches for a specified key in a nested dictionary or list and returns its value if found.
637
638        Args:
639            response_data (Mapping[str, Any]): The response data to search through, which can be a dictionary or a list.
640            key_name (str): The key to search for in the response data.
641            max_depth (int, optional): The maximum depth to search for the key to avoid infinite recursion. Defaults to 5.
642            current_depth (int, optional): The current depth of the recursion. Defaults to 0.
643
644        Returns:
645            Any: The value associated with the specified key if found, otherwise None.
646
647        Raises:
648            AirbyteTracedException: If the maximum recursion depth is reached without finding the key.
649        """
650        if current_depth > max_depth:
651            # this is needed to avoid an inf loop, possible with a very deep nesting observed.
652            message = f"The maximum level of recursion is reached. Couldn't find the specified `{key_name}` in the response."
653            raise ResponseKeysMaxRecurtionReached(
654                internal_message=message, message=message, failure_type=FailureType.config_error
655            )
656
657        if isinstance(response_data, dict):
658            # get from the root level
659            if key_name in response_data:
660                return response_data[key_name]
661
662            # get from the nested object
663            for _, value in response_data.items():
664                result = self._find_and_get_value_from_response(
665                    value, key_name, max_depth, current_depth + 1
666                )
667                if result is not None:
668                    return result
669
670        # get from the nested array object
671        elif isinstance(response_data, list):
672            for item in response_data:
673                result = self._find_and_get_value_from_response(
674                    item, key_name, max_depth, current_depth + 1
675                )
676                if result is not None:
677                    return result
678
679        return None
680
681    @property
682    def _message_repository(self) -> Optional[MessageRepository]:
683        """
684        The implementation can define a message_repository if it wants debugging logs for HTTP requests
685        """
686        return _NOOP_MESSAGE_REPOSITORY
687
688    def _log_response(self, response: requests.Response) -> None:
689        """
690        Logs the HTTP response using the message repository if it is available.
691
692        Args:
693            response (requests.Response): The HTTP response to log.
694        """
695        if self._message_repository:
696            self._message_repository.log_message(
697                Level.DEBUG,
698                lambda: format_http_message(
699                    response,
700                    "Refresh token",
701                    "Obtains access token",
702                    self._NO_STREAM_NAME,
703                    is_auxiliary=True,
704                    type="AUTH",
705                ),
706            )
707
708    # ----------------
709    # ABSTR METHODS
710    # ----------------
711
712    @abstractmethod
713    def get_token_refresh_endpoint(self) -> Optional[str]:
714        """Returns the endpoint to refresh the access token"""
715
716    @abstractmethod
717    def get_client_id_name(self) -> str:
718        """The client id name to authenticate"""
719
720    @abstractmethod
721    def get_client_id(self) -> str:
722        """The client id to authenticate"""
723
724    @abstractmethod
725    def get_client_secret_name(self) -> str:
726        """The client secret name to authenticate"""
727
728    @abstractmethod
729    def get_client_secret(self) -> str:
730        """The client secret to authenticate"""
731
732    @abstractmethod
733    def get_refresh_token_name(self) -> str:
734        """The refresh token name to authenticate"""
735
736    @abstractmethod
737    def get_refresh_token(self) -> Optional[str]:
738        """The token used to refresh the access token when it expires"""
739
740    @abstractmethod
741    def get_scopes(self) -> List[str]:
742        """List of requested scopes"""
743
744    @abstractmethod
745    def get_token_expiry_date(self) -> AirbyteDateTime:
746        """Expiration date of the access token"""
747
748    @abstractmethod
749    def set_token_expiry_date(self, value: AirbyteDateTime) -> None:
750        """Setter for access token expiration date"""
751
752    @abstractmethod
753    def get_access_token_name(self) -> str:
754        """Field to extract access token from in the response"""
755
756    @abstractmethod
757    def get_expires_in_name(self) -> str:
758        """Returns the expires_in field name"""
759
760    @abstractmethod
761    def get_refresh_request_body(self) -> Mapping[str, Any]:
762        """Returns the request body to set on the refresh request"""
763
764    @abstractmethod
765    def get_refresh_request_headers(self) -> Mapping[str, Any]:
766        """Returns the request headers to set on the refresh request"""
767
768    def should_send_refresh_request_as_query_params(self) -> bool:
769        """Returns `True` if the standard refresh args should be sent on the URL
770        query string instead of in the request body.
771
772        Defaults to `False` so existing authenticators retain their previous
773        behavior (params in body, no query params on the refresh URL). Subclasses
774        can override this to opt into the URL-query-string shape required by OAuth
775        providers like Gong.
776        """
777        return False
778
779    @abstractmethod
780    def get_grant_type(self) -> str:
781        """Returns grant_type specified for requesting access_token"""
782
783    @abstractmethod
784    def get_grant_type_name(self) -> str:
785        """Returns grant_type specified name for requesting access_token"""
786
787    @property
788    @abstractmethod
789    def access_token(self) -> str:
790        """Returns the access token"""
791
792    @access_token.setter
793    @abstractmethod
794    def access_token(self, value: str) -> str:
795        """Setter for the access token"""

Abstract class for an OAuth authenticators that implements the OAuth token refresh flow. The authenticator is designed to generically perform the refresh flow without regard to how config fields are get/set by delegating that behavior to the classes implementing the interface.

AbstractOauth2Authenticator( refresh_token_error_status_codes: Tuple[int, ...] = (), refresh_token_error_key: str = '', refresh_token_error_values: Tuple[str, ...] = (), refresh_token_error_messages: Optional[Mapping[str, str]] = None)
71    def __init__(
72        self,
73        refresh_token_error_status_codes: Tuple[int, ...] = (),
74        refresh_token_error_key: str = "",
75        refresh_token_error_values: Tuple[str, ...] = (),
76        refresh_token_error_messages: Optional[Mapping[str, str]] = None,
77    ) -> None:
78        """
79        If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set,
80        then http errors with such params will be wrapped in AirbyteTracedException.
81        refresh_token_error_messages maps fixed provider error strings to fixed user-facing messages.
82        """
83        self._refresh_token_error_status_codes = refresh_token_error_status_codes
84        self._refresh_token_error_key = refresh_token_error_key
85        self._refresh_token_error_values = refresh_token_error_values
86        self._refresh_token_error_messages: Mapping[str, str] = dict(
87            refresh_token_error_messages or {}
88        )

If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set, then http errors with such params will be wrapped in AirbyteTracedException. refresh_token_error_messages maps fixed provider error strings to fixed user-facing messages.

token_expiry_is_time_of_expiration: bool
 99    @property
100    def token_expiry_is_time_of_expiration(self) -> bool:
101        """
102        Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
103        """
104
105        return False

Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.

token_expiry_date_format: Optional[str]
107    @property
108    def token_expiry_date_format(self) -> Optional[str]:
109        """
110        Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
111        """
112
113        return None

Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires

def get_auth_header(self) -> Mapping[str, Any]:
115    def get_auth_header(self) -> Mapping[str, Any]:
116        """HTTP header to set on the requests"""
117        token = self.access_token if self._is_access_token_flow else self.get_access_token()
118        return {"Authorization": f"Bearer {token}"}

HTTP header to set on the requests

def get_access_token(self) -> str:
120    def get_access_token(self) -> str:
121        """
122        Returns the access token.
123
124        This method uses double-checked locking to ensure thread-safe token refresh.
125        When multiple threads (streams) detect an expired token simultaneously, only one
126        will perform the refresh while others wait. After acquiring the lock, the token
127        expiry is re-checked to avoid redundant refresh attempts.
128        """
129        if self.token_has_expired():
130            with self._token_refresh_lock:
131                # Double-check after acquiring lock - another thread may have already refreshed
132                if self.token_has_expired():
133                    self.refresh_and_set_access_token()
134
135        return self.access_token

Returns the access token.

This method uses double-checked locking to ensure thread-safe token refresh. When multiple threads (streams) detect an expired token simultaneously, only one will perform the refresh while others wait. After acquiring the lock, the token expiry is re-checked to avoid redundant refresh attempts.

def refresh_and_set_access_token(self) -> None:
145    def refresh_and_set_access_token(self) -> None:
146        """Force refresh the access token and update internal state.
147
148        Refreshes regardless of expiry, serialized on the class-level refresh lock. If another
149        thread using this same authenticator instance replaced the access token while this one
150        waited for the lock, the refresh is skipped and the request is retried with that token.
151        `SingleUseRefreshTokenOauth2Authenticator` reads `access_token` from the connector config
152        shared by all stream instances, so the early return also covers separate instances there.
153        Only per-instance token authenticators (base and declarative) are limited to same-instance
154        detection; the Authorization-header check in `HttpClient._handle_error_resolution` covers
155        the rest by skipping the forced refresh when the rejected request's token was already
156        replaced.
157        Subclasses may override this to handle additional state updates (e.g., persisting new
158        refresh tokens).
159        """
160        token_before_waiting = self._current_access_token_or_none()
161        with self._token_refresh_lock:
162            if self._current_access_token_or_none() != token_before_waiting:
163                return
164            token, expires_in = self.refresh_access_token()
165            self.access_token = token
166            self.set_token_expiry_date(expires_in)

Force refresh the access token and update internal state.

Refreshes regardless of expiry, serialized on the class-level refresh lock. If another thread using this same authenticator instance replaced the access token while this one waited for the lock, the refresh is skipped and the request is retried with that token. SingleUseRefreshTokenOauth2Authenticator reads access_token from the connector config shared by all stream instances, so the early return also covers separate instances there. Only per-instance token authenticators (base and declarative) are limited to same-instance detection; the Authorization-header check in HttpClient._handle_error_resolution covers the rest by skipping the forced refresh when the rejected request's token was already replaced. Subclasses may override this to handle additional state updates (e.g., persisting new refresh tokens).

def token_has_expired(self) -> bool:
168    def token_has_expired(self) -> bool:
169        """Returns True if the token is expired"""
170        return ab_datetime_now() > self.get_token_expiry_date()

Returns True if the token is expired

def build_refresh_request_body(self) -> Mapping[str, Any]:
212    def build_refresh_request_body(self) -> Mapping[str, Any]:
213        """Returns the request body to set on the refresh request.
214
215        When `should_send_refresh_request_as_query_params()` is `True`, the standard
216        refresh args are emitted on the URL query string instead and this method
217        returns an empty body. This supports OAuth providers like Gong that document
218        their refresh endpoint as a `POST` with parameters on the URL query string
219        and an empty body.
220        """
221        if self.should_send_refresh_request_as_query_params():
222            return {}
223        return self._build_standard_refresh_args()

Returns the request body to set on the refresh request.

When should_send_refresh_request_as_query_params() is True, the standard refresh args are emitted on the URL query string instead and this method returns an empty body. This supports OAuth providers like Gong that document their refresh endpoint as a POST with parameters on the URL query string and an empty body.

def build_refresh_request_headers(self) -> Optional[Mapping[str, Any]]:
225    def build_refresh_request_headers(self) -> Mapping[str, Any] | None:
226        """
227        Returns the request headers to set on the refresh request
228
229        """
230        headers = self.get_refresh_request_headers()
231        return headers if headers else None

Returns the request headers to set on the refresh request

def build_refresh_request_query_params(self) -> Optional[Mapping[str, Any]]:
233    def build_refresh_request_query_params(self) -> Mapping[str, Any] | None:
234        """Returns the URL query string parameters to set on the refresh request.
235
236        When `should_send_refresh_request_as_query_params()` is `True`, the standard
237        refresh args (grant_type, refresh_token, client credentials, scopes, plus
238        any user-configured `refresh_request_body` extras) are returned here and
239        `build_refresh_request_body()` returns an empty body.
240
241        Returns `None` otherwise so existing authenticators retain their previous
242        behavior (no query params on the refresh URL).
243        """
244        if not self.should_send_refresh_request_as_query_params():
245            return None
246        return self._build_standard_refresh_args()

Returns the URL query string parameters to set on the refresh request.

When should_send_refresh_request_as_query_params() is True, the standard refresh args (grant_type, refresh_token, client credentials, scopes, plus any user-configured refresh_request_body extras) are returned here and build_refresh_request_body() returns an empty body.

Returns None otherwise so existing authenticators retain their previous behavior (no query params on the refresh URL).

def refresh_access_token(self) -> Tuple[str, airbyte_cdk.utils.datetime_helpers.AirbyteDateTime]:
248    def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
249        """
250        Returns the refresh token and its expiration datetime
251
252        :return: a tuple of (access_token, token_lifespan)
253        """
254        try:
255            response_json = self._make_handled_request()
256        except (
257            requests.exceptions.ConnectionError,
258            requests.exceptions.ConnectTimeout,
259            requests.exceptions.ReadTimeout,
260        ) as e:
261            raise AirbyteTracedException(
262                message="OAuth access token refresh request failed due to a network error.",
263                internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
264                failure_type=FailureType.transient_error,
265            ) from e
266        self._ensure_access_token_in_response(response_json)
267
268        return (
269            self._extract_access_token(response_json),
270            self._extract_token_expiry_date(response_json),
271        )

Returns the refresh token and its expiration datetime

Returns

a tuple of (access_token, token_lifespan)

@abstractmethod
def get_token_refresh_endpoint(self) -> Optional[str]:
712    @abstractmethod
713    def get_token_refresh_endpoint(self) -> Optional[str]:
714        """Returns the endpoint to refresh the access token"""

Returns the endpoint to refresh the access token

@abstractmethod
def get_client_id_name(self) -> str:
716    @abstractmethod
717    def get_client_id_name(self) -> str:
718        """The client id name to authenticate"""

The client id name to authenticate

@abstractmethod
def get_client_id(self) -> str:
720    @abstractmethod
721    def get_client_id(self) -> str:
722        """The client id to authenticate"""

The client id to authenticate

@abstractmethod
def get_client_secret_name(self) -> str:
724    @abstractmethod
725    def get_client_secret_name(self) -> str:
726        """The client secret name to authenticate"""

The client secret name to authenticate

@abstractmethod
def get_client_secret(self) -> str:
728    @abstractmethod
729    def get_client_secret(self) -> str:
730        """The client secret to authenticate"""

The client secret to authenticate

@abstractmethod
def get_refresh_token_name(self) -> str:
732    @abstractmethod
733    def get_refresh_token_name(self) -> str:
734        """The refresh token name to authenticate"""

The refresh token name to authenticate

@abstractmethod
def get_refresh_token(self) -> Optional[str]:
736    @abstractmethod
737    def get_refresh_token(self) -> Optional[str]:
738        """The token used to refresh the access token when it expires"""

The token used to refresh the access token when it expires

@abstractmethod
def get_scopes(self) -> List[str]:
740    @abstractmethod
741    def get_scopes(self) -> List[str]:
742        """List of requested scopes"""

List of requested scopes

@abstractmethod
def get_token_expiry_date(self) -> airbyte_cdk.utils.datetime_helpers.AirbyteDateTime:
744    @abstractmethod
745    def get_token_expiry_date(self) -> AirbyteDateTime:
746        """Expiration date of the access token"""

Expiration date of the access token

@abstractmethod
def set_token_expiry_date(self, value: airbyte_cdk.utils.datetime_helpers.AirbyteDateTime) -> None:
748    @abstractmethod
749    def set_token_expiry_date(self, value: AirbyteDateTime) -> None:
750        """Setter for access token expiration date"""

Setter for access token expiration date

@abstractmethod
def get_access_token_name(self) -> str:
752    @abstractmethod
753    def get_access_token_name(self) -> str:
754        """Field to extract access token from in the response"""

Field to extract access token from in the response

@abstractmethod
def get_expires_in_name(self) -> str:
756    @abstractmethod
757    def get_expires_in_name(self) -> str:
758        """Returns the expires_in field name"""

Returns the expires_in field name

@abstractmethod
def get_refresh_request_body(self) -> Mapping[str, Any]:
760    @abstractmethod
761    def get_refresh_request_body(self) -> Mapping[str, Any]:
762        """Returns the request body to set on the refresh request"""

Returns the request body to set on the refresh request

@abstractmethod
def get_refresh_request_headers(self) -> Mapping[str, Any]:
764    @abstractmethod
765    def get_refresh_request_headers(self) -> Mapping[str, Any]:
766        """Returns the request headers to set on the refresh request"""

Returns the request headers to set on the refresh request

def should_send_refresh_request_as_query_params(self) -> bool:
768    def should_send_refresh_request_as_query_params(self) -> bool:
769        """Returns `True` if the standard refresh args should be sent on the URL
770        query string instead of in the request body.
771
772        Defaults to `False` so existing authenticators retain their previous
773        behavior (params in body, no query params on the refresh URL). Subclasses
774        can override this to opt into the URL-query-string shape required by OAuth
775        providers like Gong.
776        """
777        return False

Returns True if the standard refresh args should be sent on the URL query string instead of in the request body.

Defaults to False so existing authenticators retain their previous behavior (params in body, no query params on the refresh URL). Subclasses can override this to opt into the URL-query-string shape required by OAuth providers like Gong.

@abstractmethod
def get_grant_type(self) -> str:
779    @abstractmethod
780    def get_grant_type(self) -> str:
781        """Returns grant_type specified for requesting access_token"""

Returns grant_type specified for requesting access_token

@abstractmethod
def get_grant_type_name(self) -> str:
783    @abstractmethod
784    def get_grant_type_name(self) -> str:
785        """Returns grant_type specified name for requesting access_token"""

Returns grant_type specified name for requesting access_token

access_token: str
787    @property
788    @abstractmethod
789    def access_token(self) -> str:
790        """Returns the access token"""

Returns the access token