Skip to content
Draft
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,18 @@ token = await authorize_google(

Pass `cdp=cdp` to open the flow in a particular CDP browser, or `remote=True` to print the appapis URL and read its copied result. Both paths verify the account and write the same standard authorized-user token.

Agent tools should use the split flow so they can wait for the user's reply without blocking on `input()`:

```python
from gclientid import auth_url, finish_auth

url = auth_url('oauth-client.json', 'oauth-token.json', account='me@example.com')
# Show url to the user. Pass their copied code=...&state=... result back later.
token = await finish_auth(response)
```

`auth_url` always uses the PKCE-protected appapis callback and requests explicit consent. Pass `preset=None` with `scopes=[...]` to request only those scopes. `finish_auth` validates the returned state, exchanges the single-use code, verifies the account, and saves the same authorized-user token as `authorize_google`.

Project deletion is also available. Google treats this as a recoverable shutdown for 30 days:

```python
Expand Down
2 changes: 1 addition & 1 deletion gclientid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@
find_organization, find_project, grant_project_roles, provision_project)
from .config import config_dir, oauth_settings
from .oauth import CLOUD_SCOPES, GMAIL_SCOPE, GOOGLE_APPS_SCOPES, MAX_SCOPES, PRESETS, WORKSPACE_ADMIN_SCOPES
from .oauth import authorize_google, connect_browser, create_client, oauth_config
from .oauth import auth_url, authorize_google, connect_browser, create_client, finish_auth, oauth_config
111 changes: 80 additions & 31 deletions gclientid/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,16 @@ def _auth_scopes(names): return tuple(f'{AUTH_SCOPE}{o}' for o in names.split())
PRESETS['max'] = dict(scopes=MAX_SCOPES, apis=MAX_APIS)


def oauth_config(preset:str='google-apps', scopes=None, apis=None) -> tuple[tuple[str, ...], tuple[str, ...]]:
"Return the deduplicated OAuth scopes and APIs for a preset plus additions"
if preset not in PRESETS: raise ValueError(f'Unknown preset {preset!r}; choose from {", ".join(PRESETS)}')
def oauth_config(
preset:str|None='google-apps', # Scope and API preset, or `None` for additions only
scopes=None, # Additional OAuth scopes
apis=None, # Additional Google API service names
) -> tuple[tuple[str, ...], tuple[str, ...]]:
"Return deduplicated scopes and APIs for a preset plus additions, or additions alone with no preset"
if preset is not None and preset not in PRESETS: raise ValueError(f'Unknown preset {preset!r}; choose from {", ".join(PRESETS)}')
scopes = () if scopes is None else (scopes,) if isinstance(scopes, str) else tuple(scopes)
apis = () if apis is None else (apis,) if isinstance(apis, str) else tuple(apis)
config = PRESETS[preset]
config = PRESETS[preset] if preset is not None else dict(scopes=(), apis=())
return tuple(dict.fromkeys((*config['scopes'], *scopes))), tuple(dict.fromkeys((*config['apis'], *apis)))
HOME_URL = 'https://answerdotai.github.io/gclientid/'
PRIVACY_URL = f'{HOME_URL}privacy/'
Expand Down Expand Up @@ -394,42 +398,46 @@ async def drive():
await page.close()


async def _exchange_code(client, code, verifier, redirect_uri, account):
"Exchange an authorization code and verify the returned Google account"
async with httpx.AsyncClient(timeout=10) as http:
data = dict(client_id=client['client_id'], client_secret=client['client_secret'], code=code,
code_verifier=verifier, redirect_uri=redirect_uri, grant_type='authorization_code')
response = await http.post(TOKEN_URI, data=data)
response.raise_for_status()
token = response.json()
userinfo = await http.get('https://openidconnect.googleapis.com/v1/userinfo',
headers={'Authorization': f'Bearer {token["access_token"]}'})
userinfo.raise_for_status()
user = userinfo.json()
token['account'] = user.get('email')
if account and account.casefold() not in f'{user.get("name", "")} {token["account"]}'.casefold():
raise RuntimeError(f'Google authorized {token["account"]!r}, not account={account!r}')
return token


async def _request_token(client:dict, scopes, account:str, cdp=None, remote:bool=False, open_browser:bool=True,
force_consent:bool=False, timeout:int=600) -> dict:
"Run one Google authorization and token exchange"
redirect_uri = REMOTE_REDIRECT_URI if remote else LOCAL_REDIRECT_URI
auth_url,verifier,state,redirect_uri = _auth_request(client, scopes, account, redirect_uri, force_consent)
url,verifier,state,redirect_uri = _auth_request(client, scopes, account, redirect_uri, force_consent)
if remote:
print(f'Open this URL in a browser:\n\n{auth_url}\n')
if open_browser: webbrowser.open(auth_url)
print(f'Open this URL in a browser:\n\n{url}\n')
if open_browser: webbrowser.open(url)
payload = input('Paste the result from oauth.appapis.org: ')
else:
callback = asyncio.create_task(_local_callback(timeout))
try:
if cdp: browser = asyncio.create_task(_open_cdp(cdp, auth_url, account, timeout))
if cdp: browser = asyncio.create_task(_open_cdp(cdp, url, account, timeout))
else:
webbrowser.open(auth_url)
webbrowser.open(url)
browser = None
if browser: payload,_ = await asyncio.gather(callback, browser)
else: payload = await callback
finally:
if not callback.done(): callback.cancel()
code = _callback_code(payload, state)

async with httpx.AsyncClient(timeout=10) as http:
data = dict(client_id=client['client_id'], client_secret=client['client_secret'], code=code,
code_verifier=verifier, redirect_uri=redirect_uri, grant_type='authorization_code')
response = await http.post(TOKEN_URI, data=data)
response.raise_for_status()
token = response.json()
userinfo = await http.get('https://openidconnect.googleapis.com/v1/userinfo',
headers={'Authorization': f'Bearer {token["access_token"]}'})
userinfo.raise_for_status()
user = userinfo.json()
token['account'] = user.get('email')
if account and account.casefold() not in f'{user.get("name", "")} {token["account"]}'.casefold():
raise RuntimeError(f'Google authorized {token["account"]!r}, not account={account!r}')
return token
return await _exchange_code(client, code, verifier, redirect_uri, account)


def _matching_refresh(previous, client, scopes, account):
Expand All @@ -439,6 +447,53 @@ def _matching_refresh(previous, client, scopes, account):
if not set(scopes).issubset(previous.get('scopes', ())): return
return previous.get('refresh_token')

_pending_auth = None


def auth_url(
client_path:str|Path='oauth-client.json', # Web client JSON from create_client
token_path:str|Path='oauth-token.json', # Destination for access and refresh token JSON
preset:str|None='google-apps', # Scope preset, or `None` to use only `scopes`
scopes=None, # Additional OAuth scopes
account:str=None, # Google account email hint and verification
):
"Start remote Google authorization and return its URL; complete with `finish_auth`"
global _pending_auth
client = json.loads(Path(client_path).read_text())['web']
token_path = Path(token_path)
previous = json.loads(token_path.read_text()) if token_path.exists() else {}
scopes,_ = oauth_config(preset, scopes)
account = account or previous.get('account')
refresh = _matching_refresh(previous, client, scopes, account)
url,verifier,state,redirect_uri = _auth_request(
client, scopes, account, REMOTE_REDIRECT_URI, force_consent=True)
_pending_auth = dict(client=client, token_path=token_path, scopes=scopes, account=account,
refresh=refresh, verifier=verifier, state=state, redirect_uri=redirect_uri)
return url


def _save_token(token, client, token_path, refresh):
"Save a completed OAuth token response in authorized-user format"
if not token.get('refresh_token'):
if not refresh: raise RuntimeError('Google did not return a refresh token after explicit consent')
token['refresh_token'] = refresh
token['created_at'] = datetime.now(timezone.utc).isoformat()
token = _authorized_user(token, client)
_write_json(token_path, token)
return token


async def finish_auth(payload):
"Validate a copied appapis result, exchange its code, and save the authorized-user token"
global _pending_auth
if _pending_auth is None: raise RuntimeError('No OAuth flow in progress; call `auth_url` first')
pending = _pending_auth
code = _callback_code(payload, pending['state'])
_pending_auth = None
token = await _exchange_code(pending['client'], code, pending['verifier'],
pending['redirect_uri'], pending['account'])
return _save_token(token, pending['client'], pending['token_path'], pending['refresh'])


async def _reusable_refresh(previous, client, scopes, account):
"Return a saved refresh token only after Google accepts a refresh grant"
Expand Down Expand Up @@ -469,10 +524,4 @@ async def authorize_google(
account = account or previous.get('account')
refresh = await _reusable_refresh(previous, client, scopes, account)
token = await _request_token(client, scopes, account, cdp, remote, open_browser, force_consent=not refresh)
if not token.get('refresh_token'):
if not refresh: raise RuntimeError('Google did not return a refresh token after explicit consent')
token['refresh_token'] = refresh
token['created_at'] = datetime.now(timezone.utc).isoformat()
token = _authorized_user(token, client)
_write_json(token_path, token)
return token
return _save_token(token, client, token_path, refresh)
21 changes: 19 additions & 2 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
import asyncio, json, pytest
from urllib.parse import parse_qs, urlparse

from gclientid.oauth import LOCAL_REDIRECT_URI, REMOTE_REDIRECT_URI, _auth_request, _callback_code, _matching_refresh
from gclientid.oauth import LOCAL_REDIRECT_URI, REMOTE_REDIRECT_URI, auth_url, finish_auth, _auth_request, _callback_code, _matching_refresh


def test_callback_code():
Expand All @@ -21,6 +21,23 @@ def test_auth_redirect():
with pytest.raises(ValueError, match='does not allow'): _auth_request(client, ['scope'], None, 'http://localhost:1/')


def test_split_auth(tmp_path):
client_path = tmp_path/'oauth-client.json'
token_path = tmp_path/'oauth-token.json'
client = dict(client_id='client', client_secret='secret', token_uri='https://example.com/token',
redirect_uris=[LOCAL_REDIRECT_URI, REMOTE_REDIRECT_URI])
client_path.write_text(json.dumps(dict(web=client)))

url = auth_url(client_path, token_path, preset=None, scopes=['scope'], account='me@example.com')
query = parse_qs(urlparse(url).query)
assert query['redirect_uri'] == [REMOTE_REDIRECT_URI]
assert query['login_hint'] == ['me@example.com']
assert query['prompt'] == ['consent']
assert query['scope'] == ['scope']
with pytest.raises(RuntimeError, match='state did not match'):
asyncio.run(finish_auth('code=abc&state=wrong'))


def test_matching_refresh():
client = {'client_id': 'client'}
saved = dict(client_id='client', account='me@example.com', refresh_token='refresh', scopes=['a', 'b'])
Expand Down