mirror of
https://github.com/GAM-team/GAM.git
synced 2026-07-04 12:51:36 +00:00
825 lines
31 KiB
Python
825 lines
31 KiB
Python
"""_getMain().GAM OAuth flows and credential management.
|
|
|
|
Extracted from gam/__init__.py. Provides OAuth2 authentication flows,
|
|
credential creation/deletion/info/update/refresh/export commands.
|
|
"""
|
|
|
|
import ipaddress
|
|
import json
|
|
import multiprocessing
|
|
import os
|
|
import re
|
|
import socket
|
|
import sys
|
|
import datetime
|
|
import time
|
|
import webbrowser
|
|
import wsgiref.simple_server
|
|
|
|
import arrow
|
|
import google.oauth2.credentials
|
|
import google_auth_oauthlib.flow
|
|
|
|
from filelock import FileLock
|
|
from urllib.parse import urlparse, parse_qs, urlencode
|
|
|
|
from gamlib import glaction
|
|
from gamlib import glapi as API
|
|
from gamlib import glcfg as GC
|
|
from gamlib import glclargs
|
|
from gamlib import glentity
|
|
from gamlib import glgapi as GAPI
|
|
from gamlib import glglobals as GM
|
|
from gamlib import glindent
|
|
from gamlib import glmsgs as Msg
|
|
from gam.util.api import (
|
|
buildGAPIObject,
|
|
callGAPI,
|
|
getClientCredentials,
|
|
getHttpObj,
|
|
getOauth2TxtCredentials,
|
|
shortenURL,
|
|
writeClientCredentials,
|
|
)
|
|
from gam.util.args import (
|
|
ISOformatTimeStamp,
|
|
UTF8,
|
|
YYYYMMDDTHHMMSSZ_FORMAT,
|
|
checkForExtraneousArguments,
|
|
getArgument,
|
|
getEmailAddress,
|
|
getString,
|
|
)
|
|
from gam.util.display import (
|
|
entityActionNotPerformedWarning,
|
|
entityActionPerformed,
|
|
entityModifierNewValueActionPerformed,
|
|
printBlankLine,
|
|
printEntity,
|
|
printKeyValueList,
|
|
)
|
|
from gam.util.errors import (
|
|
CLIENT_SECRETS_JSON_REQUIRED_RC,
|
|
Cmd,
|
|
entityActionFailedExit,
|
|
invalidChoiceExit,
|
|
invalidClientSecretsJsonExit,
|
|
invalidOauth2TxtExit,
|
|
unknownArgumentExit,
|
|
)
|
|
from gam.util.fileio import DEFAULT_FILE_READ_MODE, deleteFile, readFile
|
|
from gam.util.output import (
|
|
Ind,
|
|
readStdin,
|
|
stderrErrorMsg,
|
|
systemErrorExit,
|
|
writeStdout,
|
|
)
|
|
|
|
Act = glaction.GamAction()
|
|
Ent = glentity.GamEntity()
|
|
Ind = glindent.GamIndent()
|
|
Cmd = glclargs.GamCLArgs()
|
|
|
|
|
|
def _getMain():
|
|
return sys.modules['gam']
|
|
|
|
def __getattr__(name):
|
|
"""Fall back to gam module for any undefined names."""
|
|
main = _getMain()
|
|
try:
|
|
return getattr(main, name)
|
|
except AttributeError:
|
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
|
|
ERROR_PREFIX = 'ERROR: '
|
|
|
|
|
|
VALIDEMAIL_PATTERN = re.compile(r'^[^@]+@[^@]+\.[^@]+$')
|
|
|
|
def _getValidateLoginHint(login_hint, projectId=None):
|
|
while True:
|
|
if not login_hint:
|
|
if not projectId:
|
|
login_hint = readStdin(Msg.ENTER_GSUITE_ADMIN_EMAIL_ADDRESS).strip()
|
|
else:
|
|
login_hint = readStdin(Msg.ENTER_MANAGE_GCP_PROJECT_EMAIL_ADDRESS.format(projectId)).strip()
|
|
if login_hint.find('@') == -1 and GC.Values[GC.DOMAIN]:
|
|
login_hint = f'{login_hint}@{GC.Values[GC.DOMAIN]}'
|
|
if VALIDEMAIL_PATTERN.match(login_hint):
|
|
return login_hint
|
|
sys.stdout.write(f'{ERROR_PREFIX}Invalid email address: {login_hint}\n')
|
|
login_hint = None
|
|
|
|
def getOAuthClientIDAndSecret():
|
|
cs_data = readFile(GC.Values[GC.CLIENT_SECRETS_JSON], continueOnError=True, displayError=True)
|
|
if not cs_data:
|
|
invalidClientSecretsJsonExit(Msg.NO_DATA)
|
|
try:
|
|
cs_json = json.loads(cs_data)
|
|
if not cs_json:
|
|
systemErrorExit(CLIENT_SECRETS_JSON_REQUIRED_RC, Msg.NO_CLIENT_ACCESS_CREATE_UPDATE_ALLOWED)
|
|
return (cs_json['installed']['client_id'], cs_json['installed']['client_secret'])
|
|
except (IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
|
|
invalidClientSecretsJsonExit(str(e))
|
|
|
|
def getScopesFromUser(scopesList, clientAccess, currentScopes=None):
|
|
OAUTH2_CMDS = ['s', 'u', 'e', 'c']
|
|
oauth2_menu = ''
|
|
numScopes = len(scopesList)
|
|
for a_scope in scopesList:
|
|
oauth2_menu += f"[%%s] %2d) {a_scope['name']}"
|
|
if a_scope.get('subscopes'):
|
|
oauth2_menu += f' (supports {" and ".join(a_scope["subscopes"])})'
|
|
oauth2_menu += '\n'
|
|
oauth2_menu += '''
|
|
Select an unselected scope [ ] by entering a number; yields [*]
|
|
For scopes that optionally support readonly, enter a number and an 'r' to grant readonly access; yields [R]
|
|
For scopes that optionally support actiononly, enter a number and an 'a' to grant actiononly access; yields [A]
|
|
Clear readonly access [R] or actiononly access [A] from a scope by entering a number; yields [*]
|
|
Unselect a selected scope [*] by entering a number; yields [ ]
|
|
Select all default scopes by entering an 's'; yields [*] for default scopes, [ ] for others
|
|
Unselect all scopes by entering a 'u'; yields [ ] for all scopes
|
|
Exit without changes/authorization by entering an 'e'
|
|
Continue to authorization by entering a 'c'
|
|
'''
|
|
menu = oauth2_menu % tuple(range(numScopes))
|
|
selectedScopes = ['*'] * numScopes
|
|
if currentScopes is None and clientAccess:
|
|
lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK], mode=GC.Values[GC.OAUTH2_TXT_LOCK_MODE])
|
|
with lock:
|
|
_, credentials = getOauth2TxtCredentials(exitOnError=False)
|
|
if credentials and credentials.scopes is not None:
|
|
currentScopes = sorted(credentials.scopes)
|
|
if currentScopes is not None:
|
|
if clientAccess:
|
|
i = 0
|
|
for a_scope in scopesList:
|
|
selectedScopes[i] = ' '
|
|
possibleScope = a_scope['scope']
|
|
subScopes = a_scope.get('subscopes', [])
|
|
for currentScope in currentScopes:
|
|
if currentScope == possibleScope:
|
|
selectedScopes[i] = '*'
|
|
break
|
|
if 'readonly' in subScopes:
|
|
if currentScope == possibleScope+'.readonly':
|
|
selectedScopes[i] = 'R'
|
|
break
|
|
if 'actiononly' in subScopes:
|
|
if currentScope == possibleScope+'.action':
|
|
selectedScopes[i] = 'A'
|
|
break
|
|
i += 1
|
|
else:
|
|
i = 0
|
|
for a_scope in scopesList:
|
|
selectedScopes[i] = ' '
|
|
api = a_scope['api']
|
|
possibleScope = a_scope['scope']
|
|
subScopes = a_scope.get('subscopes', [])
|
|
if api in currentScopes:
|
|
if not isinstance(possibleScope, list):
|
|
for scope in currentScopes[api]:
|
|
if scope == possibleScope:
|
|
selectedScopes[i] = '*'
|
|
break
|
|
if 'readonly' in subScopes:
|
|
if (scope == possibleScope+'.readonly') or (scope == a_scope.get('roscope')):
|
|
selectedScopes[i] = 'R'
|
|
break
|
|
else:
|
|
for scope in possibleScope:
|
|
if scope not in currentScopes[api]:
|
|
break
|
|
else:
|
|
selectedScopes[i] = '*'
|
|
i += 1
|
|
else:
|
|
i = 0
|
|
for a_scope in scopesList:
|
|
if a_scope.get('offByDefault'):
|
|
selectedScopes[i] = ' '
|
|
elif a_scope.get('roByDefault'):
|
|
selectedScopes[i] = 'R'
|
|
else:
|
|
selectedScopes[i] = '*'
|
|
i += 1
|
|
prompt = f'\nPlease enter 0-{numScopes-1}[a|r] or {"|".join(OAUTH2_CMDS)}: '
|
|
while True:
|
|
os.system(['clear', 'cls'][sys.platform.startswith('win')])
|
|
sys.stdout.write(menu % tuple(selectedScopes))
|
|
while True:
|
|
choice = readStdin(prompt)
|
|
if choice:
|
|
selection = choice.lower()
|
|
if selection.find('r') >= 0:
|
|
mode = 'R'
|
|
selection = selection.replace('r', '')
|
|
elif selection.find('a') >= 0:
|
|
mode = 'A'
|
|
selection = selection.replace('a', '')
|
|
else:
|
|
mode = ' '
|
|
if selection and selection.isdigit():
|
|
selection = int(selection)
|
|
if isinstance(selection, int) and selection < numScopes:
|
|
if mode == 'R':
|
|
if 'readonly' not in scopesList[selection].get('subscopes',[]):
|
|
sys.stdout.write(f'{ERROR_PREFIX}Scope {selection} does not support readonly mode!\n')
|
|
continue
|
|
elif mode == 'A':
|
|
if 'actiononly' not in scopesList[selection].get('subscopes', []):
|
|
sys.stdout.write(f'{ERROR_PREFIX}Scope {selection} does not support actiononly mode!\n')
|
|
continue
|
|
elif selectedScopes[selection] != '*':
|
|
mode = '*'
|
|
else:
|
|
mode = ' '
|
|
selectedScopes[selection] = mode
|
|
break
|
|
if isinstance(selection, str) and selection in OAUTH2_CMDS:
|
|
if selection == 's':
|
|
i = 0
|
|
for a_scope in scopesList:
|
|
selectedScopes[i] = ' ' if a_scope.get('offByDefault', False) else '*'
|
|
i += 1
|
|
elif selection == 'u':
|
|
for i in range(numScopes):
|
|
selectedScopes[i] = ' '
|
|
elif selection == 'e':
|
|
return None
|
|
break
|
|
sys.stdout.write(f'{ERROR_PREFIX}Invalid input "{choice}"\n')
|
|
if selection == 'c':
|
|
if clientAccess:
|
|
numSelectedScopes = 0
|
|
i = 0
|
|
for a_scope in scopesList:
|
|
if selectedScopes[i] == '*':
|
|
if a_scope['scope']:
|
|
numSelectedScopes += 1
|
|
elif selectedScopes[i] != ' ':
|
|
numSelectedScopes += 1
|
|
i += 1
|
|
if numSelectedScopes <= API.NUM_CLIENT_SCOPES_ERROR_LIMIT:
|
|
break
|
|
# If number of scopes is > 48 we'll probably get an error
|
|
writeStdout(Msg.NUM_SELECTED_CLIENT_SCOPES.format(numSelectedScopes, API.NUM_CLIENT_SCOPES_ERROR_LIMIT))
|
|
choice = readStdin('\nPlease enter c to continue to authorization or any other key to amend selection: ')
|
|
if choice and choice.lower() == 'c':
|
|
break
|
|
else:
|
|
break
|
|
return selectedScopes
|
|
|
|
def _localhost_to_ip():
|
|
'''returns IPv4 or IPv6 loopback address which localhost resolves to.
|
|
If localhost does not resolve to valid loopback IP address then returns
|
|
127.0.0.1'''
|
|
# TODO gethostbyname() will only ever return ipv4
|
|
# find a way to support IPv6 here and get preferred IP
|
|
# note that IPv6 may be broken on some systems also :-(
|
|
# for now IPv4 should do.
|
|
local_ip = socket.gethostbyname('localhost')
|
|
# local_ip = socket.getaddrinfo('localhost', None)[0][-1][0] # works with ipv6, makes wsgiref fail
|
|
if not ipaddress.ip_address(local_ip).is_loopback:
|
|
local_ip = '127.0.0.1'
|
|
return local_ip
|
|
|
|
def _waitForHttpClient(d):
|
|
wsgi_app = google_auth_oauthlib.flow._RedirectWSGIApp(Msg.AUTHENTICATION_FLOW_COMPLETE_CLOSE_BROWSER.format(_getMain().GAM))
|
|
wsgiref.simple_server.WSGIServer.allow_reuse_address = False
|
|
# Convert hostname to IP since apparently binding to the IP
|
|
# reduces odds of firewall blocking us
|
|
local_ip = _localhost_to_ip()
|
|
for port in range(8080, 8099):
|
|
try:
|
|
local_server = wsgiref.simple_server.make_server(
|
|
local_ip,
|
|
port,
|
|
wsgi_app,
|
|
handler_class=wsgiref.simple_server.WSGIRequestHandler
|
|
)
|
|
break
|
|
except OSError:
|
|
pass
|
|
redirect_uri_format = "http://{}:{}/" if d['trailing_slash'] else "http://{}:{}"
|
|
# provide redirect_uri to main process so it can formulate auth_url
|
|
d['redirect_uri'] = redirect_uri_format.format(*local_server.server_address)
|
|
# wait until main process provides auth_url
|
|
# so we can open it in web browser.
|
|
while 'auth_url' not in d:
|
|
time.sleep(0.1)
|
|
if d['open_browser']:
|
|
webbrowser.open(d['auth_url'], new=1, autoraise=True)
|
|
try:
|
|
local_server.handle_request()
|
|
authorization_response = wsgi_app.last_request_uri.replace("http", "https")
|
|
d['code'] = authorization_response
|
|
except:
|
|
pass
|
|
local_server.server_close()
|
|
|
|
def _waitForUserInput(d):
|
|
sys.stdin = open(0, DEFAULT_FILE_READ_MODE, encoding=UTF8)
|
|
d['code'] = readStdin(Msg.ENTER_VERIFICATION_CODE_OR_URL)
|
|
|
|
class _GamOauthFlow(google_auth_oauthlib.flow.InstalledAppFlow):
|
|
def run_dual(self, **kwargs):
|
|
mgr = multiprocessing.Manager()
|
|
d = mgr.dict()
|
|
d['trailing_slash'] = True
|
|
d['open_browser'] = not GC.Values[GC.NO_BROWSER]
|
|
httpClientProcess = multiprocessing.Process(target=_waitForHttpClient, args=(d,))
|
|
userInputProcess = multiprocessing.Process(target=_waitForUserInput, args=(d,))
|
|
httpClientProcess.start()
|
|
# we need to wait until web server starts on avail port
|
|
# so we know redirect_uri to use
|
|
while 'redirect_uri' not in d:
|
|
time.sleep(0.1)
|
|
self.redirect_uri = d['redirect_uri']
|
|
d['auth_url'], _ = super().authorization_url(**kwargs)
|
|
d['auth_url'] = shortenURL(d['auth_url'])
|
|
print(Msg.OAUTH2_GO_TO_LINK_MESSAGE.format(url=d['auth_url']))
|
|
userInputProcess.start()
|
|
userInput = False
|
|
checkHttp = checkUser = True
|
|
alive = 2
|
|
while alive > 0:
|
|
time.sleep(0.1)
|
|
if checkHttp and not httpClientProcess.is_alive():
|
|
if 'code' in d:
|
|
if checkUser:
|
|
userInputProcess.terminate()
|
|
break
|
|
checkHttp = False
|
|
alive -= 1
|
|
if checkUser and not userInputProcess.is_alive():
|
|
userInput = True
|
|
if 'code' in d:
|
|
if checkHttp:
|
|
httpClientProcess.terminate()
|
|
break
|
|
checkUser = False
|
|
alive -= 1
|
|
if 'code' not in d:
|
|
systemErrorExit(_getMain().SYSTEM_ERROR_RC, Msg.AUTHENTICATION_FLOW_FAILED)
|
|
while True:
|
|
code = d['code']
|
|
if code.startswith('http'):
|
|
parsed_url = urlparse(code)
|
|
parsed_params = parse_qs(parsed_url.query)
|
|
code = parsed_params.get('code', [None])[0]
|
|
try:
|
|
fetch_args = {'code': code}
|
|
if GC.Values[GC.CACERTS_PEM]:
|
|
fetch_args['verify'] = GC.Values[GC.CACERTS_PEM]
|
|
self.fetch_token(**fetch_args)
|
|
break
|
|
except Exception as e:
|
|
if not userInput:
|
|
systemErrorExit(_getMain().INVALID_TOKEN_RC, str(e))
|
|
stderrErrorMsg(str(e))
|
|
_waitForUserInput(d)
|
|
print(Msg.AUTHENTICATION_FLOW_COMPLETE)
|
|
return self.credentials
|
|
|
|
class Credentials(google.oauth2.credentials.Credentials):
|
|
"""Google OAuth2.0 Credentials with GAM-specific properties and methods."""
|
|
|
|
def __init__(self,
|
|
token,
|
|
refresh_token=None,
|
|
id_token=None,
|
|
token_uri=None,
|
|
client_id=None,
|
|
client_secret=None,
|
|
scopes=None,
|
|
quota_project_id=None,
|
|
expiry=None,
|
|
id_token_data=None,
|
|
filename=None):
|
|
"""A thread-safe OAuth2.0 credentials object.
|
|
|
|
Credentials adds additional utility properties and methods to a
|
|
standard OAuth2.0 credentials object. When used to store credentials on
|
|
disk, it implements a file lock to avoid collision during writes.
|
|
|
|
Args:
|
|
token: Optional String, The OAuth 2.0 access token. Can be None if refresh
|
|
information is provided.
|
|
refresh_token: String, The OAuth 2.0 refresh token. If specified,
|
|
credentials can be refreshed.
|
|
id_token: String, The Open _getMain().ID Connect _getMain().ID Token.
|
|
token_uri: String, The OAuth 2.0 authorization server's token endpoint
|
|
URI. Must be specified for refresh, can be left as None if the token can
|
|
not be refreshed.
|
|
client_id: String, The OAuth 2.0 client _getMain().ID. Must be specified for refresh,
|
|
can be left as None if the token can not be refreshed.
|
|
client_secret: String, The OAuth 2.0 client secret. Must be specified for
|
|
refresh, can be left as None if the token can not be refreshed.
|
|
scopes: Sequence[str], The scopes used to obtain authorization.
|
|
This parameter is used by :meth:`has_scopes`. OAuth 2.0 credentials can
|
|
not request additional scopes after authorization. The scopes must be
|
|
derivable from the refresh token if refresh information is provided
|
|
(e.g. The refresh token scopes are a superset of this or contain a
|
|
wild card scope like
|
|
'https://www.googleapis.com/auth/any-api').
|
|
quota_project_id: String, The project _getMain().ID used for quota and billing. This
|
|
project may be different from the project used to create the
|
|
credentials.
|
|
expiry: datetime.datetime, The time at which the provided token will
|
|
expire.
|
|
id_token_data: Oauth2.0 _getMain().ID Token data which was previously fetched for
|
|
this access token against the google.oauth2.id_token library.
|
|
filename: String, Path to a file that will be used to store the
|
|
credentials. If provided, a lock file of the same name and a ".lock"
|
|
extension will be created for concurrency controls. Note: New
|
|
credentials are not saved to disk until write() or refresh() are
|
|
called.
|
|
|
|
Raises:
|
|
TypeError: If id_token_data is not the required dict type.
|
|
"""
|
|
super().__init__(token=token,
|
|
refresh_token=refresh_token,
|
|
id_token=id_token,
|
|
token_uri=token_uri,
|
|
client_id=client_id,
|
|
client_secret=client_secret,
|
|
scopes=scopes,
|
|
quota_project_id=quota_project_id)
|
|
|
|
# Load data not restored by the super class
|
|
self.expiry = expiry
|
|
if id_token_data and not isinstance(id_token_data, dict):
|
|
raise TypeError(f'Expected type id_token_data dict but received {type(id_token_data)}')
|
|
self._id_token_data = id_token_data.copy() if id_token_data else None
|
|
|
|
# If a filename is provided, use a lock file to control concurrent access
|
|
# to the resource. If no filename is provided, use a thread lock that has
|
|
# the same interface as FileLock in order to simplify the implementation.
|
|
if filename:
|
|
# Convert relative paths into absolute
|
|
self._filename = os.path.abspath(filename)
|
|
else:
|
|
self._filename = None
|
|
|
|
# Use a property to prevent external mutation of the filename.
|
|
@property
|
|
def filename(self):
|
|
return self._filename
|
|
|
|
@classmethod
|
|
def from_authorized_user_info_gam(cls, info, filename=None):
|
|
"""Generates Credentials from _getMain().JSON containing authorized user info.
|
|
|
|
Args:
|
|
info: Dict, authorized user info in Google format.
|
|
filename: String, the filename used to store these credentials on disk. If
|
|
no filename is provided, the credentials will not be saved to disk.
|
|
|
|
Raises:
|
|
ValueError: If missing fields are detected in the info.
|
|
"""
|
|
# We need all of these keys
|
|
keys_needed = {'client_id', 'client_secret'}
|
|
# We need 1 or more of these keys
|
|
keys_need_one_of = {'refresh_token', 'auth_token', 'token'}
|
|
missing = keys_needed.difference(info.keys())
|
|
has_one_of = set(info) & keys_need_one_of
|
|
if missing or not has_one_of:
|
|
raise ValueError(
|
|
'Authorized user info was not in the expected format, missing '
|
|
f'fields {", ".join(missing)} and one of {", ".join(keys_need_one_of)}.')
|
|
|
|
expiry = info.get('token_expiry')
|
|
if expiry:
|
|
# Convert the raw expiry to datetime
|
|
expiry = arrow.Arrow.strptime(expiry, YYYYMMDDTHHMMSSZ_FORMAT, tzinfo='UTC').naive
|
|
id_token_data = info.get('decoded_id_token')
|
|
|
|
# Provide backwards compatibility with field names when loading from _getMain().JSON.
|
|
# Some field names may be different, depending on when/how the credentials
|
|
# were pickled.
|
|
return cls(token=info.get('token', info.get('auth_token', '')),
|
|
refresh_token=info.get('refresh_token', ''),
|
|
id_token=info.get('id_token_jwt', info.get('id_token')),
|
|
token_uri=info.get('token_uri'),
|
|
client_id=info['client_id'],
|
|
client_secret=info['client_secret'],
|
|
scopes=info.get('scopes'),
|
|
quota_project_id=info.get('quota_project_id'),
|
|
expiry=expiry,
|
|
id_token_data=id_token_data,
|
|
filename=filename)
|
|
|
|
@classmethod
|
|
def from_google_oauth2_credentials(cls, credentials, filename=None):
|
|
"""Generates Credentials from a google.oauth2.Credentials object."""
|
|
info = json.loads(credentials.to_json())
|
|
# Add properties which are not exported with the native to_json() output.
|
|
info['id_token'] = credentials.id_token
|
|
if credentials.expiry:
|
|
info['token_expiry'] = credentials.expiry.strftime(YYYYMMDDTHHMMSSZ_FORMAT)
|
|
info['quota_project_id'] = credentials.quota_project_id
|
|
|
|
return cls.from_authorized_user_info_gam(info, filename=filename)
|
|
|
|
@classmethod
|
|
def from_client_secrets(cls,
|
|
client_id,
|
|
client_secret,
|
|
scopes,
|
|
access_type='offline',
|
|
login_hint=None,
|
|
filename=None,
|
|
open_browser=True):
|
|
"""Runs an OAuth Flow from client secrets to generate credentials.
|
|
|
|
Args:
|
|
client_id: String, The OAuth2.0 Client _getMain().ID.
|
|
client_secret: String, The OAuth2.0 Client Secret.
|
|
scopes: Sequence[str], A list of scopes to include in the credentials.
|
|
access_type: String, 'offline' or 'online'. Indicates whether your
|
|
application can refresh access tokens when the user is not present at
|
|
the browser. Valid parameter values are online, which is the default
|
|
value, and offline. Set the value to offline if your application needs
|
|
to refresh access tokens when the user is not present at the browser.
|
|
This is the method of refreshing access tokens described later in this
|
|
document. This value instructs the Google authorization server to return
|
|
a refresh token and an access token the first time that your application
|
|
exchanges an authorization code for tokens.
|
|
login_hint: String, The email address that will be displayed on the Google
|
|
login page as a hint for the user to login to the correct account.
|
|
filename: String, the path to a file to use to save the credentials.
|
|
open_browser: Boolean: whether or not _getMain().GAM should try to open the browser
|
|
automatically.
|
|
|
|
Returns:
|
|
Credentials
|
|
"""
|
|
client_config = {
|
|
'installed': {
|
|
'client_id': client_id,
|
|
'client_secret': client_secret,
|
|
'redirect_uris': ['http://localhost'],
|
|
'auth_uri': API.GOOGLE_OAUTH2_ENDPOINT,
|
|
'token_uri': API.GOOGLE_OAUTH2_TOKEN_ENDPOINT,
|
|
}
|
|
}
|
|
|
|
flow = _GamOauthFlow.from_client_config(client_config,
|
|
scopes,
|
|
autogenerate_code_verifier=True)
|
|
flow_kwargs = {'access_type': access_type,
|
|
'open_browser': open_browser}
|
|
if login_hint:
|
|
flow_kwargs['login_hint'] = login_hint
|
|
flow.run_dual(**flow_kwargs)
|
|
return cls.from_google_oauth2_credentials(flow.credentials, filename=filename)
|
|
|
|
def to_json(self, strip=None):
|
|
"""Creates a _getMain().JSON representation of a Credentials.
|
|
|
|
Args:
|
|
strip: Sequence[str], Optional list of members to exclude from the
|
|
generated _getMain().JSON.
|
|
|
|
Returns:
|
|
str: A _getMain().JSON representation of this instance, suitable to pass to
|
|
from_json().
|
|
"""
|
|
expiry = self.expiry.strftime(YYYYMMDDTHHMMSSZ_FORMAT) if self.expiry else None
|
|
prep = {
|
|
'token': self.token,
|
|
'refresh_token': self.refresh_token,
|
|
'token_uri': self.token_uri,
|
|
'client_id': self.client_id,
|
|
'client_secret': self.client_secret,
|
|
'id_token': self.id_token,
|
|
# Google auth doesn't currently give us scopes back on refresh.
|
|
# 'scopes': sorted(self.scopes),
|
|
'token_expiry': expiry,
|
|
'decoded_id_token': self._id_token_data,
|
|
}
|
|
|
|
# Remove empty entries
|
|
prep = {k: v for k, v in prep.items() if v is not None}
|
|
|
|
# Remove entries that explicitly need to be removed
|
|
if strip is not None:
|
|
prep = {k: v for k, v in prep.items() if k not in strip}
|
|
|
|
return json.dumps(prep, indent=2, sort_keys=True)
|
|
|
|
def doOAuthRequest(currentScopes, login_hint, verifyScopes=False):
|
|
client_id, client_secret = getOAuthClientIDAndSecret()
|
|
scopesList = API.getClientScopesList(GC.Values[GC.COMMANDDATA_CLIENTACCESS], GC.Values[GC.TODRIVE_CLIENTACCESS])
|
|
if not currentScopes or verifyScopes:
|
|
selectedScopes = getScopesFromUser(scopesList, True, currentScopes)
|
|
if selectedScopes is None:
|
|
return False
|
|
scopes = set(API.REQUIRED_SCOPES)
|
|
i = 0
|
|
for scope in scopesList:
|
|
if selectedScopes[i] == '*':
|
|
if scope['scope']:
|
|
if not isinstance(scope['scope'], list):
|
|
scopes.add(scope['scope'])
|
|
else:
|
|
scopes.update(scope['scope'])
|
|
elif selectedScopes[i] == 'R':
|
|
scopes.add(f'{scope["scope"]}.readonly')
|
|
elif selectedScopes[i] == 'A':
|
|
scopes.add(f'{scope["scope"]}.action')
|
|
i += 1
|
|
else:
|
|
scopes = set(currentScopes+API.REQUIRED_SCOPES)
|
|
if API.STORAGE_READWRITE_SCOPE in scopes:
|
|
scopes.discard(API.STORAGE_READONLY_SCOPE)
|
|
login_hint = _getValidateLoginHint(login_hint)
|
|
# Needs to be set so oauthlib doesn't puke when Google changes our scopes
|
|
os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = 'true'
|
|
credentials = Credentials.from_client_secrets(
|
|
client_id,
|
|
client_secret,
|
|
scopes=list(scopes),
|
|
access_type='offline',
|
|
login_hint=login_hint,
|
|
open_browser=not GC.Values[GC.NO_BROWSER])
|
|
lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK], mode=GC.Values[GC.OAUTH2_TXT_LOCK_MODE])
|
|
with lock:
|
|
writeClientCredentials(credentials, GC.Values[GC.OAUTH2_TXT])
|
|
entityActionPerformed([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]])
|
|
return True
|
|
|
|
# gam oauth|oauth2 create|request [<EmailAddress>]
|
|
# gam oauth|oauth2 create|request [admin <EmailAddress>] [scope|scopes <APIScopeURLList>]
|
|
def doOAuthCreate():
|
|
if not Cmd.PeekArgumentPresent(['admin', 'scope', 'scopes']):
|
|
login_hint = getEmailAddress(noUid=True, optional=True)
|
|
scopes = None
|
|
checkForExtraneousArguments()
|
|
else:
|
|
login_hint = None
|
|
scopes = []
|
|
scopesList = API.getClientScopesList(GC.Values[GC.COMMANDDATA_CLIENTACCESS], GC.Values[GC.TODRIVE_CLIENTACCESS])
|
|
while Cmd.ArgumentsRemaining():
|
|
myarg = getArgument()
|
|
if myarg == 'admin':
|
|
login_hint = getEmailAddress(noUid=True)
|
|
elif myarg in {'scope', 'scopes'}:
|
|
for uscope in getString(Cmd.OB_API_SCOPE_URL_LIST).lower().replace(',', ' ').split():
|
|
if uscope in {'openid', 'email', API.USERINFO_EMAIL_SCOPE, 'profile', API.USERINFO_PROFILE_SCOPE}:
|
|
continue
|
|
for scope in scopesList:
|
|
subScopes = scope.get('subscopes', [])
|
|
if ((uscope == scope['scope']) or
|
|
(uscope.endswith('.action') and 'actiononly' in subScopes) or
|
|
(uscope.endswith('.readonly') and 'readonly' in subScopes)):
|
|
scopes.append(uscope)
|
|
break
|
|
else:
|
|
invalidChoiceExit(uscope,
|
|
API.getClientScopesURLs(GC.Values[GC.COMMANDDATA_CLIENTACCESS], GC.Values[GC.TODRIVE_CLIENTACCESS]),
|
|
True)
|
|
else:
|
|
unknownArgumentExit()
|
|
if len(scopes) == 0:
|
|
scopes = None
|
|
doOAuthRequest(scopes, login_hint)
|
|
|
|
def exitIfNoOauth2Txt():
|
|
if not os.path.isfile(GC.Values[GC.OAUTH2_TXT]):
|
|
entityActionNotPerformedWarning([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], Msg.DOES_NOT_EXIST)
|
|
sys.exit(GM.Globals[GM.SYSEXITRC])
|
|
|
|
# gam oauth|oauth2 delete|revoke
|
|
def doOAuthDelete():
|
|
checkForExtraneousArguments()
|
|
exitIfNoOauth2Txt()
|
|
lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK], mode=GC.Values[GC.OAUTH2_TXT_LOCK_MODE], timeout=10)
|
|
with lock:
|
|
_, credentials = getOauth2TxtCredentials(noScopes=True)
|
|
if not credentials:
|
|
return
|
|
entityType = Ent.OAUTH2_TXT_FILE
|
|
entityName = GC.Values[GC.OAUTH2_TXT]
|
|
sys.stdout.write(f'{Ent.Singular(entityType)}: {entityName}, will be Deleted in 3...')
|
|
sys.stdout.flush()
|
|
time.sleep(1)
|
|
sys.stdout.write('2...')
|
|
sys.stdout.flush()
|
|
time.sleep(1)
|
|
sys.stdout.write('1...')
|
|
sys.stdout.flush()
|
|
time.sleep(1)
|
|
sys.stdout.write('boom!\n')
|
|
sys.stdout.flush()
|
|
httpObj = getHttpObj()
|
|
params = {'token': credentials.refresh_token}
|
|
revoke_uri = f'https://accounts.google.com/o/oauth2/revoke?{urlencode(params)}'
|
|
httpObj.request(revoke_uri, 'GET')
|
|
deleteFile(GC.Values[GC.OAUTH2_TXT], continueOnError=True)
|
|
entityActionPerformed([entityType, entityName])
|
|
|
|
# gam oauth|oauth2 info|verify [showsecret] [accesstoken <AccessToken> idtoken <IDToken>] [showdetails]
|
|
def doOAuthInfo():
|
|
credentials = access_token = id_token = None
|
|
showDetails = showSecret = False
|
|
while Cmd.ArgumentsRemaining():
|
|
myarg = getArgument()
|
|
if myarg == 'accesstoken':
|
|
access_token = getString(Cmd.OB_ACCESS_TOKEN)
|
|
elif myarg == 'idtoken':
|
|
id_token = getString(Cmd.OB_ID_TOKEN)
|
|
elif myarg == 'showdetails':
|
|
showDetails = True
|
|
elif myarg == 'showsecret':
|
|
showSecret = True
|
|
else:
|
|
unknownArgumentExit()
|
|
exitIfNoOauth2Txt()
|
|
if not access_token and not id_token:
|
|
credentials = getClientCredentials(noScopes=True)
|
|
access_token = credentials.token
|
|
printEntity([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]])
|
|
oa2 = buildGAPIObject(API.OAUTH2)
|
|
try:
|
|
token_info = callGAPI(oa2, 'tokeninfo',
|
|
throwReasons=[GAPI.INVALID],
|
|
access_token=access_token, id_token=id_token)
|
|
except GAPI.invalid as e:
|
|
entityActionFailedExit([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], str(e))
|
|
if 'issued_to' in token_info:
|
|
printKeyValueList(['Client ID', token_info['issued_to']])
|
|
if credentials is not None and showSecret:
|
|
printKeyValueList(['Secret', credentials.client_secret])
|
|
if 'scope' in token_info:
|
|
scopes = token_info['scope'].split(' ')
|
|
printKeyValueList(['Scopes', len(scopes)])
|
|
Ind.Increment()
|
|
for scope in sorted(scopes):
|
|
printKeyValueList([scope])
|
|
Ind.Decrement()
|
|
if 'email' in token_info:
|
|
printKeyValueList(['Google Workspace Admin', f'{token_info["email"]}'])
|
|
if 'expires_in' in token_info:
|
|
printKeyValueList(['Expires', ISOformatTimeStamp(arrow.now(GC.Values[GC.TIMEZONE]).shift(seconds=token_info['expires_in']))])
|
|
if showDetails:
|
|
for k, v in sorted(token_info.items()):
|
|
if k not in ['email', 'expires_in', 'issued_to', 'scope']:
|
|
printKeyValueList([k, v])
|
|
printBlankLine()
|
|
|
|
# gam oauth|oauth2 update [<EmailAddress>]
|
|
# gam oauth|oauth2 update [admin <EmailAddress>]
|
|
def doOAuthUpdate():
|
|
if Cmd.PeekArgumentPresent(['admin']):
|
|
Cmd.Advance()
|
|
login_hint = getEmailAddress(noUid=True)
|
|
else:
|
|
login_hint = getEmailAddress(noUid=True, optional=True)
|
|
checkForExtraneousArguments()
|
|
exitIfNoOauth2Txt()
|
|
lock = FileLock(GM.Globals[GM.OAUTH2_TXT_LOCK], mode=GC.Values[GC.OAUTH2_TXT_LOCK_MODE])
|
|
with lock:
|
|
jsonData = readFile(GC.Values[GC.OAUTH2_TXT], continueOnError=True, displayError=False)
|
|
if not jsonData:
|
|
invalidOauth2TxtExit(Msg.NO_DATA)
|
|
try:
|
|
jsonDict = json.loads(jsonData)
|
|
if 'client_id' in jsonDict:
|
|
if 'scopes' in jsonDict:
|
|
currentScopes = jsonDict['scopes']
|
|
else:
|
|
currentScopes = API.getClientScopesURLs(GC.Values[GC.COMMANDDATA_CLIENTACCESS], GC.Values[GC.TODRIVE_CLIENTACCESS])
|
|
else:
|
|
currentScopes = []
|
|
except (AttributeError, IndexError, KeyError, SyntaxError, TypeError, ValueError) as e:
|
|
invalidOauth2TxtExit(str(e))
|
|
if not doOAuthRequest(currentScopes, login_hint, verifyScopes=True):
|
|
entityActionNotPerformedWarning([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], Msg.USER_CANCELLED)
|
|
sys.exit(GM.Globals[GM.SYSEXITRC])
|
|
|
|
# gam oauth|oauth2 refresh
|
|
def doOAuthRefresh():
|
|
checkForExtraneousArguments()
|
|
exitIfNoOauth2Txt()
|
|
getClientCredentials(forceRefresh=True, forceWrite=True, filename=GC.Values[GC.OAUTH2_TXT], refreshOnly=True)
|
|
entityActionPerformed([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]])
|
|
|
|
# gam oauth|oauth2 export [<FileName>]
|
|
def doOAuthExport():
|
|
if Cmd.ArgumentsRemaining():
|
|
filename = getString(Cmd.OB_FILE_NAME)
|
|
checkForExtraneousArguments()
|
|
else:
|
|
filename = GC.Values[GC.OAUTH2_TXT]
|
|
getClientCredentials(forceRefresh=True, forceWrite=True, filename=filename, refreshOnly=True)
|
|
if filename != '-':
|
|
entityModifierNewValueActionPerformed([Ent.OAUTH2_TXT_FILE, GC.Values[GC.OAUTH2_TXT]], Act.MODIFIER_TO, filename)
|