From 6643a364fd5e4ac8f08c697b322576dc52ce515c Mon Sep 17 00:00:00 2001 From: Reza Jelveh Date: Sun, 14 Jun 2026 22:39:48 +0800 Subject: [PATCH 1/3] feat(google): add token_command support for external token fetching - allows the use of https://github.com/pdobsan/oama to manage oauth tokens --- vdirsyncer/storage/google.py | 94 +++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 12 deletions(-) diff --git a/vdirsyncer/storage/google.py b/vdirsyncer/storage/google.py index a81eeb9b..3c7fa2a3 100644 --- a/vdirsyncer/storage/google.py +++ b/vdirsyncer/storage/google.py @@ -40,15 +40,25 @@ 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: + if isinstance(token_command, str): + self._token_command = token_command + self._token_command_shell = True + else: + self._token_command = list(token_command) + self._token_command_shell = False + 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 +68,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 +78,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 +141,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 +207,30 @@ async def _init_token(self): # FIXME: Ugly await self._save_token(self._token) + async def _fetch_token_via_command(self): + import subprocess + + assert self._token_command is not None + + try: + if self._token_command_shell: + stdout = subprocess.check_output( + self._token_command, text=True, shell=True + ) + else: + stdout = subprocess.check_output(self._token_command, text=True) + except OSError as e: + raise exceptions.UserError( + f"Failed to execute token_command: {self._token_command}\n{e!s}" + ) + + self._token = stdout.strip() + if not self._token: + raise exceptions.UserError( + f"token_command returned empty output: {self._token_command}" + ) + logger.debug("Successfully fetched token via token_command") + class GoogleCalendarStorage(dav.CalDAVStorage): class session_class(GoogleSession): @@ -185,9 +251,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 +267,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 +298,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 +307,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, ) From 498d812f62ae1e818a9790827ee9c39b429f5beb Mon Sep 17 00:00:00 2001 From: Reza Jelveh Date: Sun, 14 Jun 2026 22:49:19 +0800 Subject: [PATCH 2/3] docs(google): add token_command option for external OAuth token fetching --- docs/config.rst | 62 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index 707f7d6f..d6b32f53 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -289,10 +289,35 @@ 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``, you do not need to register a Google Cloud project or +install ``aiohttp-oauthlib``. Any OAuth helper that prints an access token to +stdout works, e.g. `oama `_:: + + [storage my_google_cal] + type = "google_calendar" + token_command = ["oama", "access", "my-google-account"] + +The command can be specified as a list (executed directly) or a string (run via +shell). + +Built-in OAuth setup +++++++++++++++++++++ + +To use the built-in OAuth flow, you need to install some additional +dependencies:: pip install vdirsyncer[google] @@ -344,15 +369,22 @@ 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: Command to fetch an OAuth access token. Can be a list + of arguments or a string (run via 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 +397,22 @@ 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: Command to fetch an OAuth access token. Can be a list + of arguments or a string (run via 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 +++++ From 398c526f0a0a4ad3b8aaa66cc84a4f6eec213af2 Mon Sep 17 00:00:00 2001 From: Reza Jelveh Date: Wed, 17 Jun 2026 22:06:36 +0800 Subject: [PATCH 3/3] refactor(google): use strategy-based token_command --- docs/config.rst | 35 +++++++++++++++++++++-------------- vdirsyncer/storage/google.py | 31 +++++++++++-------------------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/docs/config.rst b/docs/config.rst index d6b32f53..0f167c7e 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -302,16 +302,19 @@ There are two ways to handle OAuth authentication with Google: 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``, you do not need to register a Google Cloud project or -install ``aiohttp-oauthlib``. Any OAuth helper that prints an access token to -stdout works, e.g. `oama `_:: +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 = ["oama", "access", "my-google-account"] + token_command = ["command", "oama", "access", "my-google-account"] -The command can be specified as a list (executed directly) or a string (run via -shell). +Or using a shell command:: + + token_command = ["shell", "oama access my-google-account"] Built-in OAuth setup ++++++++++++++++++++ @@ -381,10 +384,12 @@ itself or write anything to it. :param client_id/client_secret: OAuth credentials, obtained from the Google API Manager. Optional if ``token_command`` is set. - :param token_command: Command to fetch an OAuth access token. Can be a list - of arguments or a string (run via shell). The command - must print a valid bearer token to stdout. When set, - ``token_file`` and OAuth credentials are not required. + :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 @@ -404,10 +409,12 @@ itself or write anything to it. :param client_id/client_secret: OAuth credentials, obtained from the Google API Manager. Optional if ``token_command`` is set. - :param token_command: Command to fetch an OAuth access token. Can be a list - of arguments or a string (run via shell). The command - must print a valid bearer token to stdout. When set, - ``token_file`` and OAuth credentials are not required. + :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 diff --git a/vdirsyncer/storage/google.py b/vdirsyncer/storage/google.py index 3c7fa2a3..dc59da09 100644 --- a/vdirsyncer/storage/google.py +++ b/vdirsyncer/storage/google.py @@ -49,12 +49,7 @@ def __init__( connector: aiohttp.BaseConnector, ): if token_command is not None: - if isinstance(token_command, str): - self._token_command = token_command - self._token_command_shell = True - else: - self._token_command = list(token_command) - self._token_command_shell = False + self._token_command = list(token_command) else: self._token_command = None if not have_oauth2: @@ -208,27 +203,23 @@ async def _init_token(self): await self._save_token(self._token) async def _fetch_token_via_command(self): - import subprocess + from vdirsyncer.cli.fetchparams import STRATEGIES assert self._token_command is not None - + strategy = self._token_command[0] try: - if self._token_command_shell: - stdout = subprocess.check_output( - self._token_command, text=True, shell=True - ) - else: - stdout = subprocess.check_output(self._token_command, text=True) - except OSError as e: + strategy_fn = STRATEGIES[strategy] + except KeyError: raise exceptions.UserError( - f"Failed to execute token_command: {self._token_command}\n{e!s}" + f"Unknown token_command strategy: {strategy}. " + f"Available: {', '.join(sorted(STRATEGIES))}" ) - - self._token = stdout.strip() - if not self._token: + token = strategy_fn(*self._token_command[1:]) + if not token: raise exceptions.UserError( - f"token_command returned empty output: {self._token_command}" + f"token_command strategy '{strategy}' returned empty output" ) + self._token = token logger.debug("Successfully fetched token via token_command")