Skip to content

Commit c92fb80

Browse files
dcramerclaude
andcommitted
Add authorization code flow fallback for Google Calendar/Gmail
Google's device code flow only supports 7 scopes — Calendar and Gmail scopes are not among them. The bridge now selects the auth flow per capability based on scope compatibility: - Device code flow (RFC 8628) for scopes in Google's allowlist - Authorization code flow with loopback redirect (http://localhost) for all other scopes (Calendar, Gmail) auth_complete now exchanges the authorization code for tokens via the token endpoint instead of storing the raw code. A flow_type guard prevents auth_complete from being called on device_code flows. The OAuth client must be "Desktop" type to support authorization code flow with loopback redirects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a814c01 commit c92fb80

5 files changed

Lines changed: 431 additions & 205 deletions

File tree

specs/capability-auth.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,10 +294,15 @@ Without `--timeout`, performs a single poll and returns immediately.
294294

295295
## Google OAuth Specifics
296296

297-
- **Client type**: "TVs and Limited Input devices" in Google Cloud Console.
298-
- **Scopes**: `gmail.readonly`, `gmail.send`, `calendar.readonly`, `calendar.events`.
299-
- **Device code endpoint**: `POST https://oauth2.googleapis.com/device/code`.
300-
- **Token endpoint**: `POST https://oauth2.googleapis.com/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`.
297+
- **Client type**: "Desktop" type in Google Cloud Console (supports authorization code flow with loopback redirect for all scopes).
298+
- **Flow selection**: The bridge selects flow type per capability based on scope compatibility:
299+
- **Device code flow** (RFC 8628): Only for scopes in Google's device code allowlist (`email`, `openid`, `profile`, `drive.appdata`, `drive.file`, `youtube`, `youtube.readonly`).
300+
- **Authorization code flow** (loopback redirect): For all other scopes, including Calendar (`calendar`) and Gmail (`gmail.readonly`, `gmail.send`). User gets a URL, opens it in their browser, approves, and pastes the redirect URL containing the auth code.
301+
- **Scopes**: `gmail.readonly`, `gmail.send`, `calendar` — all use authorization code flow since none are in the device code allowlist.
302+
- **Device code endpoint**: `POST https://oauth2.googleapis.com/device/code` (only for device-code-compatible scopes).
303+
- **Authorization endpoint**: `GET https://accounts.google.com/o/oauth2/v2/auth` (for authorization code flow).
304+
- **Token endpoint**: `POST https://oauth2.googleapis.com/token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code` or `grant_type=authorization_code`.
305+
- **Redirect URI**: `http://localhost` — standard loopback redirect for headless/CLI tools. After consent, Google redirects to localhost (nothing listening), but the URL bar contains `?code=AUTH_CODE` for the user to copy.
301306
- **Token refresh**: bridge refreshes expired access tokens before invoke operations.
302307
- **Storage**: access_token + refresh_token in vault via `FileVault.put_json`, keyed by `(user_id, capability_id, account_ref)`.
303308
- **Config**: `google_client_id` / `google_client_secret` in `[skills.google]`, passed to bridge via env vars.

src/ash/skills/bundled/gog/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ gogcli bridge
1616

1717
## Getting Started
1818

19+
**Prerequisite**: Your Google Cloud OAuth client must be "Desktop" type (not "TVs and Limited Input devices"). Desktop clients support authorization code flow with loopback redirects, which is required for Calendar and Gmail scopes.
20+
1921
Add this to `config.toml`:
2022

2123
```toml

src/ash/skills/bundled/gog/scripts/gogcli_bridge.py

Lines changed: 159 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,31 @@
4646
DEFAULT_GOOGLE_OAUTH_BASE_URL = "https://oauth2.googleapis.com"
4747
DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
4848

49+
# Google authorization endpoint (different host from token endpoint)
50+
DEFAULT_GOOGLE_AUTH_BASE_URL = "https://accounts.google.com"
51+
ENV_GOOGLE_AUTH_BASE_URL = "GOOGLE_AUTH_BASE_URL"
52+
53+
# Scopes supported by device code flow (from Google docs)
54+
DEVICE_CODE_ALLOWED_SCOPES: frozenset[str] = frozenset(
55+
{
56+
"email",
57+
"openid",
58+
"profile",
59+
"https://www.googleapis.com/auth/drive.appdata",
60+
"https://www.googleapis.com/auth/drive.file",
61+
"https://www.googleapis.com/auth/youtube",
62+
"https://www.googleapis.com/auth/youtube.readonly",
63+
}
64+
)
65+
66+
AUTH_CODE_REDIRECT_URI = "http://localhost"
67+
4968
CAPABILITY_SCOPES: dict[str, str] = {
5069
"gog.email": (
5170
"https://www.googleapis.com/auth/gmail.readonly"
5271
" https://www.googleapis.com/auth/gmail.send"
5372
),
54-
"gog.calendar": (
55-
"https://www.googleapis.com/auth/calendar.readonly"
56-
" https://www.googleapis.com/auth/calendar.events"
57-
),
73+
"gog.calendar": "https://www.googleapis.com/auth/calendar",
5874
}
5975

6076

@@ -337,6 +353,16 @@ def _google_token_url() -> str:
337353
return f"{_google_oauth_base_url()}/token"
338354

339355

356+
def _scopes_support_device_code(scope_string: str) -> bool:
357+
return all(s in DEVICE_CODE_ALLOWED_SCOPES for s in scope_string.split())
358+
359+
360+
def _google_authorization_url() -> str:
361+
value = _optional_text(os.environ.get(ENV_GOOGLE_AUTH_BASE_URL))
362+
base = value or DEFAULT_GOOGLE_AUTH_BASE_URL
363+
return f"{base}/o/oauth2/v2/auth"
364+
365+
340366
def _google_client_id() -> str:
341367
value = _optional_text(os.environ.get(ENV_GOOGLE_CLIENT_ID))
342368
if not value:
@@ -457,35 +483,83 @@ def _handle_auth_begin(params: dict[str, Any]) -> dict[str, Any]:
457483
f"no OAuth scopes configured for {capability_id}",
458484
)
459485

460-
# Device code flow (RFC 8628)
461-
device_resp = _http_post_form(
462-
_google_device_code_url(),
463-
{"client_id": client_id, "scope": scope},
464-
)
465-
device_error = _optional_text(device_resp.get("error"))
466-
if device_error:
467-
error_desc = (
468-
_optional_text(device_resp.get("error_description")) or device_error
469-
)
470-
raise BridgeError(
471-
"capability_backend_unavailable",
472-
f"Google device code request failed: {error_desc}",
486+
if _scopes_support_device_code(scope):
487+
# Device code flow (RFC 8628)
488+
device_resp = _http_post_form(
489+
_google_device_code_url(),
490+
{"client_id": client_id, "scope": scope},
473491
)
492+
device_error = _optional_text(device_resp.get("error"))
493+
if device_error:
494+
error_desc = (
495+
_optional_text(device_resp.get("error_description")) or device_error
496+
)
497+
raise BridgeError(
498+
"capability_backend_unavailable",
499+
f"Google device code request failed: {error_desc}",
500+
)
474501

475-
device_code = _optional_text(device_resp.get("device_code"))
476-
user_code = _optional_text(device_resp.get("user_code"))
477-
verification_url = _optional_text(device_resp.get("verification_url"))
478-
google_interval = _int_claim(device_resp, "interval") or 5
479-
google_expires_in = _int_claim(device_resp, "expires_in")
502+
device_code = _optional_text(device_resp.get("device_code"))
503+
user_code = _optional_text(device_resp.get("user_code"))
504+
verification_url = _optional_text(device_resp.get("verification_url"))
505+
google_interval = _int_claim(device_resp, "interval") or 5
506+
google_expires_in = _int_claim(device_resp, "expires_in")
480507

481-
if not device_code or not user_code or not verification_url:
482-
raise BridgeError(
483-
"capability_backend_unavailable",
484-
"Google device code response missing required fields",
485-
)
508+
if not device_code or not user_code or not verification_url:
509+
raise BridgeError(
510+
"capability_backend_unavailable",
511+
"Google device code response missing required fields",
512+
)
513+
514+
if google_expires_in and google_expires_in < (expires_epoch - now_epoch):
515+
expires_epoch = now_epoch + google_expires_in
486516

487-
if google_expires_in and google_expires_in < (expires_epoch - now_epoch):
488-
expires_epoch = now_epoch + google_expires_in
517+
state = _read_state()
518+
_prune_expired_flows(state=state, now_epoch=now_epoch)
519+
state["auth_flows"][flow_id] = {
520+
"user_id": claims.user_id,
521+
"capability_id": capability_id,
522+
"account_hint": account_hint,
523+
"nonce": nonce,
524+
"issued_at": now_epoch,
525+
"expires_at": expires_epoch,
526+
"device_code": device_code,
527+
"poll_interval": google_interval,
528+
"flow_type": "device_code",
529+
}
530+
_write_state(state)
531+
532+
flow_state = {
533+
"flow_id": flow_id,
534+
"nonce": nonce,
535+
"device_code": device_code,
536+
}
537+
return {
538+
"auth_url": verification_url,
539+
"expires_at": _iso8601_utc(expires_epoch),
540+
"flow_state": flow_state,
541+
"flow_type": "device_code",
542+
"user_code": user_code,
543+
"poll_interval_seconds": google_interval,
544+
}
545+
546+
# Authorization code flow (loopback redirect)
547+
from urllib.parse import urlencode
548+
549+
# state_param is included in the auth URL for Google's CSRF protection.
550+
# The bridge cannot validate it on completion because the user pastes only
551+
# the auth code, not the full redirect URL. Stored for audit/debugging.
552+
state_param = secrets.token_hex(16)
553+
auth_params = {
554+
"client_id": client_id,
555+
"redirect_uri": AUTH_CODE_REDIRECT_URI,
556+
"response_type": "code",
557+
"scope": scope,
558+
"access_type": "offline",
559+
"prompt": "consent",
560+
"state": state_param,
561+
}
562+
auth_url = f"{_google_authorization_url()}?{urlencode(auth_params)}"
489563

490564
state = _read_state()
491565
_prune_expired_flows(state=state, now_epoch=now_epoch)
@@ -496,23 +570,21 @@ def _handle_auth_begin(params: dict[str, Any]) -> dict[str, Any]:
496570
"nonce": nonce,
497571
"issued_at": now_epoch,
498572
"expires_at": expires_epoch,
499-
"device_code": device_code,
500-
"poll_interval": google_interval,
573+
"flow_type": "authorization_code",
574+
"state_param": state_param,
575+
"redirect_uri": AUTH_CODE_REDIRECT_URI,
501576
}
502577
_write_state(state)
503578

504579
flow_state = {
505580
"flow_id": flow_id,
506581
"nonce": nonce,
507-
"device_code": device_code,
508582
}
509583
return {
510-
"auth_url": verification_url,
584+
"auth_url": auth_url,
511585
"expires_at": _iso8601_utc(expires_epoch),
512586
"flow_state": flow_state,
513-
"flow_type": "device_code",
514-
"user_code": user_code,
515-
"poll_interval_seconds": google_interval,
587+
"flow_type": "authorization_code",
516588
}
517589

518590

@@ -739,13 +811,57 @@ def _handle_auth_complete(params: dict[str, Any]) -> dict[str, Any]:
739811
"flow_state nonce mismatch",
740812
)
741813

814+
stored_flow_type = _optional_text(stored_flow.get("flow_type"))
815+
if stored_flow_type == "device_code":
816+
raise BridgeError(
817+
"capability_invalid_input",
818+
"device_code flows must use auth_poll, not auth_complete",
819+
)
820+
742821
account_ref = (
743822
_optional_text(stored_flow.get("account_hint"))
744823
or _optional_text(params.get("account_hint"))
745824
or "default"
746825
)
747-
callback_url = _optional_text(params.get("callback_url"))
748-
code = _optional_text(params.get("code"))
826+
code = _required_text(
827+
params.get("code"),
828+
code="capability_invalid_input",
829+
message="code is required for auth_complete",
830+
)
831+
redirect_uri = (
832+
_optional_text(stored_flow.get("redirect_uri")) or AUTH_CODE_REDIRECT_URI
833+
)
834+
835+
# Exchange authorization code for tokens
836+
client_id = _google_client_id()
837+
client_secret = _google_client_secret()
838+
token_resp = _http_post_form(
839+
_google_token_url(),
840+
{
841+
"grant_type": "authorization_code",
842+
"code": code,
843+
"client_id": client_id,
844+
"client_secret": client_secret,
845+
"redirect_uri": redirect_uri,
846+
},
847+
)
848+
849+
error_code = _optional_text(token_resp.get("error"))
850+
if error_code:
851+
error_desc = _optional_text(token_resp.get("error_description")) or error_code
852+
raise BridgeError(
853+
"capability_backend_unavailable",
854+
f"Google token exchange failed: {error_desc}",
855+
)
856+
857+
access_token = _optional_text(token_resp.get("access_token"))
858+
refresh_token = _optional_text(token_resp.get("refresh_token"))
859+
if not access_token:
860+
raise BridgeError(
861+
"capability_backend_unavailable",
862+
"Google token response missing access_token",
863+
)
864+
749865
account_key = _account_key(claims.user_id, capability_id, account_ref)
750866
existing = state["accounts"].get(account_key)
751867
existing_created_at = (
@@ -758,11 +874,6 @@ def _handle_auth_complete(params: dict[str, Any]) -> dict[str, Any]:
758874
)
759875
if credential_key is None:
760876
credential_key = f"cred_{secrets.token_hex(8)}"
761-
vault_ref = (
762-
_optional_text(existing.get("vault_ref"))
763-
if isinstance(existing, dict)
764-
else None
765-
)
766877
try:
767878
vault = _vault()
768879
vault_ref = vault.put_json(
@@ -775,10 +886,11 @@ def _handle_auth_complete(params: dict[str, Any]) -> dict[str, Any]:
775886
"user_id": claims.user_id,
776887
"account_ref": account_ref,
777888
"linked_at": now_epoch,
778-
"auth_exchange": {
779-
"code": code,
780-
"callback_url": callback_url,
781-
},
889+
"access_token": access_token,
890+
"refresh_token": refresh_token,
891+
"token_type": _optional_text(token_resp.get("token_type")),
892+
"expires_in": _int_claim(token_resp, "expires_in"),
893+
"obtained_at": now_epoch,
782894
},
783895
)
784896
except VaultError:

0 commit comments

Comments
 (0)