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:
committed by
Jon Wayne Parrott
parent
619dff806e
commit
3614fd147a
65
oauth2client/_pkce.py
Normal file
65
oauth2client/_pkce.py
Normal 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())
|
||||
@@ -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
53
tests/test__pkce.py
Normal 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)
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user