# Copyright (C) 2023 Red Hat, Inc., Jose Castillo <jcastillo@redhat.com>
# This file is part of the sos project: https://github.com/sosreport/sos
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# version 2 of the GNU General Public License.
#
# See the LICENSE file in the source distribution for further information.
import logging
try:
import requests
REQUESTS_LOADED = True
except ImportError:
REQUESTS_LOADED = False
import time
from datetime import datetime, timedelta
DEVICE_AUTH_CLIENT_ID = "sos-tools"
GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
logger = logging.getLogger("sos")
class DeviceAuthorizationClass:
"""
Device Authorization Class
"""
def __init__(self, client_identifier_url, token_endpoint):
self._access_token = None
self._access_expires_at = None
self.__device_code = None
self.client_identifier_url = client_identifier_url
self.token_endpoint = token_endpoint
self._use_device_code_grant()
def _use_device_code_grant(self):
"""
Start the device auth flow. In the future we will
store the tokens in an in-memory keyring.
"""
self._request_device_code()
print(
"Please visit the following URL to authenticate this device: {}"
.format(self._verification_uri_complete)
)
self.poll_for_auth_completion()
def _request_device_code(self):
"""
Initialize new Device Authorization Grant attempt by
requesting a new device code.
"""
data = "client_id={}".format(DEVICE_AUTH_CLIENT_ID)
headers = {'content-type': 'application/x-www-form-urlencoded'}
if not REQUESTS_LOADED:
raise Exception("python3-requests is not installed and is required"
" for obtaining device auth token.")
try:
res = requests.post(
self.client_identifier_url,
data=data,
headers=headers)
res.raise_for_status()
response = res.json()
self._user_code = response.get("user_code")
self._verification_uri = response.get("verification_uri")
self._interval = response.get("interval")
self.__device_code = response.get("device_code")
self._verification_uri_complete = response.get(
"verification_uri_complete")
except requests.HTTPError as e:
raise requests.HTTPError(
"HTTP request failed while attempting to acquire the tokens."
" Error returned was {}".format(res.status_code)
)
def poll_for_auth_completion(self):
"""
Continuously poll OIDC token endpoint until the user is successfully
authenticated or an error occurs.
"""
token_data = {'grant_type': GRANT_TYPE_DEVICE_CODE,
'client_id': DEVICE_AUTH_CLIENT_ID,
'device_code': self.__device_code}
if not REQUESTS_LOADED:
raise Exception("python3-requests is not installed and is required"
" for obtaining device auth token.")
while self._access_token is None:
time.sleep(self._interval)
try:
check_auth_completion = requests.post(self.token_endpoint,
data=token_data)
status_code = check_auth_completion.status_code
if status_code == 200:
logger.info("The SSO authentication is successful")
self._set_token_data(check_auth_completion.json())
if status_code not in [200, 400]:
raise Exception(status_code, check_auth_completion.text)
if status_code == 400 and \
check_auth_completion.json()['error'] not in \
("authorization_pending", "slow_down"):
raise Exception(status_code, check_auth_completion.text)
except requests.exceptions.RequestException as e:
logger.error("Error was found while posting a request: {}"
.format(e))
def _set_token_data(self, token_data):
"""
Set the class attributes as per the input token_data received.
In the future we will persist the token data in a local,
in-memory keyring, to avoid visting the browser frequently.
:param token_data: Token data containing access_token, refresh_token
and their expiry etc.
"""
self._access_token = token_data.get("access_token")
self._access_expires_at = datetime.utcnow() + \
timedelta(seconds=token_data.get("expires_in"))
self._refresh_token = token_data.get("refresh_token")
self._refresh_expires_in = token_data.get("refresh_expires_in")
if self._refresh_expires_in == 0:
self._refresh_expires_at = datetime.max
else:
self._refresh_expires_at = datetime.utcnow() + \
timedelta(seconds=self._refresh_expires_in)
def get_access_token(self):
"""
Get the valid access_token at any given time.
:return: Access_token
:rtype: string
"""
if self.is_access_token_valid():
return self._access_token
else:
if self.is_refresh_token_valid():
self._use_refresh_token_grant()
return self._access_token
else:
self._use_device_code_grant()
return self._access_token
def is_access_token_valid(self):
"""
Check the validity of access_token. We are considering it invalid 180
sec. prior to it's exact expiry time.
:return: True/False
"""
return self._access_token and self._access_expires_at and \
self._access_expires_at - timedelta(seconds=180) > \
datetime.utcnow()
def is_refresh_token_valid(self):
"""
Check the validity of refresh_token. We are considering it invalid
180 sec. prior to it's exact expiry time.
:return: True/False
"""
return self._refresh_token and self._refresh_expires_at and \
self._refresh_expires_at - timedelta(seconds=180) > \
datetime.utcnow()
def _use_refresh_token_grant(self, refresh_token=None):
"""
Fetch the new access_token and refresh_token using the existing
refresh_token and persist it.
:param refresh_token: optional param for refresh_token
"""
if not REQUESTS_LOADED:
raise Exception("python3-requests is not installed and is required"
" for obtaining device auth token.")
refresh_token_data = {'client_id': DEVICE_AUTH_CLIENT_ID,
'grant_type': 'refresh_token',
'refresh_token': self._refresh_token if not
refresh_token else refresh_token}
refresh_token_res = requests.post(self.token_endpoint,
data=refresh_token_data)
if refresh_token_res.status_code == 200:
self._set_token_data(refresh_token_res.json())
elif refresh_token_res.status_code == 400 and 'invalid' in\
refresh_token_res.json()['error']:
logger.warning("Problem while fetching the new tokens from refresh"
" token grant - {} {}."
" New Device code will be requested !".format
(refresh_token_res.status_code,
refresh_token_res.json()['error']))
self._use_device_code_grant()
else:
raise Exception(
"Something went wrong while using the "
"Refresh token grant for fetching tokens:"
"Returned status code {0} and error {1}"
.format(refresh_token_res.status_code,
refresh_token_res.json()['error']))