Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit e1071fd

Browse files
authored
Support for form_post in OIDC responses (#9376)
Apple want to POST the OIDC auth response back to us rather than using query-params; add the necessary support to make that work.
1 parent 33f64ca commit e1071fd

File tree

4 files changed

+78
-36
lines changed

4 files changed

+78
-36
lines changed

changelog.d/9376.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for receiving OpenID Connect authentication responses via form `POST`s rather than `GET`s.

synapse/handlers/oidc_handler.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,26 @@
4848

4949
logger = logging.getLogger(__name__)
5050

51-
SESSION_COOKIE_NAME = b"oidc_session"
51+
# we want the cookie to be returned to us even when the request is the POSTed
52+
# result of a form on another domain, as is used with `response_mode=form_post`.
53+
#
54+
# Modern browsers will not do so unless we set SameSite=None; however *older*
55+
# browsers (including all versions of Safari on iOS 12?) don't support
56+
# SameSite=None, and interpret it as SameSite=Strict:
57+
# https://bugs.webkit.org/show_bug.cgi?id=198181
58+
#
59+
# As a rather painful workaround, we set *two* cookies, one with SameSite=None
60+
# and one with no SameSite, in the hope that at least one of them will get
61+
# back to us.
62+
#
63+
# Secure is necessary for SameSite=None (and, empirically, also breaks things
64+
# on iOS 12.)
65+
#
66+
# Here we have the names of the cookies, and the options we use to set them.
67+
_SESSION_COOKIES = [
68+
(b"oidc_session", b"Path=/_synapse/client/oidc; HttpOnly; Secure; SameSite=None"),
69+
(b"oidc_session_no_samesite", b"Path=/_synapse/client/oidc; HttpOnly"),
70+
]
5271

5372
#: A token exchanged from the token endpoint, as per RFC6749 sec 5.1. and
5473
#: OpenID.Core sec 3.1.3.3.
@@ -149,26 +168,33 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
149168
# otherwise, it is presumably a successful response. see:
150169
# https://tools.ietf.org/html/rfc6749#section-4.1.2
151170

152-
# Fetch the session cookie
153-
session = request.getCookie(SESSION_COOKIE_NAME) # type: Optional[bytes]
154-
if session is None:
171+
# Fetch the session cookie. See the comments on SESSION_COOKIES for why there
172+
# are two.
173+
174+
for cookie_name, _ in _SESSION_COOKIES:
175+
session = request.getCookie(cookie_name) # type: Optional[bytes]
176+
if session is not None:
177+
break
178+
else:
155179
logger.info("Received OIDC callback, with no session cookie")
156180
self._sso_handler.render_error(
157181
request, "missing_session", "No session cookie found"
158182
)
159183
return
160184

161-
# Remove the cookie. There is a good chance that if the callback failed
185+
# Remove the cookies. There is a good chance that if the callback failed
162186
# once, it will fail next time and the code will already be exchanged.
163-
# Removing it early avoids spamming the provider with token requests.
164-
request.addCookie(
165-
SESSION_COOKIE_NAME,
166-
b"",
167-
path="/_synapse/oidc",
168-
expires="Thu, Jan 01 1970 00:00:00 UTC",
169-
httpOnly=True,
170-
sameSite="lax",
171-
)
187+
# Removing the cookies early avoids spamming the provider with token requests.
188+
#
189+
# we have to build the header by hand rather than calling request.addCookie
190+
# because the latter does not support SameSite=None
191+
# (https://twistedmatrix.com/trac/ticket/10088)
192+
193+
for cookie_name, options in _SESSION_COOKIES:
194+
request.cookies.append(
195+
b"%s=; Expires=Thu, Jan 01 1970 00:00:00 UTC; %s"
196+
% (cookie_name, options)
197+
)
172198

173199
# Check for the state query parameter
174200
if b"state" not in request.args:
@@ -722,14 +748,18 @@ async def handle_redirect_request(
722748
ui_auth_session_id=ui_auth_session_id,
723749
),
724750
)
725-
request.addCookie(
726-
SESSION_COOKIE_NAME,
727-
cookie,
728-
path="/_synapse/client/oidc",
729-
max_age="3600",
730-
httpOnly=True,
731-
sameSite="lax",
732-
)
751+
752+
# Set the cookies. See the comments on _SESSION_COOKIES for why there are two.
753+
#
754+
# we have to build the header by hand rather than calling request.addCookie
755+
# because the latter does not support SameSite=None
756+
# (https://twistedmatrix.com/trac/ticket/10088)
757+
758+
for cookie_name, options in _SESSION_COOKIES:
759+
request.cookies.append(
760+
b"%s=%s; Max-Age=3600; %s"
761+
% (cookie_name, cookie.encode("utf-8"), options)
762+
)
733763

734764
metadata = await self.load_metadata()
735765
authorization_endpoint = metadata.get("authorization_endpoint")

synapse/rest/synapse/client/oidc/callback_resource.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,30 @@
1212
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
15+
1516
import logging
17+
from typing import TYPE_CHECKING
1618

1719
from synapse.http.server import DirectServeHtmlResource
1820

21+
if TYPE_CHECKING:
22+
from synapse.server import HomeServer
23+
1924
logger = logging.getLogger(__name__)
2025

2126

2227
class OIDCCallbackResource(DirectServeHtmlResource):
2328
isLeaf = 1
2429

25-
def __init__(self, hs):
30+
def __init__(self, hs: "HomeServer"):
2631
super().__init__()
2732
self._oidc_handler = hs.get_oidc_handler()
2833

2934
async def _async_render_GET(self, request):
3035
await self._oidc_handler.handle_oidc_callback(request)
36+
37+
async def _async_render_POST(self, request):
38+
# the auth response can be returned via an x-www-form-urlencoded form instead
39+
# of GET params, as per
40+
# https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html.
41+
await self._oidc_handler.handle_oidc_callback(request)

tests/handlers/test_oidc.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,9 @@ def test_skip_verification(self):
327327

328328
def test_redirect_request(self):
329329
"""The redirect request has the right arguments & generates a valid session cookie."""
330-
req = Mock(spec=["addCookie"])
330+
req = Mock(spec=["cookies"])
331+
req.cookies = []
332+
331333
url = self.get_success(
332334
self.provider.handle_redirect_request(req, b"http://client/redirect")
333335
)
@@ -346,19 +348,16 @@ def test_redirect_request(self):
346348
self.assertEqual(len(params["state"]), 1)
347349
self.assertEqual(len(params["nonce"]), 1)
348350

349-
# Check what is in the cookie
350-
# note: python3.5 mock does not have the .called_once() method
351-
calls = req.addCookie.call_args_list
352-
self.assertEqual(len(calls), 1) # called once
353-
# For some reason, call.args does not work with python3.5
354-
args = calls[0][0]
355-
kwargs = calls[0][1]
351+
# Check what is in the cookies
352+
self.assertEqual(len(req.cookies), 2) # two cookies
353+
cookie_header = req.cookies[0]
356354

357355
# The cookie name and path don't really matter, just that it has to be coherent
358356
# between the callback & redirect handlers.
359-
self.assertEqual(args[0], b"oidc_session")
360-
self.assertEqual(kwargs["path"], "/_synapse/client/oidc")
361-
cookie = args[1]
357+
parts = [p.strip() for p in cookie_header.split(b";")]
358+
self.assertIn(b"Path=/_synapse/client/oidc", parts)
359+
name, cookie = parts[0].split(b"=")
360+
self.assertEqual(name, b"oidc_session")
362361

363362
macaroon = pymacaroons.Macaroon.deserialize(cookie)
364363
state = self.handler._token_generator._get_value_from_macaroon(
@@ -489,7 +488,7 @@ def test_callback(self):
489488

490489
def test_callback_session(self):
491490
"""The callback verifies the session presence and validity"""
492-
request = Mock(spec=["args", "getCookie", "addCookie"])
491+
request = Mock(spec=["args", "getCookie", "cookies"])
493492

494493
# Missing cookie
495494
request.args = {}
@@ -943,13 +942,14 @@ def _build_callback_request(
943942
spec=[
944943
"args",
945944
"getCookie",
946-
"addCookie",
945+
"cookies",
947946
"requestHeaders",
948947
"getClientIP",
949948
"getHeader",
950949
]
951950
)
952951

952+
request.cookies = []
953953
request.getCookie.return_value = session
954954
request.args = {}
955955
request.args[b"code"] = [code.encode("utf-8")]

0 commit comments

Comments
 (0)