Skip to content

Commit de53298

Browse files
authored
fix(oauth2): avoid redundant JWKS network fetches (#17891)
Addresses a performance regression resulting in duplicate fetches of the JWKS endpoint by utilizing the already-fetched certs payload via PyJWKSet.from_dict instead of deferring to PyJWKClient.
1 parent e68eb8f commit de53298

3 files changed

Lines changed: 70 additions & 11 deletions

File tree

mypy.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ ignore_missing_imports = True
5757
[mypy-grpc_status]
5858
ignore_missing_imports = True
5959

60+
[mypy-jwt,jwt.*]
61+
ignore_missing_imports = True
62+
6063
[mypy-ibis.*]
6164
ignore_missing_imports = True
6265

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,25 +124,43 @@ def verify_token(
124124
intended for. If None then the audience is not verified.
125125
certs_url (str): The URL that specifies the certificates to use to
126126
verify the token. This URL should return JSON in the format of
127-
``{'key id': 'x509 certificate'}`` or a certificate array according to
127+
``{'key id': 'x509 certificate'}`` or a JWK Set according to
128128
the JWK spec (see https://tools.ietf.org/html/rfc7517).
129129
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
130130
validation.
131131
132132
Returns:
133133
Mapping[str, Any]: The decoded token.
134134
"""
135+
if isinstance(id_token, bytes):
136+
id_token = id_token.decode("utf-8")
137+
135138
certs = _fetch_certs(request, certs_url)
136139

137140
if "keys" in certs:
138141
try:
139-
import jwt as jwt_lib # type: ignore
142+
import jwt as jwt_lib
143+
from jwt.api_jwk import PyJWKSet
144+
from jwt.exceptions import PyJWKClientError
140145
except ImportError as caught_exc: # pragma: NO COVER
141146
raise ImportError(
142147
"The pyjwt library is not installed, please install the pyjwt package to use the jwk certs format."
143148
) from caught_exc
144-
jwks_client = jwt_lib.PyJWKClient(certs_url)
145-
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
149+
jwkset = PyJWKSet.from_dict(certs)
150+
header = jwt_lib.get_unverified_header(id_token)
151+
kid = header.get("kid")
152+
153+
signing_key = None
154+
for key in jwkset.keys:
155+
if kid and key.key_id == kid and key.public_key_use in ("sig", None):
156+
signing_key = key
157+
break
158+
159+
if signing_key is None:
160+
raise PyJWKClientError(
161+
f'Unable to find a signing key that matches: "{kid}"'
162+
)
163+
146164
return jwt_lib.decode(
147165
id_token,
148166
signing_key.key,

packages/google-auth/tests/oauth2/test_id_token.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,28 +86,66 @@ def test_verify_token(_fetch_certs, decode):
8686

8787

8888
@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True)
89-
@mock.patch("jwt.PyJWKClient", autospec=True)
89+
@mock.patch("jwt.api_jwk.PyJWKSet", autospec=True)
90+
@mock.patch("jwt.get_unverified_header", autospec=True)
9091
@mock.patch("jwt.decode", autospec=True)
91-
def test_verify_token_jwk(decode, py_jwk, _fetch_certs):
92+
def test_verify_token_jwk(decode, get_unverified_header, py_jwk_set, _fetch_certs):
9293
certs_url = "abc123"
9394
data = {"keys": [{"alg": "RS256"}]}
9495
_fetch_certs.return_value = data
96+
get_unverified_header.return_value = {"kid": "mock-kid"}
97+
98+
mock_key = mock.MagicMock()
99+
mock_key.key_id = "mock-kid"
100+
mock_key.public_key_use = "sig"
101+
mock_key.key = mock.sentinel.key
102+
mock_key.algorithm_name = "mock-alg"
103+
py_jwk_set.from_dict.return_value.keys = [mock_key]
95104
result = id_token.verify_token(
96105
mock.sentinel.token, mock.sentinel.request, certs_url=certs_url
97106
)
98107
assert result == decode.return_value
99-
py_jwk.assert_called_once_with(certs_url)
100-
signing_key = py_jwk.return_value.get_signing_key_from_jwt
108+
py_jwk_set.from_dict.assert_called_once_with(data)
109+
get_unverified_header.assert_called_once_with(mock.sentinel.token)
110+
101111
_fetch_certs.assert_called_once_with(mock.sentinel.request, certs_url)
102-
signing_key.assert_called_once_with(mock.sentinel.token)
103112
decode.assert_called_once_with(
104113
mock.sentinel.token,
105-
signing_key.return_value.key,
106-
algorithms=[signing_key.return_value.algorithm_name],
114+
mock.sentinel.key,
115+
algorithms=["mock-alg"],
107116
audience=None,
108117
)
109118

110119

120+
@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True)
121+
@mock.patch("jwt.api_jwk.PyJWKSet", autospec=True)
122+
@mock.patch("jwt.get_unverified_header", autospec=True)
123+
@mock.patch("jwt.decode", autospec=True)
124+
def test_verify_token_jwk_missing_kid(
125+
decode, get_unverified_header, py_jwk_set, _fetch_certs
126+
):
127+
from jwt.exceptions import PyJWKClientError
128+
129+
certs_url = "abc123"
130+
data = {"keys": [{"alg": "RS256"}]}
131+
_fetch_certs.return_value = data
132+
get_unverified_header.return_value = {"kid": "mock-kid"}
133+
134+
mock_key = mock.MagicMock()
135+
mock_key.key_id = "different-kid"
136+
mock_key.public_key_use = "sig"
137+
mock_key.key = mock.sentinel.key
138+
mock_key.algorithm_name = "mock-alg"
139+
py_jwk_set.from_dict.return_value.keys = [mock_key]
140+
141+
with pytest.raises(
142+
PyJWKClientError, match='Unable to find a signing key that matches: "mock-kid"'
143+
):
144+
id_token.verify_token(
145+
mock.sentinel.token, mock.sentinel.request, certs_url=certs_url
146+
)
147+
148+
111149
@mock.patch("google.auth.jwt.decode", autospec=True)
112150
@mock.patch("google.oauth2.id_token._fetch_certs", autospec=True)
113151
def test_verify_token_args(_fetch_certs, decode):

0 commit comments

Comments
 (0)