Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 58 additions & 11 deletions docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,38 @@ for reference) and the
`BDAY <https://www.rfc-editor.org/rfc/rfc6350#section-6.2.5>`_ 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
<fetch_params>`. 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]

Expand Down Expand Up @@ -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

Expand All @@ -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
+++++
Expand Down
85 changes: 73 additions & 12 deletions vdirsyncer/storage/google.py
Comment thread
fishman marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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=(),
Expand All @@ -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,
Expand Down Expand Up @@ -230,14 +289,16 @@ 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

super().__init__(
token_file=token_file,
client_id=client_id,
client_secret=client_secret,
token_command=token_command,
**kwargs,
)

Expand Down