From c8eaffae9edc82044956ea995a0ecea3061c81fa Mon Sep 17 00:00:00 2001 From: aalexand Date: Fri, 6 Mar 2015 10:12:48 -0800 Subject: [PATCH] Adds support for Google Developer Shell session credentials --- docs/source/oauth2client.rst | 8 +++ oauth2client/devshell.py | 136 +++++++++++++++++++++++++++++++++++ tests/test_devshell.py | 130 +++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 oauth2client/devshell.py create mode 100644 tests/test_devshell.py diff --git a/docs/source/oauth2client.rst b/docs/source/oauth2client.rst index 3888bd9..5f597d8 100644 --- a/docs/source/oauth2client.rst +++ b/docs/source/oauth2client.rst @@ -36,6 +36,14 @@ oauth2client.crypt module :undoc-members: :show-inheritance: +oauth2client.devshell module +---------------------------- + +.. automodule:: oauth2client.devshell + :members: + :undoc-members: + :show-inheritance: + oauth2client.django_orm module ------------------------------ diff --git a/oauth2client/devshell.py b/oauth2client/devshell.py new file mode 100644 index 0000000..a33de87 --- /dev/null +++ b/oauth2client/devshell.py @@ -0,0 +1,136 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OAuth 2.0 utitilies for Google Developer Shell environment.""" + +import json +import os + +from oauth2client import client + + +DEVSHELL_ENV = 'DEVSHELL_CLIENT_PORT' + + +class Error(Exception): + """Errors for this module.""" + pass + + +class CommunicationError(Error): + """Errors for communication with the Developer Shell server.""" + + +class NoDevshellServer(Error): + """Error when no Developer Shell server can be contacted.""" + + +# The request for credential information to the Developer Shell client socket is +# always an empty PBLite-formatted JSON object, so just define it as a constant. +CREDENTIAL_INFO_REQUEST_JSON = '[]' + + +class CredentialInfoResponse(object): + """Credential information response from Developer Shell server. + + The credential information response from Developer Shell socket is a + PBLite-formatted JSON array with fields encoded by their index in the array: + * Index 0 - user email + * Index 1 - default project ID. None if the project context is not known. + * Index 2 - OAuth2 access token. None if there is no valid auth context. + """ + + def __init__(self, json_string): + """Initialize the response data from JSON PBLite array.""" + pbl = json.loads(json_string) + if not isinstance(pbl, list): + raise ValueError('Not a list: ' + str(pbl)) + pbl_len = len(pbl) + self.user_email = pbl[0] if pbl_len > 0 else None + self.project_id = pbl[1] if pbl_len > 1 else None + self.access_token = pbl[2] if pbl_len > 2 else None + + +def _SendRecv(): + """Communicate with the Developer Shell server socket.""" + + port = int(os.getenv(DEVSHELL_ENV, 0)) + if port == 0: + raise NoDevshellServer() + + import socket + + sock = socket.socket() + sock.connect(('localhost', port)) + + data = CREDENTIAL_INFO_REQUEST_JSON + msg = '%s\n%s' % (len(data), data) + sock.sendall(msg.encode()) + + header = sock.recv(6).decode() + if '\n' not in header: + raise CommunicationError('saw no newline in the first 6 bytes') + len_str, json_str = header.split('\n', 1) + to_read = int(len_str) - len(json_str) + if to_read > 0: + json_str += sock.recv(to_read, socket.MSG_WAITALL).decode() + + return CredentialInfoResponse(json_str) + + +class DevshellCredentials(client.GoogleCredentials): + """Credentials object for Google Developer Shell environment. + + This object will allow a Google Developer Shell session to identify its user + to Google and other OAuth 2.0 servers that can verify assertions. It can be + used for the purpose of accessing data stored under the user account. + + This credential does not require a flow to instantiate because it represents + a two legged flow, and therefore has all of the required information to + generate and refresh its own access tokens. + """ + + def __init__(self, user_agent=None): + super(DevshellCredentials, self).__init__( + None, # access_token, initialized below + None, # client_id + None, # client_secret + None, # refresh_token + None, # token_expiry + None, # token_uri + user_agent) + self._refresh(None) + + def _refresh(self, http_request): + self.devshell_response = _SendRecv() + self.access_token = self.devshell_response.access_token + + @property + def user_email(self): + return self.devshell_response.user_email + + @property + def project_id(self): + return self.devshell_response.project_id + + @classmethod + def from_json(cls, json_data): + raise NotImplementedError( + 'Cannot load Developer Shell credentials from JSON.') + + @property + def serialization_data(self): + raise NotImplementedError( + 'Cannot serialize Developer Shell credentials.') + diff --git a/tests/test_devshell.py b/tests/test_devshell.py new file mode 100644 index 0000000..7a13ab4 --- /dev/null +++ b/tests/test_devshell.py @@ -0,0 +1,130 @@ +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Tests for oauth2client.devshell.""" + +import os +import socket +import threading +import unittest + +from oauth2client.client import save_to_well_known_file +from oauth2client.devshell import _SendRecv +from oauth2client.devshell import CREDENTIAL_INFO_REQUEST_JSON +from oauth2client.devshell import DEVSHELL_ENV +from oauth2client.devshell import DevshellCredentials +from oauth2client.devshell import NoDevshellServer + + +class _AuthReferenceServer(threading.Thread): + + def __init__(self, response=None): + super(_AuthReferenceServer, self).__init__(None) + self.response = (response or + '["joe@example.com", "fooproj", "sometoken"]') + + def __enter__(self): + self.start_server() + + def start_server(self): + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.bind(('localhost', 0)) + port = self._socket.getsockname()[1] + os.environ[DEVSHELL_ENV] = str(port) + self._socket.listen(0) + self.start() + return self + + def __exit__(self, e_type, value, traceback): + self.stop_server() + + def stop_server(self): + del os.environ[DEVSHELL_ENV] + self._socket.close() + + def run(self): + s = None + try: + self._socket.settimeout(15) + s, unused_addr = self._socket.accept() + resp_buffer = '' + resp_1 = s.recv(6).decode() + if '\n' not in resp_1: + raise Exception('invalid request data') + nstr, extra = resp_1.split('\n', 1) + resp_buffer = extra + n = int(nstr) + to_read = n-len(extra) + if to_read > 0: + resp_buffer += s.recv(to_read, socket.MSG_WAITALL) + if resp_buffer != CREDENTIAL_INFO_REQUEST_JSON: + raise Exception('bad request') + l = len(self.response) + s.sendall(('%d\n%s' % (l, self.response)).encode()) + finally: + if s: + s.close() + + +class DevshellCredentialsTests(unittest.TestCase): + + def test_signals_no_server(self): + self.assertRaises(NoDevshellServer, DevshellCredentials) + + def test_request_response(self): + with _AuthReferenceServer(): + response = _SendRecv() + self.assertEqual(response.user_email, 'joe@example.com') + self.assertEqual(response.project_id, 'fooproj') + self.assertEqual(response.access_token, 'sometoken') + + def test_no_refresh_token(self): + with _AuthReferenceServer(): + creds = DevshellCredentials() + self.assertEquals(None, creds.refresh_token) + + def test_reads_credentials(self): + with _AuthReferenceServer(): + creds = DevshellCredentials() + self.assertEqual('joe@example.com', creds.user_email) + self.assertEqual('fooproj', creds.project_id) + self.assertEqual('sometoken', creds.access_token) + + def test_handles_skipped_fields(self): + with _AuthReferenceServer('["joe@example.com"]'): + creds = DevshellCredentials() + self.assertEqual('joe@example.com', creds.user_email) + self.assertEqual(None, creds.project_id) + self.assertEqual(None, creds.access_token) + + def test_handles_tiny_response(self): + with _AuthReferenceServer('[]'): + creds = DevshellCredentials() + self.assertEqual(None, creds.user_email) + self.assertEqual(None, creds.project_id) + self.assertEqual(None, creds.access_token) + + def test_handles_ignores_extra_fields(self): + with _AuthReferenceServer( + '["joe@example.com", "fooproj", "sometoken", "extra"]'): + creds = DevshellCredentials() + self.assertEqual('joe@example.com', creds.user_email) + self.assertEqual('fooproj', creds.project_id) + self.assertEqual('sometoken', creds.access_token) + + def test_refuses_to_save_to_well_known_file(self): + with _AuthReferenceServer(): + creds = DevshellCredentials() + self.assertRaises(NotImplementedError, save_to_well_known_file, creds)