Skip to content

Commit 3614fd1

Browse files
bjmcJon Wayne Parrott
authored and
Jon Wayne Parrott
committed
Add support for RFC7636 PKCE (googleapis#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).
1 parent 619dff8 commit 3614fd1

File tree

4 files changed

+209
-9
lines changed

4 files changed

+209
-9
lines changed

oauth2client/_pkce.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Copyright 2016 Google Inc. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Utility functions for implementing Proof Key for Code Exchange (PKCE) by OAuth
17+
Public Clients
18+
19+
See RFC7636.
20+
"""
21+
22+
import base64
23+
import hashlib
24+
import os
25+
26+
27+
def code_verifier(n_bytes=64):
28+
"""
29+
Generates a 'code_verifier' as described in section 4.1 of RFC 7636.
30+
31+
This is a 'high-entropy cryptographic random string' that will be
32+
impractical for an attacker to guess.
33+
34+
Args:
35+
n_bytes: integer between 31 and 96, inclusive. default: 64
36+
number of bytes of entropy to include in verifier.
37+
38+
Returns:
39+
Bytestring, representing urlsafe base64-encoded random data.
40+
"""
41+
verifier = base64.urlsafe_b64encode(os.urandom(n_bytes))
42+
# https://tools.ietf.org/html/rfc7636#section-4.1
43+
# minimum length of 43 characters and a maximum length of 128 characters.
44+
if len(verifier) < 43:
45+
raise ValueError("Verifier too short. n_bytes must be > 30.")
46+
elif len(verifier) > 128:
47+
raise ValueError("Verifier too long. n_bytes must be < 97.")
48+
else:
49+
return verifier
50+
51+
52+
def code_challenge(verifier):
53+
"""
54+
Creates a 'code_challenge' as described in section 4.2 of RFC 7636
55+
by taking the sha256 hash of the verifier and then urlsafe
56+
base64-encoding it.
57+
58+
Args:
59+
verifier: bytestring, representing a code_verifier as generated by
60+
code_verifier().
61+
62+
Returns:
63+
Bytestring, representing a urlsafe base64-encoded sha256 hash digest.
64+
"""
65+
return base64.urlsafe_b64encode(hashlib.sha256(verifier).digest())

oauth2client/client.py

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
import oauth2client
3636
from oauth2client import _helpers
37+
from oauth2client import _pkce
3738
from oauth2client import clientsecrets
3839
from oauth2client import transport
3940

@@ -1632,7 +1633,9 @@ def credentials_from_code(client_id, client_secret, scope, code,
16321633
auth_uri=oauth2client.GOOGLE_AUTH_URI,
16331634
revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
16341635
device_uri=oauth2client.GOOGLE_DEVICE_URI,
1635-
token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI):
1636+
token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI,
1637+
pkce=False,
1638+
code_verifier=None):
16361639
"""Exchanges an authorization code for an OAuth2Credentials object.
16371640
16381641
Args:
@@ -1656,6 +1659,15 @@ def credentials_from_code(client_id, client_secret, scope, code,
16561659
device_uri: string, URI for device authorization endpoint. For
16571660
convenience defaults to Google's endpoints but any OAuth
16581661
2.0 provider can be used.
1662+
pkce: boolean, default: False, Generate and include a "Proof Key
1663+
for Code Exchange" (PKCE) with your authorization and token
1664+
requests. This adds security for installed applications that
1665+
cannot protect a client_secret. See RFC 7636 for details.
1666+
code_verifier: bytestring or None, default: None, parameter passed
1667+
as part of the code exchange when pkce=True. If
1668+
None, a code_verifier will automatically be
1669+
generated as part of step1_get_authorize_url(). See
1670+
RFC 7636 for details.
16591671
16601672
Returns:
16611673
An OAuth2Credentials object.
@@ -1666,10 +1678,14 @@ def credentials_from_code(client_id, client_secret, scope, code,
16661678
"""
16671679
flow = OAuth2WebServerFlow(client_id, client_secret, scope,
16681680
redirect_uri=redirect_uri,
1669-
user_agent=user_agent, auth_uri=auth_uri,
1670-
token_uri=token_uri, revoke_uri=revoke_uri,
1681+
user_agent=user_agent,
1682+
auth_uri=auth_uri,
1683+
token_uri=token_uri,
1684+
revoke_uri=revoke_uri,
16711685
device_uri=device_uri,
1672-
token_info_uri=token_info_uri)
1686+
token_info_uri=token_info_uri,
1687+
pkce=pkce,
1688+
code_verifier=code_verifier)
16731689

16741690
credentials = flow.step2_exchange(code, http=http)
16751691
return credentials
@@ -1704,6 +1720,15 @@ def credentials_from_clientsecrets_and_code(filename, scope, code,
17041720
cache: An optional cache service client that implements get() and set()
17051721
methods. See clientsecrets.loadfile() for details.
17061722
device_uri: string, OAuth 2.0 device authorization endpoint
1723+
pkce: boolean, default: False, Generate and include a "Proof Key
1724+
for Code Exchange" (PKCE) with your authorization and token
1725+
requests. This adds security for installed applications that
1726+
cannot protect a client_secret. See RFC 7636 for details.
1727+
code_verifier: bytestring or None, default: None, parameter passed
1728+
as part of the code exchange when pkce=True. If
1729+
None, a code_verifier will automatically be
1730+
generated as part of step1_get_authorize_url(). See
1731+
RFC 7636 for details.
17071732
17081733
Returns:
17091734
An OAuth2Credentials object.
@@ -1807,6 +1832,8 @@ def __init__(self, client_id,
18071832
device_uri=oauth2client.GOOGLE_DEVICE_URI,
18081833
token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI,
18091834
authorization_header=None,
1835+
pkce=False,
1836+
code_verifier=None,
18101837
**kwargs):
18111838
"""Constructor for OAuth2WebServerFlow.
18121839
@@ -1844,6 +1871,15 @@ def __init__(self, client_id,
18441871
require a client to authenticate using a
18451872
header value instead of passing client_secret
18461873
in the POST body.
1874+
pkce: boolean, default: False, Generate and include a "Proof Key
1875+
for Code Exchange" (PKCE) with your authorization and token
1876+
requests. This adds security for installed applications that
1877+
cannot protect a client_secret. See RFC 7636 for details.
1878+
code_verifier: bytestring or None, default: None, parameter passed
1879+
as part of the code exchange when pkce=True. If
1880+
None, a code_verifier will automatically be
1881+
generated as part of step1_get_authorize_url(). See
1882+
RFC 7636 for details.
18471883
**kwargs: dict, The keyword arguments are all optional and required
18481884
parameters for the OAuth calls.
18491885
"""
@@ -1863,6 +1899,8 @@ def __init__(self, client_id,
18631899
self.device_uri = device_uri
18641900
self.token_info_uri = token_info_uri
18651901
self.authorization_header = authorization_header
1902+
self._pkce = pkce
1903+
self.code_verifier = code_verifier
18661904
self.params = _oauth2_web_server_flow_params(kwargs)
18671905

18681906
@_helpers.positional(1)
@@ -1903,6 +1941,13 @@ def step1_get_authorize_url(self, redirect_uri=None, state=None):
19031941
query_params['state'] = state
19041942
if self.login_hint is not None:
19051943
query_params['login_hint'] = self.login_hint
1944+
if self._pkce:
1945+
if not self.code_verifier:
1946+
self.code_verifier = _pkce.code_verifier()
1947+
challenge = _pkce.code_challenge(self.code_verifier)
1948+
query_params['code_challenge'] = challenge
1949+
query_params['code_challenge_method'] = 'S256'
1950+
19061951
query_params.update(self.params)
19071952
return _update_query_params(self.auth_uri, query_params)
19081953

@@ -1997,6 +2042,8 @@ def step2_exchange(self, code=None, http=None, device_flow_info=None):
19972042
}
19982043
if self.client_secret is not None:
19992044
post_data['client_secret'] = self.client_secret
2045+
if self._pkce:
2046+
post_data['code_verifier'] = self.code_verifier
20002047
if device_flow_info is not None:
20012048
post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0'
20022049
else:
@@ -2054,7 +2101,7 @@ def step2_exchange(self, code=None, http=None, device_flow_info=None):
20542101
@_helpers.positional(2)
20552102
def flow_from_clientsecrets(filename, scope, redirect_uri=None,
20562103
message=None, cache=None, login_hint=None,
2057-
device_uri=None):
2104+
device_uri=None, pkce=None, code_verifier=None):
20582105
"""Create a Flow from a clientsecrets file.
20592106
20602107
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,
21032150
'login_hint': login_hint,
21042151
}
21052152
revoke_uri = client_info.get('revoke_uri')
2106-
if revoke_uri is not None:
2107-
constructor_kwargs['revoke_uri'] = revoke_uri
2108-
if device_uri is not None:
2109-
constructor_kwargs['device_uri'] = device_uri
2153+
optional = ('revoke_uri', 'device_uri', 'pkce', 'code_verifier')
2154+
for param in optional:
2155+
if locals()[param] is not None:
2156+
constructor_kwargs[param] = locals()[param]
2157+
21102158
return OAuth2WebServerFlow(
21112159
client_info['client_id'], client_info['client_secret'],
21122160
scope, **constructor_kwargs)

tests/test__pkce.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright 2016 Google Inc. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import mock
16+
import unittest2
17+
18+
from oauth2client import _pkce
19+
20+
21+
class PKCETests(unittest2.TestCase):
22+
23+
@mock.patch('oauth2client._pkce.os.urandom')
24+
def test_verifier(self, fake_urandom):
25+
canned_randomness = (
26+
b'\x98\x10D7\xf3\xb7\xaa\xfc\xdd\xd3M\xe2'
27+
b'\xa3,\x06\xa0\xb0\xa9\xb4\x8f\xcb\xd0'
28+
b'\xf5\x86N2p\x8c]!W\x9a\xed54\x99\x9d'
29+
b'\x8dv\\\xa7/\x81\xf3J\x98\xc3\x90\xee'
30+
b'\xb0\x8c\xb7Zc#\x05M0O\x08\xda\t\x1f\x07'
31+
)
32+
fake_urandom.return_value = canned_randomness
33+
expected = (
34+
b'mBBEN_O3qvzd003ioywGoLCptI_L0PWGTjJwjF0hV5rt'
35+
b'NTSZnY12XKcvgfNKmMOQ7rCMt1pjIwVNME8I2gkfBw=='
36+
)
37+
result = _pkce.code_verifier()
38+
self.assertEqual(result, expected)
39+
40+
def test_verifier_too_long(self):
41+
with self.assertRaises(ValueError) as caught:
42+
_pkce.code_verifier(97)
43+
self.assertIn("too long", str(caught.exception))
44+
45+
def test_verifier_too_short(self):
46+
with self.assertRaises(ValueError) as caught:
47+
_pkce.code_verifier(30)
48+
self.assertIn("too short", str(caught.exception))
49+
50+
def test_challenge(self):
51+
result = _pkce.code_challenge(b'SOME_VERIFIER')
52+
expected = b'6xJCQsjTtS3zjUwd8_ZqH0SyviGHnp5PsHXWKOCqDuI='
53+
self.assertEqual(result, expected)

tests/test_client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1688,6 +1688,23 @@ def test_step1_get_authorize_url_redirect_override(self, logger):
16881688
# Check stubs.
16891689
self.assertEqual(logger.warning.call_count, 1)
16901690

1691+
@mock.patch('oauth2client.client._pkce.code_challenge')
1692+
@mock.patch('oauth2client.client._pkce.code_verifier')
1693+
def test_step1_get_authorize_url_pkce(self, fake_verifier, fake_challenge):
1694+
fake_verifier.return_value = b'__TEST_VERIFIER__'
1695+
fake_challenge.return_value = b'__TEST_CHALLENGE__'
1696+
flow = client.OAuth2WebServerFlow(
1697+
'client_id+1',
1698+
scope='foo',
1699+
redirect_uri='http://example.com',
1700+
pkce=True)
1701+
auth_url = urllib.parse.urlparse(flow.step1_get_authorize_url())
1702+
self.assertEqual(flow.code_verifier, b'__TEST_VERIFIER__')
1703+
results = dict(urllib.parse.parse_qsl(auth_url.query))
1704+
self.assertEqual(results['code_challenge'], '__TEST_CHALLENGE__')
1705+
self.assertEqual(results['code_challenge_method'], 'S256')
1706+
fake_challenge.assert_called_with(b'__TEST_VERIFIER__')
1707+
16911708
def test_step1_get_authorize_url_without_redirect(self):
16921709
flow = client.OAuth2WebServerFlow('client_id+1', scope='foo',
16931710
redirect_uri=None)
@@ -1933,6 +1950,23 @@ def __contains__(self, name):
19331950
http.requests[0]['body'])['code'][0]
19341951
self.assertEqual(code, request_code)
19351952

1953+
def test_exchange_with_pkce(self):
1954+
http = http_mock.HttpMockSequence([
1955+
({'status': http_client.OK}, b'access_token=SlAV32hkKG'),
1956+
])
1957+
flow = client.OAuth2WebServerFlow(
1958+
'client_id+1',
1959+
scope='foo',
1960+
redirect_uri='http://example.com',
1961+
pkce=True,
1962+
code_verifier=b'__TEST_VERIFIER__'
1963+
)
1964+
flow.step2_exchange(code='some random code', http=http)
1965+
1966+
self.assertEqual(len(http.requests), 1)
1967+
test_request = http.requests[0]
1968+
self.assertIn('code_verifier=__TEST_VERIFIER__', test_request['body'])
1969+
19361970
def test_exchange_using_authorization_header(self):
19371971
auth_header = 'Basic Y2xpZW50X2lkKzE6c2Vjexc_managerV0KzE=',
19381972
flow = client.OAuth2WebServerFlow(

0 commit comments

Comments
 (0)