Skip to content
Snippets Groups Projects
Unverified Commit e1071fd6 authored by Richard van der Hoff's avatar Richard van der Hoff Committed by GitHub
Browse files

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.
parent 33f64ca7
No related branches found
No related tags found
No related merge requests found
Add support for receiving OpenID Connect authentication responses via form `POST`s rather than `GET`s.
...@@ -48,7 +48,26 @@ if TYPE_CHECKING: ...@@ -48,7 +48,26 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SESSION_COOKIE_NAME = b"oidc_session" # we want the cookie to be returned to us even when the request is the POSTed
# result of a form on another domain, as is used with `response_mode=form_post`.
#
# Modern browsers will not do so unless we set SameSite=None; however *older*
# browsers (including all versions of Safari on iOS 12?) don't support
# SameSite=None, and interpret it as SameSite=Strict:
# https://bugs.webkit.org/show_bug.cgi?id=198181
#
# As a rather painful workaround, we set *two* cookies, one with SameSite=None
# and one with no SameSite, in the hope that at least one of them will get
# back to us.
#
# Secure is necessary for SameSite=None (and, empirically, also breaks things
# on iOS 12.)
#
# Here we have the names of the cookies, and the options we use to set them.
_SESSION_COOKIES = [
(b"oidc_session", b"Path=/_synapse/client/oidc; HttpOnly; Secure; SameSite=None"),
(b"oidc_session_no_samesite", b"Path=/_synapse/client/oidc; HttpOnly"),
]
#: A token exchanged from the token endpoint, as per RFC6749 sec 5.1. and #: A token exchanged from the token endpoint, as per RFC6749 sec 5.1. and
#: OpenID.Core sec 3.1.3.3. #: OpenID.Core sec 3.1.3.3.
...@@ -149,26 +168,33 @@ class OidcHandler: ...@@ -149,26 +168,33 @@ class OidcHandler:
# otherwise, it is presumably a successful response. see: # otherwise, it is presumably a successful response. see:
# https://tools.ietf.org/html/rfc6749#section-4.1.2 # https://tools.ietf.org/html/rfc6749#section-4.1.2
# Fetch the session cookie # Fetch the session cookie. See the comments on SESSION_COOKIES for why there
session = request.getCookie(SESSION_COOKIE_NAME) # type: Optional[bytes] # are two.
if session is None:
for cookie_name, _ in _SESSION_COOKIES:
session = request.getCookie(cookie_name) # type: Optional[bytes]
if session is not None:
break
else:
logger.info("Received OIDC callback, with no session cookie") logger.info("Received OIDC callback, with no session cookie")
self._sso_handler.render_error( self._sso_handler.render_error(
request, "missing_session", "No session cookie found" request, "missing_session", "No session cookie found"
) )
return return
# Remove the cookie. There is a good chance that if the callback failed # Remove the cookies. There is a good chance that if the callback failed
# once, it will fail next time and the code will already be exchanged. # once, it will fail next time and the code will already be exchanged.
# Removing it early avoids spamming the provider with token requests. # Removing the cookies early avoids spamming the provider with token requests.
request.addCookie( #
SESSION_COOKIE_NAME, # we have to build the header by hand rather than calling request.addCookie
b"", # because the latter does not support SameSite=None
path="/_synapse/oidc", # (https://twistedmatrix.com/trac/ticket/10088)
expires="Thu, Jan 01 1970 00:00:00 UTC",
httpOnly=True, for cookie_name, options in _SESSION_COOKIES:
sameSite="lax", request.cookies.append(
) b"%s=; Expires=Thu, Jan 01 1970 00:00:00 UTC; %s"
% (cookie_name, options)
)
# Check for the state query parameter # Check for the state query parameter
if b"state" not in request.args: if b"state" not in request.args:
...@@ -722,14 +748,18 @@ class OidcProvider: ...@@ -722,14 +748,18 @@ class OidcProvider:
ui_auth_session_id=ui_auth_session_id, ui_auth_session_id=ui_auth_session_id,
), ),
) )
request.addCookie(
SESSION_COOKIE_NAME, # Set the cookies. See the comments on _SESSION_COOKIES for why there are two.
cookie, #
path="/_synapse/client/oidc", # we have to build the header by hand rather than calling request.addCookie
max_age="3600", # because the latter does not support SameSite=None
httpOnly=True, # (https://twistedmatrix.com/trac/ticket/10088)
sameSite="lax",
) for cookie_name, options in _SESSION_COOKIES:
request.cookies.append(
b"%s=%s; Max-Age=3600; %s"
% (cookie_name, cookie.encode("utf-8"), options)
)
metadata = await self.load_metadata() metadata = await self.load_metadata()
authorization_endpoint = metadata.get("authorization_endpoint") authorization_endpoint = metadata.get("authorization_endpoint")
......
...@@ -12,19 +12,30 @@ ...@@ -12,19 +12,30 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging import logging
from typing import TYPE_CHECKING
from synapse.http.server import DirectServeHtmlResource from synapse.http.server import DirectServeHtmlResource
if TYPE_CHECKING:
from synapse.server import HomeServer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class OIDCCallbackResource(DirectServeHtmlResource): class OIDCCallbackResource(DirectServeHtmlResource):
isLeaf = 1 isLeaf = 1
def __init__(self, hs): def __init__(self, hs: "HomeServer"):
super().__init__() super().__init__()
self._oidc_handler = hs.get_oidc_handler() self._oidc_handler = hs.get_oidc_handler()
async def _async_render_GET(self, request): async def _async_render_GET(self, request):
await self._oidc_handler.handle_oidc_callback(request) await self._oidc_handler.handle_oidc_callback(request)
async def _async_render_POST(self, request):
# the auth response can be returned via an x-www-form-urlencoded form instead
# of GET params, as per
# https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html.
await self._oidc_handler.handle_oidc_callback(request)
...@@ -327,7 +327,9 @@ class OidcHandlerTestCase(HomeserverTestCase): ...@@ -327,7 +327,9 @@ class OidcHandlerTestCase(HomeserverTestCase):
def test_redirect_request(self): def test_redirect_request(self):
"""The redirect request has the right arguments & generates a valid session cookie.""" """The redirect request has the right arguments & generates a valid session cookie."""
req = Mock(spec=["addCookie"]) req = Mock(spec=["cookies"])
req.cookies = []
url = self.get_success( url = self.get_success(
self.provider.handle_redirect_request(req, b"http://client/redirect") self.provider.handle_redirect_request(req, b"http://client/redirect")
) )
...@@ -346,19 +348,16 @@ class OidcHandlerTestCase(HomeserverTestCase): ...@@ -346,19 +348,16 @@ class OidcHandlerTestCase(HomeserverTestCase):
self.assertEqual(len(params["state"]), 1) self.assertEqual(len(params["state"]), 1)
self.assertEqual(len(params["nonce"]), 1) self.assertEqual(len(params["nonce"]), 1)
# Check what is in the cookie # Check what is in the cookies
# note: python3.5 mock does not have the .called_once() method self.assertEqual(len(req.cookies), 2) # two cookies
calls = req.addCookie.call_args_list cookie_header = req.cookies[0]
self.assertEqual(len(calls), 1) # called once
# For some reason, call.args does not work with python3.5
args = calls[0][0]
kwargs = calls[0][1]
# The cookie name and path don't really matter, just that it has to be coherent # The cookie name and path don't really matter, just that it has to be coherent
# between the callback & redirect handlers. # between the callback & redirect handlers.
self.assertEqual(args[0], b"oidc_session") parts = [p.strip() for p in cookie_header.split(b";")]
self.assertEqual(kwargs["path"], "/_synapse/client/oidc") self.assertIn(b"Path=/_synapse/client/oidc", parts)
cookie = args[1] name, cookie = parts[0].split(b"=")
self.assertEqual(name, b"oidc_session")
macaroon = pymacaroons.Macaroon.deserialize(cookie) macaroon = pymacaroons.Macaroon.deserialize(cookie)
state = self.handler._token_generator._get_value_from_macaroon( state = self.handler._token_generator._get_value_from_macaroon(
...@@ -489,7 +488,7 @@ class OidcHandlerTestCase(HomeserverTestCase): ...@@ -489,7 +488,7 @@ class OidcHandlerTestCase(HomeserverTestCase):
def test_callback_session(self): def test_callback_session(self):
"""The callback verifies the session presence and validity""" """The callback verifies the session presence and validity"""
request = Mock(spec=["args", "getCookie", "addCookie"]) request = Mock(spec=["args", "getCookie", "cookies"])
# Missing cookie # Missing cookie
request.args = {} request.args = {}
...@@ -943,13 +942,14 @@ def _build_callback_request( ...@@ -943,13 +942,14 @@ def _build_callback_request(
spec=[ spec=[
"args", "args",
"getCookie", "getCookie",
"addCookie", "cookies",
"requestHeaders", "requestHeaders",
"getClientIP", "getClientIP",
"getHeader", "getHeader",
] ]
) )
request.cookies = []
request.getCookie.return_value = session request.getCookie.return_value = session
request.args = {} request.args = {}
request.args[b"code"] = [code.encode("utf-8")] request.args[b"code"] = [code.encode("utf-8")]
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment