Add support for RFC7636 PKCE (#588)

RFC7636 extends OAuth2 to include a challenge-response protocol
called "Proof Key for Code Exchange" (PKCE) in order to mitigate
attacks in situations where clients that cannot protect a client
secret (e.g.installed desktop applications).
This commit is contained in:
Brendan McCollam
2016-08-11 20:28:19 +01:00
committed by Jon Wayne Parrott
parent 619dff806e
commit 3614fd147a
4 changed files with 209 additions and 9 deletions

65
oauth2client/_pkce.py Normal file
View File

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

View File

@@ -34,6 +34,7 @@ from six.moves import urllib
import oauth2client import oauth2client
from oauth2client import _helpers from oauth2client import _helpers
from oauth2client import _pkce
from oauth2client import clientsecrets from oauth2client import clientsecrets
from oauth2client import transport from oauth2client import transport
@@ -1632,7 +1633,9 @@ def credentials_from_code(client_id, client_secret, scope, code,
auth_uri=oauth2client.GOOGLE_AUTH_URI, auth_uri=oauth2client.GOOGLE_AUTH_URI,
revoke_uri=oauth2client.GOOGLE_REVOKE_URI, revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
device_uri=oauth2client.GOOGLE_DEVICE_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. """Exchanges an authorization code for an OAuth2Credentials object.
Args: Args:
@@ -1656,6 +1659,15 @@ def credentials_from_code(client_id, client_secret, scope, code,
device_uri: string, URI for device authorization endpoint. For device_uri: string, URI for device authorization endpoint. For
convenience defaults to Google's endpoints but any OAuth convenience defaults to Google's endpoints but any OAuth
2.0 provider can be used. 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: Returns:
An OAuth2Credentials object. An OAuth2Credentials object.
@@ -1666,10 +1678,14 @@ def credentials_from_code(client_id, client_secret, scope, code,
""" """
flow = OAuth2WebServerFlow(client_id, client_secret, scope, flow = OAuth2WebServerFlow(client_id, client_secret, scope,
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
user_agent=user_agent, auth_uri=auth_uri, user_agent=user_agent,
token_uri=token_uri, revoke_uri=revoke_uri, auth_uri=auth_uri,
token_uri=token_uri,
revoke_uri=revoke_uri,
device_uri=device_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) credentials = flow.step2_exchange(code, http=http)
return credentials 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() cache: An optional cache service client that implements get() and set()
methods. See clientsecrets.loadfile() for details. methods. See clientsecrets.loadfile() for details.
device_uri: string, OAuth 2.0 device authorization endpoint 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: Returns:
An OAuth2Credentials object. An OAuth2Credentials object.
@@ -1807,6 +1832,8 @@ class OAuth2WebServerFlow(Flow):
device_uri=oauth2client.GOOGLE_DEVICE_URI, device_uri=oauth2client.GOOGLE_DEVICE_URI,
token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI,
authorization_header=None, authorization_header=None,
pkce=False,
code_verifier=None,
**kwargs): **kwargs):
"""Constructor for OAuth2WebServerFlow. """Constructor for OAuth2WebServerFlow.
@@ -1844,6 +1871,15 @@ class OAuth2WebServerFlow(Flow):
require a client to authenticate using a require a client to authenticate using a
header value instead of passing client_secret header value instead of passing client_secret
in the POST body. 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 **kwargs: dict, The keyword arguments are all optional and required
parameters for the OAuth calls. parameters for the OAuth calls.
""" """
@@ -1863,6 +1899,8 @@ class OAuth2WebServerFlow(Flow):
self.device_uri = device_uri self.device_uri = device_uri
self.token_info_uri = token_info_uri self.token_info_uri = token_info_uri
self.authorization_header = authorization_header self.authorization_header = authorization_header
self._pkce = pkce
self.code_verifier = code_verifier
self.params = _oauth2_web_server_flow_params(kwargs) self.params = _oauth2_web_server_flow_params(kwargs)
@_helpers.positional(1) @_helpers.positional(1)
@@ -1903,6 +1941,13 @@ class OAuth2WebServerFlow(Flow):
query_params['state'] = state query_params['state'] = state
if self.login_hint is not None: if self.login_hint is not None:
query_params['login_hint'] = self.login_hint 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) query_params.update(self.params)
return _update_query_params(self.auth_uri, query_params) return _update_query_params(self.auth_uri, query_params)
@@ -1997,6 +2042,8 @@ class OAuth2WebServerFlow(Flow):
} }
if self.client_secret is not None: if self.client_secret is not None:
post_data['client_secret'] = self.client_secret post_data['client_secret'] = self.client_secret
if self._pkce:
post_data['code_verifier'] = self.code_verifier
if device_flow_info is not None: if device_flow_info is not None:
post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0' post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0'
else: else:
@@ -2054,7 +2101,7 @@ class OAuth2WebServerFlow(Flow):
@_helpers.positional(2) @_helpers.positional(2)
def flow_from_clientsecrets(filename, scope, redirect_uri=None, def flow_from_clientsecrets(filename, scope, redirect_uri=None,
message=None, cache=None, login_hint=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. """Create a Flow from a clientsecrets file.
Will create the right kind of Flow based on the contents of the 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, 'login_hint': login_hint,
} }
revoke_uri = client_info.get('revoke_uri') revoke_uri = client_info.get('revoke_uri')
if revoke_uri is not None: optional = ('revoke_uri', 'device_uri', 'pkce', 'code_verifier')
constructor_kwargs['revoke_uri'] = revoke_uri for param in optional:
if device_uri is not None: if locals()[param] is not None:
constructor_kwargs['device_uri'] = device_uri constructor_kwargs[param] = locals()[param]
return OAuth2WebServerFlow( return OAuth2WebServerFlow(
client_info['client_id'], client_info['client_secret'], client_info['client_id'], client_info['client_secret'],
scope, **constructor_kwargs) scope, **constructor_kwargs)

53
tests/test__pkce.py Normal file
View File

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

View File

@@ -1688,6 +1688,23 @@ class OAuth2WebServerFlowTest(unittest.TestCase):
# Check stubs. # Check stubs.
self.assertEqual(logger.warning.call_count, 1) 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): def test_step1_get_authorize_url_without_redirect(self):
flow = client.OAuth2WebServerFlow('client_id+1', scope='foo', flow = client.OAuth2WebServerFlow('client_id+1', scope='foo',
redirect_uri=None) redirect_uri=None)
@@ -1933,6 +1950,23 @@ class OAuth2WebServerFlowTest(unittest.TestCase):
http.requests[0]['body'])['code'][0] http.requests[0]['body'])['code'][0]
self.assertEqual(code, request_code) 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): def test_exchange_using_authorization_header(self):
auth_header = 'Basic Y2xpZW50X2lkKzE6c2Vjexc_managerV0KzE=', auth_header = 'Basic Y2xpZW50X2lkKzE6c2Vjexc_managerV0KzE=',
flow = client.OAuth2WebServerFlow( flow = client.OAuth2WebServerFlow(