feat(auth): use remote copy-paste OAuth2 flow instead of localhost server (#54)
* feat(auth): use remote copy-paste OAuth2 flow instead of localhost server
Tyler committed
Jun 11, 2026 at 14:53 UTC
a0d9671b008a532df5d8da8889142e14b9387006
4 files changed
+89
-14
AGENTS.md
+1
-1
@@ -5,7 +5,7 @@
5
- **Common**: `common.py` centralizes shared `State` (lazy-loading) and session resolution.
6
- **Client**: `ColabClient` handles API interactions (assignment, unassignment).
7
- **Auth**: `auth.py` exposes a single `get_credentials(config_path, provider)` facade that dispatches on the `AuthProvider` enum. Two providers are supported, selected via the global `--auth=oauth2|adc` flag (default `oauth2`):
8
- - `oauth2`: public `google-auth-oauthlib` `InstalledAppFlow`, token cached at `~/.config/colab-cli/token.json`. Requires an explicit client OAuth config (`-c/--client-oauth-config`, default `~/.colab-cli-oauth-config.json`); the previously-bundled `oauth_config.json` resource fallback was removed (commit `20eb88e`).
8
+ - `oauth2`: public `google-auth-oauthlib` `InstalledAppFlow`, token cached at `~/.config/colab-cli/token.json`. Reads the client OAuth config from `-c/--client-oauth-config` (default `~/.colab-cli-oauth-config.json`), falling back to the **bundled** `src/colab_cli/oauth_config.json` resource (re-added in PR #41 / `9f44fe2`, 2026-05-29 — the earlier "removed in `20eb88e`" note was stale/incorrect; the file exists and `auth.py:_get_google_auth_credentials` loads it via `importlib.resources`). As of 2026-06-11 the flow is a **remote copy-paste flow**, not a localhost server: `_run_remote_flow` sets `redirect_uri=https://sdk.cloud.google.com/applicationdefaultauthcode.html` + `token_usage=remote`, prints the URL, and reads the pasted code via `input()`. NEVER revert to OOB (`urn:ietf:wg:oauth:2.0:oob`) — Google blocked it in 2022 ("OOB flow has been blocked"); the `sdk.cloud.google.com` redirect is registered only to the bundled cloud-SDK client (`764086051850-...`), so any other client id gets `redirect_uri_mismatch`. Server-side acceptance/rejection of these variants is verifiable GET-only by building the authorization URL and inspecting whether Google reaches sign-in vs. an OAuth error page (no resources allocated).
9
- `adc`: Google Application Default Credentials via `google.auth.default()`. The CLI passes `scopes=PUBLIC_SCOPES` (which includes `colaboratory`) and re-applies via `creds.with_scopes()` for credential types that support it. **User credentials minted by `gcloud auth application-default login` ignore the `scopes=` kwarg AND raise `NotImplementedError` on `with_scopes`**: ADC users must explicitly re-authenticate with `gcloud auth application-default login --scopes=openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory`. `userinfo.email` is required by the session backend at `colab.research.google.com` (assign/unassign/sessions return 401 without it); `colaboratory` is required by the `RuntimeService` at `colab.pa.googleapis.com` (keep-alive returns 403 without it); `openid` and `cloud-platform` are mandated by `gcloud` itself, which rejects scope lists that omit `cloud-platform` with `Invalid value for [--scopes]`. Service-account / GCE / GKE / impersonated creds get the right scopes transparently via `with_scopes`.
10
- **Backend Hosts**: Two distinct backends with different requirements:
11
- `colab.research.google.com` (session backend / `tun/m/...`): accepts the `userinfo.email` scope.
docs/04_automation_and_utility.md
+20
-5
@@ -1,5 +1,6 @@
1
---
2
log:
3
+2026-06-11: Replaced the `oauth2` provider's `run_local_server()` (localhost redirect) with a remote copy-paste flow (`_run_remote_flow` in `auth.py`). The CLI now prints an authorization URL built with `redirect_uri=https://sdk.cloud.google.com/applicationdefaultauthcode.html` and `token_usage=remote`, then reads the pasted authorization code via `input()` and exchanges it with `flow.fetch_token(code=...)`. This is the same flow `gcloud auth application-default login` uses and works identically in local and remote/headless/container environments, removing the heuristic of whether to auto-open a browser. Confirmed server-side acceptance with a live GET-only check against the bundled cloud-SDK client (`764086051850-...`); the OOB redirect and a non-bundled client id were both verified to be rejected (`OOB flow has been blocked` / `redirect_uri_mismatch`). Unit tests in `tests/test_auth.py` assert no localhost server is started, the redirect URI + `token_usage=remote` are set, and the pasted code is exchanged.
4
2026-06-01: Enabled `colab update --install` self-update on macOS in addition to Linux. Refactored platform check logic to keep the implementation DRY and updated both tests and documentation. Also, on these platforms, an additional message is shown recommending `colab update --install` to upgrade in place, positioned above the standard `pip`/`uv` installation command.
5
2026-05-29: Added default OAuth2 client config (`oauth_config.json`) as a bundled package resource and restored fallback loading logic in `get_credentials()`. The CLI now falls back to using these default credentials when no explicit local config is found. Added `integration/repro_bundled_oauth` integration test.
6
2026-05-27: Refactored `colab README` and `colab AGENT` to bundle `README.md` and `AGENTS.md` via Hatchling's `force-include` and read them using `importlib.resources` instead of `importlib.metadata`. `colab AGENT` now correctly prints `AGENTS.md`.
@@ -23,11 +24,25 @@ managing local state, or inspecting the environment.
24
The CLI supports two authentication strategies for talking to the Colab
25
backend, selected via the global `--auth=<provider>` flag:
26
26
-1. **`oauth2`** (default): Standard public InstalledAppFlow via
27
- `google-auth-oauthlib`. Opens a browser for consent, caches the refresh
28
- token at `~/.config/colab-cli/token.json`. If no local config is provided
29
- via `-c/--client-oauth-config` or found at `~/.colab-cli-oauth-config.json`,
30
- it falls back to a bundled `oauth_config.json` containing default OAuth credentials.
27
+1. **`oauth2`** (default): Public `InstalledAppFlow` via
28
+ `google-auth-oauthlib`, but run with a **remote copy-paste flow** rather
29
+ than a localhost server. The CLI prints an authorization URL (with
30
+ `token_usage=remote`) using the registered HTTPS landing page
31
+ `https://sdk.cloud.google.com/applicationdefaultauthcode.html`; the user
32
+ signs in, copies the code Google displays, and pastes it back at the
33
+ prompt. The refresh token is cached at `~/.config/colab-cli/token.json`.
34
+ This is the same mechanism `gcloud auth application-default login` uses,
35
+ and it behaves identically on local, remote, headless, and container
36
+ hosts (no auto-opened browser, no bound port). We deliberately do **not**
37
+ use `run_local_server()` (environment-dependent) or the out-of-band (OOB)
38
+ redirect `urn:ietf:wg:oauth:2.0:oob` (blocked by Google in 2022 — see
39
+ `_run_remote_flow` / `REMOTE_REDIRECT_URI` in `auth.py`). The
40
+ `sdk.cloud.google.com` redirect is registered to the cloud-SDK OAuth
41
+ client (`764086051850-...`), which is also the client shipped in the
42
+ bundled `oauth_config.json`; reusing it with any other client id yields
43
+ `redirect_uri_mismatch`. If no local config is provided via
44
+ `-c/--client-oauth-config` or found at `~/.colab-cli-oauth-config.json`,
45
+ it falls back to that bundled `oauth_config.json`.
46
2. **`adc`**: Application Default Credentials via `google.auth.default()`.
47
Honors the standard ADC discovery chain
48
(`GOOGLE_APPLICATION_CREDENTIALS`, `gcloud auth application-default
src/colab_cli/auth.py
+41
-4
@@ -52,7 +52,46 @@ PUBLIC_SCOPES = [
52
53
54
TOKEN_CONFIG_PATH = os.path.expanduser("~/.config/colab-cli/token.json")
55
-OAUTH_SERVER_PORT = 8200
55
+
56
+# Remote copy-paste OAuth flow.
57
+#
58
+# We deliberately do NOT use a localhost redirect (`run_local_server`) or the
59
+# out-of-band (OOB) redirect `urn:ietf:wg:oauth:2.0:oob`. OOB was blocked by
60
+# Google in 2022 ("The out-of-band (OOB) flow has been blocked in order to
61
+# keep users secure") and a localhost server is environment-dependent (fails
62
+# on headless/remote/container hosts, requires an auto-openable browser, etc.).
63
+#
64
+# Instead we use the same mechanism `gcloud auth application-default login`
65
+# uses: a real registered HTTPS landing page that displays the authorization
66
+# code for the user to copy & paste, combined with the `token_usage=remote`
67
+# consent parameter. This works identically in local and remote environments.
68
+#
69
+# The landing page below is registered to Google's cloud-SDK OAuth client
70
+# (`764086051850-...`), which is also the client shipped in
71
+# `colab_cli/oauth_config.json`; reusing another client id with this redirect
72
+# yields `redirect_uri_mismatch`.
73
+REMOTE_REDIRECT_URI = "https://sdk.cloud.google.com/applicationdefaultauthcode.html"
74
+
75
+
76
+def _run_remote_flow(client_config: dict) -> Credentials:
77
+ """Run the remote copy-paste OAuth2 flow.
78
+
79
+ Prints an authorization URL, waits for the user to sign in and paste back
80
+ the authorization code shown on Google's landing page, then exchanges the
81
+ code for credentials. See ``REMOTE_REDIRECT_URI`` for why this is preferred
82
+ over a localhost server or the blocked OOB flow.
83
+ """
84
+ flow = InstalledAppFlow.from_client_config(client_config, PUBLIC_SCOPES)
85
+ flow.redirect_uri = REMOTE_REDIRECT_URI
86
+ auth_url, _ = flow.authorization_url(prompt="consent", token_usage="remote")
87
+
88
+ typer.echo("\nTo authorize colab-cli, visit this URL in any browser:\n", err=True)
89
+ typer.echo(" " + auth_url + "\n", err=True)
90
+ typer.echo("After approving, Google will display an authorization code.", err=True)
91
+ code = input("Enter the authorization code: ").strip()
92
+
93
+ flow.fetch_token(code=code)
94
+ return flow.credentials
95
96
97
def _get_google_auth_credentials(config_path: str) -> Credentials:
@@ -100,8 +139,7 @@ def _get_google_auth_credentials(config_path: str) -> Credentials:
139
creds = None
140
141
if not creds:
103
- flow = InstalledAppFlow.from_client_config(client_config, PUBLIC_SCOPES)
104
- creds = flow.run_local_server(port=OAUTH_SERVER_PORT)
142
+ creds = _run_remote_flow(client_config)
143
144
# Save the credentials for the next run
145
try:
@@ -150,7 +188,6 @@ def _get_adc_credentials() -> Credentials:
188
)
189
creds, _ = google.auth.default(scopes=list(PUBLIC_SCOPES))
190
153
-
191
if not creds.valid:
192
from google.auth import compute_engine
193
tests/test_auth.py
+27
-4
@@ -16,7 +16,12 @@ from unittest.mock import MagicMock, mock_open, patch
16
17
import pytest
18
19
-from colab_cli.auth import TOKEN_CONFIG_PATH, AuthProvider, get_credentials
19
+from colab_cli.auth import (
20
+ REMOTE_REDIRECT_URI,
21
+ TOKEN_CONFIG_PATH,
22
+ AuthProvider,
23
+ get_credentials,
24
+)
25
26
27
@pytest.fixture
@@ -96,21 +101,39 @@ def test_get_credentials_expired_token_refresh(mock_deps):
101
assert res == mock_deps["session"].return_value
102
103
99
-def test_get_credentials_no_token(mock_deps):
104
+def test_get_credentials_no_token(mock_deps, mocker):
105
+ """With no cached token, the remote copy-paste flow runs and exchanges code."""
106
mock_deps["exists"].side_effect = lambda path: path == "dummy_config.json"
107
108
mock_flow = MagicMock()
109
mock_creds_new = MagicMock()
110
mock_creds_new.to_json.return_value = '{"token":"new"}'
105
- mock_flow.run_local_server.return_value = mock_creds_new
111
+ mock_flow.authorization_url.return_value = ("https://auth.example/url", "state")
112
+ mock_flow.credentials = mock_creds_new
113
mock_deps["flow_cls"].from_client_config.return_value = mock_flow
114
115
+ # User pastes the authorization code at the prompt.
116
+ mocker.patch("colab_cli.auth.input", create=True, return_value="pasted-code")
117
+
118
m_open = mock_open(read_data='{"web":{"client_id":"id"}}')
119
with patch("builtins.open", m_open):
120
get_credentials("dummy_config.json", provider=AuthProvider.OAUTH2)
121
122
mock_deps["flow_cls"].from_client_config.assert_called_once()
113
- mock_flow.run_local_server.assert_called_once()
123
+ # No localhost server should ever be started.
124
+ mock_flow.run_local_server.assert_not_called()
125
+ # Remote flow: OOB-free redirect + token_usage=remote consent param.
126
+ assert mock_flow.redirect_uri == REMOTE_REDIRECT_URI
127
+ _, kwargs = mock_flow.authorization_url.call_args
128
+ assert kwargs.get("token_usage") == "remote"
129
+ # The pasted code is exchanged for a token.
130
+ mock_flow.fetch_token.assert_called_once_with(code="pasted-code")
131
+
132
+
133
+def test_remote_redirect_is_not_oob():
134
+ """Guard against regressing to the dead OOB redirect URI."""
135
+ assert REMOTE_REDIRECT_URI.startswith("https://")
136
+ assert "oob" not in REMOTE_REDIRECT_URI
137
138
139
def test_get_credentials_fallback_config(mock_deps):