From c8bda91f97b20ac976edf9f7fa0752bf56fae9d7 Mon Sep 17 00:00:00 2001 From: Yating Date: Wed, 28 Dec 2022 09:39:55 -0500 Subject: [PATCH 1/5] Remove deprecated alias `numpy.bool8` (#6117) `numpy.bool8` is just a deprecated alias of [`numpy.bool_`](https://numpy.org/doc/stable/reference/arrays.scalars.html#numpy.bool_). Removed here to get rid of the deprecation warnings (https://github.com/tensorflow/tensorboard/issues/6110). Googlers, see cl/498031924 for internal tests. #oncall (cherry picked from commit 2bfdca46b011daa9e3869901796d66215a70f8d4) --- tensorboard/compat/tensorflow_stub/dtypes.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tensorboard/compat/tensorflow_stub/dtypes.py b/tensorboard/compat/tensorflow_stub/dtypes.py index 50da1320ad..b13762908c 100644 --- a/tensorboard/compat/tensorflow_stub/dtypes.py +++ b/tensorboard/compat/tensorflow_stub/dtypes.py @@ -323,7 +323,6 @@ def size(self): # Define data type range of numpy dtype dtype_range = { np.bool_: (False, True), - np.bool8: (False, True), np.uint8: (0, 255), np.uint16: (0, 65535), np.int8: (-128, 127), From 5d28c3a6ca90b83fd1e7158bc73b06c169669d2f Mon Sep 17 00:00:00 2001 From: Adrian RC Date: Wed, 11 Jan 2023 18:10:00 -0800 Subject: [PATCH 2/5] Updates references to numpy deprecated type aliases. (#6140) Numpy library deprecated some type aliases in version 1.20.0 [1], and then removed them in version 1.24.0 [2], which was released on Dec 18, 2022. Without this change, our build would be broken when using numpy version >= 1.24.0, with error `AttributeError: module 'numpy' has no attribute 'float'`. The fix suggested in release notes from numpy version 1.20.0 is to replace these types with the equivalent primitive python types. (In this case, simply `float`.) [1] http://numpy.org/doc/stable/release/1.20.0-notes.html#using-the-aliases-of-builtin-types-like-np-int-is-deprecated [2] http://numpy.org/doc/stable/release/1.24.0-notes.html#expired-deprecations * Motivation for features / changes Newer numpy versions break our build. This code is exactly equivalent, as the identifiers used previously were aliases for the same type. * Technical description of changes Replace occurrences of `np.float` for the primitive type `float`. * Screenshots of UI changes N/A * Detailed steps to verify changes work correctly (as executed by you) Ran tests. * Alternate designs / implementations considered N/A. (cherry picked from commit 7bcc5e82d0da5a6723a6ba43c1cee90d333f398c) --- tensorboard/plugins/npmi/csv_to_plugin_data_demo.py | 2 +- tensorboard/plugins/pr_curve/summary.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tensorboard/plugins/npmi/csv_to_plugin_data_demo.py b/tensorboard/plugins/npmi/csv_to_plugin_data_demo.py index 3ed3ab4a95..02ddb65756 100644 --- a/tensorboard/plugins/npmi/csv_to_plugin_data_demo.py +++ b/tensorboard/plugins/npmi/csv_to_plugin_data_demo.py @@ -70,7 +70,7 @@ def convert_file(file_path): for row in csv_reader: annotations.append(row[0]) values.append(row[1:]) - values = np.array(values).astype(np.float) + values = np.array(values).astype(float) writer = tf.summary.create_file_writer(os.path.dirname(file_path)) with writer.as_default(): diff --git a/tensorboard/plugins/pr_curve/summary.py b/tensorboard/plugins/pr_curve/summary.py index 826586ba47..9eed11cee9 100644 --- a/tensorboard/plugins/pr_curve/summary.py +++ b/tensorboard/plugins/pr_curve/summary.py @@ -215,7 +215,7 @@ def pb( # Compute bins of true positives and false positives. bucket_indices = np.int32(np.floor(predictions * (num_thresholds - 1))) - float_labels = labels.astype(np.float) + float_labels = labels.astype(float) histogram_range = (0, num_thresholds - 1) tp_buckets, _ = np.histogram( bucket_indices, From 580d0dd912b9b3bf3c637e09707f1a48f676810e Mon Sep 17 00:00:00 2001 From: Adrian RC Date: Thu, 5 Jan 2023 15:17:51 -0800 Subject: [PATCH 3/5] Implements limited-input device auth flow, to replace deprecated OOB auth flow. (#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. (cherry picked from commit 8da06b5fc09b96c116926d747a089c038d9fad6f) --- tensorboard/uploader/auth.py | 253 +++++++++++++--- tensorboard/uploader/auth_test.py | 304 +++++++++++++++++++- tensorboard/uploader/uploader_subcommand.py | 7 +- 3 files changed, 517 insertions(+), 47 deletions(-) diff --git a/tensorboard/uploader/auth.py b/tensorboard/uploader/auth.py index 890f0216b2..91bdcea346 100644 --- a/tensorboard/uploader/auth.py +++ b/tensorboard/uploader/auth.py @@ -16,13 +16,16 @@ """Provides authentication support for TensorBoardUploader.""" +import datetime import errno import json import os +import requests import sys +import time import webbrowser -import google_auth_oauthlib.flow +import google_auth_oauthlib.flow as auth_flows import grpc import google.auth import google.auth.transport.requests @@ -42,24 +45,70 @@ "https://www.googleapis.com/auth/userinfo.email", ) - -# The client "secret" is public by design for installed apps. See +# This config was downloaded from our GCP project at: +# console.cloud.google.com/apis/credentials?project=hosted-tensorboard-prod +# and in b/143316611. +# +# The client "secret" is considered public, as it's distributed to the devices +# where this runs. See: # https://developers.google.com/identity/protocols/OAuth2?csw=1#installed -OAUTH_CLIENT_CONFIG = """ -{ - "installed": { - "client_id": "373649185512-8v619h5kft38l4456nm2dj4ubeqsrvh6.apps.googleusercontent.com", - "project_id": "hosted-tensorboard-prod", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_secret": "pOyAuU2yq2arsM98Bw5hwYtr", - "redirect_uris": [ - "urn:ietf:wg:oauth:2.0:oob", - "http://localhost" - ] - } -} +# +# See below for the config for another accepted credential. +_INSTALLED_APP_OAUTH_CLIENT_CONFIG = """ + { + "installed":{ + "client_id":"373649185512-8v619h5kft38l4456nm2dj4ubeqsrvh6.apps.googleusercontent.com", + "project_id":"hosted-tensorboard-prod", + "auth_uri":"https://accounts.google.com/o/oauth2/auth", + "token_uri":"https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", + "client_secret":"pOyAuU2yq2arsM98Bw5hwYtr", + "redirect_uris":["http://localhost"] + } + } +""" + + +# These values can be updated with values from the well-known "discovery url": +# https://accounts.google.com/.well-known/openid-configuration +# +# See: +# developers.google.com/identity/openid-connect/openid-connect#discovery +_DEVICE_AUTH_CODE_URI = "https://oauth2.googleapis.com/device/code" + + +_LIMITED_INPUT_DEVICE_AUTH_GRANT_TYPE = ( + "urn:ietf:params:oauth:grant-type:device_code" +) + +# This config was downloaded from our GCP project at: +# console.cloud.google.com/apis/credentials?project=hosted-tensorboard-prod +# and in b/262276562. +# +# Note that some of these fields are not really useful for this flow. +# It seems the limited-input device flow is not quite as well supported yet by +# neither the Google python oauth libraries, nor the GCP console, so this config +# does not match what we would need to authenticate this way (starting from the +# fact that it seems to be a config for an "installed" app), but we do use some +# of them (e.g. client_id and client_secret), along with other values defined in +# separate constants for this auth flow. +# +# The client "secret" is considered public, as it's distributed to the devices +# where this runs. See: +# https://developers.google.com/identity/protocols/oauth2/limited-input-device +# +# See above for the config for another accepted credential. +_LIMITED_INPUT_DEVICE_OAUTH_CLIENT_CONFIG = """ + { + "installed":{ + "client_id":"373649185512-26ojik4u7dt0rdtfdmfnhpajqqh579qd.apps.googleusercontent.com", + "project_id":"hosted-tensorboard-prod", + "auth_uri":"https://accounts.google.com/o/oauth2/auth", + "token_uri":"https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", + "client_secret":"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" + } + } """ @@ -149,36 +198,158 @@ def clear(self): raise -def build_installed_app_flow(client_config): - """Returns a `CustomInstalledAppFlow` for the given config. +def authenticate_user( + force_console=False, +) -> google.oauth2.credentials.Credentials: + """Makes the user authenticate to retrieve auth credentials. + + The default behavior is to use the [installed app flow]( + http://developers.google.com/identity/protocols/oauth2/native-app), in which + a browser is started for the user to authenticate, along with a local web + server. The authentication in the browser would produce a redirect response + to `localhost` with an authorization code that would then be received by the + local web server started here. + + The two most notable cases where the default flow is not well supported are: + - When the uploader is run from a colab notebook. + - Then the uploader is run via a remote terminal (SSH). + + If any of the following is true, a different auth flow will be used: + - the flag `--auth_force_console` is set to true, or + - a browser is not available, or + - a local web server cannot be started + + In this case, a [limited-input device flow]( + http://developers.google.com/identity/protocols/oauth2/limited-input-device) + will be used, in which the user is presented with a URL and a short code + that they'd need to use to authenticate and authorize access in a separate + browser or device. The uploader will poll for access until the access is + granted or rejected, or the initiated authorization request expires. + """ + scopes = OPENID_CONNECT_SCOPES + # TODO(b/141721828): make auto-detection smarter, especially for macOS. + if not force_console and os.getenv("DISPLAY"): + try: + client_config = json.loads(_INSTALLED_APP_OAUTH_CLIENT_CONFIG) + flow = auth_flows.InstalledAppFlow.from_client_config( + client_config, scopes=scopes + ) + return flow.run_local_server(port=0) + except webbrowser.Error: + sys.stderr.write("Falling back to remote authentication flow...\n") - Args: - client_config (Mapping[str, Any]): The client configuration in the Google - client secrets format. + client_config = json.loads(_LIMITED_INPUT_DEVICE_OAUTH_CLIENT_CONFIG) + flow = _LimitedInputDeviceAuthFlow(client_config, scopes=scopes) + return flow.run() - Returns: - CustomInstalledAppFlow: the constructed flow. + +class _LimitedInputDeviceAuthFlow: + """OAuth flow to authenticate using the limited-input device flow. + + See: + http://developers.google.com/identity/protocols/oauth2/limited-input-device """ - return CustomInstalledAppFlow.from_client_config( - client_config, scopes=OPENID_CONNECT_SCOPES - ) + def __init__(self, client_config, scopes): + self._client_config = client_config + self._scopes = scopes + + def run(self) -> google.oauth2.credentials.Credentials: + device_response = self._send_device_auth_request() + prompt_message = ( + "To sign in with the TensorBoard uploader:\n" + "\n" + "1. On your computer or phone, visit:\n" + "\n" + " {url}\n" + "\n" + "2. Sign in with your Google account, then enter:\n" + "\n" + " {code}\n".format( + url=device_response["verification_url"], + code=device_response["user_code"], + ) + ) + print(prompt_message) -class CustomInstalledAppFlow(google_auth_oauthlib.flow.InstalledAppFlow): - """Customized version of the Installed App OAuth2 flow.""" + auth_response = self._poll_for_auth_token( + device_code=device_response["device_code"], + polling_interval=device_response["interval"], + expiration_seconds=device_response["expires_in"], + ) - def run(self, force_console=False): - """Run the flow using a local server if possible, otherwise the - console.""" - # TODO(b/141721828): make auto-detection smarter, especially for macOS. - if not force_console and os.getenv("DISPLAY"): - try: - return self.run_local_server(port=0) - except webbrowser.Error: - sys.stderr.write( - "Falling back to console authentication flow...\n" + return self._build_credentials(auth_response) + + def _send_device_auth_request(self): + params = { + "client_id": self._client_config["client_id"], + "scope": " ".join(self._scopes), + } + r = requests.post(_DEVICE_AUTH_CODE_URI, data=params).json() + if "device_code" not in r: + raise RuntimeError( + "There was an error while contacting Google's authorization " + "server. Please try again later." + ) + return r + + def _poll_for_auth_token( + self, device_code: str, polling_interval: int, expiration_seconds: int + ): + token_uri = self._client_config["token_uri"] + params = { + "client_id": self._client_config["client_id"], + "client_secret": self._client_config["client_secret"], + "device_code": device_code, + "grant_type": _LIMITED_INPUT_DEVICE_AUTH_GRANT_TYPE, + } + expiration_time = time.time() + expiration_seconds + # Error cases documented in + # https://developers.google.com/identity/protocols/oauth2/limited-input-device#step-6:-handle-responses-to-polling-requests + while time.time() < expiration_time: + resp = requests.post(token_uri, data=params) + r = resp.json() + if "access_token" in r: + return r + elif "error" in r and r["error"] == "authorization_pending": + # Not really an error. This is the expected response when the + # user has not yet granted access to this app. + time.sleep(polling_interval) + elif "error" in r and r["error"] == "slow_down": + # We should be polling at the specified interval from the + # previous response, so this error would be unexpected. + # However, it is just a temporary/retryable error, so we can + # poll a bit more slowly. + polling_interval = int(polling_interval * 1.5) + time.sleep(polling_interval) + elif "error" in r and r["error"] == "access_denied": + raise PermissionError("Access was denied by user.") + elif resp.status_code in {400, 401}: + raise ValueError("There must be an error in the request.") + else: + raise RuntimeError( + "An unexpected error occurred while waiting for " + "authorization." ) - return self.run_console() + raise TimeoutError("Timed out waiting for authorization.") + + def _build_credentials( + self, auth_response + ) -> google.oauth2.credentials.Credentials: + + expiration_datetime = datetime.datetime.utcfromtimestamp( + int(time.time()) + auth_response["expires_in"] + ) + return google.oauth2.credentials.Credentials( + auth_response["access_token"], + refresh_token=auth_response["refresh_token"], + id_token=auth_response["id_token"], + token_uri=self._client_config["token_uri"], + client_id=self._client_config["client_id"], + client_secret=self._client_config["client_secret"], + scopes=self._scopes, + expiry=expiration_datetime, + ) class IdTokenAuthMetadataPlugin(grpc.AuthMetadataPlugin): diff --git a/tensorboard/uploader/auth_test.py b/tensorboard/uploader/auth_test.py index aa4665ade2..e21de43807 100644 --- a/tensorboard/uploader/auth_test.py +++ b/tensorboard/uploader/auth_test.py @@ -15,12 +15,18 @@ # Lint as: python3 """Tests for tensorboard.uploader.auth.""" - +from datetime import datetime import json import os +import webbrowser +import requests +import time +from typing import Dict +from unittest import mock +import google_auth_oauthlib.flow as auth_flows import google.auth.credentials -import google.oauth2.credentials +from google.oauth2.credentials import Credentials from tensorboard.uploader import auth from tensorboard import test as tb_test @@ -149,5 +155,299 @@ def test_read_invalid_json_file(self): store.read_credentials() +class FakeInstalledAppFlow: + """A minimal fake for the InstalledApp flow with the function we call. + + This is a fake of a publicly available class that should already be tested, + so we mostly want to test high-level interactions with it. + """ + + def __init__(self, credentials=None, raiseError=False): + if not credentials and not raiseError: + ValueError("credentials cannot be None when raiseError is False") + if credentials and raiseError: + ValueError("credentials must be None when raiseError is True") + self.run_local_server_was_called = False + self._creds = credentials + self._raiseError = raiseError + + def run_local_server(self, port=8080): + self.run_local_server_was_called = True + if self._raiseError: + raise webbrowser.Error() + return self._creds + + +class AuthenticateUserTest(tb_test.TestCase): + def setUp(self): + super().setUp() + # Used to estimate if a browser is available in this env. + self.os_env_display_fn = self.enter_context( + mock.patch.object(os, "getenv") + ) + + self.mocked_installed_auth_flow_creator_fn = self.enter_context( + mock.patch.object(auth_flows.InstalledAppFlow, "from_client_config") + ) + + self.mocked_device_auth_flow = self.enter_context( + mock.patch.object( + auth, "_LimitedInputDeviceAuthFlow", autospec=True + ) + ) + + def test_uses_installed_app_flow_when_has_display(self): + self.os_env_display_fn.return_value = "some_display" + + fake_auth_flow = FakeInstalledAppFlow( + credentials=Credentials("fake_access_token") + ) + self.mocked_installed_auth_flow_creator_fn.return_value = fake_auth_flow + + auth.authenticate_user() + + self.mocked_installed_auth_flow_creator_fn.assert_called_once() + self.assertTrue(fake_auth_flow.run_local_server_was_called) + self.mocked_device_auth_flow.assert_not_called() + + def test_uses_device_flow_when_no_display(self): + self.os_env_display_fn.return_value = None + + auth.authenticate_user() + + self.mocked_installed_auth_flow_creator_fn.assert_not_called() + self.mocked_device_auth_flow.assert_called_once() + self.mocked_device_auth_flow.return_value.run.assert_called_once() + + def test_falls_back_to_device_flow_when_installed_app_flow_gets_error(self): + fake_auth_flow = FakeInstalledAppFlow(raiseError=True) + self.mocked_installed_auth_flow_creator_fn.return_value = fake_auth_flow + self.os_env_display_fn.return_value = "some_display" + + auth.authenticate_user() + + # "installed app" flow was instantiated and ran, which raised an + # exception, so the other flow also ran. + self.mocked_installed_auth_flow_creator_fn.assert_called_once() + self.assertTrue(fake_auth_flow.run_local_server_was_called) + self.mocked_device_auth_flow.assert_called_once() + self.mocked_device_auth_flow.return_value.run.assert_called_once() + + def test_uses_device_flow_when_has_console_override(self): + auth.authenticate_user(force_console=True) + self.mocked_installed_auth_flow_creator_fn.assert_not_called() + self.mocked_device_auth_flow.assert_called_once() + self.mocked_device_auth_flow.return_value.run.assert_called_once() + + +class FakeHttpResponse: + """A fake implementation of the response from the requests library.""" + + def __init__(self, data: Dict, status: int = 200): + self.status_code = status + self._data = data + + def json(self): + return self._data + + +class LimitedInputDeviceAuthFlowTest(tb_test.TestCase): + _OAUTH_CONFIG = { + "client_id": "console_client_id", + "token_uri": "https://google.com/token", + "client_secret": "console_client_secret", + } + + _SCOPES = ["email", "openid"] + + _DEVICE_RESPONSE = FakeHttpResponse( + { + "device_code": "resp_device_code", + "verification_url": "auth.google.com/device", + "user_code": "resp_user_code", + "interval": 5, + "expires_in": 300, + } + ) + + _AUTH_GRANTED_RESPONSE = FakeHttpResponse( + { + "access_token": "some_access_token", + "refresh_token": "some_refresh_token", + "id_token": "some_id_token", + "expires_in": 3600, # seconds + } + ) + + def setUp(self): + super().setUp() + + self.mocked_time = self.enter_context( + mock.patch.object( + time, + "time", + # Timestamps from a fake clock. + # The values don't matter in most tests. + side_effect=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ) + ) + + self.mocked_sleep = self.enter_context( + mock.patch.object(time, "sleep", autospec=True) + ) + + self.mocked_post = self.enter_context( + mock.patch.object(requests, "post", autospec=True) + ) + + self.flow = auth._LimitedInputDeviceAuthFlow( + self._OAUTH_CONFIG, + self._SCOPES, + ) + + def test_raises_when_device_request_fails(self): + self.mocked_post.return_value = FakeHttpResponse( + {"error": "quota exceeded"}, status=403 + ) + + expected_error_msg = ( + "There was an error while contacting Google's " + "authorization server. Please try again later." + ) + with self.assertRaisesRegex(RuntimeError, expected_error_msg): + self.flow.run() + + def test_keeps_polling_when_auth_pending_response_is_received(self): + auth_pending_response = FakeHttpResponse( + {"error": "authorization_pending"}, status=428 + ) + self.mocked_post.side_effect = [ + self._DEVICE_RESPONSE, + auth_pending_response, + self._AUTH_GRANTED_RESPONSE, + ] + + self.flow.run() + + expected_device_params = { + "client_id": "console_client_id", + "scope": "email openid", + } + expected_polling_params = { + "client_id": "console_client_id", + "client_secret": "console_client_secret", + "device_code": "resp_device_code", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + device_uri = auth._DEVICE_AUTH_CODE_URI + token_uri = "https://google.com/token" + # One device code request, then two polling calls: + # the first poll returned auth_pending, the second one returned success. + expected_post_requests = [ + mock.call(device_uri, data=expected_device_params), + mock.call(token_uri, data=expected_polling_params), + mock.call(token_uri, data=expected_polling_params), + ] + self.assertSequenceEqual( + expected_post_requests, self.mocked_post.call_args_list + ) + # `interval` in _DEVICE_RESPONSE is 5 + self.mocked_sleep.assert_called_once_with(5) + + def test_returns_credentials_when_access_is_granted(self): + self.mocked_post.side_effect = [ + self._DEVICE_RESPONSE, + self._AUTH_GRANTED_RESPONSE, + ] + # Based on these mocked responses, time.time() is called 3 times: + # 1. To generate an expiration time for polling + # 2. While polling to check if we reached the expiration time + # 3. To calculate an expiration time for the credentials (useful below) + now_timestamp = 3 + self.mocked_time.side_effect = [1, 2, now_timestamp] + + creds = self.flow.run() + + credentials_ttl = self._AUTH_GRANTED_RESPONSE.json()["expires_in"] + expected_expiration_timestamp = datetime.utcfromtimestamp( + now_timestamp + credentials_ttl + ) + + expected_credentials = Credentials( + "some_access_token", + refresh_token="some_refresh_token", + id_token="some_id_token", + token_uri="https://google.com/token", + client_id="console_client_id", + client_secret="console_client_secret", + scopes=self._SCOPES, + expiry=expected_expiration_timestamp, + ) + self.assertEqual(creds.to_json(), expected_credentials.to_json()) + + def test_raises_when_access_is_denied(self): + access_denied_response = FakeHttpResponse( + {"error": "access_denied"}, 403 + ) + self.mocked_post.side_effect = [ + self._DEVICE_RESPONSE, + access_denied_response, + ] + + expected_error_msg = "Access was denied by user." + with self.assertRaisesRegex(PermissionError, expected_error_msg): + self.flow.run() + + def test_waits_longer_to_poll_when_slow_down_response_is_received(self): + slow_down_response = FakeHttpResponse({"error": "slow_down"}) + self.mocked_post.side_effect = [ + self._DEVICE_RESPONSE, + slow_down_response, + self._AUTH_GRANTED_RESPONSE, + ] + + self.flow.run() + + # `interval`` in _DEVICE_RESPONSE is 5, which is multiplied by 1.5 and + # parsed to int() when a slow_down error is received while polling. + sleep_time = 7 + self.mocked_sleep.assert_called_once_with(sleep_time) + + def test_raises_when_bad_request_response_is_received(self): + bad_request_response = FakeHttpResponse({"error": "bad_request"}, 401) + self.mocked_post.side_effect = [ + self._DEVICE_RESPONSE, + bad_request_response, + ] + + expected_error_msg = "There must be an error in the request." + with self.assertRaisesRegex(ValueError, expected_error_msg): + self.flow.run() + + def test_raises_when_access_is_not_granted_by_time_out_period(self): + self.mocked_post.return_value = self._DEVICE_RESPONSE + # Not very realistic, but before starting to poll, the time increased + # more than the expiration time from the _DEVICE_RESPONSE. + self.mocked_time.side_effect = [1, 5000] + + with self.assertRaisesRegex( + TimeoutError, "Timed out waiting for authorization." + ): + self.flow.run() + + def test_raises_when_unexpected_error_is_received(self): + unexpected_response = FakeHttpResponse({"error": "unexpected 500"}, 500) + self.mocked_post.side_effect = [ + self._DEVICE_RESPONSE, + unexpected_response, + ] + + expected_error_msg = ( + "An unexpected error occurred while waiting for authorization." + ) + with self.assertRaisesRegex(RuntimeError, expected_error_msg): + self.flow.run() + + if __name__ == "__main__": tb_test.main() diff --git a/tensorboard/uploader/uploader_subcommand.py b/tensorboard/uploader/uploader_subcommand.py index a956f05a36..64ec292bfe 100644 --- a/tensorboard/uploader/uploader_subcommand.py +++ b/tensorboard/uploader/uploader_subcommand.py @@ -16,7 +16,6 @@ import abc -import json import os import sys import textwrap @@ -92,9 +91,9 @@ def _run(flags, experiment_url_callback=None): credentials = store.read_credentials() if not credentials: _prompt_for_user_ack(intent) - client_config = json.loads(auth.OAUTH_CLIENT_CONFIG) - flow = auth.build_installed_app_flow(client_config) - credentials = flow.run(force_console=flags.auth_force_console) + credentials = auth.authenticate_user( + force_console=flags.auth_force_console + ) sys.stderr.write("\n") # Extra newline after auth flow messages. store.write_credentials(credentials) From 99060fa3dfdb6d02cb4b0250cec0202bdf53e223 Mon Sep 17 00:00:00 2001 From: Adrian RC Date: Thu, 12 Jan 2023 03:10:31 +0000 Subject: [PATCH 4/5] Add 2.11.1 relnotes to RELEASE.md --- RELEASE.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/RELEASE.md b/RELEASE.md index d3de1b5d21..8cc7cc3393 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,3 +1,11 @@ +# Release 2.11.1 + +## Bug Fixes + +- Prevent regression in TensorBoard.dev uploader authentication by replacing deprecated OOB auth flow with limited-input device flow. (#6107) + - See [deprecation announcement](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html). +- Fix compatibility with numpy 1.24.0 by removing deprecated type aliases (#6117, #6140) + # Release 2.11.0 The 2.11 minor series tracks TensorFlow 2.11. From eddcf57d0dc2d6600f0d42229729ba31cedacddc Mon Sep 17 00:00:00 2001 From: Adrian RC Date: Thu, 12 Jan 2023 03:11:18 +0000 Subject: [PATCH 5/5] TensorBoard 2.11.1 --- tensorboard/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tensorboard/version.py b/tensorboard/version.py index 3116445e48..870124ae10 100644 --- a/tensorboard/version.py +++ b/tensorboard/version.py @@ -15,4 +15,4 @@ """Contains the version string.""" -VERSION = "2.11.0" +VERSION = "2.11.1"