diff --git a/docs/config.rst b/docs/config.rst index 707f7d6f..0f167c7e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -289,10 +289,38 @@ for reference) and the `BDAY `_ property is not synced when only partial date information is present (e.g. the year is missing). -At first run you will be asked to authorize application for Google account -access. +Authentication +++++++++++++++ -To use this storage type, you need to install some additional dependencies:: +There are two ways to handle OAuth authentication with Google: + +1. **Built-in OAuth flow** (``token_file`` + ``client_id`` + ``client_secret``): + Vdirsyncer manages the OAuth2 flow, including token storage and refresh. At + first run you will be asked to authorize the application via a browser. + +2. **External token command** (``token_command``): An external command is + executed to obtain a fresh access token on demand. Vdirsyncer passes the + token as a ``Bearer`` header and re-runs the command on 401 responses. + +With ``token_command``, vdirsyncer doesn't need OAuth credentials or +``aiohttp-oauthlib``. The external helper handles Google registration. The format is +``["strategy", "arg1", ...]``, same as :ref:`the .fetch config values +`. Supported strategies are ``command`` (exec directly) and +``shell`` (run via shell):: + + [storage my_google_cal] + type = "google_calendar" + token_command = ["command", "oama", "access", "my-google-account"] + +Or using a shell command:: + + token_command = ["shell", "oama access my-google-account"] + +Built-in OAuth setup +++++++++++++++++++++ + +To use the built-in OAuth flow, you need to install some additional +dependencies:: pip install vdirsyncer[google] @@ -344,15 +372,24 @@ itself or write anything to it. token_file = "..." client_id = "..." client_secret = "..." + #token_command = null #start_date = null #end_date = null #item_types = [] Please refer to :storage:`caldav` regarding the ``item_types`` and timerange parameters. - :param token_file: A filepath where access tokens are stored. + :param token_file: A filepath where access tokens are stored. Optional if + ``token_command`` is set. :param client_id/client_secret: OAuth credentials, obtained from the Google - API Manager. + API Manager. Optional if ``token_command`` + is set. + :param token_command: Fetch strategy for OAuth access token, same format as + ``.fetch`` params: ``["strategy", "arg1", ...]``. + Supported strategies: ``command``, ``shell``. + The command must print a valid bearer token to stdout. + When set, ``token_file`` and OAuth credentials are not + required. .. storage:: google_contacts @@ -365,14 +402,24 @@ itself or write anything to it. token_file = "..." client_id = "..." client_secret = "..." + #token_command = null - :param token_file: A filepath where access tokens are stored. + :param token_file: A filepath where access tokens are stored. Optional if + ``token_command`` is set. :param client_id/client_secret: OAuth credentials, obtained from the Google - API Manager. - -The current flow is not ideal, but Google has deprecated the previous APIs used -for this without providing a suitable replacement. See :gh:`975` for discussion -on the topic. + API Manager. Optional if ``token_command`` + is set. + :param token_command: Fetch strategy for OAuth access token, same format as + ``.fetch`` params: ``["strategy", "arg1", ...]``. + Supported strategies: ``command``, ``shell``. + The command must print a valid bearer token to stdout. + When set, ``token_file`` and OAuth credentials are not + required. + +The built-in OAuth flow is not ideal, but Google has deprecated the previous +APIs used for this without providing a suitable replacement. See :gh:`975` for +discussion on the topic. Using ``token_command`` with an external OAuth helper +sidesteps these issues. Local +++++ diff --git a/vdirsyncer/storage/google.py b/vdirsyncer/storage/google.py index a81eeb9b..dc59da09 100644 --- a/vdirsyncer/storage/google.py +++ b/vdirsyncer/storage/google.py @@ -40,15 +40,20 @@ class GoogleSession(dav.DAVSession): def __init__( self, - token_file, - client_id, - client_secret, + token_file=None, + client_id=None, + client_secret=None, + token_command=None, url=None, *, connector: aiohttp.BaseConnector, ): - if not have_oauth2: - raise exceptions.UserError("aiohttp-oauthlib not installed") + if token_command is not None: + self._token_command = list(token_command) + else: + self._token_command = None + if not have_oauth2: + raise exceptions.UserError("aiohttp-oauthlib not installed") # Required for discovering collections if url is not None: @@ -58,7 +63,7 @@ def __init__( self._settings = {} self.connector = connector - self._token_file = Path(expand_path(token_file)) + self._token_file = Path(expand_path(token_file)) if token_file else None self._client_id = client_id self._client_secret = client_secret self._token = None @@ -68,24 +73,52 @@ async def request(self, method, path, **kwargs): if not self._token: await self._init_token() + if self._token_command: + kwargs["headers"] = dict(kwargs.get("headers", {})) + kwargs["headers"]["Authorization"] = f"Bearer {self._token}" + try: + return await super().request(method, path, **kwargs) + except aiohttp.ClientResponseError as e: + if e.status == 401: + logger.debug("Token expired, re-fetching via token_command") + await self._fetch_token_via_command() + kwargs["headers"]["Authorization"] = f"Bearer {self._token}" + return await super().request(method, path, **kwargs) + raise + return await super().request(method, path, **kwargs) async def _save_token(self, token): """Helper function called by OAuth2Session when a token is updated.""" + if self._token_command: + return + assert self._token_file is not None checkdir(expand_path(os.path.dirname(self._token_file)), create=True) with atomic_write(self._token_file, mode="w", overwrite=True) as f: json.dump(token, f) @property def _session(self): - """Return a new OAuth session for requests. + """Return a new session for requests. + + When token_command is set, returns a plain aiohttp session (the + external command handles all OAuth concerns). Otherwise returns an + OAuth2Session that manages the OAuth2 flow with Google. - Accesses the self.redirect_uri field (str): the URI to redirect + The OAuth2 path uses self.redirect_uri (str): the URI to redirect authentication to. Should be a loopback address for a local server that follows the process detailed in https://developers.google.com/identity/protocols/oauth2/native-app. """ + if self._token_command: + return aiohttp.ClientSession( + connector=self.connector, + connector_owner=False, + trust_env=True, + ) + + assert have_oauth2 return OAuth2Session( client_id=self._client_id, token=self._token, @@ -103,6 +136,10 @@ def _session(self): ) async def _init_token(self): + if self._token_command: + await self._fetch_token_via_command() + return + try: with self._token_file.open() as f: self._token = json.load(f) @@ -165,6 +202,26 @@ async def _init_token(self): # FIXME: Ugly await self._save_token(self._token) + async def _fetch_token_via_command(self): + from vdirsyncer.cli.fetchparams import STRATEGIES + + assert self._token_command is not None + strategy = self._token_command[0] + try: + strategy_fn = STRATEGIES[strategy] + except KeyError: + raise exceptions.UserError( + f"Unknown token_command strategy: {strategy}. " + f"Available: {', '.join(sorted(STRATEGIES))}" + ) + token = strategy_fn(*self._token_command[1:]) + if not token: + raise exceptions.UserError( + f"token_command strategy '{strategy}' returned empty output" + ) + self._token = token + logger.debug("Successfully fetched token via token_command") + class GoogleCalendarStorage(dav.CalDAVStorage): class session_class(GoogleSession): @@ -185,9 +242,10 @@ def _get_collection_from_url(url): def __init__( self, - token_file, - client_id, - client_secret, + token_file=None, + client_id=None, + client_secret=None, + token_command=None, start_date=None, end_date=None, item_types=(), @@ -200,6 +258,7 @@ def __init__( token_file=token_file, client_id=client_id, client_secret=client_secret, + token_command=token_command, start_date=start_date, end_date=end_date, item_types=item_types, @@ -230,7 +289,8 @@ class discovery_class(dav.CardDiscover): storage_name = "google_contacts" - def __init__(self, token_file, client_id, client_secret, **kwargs): + def __init__(self, token_file=None, client_id=None, client_secret=None, + token_command=None, **kwargs): if not kwargs.get("collection"): raise exceptions.CollectionRequired @@ -238,6 +298,7 @@ def __init__(self, token_file, client_id, client_secret, **kwargs): token_file=token_file, client_id=client_id, client_secret=client_secret, + token_command=token_command, **kwargs, )