|
16 | 16 | """Provides authentication support for TensorBoardUploader."""
|
17 | 17 |
|
18 | 18 |
|
| 19 | +import datetime |
19 | 20 | import errno
|
20 | 21 | import json
|
21 | 22 | import os
|
| 23 | +import requests |
22 | 24 | import sys
|
| 25 | +import time |
23 | 26 | import webbrowser
|
24 | 27 |
|
25 |
| -import google_auth_oauthlib.flow |
| 28 | +import google_auth_oauthlib.flow as auth_flows |
26 | 29 | import grpc
|
27 | 30 | import google.auth
|
28 | 31 | import google.auth.transport.requests
|
|
42 | 45 | "https://www.googleapis.com/auth/userinfo.email",
|
43 | 46 | )
|
44 | 47 |
|
45 |
| - |
46 |
| -# The client "secret" is public by design for installed apps. See |
| 48 | +# This config was downloaded from our GCP project at: |
| 49 | +# console.cloud.google.com/apis/credentials?project=hosted-tensorboard-prod |
| 50 | +# and in b/143316611. |
| 51 | +# |
| 52 | +# The client "secret" is considered public, as it's distributed to the devices |
| 53 | +# where this runs. See: |
47 | 54 | # https://developers.google.com/identity/protocols/OAuth2?csw=1#installed
|
48 |
| -OAUTH_CLIENT_CONFIG = """ |
49 |
| -{ |
50 |
| - "installed": { |
51 |
| - "client_id": "373649185512-8v619h5kft38l4456nm2dj4ubeqsrvh6.apps.googleusercontent.com", |
52 |
| - "project_id": "hosted-tensorboard-prod", |
53 |
| - "auth_uri": "https://accounts.google.com/o/oauth2/auth", |
54 |
| - "token_uri": "https://oauth2.googleapis.com/token", |
55 |
| - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", |
56 |
| - "client_secret": "pOyAuU2yq2arsM98Bw5hwYtr", |
57 |
| - "redirect_uris": [ |
58 |
| - "urn:ietf:wg:oauth:2.0:oob", |
59 |
| - "http://localhost" |
60 |
| - ] |
61 |
| - } |
62 |
| -} |
| 55 | +# |
| 56 | +# See below for the config for another accepted credential. |
| 57 | +_INSTALLED_APP_OAUTH_CLIENT_CONFIG = """ |
| 58 | + { |
| 59 | + "installed":{ |
| 60 | + "client_id":"373649185512-8v619h5kft38l4456nm2dj4ubeqsrvh6.apps.googleusercontent.com", |
| 61 | + "project_id":"hosted-tensorboard-prod", |
| 62 | + "auth_uri":"https://accounts.google.com/o/oauth2/auth", |
| 63 | + "token_uri":"https://oauth2.googleapis.com/token", |
| 64 | + "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", |
| 65 | + "client_secret":"pOyAuU2yq2arsM98Bw5hwYtr", |
| 66 | + "redirect_uris":["http://localhost"] |
| 67 | + } |
| 68 | + } |
| 69 | +""" |
| 70 | + |
| 71 | + |
| 72 | +# These values can be updated with values from the well-known "discovery url": |
| 73 | +# https://accounts.google.com/.well-known/openid-configuration |
| 74 | +# |
| 75 | +# See: |
| 76 | +# developers.google.com/identity/openid-connect/openid-connect#discovery |
| 77 | +_DEVICE_AUTH_CODE_URI = "https://oauth2.googleapis.com/device/code" |
| 78 | + |
| 79 | + |
| 80 | +_LIMITED_INPUT_DEVICE_AUTH_GRANT_TYPE = ( |
| 81 | + "urn:ietf:params:oauth:grant-type:device_code" |
| 82 | +) |
| 83 | + |
| 84 | +# This config was downloaded from our GCP project at: |
| 85 | +# console.cloud.google.com/apis/credentials?project=hosted-tensorboard-prod |
| 86 | +# and in b/262276562. |
| 87 | +# |
| 88 | +# Note that some of these fields are not really useful for this flow. |
| 89 | +# It seems the limited-input device flow is not quite as well supported yet by |
| 90 | +# neither the Google python oauth libraries, nor the GCP console, so this config |
| 91 | +# does not match what we would need to authenticate this way (starting from the |
| 92 | +# fact that it seems to be a config for an "installed" app), but we do use some |
| 93 | +# of them (e.g. client_id and client_secret), along with other values defined in |
| 94 | +# separate constants for this auth flow. |
| 95 | +# |
| 96 | +# The client "secret" is considered public, as it's distributed to the devices |
| 97 | +# where this runs. See: |
| 98 | +# https://developers.google.com/identity/protocols/oauth2/limited-input-device |
| 99 | +# |
| 100 | +# See above for the config for another accepted credential. |
| 101 | +_LIMITED_INPUT_DEVICE_OAUTH_CLIENT_CONFIG = """ |
| 102 | + { |
| 103 | + "installed":{ |
| 104 | + "client_id":"373649185512-26ojik4u7dt0rdtfdmfnhpajqqh579qd.apps.googleusercontent.com", |
| 105 | + "project_id":"hosted-tensorboard-prod", |
| 106 | + "auth_uri":"https://accounts.google.com/o/oauth2/auth", |
| 107 | + "token_uri":"https://oauth2.googleapis.com/token", |
| 108 | + "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", |
| 109 | + "client_secret":"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" |
| 110 | + } |
| 111 | + } |
63 | 112 | """
|
64 | 113 |
|
65 | 114 |
|
@@ -149,36 +198,158 @@ def clear(self):
|
149 | 198 | raise
|
150 | 199 |
|
151 | 200 |
|
152 |
| -def build_installed_app_flow(client_config): |
153 |
| - """Returns a `CustomInstalledAppFlow` for the given config. |
| 201 | +def authenticate_user( |
| 202 | + force_console=False, |
| 203 | +) -> google.oauth2.credentials.Credentials: |
| 204 | + """Makes the user authenticate to retrieve auth credentials. |
| 205 | +
|
| 206 | + The default behavior is to use the [installed app flow]( |
| 207 | + http://developers.google.com/identity/protocols/oauth2/native-app), in which |
| 208 | + a browser is started for the user to authenticate, along with a local web |
| 209 | + server. The authentication in the browser would produce a redirect response |
| 210 | + to `localhost` with an authorization code that would then be received by the |
| 211 | + local web server started here. |
| 212 | +
|
| 213 | + The two most notable cases where the default flow is not well supported are: |
| 214 | + - When the uploader is run from a colab notebook. |
| 215 | + - Then the uploader is run via a remote terminal (SSH). |
| 216 | +
|
| 217 | + If any of the following is true, a different auth flow will be used: |
| 218 | + - the flag `--auth_force_console` is set to true, or |
| 219 | + - a browser is not available, or |
| 220 | + - a local web server cannot be started |
| 221 | +
|
| 222 | + In this case, a [limited-input device flow]( |
| 223 | + http://developers.google.com/identity/protocols/oauth2/limited-input-device) |
| 224 | + will be used, in which the user is presented with a URL and a short code |
| 225 | + that they'd need to use to authenticate and authorize access in a separate |
| 226 | + browser or device. The uploader will poll for access until the access is |
| 227 | + granted or rejected, or the initiated authorization request expires. |
| 228 | + """ |
| 229 | + scopes = OPENID_CONNECT_SCOPES |
| 230 | + # TODO(b/141721828): make auto-detection smarter, especially for macOS. |
| 231 | + if not force_console and os.getenv("DISPLAY"): |
| 232 | + try: |
| 233 | + client_config = json.loads(_INSTALLED_APP_OAUTH_CLIENT_CONFIG) |
| 234 | + flow = auth_flows.InstalledAppFlow.from_client_config( |
| 235 | + client_config, scopes=scopes |
| 236 | + ) |
| 237 | + return flow.run_local_server(port=0) |
| 238 | + except webbrowser.Error: |
| 239 | + sys.stderr.write("Falling back to remote authentication flow...\n") |
154 | 240 |
|
155 |
| - Args: |
156 |
| - client_config (Mapping[str, Any]): The client configuration in the Google |
157 |
| - client secrets format. |
| 241 | + client_config = json.loads(_LIMITED_INPUT_DEVICE_OAUTH_CLIENT_CONFIG) |
| 242 | + flow = _LimitedInputDeviceAuthFlow(client_config, scopes=scopes) |
| 243 | + return flow.run() |
158 | 244 |
|
159 |
| - Returns: |
160 |
| - CustomInstalledAppFlow: the constructed flow. |
| 245 | + |
| 246 | +class _LimitedInputDeviceAuthFlow: |
| 247 | + """OAuth flow to authenticate using the limited-input device flow. |
| 248 | +
|
| 249 | + See: |
| 250 | + http://developers.google.com/identity/protocols/oauth2/limited-input-device |
161 | 251 | """
|
162 |
| - return CustomInstalledAppFlow.from_client_config( |
163 |
| - client_config, scopes=OPENID_CONNECT_SCOPES |
164 |
| - ) |
165 | 252 |
|
| 253 | + def __init__(self, client_config, scopes): |
| 254 | + self._client_config = client_config |
| 255 | + self._scopes = scopes |
| 256 | + |
| 257 | + def run(self) -> google.oauth2.credentials.Credentials: |
| 258 | + device_response = self._send_device_auth_request() |
| 259 | + prompt_message = ( |
| 260 | + "To sign in with the TensorBoard uploader:\n" |
| 261 | + "\n" |
| 262 | + "1. On your computer or phone, visit:\n" |
| 263 | + "\n" |
| 264 | + " {url}\n" |
| 265 | + "\n" |
| 266 | + "2. Sign in with your Google account, then enter:\n" |
| 267 | + "\n" |
| 268 | + " {code}\n".format( |
| 269 | + url=device_response["verification_url"], |
| 270 | + code=device_response["user_code"], |
| 271 | + ) |
| 272 | + ) |
| 273 | + print(prompt_message) |
166 | 274 |
|
167 |
| -class CustomInstalledAppFlow(google_auth_oauthlib.flow.InstalledAppFlow): |
168 |
| - """Customized version of the Installed App OAuth2 flow.""" |
| 275 | + auth_response = self._poll_for_auth_token( |
| 276 | + device_code=device_response["device_code"], |
| 277 | + polling_interval=device_response["interval"], |
| 278 | + expiration_seconds=device_response["expires_in"], |
| 279 | + ) |
169 | 280 |
|
170 |
| - def run(self, force_console=False): |
171 |
| - """Run the flow using a local server if possible, otherwise the |
172 |
| - console.""" |
173 |
| - # TODO(b/141721828): make auto-detection smarter, especially for macOS. |
174 |
| - if not force_console and os.getenv("DISPLAY"): |
175 |
| - try: |
176 |
| - return self.run_local_server(port=0) |
177 |
| - except webbrowser.Error: |
178 |
| - sys.stderr.write( |
179 |
| - "Falling back to console authentication flow...\n" |
| 281 | + return self._build_credentials(auth_response) |
| 282 | + |
| 283 | + def _send_device_auth_request(self): |
| 284 | + params = { |
| 285 | + "client_id": self._client_config["client_id"], |
| 286 | + "scope": " ".join(self._scopes), |
| 287 | + } |
| 288 | + r = requests.post(_DEVICE_AUTH_CODE_URI, data=params).json() |
| 289 | + if "device_code" not in r: |
| 290 | + raise RuntimeError( |
| 291 | + "There was an error while contacting Google's authorization " |
| 292 | + "server. Please try again later." |
| 293 | + ) |
| 294 | + return r |
| 295 | + |
| 296 | + def _poll_for_auth_token( |
| 297 | + self, device_code: str, polling_interval: int, expiration_seconds: int |
| 298 | + ): |
| 299 | + token_uri = self._client_config["token_uri"] |
| 300 | + params = { |
| 301 | + "client_id": self._client_config["client_id"], |
| 302 | + "client_secret": self._client_config["client_secret"], |
| 303 | + "device_code": device_code, |
| 304 | + "grant_type": _LIMITED_INPUT_DEVICE_AUTH_GRANT_TYPE, |
| 305 | + } |
| 306 | + expiration_time = time.time() + expiration_seconds |
| 307 | + # Error cases documented in |
| 308 | + # https://developers.google.com/identity/protocols/oauth2/limited-input-device#step-6:-handle-responses-to-polling-requests |
| 309 | + while time.time() < expiration_time: |
| 310 | + resp = requests.post(token_uri, data=params) |
| 311 | + r = resp.json() |
| 312 | + if "access_token" in r: |
| 313 | + return r |
| 314 | + elif "error" in r and r["error"] == "authorization_pending": |
| 315 | + # Not really an error. This is the expected response when the |
| 316 | + # user has not yet granted access to this app. |
| 317 | + time.sleep(polling_interval) |
| 318 | + elif "error" in r and r["error"] == "slow_down": |
| 319 | + # We should be polling at the specified interval from the |
| 320 | + # previous response, so this error would be unexpected. |
| 321 | + # However, it is just a temporary/retryable error, so we can |
| 322 | + # poll a bit more slowly. |
| 323 | + polling_interval = int(polling_interval * 1.5) |
| 324 | + time.sleep(polling_interval) |
| 325 | + elif "error" in r and r["error"] == "access_denied": |
| 326 | + raise PermissionError("Access was denied by user.") |
| 327 | + elif resp.status_code in {400, 401}: |
| 328 | + raise ValueError("There must be an error in the request.") |
| 329 | + else: |
| 330 | + raise RuntimeError( |
| 331 | + "An unexpected error occurred while waiting for " |
| 332 | + "authorization." |
180 | 333 | )
|
181 |
| - return self.run_console() |
| 334 | + raise TimeoutError("Timed out waiting for authorization.") |
| 335 | + |
| 336 | + def _build_credentials( |
| 337 | + self, auth_response |
| 338 | + ) -> google.oauth2.credentials.Credentials: |
| 339 | + |
| 340 | + expiration_datetime = datetime.datetime.utcfromtimestamp( |
| 341 | + int(time.time()) + auth_response["expires_in"] |
| 342 | + ) |
| 343 | + return google.oauth2.credentials.Credentials( |
| 344 | + auth_response["access_token"], |
| 345 | + refresh_token=auth_response["refresh_token"], |
| 346 | + id_token=auth_response["id_token"], |
| 347 | + token_uri=self._client_config["token_uri"], |
| 348 | + client_id=self._client_config["client_id"], |
| 349 | + client_secret=self._client_config["client_secret"], |
| 350 | + scopes=self._scopes, |
| 351 | + expiry=expiration_datetime, |
| 352 | + ) |
182 | 353 |
|
183 | 354 |
|
184 | 355 | class IdTokenAuthMetadataPlugin(grpc.AuthMetadataPlugin):
|
|
0 commit comments