main
py 247 lines 9.68 KB
Raw
1 # Copyright 2026 Google LLC
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 import enum
16 import json
17 import logging
18 from importlib import resources
19 import os
20 import warnings
21 from typing import Optional
22
23 import google.auth
24 import typer
25 from google.auth.transport import requests
26 from google.auth.transport.requests import Request
27 from google.oauth2.credentials import Credentials
28 from google_auth_oauthlib.flow import InstalledAppFlow
29
30 logger = logging.getLogger(__name__)
31
32
33 class AuthProvider(str, enum.Enum):
34 """Authentication strategy for talking to the Colab backend.
35
36 Values are the lowercase strings accepted by the global ``--auth`` flag.
37 """
38
39 OAUTH2 = "oauth2"
40 ADC = "adc"
41
42
43 # Standard Scopes for Colab and Drive (Public Auth)
44 PUBLIC_SCOPES = [
45 "openid",
46 "https://www.googleapis.com/auth/userinfo.profile",
47 "https://www.googleapis.com/auth/userinfo.email",
48 "https://www.googleapis.com/auth/cloud-platform",
49 "https://www.googleapis.com/auth/colaboratory",
50 "https://www.googleapis.com/auth/drive.file",
51 ]
52
53
54 TOKEN_CONFIG_PATH = os.path.expanduser("~/.config/colab-cli/token.json")
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:
98 """
99 Retrieves credentials using standard public OAuth2 flow.
100 """
101 client_config = None
102 if os.path.exists(config_path):
103 with open(config_path, "r") as f:
104 client_config = json.load(f)
105 else:
106 # Last resort: try inlined config
107 try:
108 config_resource = resources.files("colab_cli").joinpath("oauth_config.json")
109 if config_resource.is_file():
110 client_config = json.loads(config_resource.read_text())
111 except Exception as e:
112 logger.debug(f"Failed to load inlined config: {e}")
113
114 if not client_config:
115 raise FileNotFoundError(
116 f"Client OAuth config not found at {config_path} and no inlined config available. "
117 "Please provide a valid path via -c/--client-oauth-config."
118 )
119
120 creds = None
121
122 # Ensure config directory exists for the token file
123 os.makedirs(os.path.dirname(TOKEN_CONFIG_PATH), exist_ok=True)
124
125 if os.path.exists(TOKEN_CONFIG_PATH):
126 try:
127 creds = Credentials.from_authorized_user_file(
128 TOKEN_CONFIG_PATH, PUBLIC_SCOPES
129 )
130 except Exception as e:
131 logger.warning(f"Failed to load token from {TOKEN_CONFIG_PATH}: {e}")
132
133 if not creds or not creds.valid:
134 if creds and creds.expired and creds.refresh_token:
135 try:
136 creds.refresh(Request())
137 except Exception as e:
138 logger.warning(f"Failed to refresh token: {e}")
139 creds = None
140
141 if not creds:
142 creds = _run_remote_flow(client_config)
143
144 # Save the credentials for the next run
145 try:
146 with open(TOKEN_CONFIG_PATH, "w") as token_file:
147 token_file.write(creds.to_json())
148 except Exception as e:
149 logger.error(f"Failed to save token to {TOKEN_CONFIG_PATH}: {e}")
150
151 return creds
152
153
154 def _get_adc_credentials() -> Credentials:
155 """Retrieves credentials using Google Application Default Credentials.
156
157 Honors the standard ADC discovery chain (``GOOGLE_APPLICATION_CREDENTIALS``,
158 ``gcloud auth application-default login``, GCE/GKE metadata server, etc.).
159
160 The RuntimeService at colab.pa.googleapis.com requires the
161 `colaboratory` scope (otherwise keep-alive returns 403 SCOPE_NOT_PERMITTED).
162 Most ADC credential types (service accounts, GCE/GKE, impersonated)
163 support `with_scopes`; user credentials minted by
164 `gcloud auth application-default login` do not. For the latter, the user
165 must re-run `gcloud auth application-default login` with
166 `--scopes=openid,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory`
167 (`openid` and `cloud-platform` are required by `gcloud` itself; `userinfo.email`
168 is required by the session backend; `colaboratory` is required by this RPC).
169 """
170 # `google.auth._default` emits a UserWarning when ADC user credentials
171 # don't have a quota project pinned ("Your application has authenticated
172 # using end user credentials from Google Cloud SDK without a quota
173 # project. You might receive a 'quota exceeded' or 'API not enabled'
174 # error.").
175 #
176 # That heuristic does not apply to this CLI: every call we make to
177 # `colab.pa.googleapis.com` carries `X-Goog-User-Project: 1014160490159`
178 # (Colab's project id) — see AGENTS.md item 18 — so the user's
179 # quota-project setting is irrelevant. The warning shows up on every
180 # single `colab` invocation under ADC, which is pure noise. Filter it,
181 # but keep the scope as tight as possible: only this exact message,
182 # only during this one call.
183 with warnings.catch_warnings():
184 warnings.filterwarnings(
185 "ignore",
186 message=r"Your application has authenticated using end user credentials.*",
187 category=UserWarning,
188 )
189 creds, _ = google.auth.default(scopes=list(PUBLIC_SCOPES))
190
191 if not creds.valid:
192 from google.auth import compute_engine
193
194 if isinstance(creds, compute_engine.Credentials):
195 creds = None
196 else:
197 logger.warning("Failed to obtain valid ADC credentials.")
198 try:
199 logger.warning("Trying to refresh ADC credentials")
200 creds.refresh(Request())
201 except Exception as e:
202 logger.warning(f"Failed to refresh token: {e}")
203 creds = None
204
205 if not creds:
206 typer.echo(
207 "No valid default credentials found. To authenticate, run:\n\n"
208 " gcloud auth application-default login \\\n"
209 " --scopes=openid,"
210 "https://www.googleapis.com/auth/cloud-platform,"
211 "https://www.googleapis.com/auth/userinfo.email,"
212 "https://www.googleapis.com/auth/colaboratory\n",
213 err=True,
214 )
215 exit(1)
216
217 # Some credential subclasses ignore the `scopes=` kwarg in `default()`
218 # (e.g. user creds), so re-apply via `with_scopes` when supported.
219 if getattr(creds, "requires_scopes", False):
220 try:
221 creds = creds.with_scopes(list(PUBLIC_SCOPES))
222 except Exception as e: # NotImplementedError for non-scopable creds.
223 logger.debug(f"Could not augment ADC scopes via with_scopes: {e}")
224 return creds
225
226
227 def get_credentials(
228 config_path: Optional[str] = None,
229 provider: AuthProvider = AuthProvider.OAUTH2,
230 ) -> requests.AuthorizedSession:
231 """Unified entry point for retrieving an authorized session.
232
233 Args:
234 config_path: Path to the OAuth2 client config JSON. Only consulted when
235 ``provider`` is ``OAUTH2``.
236 provider: Which authentication strategy to use.
237 """
238 if provider == AuthProvider.OAUTH2:
239 if not config_path:
240 config_path = os.path.expanduser("~/.colab-cli-oauth-config.json")
241 creds = _get_google_auth_credentials(config_path)
242 elif provider == AuthProvider.ADC:
243 creds = _get_adc_credentials()
244 else:
245 raise ValueError(f"Unknown auth provider: {provider!r}")
246
247 return requests.AuthorizedSession(creds)