Skip to content

Commit e56853e

Browse files
jay0leetseaver
authored andcommitted
Implement code verifier (PKCE) (#42)
1 parent c547be6 commit e56853e

File tree

2 files changed

+61
-8
lines changed

2 files changed

+61
-8
lines changed

google_auth_oauthlib/flow.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@
5151
.. _OAuth 2.0 Authorization Flow:
5252
https://tools.ietf.org/html/rfc6749#section-1.2
5353
"""
54-
54+
from base64 import urlsafe_b64encode
55+
import hashlib
5556
import json
5657
import logging
58+
try:
59+
from secrets import SystemRandom
60+
except ImportError: # pragma: NO COVER
61+
from random import SystemRandom
62+
from string import ascii_letters, digits
5763
import webbrowser
5864
import wsgiref.simple_server
5965
import wsgiref.util
@@ -89,7 +95,7 @@ class Flow(object):
8995

9096
def __init__(
9197
self, oauth2session, client_type, client_config,
92-
redirect_uri=None):
98+
redirect_uri=None, code_verifier=None):
9399
"""
94100
Args:
95101
oauth2session (requests_oauthlib.OAuth2Session):
@@ -101,6 +107,8 @@ def __init__(
101107
redirect_uri (str): The OAuth 2.0 redirect URI if known at flow
102108
creation time. Otherwise, it will need to be set using
103109
:attr:`redirect_uri`.
110+
code_verifier (str): random string of 43-128 chars used to verify
111+
the key exchange.using PKCE. Auto-generated if not provided.
104112
105113
.. _client secrets:
106114
https://developers.google.com/api-client-library/python/guide
@@ -113,6 +121,7 @@ def __init__(
113121
self.oauth2session = oauth2session
114122
"""requests_oauthlib.OAuth2Session: The OAuth 2.0 session."""
115123
self.redirect_uri = redirect_uri
124+
self.code_verifier = code_verifier
116125

117126
@classmethod
118127
def from_client_config(cls, client_config, scopes, **kwargs):
@@ -208,6 +217,18 @@ def authorization_url(self, **kwargs):
208217
specify the ``state`` when constructing the :class:`Flow`.
209218
"""
210219
kwargs.setdefault('access_type', 'offline')
220+
if not self.code_verifier:
221+
chars = ascii_letters+digits+'-._~'
222+
rnd = SystemRandom()
223+
random_verifier = [rnd.choice(chars) for _ in range(0, 128)]
224+
self.code_verifier = ''.join(random_verifier)
225+
code_hash = hashlib.sha256()
226+
code_hash.update(str.encode(self.code_verifier))
227+
unencoded_challenge = code_hash.digest()
228+
b64_challenge = urlsafe_b64encode(unencoded_challenge)
229+
code_challenge = b64_challenge.decode().split('=')[0]
230+
kwargs.setdefault('code_challenge', code_challenge)
231+
kwargs.setdefault('code_challenge_method', 'S256')
211232
url, state = self.oauth2session.authorization_url(
212233
self.client_config['auth_uri'], **kwargs)
213234

@@ -237,6 +258,7 @@ def fetch_token(self, **kwargs):
237258
:class:`~google.auth.credentials.Credentials` instance.
238259
"""
239260
kwargs.setdefault('client_secret', self.client_config['client_secret'])
261+
kwargs.setdefault('code_verifier', self.code_verifier)
240262
return self.oauth2session.fetch_token(
241263
self.client_config['token_uri'], **kwargs)
242264

tests/test_flow.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from functools import partial
1818
import json
1919
import os
20+
import re
2021

2122
import mock
2223
import pytest
@@ -87,6 +88,7 @@ def test_redirect_uri(self, instance):
8788
def test_authorization_url(self, instance):
8889
scope = 'scope_one'
8990
instance.oauth2session.scope = [scope]
91+
instance.code_verifier = 'amanaplanacanalpanama'
9092
authorization_url_patch = mock.patch.object(
9193
instance.oauth2session, 'authorization_url',
9294
wraps=instance.oauth2session.authorization_url)
@@ -99,11 +101,14 @@ def test_authorization_url(self, instance):
99101
authorization_url_spy.assert_called_with(
100102
CLIENT_SECRETS_INFO['web']['auth_uri'],
101103
access_type='offline',
102-
prompt='consent')
104+
prompt='consent',
105+
code_challenge='2yN0TOdl0gkGwFOmtfx3f913tgEaLM2d2S0WlmG1Z6Q',
106+
code_challenge_method='S256')
103107

104108
def test_authorization_url_access_type(self, instance):
105109
scope = 'scope_one'
106110
instance.oauth2session.scope = [scope]
111+
instance.code_verifier = 'amanaplanacanalpanama'
107112
authorization_url_patch = mock.patch.object(
108113
instance.oauth2session, 'authorization_url',
109114
wraps=instance.oauth2session.authorization_url)
@@ -115,9 +120,31 @@ def test_authorization_url_access_type(self, instance):
115120
assert scope in url
116121
authorization_url_spy.assert_called_with(
117122
CLIENT_SECRETS_INFO['web']['auth_uri'],
118-
access_type='meep')
123+
access_type='meep',
124+
code_challenge='2yN0TOdl0gkGwFOmtfx3f913tgEaLM2d2S0WlmG1Z6Q',
125+
code_challenge_method='S256')
126+
127+
def test_authorization_url_generated_verifier(self, instance):
128+
scope = 'scope_one'
129+
instance.oauth2session.scope = [scope]
130+
authorization_url_path = mock.patch.object(
131+
instance.oauth2session, 'authorization_url',
132+
wraps=instance.oauth2session.authorization_url)
133+
134+
with authorization_url_path as authorization_url_spy:
135+
instance.authorization_url()
136+
137+
_, kwargs = authorization_url_spy.call_args_list[0]
138+
assert kwargs['code_challenge_method'] == 'S256'
139+
assert len(instance.code_verifier) == 128
140+
assert len(kwargs['code_challenge']) == 43
141+
valid_verifier = r'^[A-Za-z0-9-._~]*$'
142+
valid_challenge = r'^[A-Za-z0-9-_]*$'
143+
assert re.match(valid_verifier, instance.code_verifier)
144+
assert re.match(valid_challenge, kwargs['code_challenge'])
119145

120146
def test_fetch_token(self, instance):
147+
instance.code_verifier = 'amanaplanacanalpanama'
121148
fetch_token_patch = mock.patch.object(
122149
instance.oauth2session, 'fetch_token', autospec=True,
123150
return_value=mock.sentinel.token)
@@ -129,7 +156,8 @@ def test_fetch_token(self, instance):
129156
fetch_token_mock.assert_called_with(
130157
CLIENT_SECRETS_INFO['web']['token_uri'],
131158
client_secret=CLIENT_SECRETS_INFO['web']['client_secret'],
132-
code=mock.sentinel.code)
159+
code=mock.sentinel.code,
160+
code_verifier='amanaplanacanalpanama')
133161

134162
def test_credentials(self, instance):
135163
instance.oauth2session.token = {
@@ -194,7 +222,7 @@ def set_token(*args, **kwargs):
194222
@mock.patch('google_auth_oauthlib.flow.input', autospec=True)
195223
def test_run_console(self, input_mock, instance, mock_fetch_token):
196224
input_mock.return_value = mock.sentinel.code
197-
225+
instance.code_verifier = 'amanaplanacanalpanama'
198226
credentials = instance.run_console()
199227

200228
assert credentials.token == mock.sentinel.access_token
@@ -204,7 +232,8 @@ def test_run_console(self, input_mock, instance, mock_fetch_token):
204232
mock_fetch_token.assert_called_with(
205233
CLIENT_SECRETS_INFO['web']['token_uri'],
206234
client_secret=CLIENT_SECRETS_INFO['web']['client_secret'],
207-
code=mock.sentinel.code)
235+
code=mock.sentinel.code,
236+
code_verifier='amanaplanacanalpanama')
208237

209238
@pytest.mark.webtest
210239
@mock.patch('google_auth_oauthlib.flow.webbrowser', autospec=True)
@@ -213,6 +242,7 @@ def test_run_local_server(
213242
auth_redirect_url = urllib.parse.urljoin(
214243
'http://localhost:60452',
215244
self.REDIRECT_REQUEST_PATH)
245+
instance.code_verifier = 'amanaplanacanalpanama'
216246

217247
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
218248
future = pool.submit(partial(
@@ -235,7 +265,8 @@ def test_run_local_server(
235265
mock_fetch_token.assert_called_with(
236266
CLIENT_SECRETS_INFO['web']['token_uri'],
237267
client_secret=CLIENT_SECRETS_INFO['web']['client_secret'],
238-
authorization_response=expected_auth_response)
268+
authorization_response=expected_auth_response,
269+
code_verifier='amanaplanacanalpanama')
239270

240271
@mock.patch('google_auth_oauthlib.flow.webbrowser', autospec=True)
241272
@mock.patch('wsgiref.simple_server.make_server', autospec=True)

0 commit comments

Comments
 (0)