diff --git a/oauth2client/_pkce.py b/oauth2client/_pkce.py new file mode 100644 index 0000000..8f22f57 --- /dev/null +++ b/oauth2client/_pkce.py @@ -0,0 +1,65 @@ +# Copyright 2016 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. + +""" +Utility functions for implementing Proof Key for Code Exchange (PKCE) by OAuth +Public Clients + +See RFC7636. +""" + +import base64 +import hashlib +import os + + +def code_verifier(n_bytes=64): + """ + Generates a 'code_verifier' as described in section 4.1 of RFC 7636. + + This is a 'high-entropy cryptographic random string' that will be + impractical for an attacker to guess. + + Args: + n_bytes: integer between 31 and 96, inclusive. default: 64 + number of bytes of entropy to include in verifier. + + Returns: + Bytestring, representing urlsafe base64-encoded random data. + """ + verifier = base64.urlsafe_b64encode(os.urandom(n_bytes)) + # https://tools.ietf.org/html/rfc7636#section-4.1 + # minimum length of 43 characters and a maximum length of 128 characters. + if len(verifier) < 43: + raise ValueError("Verifier too short. n_bytes must be > 30.") + elif len(verifier) > 128: + raise ValueError("Verifier too long. n_bytes must be < 97.") + else: + return verifier + + +def code_challenge(verifier): + """ + Creates a 'code_challenge' as described in section 4.2 of RFC 7636 + by taking the sha256 hash of the verifier and then urlsafe + base64-encoding it. + + Args: + verifier: bytestring, representing a code_verifier as generated by + code_verifier(). + + Returns: + Bytestring, representing a urlsafe base64-encoded sha256 hash digest. + """ + return base64.urlsafe_b64encode(hashlib.sha256(verifier).digest()) diff --git a/oauth2client/client.py b/oauth2client/client.py index d92ec82..2d1f6e8 100644 --- a/oauth2client/client.py +++ b/oauth2client/client.py @@ -34,6 +34,7 @@ from six.moves import urllib import oauth2client from oauth2client import _helpers +from oauth2client import _pkce from oauth2client import clientsecrets from oauth2client import transport @@ -1632,7 +1633,9 @@ def credentials_from_code(client_id, client_secret, scope, code, auth_uri=oauth2client.GOOGLE_AUTH_URI, revoke_uri=oauth2client.GOOGLE_REVOKE_URI, device_uri=oauth2client.GOOGLE_DEVICE_URI, - token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI): + token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, + pkce=False, + code_verifier=None): """Exchanges an authorization code for an OAuth2Credentials object. Args: @@ -1656,6 +1659,15 @@ def credentials_from_code(client_id, client_secret, scope, code, device_uri: string, URI for device authorization endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. Returns: An OAuth2Credentials object. @@ -1666,10 +1678,14 @@ def credentials_from_code(client_id, client_secret, scope, code, """ flow = OAuth2WebServerFlow(client_id, client_secret, scope, redirect_uri=redirect_uri, - user_agent=user_agent, auth_uri=auth_uri, - token_uri=token_uri, revoke_uri=revoke_uri, + user_agent=user_agent, + auth_uri=auth_uri, + token_uri=token_uri, + revoke_uri=revoke_uri, device_uri=device_uri, - token_info_uri=token_info_uri) + token_info_uri=token_info_uri, + pkce=pkce, + code_verifier=code_verifier) credentials = flow.step2_exchange(code, http=http) return credentials @@ -1704,6 +1720,15 @@ def credentials_from_clientsecrets_and_code(filename, scope, code, cache: An optional cache service client that implements get() and set() methods. See clientsecrets.loadfile() for details. device_uri: string, OAuth 2.0 device authorization endpoint + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. Returns: An OAuth2Credentials object. @@ -1807,6 +1832,8 @@ class OAuth2WebServerFlow(Flow): device_uri=oauth2client.GOOGLE_DEVICE_URI, token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, authorization_header=None, + pkce=False, + code_verifier=None, **kwargs): """Constructor for OAuth2WebServerFlow. @@ -1844,6 +1871,15 @@ class OAuth2WebServerFlow(Flow): require a client to authenticate using a header value instead of passing client_secret in the POST body. + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. **kwargs: dict, The keyword arguments are all optional and required parameters for the OAuth calls. """ @@ -1863,6 +1899,8 @@ class OAuth2WebServerFlow(Flow): self.device_uri = device_uri self.token_info_uri = token_info_uri self.authorization_header = authorization_header + self._pkce = pkce + self.code_verifier = code_verifier self.params = _oauth2_web_server_flow_params(kwargs) @_helpers.positional(1) @@ -1903,6 +1941,13 @@ class OAuth2WebServerFlow(Flow): query_params['state'] = state if self.login_hint is not None: query_params['login_hint'] = self.login_hint + if self._pkce: + if not self.code_verifier: + self.code_verifier = _pkce.code_verifier() + challenge = _pkce.code_challenge(self.code_verifier) + query_params['code_challenge'] = challenge + query_params['code_challenge_method'] = 'S256' + query_params.update(self.params) return _update_query_params(self.auth_uri, query_params) @@ -1997,6 +2042,8 @@ class OAuth2WebServerFlow(Flow): } if self.client_secret is not None: post_data['client_secret'] = self.client_secret + if self._pkce: + post_data['code_verifier'] = self.code_verifier if device_flow_info is not None: post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0' else: @@ -2054,7 +2101,7 @@ class OAuth2WebServerFlow(Flow): @_helpers.positional(2) def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, cache=None, login_hint=None, - device_uri=None): + device_uri=None, pkce=None, code_verifier=None): """Create a Flow from a clientsecrets file. Will create the right kind of Flow based on the contents of the @@ -2103,10 +2150,11 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, 'login_hint': login_hint, } revoke_uri = client_info.get('revoke_uri') - if revoke_uri is not None: - constructor_kwargs['revoke_uri'] = revoke_uri - if device_uri is not None: - constructor_kwargs['device_uri'] = device_uri + optional = ('revoke_uri', 'device_uri', 'pkce', 'code_verifier') + for param in optional: + if locals()[param] is not None: + constructor_kwargs[param] = locals()[param] + return OAuth2WebServerFlow( client_info['client_id'], client_info['client_secret'], scope, **constructor_kwargs) diff --git a/tests/test__pkce.py b/tests/test__pkce.py new file mode 100644 index 0000000..492463e --- /dev/null +++ b/tests/test__pkce.py @@ -0,0 +1,53 @@ +# Copyright 2016 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. + +import mock +import unittest2 + +from oauth2client import _pkce + + +class PKCETests(unittest2.TestCase): + + @mock.patch('oauth2client._pkce.os.urandom') + def test_verifier(self, fake_urandom): + canned_randomness = ( + b'\x98\x10D7\xf3\xb7\xaa\xfc\xdd\xd3M\xe2' + b'\xa3,\x06\xa0\xb0\xa9\xb4\x8f\xcb\xd0' + b'\xf5\x86N2p\x8c]!W\x9a\xed54\x99\x9d' + b'\x8dv\\\xa7/\x81\xf3J\x98\xc3\x90\xee' + b'\xb0\x8c\xb7Zc#\x05M0O\x08\xda\t\x1f\x07' + ) + fake_urandom.return_value = canned_randomness + expected = ( + b'mBBEN_O3qvzd003ioywGoLCptI_L0PWGTjJwjF0hV5rt' + b'NTSZnY12XKcvgfNKmMOQ7rCMt1pjIwVNME8I2gkfBw==' + ) + result = _pkce.code_verifier() + self.assertEqual(result, expected) + + def test_verifier_too_long(self): + with self.assertRaises(ValueError) as caught: + _pkce.code_verifier(97) + self.assertIn("too long", str(caught.exception)) + + def test_verifier_too_short(self): + with self.assertRaises(ValueError) as caught: + _pkce.code_verifier(30) + self.assertIn("too short", str(caught.exception)) + + def test_challenge(self): + result = _pkce.code_challenge(b'SOME_VERIFIER') + expected = b'6xJCQsjTtS3zjUwd8_ZqH0SyviGHnp5PsHXWKOCqDuI=' + self.assertEqual(result, expected) diff --git a/tests/test_client.py b/tests/test_client.py index 3d41da1..b00ae29 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1688,6 +1688,23 @@ class OAuth2WebServerFlowTest(unittest.TestCase): # Check stubs. self.assertEqual(logger.warning.call_count, 1) + @mock.patch('oauth2client.client._pkce.code_challenge') + @mock.patch('oauth2client.client._pkce.code_verifier') + def test_step1_get_authorize_url_pkce(self, fake_verifier, fake_challenge): + fake_verifier.return_value = b'__TEST_VERIFIER__' + fake_challenge.return_value = b'__TEST_CHALLENGE__' + flow = client.OAuth2WebServerFlow( + 'client_id+1', + scope='foo', + redirect_uri='http://example.com', + pkce=True) + auth_url = urllib.parse.urlparse(flow.step1_get_authorize_url()) + self.assertEqual(flow.code_verifier, b'__TEST_VERIFIER__') + results = dict(urllib.parse.parse_qsl(auth_url.query)) + self.assertEqual(results['code_challenge'], '__TEST_CHALLENGE__') + self.assertEqual(results['code_challenge_method'], 'S256') + fake_challenge.assert_called_with(b'__TEST_VERIFIER__') + def test_step1_get_authorize_url_without_redirect(self): flow = client.OAuth2WebServerFlow('client_id+1', scope='foo', redirect_uri=None) @@ -1933,6 +1950,23 @@ class OAuth2WebServerFlowTest(unittest.TestCase): http.requests[0]['body'])['code'][0] self.assertEqual(code, request_code) + def test_exchange_with_pkce(self): + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b'access_token=SlAV32hkKG'), + ]) + flow = client.OAuth2WebServerFlow( + 'client_id+1', + scope='foo', + redirect_uri='http://example.com', + pkce=True, + code_verifier=b'__TEST_VERIFIER__' + ) + flow.step2_exchange(code='some random code', http=http) + + self.assertEqual(len(http.requests), 1) + test_request = http.requests[0] + self.assertIn('code_verifier=__TEST_VERIFIER__', test_request['body']) + def test_exchange_using_authorization_header(self): auth_header = 'Basic Y2xpZW50X2lkKzE6c2Vjexc_managerV0KzE=', flow = client.OAuth2WebServerFlow(