import time from abc import ABC, abstractmethod from datetime import datetime from typing import Optional, Tuple, TYPE_CHECKING, Union, Any from boxsdk.auth.oauth2 import OAuth2 from boxsdk.exception import BoxOAuthException from boxsdk.config import API from boxsdk.object.user import User if TYPE_CHECKING: from boxsdk.network.network_interface import NetworkResponse class ServerAuth(ABC, OAuth2): USER_SUBJECT_TYPE = 'user' ENTERPRISE_SUBJECT_TYPE = 'enterprise' def __init__( self, client_id: str, client_secret: str, enterprise_id: Optional[str] = None, user: Optional[Union[str, 'User']] = None, **kwargs: Any ): super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) self._enterprise_id = enterprise_id self._user_id = self._normalize_user_id(user) def _refresh(self, access_token: str) -> Tuple[str, None]: """ Base class override. Instead of refreshing an access token using a refresh token, we just issue a new JWT request. """ # pylint:disable=unused-argument if self._user_id is None: new_access_token = self.authenticate_instance() else: new_access_token = self.authenticate_user() return new_access_token, None def authenticate_user(self, user: Union[str, 'User'] = None) -> str: """ Get an access token for a User. May be one of this application's created App User. Depending on the configured User Access Level, may also be any other App User or Managed User in the enterprise. :param user: (optional) The user to authenticate, expressed as a Box User ID or as a :class:`User` instance. If not given, then the most recently provided user ID, if available, will be used. :raises: :exc:`ValueError` if no user ID was passed and the object is not currently configured with one. :return: The access token for the user. """ sub = self._normalize_user_id(user) or self._user_id if not sub: raise ValueError("authenticate_user: Requires the user ID, but it was not provided.") self._user_id = sub return self._authenticate(sub, self.USER_SUBJECT_TYPE) authenticate_app_user = authenticate_user def authenticate_instance(self, enterprise: Optional[str] = None) -> str: """ Get an access token for a Box Developer Edition enterprise. :param enterprise: The ID of the Box Developer Edition enterprise. Optional if the value was already given to `__init__`, otherwise required. :raises: :exc:`ValueError` if `None` was passed for the enterprise ID here and in `__init__`, or if the non-`None` value passed here does not match the non-`None` value passed to `__init__`. :return: The access token for the enterprise which can provision/deprovision app users. """ enterprises = [enterprise, self._enterprise_id] if not any(enterprises): raise ValueError("authenticate_instance: Requires the enterprise ID, but it was not provided.") if all(enterprises) and (enterprise != self._enterprise_id): raise ValueError( f"authenticate_instance: Given enterprise ID {enterprise!r}, " f"but {self} already has ID {self._enterprise_id!r}" ) if not self._enterprise_id: self._enterprise_id = enterprise self._user_id = None return self._authenticate(self._enterprise_id, self.ENTERPRISE_SUBJECT_TYPE) def _authenticate(self, subject_id: str, subject_type: str) -> str: """ Authenticate with server type authentication (JWT or CCG). If authorization fails because the expiration time is out of sync with the Box servers, retry using the time returned in the error response. Pass an enterprise ID to get an enterprise token (which can be used to provision/deprovision users), or a user ID to get a user token. :param subject_id: The enterprise ID or user ID to auth. :param subject_type: Either 'enterprise' or 'user' :return: The access token for the enterprise or app user. """ attempt_number = 0 date = None while True: try: return self._fetch_access_token(subject_id, subject_type, date) except BoxOAuthException as ex: network_response = ex.network_response code = network_response.status_code # pylint: disable=maybe-no-member box_datetime = self._get_date_header(network_response) if attempt_number >= API.MAX_RETRY_ATTEMPTS: raise ex if code == 429 or code >= 500: date = None elif box_datetime is not None and self._is_auth_error_retryable(network_response): date = box_datetime else: raise ex time_delay = self._session.get_retry_after_time( attempt_number, network_response.headers.get('Retry-After', None) ) time.sleep(time_delay) attempt_number += 1 self._logger.debug('Retrying authentication request') @abstractmethod def _fetch_access_token(self, subject_id: str, subject_type: str, now_time: Optional[datetime] = None) -> str: pass @staticmethod def _get_date_header(network_response: 'NetworkResponse') -> Optional[datetime]: """ Get datetime object for Date header, if the Date header is available. :param network_response: The response from the Box API that should include a Date header. :return: The datetime parsed from the Date header, or None if the header is absent or if it couldn't be parsed. """ box_date_header = network_response.headers.get('Date', None) if box_date_header is not None: try: return datetime.strptime(box_date_header, '%a, %d %b %Y %H:%M:%S %Z') except ValueError: pass return None @staticmethod def _is_auth_error_retryable(network_response: 'NetworkResponse') -> bool: """ Determine whether the network response indicates that the authorization request was rejected because of the exp or jti claim and can be retried. Exp claim error can happen if the current system time is too different from the Box server time. If got an error: "A unique 'jti' value is required", we also retry auth in order to use new 'jti' claim. Returns True if the status code is 400, the error code is invalid_grant, and the error description indicates a problem with the exp or jti claim; False, otherwise. :param network_response: The response from the Box API that should include a Date header. """ status_code = network_response.status_code try: json_response = network_response.json() except ValueError: return False error_code = json_response.get('error', '') error_description = json_response.get('error_description', '') return status_code == 400 and error_code == 'invalid_grant' \ and ('exp' in error_description or 'jti' in error_description) @classmethod def _normalize_user_id(cls, user: Any) -> Optional[str]: """Get a Box user ID from a selection of supported param types. :param user: An object representing the user or user ID. Currently supported types are `unicode` (which represents the user ID) and :class:`User`. If `None`, returns `None`. :raises: :exc:`TypeError` for unsupported types. """ if user is None: return None if isinstance(user, User): return user.object_id if isinstance(user, str): return str(user) raise TypeError(f"Got unsupported type {user.__class__.__name__!r} for user.")