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

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.
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(