Index: auth.py |
diff --git a/auth.py b/auth.py |
index 66137b5924c54ee1934a287600da9b6434ed1886..15fc15deb9033d08bf0370216902912fbe9ebad1 100644 |
--- a/auth.py |
+++ b/auth.py |
@@ -8,6 +8,7 @@ import BaseHTTPServer |
import collections |
import datetime |
import functools |
+import hashlib |
import json |
import logging |
import optparse |
@@ -62,6 +63,7 @@ AuthConfig = collections.namedtuple('AuthConfig', [ |
'save_cookies', # deprecated, will be removed |
'use_local_webserver', |
'webserver_port', |
+ 'refresh_token_json', |
]) |
@@ -72,6 +74,14 @@ AccessToken = collections.namedtuple('AccessToken', [ |
]) |
+# Refresh token passed via --auth-refresh-token-json. |
+RefreshToken = collections.namedtuple('RefreshToken', [ |
+ 'client_id', |
+ 'client_secret', |
+ 'refresh_token', |
+]) |
+ |
+ |
class AuthenticationError(Exception): |
"""Raised on errors related to authentication.""" |
@@ -91,7 +101,8 @@ def make_auth_config( |
use_oauth2=None, |
save_cookies=None, |
use_local_webserver=None, |
- webserver_port=None): |
+ webserver_port=None, |
+ refresh_token_json=None): |
"""Returns new instance of AuthConfig. |
If some config option is None, it will be set to a reasonable default value. |
@@ -103,7 +114,8 @@ def make_auth_config( |
default(use_oauth2, _should_use_oauth2()), |
default(save_cookies, True), |
default(use_local_webserver, not _is_headless()), |
- default(webserver_port, 8090)) |
+ default(webserver_port, 8090), |
+ default(refresh_token_json, '')) |
def add_auth_options(parser, default_config=None): |
@@ -148,6 +160,10 @@ def add_auth_options(parser, default_config=None): |
default=default_config.webserver_port, |
help='Port a local web server should listen on. Used only if ' |
'--auth-no-local-webserver is not set. [default: %default]') |
+ parser.auth_group.add_option( |
+ '--auth-refresh-token-json', |
+ default=default_config.refresh_token_json, |
+ help='Path to a JSON file with role account refresh token to use.') |
def extract_auth_config_from_options(options): |
@@ -159,7 +175,8 @@ def extract_auth_config_from_options(options): |
use_oauth2=options.use_oauth2, |
save_cookies=False if options.use_oauth2 else options.save_cookies, |
use_local_webserver=options.use_local_webserver, |
- webserver_port=options.auth_host_port) |
+ webserver_port=options.auth_host_port, |
+ refresh_token_json=options.auth_refresh_token_json) |
def auth_config_to_command_options(auth_config): |
@@ -181,6 +198,9 @@ def auth_config_to_command_options(auth_config): |
opts.append('--auth-no-local-webserver') |
if auth_config.webserver_port != defaults.webserver_port: |
opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) |
+ if auth_config.refresh_token_json != defaults.refresh_token_json: |
+ opts.extend([ |
+ '--auth-refresh-token-json', str(auth_config.refresh_token_json)]) |
return opts |
@@ -222,6 +242,9 @@ class Authenticator(object): |
self._config = config |
self._lock = threading.Lock() |
self._token_cache_key = token_cache_key |
+ self._external_token = None |
+ if config.refresh_token_json: |
+ self._external_token = _read_refresh_token_json(config.refresh_token_json) |
def login(self): |
"""Performs interactive login flow if necessary. |
@@ -229,24 +252,29 @@ class Authenticator(object): |
Raises: |
AuthenticationError on error or if interrupted. |
""" |
+ if self._external_token: |
+ raise AuthenticationError( |
+ 'Can\'t run login flow when using --auth-refresh-token-json.') |
return self.get_access_token( |
force_refresh=True, allow_user_interaction=True) |
def logout(self): |
"""Revokes the refresh token and deletes it from the cache. |
- Returns True if actually revoked a token. |
+ Returns True if had some credentials cached. |
""" |
- revoked = False |
with self._lock: |
self._access_token = None |
storage = self._get_storage() |
credentials = storage.get() |
- if credentials: |
- credentials.revoke(httplib2.Http()) |
- revoked = True |
+ had_creds = bool(credentials) |
+ if credentials and credentials.refresh_token and credentials.revoke_uri: |
+ try: |
+ credentials.revoke(httplib2.Http()) |
+ except client.TokenRevokeError as e: |
+ logging.warning('Failed to revoke refresh token: %s', e) |
storage.delete() |
- return revoked |
+ return had_creds |
def has_cached_credentials(self): |
"""Returns True if long term credentials (refresh token) are in cache. |
@@ -262,8 +290,7 @@ class Authenticator(object): |
it. |
""" |
with self._lock: |
- credentials = self._get_storage().get() |
- return credentials and not credentials.invalid |
+ return bool(self._get_cached_credentials()) |
def get_access_token(self, force_refresh=False, allow_user_interaction=False): |
"""Returns AccessToken, refreshing it if necessary. |
@@ -348,17 +375,57 @@ class Authenticator(object): |
def _get_storage(self): |
"""Returns oauth2client.Storage with cached tokens.""" |
+ # Do not mix cache keys for different externally provided tokens. |
+ if self._external_token: |
+ token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest() |
+ cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash) |
+ else: |
+ cache_key = self._token_cache_key |
return multistore_file.get_credential_storage_custom_string_key( |
- OAUTH_TOKENS_CACHE, self._token_cache_key) |
+ OAUTH_TOKENS_CACHE, cache_key) |
+ |
+ def _get_cached_credentials(self): |
+ """Returns oauth2client.Credentials loaded from storage.""" |
+ storage = self._get_storage() |
+ credentials = storage.get() |
+ |
+ # Is using --auth-refresh-token-json? |
+ if self._external_token: |
+ # Cached credentials are valid and match external token -> use them. It is |
+ # important to reuse credentials from the storage because they contain |
+ # cached access token. |
+ valid = ( |
+ credentials and not credentials.invalid and |
+ credentials.refresh_token == self._external_token.refresh_token and |
+ credentials.client_id == self._external_token.client_id and |
+ credentials.client_secret == self._external_token.client_secret) |
+ if valid: |
+ return credentials |
+ # Construct new credentials from externally provided refresh token, |
+ # associate them with cache storage (so that access_token will be placed |
+ # in the cache later too). |
+ credentials = client.OAuth2Credentials( |
+ access_token=None, |
+ client_id=self._external_token.client_id, |
+ client_secret=self._external_token.client_secret, |
+ refresh_token=self._external_token.refresh_token, |
+ token_expiry=None, |
+ token_uri='https://accounts.google.com/o/oauth2/token', |
+ user_agent=None, |
+ revoke_uri=None) |
+ credentials.set_store(storage) |
+ storage.put(credentials) |
+ return credentials |
+ |
+ # Not using external refresh token -> return whatever is cached. |
+ return credentials if (credentials and not credentials.invalid) else None |
def _load_access_token(self): |
"""Returns cached AccessToken if it is not expired yet.""" |
- credentials = self._get_storage().get() |
- if not credentials or credentials.invalid: |
- return None |
- if not credentials.access_token or credentials.access_token_expired: |
+ creds = self._get_cached_credentials() |
+ if not creds or not creds.access_token or creds.access_token_expired: |
return None |
- return AccessToken(str(credentials.access_token), credentials.token_expiry) |
+ return AccessToken(str(creds.access_token), creds.token_expiry) |
def _create_access_token(self, allow_user_interaction=False): |
"""Mints and caches a new access token, launching OAuth2 dance if necessary. |
@@ -379,11 +446,9 @@ class Authenticator(object): |
LoginRequiredError if user interaction is required, but |
allow_user_interaction is False. |
""" |
- storage = self._get_storage() |
- credentials = None |
+ credentials = self._get_cached_credentials() |
# 3-legged flow with (perhaps cached) refresh token. |
- credentials = storage.get() |
refreshed = False |
if credentials and not credentials.invalid: |
try: |
@@ -391,11 +456,15 @@ class Authenticator(object): |
refreshed = True |
except client.Error as err: |
logging.warning( |
- 'OAuth error during access token refresh: %s. ' |
+ 'OAuth error during access token refresh (%s). ' |
'Attempting a full authentication flow.', err) |
# Refresh token is missing or invalid, go through the full flow. |
if not refreshed: |
+ # Can't refresh externally provided token. |
+ if self._external_token: |
+ raise AuthenticationError( |
+ 'Token provided via --auth-refresh-token-json is no longer valid.') |
if not allow_user_interaction: |
raise LoginRequiredError(self._token_cache_key) |
credentials = _run_oauth_dance(self._config) |
@@ -403,6 +472,7 @@ class Authenticator(object): |
logging.info( |
'OAuth access_token refreshed. Expires in %s.', |
credentials.token_expiry - datetime.datetime.utcnow()) |
+ storage = self._get_storage() |
credentials.set_store(storage) |
storage.put(credentials) |
return AccessToken(str(credentials.access_token), credentials.token_expiry) |
@@ -424,6 +494,23 @@ def _is_headless(): |
return sys.platform == 'linux2' and not os.environ.get('DISPLAY') |
+def _read_refresh_token_json(path): |
+ """Returns RefreshToken by reading it from the JSON file.""" |
+ try: |
+ with open(path, 'r') as f: |
+ data = json.load(f) |
+ return RefreshToken( |
+ client_id=str(data.get('client_id', OAUTH_CLIENT_ID)), |
+ client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)), |
+ refresh_token=str(data['refresh_token'])) |
+ except (IOError, ValueError) as e: |
+ raise AuthenticationError( |
+ 'Failed to read refresh token from %s: %s' % (path, e)) |
+ except KeyError as e: |
+ raise AuthenticationError( |
+ 'Failed to read refresh token from %s: missing key %s' % (path, e)) |
+ |
+ |
def _needs_refresh(access_token): |
"""True if AccessToken should be refreshed.""" |
if access_token.expires_at is not None: |