Skip to content

Commit 48303fc

Browse files
sandhoseerikjohnston
authored andcommitted
MSC3861: load the issuer and account management URLs from OIDC discovery (#17407)
This will help mitigating any discrepancies between the issuer configured and the one returned by the OIDC provider. This also removes the need for configuring the `account_management_url` explicitely, as it will now be loaded from the OIDC discovery, as per MSC2965. Because we may now fetch stuff for the .well-known/matrix/client endpoint, this also transforms the client well-known resource to be asynchronous.
1 parent 53a3783 commit 48303fc

File tree

9 files changed

+126
-46
lines changed

9 files changed

+126
-46
lines changed

changelog.d/17407.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
MSC3861: load the issuer and account management URLs from OIDC discovery.

synapse/api/auth/msc3861_delegated.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,9 @@ def __init__(self, hs: "HomeServer"):
121121
self._hostname = hs.hostname
122122
self._admin_token = self._config.admin_token
123123

124-
self._issuer_metadata = RetryOnExceptionCachedCall(self._load_metadata)
124+
self._issuer_metadata = RetryOnExceptionCachedCall[OpenIDProviderMetadata](
125+
self._load_metadata
126+
)
125127

126128
if isinstance(auth_method, PrivateKeyJWTWithKid):
127129
# Use the JWK as the client secret when using the private_key_jwt method
@@ -145,6 +147,33 @@ async def _load_metadata(self) -> OpenIDProviderMetadata:
145147
# metadata.validate_introspection_endpoint()
146148
return metadata
147149

150+
async def issuer(self) -> str:
151+
"""
152+
Get the configured issuer
153+
154+
This will use the issuer value set in the metadata,
155+
falling back to the one set in the config if not set in the metadata
156+
"""
157+
metadata = await self._issuer_metadata.get()
158+
return metadata.issuer or self._config.issuer
159+
160+
async def account_management_url(self) -> Optional[str]:
161+
"""
162+
Get the configured account management URL
163+
164+
This will discover the account management URL from the issuer if it's not set in the config
165+
"""
166+
if self._config.account_management_url is not None:
167+
return self._config.account_management_url
168+
169+
try:
170+
metadata = await self._issuer_metadata.get()
171+
return metadata.get("account_management_uri", None)
172+
# We don't want to raise here if we can't load the metadata
173+
except Exception:
174+
logger.warning("Failed to load metadata:", exc_info=True)
175+
return None
176+
148177
async def _introspection_endpoint(self) -> str:
149178
"""
150179
Returns the introspection endpoint of the issuer
@@ -154,7 +183,7 @@ async def _introspection_endpoint(self) -> str:
154183
if self._config.introspection_endpoint is not None:
155184
return self._config.introspection_endpoint
156185

157-
metadata = await self._load_metadata()
186+
metadata = await self._issuer_metadata.get()
158187
return metadata.get("introspection_endpoint")
159188

160189
async def _introspect_token(self, token: str) -> IntrospectionToken:

synapse/rest/client/auth.py

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

2222
import logging
23-
from typing import TYPE_CHECKING
23+
from typing import TYPE_CHECKING, cast
2424

2525
from twisted.web.server import Request
2626

@@ -70,11 +70,17 @@ async def on_GET(self, request: SynapseRequest, stagetype: str) -> None:
7070
self.hs.config.experimental.msc3861.enabled
7171
and stagetype == "org.matrix.cross_signing_reset"
7272
):
73-
config = self.hs.config.experimental.msc3861
74-
if config.account_management_url is not None:
75-
url = f"{config.account_management_url}?action=org.matrix.cross_signing_reset"
73+
# If MSC3861 is enabled, we can assume self._auth is an instance of MSC3861DelegatedAuth
74+
# We import lazily here because of the authlib requirement
75+
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
76+
77+
auth = cast(MSC3861DelegatedAuth, self.auth)
78+
79+
url = await auth.account_management_url()
80+
if url is not None:
81+
url = f"{url}?action=org.matrix.cross_signing_reset"
7682
else:
77-
url = config.issuer
83+
url = await auth.issuer()
7884
respond_with_redirect(request, str.encode(url))
7985

8086
if stagetype == LoginType.RECAPTCHA:

synapse/rest/client/auth_issuer.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# limitations under the License.
1414
import logging
1515
import typing
16-
from typing import Tuple
16+
from typing import Tuple, cast
1717

1818
from synapse.api.errors import Codes, SynapseError
1919
from synapse.http.server import HttpServer
@@ -43,10 +43,16 @@ class AuthIssuerServlet(RestServlet):
4343
def __init__(self, hs: "HomeServer"):
4444
super().__init__()
4545
self._config = hs.config
46+
self._auth = hs.get_auth()
4647

4748
async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
4849
if self._config.experimental.msc3861.enabled:
49-
return 200, {"issuer": self._config.experimental.msc3861.issuer}
50+
# If MSC3861 is enabled, we can assume self._auth is an instance of MSC3861DelegatedAuth
51+
# We import lazily here because of the authlib requirement
52+
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
53+
54+
auth = cast(MSC3861DelegatedAuth, self._auth)
55+
return 200, {"issuer": await auth.issuer()}
5056
else:
5157
# Wouldn't expect this to be reached: the servelet shouldn't have been
5258
# registered. Still, fail gracefully if we are registered for some reason.

synapse/rest/client/keys.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import logging
2424
import re
2525
from collections import Counter
26-
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
26+
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, cast
2727

2828
from synapse.api.errors import (
2929
InteractiveAuthIncompleteError,
@@ -406,11 +406,17 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
406406
# explicitly mark the master key as replaceable.
407407
if self.hs.config.experimental.msc3861.enabled:
408408
if not master_key_updatable_without_uia:
409-
config = self.hs.config.experimental.msc3861
410-
if config.account_management_url is not None:
411-
url = f"{config.account_management_url}?action=org.matrix.cross_signing_reset"
409+
# If MSC3861 is enabled, we can assume self.auth is an instance of MSC3861DelegatedAuth
410+
# We import lazily here because of the authlib requirement
411+
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
412+
413+
auth = cast(MSC3861DelegatedAuth, self.auth)
414+
415+
uri = await auth.account_management_url()
416+
if uri is not None:
417+
url = f"{uri}?action=org.matrix.cross_signing_reset"
412418
else:
413-
url = config.issuer
419+
url = await auth.issuer()
414420

415421
# We use a dummy session ID as this isn't really a UIA flow, but we
416422
# reuse the same API shape for better client compatibility.

synapse/rest/client/login.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, LoginResponse]:
268268
approval_notice_medium=ApprovalNoticeMedium.NONE,
269269
)
270270

271-
well_known_data = self._well_known_builder.get_well_known()
271+
well_known_data = await self._well_known_builder.get_well_known()
272272
if well_known_data:
273273
result["well_known"] = well_known_data
274274
return 200, result

synapse/rest/well_known.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
#
1919
#
2020
import logging
21-
from typing import TYPE_CHECKING, Optional
21+
from typing import TYPE_CHECKING, Optional, Tuple, cast
2222

2323
from twisted.web.resource import Resource
2424
from twisted.web.server import Request
2525

26-
from synapse.http.server import set_cors_headers
26+
from synapse.api.errors import NotFoundError
27+
from synapse.http.server import DirectServeJsonResource
2728
from synapse.http.site import SynapseRequest
2829
from synapse.types import JsonDict
2930
from synapse.util import json_encoder
@@ -38,8 +39,9 @@
3839
class WellKnownBuilder:
3940
def __init__(self, hs: "HomeServer"):
4041
self._config = hs.config
42+
self._auth = hs.get_auth()
4143

42-
def get_well_known(self) -> Optional[JsonDict]:
44+
async def get_well_known(self) -> Optional[JsonDict]:
4345
if not self._config.server.serve_client_wellknown:
4446
return None
4547

@@ -52,13 +54,20 @@ def get_well_known(self) -> Optional[JsonDict]:
5254

5355
# We use the MSC3861 values as they are used by multiple MSCs
5456
if self._config.experimental.msc3861.enabled:
57+
# If MSC3861 is enabled, we can assume self._auth is an instance of MSC3861DelegatedAuth
58+
# We import lazily here because of the authlib requirement
59+
from synapse.api.auth.msc3861_delegated import MSC3861DelegatedAuth
60+
61+
auth = cast(MSC3861DelegatedAuth, self._auth)
62+
5563
result["org.matrix.msc2965.authentication"] = {
56-
"issuer": self._config.experimental.msc3861.issuer
64+
"issuer": await auth.issuer(),
5765
}
58-
if self._config.experimental.msc3861.account_management_url is not None:
66+
account_management_url = await auth.account_management_url()
67+
if account_management_url is not None:
5968
result["org.matrix.msc2965.authentication"][
6069
"account"
61-
] = self._config.experimental.msc3861.account_management_url
70+
] = account_management_url
6271

6372
if self._config.server.extra_well_known_client_content:
6473
for (
@@ -71,26 +80,22 @@ def get_well_known(self) -> Optional[JsonDict]:
7180
return result
7281

7382

74-
class ClientWellKnownResource(Resource):
83+
class ClientWellKnownResource(DirectServeJsonResource):
7584
"""A Twisted web resource which renders the .well-known/matrix/client file"""
7685

7786
isLeaf = 1
7887

7988
def __init__(self, hs: "HomeServer"):
80-
Resource.__init__(self)
89+
super().__init__()
8190
self._well_known_builder = WellKnownBuilder(hs)
8291

83-
def render_GET(self, request: SynapseRequest) -> bytes:
84-
set_cors_headers(request)
85-
r = self._well_known_builder.get_well_known()
92+
async def _async_render_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
93+
r = await self._well_known_builder.get_well_known()
8694
if not r:
87-
request.setResponseCode(404)
88-
request.setHeader(b"Content-Type", b"text/plain")
89-
return b".well-known not available"
95+
raise NotFoundError(".well-known not available")
9096

9197
logger.debug("returning: %s", r)
92-
request.setHeader(b"Content-Type", b"application/json")
93-
return json_encoder.encode(r).encode("utf-8")
98+
return 200, r
9499

95100

96101
class ServerWellKnownResource(Resource):

tests/rest/client/test_auth_issuer.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
from http import HTTPStatus
15+
from unittest.mock import AsyncMock
1516

1617
from synapse.rest.client import auth_issuer
1718

@@ -50,10 +51,27 @@ def test_returns_404_when_msc3861_disabled(self) -> None:
5051
}
5152
)
5253
def test_returns_issuer_when_oidc_enabled(self) -> None:
53-
# Make an unauthenticated request for the discovery info.
54+
# Patch the HTTP client to return the issuer metadata
55+
req_mock = AsyncMock(return_value={"issuer": ISSUER})
56+
self.hs.get_proxied_http_client().get_json = req_mock # type: ignore[method-assign]
57+
58+
channel = self.make_request(
59+
"GET",
60+
"/_matrix/client/unstable/org.matrix.msc2965/auth_issuer",
61+
)
62+
63+
self.assertEqual(channel.code, HTTPStatus.OK)
64+
self.assertEqual(channel.json_body, {"issuer": ISSUER})
65+
66+
req_mock.assert_called_with("https://account.example.com/.well-known/openid-configuration")
67+
req_mock.reset_mock()
68+
69+
# Second call it should use the cached value
5470
channel = self.make_request(
5571
"GET",
5672
"/_matrix/client/unstable/org.matrix.msc2965/auth_issuer",
5773
)
74+
5875
self.assertEqual(channel.code, HTTPStatus.OK)
5976
self.assertEqual(channel.json_body, {"issuer": ISSUER})
77+
req_mock.assert_not_called()

tests/rest/test_well_known.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
# [This file includes modifications made by New Vector Limited]
1818
#
1919
#
20+
from unittest.mock import AsyncMock
21+
2022
from twisted.web.resource import Resource
2123

2224
from synapse.rest.well_known import well_known_resource
@@ -112,7 +114,6 @@ def test_server_well_known_disabled(self) -> None:
112114
"msc3861": {
113115
"enabled": True,
114116
"issuer": "https://issuer",
115-
"account_management_url": "https://my-account.issuer",
116117
"client_id": "id",
117118
"client_auth_method": "client_secret_post",
118119
"client_secret": "secret",
@@ -122,18 +123,26 @@ def test_server_well_known_disabled(self) -> None:
122123
}
123124
)
124125
def test_client_well_known_msc3861_oauth_delegation(self) -> None:
125-
channel = self.make_request(
126-
"GET", "/.well-known/matrix/client", shorthand=False
127-
)
126+
# Patch the HTTP client to return the issuer metadata
127+
req_mock = AsyncMock(return_value={"issuer": "https://issuer", "account_management_uri": "https://my-account.issuer"})
128+
self.hs.get_proxied_http_client().get_json = req_mock # type: ignore[method-assign]
128129

129-
self.assertEqual(channel.code, 200)
130-
self.assertEqual(
131-
channel.json_body,
132-
{
133-
"m.homeserver": {"base_url": "https://homeserver/"},
134-
"org.matrix.msc2965.authentication": {
135-
"issuer": "https://issuer",
136-
"account": "https://my-account.issuer",
130+
for _ in range(2):
131+
channel = self.make_request(
132+
"GET", "/.well-known/matrix/client", shorthand=False
133+
)
134+
135+
self.assertEqual(channel.code, 200)
136+
self.assertEqual(
137+
channel.json_body,
138+
{
139+
"m.homeserver": {"base_url": "https://homeserver/"},
140+
"org.matrix.msc2965.authentication": {
141+
"issuer": "https://issuer",
142+
"account": "https://my-account.issuer",
143+
},
137144
},
138-
},
139-
)
145+
)
146+
147+
# It should have been called exactly once, because it gets cached
148+
req_mock.assert_called_once_with("https://issuer/.well-known/openid-configuration")

0 commit comments

Comments
 (0)