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 threading
  7from abc import abstractmethod
  8from datetime import timedelta
  9from json import JSONDecodeError
 10from typing import Any, List, Mapping, MutableMapping, Optional, Tuple, Union
 11
 12import backoff
 13import requests
 14from requests.auth import AuthBase
 15
 16from airbyte_cdk.models import FailureType, Level
 17from airbyte_cdk.sources.http_logger import format_http_message
 18from airbyte_cdk.sources.message import MessageRepository, NoopMessageRepository
 19from airbyte_cdk.utils import AirbyteTracedException
 20from airbyte_cdk.utils.airbyte_secrets_utils import add_to_secrets
 21from airbyte_cdk.utils.datetime_helpers import AirbyteDateTime, ab_datetime_now, ab_datetime_parse
 22
 23from ..exceptions import DefaultBackoffException
 24
 25logger = logging.getLogger("airbyte")
 26_NOOP_MESSAGE_REPOSITORY = NoopMessageRepository()
 27
 28
 29class ResponseKeysMaxRecurtionReached(AirbyteTracedException):
 30    """
 31    Raised when the max level of recursion is reached, when trying to
 32    find-and-get the target key, during the `_make_handled_request`
 33    """
 34
 35
 36class AbstractOauth2Authenticator(AuthBase):
 37    """
 38    Abstract class for an OAuth authenticators that implements the OAuth token refresh flow. The authenticator
 39    is designed to generically perform the refresh flow without regard to how config fields are get/set by
 40    delegating that behavior to the classes implementing the interface.
 41    """
 42
 43    _NO_STREAM_NAME = None
 44
 45    # Class-level lock to prevent concurrent token refresh across multiple authenticator instances.
 46    # This is necessary because multiple streams may share the same OAuth credentials (refresh token)
 47    # through the connector config. Without this lock, concurrent refresh attempts can cause race
 48    # conditions where one stream successfully refreshes the token while others fail because the
 49    # refresh token has been invalidated (especially for single-use refresh tokens).
 50    _token_refresh_lock: threading.Lock = threading.Lock()
 51
 52    def __init__(
 53        self,
 54        refresh_token_error_status_codes: Tuple[int, ...] = (),
 55        refresh_token_error_key: str = "",
 56        refresh_token_error_values: Tuple[str, ...] = (),
 57    ) -> None:
 58        """
 59        If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set,
 60        then http errors with such params will be wrapped in AirbyteTracedException.
 61        """
 62        self._refresh_token_error_status_codes = refresh_token_error_status_codes
 63        self._refresh_token_error_key = refresh_token_error_key
 64        self._refresh_token_error_values = refresh_token_error_values
 65
 66    def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
 67        """Attach the HTTP headers required to authenticate on the HTTP request"""
 68        request.headers.update(self.get_auth_header())
 69        return request
 70
 71    @property
 72    def _is_access_token_flow(self) -> bool:
 73        return self.get_token_refresh_endpoint() is None and self.access_token is not None
 74
 75    @property
 76    def token_expiry_is_time_of_expiration(self) -> bool:
 77        """
 78        Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
 79        """
 80
 81        return False
 82
 83    @property
 84    def token_expiry_date_format(self) -> Optional[str]:
 85        """
 86        Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
 87        """
 88
 89        return None
 90
 91    def get_auth_header(self) -> Mapping[str, Any]:
 92        """HTTP header to set on the requests"""
 93        token = self.access_token if self._is_access_token_flow else self.get_access_token()
 94        return {"Authorization": f"Bearer {token}"}
 95
 96    def get_access_token(self) -> str:
 97        """
 98        Returns the access token.
 99
100        This method uses double-checked locking to ensure thread-safe token refresh.
101        When multiple threads (streams) detect an expired token simultaneously, only one
102        will perform the refresh while others wait. After acquiring the lock, the token
103        expiry is re-checked to avoid redundant refresh attempts.
104        """
105        if self.token_has_expired():
106            with self._token_refresh_lock:
107                # Double-check after acquiring lock - another thread may have already refreshed
108                if self.token_has_expired():
109                    self.refresh_and_set_access_token()
110
111        return self.access_token
112
113    def refresh_and_set_access_token(self) -> None:
114        """Force refresh the access token and update internal state.
115
116        This method refreshes the access token regardless of whether it has expired,
117        and updates the internal token and expiry date. Subclasses may override this
118        to handle additional state updates (e.g., persisting new refresh tokens).
119        """
120        token, expires_in = self.refresh_access_token()
121        self.access_token = token
122        self.set_token_expiry_date(expires_in)
123
124    def token_has_expired(self) -> bool:
125        """Returns True if the token is expired"""
126        return ab_datetime_now() > self.get_token_expiry_date()
127
128    def _build_standard_refresh_args(self) -> MutableMapping[str, Any]:
129        """Build the standard OAuth refresh args (grant_type, refresh_token, client
130        credentials, scopes, plus any user-configured `refresh_request_body` extras).
131
132        Used by both `build_refresh_request_body()` and
133        `build_refresh_request_query_params()` so the same set of args can be emitted
134        in either the body or the URL query string depending on
135        `should_send_refresh_request_as_query_params()`.
136
137        Client credentials (client_id and client_secret) are excluded when
138        `refresh_request_headers` contains an `Authorization` header (e.g. Basic
139        auth). This is required by OAuth providers like Gong that expect credentials
140        ONLY in the Authorization header and reject requests that include them in
141        both places.
142        """
143        headers = self.get_refresh_request_headers()
144        credentials_in_header = headers and "Authorization" in headers
145        include_client_credentials = not credentials_in_header
146
147        payload: MutableMapping[str, Any] = {
148            self.get_grant_type_name(): self.get_grant_type(),
149        }
150
151        if include_client_credentials:
152            payload[self.get_client_id_name()] = self.get_client_id()
153            payload[self.get_client_secret_name()] = self.get_client_secret()
154
155        payload[self.get_refresh_token_name()] = self.get_refresh_token()
156
157        if self.get_scopes():
158            payload["scopes"] = self.get_scopes()
159
160        if self.get_refresh_request_body():
161            for key, val in self.get_refresh_request_body().items():
162                # Existing oauth args take precedence over custom configured fields.
163                if key not in payload:
164                    payload[key] = val
165
166        return payload
167
168    def build_refresh_request_body(self) -> Mapping[str, Any]:
169        """Returns the request body to set on the refresh request.
170
171        When `should_send_refresh_request_as_query_params()` is `True`, the standard
172        refresh args are emitted on the URL query string instead and this method
173        returns an empty body. This supports OAuth providers like Gong that document
174        their refresh endpoint as a `POST` with parameters on the URL query string
175        and an empty body.
176        """
177        if self.should_send_refresh_request_as_query_params():
178            return {}
179        return self._build_standard_refresh_args()
180
181    def build_refresh_request_headers(self) -> Mapping[str, Any] | None:
182        """
183        Returns the request headers to set on the refresh request
184
185        """
186        headers = self.get_refresh_request_headers()
187        return headers if headers else None
188
189    def build_refresh_request_query_params(self) -> Mapping[str, Any] | None:
190        """Returns the URL query string parameters to set on the refresh request.
191
192        When `should_send_refresh_request_as_query_params()` is `True`, the standard
193        refresh args (grant_type, refresh_token, client credentials, scopes, plus
194        any user-configured `refresh_request_body` extras) are returned here and
195        `build_refresh_request_body()` returns an empty body.
196
197        Returns `None` otherwise so existing authenticators retain their previous
198        behavior (no query params on the refresh URL).
199        """
200        if not self.should_send_refresh_request_as_query_params():
201            return None
202        return self._build_standard_refresh_args()
203
204    def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
205        """
206        Returns the refresh token and its expiration datetime
207
208        :return: a tuple of (access_token, token_lifespan)
209        """
210        try:
211            response_json = self._make_handled_request()
212        except (
213            requests.exceptions.ConnectionError,
214            requests.exceptions.ConnectTimeout,
215            requests.exceptions.ReadTimeout,
216        ) as e:
217            raise AirbyteTracedException(
218                message="OAuth access token refresh request failed due to a network error.",
219                internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
220                failure_type=FailureType.transient_error,
221            ) from e
222        self._ensure_access_token_in_response(response_json)
223
224        return (
225            self._extract_access_token(response_json),
226            self._extract_token_expiry_date(response_json),
227        )
228
229    # ----------------
230    # PRIVATE METHODS
231    # ----------------
232
233    def _default_token_expiry_date(self) -> AirbyteDateTime:
234        """
235        Returns the default token expiry date
236        """
237        # 1 hour was chosen as a middle ground to avoid unnecessary frequent refreshes and token expiration
238        default_token_expiry_duration_hours = 1  # 1 hour
239        return ab_datetime_now() + timedelta(hours=default_token_expiry_duration_hours)
240
241    def _wrap_refresh_token_exception(
242        self, exception: requests.exceptions.RequestException
243    ) -> bool:
244        """
245        Wraps and handles exceptions that occur during the refresh token process.
246
247        This method checks if the provided exception is related to a refresh token error
248        by examining the response status code and specific error content.
249
250        Args:
251            exception (requests.exceptions.RequestException): The exception raised during the request.
252
253        Returns:
254            bool: True if the exception is related to a refresh token error, False otherwise.
255        """
256        try:
257            if exception.response is not None:
258                exception_content = exception.response.json()
259            else:
260                return False
261        except JSONDecodeError:
262            return False
263        return (
264            exception.response.status_code in self._refresh_token_error_status_codes
265            and exception_content.get(self._refresh_token_error_key)
266            in self._refresh_token_error_values
267        )
268
269    @backoff.on_exception(
270        backoff.expo,
271        (
272            DefaultBackoffException,
273            requests.exceptions.ConnectionError,
274            requests.exceptions.ConnectTimeout,
275            requests.exceptions.ReadTimeout,
276        ),
277        on_backoff=lambda details: logger.info(
278            f"Caught retryable error after {details['tries']} tries. Waiting {details['wait']} seconds then retrying..."
279        ),
280        max_time=300,
281    )
282    def _make_handled_request(self) -> Any:
283        """
284        Makes a handled HTTP request to refresh an OAuth token.
285
286        This method sends a POST request to the token refresh endpoint with the necessary
287        headers and body to obtain a new access token. It handles various exceptions that
288        may occur during the request and logs the response for troubleshooting purposes.
289
290        Returns:
291            Mapping[str, Any]: The JSON response from the token refresh endpoint.
292
293        Raises:
294            DefaultBackoffException: If the response status code is 429 (Too Many Requests)
295                                     or any 5xx server error.
296            AirbyteTracedException: If the refresh token is invalid or expired, prompting
297                                    re-authentication.
298            Exception: For any other exceptions that occur during the request.
299        """
300        try:
301            response = requests.request(
302                method="POST",
303                url=self.get_token_refresh_endpoint(),  # type: ignore # returns None, if not provided, but str | bytes is expected.
304                data=self.build_refresh_request_body(),
305                headers=self.build_refresh_request_headers(),
306                params=self.build_refresh_request_query_params(),
307            )
308
309            if not response.ok:
310                # log the response even if the request failed for troubleshooting purposes
311                self._log_response(response)
312                response.raise_for_status()
313
314            response_json = response.json()
315
316            try:
317                # extract the access token and add to secrets to avoid logging the raw value
318                access_key = self._extract_access_token(response_json)
319                if access_key:
320                    add_to_secrets(access_key)
321            except ResponseKeysMaxRecurtionReached as e:
322                # could not find the access token in the response, so do nothing
323                pass
324
325            self._log_response(response)
326
327            return response_json
328        except requests.exceptions.RequestException as e:
329            if e.response is not None:
330                if e.response.status_code == 429 or e.response.status_code >= 500:
331                    raise DefaultBackoffException(
332                        request=e.response.request,
333                        response=e.response,
334                        failure_type=FailureType.transient_error,
335                    )
336            if self._wrap_refresh_token_exception(e):
337                response_info = (
338                    f"HTTP {e.response.status_code}: {e.response.text[:1000]}"
339                    if e.response is not None
340                    else str(e)
341                )
342                raise AirbyteTracedException(
343                    internal_message=f"Refresh token rejected by the OAuth token endpoint. {response_info}",
344                    message="Refresh token was rejected by the OAuth provider (invalid, expired, or already used). Re-authenticate this source's credentials in its connection settings.",
345                    failure_type=FailureType.config_error,
346                ) from e
347            raise
348        except Exception as e:
349            raise AirbyteTracedException(
350                message="OAuth access token refresh request failed.",
351                internal_message=f"Unexpected error during OAuth token refresh: {e}",
352                failure_type=FailureType.system_error,
353            ) from e
354
355    def _ensure_access_token_in_response(self, response_data: Mapping[str, Any]) -> None:
356        """
357        Ensures that the access token is present in the response data.
358
359        This method attempts to extract the access token from the provided response data.
360        If the access token is not found, it raises an exception indicating that the token
361        refresh API response was missing the access token.
362
363        Args:
364            response_data (Mapping[str, Any]): The response data from which to extract the access token.
365
366        Raises:
367            Exception: If the access token is not found in the response data.
368            ResponseKeysMaxRecurtionReached: If the maximum recursion depth is reached while extracting the access token.
369        """
370        try:
371            access_key = self._extract_access_token(response_data)
372            if not access_key:
373                raise Exception(
374                    f"Token refresh API response was missing access token {self.get_access_token_name()}"
375                )
376        except ResponseKeysMaxRecurtionReached as e:
377            raise e
378
379    def _parse_token_expiration_date(self, value: Union[str, int]) -> AirbyteDateTime:
380        """
381        Parse a string or integer token expiration date into a datetime object
382
383        :return: expiration datetime
384        """
385        if self.token_expiry_is_time_of_expiration:
386            if not self.token_expiry_date_format:
387                raise ValueError(
388                    f"Invalid token expiry date format {self.token_expiry_date_format}; a string representing the format is required."
389                )
390            try:
391                return ab_datetime_parse(str(value))
392            except ValueError as e:
393                raise ValueError(f"Invalid token expiry date format: {e}")
394        else:
395            try:
396                # Only accept numeric values (as int/float/string) when no format specified
397                seconds = int(float(str(value)))
398                return ab_datetime_now() + timedelta(seconds=seconds)
399            except (ValueError, TypeError):
400                raise ValueError(
401                    f"Invalid expires_in value: {value}. Expected number of seconds when no format specified."
402                )
403
404    def _extract_access_token(self, response_data: Mapping[str, Any]) -> Any:
405        """
406        Extracts the access token from the given response data.
407
408        Args:
409            response_data (Mapping[str, Any]): The response data from which to extract the access token.
410
411        Returns:
412            str: The extracted access token.
413        """
414        return self._find_and_get_value_from_response(response_data, self.get_access_token_name())
415
416    def _extract_refresh_token(self, response_data: Mapping[str, Any]) -> Any:
417        """
418        Extracts the refresh token from the given response data.
419
420        Args:
421            response_data (Mapping[str, Any]): The response data from which to extract the refresh token.
422
423        Returns:
424            str: The extracted refresh token.
425        """
426        return self._find_and_get_value_from_response(response_data, self.get_refresh_token_name())
427
428    def _extract_token_expiry_date(self, response_data: Mapping[str, Any]) -> AirbyteDateTime:
429        """
430        Extracts the token_expiry_date, like `expires_in` or `expires_at`, etc from the given response data.
431
432        If the token_expiry_date is not found, it will return an existing token expiry date if set, or a default token expiry date.
433
434        Args:
435            response_data (Mapping[str, Any]): The response data from which to extract the token_expiry_date.
436
437        Returns:
438            The extracted token_expiry_date or None if not found.
439        """
440        expires_in = self._find_and_get_value_from_response(
441            response_data, self.get_expires_in_name()
442        )
443        if expires_in is not None:
444            return self._parse_token_expiration_date(expires_in)
445
446        # expires_in is None
447        existing_expiry_date = self.get_token_expiry_date()
448        if existing_expiry_date and not self.token_has_expired():
449            return existing_expiry_date
450
451        return self._default_token_expiry_date()
452
453    def _find_and_get_value_from_response(
454        self,
455        response_data: Mapping[str, Any],
456        key_name: str,
457        max_depth: int = 5,
458        current_depth: int = 0,
459    ) -> Any:
460        """
461        Recursively searches for a specified key in a nested dictionary or list and returns its value if found.
462
463        Args:
464            response_data (Mapping[str, Any]): The response data to search through, which can be a dictionary or a list.
465            key_name (str): The key to search for in the response data.
466            max_depth (int, optional): The maximum depth to search for the key to avoid infinite recursion. Defaults to 5.
467            current_depth (int, optional): The current depth of the recursion. Defaults to 0.
468
469        Returns:
470            Any: The value associated with the specified key if found, otherwise None.
471
472        Raises:
473            AirbyteTracedException: If the maximum recursion depth is reached without finding the key.
474        """
475        if current_depth > max_depth:
476            # this is needed to avoid an inf loop, possible with a very deep nesting observed.
477            message = f"The maximum level of recursion is reached. Couldn't find the specified `{key_name}` in the response."
478            raise ResponseKeysMaxRecurtionReached(
479                internal_message=message, message=message, failure_type=FailureType.config_error
480            )
481
482        if isinstance(response_data, dict):
483            # get from the root level
484            if key_name in response_data:
485                return response_data[key_name]
486
487            # get from the nested object
488            for _, value in response_data.items():
489                result = self._find_and_get_value_from_response(
490                    value, key_name, max_depth, current_depth + 1
491                )
492                if result is not None:
493                    return result
494
495        # get from the nested array object
496        elif isinstance(response_data, list):
497            for item in response_data:
498                result = self._find_and_get_value_from_response(
499                    item, key_name, max_depth, current_depth + 1
500                )
501                if result is not None:
502                    return result
503
504        return None
505
506    @property
507    def _message_repository(self) -> Optional[MessageRepository]:
508        """
509        The implementation can define a message_repository if it wants debugging logs for HTTP requests
510        """
511        return _NOOP_MESSAGE_REPOSITORY
512
513    def _log_response(self, response: requests.Response) -> None:
514        """
515        Logs the HTTP response using the message repository if it is available.
516
517        Args:
518            response (requests.Response): The HTTP response to log.
519        """
520        if self._message_repository:
521            self._message_repository.log_message(
522                Level.DEBUG,
523                lambda: format_http_message(
524                    response,
525                    "Refresh token",
526                    "Obtains access token",
527                    self._NO_STREAM_NAME,
528                    is_auxiliary=True,
529                    type="AUTH",
530                ),
531            )
532
533    # ----------------
534    # ABSTR METHODS
535    # ----------------
536
537    @abstractmethod
538    def get_token_refresh_endpoint(self) -> Optional[str]:
539        """Returns the endpoint to refresh the access token"""
540
541    @abstractmethod
542    def get_client_id_name(self) -> str:
543        """The client id name to authenticate"""
544
545    @abstractmethod
546    def get_client_id(self) -> str:
547        """The client id to authenticate"""
548
549    @abstractmethod
550    def get_client_secret_name(self) -> str:
551        """The client secret name to authenticate"""
552
553    @abstractmethod
554    def get_client_secret(self) -> str:
555        """The client secret to authenticate"""
556
557    @abstractmethod
558    def get_refresh_token_name(self) -> str:
559        """The refresh token name to authenticate"""
560
561    @abstractmethod
562    def get_refresh_token(self) -> Optional[str]:
563        """The token used to refresh the access token when it expires"""
564
565    @abstractmethod
566    def get_scopes(self) -> List[str]:
567        """List of requested scopes"""
568
569    @abstractmethod
570    def get_token_expiry_date(self) -> AirbyteDateTime:
571        """Expiration date of the access token"""
572
573    @abstractmethod
574    def set_token_expiry_date(self, value: AirbyteDateTime) -> None:
575        """Setter for access token expiration date"""
576
577    @abstractmethod
578    def get_access_token_name(self) -> str:
579        """Field to extract access token from in the response"""
580
581    @abstractmethod
582    def get_expires_in_name(self) -> str:
583        """Returns the expires_in field name"""
584
585    @abstractmethod
586    def get_refresh_request_body(self) -> Mapping[str, Any]:
587        """Returns the request body to set on the refresh request"""
588
589    @abstractmethod
590    def get_refresh_request_headers(self) -> Mapping[str, Any]:
591        """Returns the request headers to set on the refresh request"""
592
593    def should_send_refresh_request_as_query_params(self) -> bool:
594        """Returns `True` if the standard refresh args should be sent on the URL
595        query string instead of in the request body.
596
597        Defaults to `False` so existing authenticators retain their previous
598        behavior (params in body, no query params on the refresh URL). Subclasses
599        can override this to opt into the URL-query-string shape required by OAuth
600        providers like Gong.
601        """
602        return False
603
604    @abstractmethod
605    def get_grant_type(self) -> str:
606        """Returns grant_type specified for requesting access_token"""
607
608    @abstractmethod
609    def get_grant_type_name(self) -> str:
610        """Returns grant_type specified name for requesting access_token"""
611
612    @property
613    @abstractmethod
614    def access_token(self) -> str:
615        """Returns the access token"""
616
617    @access_token.setter
618    @abstractmethod
619    def access_token(self, value: str) -> str:
620        """Setter for the access token"""
logger = <Logger airbyte (INFO)>
class ResponseKeysMaxRecurtionReached(airbyte_cdk.utils.traced_exception.AirbyteTracedException):
30class ResponseKeysMaxRecurtionReached(AirbyteTracedException):
31    """
32    Raised when the max level of recursion is reached, when trying to
33    find-and-get the target key, during the `_make_handled_request`
34    """

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):
 37class AbstractOauth2Authenticator(AuthBase):
 38    """
 39    Abstract class for an OAuth authenticators that implements the OAuth token refresh flow. The authenticator
 40    is designed to generically perform the refresh flow without regard to how config fields are get/set by
 41    delegating that behavior to the classes implementing the interface.
 42    """
 43
 44    _NO_STREAM_NAME = None
 45
 46    # Class-level lock to prevent concurrent token refresh across multiple authenticator instances.
 47    # This is necessary because multiple streams may share the same OAuth credentials (refresh token)
 48    # through the connector config. Without this lock, concurrent refresh attempts can cause race
 49    # conditions where one stream successfully refreshes the token while others fail because the
 50    # refresh token has been invalidated (especially for single-use refresh tokens).
 51    _token_refresh_lock: threading.Lock = threading.Lock()
 52
 53    def __init__(
 54        self,
 55        refresh_token_error_status_codes: Tuple[int, ...] = (),
 56        refresh_token_error_key: str = "",
 57        refresh_token_error_values: Tuple[str, ...] = (),
 58    ) -> None:
 59        """
 60        If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set,
 61        then http errors with such params will be wrapped in AirbyteTracedException.
 62        """
 63        self._refresh_token_error_status_codes = refresh_token_error_status_codes
 64        self._refresh_token_error_key = refresh_token_error_key
 65        self._refresh_token_error_values = refresh_token_error_values
 66
 67    def __call__(self, request: requests.PreparedRequest) -> requests.PreparedRequest:
 68        """Attach the HTTP headers required to authenticate on the HTTP request"""
 69        request.headers.update(self.get_auth_header())
 70        return request
 71
 72    @property
 73    def _is_access_token_flow(self) -> bool:
 74        return self.get_token_refresh_endpoint() is None and self.access_token is not None
 75
 76    @property
 77    def token_expiry_is_time_of_expiration(self) -> bool:
 78        """
 79        Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
 80        """
 81
 82        return False
 83
 84    @property
 85    def token_expiry_date_format(self) -> Optional[str]:
 86        """
 87        Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
 88        """
 89
 90        return None
 91
 92    def get_auth_header(self) -> Mapping[str, Any]:
 93        """HTTP header to set on the requests"""
 94        token = self.access_token if self._is_access_token_flow else self.get_access_token()
 95        return {"Authorization": f"Bearer {token}"}
 96
 97    def get_access_token(self) -> str:
 98        """
 99        Returns the access token.
100
101        This method uses double-checked locking to ensure thread-safe token refresh.
102        When multiple threads (streams) detect an expired token simultaneously, only one
103        will perform the refresh while others wait. After acquiring the lock, the token
104        expiry is re-checked to avoid redundant refresh attempts.
105        """
106        if self.token_has_expired():
107            with self._token_refresh_lock:
108                # Double-check after acquiring lock - another thread may have already refreshed
109                if self.token_has_expired():
110                    self.refresh_and_set_access_token()
111
112        return self.access_token
113
114    def refresh_and_set_access_token(self) -> None:
115        """Force refresh the access token and update internal state.
116
117        This method refreshes the access token regardless of whether it has expired,
118        and updates the internal token and expiry date. Subclasses may override this
119        to handle additional state updates (e.g., persisting new refresh tokens).
120        """
121        token, expires_in = self.refresh_access_token()
122        self.access_token = token
123        self.set_token_expiry_date(expires_in)
124
125    def token_has_expired(self) -> bool:
126        """Returns True if the token is expired"""
127        return ab_datetime_now() > self.get_token_expiry_date()
128
129    def _build_standard_refresh_args(self) -> MutableMapping[str, Any]:
130        """Build the standard OAuth refresh args (grant_type, refresh_token, client
131        credentials, scopes, plus any user-configured `refresh_request_body` extras).
132
133        Used by both `build_refresh_request_body()` and
134        `build_refresh_request_query_params()` so the same set of args can be emitted
135        in either the body or the URL query string depending on
136        `should_send_refresh_request_as_query_params()`.
137
138        Client credentials (client_id and client_secret) are excluded when
139        `refresh_request_headers` contains an `Authorization` header (e.g. Basic
140        auth). This is required by OAuth providers like Gong that expect credentials
141        ONLY in the Authorization header and reject requests that include them in
142        both places.
143        """
144        headers = self.get_refresh_request_headers()
145        credentials_in_header = headers and "Authorization" in headers
146        include_client_credentials = not credentials_in_header
147
148        payload: MutableMapping[str, Any] = {
149            self.get_grant_type_name(): self.get_grant_type(),
150        }
151
152        if include_client_credentials:
153            payload[self.get_client_id_name()] = self.get_client_id()
154            payload[self.get_client_secret_name()] = self.get_client_secret()
155
156        payload[self.get_refresh_token_name()] = self.get_refresh_token()
157
158        if self.get_scopes():
159            payload["scopes"] = self.get_scopes()
160
161        if self.get_refresh_request_body():
162            for key, val in self.get_refresh_request_body().items():
163                # Existing oauth args take precedence over custom configured fields.
164                if key not in payload:
165                    payload[key] = val
166
167        return payload
168
169    def build_refresh_request_body(self) -> Mapping[str, Any]:
170        """Returns the request body to set on the refresh request.
171
172        When `should_send_refresh_request_as_query_params()` is `True`, the standard
173        refresh args are emitted on the URL query string instead and this method
174        returns an empty body. This supports OAuth providers like Gong that document
175        their refresh endpoint as a `POST` with parameters on the URL query string
176        and an empty body.
177        """
178        if self.should_send_refresh_request_as_query_params():
179            return {}
180        return self._build_standard_refresh_args()
181
182    def build_refresh_request_headers(self) -> Mapping[str, Any] | None:
183        """
184        Returns the request headers to set on the refresh request
185
186        """
187        headers = self.get_refresh_request_headers()
188        return headers if headers else None
189
190    def build_refresh_request_query_params(self) -> Mapping[str, Any] | None:
191        """Returns the URL query string parameters to set on the refresh request.
192
193        When `should_send_refresh_request_as_query_params()` is `True`, the standard
194        refresh args (grant_type, refresh_token, client credentials, scopes, plus
195        any user-configured `refresh_request_body` extras) are returned here and
196        `build_refresh_request_body()` returns an empty body.
197
198        Returns `None` otherwise so existing authenticators retain their previous
199        behavior (no query params on the refresh URL).
200        """
201        if not self.should_send_refresh_request_as_query_params():
202            return None
203        return self._build_standard_refresh_args()
204
205    def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
206        """
207        Returns the refresh token and its expiration datetime
208
209        :return: a tuple of (access_token, token_lifespan)
210        """
211        try:
212            response_json = self._make_handled_request()
213        except (
214            requests.exceptions.ConnectionError,
215            requests.exceptions.ConnectTimeout,
216            requests.exceptions.ReadTimeout,
217        ) as e:
218            raise AirbyteTracedException(
219                message="OAuth access token refresh request failed due to a network error.",
220                internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
221                failure_type=FailureType.transient_error,
222            ) from e
223        self._ensure_access_token_in_response(response_json)
224
225        return (
226            self._extract_access_token(response_json),
227            self._extract_token_expiry_date(response_json),
228        )
229
230    # ----------------
231    # PRIVATE METHODS
232    # ----------------
233
234    def _default_token_expiry_date(self) -> AirbyteDateTime:
235        """
236        Returns the default token expiry date
237        """
238        # 1 hour was chosen as a middle ground to avoid unnecessary frequent refreshes and token expiration
239        default_token_expiry_duration_hours = 1  # 1 hour
240        return ab_datetime_now() + timedelta(hours=default_token_expiry_duration_hours)
241
242    def _wrap_refresh_token_exception(
243        self, exception: requests.exceptions.RequestException
244    ) -> bool:
245        """
246        Wraps and handles exceptions that occur during the refresh token process.
247
248        This method checks if the provided exception is related to a refresh token error
249        by examining the response status code and specific error content.
250
251        Args:
252            exception (requests.exceptions.RequestException): The exception raised during the request.
253
254        Returns:
255            bool: True if the exception is related to a refresh token error, False otherwise.
256        """
257        try:
258            if exception.response is not None:
259                exception_content = exception.response.json()
260            else:
261                return False
262        except JSONDecodeError:
263            return False
264        return (
265            exception.response.status_code in self._refresh_token_error_status_codes
266            and exception_content.get(self._refresh_token_error_key)
267            in self._refresh_token_error_values
268        )
269
270    @backoff.on_exception(
271        backoff.expo,
272        (
273            DefaultBackoffException,
274            requests.exceptions.ConnectionError,
275            requests.exceptions.ConnectTimeout,
276            requests.exceptions.ReadTimeout,
277        ),
278        on_backoff=lambda details: logger.info(
279            f"Caught retryable error after {details['tries']} tries. Waiting {details['wait']} seconds then retrying..."
280        ),
281        max_time=300,
282    )
283    def _make_handled_request(self) -> Any:
284        """
285        Makes a handled HTTP request to refresh an OAuth token.
286
287        This method sends a POST request to the token refresh endpoint with the necessary
288        headers and body to obtain a new access token. It handles various exceptions that
289        may occur during the request and logs the response for troubleshooting purposes.
290
291        Returns:
292            Mapping[str, Any]: The JSON response from the token refresh endpoint.
293
294        Raises:
295            DefaultBackoffException: If the response status code is 429 (Too Many Requests)
296                                     or any 5xx server error.
297            AirbyteTracedException: If the refresh token is invalid or expired, prompting
298                                    re-authentication.
299            Exception: For any other exceptions that occur during the request.
300        """
301        try:
302            response = requests.request(
303                method="POST",
304                url=self.get_token_refresh_endpoint(),  # type: ignore # returns None, if not provided, but str | bytes is expected.
305                data=self.build_refresh_request_body(),
306                headers=self.build_refresh_request_headers(),
307                params=self.build_refresh_request_query_params(),
308            )
309
310            if not response.ok:
311                # log the response even if the request failed for troubleshooting purposes
312                self._log_response(response)
313                response.raise_for_status()
314
315            response_json = response.json()
316
317            try:
318                # extract the access token and add to secrets to avoid logging the raw value
319                access_key = self._extract_access_token(response_json)
320                if access_key:
321                    add_to_secrets(access_key)
322            except ResponseKeysMaxRecurtionReached as e:
323                # could not find the access token in the response, so do nothing
324                pass
325
326            self._log_response(response)
327
328            return response_json
329        except requests.exceptions.RequestException as e:
330            if e.response is not None:
331                if e.response.status_code == 429 or e.response.status_code >= 500:
332                    raise DefaultBackoffException(
333                        request=e.response.request,
334                        response=e.response,
335                        failure_type=FailureType.transient_error,
336                    )
337            if self._wrap_refresh_token_exception(e):
338                response_info = (
339                    f"HTTP {e.response.status_code}: {e.response.text[:1000]}"
340                    if e.response is not None
341                    else str(e)
342                )
343                raise AirbyteTracedException(
344                    internal_message=f"Refresh token rejected by the OAuth token endpoint. {response_info}",
345                    message="Refresh token was rejected by the OAuth provider (invalid, expired, or already used). Re-authenticate this source's credentials in its connection settings.",
346                    failure_type=FailureType.config_error,
347                ) from e
348            raise
349        except Exception as e:
350            raise AirbyteTracedException(
351                message="OAuth access token refresh request failed.",
352                internal_message=f"Unexpected error during OAuth token refresh: {e}",
353                failure_type=FailureType.system_error,
354            ) from e
355
356    def _ensure_access_token_in_response(self, response_data: Mapping[str, Any]) -> None:
357        """
358        Ensures that the access token is present in the response data.
359
360        This method attempts to extract the access token from the provided response data.
361        If the access token is not found, it raises an exception indicating that the token
362        refresh API response was missing the access token.
363
364        Args:
365            response_data (Mapping[str, Any]): The response data from which to extract the access token.
366
367        Raises:
368            Exception: If the access token is not found in the response data.
369            ResponseKeysMaxRecurtionReached: If the maximum recursion depth is reached while extracting the access token.
370        """
371        try:
372            access_key = self._extract_access_token(response_data)
373            if not access_key:
374                raise Exception(
375                    f"Token refresh API response was missing access token {self.get_access_token_name()}"
376                )
377        except ResponseKeysMaxRecurtionReached as e:
378            raise e
379
380    def _parse_token_expiration_date(self, value: Union[str, int]) -> AirbyteDateTime:
381        """
382        Parse a string or integer token expiration date into a datetime object
383
384        :return: expiration datetime
385        """
386        if self.token_expiry_is_time_of_expiration:
387            if not self.token_expiry_date_format:
388                raise ValueError(
389                    f"Invalid token expiry date format {self.token_expiry_date_format}; a string representing the format is required."
390                )
391            try:
392                return ab_datetime_parse(str(value))
393            except ValueError as e:
394                raise ValueError(f"Invalid token expiry date format: {e}")
395        else:
396            try:
397                # Only accept numeric values (as int/float/string) when no format specified
398                seconds = int(float(str(value)))
399                return ab_datetime_now() + timedelta(seconds=seconds)
400            except (ValueError, TypeError):
401                raise ValueError(
402                    f"Invalid expires_in value: {value}. Expected number of seconds when no format specified."
403                )
404
405    def _extract_access_token(self, response_data: Mapping[str, Any]) -> Any:
406        """
407        Extracts the access token from the given response data.
408
409        Args:
410            response_data (Mapping[str, Any]): The response data from which to extract the access token.
411
412        Returns:
413            str: The extracted access token.
414        """
415        return self._find_and_get_value_from_response(response_data, self.get_access_token_name())
416
417    def _extract_refresh_token(self, response_data: Mapping[str, Any]) -> Any:
418        """
419        Extracts the refresh token from the given response data.
420
421        Args:
422            response_data (Mapping[str, Any]): The response data from which to extract the refresh token.
423
424        Returns:
425            str: The extracted refresh token.
426        """
427        return self._find_and_get_value_from_response(response_data, self.get_refresh_token_name())
428
429    def _extract_token_expiry_date(self, response_data: Mapping[str, Any]) -> AirbyteDateTime:
430        """
431        Extracts the token_expiry_date, like `expires_in` or `expires_at`, etc from the given response data.
432
433        If the token_expiry_date is not found, it will return an existing token expiry date if set, or a default token expiry date.
434
435        Args:
436            response_data (Mapping[str, Any]): The response data from which to extract the token_expiry_date.
437
438        Returns:
439            The extracted token_expiry_date or None if not found.
440        """
441        expires_in = self._find_and_get_value_from_response(
442            response_data, self.get_expires_in_name()
443        )
444        if expires_in is not None:
445            return self._parse_token_expiration_date(expires_in)
446
447        # expires_in is None
448        existing_expiry_date = self.get_token_expiry_date()
449        if existing_expiry_date and not self.token_has_expired():
450            return existing_expiry_date
451
452        return self._default_token_expiry_date()
453
454    def _find_and_get_value_from_response(
455        self,
456        response_data: Mapping[str, Any],
457        key_name: str,
458        max_depth: int = 5,
459        current_depth: int = 0,
460    ) -> Any:
461        """
462        Recursively searches for a specified key in a nested dictionary or list and returns its value if found.
463
464        Args:
465            response_data (Mapping[str, Any]): The response data to search through, which can be a dictionary or a list.
466            key_name (str): The key to search for in the response data.
467            max_depth (int, optional): The maximum depth to search for the key to avoid infinite recursion. Defaults to 5.
468            current_depth (int, optional): The current depth of the recursion. Defaults to 0.
469
470        Returns:
471            Any: The value associated with the specified key if found, otherwise None.
472
473        Raises:
474            AirbyteTracedException: If the maximum recursion depth is reached without finding the key.
475        """
476        if current_depth > max_depth:
477            # this is needed to avoid an inf loop, possible with a very deep nesting observed.
478            message = f"The maximum level of recursion is reached. Couldn't find the specified `{key_name}` in the response."
479            raise ResponseKeysMaxRecurtionReached(
480                internal_message=message, message=message, failure_type=FailureType.config_error
481            )
482
483        if isinstance(response_data, dict):
484            # get from the root level
485            if key_name in response_data:
486                return response_data[key_name]
487
488            # get from the nested object
489            for _, value in response_data.items():
490                result = self._find_and_get_value_from_response(
491                    value, key_name, max_depth, current_depth + 1
492                )
493                if result is not None:
494                    return result
495
496        # get from the nested array object
497        elif isinstance(response_data, list):
498            for item in response_data:
499                result = self._find_and_get_value_from_response(
500                    item, key_name, max_depth, current_depth + 1
501                )
502                if result is not None:
503                    return result
504
505        return None
506
507    @property
508    def _message_repository(self) -> Optional[MessageRepository]:
509        """
510        The implementation can define a message_repository if it wants debugging logs for HTTP requests
511        """
512        return _NOOP_MESSAGE_REPOSITORY
513
514    def _log_response(self, response: requests.Response) -> None:
515        """
516        Logs the HTTP response using the message repository if it is available.
517
518        Args:
519            response (requests.Response): The HTTP response to log.
520        """
521        if self._message_repository:
522            self._message_repository.log_message(
523                Level.DEBUG,
524                lambda: format_http_message(
525                    response,
526                    "Refresh token",
527                    "Obtains access token",
528                    self._NO_STREAM_NAME,
529                    is_auxiliary=True,
530                    type="AUTH",
531                ),
532            )
533
534    # ----------------
535    # ABSTR METHODS
536    # ----------------
537
538    @abstractmethod
539    def get_token_refresh_endpoint(self) -> Optional[str]:
540        """Returns the endpoint to refresh the access token"""
541
542    @abstractmethod
543    def get_client_id_name(self) -> str:
544        """The client id name to authenticate"""
545
546    @abstractmethod
547    def get_client_id(self) -> str:
548        """The client id to authenticate"""
549
550    @abstractmethod
551    def get_client_secret_name(self) -> str:
552        """The client secret name to authenticate"""
553
554    @abstractmethod
555    def get_client_secret(self) -> str:
556        """The client secret to authenticate"""
557
558    @abstractmethod
559    def get_refresh_token_name(self) -> str:
560        """The refresh token name to authenticate"""
561
562    @abstractmethod
563    def get_refresh_token(self) -> Optional[str]:
564        """The token used to refresh the access token when it expires"""
565
566    @abstractmethod
567    def get_scopes(self) -> List[str]:
568        """List of requested scopes"""
569
570    @abstractmethod
571    def get_token_expiry_date(self) -> AirbyteDateTime:
572        """Expiration date of the access token"""
573
574    @abstractmethod
575    def set_token_expiry_date(self, value: AirbyteDateTime) -> None:
576        """Setter for access token expiration date"""
577
578    @abstractmethod
579    def get_access_token_name(self) -> str:
580        """Field to extract access token from in the response"""
581
582    @abstractmethod
583    def get_expires_in_name(self) -> str:
584        """Returns the expires_in field name"""
585
586    @abstractmethod
587    def get_refresh_request_body(self) -> Mapping[str, Any]:
588        """Returns the request body to set on the refresh request"""
589
590    @abstractmethod
591    def get_refresh_request_headers(self) -> Mapping[str, Any]:
592        """Returns the request headers to set on the refresh request"""
593
594    def should_send_refresh_request_as_query_params(self) -> bool:
595        """Returns `True` if the standard refresh args should be sent on the URL
596        query string instead of in the request body.
597
598        Defaults to `False` so existing authenticators retain their previous
599        behavior (params in body, no query params on the refresh URL). Subclasses
600        can override this to opt into the URL-query-string shape required by OAuth
601        providers like Gong.
602        """
603        return False
604
605    @abstractmethod
606    def get_grant_type(self) -> str:
607        """Returns grant_type specified for requesting access_token"""
608
609    @abstractmethod
610    def get_grant_type_name(self) -> str:
611        """Returns grant_type specified name for requesting access_token"""
612
613    @property
614    @abstractmethod
615    def access_token(self) -> str:
616        """Returns the access token"""
617
618    @access_token.setter
619    @abstractmethod
620    def access_token(self, value: str) -> str:
621        """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, ...] = ())
53    def __init__(
54        self,
55        refresh_token_error_status_codes: Tuple[int, ...] = (),
56        refresh_token_error_key: str = "",
57        refresh_token_error_values: Tuple[str, ...] = (),
58    ) -> None:
59        """
60        If all of refresh_token_error_status_codes, refresh_token_error_key, and refresh_token_error_values are set,
61        then http errors with such params will be wrapped in AirbyteTracedException.
62        """
63        self._refresh_token_error_status_codes = refresh_token_error_status_codes
64        self._refresh_token_error_key = refresh_token_error_key
65        self._refresh_token_error_values = refresh_token_error_values

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.

token_expiry_is_time_of_expiration: bool
76    @property
77    def token_expiry_is_time_of_expiration(self) -> bool:
78        """
79        Indicates that the Token Expiry returns the date until which the token will be valid, not the amount of time it will be valid.
80        """
81
82        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]
84    @property
85    def token_expiry_date_format(self) -> Optional[str]:
86        """
87        Format of the datetime; exists it if expires_in is returned as the expiration datetime instead of seconds until it expires
88        """
89
90        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]:
92    def get_auth_header(self) -> Mapping[str, Any]:
93        """HTTP header to set on the requests"""
94        token = self.access_token if self._is_access_token_flow else self.get_access_token()
95        return {"Authorization": f"Bearer {token}"}

HTTP header to set on the requests

def get_access_token(self) -> str:
 97    def get_access_token(self) -> str:
 98        """
 99        Returns the access token.
100
101        This method uses double-checked locking to ensure thread-safe token refresh.
102        When multiple threads (streams) detect an expired token simultaneously, only one
103        will perform the refresh while others wait. After acquiring the lock, the token
104        expiry is re-checked to avoid redundant refresh attempts.
105        """
106        if self.token_has_expired():
107            with self._token_refresh_lock:
108                # Double-check after acquiring lock - another thread may have already refreshed
109                if self.token_has_expired():
110                    self.refresh_and_set_access_token()
111
112        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:
114    def refresh_and_set_access_token(self) -> None:
115        """Force refresh the access token and update internal state.
116
117        This method refreshes the access token regardless of whether it has expired,
118        and updates the internal token and expiry date. Subclasses may override this
119        to handle additional state updates (e.g., persisting new refresh tokens).
120        """
121        token, expires_in = self.refresh_access_token()
122        self.access_token = token
123        self.set_token_expiry_date(expires_in)

Force refresh the access token and update internal state.

This method refreshes the access token regardless of whether it has expired, and updates the internal token and expiry date. Subclasses may override this to handle additional state updates (e.g., persisting new refresh tokens).

def token_has_expired(self) -> bool:
125    def token_has_expired(self) -> bool:
126        """Returns True if the token is expired"""
127        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]:
169    def build_refresh_request_body(self) -> Mapping[str, Any]:
170        """Returns the request body to set on the refresh request.
171
172        When `should_send_refresh_request_as_query_params()` is `True`, the standard
173        refresh args are emitted on the URL query string instead and this method
174        returns an empty body. This supports OAuth providers like Gong that document
175        their refresh endpoint as a `POST` with parameters on the URL query string
176        and an empty body.
177        """
178        if self.should_send_refresh_request_as_query_params():
179            return {}
180        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]]:
182    def build_refresh_request_headers(self) -> Mapping[str, Any] | None:
183        """
184        Returns the request headers to set on the refresh request
185
186        """
187        headers = self.get_refresh_request_headers()
188        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]]:
190    def build_refresh_request_query_params(self) -> Mapping[str, Any] | None:
191        """Returns the URL query string parameters to set on the refresh request.
192
193        When `should_send_refresh_request_as_query_params()` is `True`, the standard
194        refresh args (grant_type, refresh_token, client credentials, scopes, plus
195        any user-configured `refresh_request_body` extras) are returned here and
196        `build_refresh_request_body()` returns an empty body.
197
198        Returns `None` otherwise so existing authenticators retain their previous
199        behavior (no query params on the refresh URL).
200        """
201        if not self.should_send_refresh_request_as_query_params():
202            return None
203        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]:
205    def refresh_access_token(self) -> Tuple[str, AirbyteDateTime]:
206        """
207        Returns the refresh token and its expiration datetime
208
209        :return: a tuple of (access_token, token_lifespan)
210        """
211        try:
212            response_json = self._make_handled_request()
213        except (
214            requests.exceptions.ConnectionError,
215            requests.exceptions.ConnectTimeout,
216            requests.exceptions.ReadTimeout,
217        ) as e:
218            raise AirbyteTracedException(
219                message="OAuth access token refresh request failed due to a network error.",
220                internal_message=f"Network error during OAuth token refresh after retries were exhausted: {e}",
221                failure_type=FailureType.transient_error,
222            ) from e
223        self._ensure_access_token_in_response(response_json)
224
225        return (
226            self._extract_access_token(response_json),
227            self._extract_token_expiry_date(response_json),
228        )

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]:
538    @abstractmethod
539    def get_token_refresh_endpoint(self) -> Optional[str]:
540        """Returns the endpoint to refresh the access token"""

Returns the endpoint to refresh the access token

@abstractmethod
def get_client_id_name(self) -> str:
542    @abstractmethod
543    def get_client_id_name(self) -> str:
544        """The client id name to authenticate"""

The client id name to authenticate

@abstractmethod
def get_client_id(self) -> str:
546    @abstractmethod
547    def get_client_id(self) -> str:
548        """The client id to authenticate"""

The client id to authenticate

@abstractmethod
def get_client_secret_name(self) -> str:
550    @abstractmethod
551    def get_client_secret_name(self) -> str:
552        """The client secret name to authenticate"""

The client secret name to authenticate

@abstractmethod
def get_client_secret(self) -> str:
554    @abstractmethod
555    def get_client_secret(self) -> str:
556        """The client secret to authenticate"""

The client secret to authenticate

@abstractmethod
def get_refresh_token_name(self) -> str:
558    @abstractmethod
559    def get_refresh_token_name(self) -> str:
560        """The refresh token name to authenticate"""

The refresh token name to authenticate

@abstractmethod
def get_refresh_token(self) -> Optional[str]:
562    @abstractmethod
563    def get_refresh_token(self) -> Optional[str]:
564        """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]:
566    @abstractmethod
567    def get_scopes(self) -> List[str]:
568        """List of requested scopes"""

List of requested scopes

@abstractmethod
def get_token_expiry_date(self) -> airbyte_cdk.utils.datetime_helpers.AirbyteDateTime:
570    @abstractmethod
571    def get_token_expiry_date(self) -> AirbyteDateTime:
572        """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:
574    @abstractmethod
575    def set_token_expiry_date(self, value: AirbyteDateTime) -> None:
576        """Setter for access token expiration date"""

Setter for access token expiration date

@abstractmethod
def get_access_token_name(self) -> str:
578    @abstractmethod
579    def get_access_token_name(self) -> str:
580        """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:
582    @abstractmethod
583    def get_expires_in_name(self) -> str:
584        """Returns the expires_in field name"""

Returns the expires_in field name

@abstractmethod
def get_refresh_request_body(self) -> Mapping[str, Any]:
586    @abstractmethod
587    def get_refresh_request_body(self) -> Mapping[str, Any]:
588        """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]:
590    @abstractmethod
591    def get_refresh_request_headers(self) -> Mapping[str, Any]:
592        """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:
594    def should_send_refresh_request_as_query_params(self) -> bool:
595        """Returns `True` if the standard refresh args should be sent on the URL
596        query string instead of in the request body.
597
598        Defaults to `False` so existing authenticators retain their previous
599        behavior (params in body, no query params on the refresh URL). Subclasses
600        can override this to opt into the URL-query-string shape required by OAuth
601        providers like Gong.
602        """
603        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:
605    @abstractmethod
606    def get_grant_type(self) -> str:
607        """Returns grant_type specified for requesting access_token"""

Returns grant_type specified for requesting access_token

@abstractmethod
def get_grant_type_name(self) -> str:
609    @abstractmethod
610    def get_grant_type_name(self) -> str:
611        """Returns grant_type specified name for requesting access_token"""

Returns grant_type specified name for requesting access_token

access_token: str
613    @property
614    @abstractmethod
615    def access_token(self) -> str:
616        """Returns the access token"""

Returns the access token