Skip to content

Commit 1fd78c7

Browse files
feat: add reauth feature to user credentials (#727)
* feat: add reauth support to oauth2 credentials * update
1 parent 10b539d commit 1fd78c7

11 files changed

Lines changed: 1152 additions & 48 deletions

File tree

packages/google-auth/google/auth/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,12 @@ class ClientCertError(GoogleAuthError):
4848
class OAuthError(GoogleAuthError):
4949
"""Used to indicate an error occurred during an OAuth related HTTP
5050
request."""
51+
52+
53+
class ReauthFailError(RefreshError):
54+
"""An exception for when reauth failed."""
55+
56+
def __init__(self, message=None):
57+
super(ReauthFailError, self).__init__(
58+
"Reauthentication failed. {0}".format(message)
59+
)

packages/google-auth/google/oauth2/_client.py

Lines changed: 99 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -35,29 +35,29 @@
3535
from google.auth import jwt
3636

3737
_URLENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded"
38+
_JSON_CONTENT_TYPE = "application/json"
3839
_JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
3940
_REFRESH_GRANT_TYPE = "refresh_token"
4041

4142

42-
def _handle_error_response(response_body):
43-
""""Translates an error response into an exception.
43+
def _handle_error_response(response_data):
44+
"""Translates an error response into an exception.
4445
4546
Args:
46-
response_body (str): The decoded response data.
47+
response_data (Mapping): The decoded response data.
4748
4849
Raises:
49-
google.auth.exceptions.RefreshError
50+
google.auth.exceptions.RefreshError: The errors contained in response_data.
5051
"""
5152
try:
52-
error_data = json.loads(response_body)
5353
error_details = "{}: {}".format(
54-
error_data["error"], error_data.get("error_description")
54+
response_data["error"], response_data.get("error_description")
5555
)
5656
# If no details could be extracted, use the response data.
5757
except (KeyError, ValueError):
58-
error_details = response_body
58+
error_details = json.dumps(response_data)
5959

60-
raise exceptions.RefreshError(error_details, response_body)
60+
raise exceptions.RefreshError(error_details, response_data)
6161

6262

6363
def _parse_expiry(response_data):
@@ -78,25 +78,35 @@ def _parse_expiry(response_data):
7878
return None
7979

8080

81-
def _token_endpoint_request(request, token_uri, body):
81+
def _token_endpoint_request_no_throw(
82+
request, token_uri, body, access_token=None, use_json=False
83+
):
8284
"""Makes a request to the OAuth 2.0 authorization server's token endpoint.
85+
This function doesn't throw on response errors.
8386
8487
Args:
8588
request (google.auth.transport.Request): A callable used to make
8689
HTTP requests.
8790
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
8891
URI.
8992
body (Mapping[str, str]): The parameters to send in the request body.
93+
access_token (Optional(str)): The access token needed to make the request.
94+
use_json (Optional(bool)): Use urlencoded format or json format for the
95+
content type. The default value is False.
9096
9197
Returns:
92-
Mapping[str, str]: The JSON-decoded response data.
93-
94-
Raises:
95-
google.auth.exceptions.RefreshError: If the token endpoint returned
96-
an error.
98+
Tuple(bool, Mapping[str, str]): A boolean indicating if the request is
99+
successful, and a mapping for the JSON-decoded response data.
97100
"""
98-
body = urllib.parse.urlencode(body).encode("utf-8")
99-
headers = {"content-type": _URLENCODED_CONTENT_TYPE}
101+
if use_json:
102+
headers = {"Content-Type": _JSON_CONTENT_TYPE}
103+
body = json.dumps(body).encode("utf-8")
104+
else:
105+
headers = {"Content-Type": _URLENCODED_CONTENT_TYPE}
106+
body = urllib.parse.urlencode(body).encode("utf-8")
107+
108+
if access_token:
109+
headers["Authorization"] = "Bearer {}".format(access_token)
100110

101111
retry = 0
102112
# retry to fetch token for maximum of two times if any internal failure
@@ -121,8 +131,38 @@ def _token_endpoint_request(request, token_uri, body):
121131
):
122132
retry += 1
123133
continue
124-
_handle_error_response(response_body)
134+
return response.status == http_client.OK, response_data
135+
136+
return response.status == http_client.OK, response_data
137+
138+
139+
def _token_endpoint_request(
140+
request, token_uri, body, access_token=None, use_json=False
141+
):
142+
"""Makes a request to the OAuth 2.0 authorization server's token endpoint.
143+
144+
Args:
145+
request (google.auth.transport.Request): A callable used to make
146+
HTTP requests.
147+
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
148+
URI.
149+
body (Mapping[str, str]): The parameters to send in the request body.
150+
access_token (Optional(str)): The access token needed to make the request.
151+
use_json (Optional(bool)): Use urlencoded format or json format for the
152+
content type. The default value is False.
153+
154+
Returns:
155+
Mapping[str, str]: The JSON-decoded response data.
125156
157+
Raises:
158+
google.auth.exceptions.RefreshError: If the token endpoint returned
159+
an error.
160+
"""
161+
response_status_ok, response_data = _token_endpoint_request_no_throw(
162+
request, token_uri, body, access_token=access_token, use_json=use_json
163+
)
164+
if not response_status_ok:
165+
_handle_error_response(response_data)
126166
return response_data
127167

128168

@@ -204,8 +244,43 @@ def id_token_jwt_grant(request, token_uri, assertion):
204244
return id_token, expiry, response_data
205245

206246

247+
def _handle_refresh_grant_response(response_data, refresh_token):
248+
"""Extract tokens from refresh grant response.
249+
250+
Args:
251+
response_data (Mapping[str, str]): Refresh grant response data.
252+
refresh_token (str): Current refresh token.
253+
254+
Returns:
255+
Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access token,
256+
refresh token, expiration, and additional data returned by the token
257+
endpoint. If response_data doesn't have refresh token, then the current
258+
refresh token will be returned.
259+
260+
Raises:
261+
google.auth.exceptions.RefreshError: If the token endpoint returned
262+
an error.
263+
"""
264+
try:
265+
access_token = response_data["access_token"]
266+
except KeyError as caught_exc:
267+
new_exc = exceptions.RefreshError("No access token in response.", response_data)
268+
six.raise_from(new_exc, caught_exc)
269+
270+
refresh_token = response_data.get("refresh_token", refresh_token)
271+
expiry = _parse_expiry(response_data)
272+
273+
return access_token, refresh_token, expiry, response_data
274+
275+
207276
def refresh_grant(
208-
request, token_uri, refresh_token, client_id, client_secret, scopes=None
277+
request,
278+
token_uri,
279+
refresh_token,
280+
client_id,
281+
client_secret,
282+
scopes=None,
283+
rapt_token=None,
209284
):
210285
"""Implements the OAuth 2.0 refresh token grant.
211286
@@ -224,10 +299,11 @@ def refresh_grant(
224299
scopes must be authorized for the refresh token. Useful if refresh
225300
token has a wild card scope (e.g.
226301
'https://www.googleapis.com/auth/any-api').
302+
rapt_token (Optional(str)): The reauth Proof Token.
227303
228304
Returns:
229-
Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The
230-
access token, new refresh token, expiration, and additional data
305+
Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access
306+
token, new or current refresh token, expiration, and additional data
231307
returned by the token endpoint.
232308
233309
Raises:
@@ -244,16 +320,8 @@ def refresh_grant(
244320
}
245321
if scopes:
246322
body["scope"] = " ".join(scopes)
323+
if rapt_token:
324+
body["rapt"] = rapt_token
247325

248326
response_data = _token_endpoint_request(request, token_uri, body)
249-
250-
try:
251-
access_token = response_data["access_token"]
252-
except KeyError as caught_exc:
253-
new_exc = exceptions.RefreshError("No access token in response.", response_data)
254-
six.raise_from(new_exc, caught_exc)
255-
256-
refresh_token = response_data.get("refresh_token", refresh_token)
257-
expiry = _parse_expiry(response_data)
258-
259-
return access_token, refresh_token, expiry, response_data
327+
return _handle_refresh_grant_response(response_data, refresh_token)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Copyright 2021 Google LLC
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+
""" Challenges for reauthentication.
16+
"""
17+
18+
import abc
19+
import base64
20+
import getpass
21+
import sys
22+
23+
import six
24+
25+
from google.auth import _helpers
26+
from google.auth import exceptions
27+
28+
29+
REAUTH_ORIGIN = "https://accounts.google.com"
30+
31+
32+
def get_user_password(text):
33+
"""Get password from user.
34+
35+
Override this function with a different logic if you are using this library
36+
outside a CLI.
37+
38+
Args:
39+
text (str): message for the password prompt.
40+
41+
Returns:
42+
str: password string.
43+
"""
44+
return getpass.getpass(text)
45+
46+
47+
@six.add_metaclass(abc.ABCMeta)
48+
class ReauthChallenge(object):
49+
"""Base class for reauth challenges."""
50+
51+
@property
52+
@abc.abstractmethod
53+
def name(self): # pragma: NO COVER
54+
"""Returns the name of the challenge."""
55+
raise NotImplementedError("name property must be implemented")
56+
57+
@property
58+
@abc.abstractmethod
59+
def is_locally_eligible(self): # pragma: NO COVER
60+
"""Returns true if a challenge is supported locally on this machine."""
61+
raise NotImplementedError("is_locally_eligible property must be implemented")
62+
63+
@abc.abstractmethod
64+
def obtain_challenge_input(self, metadata): # pragma: NO COVER
65+
"""Performs logic required to obtain credentials and returns it.
66+
67+
Args:
68+
metadata (Mapping): challenge metadata returned in the 'challenges' field in
69+
the initial reauth request. Includes the 'challengeType' field
70+
and other challenge-specific fields.
71+
72+
Returns:
73+
response that will be send to the reauth service as the content of
74+
the 'proposalResponse' field in the request body. Usually a dict
75+
with the keys specific to the challenge. For example,
76+
``{'credential': password}`` for password challenge.
77+
"""
78+
raise NotImplementedError("obtain_challenge_input method must be implemented")
79+
80+
81+
class PasswordChallenge(ReauthChallenge):
82+
"""Challenge that asks for user's password."""
83+
84+
@property
85+
def name(self):
86+
return "PASSWORD"
87+
88+
@property
89+
def is_locally_eligible(self):
90+
return True
91+
92+
@_helpers.copy_docstring(ReauthChallenge)
93+
def obtain_challenge_input(self, unused_metadata):
94+
passwd = get_user_password("Please enter your password:")
95+
if not passwd:
96+
passwd = " " # avoid the server crashing in case of no password :D
97+
return {"credential": passwd}
98+
99+
100+
class SecurityKeyChallenge(ReauthChallenge):
101+
"""Challenge that asks for user's security key touch."""
102+
103+
@property
104+
def name(self):
105+
return "SECURITY_KEY"
106+
107+
@property
108+
def is_locally_eligible(self):
109+
return True
110+
111+
@_helpers.copy_docstring(ReauthChallenge)
112+
def obtain_challenge_input(self, metadata):
113+
try:
114+
import pyu2f.convenience.authenticator
115+
import pyu2f.errors
116+
import pyu2f.model
117+
except ImportError:
118+
raise exceptions.ReauthFailError(
119+
"pyu2f dependency is required to use Security key reauth feature. "
120+
"It can be installed via `pip install pyu2f` or `pip install google-auth[reauth]`."
121+
)
122+
sk = metadata["securityKey"]
123+
challenges = sk["challenges"]
124+
app_id = sk["applicationId"]
125+
126+
challenge_data = []
127+
for c in challenges:
128+
kh = c["keyHandle"].encode("ascii")
129+
key = pyu2f.model.RegisteredKey(bytearray(base64.urlsafe_b64decode(kh)))
130+
challenge = c["challenge"].encode("ascii")
131+
challenge = base64.urlsafe_b64decode(challenge)
132+
challenge_data.append({"key": key, "challenge": challenge})
133+
134+
try:
135+
api = pyu2f.convenience.authenticator.CreateCompositeAuthenticator(
136+
REAUTH_ORIGIN
137+
)
138+
response = api.Authenticate(
139+
app_id, challenge_data, print_callback=sys.stderr.write
140+
)
141+
return {"securityKey": response}
142+
except pyu2f.errors.U2FError as e:
143+
if e.code == pyu2f.errors.U2FError.DEVICE_INELIGIBLE:
144+
sys.stderr.write("Ineligible security key.\n")
145+
elif e.code == pyu2f.errors.U2FError.TIMEOUT:
146+
sys.stderr.write("Timed out while waiting for security key touch.\n")
147+
else:
148+
raise e
149+
except pyu2f.errors.NoDeviceFoundError:
150+
sys.stderr.write("No security key found.\n")
151+
return None
152+
153+
154+
AVAILABLE_CHALLENGES = {
155+
challenge.name: challenge
156+
for challenge in [SecurityKeyChallenge(), PasswordChallenge()]
157+
}

0 commit comments

Comments
 (0)