Skip to content

Commit 701bcc6

Browse files
committed
refactor(socialaccount): Extract JWT verification
1 parent 9c08094 commit 701bcc6

File tree

5 files changed

+120
-98
lines changed

5 files changed

+120
-98
lines changed

Diff for: allauth/socialaccount/internal/__init__.py

Whitespace-only changes.

Diff for: allauth/socialaccount/internal/jwtkit.py

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import json
2+
3+
import jwt
4+
from cryptography.hazmat.backends import default_backend
5+
from cryptography.x509 import load_pem_x509_certificate
6+
7+
from allauth.socialaccount.adapter import get_adapter
8+
from allauth.socialaccount.providers.oauth2.client import OAuth2Error
9+
10+
11+
def lookup_kid_pem_x509_certificate(keys_data, kid):
12+
"""
13+
Looks up the key given keys data of the form:
14+
15+
{"<kid>": "-----BEGIN CERTIFICATE-----\nCERTIFICATE"}
16+
"""
17+
key = keys_data.get(kid)
18+
if key:
19+
public_key = load_pem_x509_certificate(
20+
key.encode("utf8"), default_backend()
21+
).public_key()
22+
return public_key
23+
24+
25+
def lookup_kid_jwk(keys_data, kid):
26+
"""
27+
Looks up the key given keys data of the form:
28+
29+
{
30+
"keys": [
31+
{
32+
"kty": "RSA",
33+
"kid": "W6WcOKB",
34+
"use": "sig",
35+
"alg": "RS256",
36+
"n": "2Zc5d0-zk....",
37+
"e": "AQAB"
38+
}]
39+
}
40+
"""
41+
for d in keys_data["keys"]:
42+
if d["kid"] == kid:
43+
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(d))
44+
return public_key
45+
46+
47+
def fetch_key(credential, keys_url, lookup):
48+
header = jwt.get_unverified_header(credential)
49+
# {'alg': 'RS256', 'kid': '0ad1fec78504f447bae65bcf5afaedb65eec9e81', 'typ': 'JWT'}
50+
kid = header["kid"]
51+
alg = header["alg"]
52+
response = get_adapter().get_requests_session().get(keys_url)
53+
response.raise_for_status()
54+
keys_data = response.json()
55+
key = lookup(keys_data, kid)
56+
if not key:
57+
raise OAuth2Error(f"Invalid 'kid': '{kid}'")
58+
return alg, key
59+
60+
61+
def verify_and_decode(
62+
*, credential, keys_url, issuer, audience, lookup_kid, verify_signature=True
63+
):
64+
try:
65+
if verify_signature:
66+
alg, key = fetch_key(credential, keys_url, lookup_kid)
67+
algorithms = [alg]
68+
else:
69+
key = ""
70+
algorithms = None
71+
data = jwt.decode(
72+
credential,
73+
key=key,
74+
options={
75+
"verify_signature": verify_signature,
76+
"verify_iss": True,
77+
"verify_aud": True,
78+
"verify_exp": True,
79+
},
80+
issuer=issuer,
81+
audience=audience,
82+
algorithms=algorithms,
83+
)
84+
return data
85+
except jwt.PyJWTError as e:
86+
raise OAuth2Error("Invalid id_token") from e

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

+9-39
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
from django.utils.http import urlencode
88
from django.views.decorators.csrf import csrf_exempt
99

10-
import jwt
11-
1210
from allauth.socialaccount.adapter import get_adapter
11+
from allauth.socialaccount.internal import jwtkit
1312
from allauth.socialaccount.models import SocialToken
14-
from allauth.socialaccount.providers.oauth2.client import OAuth2Error
1513
from allauth.socialaccount.providers.oauth2.views import (
1614
OAuth2Adapter,
1715
OAuth2CallbackView,
@@ -31,49 +29,21 @@ class AppleOAuth2Adapter(OAuth2Adapter):
3129
authorize_url = "https://appleid.apple.com/auth/authorize"
3230
public_key_url = "https://appleid.apple.com/auth/keys"
3331

34-
def _get_apple_public_key(self, kid):
35-
response = get_adapter().get_requests_session().get(self.public_key_url)
36-
response.raise_for_status()
37-
try:
38-
data = response.json()
39-
except json.JSONDecodeError as e:
40-
raise OAuth2Error("Error retrieving apple public key.") from e
41-
42-
for d in data["keys"]:
43-
if d["kid"] == kid:
44-
return d
45-
46-
def get_public_key(self, id_token):
47-
"""
48-
Get the public key which matches the `kid` in the id_token header.
49-
"""
50-
kid = jwt.get_unverified_header(id_token)["kid"]
51-
apple_public_key = self._get_apple_public_key(kid=kid)
52-
53-
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(apple_public_key))
54-
return public_key
55-
5632
def get_client_id(self, provider):
5733
app = get_adapter().get_app(request=None, provider=self.provider_id)
5834
return [aud.strip() for aud in app.client_id.split(",")]
5935

6036
def get_verified_identity_data(self, id_token):
6137
provider = self.get_provider()
6238
allowed_auds = self.get_client_id(provider)
63-
64-
try:
65-
public_key = self.get_public_key(id_token)
66-
identity_data = jwt.decode(
67-
id_token,
68-
public_key,
69-
algorithms=["RS256"],
70-
audience=allowed_auds,
71-
issuer="https://appleid.apple.com",
72-
)
73-
return identity_data
74-
75-
except jwt.PyJWTError as e:
76-
raise OAuth2Error("Invalid id_token") from e
39+
data = jwtkit.verify_and_decode(
40+
credential=id_token,
41+
keys_url=self.public_key_url,
42+
issuer="https://appleid.apple.com",
43+
audience=allowed_auds,
44+
lookup_kid=jwtkit.lookup_kid_jwk,
45+
)
46+
return data
7747

7848
def parse_token(self, data):
7949
token = SocialToken(

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

+3-3
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,14 @@ class AppInSettingsTests(GoogleTests):
233233
def test_login_by_token(db, client, settings_with_google_provider):
234234
client.cookies.load({"g_csrf_token": "csrf"})
235235
with patch(
236-
"allauth.socialaccount.providers.google.views.jwt.get_unverified_header"
236+
"allauth.socialaccount.internal.jwtkit.jwt.get_unverified_header"
237237
) as g_u_h:
238238
with mocked_response({"dummykid": "-----BEGIN CERTIFICATE-----"}):
239239
with patch(
240-
"allauth.socialaccount.providers.google.views.load_pem_x509_certificate"
240+
"allauth.socialaccount.internal.jwtkit.load_pem_x509_certificate"
241241
) as load_pem:
242242
with patch(
243-
"allauth.socialaccount.providers.google.views.jwt.decode"
243+
"allauth.socialaccount.internal.jwtkit.jwt.decode"
244244
) as decode:
245245
decode.return_value = {
246246
"iss": "https://accounts.google.com",

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

+22-56
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,12 @@
55
from django.views.decorators.csrf import csrf_exempt
66
from django.views.generic import View
77

8-
import jwt
9-
from cryptography.hazmat.backends import default_backend
10-
from cryptography.x509 import load_pem_x509_certificate
11-
128
from allauth.socialaccount.adapter import get_adapter
139
from allauth.socialaccount.helpers import (
1410
complete_social_login,
1511
render_authentication_error,
1612
)
13+
from allauth.socialaccount.internal import jwtkit
1714
from allauth.socialaccount.providers.oauth2.client import OAuth2Error
1815
from allauth.socialaccount.providers.oauth2.views import (
1916
OAuth2Adapter,
@@ -61,6 +58,17 @@
6158
)
6259

6360

61+
def _verify_and_decode(app, credential, verify_signature=True):
62+
return jwtkit.verify_and_decode(
63+
credential=credential,
64+
keys_url=CERTS_URL,
65+
issuer=ID_TOKEN_ISSUER,
66+
audience=app.client_id,
67+
lookup_kid=jwtkit.lookup_kid_pem_x509_certificate,
68+
verify_signature=verify_signature,
69+
)
70+
71+
6472
class GoogleOAuth2Adapter(OAuth2Adapter):
6573
provider_id = GoogleProvider.id
6674
access_token_url = ACCESS_TOKEN_URL
@@ -85,27 +93,14 @@ def complete_login(self, request, app, token, response, **kwargs):
8593
return login
8694

8795
def _decode_id_token(self, app, id_token):
88-
try:
89-
data = jwt.decode(
90-
id_token,
91-
# Since the token was received by direct communication
92-
# protected by TLS between this library and Google, we
93-
# are allowed to skip checking the token signature
94-
# according to the OpenID Connect Core 1.0
95-
# specification.
96-
# https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
97-
options={
98-
"verify_signature": False,
99-
"verify_iss": True,
100-
"verify_aud": True,
101-
"verify_exp": True,
102-
},
103-
issuer=self.id_token_issuer,
104-
audience=app.client_id,
105-
)
106-
except jwt.PyJWTError as e:
107-
raise OAuth2Error("Invalid id_token") from e
108-
return data
96+
"""
97+
Since the token was received by direct communication protected by
98+
TLS between this library and Google, we are allowed to skip checking the
99+
token signature according to the OpenID Connect Core 1.0 specification.
100+
101+
https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
102+
"""
103+
return _verify_and_decode(app, id_token, verify_signature=False)
109104

110105
def _fetch_user_info(self, access_token):
111106
resp = (
@@ -132,9 +127,9 @@ def dispatch(self, request):
132127
try:
133128
return super().dispatch(request)
134129
except (
130+
OAuth2Error,
135131
requests.RequestException,
136132
PermissionDenied,
137-
jwt.PyJWTError,
138133
) as exc:
139134
return render_authentication_error(request, self.provider, exception=exc)
140135

@@ -147,20 +142,7 @@ def post(self, request, *args, **kwargs):
147142
self.check_csrf(request)
148143

149144
credential = request.POST.get("credential")
150-
alg, key = self.get_key(credential)
151-
identity_data = jwt.decode(
152-
credential,
153-
key,
154-
options={
155-
"verify_signature": True,
156-
"verify_iss": True,
157-
"verify_aud": True,
158-
"verify_exp": True,
159-
},
160-
issuer=ID_TOKEN_ISSUER,
161-
audience=self.provider.app.client_id,
162-
algorithms=[alg],
163-
)
145+
identity_data = _verify_and_decode(app=self.provider.app, credential=credential)
164146
login = self.provider.sociallogin_from_response(request, identity_data)
165147
return complete_social_login(request, login)
166148

@@ -174,21 +156,5 @@ def check_csrf(self, request):
174156
if csrf_token_cookie != csrf_token_body:
175157
raise PermissionDenied("Failed to verify double submit cookie.")
176158

177-
def get_key(self, credential):
178-
header = jwt.get_unverified_header(credential)
179-
# {'alg': 'RS256', 'kid': '0ad1fec78504f447bae65bcf5afaedb65eec9e81', 'typ': 'JWT'}
180-
kid = header["kid"]
181-
alg = header["alg"]
182-
response = get_adapter().get_requests_session().get(CERTS_URL)
183-
response.raise_for_status()
184-
jwks = response.json()
185-
key = jwks.get(kid)
186-
if not key:
187-
raise PermissionDenied("invalid 'kid'")
188-
key = load_pem_x509_certificate(
189-
key.encode("utf8"), default_backend()
190-
).public_key()
191-
return alg, key
192-
193159

194160
login_by_token = csrf_exempt(LoginByTokenView.as_view())

0 commit comments

Comments
 (0)