oauth.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. #!/usr/bin/env python
  2. """Earth Engine OAuth2 helper functions for generating client tokens.
  3. Typical use-case consists of:
  4. 1. Calling 'get_authorization_url'
  5. 2. Using a browser to access the output URL and copy the generated OAuth2 code
  6. 3. Calling 'request_token' to request a token using that code and the OAuth API
  7. 4. Calling 'write_private_json' to save the token at the path given by
  8. 'get_credentials_path'
  9. """
  10. import base64
  11. import errno
  12. import hashlib
  13. import http.server
  14. import json
  15. import os
  16. import subprocess
  17. import sys
  18. import urllib.error
  19. import urllib.parse
  20. import urllib.request
  21. import webbrowser
  22. from google.auth import _cloud_sdk
  23. import six
  24. # Optional imports used for specific shells.
  25. # pylint: disable=g-import-not-at-top
  26. try:
  27. import IPython
  28. except ImportError:
  29. pass
  30. CLIENT_ID = ('517222506229-vsmmajv00ul0bs7p89v5m89qs8eb9359.'
  31. 'apps.googleusercontent.com')
  32. CLIENT_SECRET = 'RUP0RZ6e0pPhDzsqIJ7KlNd1'
  33. SCOPES = [
  34. 'https://www.googleapis.com/auth/earthengine',
  35. 'https://www.googleapis.com/auth/devstorage.full_control'
  36. ]
  37. REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob' # Prompts user to copy-paste code
  38. TOKEN_URI = 'https://accounts.google.com/o/oauth2/token'
  39. AUTH_URI = 'https://accounts.google.com/o/oauth2/auth'
  40. AUTH_PAGE_URL = 'https://code.earthengine.google.com/client-auth'
  41. MODE_URL = AUTH_PAGE_URL + '/mode'
  42. FETCH_URL = AUTH_PAGE_URL + '/fetch'
  43. AUTH_URL_TEMPLATE = AUTH_PAGE_URL + '?scopes={scopes}' + (
  44. '&request_id={request_id}&tc={token_challenge}&cc={client_challenge}')
  45. # Command to execute in gcloud mode
  46. GCLOUD_COMMAND = 'gcloud auth application-default login'
  47. DEFAULT_LOCAL_PORT = 8085
  48. WAITING_CODA = 'Waiting for successful authorization from web browser ...'
  49. PASTE_CODA = ('The authorization workflow will generate a code, which you'
  50. ' should paste in the box below.')
  51. # Command-line browsers cannot handle the auth pages.
  52. TEXT_BROWSERS = ['elinks', 'links', 'lynx', 'w3m', 'www-browser']
  53. def get_credentials_path():
  54. cred_path = os.path.expanduser(
  55. '~/.config/earthengine/credentials',
  56. )
  57. return cred_path
  58. def get_credentials_arguments():
  59. with open(get_credentials_path()) as creds:
  60. stored = json.load(creds)
  61. args = {}
  62. args['token_uri'] = TOKEN_URI # Not overridable in file
  63. args['refresh_token'] = stored['refresh_token'] # Must be present
  64. args['client_id'] = stored.get('client_id', CLIENT_ID)
  65. args['client_secret'] = stored.get('client_secret', CLIENT_SECRET)
  66. args['scopes'] = stored.get('scopes', SCOPES)
  67. return args
  68. def get_authorization_url(code_challenge, scopes=None, redirect_uri=None):
  69. """Returns a URL to generate an auth code."""
  70. return 'https://accounts.google.com/o/oauth2/auth?' + urllib.parse.urlencode({
  71. 'client_id': CLIENT_ID,
  72. 'scope': ' '.join(scopes or SCOPES),
  73. 'redirect_uri': redirect_uri or REDIRECT_URI,
  74. 'response_type': 'code',
  75. 'code_challenge': code_challenge,
  76. 'code_challenge_method': 'S256',
  77. })
  78. def request_token(auth_code,
  79. code_verifier,
  80. client_id=None,
  81. client_secret=None,
  82. redirect_uri=None):
  83. """Uses authorization code to request tokens."""
  84. request_args = {
  85. 'code': auth_code,
  86. 'client_id': client_id or CLIENT_ID,
  87. 'client_secret': client_secret or CLIENT_SECRET,
  88. 'redirect_uri': redirect_uri or REDIRECT_URI,
  89. 'grant_type': 'authorization_code',
  90. 'code_verifier': code_verifier,
  91. }
  92. refresh_token = None
  93. try:
  94. response = urllib.request.urlopen(
  95. TOKEN_URI,
  96. urllib.parse.urlencode(request_args).encode()).read().decode()
  97. refresh_token = json.loads(response)['refresh_token']
  98. except urllib.error.HTTPError as e:
  99. raise Exception('Problem requesting tokens. Please try again. %s %s' %
  100. (e, e.read()))
  101. return refresh_token
  102. def write_private_json(json_path, info_dict):
  103. """Attempts to write the passed token to the given user directory."""
  104. dirname = os.path.dirname(json_path)
  105. try:
  106. os.makedirs(dirname)
  107. except OSError as e:
  108. if e.errno != errno.EEXIST:
  109. raise Exception('Error creating directory %s: %s' % (dirname, e))
  110. file_content = json.dumps(info_dict)
  111. if os.path.exists(json_path):
  112. # Remove file because os.open will not change permissions of existing files
  113. os.remove(json_path)
  114. with os.fdopen(
  115. os.open(json_path, os.O_WRONLY | os.O_CREAT, 0o600), 'w') as f:
  116. f.write(file_content)
  117. def _in_colab_shell():
  118. """Tests if the code is being executed within Google Colab."""
  119. try:
  120. import google.colab # pylint: disable=unused-import
  121. return True
  122. except ImportError:
  123. return False
  124. def _in_jupyter_shell():
  125. """Tests if the code is being executed within Jupyter."""
  126. try:
  127. import ipykernel.zmqshell
  128. return isinstance(IPython.get_ipython(),
  129. ipykernel.zmqshell.ZMQInteractiveShell)
  130. except ImportError:
  131. return False
  132. except NameError:
  133. return False
  134. def _obtain_and_write_token(auth_code=None,
  135. code_verifier=None,
  136. scopes=None,
  137. redirect_uri=None):
  138. """Obtains and writes credentials token based on an authorization code."""
  139. fetch_data = {}
  140. if code_verifier and ':' in code_verifier:
  141. request_id, code_verifier, client_verifier = code_verifier.split(':')
  142. fetch_data = dict(request_id=request_id, client_verifier=client_verifier)
  143. client_info = {}
  144. if redirect_uri:
  145. client_info['redirect_uri'] = redirect_uri
  146. if not auth_code:
  147. auth_code = input('Enter verification code: ')
  148. assert isinstance(auth_code, str)
  149. scopes = scopes or SCOPES
  150. if fetch_data:
  151. data = json.dumps(fetch_data).encode()
  152. headers = {'Content-Type': 'application/json; charset=UTF-8'}
  153. fetch_client = urllib.request.Request(FETCH_URL, data=data, headers=headers)
  154. fetched_info = json.loads(
  155. urllib.request.urlopen(fetch_client).read().decode())
  156. client_info = {k: fetched_info[k] for k in ['client_id', 'client_secret']}
  157. scopes = fetched_info.get('scopes') or scopes
  158. token = request_token(auth_code.strip(), code_verifier, **client_info)
  159. client_info['refresh_token'] = token
  160. client_info['scopes'] = scopes
  161. write_private_json(get_credentials_path(), client_info)
  162. print('\nSuccessfully saved authorization token.')
  163. def _display_auth_instructions_for_noninteractive(auth_url, code_verifier):
  164. """Displays instructions for authenticating without blocking for user input."""
  165. print('Paste the following address into a web browser:\n'
  166. '\n'
  167. ' {0}\n'
  168. '\n'
  169. 'On the web page, please authorize access to your '
  170. 'Earth Engine account and copy the authentication code. '
  171. 'Next authenticate with the following command:\n'
  172. '\n'
  173. ' earthengine authenticate --code-verifier={1} '
  174. '--authorization-code=PLACE_AUTH_CODE_HERE\n'.format(
  175. auth_url, six.ensure_str(code_verifier)))
  176. def _display_auth_instructions_with_print(auth_url, coda=None):
  177. """Displays instructions for authenticating using a print statement."""
  178. print('To authorize access needed by Earth Engine, open the following '
  179. 'URL in a web browser and follow the instructions. If the web '
  180. 'browser does not start automatically, please manually browse the '
  181. 'URL below.\n'
  182. '\n'
  183. ' {0}\n'
  184. '\n{1}'.format(auth_url, coda or PASTE_CODA))
  185. def _display_auth_instructions_with_html(auth_url, coda=None):
  186. """Displays instructions for authenticating using HTML code."""
  187. try:
  188. IPython.display.display(IPython.display.HTML(
  189. """<p>To authorize access needed by Earth Engine, open the following
  190. URL in a web browser and follow the instructions:</p>
  191. <p><a href={0}>{0}</a></p>
  192. <p>{1}</p>
  193. """.format(auth_url, coda or PASTE_CODA)))
  194. except NameError:
  195. print('The IPython module must be installed to use HTML.')
  196. raise
  197. def _base64param(byte_string):
  198. """Encodes bytes for use as a URL parameter."""
  199. return base64.urlsafe_b64encode(byte_string).rstrip(b'=')
  200. def _nonce_table(*nonce_keys):
  201. """Makes random nonces, and adds PKCE challenges for each _verifier nonce."""
  202. table = {}
  203. for key in nonce_keys:
  204. table[key] = _base64param(os.urandom(32))
  205. if key.endswith('_verifier'):
  206. # Generate a challenge that the server will use to ensure that requests
  207. # only work with our verifiers. https://tools.ietf.org/html/rfc7636
  208. pkce_challenge = _base64param(hashlib.sha256(table[key]).digest())
  209. table[key.replace('_verifier', '_challenge')] = pkce_challenge
  210. return {k: v.decode() for k, v in table.items()}
  211. def _open_new_browser(url):
  212. """Opens a web browser if possible."""
  213. try:
  214. browser = webbrowser.get()
  215. if hasattr(browser, 'name') and browser.name in TEXT_BROWSERS:
  216. return
  217. except webbrowser.Error:
  218. return
  219. webbrowser.open_new(url)
  220. def _in_notebook():
  221. return _in_colab_shell() or _in_jupyter_shell()
  222. def _load_app_default_credentials(run_gcloud=True, scopes=None, quiet=None):
  223. """Initializes credentials from ADC, optionally running gcloud to get them."""
  224. adc_path = _cloud_sdk.get_application_default_credentials_path()
  225. if run_gcloud:
  226. client_id_json = dict(
  227. client_id=CLIENT_ID,
  228. client_secret=CLIENT_SECRET,
  229. redirect_uri=REDIRECT_URI,
  230. auth_uri=AUTH_URI,
  231. token_uri=TOKEN_URI)
  232. client_id_file = get_credentials_path() + '-client-id.json'
  233. write_private_json(client_id_file, dict(installed=client_id_json))
  234. command = GCLOUD_COMMAND.split()
  235. command += ['--scopes=%s' % (','.join(scopes or SCOPES))]
  236. command += ['--client-id-file=%s' % client_id_file]
  237. command += ['--no-browser'] if quiet else []
  238. print('Fetching credentials using gcloud')
  239. more_info = '\nMore information: ' + (
  240. 'https://developers.google.com/earth-engine/guides/python_install\n')
  241. try:
  242. subprocess.run(command, check=True)
  243. except FileNotFoundError as e:
  244. tip = 'Please ensure that gcloud is installed.' + more_info
  245. raise Exception('gcloud command not found. ' + tip) from e
  246. except subprocess.CalledProcessError as e:
  247. tip = ('Please check for any errors above.\n*Possible fixes:'
  248. ' If you loaded a page with a "redirect_uri_mismatch" error,'
  249. ' run earthengine authenticate with the --quiet flag;'
  250. ' if the error page says "invalid_request", be sure to run the'
  251. ' entire gcloud auth command that is shown.' + more_info)
  252. raise Exception('gcloud failed. ' + tip) from e
  253. finally:
  254. os.remove(client_id_file)
  255. else:
  256. # Only consult the environment variable in appdefault mode, because gcloud
  257. # always writes to the default location.
  258. adc_path = os.getenv('GOOGLE_APPLICATION_CREDENTIALS', adc_path)
  259. with open(adc_path) as adc_json:
  260. adc = json.load(adc_json)
  261. adc = {k: adc[k] for k in ['client_id', 'client_secret', 'refresh_token']}
  262. write_private_json(get_credentials_path(), adc)
  263. print('\nSuccessfully saved authorization token.')
  264. def _start_server(port):
  265. """Starts and returns a web server that handles the OAuth callback."""
  266. class Handler(http.server.BaseHTTPRequestHandler):
  267. """Handles the OAuth callback and reports a success page."""
  268. code = None
  269. def do_GET(self): # pylint: disable=invalid-name
  270. Handler.code = urllib.parse.parse_qs(
  271. urllib.parse.urlparse(self.path).query)['code'][0]
  272. self.send_response(200)
  273. self.send_header('Content-type', 'text/plain; charset=utf-8')
  274. self.end_headers()
  275. self.wfile.write(
  276. b'\n\nGoogle Earth Engine authorization successful!\n\n\n'
  277. b'Credentials have been retrieved. Please close this window.\n\n'
  278. b' \xf0\x9f\x8c\x8d \xe2\x9a\x99\xef\xb8\x8f \xf0\x9f\x8c\x8f'
  279. b' \xe2\x9a\x99\xef\xb8\x8f \xf0\x9f\x8c\x8e ') # Earth emoji
  280. def log_message(self, *_):
  281. pass # Suppresses the logging of request info to stderr.
  282. class Server(object):
  283. def __init__(self):
  284. self.server = http.server.HTTPServer(('localhost', port), Handler)
  285. self.url = 'http://localhost:%s' % self.server.server_address[1]
  286. def fetch_code(self):
  287. self.server.handle_request() # Blocks until a single request arrives.
  288. self.server.server_close()
  289. return Handler.code
  290. return Server()
  291. def authenticate(
  292. cli_authorization_code=None,
  293. quiet=False,
  294. cli_code_verifier=None,
  295. auth_mode=None,
  296. scopes=None):
  297. """Prompts the user to authorize access to Earth Engine via OAuth2.
  298. Args:
  299. cli_authorization_code: An optional authorization code. Supports CLI mode,
  300. where the code is passed as an argument to `earthengine authenticate`.
  301. quiet: If true, do not require interactive prompts.
  302. cli_code_verifier: PKCE verifier to prevent auth code stealing. Must be
  303. provided if cli_authorization_code is given.
  304. auth_mode: The authorization mode. One of:
  305. "notebook" - send user to notebook authenticator page. Intended for
  306. web users who do not run code locally. Credentials expire in 7 days.
  307. "gcloud" - use gcloud to obtain credentials. This runs a command line to
  308. set the appdefault file, which must run on your local machine.
  309. "appdefault" - read an existing $GOOGLE_APPLICATION_CREDENTIALS file
  310. without running gcloud.
  311. "localhost" - sends credentials to the Python environment on the same
  312. localhost as the browser. Does not work for remote shells. Default
  313. port is 8085; use localhost:N set port or localhost:0 to auto-select.
  314. None - a default mode is chosen based on your environment.
  315. scopes: List of scopes to use for authorization. Defaults to [
  316. 'https://www.googleapis.com/auth/earthengine',
  317. 'https://www.googleapis.com/auth/devstorage.full_control' ].
  318. Raises:
  319. Exception: on invalid arguments.
  320. """
  321. if cli_authorization_code:
  322. _obtain_and_write_token(cli_authorization_code, cli_code_verifier, scopes)
  323. return
  324. if not auth_mode:
  325. auth_mode = 'notebook' if _in_notebook() else 'gcloud'
  326. if auth_mode in ['appdefault', 'gcloud']:
  327. _load_app_default_credentials(auth_mode == 'gcloud', scopes, quiet)
  328. return
  329. flow = Flow(auth_mode, scopes)
  330. if flow.display_instructions(quiet):
  331. _open_new_browser(flow.auth_url)
  332. flow.save_code()
  333. class Flow(object):
  334. """Holds state for auth flows."""
  335. def __init__(self, auth_mode='notebook', scopes=None):
  336. """Initializes auth URL and PKCE verifier, for use in save_code().
  337. Args:
  338. auth_mode: Authorization mode, one of "notebook" or "localhost[:PORT]".
  339. scopes: Optional scope list override.
  340. Raises:
  341. Exception: on invalid arguments.
  342. """
  343. port = DEFAULT_LOCAL_PORT
  344. if auth_mode and auth_mode.startswith('localhost:'):
  345. auth_mode, port = auth_mode.split(':', 1)
  346. self.scopes = scopes or SCOPES
  347. self.server = None
  348. if auth_mode == 'localhost':
  349. pkce = _nonce_table('code_verifier')
  350. self.code_verifier = pkce['code_verifier']
  351. self.server = _start_server(int(port))
  352. self.auth_url = get_authorization_url(pkce['code_challenge'], self.scopes,
  353. self.server.url)
  354. elif auth_mode == 'notebook':
  355. nonces = ['request_id', 'token_verifier', 'client_verifier']
  356. request_info = _nonce_table(*nonces)
  357. self.auth_url = AUTH_URL_TEMPLATE.format(
  358. scopes=urllib.parse.quote(' '.join(self.scopes)), **request_info)
  359. self.code_verifier = ':'.join(request_info[k] for k in nonces)
  360. else:
  361. raise Exception('Unknown auth_mode "%s"' % auth_mode)
  362. def save_code(self, code=None):
  363. """Fetches auth code if not given, and saves the generated credentials."""
  364. redirect_uri = None
  365. if self.server and not code:
  366. redirect_uri = self.server.url
  367. code = self.server.fetch_code() # Waits for oauth callback
  368. _obtain_and_write_token(code, self.code_verifier, self.scopes, redirect_uri)
  369. def display_instructions(self, quiet=None):
  370. """Prints to stdout, and returns True if a browser should be opened."""
  371. if quiet:
  372. _display_auth_instructions_for_noninteractive(self.auth_url,
  373. self.code_verifier)
  374. return True
  375. coda = WAITING_CODA if self.server else None
  376. if _in_colab_shell():
  377. if sys.version_info[0] == 2: # Python 2
  378. _display_auth_instructions_for_noninteractive(self.auth_url,
  379. self.code_verifier)
  380. return False
  381. else: # Python 3
  382. _display_auth_instructions_with_print(self.auth_url, coda)
  383. elif _in_jupyter_shell():
  384. _display_auth_instructions_with_html(self.auth_url, coda)
  385. else:
  386. _display_auth_instructions_with_print(self.auth_url, coda)
  387. return True