diff --git a/README.md b/README.md index 69cf976..a14a2bf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gclientid/__init__.py b/gclientid/__init__.py index 96036b8..af4aae9 100644 --- a/gclientid/__init__.py +++ b/gclientid/__init__.py @@ -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 diff --git a/gclientid/oauth.py b/gclientid/oauth.py index 26b8b46..f06c5bf 100644 --- a/gclientid/oauth.py +++ b/gclientid/oauth.py @@ -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/' @@ -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): @@ -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" @@ -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) diff --git a/tests/test_oauth.py b/tests/test_oauth.py index 5c1159a..2adb06b 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -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(): @@ -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'])