Skip to content

Commit 8da06b5

Browse files
authored
Implements limited-input device auth flow, to replace deprecated OOB auth flow. (tensorflow#6107)
* Motivation for features / changes The OOB auth flow has been deprecated. We concluded that the limited-input device flow is appropriate for our use case where the uploader runs in an environment where a browser is not available. * Technical description of changes Implements a new auth flow which calls an RPC to fetch a device_code, verification_url and user_code, and asks user to visit the verification_url in another device and enter the user_code; then starts polling for the access token after the user authorizes the access from another device. * Screenshots of UI changes N/A * Detailed steps to verify changes work correctly (as executed by you) - Wrote test script that uses this class, and tested the auth flow and was able to print the credentials. - Wrote tests. * Alternate designs / implementations considered Basically, implementing something similar to this flow or the OOB flow ourselves.
1 parent 4c929f6 commit 8da06b5

File tree

3 files changed

+517
-47
lines changed

3 files changed

+517
-47
lines changed

tensorboard/uploader/auth.py

+212-41
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616
"""Provides authentication support for TensorBoardUploader."""
1717

1818

19+
import datetime
1920
import errno
2021
import json
2122
import os
23+
import requests
2224
import sys
25+
import time
2326
import webbrowser
2427

25-
import google_auth_oauthlib.flow
28+
import google_auth_oauthlib.flow as auth_flows
2629
import grpc
2730
import google.auth
2831
import google.auth.transport.requests
@@ -42,24 +45,70 @@
4245
"https://www.googleapis.com/auth/userinfo.email",
4346
)
4447

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:
4754
# 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+
}
63112
"""
64113

65114

@@ -149,36 +198,158 @@ def clear(self):
149198
raise
150199

151200

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")
154240

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()
158244

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
161251
"""
162-
return CustomInstalledAppFlow.from_client_config(
163-
client_config, scopes=OPENID_CONNECT_SCOPES
164-
)
165252

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)
166274

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+
)
169280

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."
180333
)
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+
)
182353

183354

184355
class IdTokenAuthMetadataPlugin(grpc.AuthMetadataPlugin):

0 commit comments

Comments
 (0)