Skip to content

Commit 93d47fd

Browse files
committed
fix(google): Gracefully handle cases where id_token is absent
1 parent 48a661a commit 93d47fd

File tree

4 files changed

+153
-25
lines changed

4 files changed

+153
-25
lines changed

Diff for: ChangeLog.rst

+6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ Note worthy changes
99
password was changed", including information on user agent / IP address from where the change
1010
originated, will be emailed.
1111

12+
- Google: Starting from 0.52.0, the ``id_token`` is being used for extracting
13+
user information. To accommodate for scenario's where django-allauth is used
14+
in contexts where the ``id_token`` is not posted, the provider now looks up
15+
the required information from the ``/userinfo`` endpoint based on the access
16+
token if the ``id_token`` is absent.
17+
1218

1319
Security notice
1420
---------------

Diff for: allauth/socialaccount/providers/google/provider.py

+35-3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ class Scope(object):
1010

1111

1212
class GoogleAccount(ProviderAccount):
13+
"""
14+
The account data can be in two formats. One, originating from
15+
the /v2/userinfo endpoint:
16+
17+
{'email': '[email protected]',
18+
'given_name': 'John',
19+
'id': '12345678901234567890',
20+
'locale': 'en',
21+
'name': 'John',
22+
'picture': 'https://lh3.googleusercontent.com/a/code',
23+
'verified_email': True}
24+
25+
The second, which is the payload of the id_token:
26+
27+
{'at_hash': '-someHASH',
28+
'aud': '123-pqr.apps.googleusercontent.com',
29+
'azp': '123-pqr.apps.googleusercontent.com',
30+
'email': '[email protected]',
31+
'email_verified': True,
32+
'exp': 1707297277,
33+
'given_name': 'John',
34+
'iat': 1707293677,
35+
'iss': 'https://accounts.google.com',
36+
'locale': 'en',
37+
'name': 'John',
38+
'picture': 'https://lh3.googleusercontent.com/a/code',
39+
'sub': '12345678901234567890'}
40+
"""
41+
1342
def get_profile_url(self):
1443
return self.account.extra_data.get("link")
1544

@@ -39,7 +68,9 @@ def get_auth_params(self, request, action):
3968
return ret
4069

4170
def extract_uid(self, data):
42-
return data["sub"]
71+
if "sub" in data:
72+
return data["sub"]
73+
return data["id"]
4374

4475
def extract_common_fields(self, data):
4576
return dict(
@@ -51,8 +82,9 @@ def extract_common_fields(self, data):
5182
def extract_email_addresses(self, data):
5283
ret = []
5384
email = data.get("email")
54-
if email and data.get("email_verified"):
55-
ret.append(EmailAddress(email=email, verified=True, primary=True))
85+
if email:
86+
verified = bool(data.get("email_verified") or data.get("verified_email"))
87+
ret.append(EmailAddress(email=email, verified=verified, primary=True))
5688
return ret
5789

5890

Diff for: allauth/socialaccount/providers/google/tests.py

+72-3
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
import pytest
1414

1515
from allauth.account import app_settings as account_settings
16-
from allauth.account.adapter import get_adapter
16+
from allauth.account.adapter import get_adapter as get_account_adapter
1717
from allauth.account.models import EmailAddress, EmailConfirmation
1818
from allauth.account.signals import user_signed_up
19-
from allauth.socialaccount.models import SocialAccount
19+
from allauth.socialaccount.adapter import get_adapter
20+
from allauth.socialaccount.models import SocialAccount, SocialToken
2021
from allauth.socialaccount.providers.apple.client import jwt_encode
22+
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
2123
from allauth.socialaccount.tests import OAuth2TestsMixin
2224
from allauth.tests import TestCase, mocked_response
2325

@@ -155,7 +157,7 @@ def test_email_verified_stashed(self):
155157
self.client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key
156158
request = RequestFactory().get("/")
157159
request.session = self.client.session
158-
adapter = get_adapter()
160+
adapter = get_account_adapter()
159161
adapter.stash_verified_email(request, self.email)
160162
request.session.save()
161163

@@ -271,3 +273,70 @@ def test_login_by_token(db, client, settings_with_google_provider):
271273
assert resp.status_code == 302
272274
socialaccount = SocialAccount.objects.get(uid="123sub")
273275
assert socialaccount.user.email == "[email protected]"
276+
277+
278+
@pytest.mark.parametrize(
279+
"id_key,verified_key",
280+
[
281+
("id", "email_verified"),
282+
("sub", "verified_email"),
283+
],
284+
)
285+
@pytest.mark.parametrize("verified", [False, True])
286+
def test_extract_data(
287+
id_key, verified_key, verified, settings_with_google_provider, db
288+
):
289+
data = {
290+
"email": "[email protected]",
291+
}
292+
data[id_key] = "123"
293+
data[verified_key] = verified
294+
provider = get_adapter().get_provider(None, GoogleProvider.id)
295+
assert provider.extract_uid(data) == "123"
296+
emails = provider.extract_email_addresses(data)
297+
assert len(emails) == 1
298+
assert emails[0].verified == verified
299+
assert emails[0].email == "[email protected]"
300+
301+
302+
@pytest.mark.parametrize(
303+
"fetch_userinfo,id_token_has_picture,response,expected_uid, expected_picture",
304+
[
305+
(True, True, {"id_token": "123"}, "uid-from-id-token", "pic-from-id-token"),
306+
(True, False, {"id_token": "123"}, "uid-from-id-token", "pic-from-userinfo"),
307+
(True, True, {"access_token": "123"}, "uid-from-userinfo", "pic-from-userinfo"),
308+
],
309+
)
310+
def test_complete_login_variants(
311+
response,
312+
settings_with_google_provider,
313+
db,
314+
fetch_userinfo,
315+
expected_uid,
316+
expected_picture,
317+
id_token_has_picture,
318+
):
319+
with patch.object(
320+
GoogleOAuth2Adapter,
321+
"_fetch_user_info",
322+
return_value={
323+
"id": "uid-from-userinfo",
324+
"picture": "pic-from-userinfo",
325+
},
326+
):
327+
id_token = {"sub": "uid-from-id-token"}
328+
if id_token_has_picture:
329+
id_token["picture"] = "pic-from-id-token"
330+
with patch.object(
331+
GoogleOAuth2Adapter,
332+
"_decode_id_token",
333+
return_value=id_token,
334+
):
335+
request = None
336+
app = None
337+
adapter = GoogleOAuth2Adapter(request)
338+
adapter.fetch_userinfo = fetch_userinfo
339+
token = SocialToken()
340+
login = adapter.complete_login(request, app, token, response)
341+
assert login.account.uid == expected_uid
342+
assert login.account.extra_data["picture"] == expected_picture

Diff for: allauth/socialaccount/providers/google/views.py

+40-19
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,17 @@
2424
from .provider import GoogleProvider
2525

2626

27-
CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs"
27+
CERTS_URL = (
28+
getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
29+
.get("google", {})
30+
.get("CERTS_URL", "https://www.googleapis.com/oauth2/v1/certs")
31+
)
2832

29-
IDENTITY_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
33+
IDENTITY_URL = (
34+
getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
35+
.get("google", {})
36+
.get("IDENTITY_URL", "https://www.googleapis.com/oauth2/v2/userinfo")
37+
)
3038

3139
ACCESS_TOKEN_URL = (
3240
getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
@@ -62,9 +70,24 @@ class GoogleOAuth2Adapter(OAuth2Adapter):
6270
fetch_userinfo = FETCH_USERINFO
6371

6472
def complete_login(self, request, app, token, response, **kwargs):
73+
data = None
74+
id_token = response.get("id_token")
75+
if id_token:
76+
data = self._decode_id_token(app, id_token)
77+
if self.fetch_userinfo and "picture" not in data:
78+
info = self._fetch_user_info(token.token)
79+
picture = info.get("picture")
80+
if picture:
81+
data["picture"] = picture
82+
else:
83+
data = self._fetch_user_info(token.token)
84+
login = self.get_provider().sociallogin_from_response(request, data)
85+
return login
86+
87+
def _decode_id_token(self, app, id_token):
6588
try:
66-
identity_data = jwt.decode(
67-
response["id_token"],
89+
data = jwt.decode(
90+
id_token,
6891
# Since the token was received by direct communication
6992
# protected by TLS between this library and Google, we
7093
# are allowed to skip checking the token signature
@@ -82,22 +105,20 @@ def complete_login(self, request, app, token, response, **kwargs):
82105
)
83106
except jwt.PyJWTError as e:
84107
raise OAuth2Error("Invalid id_token") from e
85-
86-
if self.fetch_userinfo and "picture" not in identity_data:
87-
resp = (
88-
get_adapter()
89-
.get_requests_session()
90-
.get(
91-
self.identity_url,
92-
headers={"Authorization": "Bearer {}".format(token)},
93-
)
108+
return data
109+
110+
def _fetch_user_info(self, access_token):
111+
resp = (
112+
get_adapter()
113+
.get_requests_session()
114+
.get(
115+
self.identity_url,
116+
headers={"Authorization": "Bearer {}".format(access_token)},
94117
)
95-
if not resp.ok:
96-
raise OAuth2Error("Request to user info failed")
97-
identity_data["picture"] = resp.json()["picture"]
98-
99-
login = self.get_provider().sociallogin_from_response(request, identity_data)
100-
return login
118+
)
119+
if not resp.ok:
120+
raise OAuth2Error("Request to user info failed")
121+
return resp.json()
101122

102123

103124
oauth2_login = OAuth2LoginView.adapter_view(GoogleOAuth2Adapter)

0 commit comments

Comments
 (0)