ferrero-opentext/Python-Version/venv/lib/python3.12/site-packages/boxsdk/auth/server_auth.py

212 lines
8.4 KiB
Python

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.
<https://developer.box.com/en/guides/applications/>
<https://developer.box.com/en/guides/authentication/select/>
: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.")