Skip to content

Commit 6643a36

Browse files
committed
feat(google): add token_command support for external token fetching
- allows the use of https://github.com/pdobsan/oama to manage oauth tokens
1 parent 0d69ab9 commit 6643a36

1 file changed

Lines changed: 82 additions & 12 deletions

File tree

vdirsyncer/storage/google.py

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,25 @@
4040
class GoogleSession(dav.DAVSession):
4141
def __init__(
4242
self,
43-
token_file,
44-
client_id,
45-
client_secret,
43+
token_file=None,
44+
client_id=None,
45+
client_secret=None,
46+
token_command=None,
4647
url=None,
4748
*,
4849
connector: aiohttp.BaseConnector,
4950
):
50-
if not have_oauth2:
51-
raise exceptions.UserError("aiohttp-oauthlib not installed")
51+
if token_command is not None:
52+
if isinstance(token_command, str):
53+
self._token_command = token_command
54+
self._token_command_shell = True
55+
else:
56+
self._token_command = list(token_command)
57+
self._token_command_shell = False
58+
else:
59+
self._token_command = None
60+
if not have_oauth2:
61+
raise exceptions.UserError("aiohttp-oauthlib not installed")
5262

5363
# Required for discovering collections
5464
if url is not None:
@@ -58,7 +68,7 @@ def __init__(
5868
self._settings = {}
5969
self.connector = connector
6070

61-
self._token_file = Path(expand_path(token_file))
71+
self._token_file = Path(expand_path(token_file)) if token_file else None
6272
self._client_id = client_id
6373
self._client_secret = client_secret
6474
self._token = None
@@ -68,24 +78,52 @@ async def request(self, method, path, **kwargs):
6878
if not self._token:
6979
await self._init_token()
7080

81+
if self._token_command:
82+
kwargs["headers"] = dict(kwargs.get("headers", {}))
83+
kwargs["headers"]["Authorization"] = f"Bearer {self._token}"
84+
try:
85+
return await super().request(method, path, **kwargs)
86+
except aiohttp.ClientResponseError as e:
87+
if e.status == 401:
88+
logger.debug("Token expired, re-fetching via token_command")
89+
await self._fetch_token_via_command()
90+
kwargs["headers"]["Authorization"] = f"Bearer {self._token}"
91+
return await super().request(method, path, **kwargs)
92+
raise
93+
7194
return await super().request(method, path, **kwargs)
7295

7396
async def _save_token(self, token):
7497
"""Helper function called by OAuth2Session when a token is updated."""
98+
if self._token_command:
99+
return
100+
assert self._token_file is not None
75101
checkdir(expand_path(os.path.dirname(self._token_file)), create=True)
76102
with atomic_write(self._token_file, mode="w", overwrite=True) as f:
77103
json.dump(token, f)
78104

79105
@property
80106
def _session(self):
81-
"""Return a new OAuth session for requests.
107+
"""Return a new session for requests.
108+
109+
When token_command is set, returns a plain aiohttp session (the
110+
external command handles all OAuth concerns). Otherwise returns an
111+
OAuth2Session that manages the OAuth2 flow with Google.
82112
83-
Accesses the self.redirect_uri field (str): the URI to redirect
113+
The OAuth2 path uses self.redirect_uri (str): the URI to redirect
84114
authentication to. Should be a loopback address for a local server that
85115
follows the process detailed in
86116
https://developers.google.com/identity/protocols/oauth2/native-app.
87117
"""
88118

119+
if self._token_command:
120+
return aiohttp.ClientSession(
121+
connector=self.connector,
122+
connector_owner=False,
123+
trust_env=True,
124+
)
125+
126+
assert have_oauth2
89127
return OAuth2Session(
90128
client_id=self._client_id,
91129
token=self._token,
@@ -103,6 +141,10 @@ def _session(self):
103141
)
104142

105143
async def _init_token(self):
144+
if self._token_command:
145+
await self._fetch_token_via_command()
146+
return
147+
106148
try:
107149
with self._token_file.open() as f:
108150
self._token = json.load(f)
@@ -165,6 +207,30 @@ async def _init_token(self):
165207
# FIXME: Ugly
166208
await self._save_token(self._token)
167209

210+
async def _fetch_token_via_command(self):
211+
import subprocess
212+
213+
assert self._token_command is not None
214+
215+
try:
216+
if self._token_command_shell:
217+
stdout = subprocess.check_output(
218+
self._token_command, text=True, shell=True
219+
)
220+
else:
221+
stdout = subprocess.check_output(self._token_command, text=True)
222+
except OSError as e:
223+
raise exceptions.UserError(
224+
f"Failed to execute token_command: {self._token_command}\n{e!s}"
225+
)
226+
227+
self._token = stdout.strip()
228+
if not self._token:
229+
raise exceptions.UserError(
230+
f"token_command returned empty output: {self._token_command}"
231+
)
232+
logger.debug("Successfully fetched token via token_command")
233+
168234

169235
class GoogleCalendarStorage(dav.CalDAVStorage):
170236
class session_class(GoogleSession):
@@ -185,9 +251,10 @@ def _get_collection_from_url(url):
185251

186252
def __init__(
187253
self,
188-
token_file,
189-
client_id,
190-
client_secret,
254+
token_file=None,
255+
client_id=None,
256+
client_secret=None,
257+
token_command=None,
191258
start_date=None,
192259
end_date=None,
193260
item_types=(),
@@ -200,6 +267,7 @@ def __init__(
200267
token_file=token_file,
201268
client_id=client_id,
202269
client_secret=client_secret,
270+
token_command=token_command,
203271
start_date=start_date,
204272
end_date=end_date,
205273
item_types=item_types,
@@ -230,14 +298,16 @@ class discovery_class(dav.CardDiscover):
230298

231299
storage_name = "google_contacts"
232300

233-
def __init__(self, token_file, client_id, client_secret, **kwargs):
301+
def __init__(self, token_file=None, client_id=None, client_secret=None,
302+
token_command=None, **kwargs):
234303
if not kwargs.get("collection"):
235304
raise exceptions.CollectionRequired
236305

237306
super().__init__(
238307
token_file=token_file,
239308
client_id=client_id,
240309
client_secret=client_secret,
310+
token_command=token_command,
241311
**kwargs,
242312
)
243313

0 commit comments

Comments
 (0)