Skip to content

Commit fd5d3d8

Browse files
Don't check the at_hash (access token hash) in OIDC ID Tokens if we don't use the access token (#18374)
Co-authored-by: Eric Eastwood <[email protected]>
1 parent ea37612 commit fd5d3d8

File tree

4 files changed

+89
-4
lines changed

4 files changed

+89
-4
lines changed

changelog.d/18374.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Don't validate the `at_hash` (access token hash) field in OIDC ID Tokens if we don't end up actually using the OIDC Access Token.

synapse/handlers/oidc.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,24 @@ def _uses_userinfo(self) -> bool:
586586
or self._user_profile_method == "userinfo_endpoint"
587587
)
588588

589+
@property
590+
def _uses_access_token(self) -> bool:
591+
"""Return True if the `access_token` will be used during the login process.
592+
593+
This is useful to determine whether the access token
594+
returned by the identity provider, and
595+
any related metadata (such as the `at_hash` field in
596+
the ID token), should be validated.
597+
"""
598+
# Currently, Synapse only uses the access_token to fetch user metadata
599+
# from the userinfo endpoint. Therefore we only have a single criteria
600+
# to check right now but this may change in the future and this function
601+
# should be updated if more usages are introduced.
602+
#
603+
# For example, if we start to use the access_token given to us by the
604+
# IdP for more things, such as accessing Resource Server APIs.
605+
return self._uses_userinfo
606+
589607
@property
590608
def issuer(self) -> str:
591609
"""The issuer identifying this provider."""
@@ -957,9 +975,16 @@ async def _parse_id_token(self, token: Token, nonce: str) -> CodeIDToken:
957975
"nonce": nonce,
958976
"client_id": self._client_auth.client_id,
959977
}
960-
if "access_token" in token:
978+
if self._uses_access_token and "access_token" in token:
961979
# If we got an `access_token`, there should be an `at_hash` claim
962-
# in the `id_token` that we can check against.
980+
# in the `id_token` that we can check against. Setting this
981+
# instructs authlib to check the value of `at_hash` in the
982+
# ID token.
983+
#
984+
# We only need to verify the access token if we actually make
985+
# use of it. Which currently only happens when we need to fetch
986+
# the user's information from the userinfo_endpoint. Thus, this
987+
# check is also gated on self._uses_userinfo.
963988
claims_params["access_token"] = token["access_token"]
964989

965990
claims_options = {"iss": {"values": [metadata["issuer"]]}}

tests/handlers/test_oidc.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,50 @@ def test_code_exchange_with_overridden_redirect_uri(self) -> None:
10291029
args = parse_qs(kwargs["data"].decode("utf-8"))
10301030
self.assertEqual(args["redirect_uri"], [TEST_REDIRECT_URI])
10311031

1032+
@override_config(
1033+
{
1034+
"oidc_config": {
1035+
**DEFAULT_CONFIG,
1036+
"redirect_uri": TEST_REDIRECT_URI,
1037+
}
1038+
}
1039+
)
1040+
def test_code_exchange_ignores_access_token(self) -> None:
1041+
"""
1042+
Code exchange completes successfully and doesn't validate the `at_hash`
1043+
(access token hash) field of an ID token when the access token isn't
1044+
going to be used.
1045+
1046+
The access token won't be used in this test because Synapse (currently)
1047+
only needs it to fetch a user's metadata if it isn't included in the ID
1048+
token itself.
1049+
1050+
Because we have included "openid" in the requested scopes for this IdP
1051+
(see `SCOPES`), user metadata is be included in the ID token. Thus the
1052+
access token isn't needed, and it's unnecessary for Synapse to validate
1053+
the access token.
1054+
1055+
This is a regression test for a situation where an upstream identity
1056+
provider was providing an invalid `at_hash` value, which Synapse errored
1057+
on, yet Synapse wasn't using the access token for anything.
1058+
"""
1059+
# Exchange the code against the fake IdP.
1060+
userinfo = {
1061+
"sub": "foo",
1062+
"username": "foo",
1063+
"phone": "1234567",
1064+
}
1065+
with self.fake_server.id_token_override(
1066+
{
1067+
"at_hash": "invalid-hash",
1068+
}
1069+
):
1070+
request, _ = self.start_authorization(userinfo)
1071+
self.get_success(self.handler.handle_oidc_callback(request))
1072+
1073+
# If no error was rendered, then we have success.
1074+
self.render_error.assert_not_called()
1075+
10321076
@override_config(
10331077
{
10341078
"oidc_config": {

tests/test_utils/oidc.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
#
2121

2222

23+
import base64
2324
import json
25+
from hashlib import sha256
2426
from typing import Any, ContextManager, Dict, List, Optional, Tuple
2527
from unittest.mock import Mock, patch
2628
from urllib.parse import parse_qs
@@ -154,10 +156,23 @@ def _sign(self, payload: dict) -> str:
154156
json_payload = json.dumps(payload)
155157
return jws.serialize_compact(protected, json_payload, self._key).decode("utf-8")
156158

157-
def generate_id_token(self, grant: FakeAuthorizationGrant) -> str:
159+
def generate_id_token(
160+
self, grant: FakeAuthorizationGrant, access_token: str
161+
) -> str:
162+
# Generate a hash of the access token for the optional
163+
# `at_hash` field in an ID Token.
164+
#
165+
# 3.1.3.6. ID Token, https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken
166+
at_hash = (
167+
base64.urlsafe_b64encode(sha256(access_token.encode("ascii")).digest()[:16])
168+
.rstrip(b"=")
169+
.decode("ascii")
170+
)
171+
158172
now = int(self._clock.time())
159173
id_token = {
160174
**grant.userinfo,
175+
"at_hash": at_hash,
161176
"iss": self.issuer,
162177
"aud": grant.client_id,
163178
"iat": now,
@@ -243,7 +258,7 @@ def exchange_code(self, code: str) -> Optional[Dict[str, Any]]:
243258
}
244259

245260
if "openid" in grant.scope:
246-
token["id_token"] = self.generate_id_token(grant)
261+
token["id_token"] = self.generate_id_token(grant, access_token)
247262

248263
return dict(token)
249264

0 commit comments

Comments
 (0)