diff --git a/changelog.d/7186.feature b/changelog.d/7186.feature
new file mode 100644
index 0000000000..01057aa396
--- /dev/null
+++ b/changelog.d/7186.feature
@@ -0,0 +1 @@
+Support SSO in the user interactive authentication workflow.
diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py
index 7c09d15a72..892adb00b9 100644
--- a/synapse/handlers/auth.py
+++ b/synapse/handlers/auth.py
@@ -116,7 +116,7 @@ class AuthHandler(BaseHandler):
self.hs = hs # FIXME better possibility to access registrationHandler later?
self.macaroon_gen = hs.get_macaroon_generator()
self._password_enabled = hs.config.password_enabled
- self._saml2_enabled = hs.config.saml2_enabled
+ self._sso_enabled = hs.config.saml2_enabled or hs.config.cas_enabled
# we keep this as a list despite the O(N^2) implication so that we can
# keep PASSWORD first and avoid confusing clients which pick the first
@@ -136,7 +136,7 @@ class AuthHandler(BaseHandler):
# necessarily identical. Login types have SSO (and other login types)
# added in the rest layer, see synapse.rest.client.v1.login.LoginRestServerlet.on_GET.
ui_auth_types = login_types.copy()
- if self._saml2_enabled:
+ if self._sso_enabled:
ui_auth_types.append(LoginType.SSO)
self._supported_ui_auth_types = ui_auth_types
diff --git a/synapse/handlers/cas_handler.py b/synapse/handlers/cas_handler.py
index f8dc274b78..d977badf35 100644
--- a/synapse/handlers/cas_handler.py
+++ b/synapse/handlers/cas_handler.py
@@ -15,7 +15,7 @@
import logging
import xml.etree.ElementTree as ET
-from typing import AnyStr, Dict, Optional, Tuple
+from typing import Dict, Optional, Tuple
from six.moves import urllib
@@ -48,26 +48,47 @@ class CasHandler:
self._http_client = hs.get_proxied_http_client()
- def _build_service_param(self, client_redirect_url: AnyStr) -> str:
+ def _build_service_param(self, args: Dict[str, str]) -> str:
+ """
+ Generates a value to use as the "service" parameter when redirecting or
+ querying the CAS service.
+
+ Args:
+ args: Additional arguments to include in the final redirect URL.
+
+ Returns:
+ The URL to use as a "service" parameter.
+ """
return "%s%s?%s" % (
self._cas_service_url,
"/_matrix/client/r0/login/cas/ticket",
- urllib.parse.urlencode({"redirectUrl": client_redirect_url}),
+ urllib.parse.urlencode(args),
)
- async def _handle_cas_response(
- self, request: SynapseRequest, cas_response_body: str, client_redirect_url: str
- ) -> None:
+ async def _validate_ticket(
+ self, ticket: str, service_args: Dict[str, str]
+ ) -> Tuple[str, Optional[str]]:
"""
- Retrieves the user and display name from the CAS response and continues with the authentication.
+ Validate a CAS ticket with the server, parse the response, and return the user and display name.
Args:
- request: The original client request.
- cas_response_body: The response from the CAS server.
- client_redirect_url: The URl to redirect the client to when
- everything is done.
+ ticket: The CAS ticket from the client.
+ service_args: Additional arguments to include in the service URL.
+ Should be the same as those passed to `get_redirect_url`.
"""
- user, attributes = self._parse_cas_response(cas_response_body)
+ uri = self._cas_server_url + "/proxyValidate"
+ args = {
+ "ticket": ticket,
+ "service": self._build_service_param(service_args),
+ }
+ try:
+ body = await self._http_client.get_raw(uri, args)
+ except PartialDownloadError as pde:
+ # Twisted raises this error if the connection is closed,
+ # even if that's being used old-http style to signal end-of-data
+ body = pde.response
+
+ user, attributes = self._parse_cas_response(body)
displayname = attributes.pop(self._cas_displayname_attribute, None)
for required_attribute, required_value in self._cas_required_attributes.items():
@@ -82,7 +103,7 @@ class CasHandler:
if required_value != actual_value:
raise LoginError(401, "Unauthorized", errcode=Codes.UNAUTHORIZED)
- await self._on_successful_auth(user, request, client_redirect_url, displayname)
+ return user, displayname
def _parse_cas_response(
self, cas_response_body: str
@@ -127,78 +148,74 @@ class CasHandler:
)
return user, attributes
- async def _on_successful_auth(
- self,
- username: str,
- request: SynapseRequest,
- client_redirect_url: str,
- user_display_name: Optional[str] = None,
- ) -> None:
- """Called once the user has successfully authenticated with the SSO.
-
- Registers the user if necessary, and then returns a redirect (with
- a login token) to the client.
+ def get_redirect_url(self, service_args: Dict[str, str]) -> str:
+ """
+ Generates a URL for the CAS server where the client should be redirected.
Args:
- username: the remote user id. We'll map this onto
- something sane for a MXID localpath.
+ service_args: Additional arguments to include in the final redirect URL.
- request: the incoming request from the browser. We'll
- respond to it with a redirect.
+ Returns:
+ The URL to redirect the client to.
+ """
+ args = urllib.parse.urlencode(
+ {"service": self._build_service_param(service_args)}
+ )
- client_redirect_url: the redirect_url the client gave us when
- it first started the process.
+ return "%s/login?%s" % (self._cas_server_url, args)
- user_display_name: if set, and we have to register a new user,
- we will set their displayname to this.
+ async def handle_ticket(
+ self,
+ request: SynapseRequest,
+ ticket: str,
+ client_redirect_url: Optional[str],
+ session: Optional[str],
+ ) -> None:
"""
- localpart = map_username_to_mxid_localpart(username)
- user_id = UserID(localpart, self._hostname).to_string()
- registered_user_id = await self._auth_handler.check_user_exists(user_id)
- if not registered_user_id:
- registered_user_id = await self._registration_handler.register_user(
- localpart=localpart, default_display_name=user_display_name
- )
+ Called once the user has successfully authenticated with the SSO.
+ Validates a CAS ticket sent by the client and completes the auth process.
- self._auth_handler.complete_sso_login(
- registered_user_id, request, client_redirect_url
- )
+ If the user interactive authentication session is provided, marks the
+ UI Auth session as complete, then returns an HTML page notifying the
+ user they are done.
- def handle_redirect_request(self, client_redirect_url: bytes) -> bytes:
- """
- Generates a URL to the CAS server where the client should be redirected.
+ Otherwise, this registers the user if necessary, and then returns a
+ redirect (with a login token) to the client.
Args:
- client_redirect_url: The final URL the client should go to after the
- user has negotiated SSO.
+ request: the incoming request from the browser. We'll
+ respond to it with a redirect or an HTML page.
- Returns:
- The URL to redirect to.
- """
- args = urllib.parse.urlencode(
- {"service": self._build_service_param(client_redirect_url)}
- )
+ ticket: The CAS ticket provided by the client.
- return ("%s/login?%s" % (self._cas_server_url, args)).encode("ascii")
+ client_redirect_url: the redirectUrl parameter from the `/cas/ticket` HTTP request, if given.
+ This should be the same as the redirectUrl from the original `/login/sso/redirect` request.
- async def handle_ticket_request(
- self, request: SynapseRequest, client_redirect_url: str, ticket: str
- ) -> None:
+ session: The session parameter from the `/cas/ticket` HTTP request, if given.
+ This should be the UI Auth session id.
"""
- Validates a CAS ticket sent by the client for login/registration.
+ args = {}
+ if client_redirect_url:
+ args["redirectUrl"] = client_redirect_url
+ if session:
+ args["session"] = session
+ username, user_display_name = await self._validate_ticket(ticket, args)
- On a successful request, writes a redirect to the request.
- """
- uri = self._cas_server_url + "/proxyValidate"
- args = {
- "ticket": ticket,
- "service": self._build_service_param(client_redirect_url),
- }
- try:
- body = await self._http_client.get_raw(uri, args)
- except PartialDownloadError as pde:
- # Twisted raises this error if the connection is closed,
- # even if that's being used old-http style to signal end-of-data
- body = pde.response
+ localpart = map_username_to_mxid_localpart(username)
+ user_id = UserID(localpart, self._hostname).to_string()
+ registered_user_id = await self._auth_handler.check_user_exists(user_id)
- await self._handle_cas_response(request, body, client_redirect_url)
+ if session:
+ self._auth_handler.complete_sso_ui_auth(
+ registered_user_id, session, request,
+ )
+
+ else:
+ if not registered_user_id:
+ registered_user_id = await self._registration_handler.register_user(
+ localpart=localpart, default_display_name=user_display_name
+ )
+
+ self._auth_handler.complete_sso_login(
+ registered_user_id, request, client_redirect_url
+ )
diff --git a/synapse/rest/client/v1/login.py b/synapse/rest/client/v1/login.py
index 59593cbf6e..4de2f97d06 100644
--- a/synapse/rest/client/v1/login.py
+++ b/synapse/rest/client/v1/login.py
@@ -425,7 +425,9 @@ class CasRedirectServlet(BaseSSORedirectServlet):
self._cas_handler = hs.get_cas_handler()
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
- return self._cas_handler.handle_redirect_request(client_redirect_url)
+ return self._cas_handler.get_redirect_url(
+ {"redirectUrl": client_redirect_url}
+ ).encode("ascii")
class CasTicketServlet(RestServlet):
@@ -436,10 +438,20 @@ class CasTicketServlet(RestServlet):
self._cas_handler = hs.get_cas_handler()
async def on_GET(self, request: SynapseRequest) -> None:
- client_redirect_url = parse_string(request, "redirectUrl", required=True)
+ client_redirect_url = parse_string(request, "redirectUrl")
ticket = parse_string(request, "ticket", required=True)
- await self._cas_handler.handle_ticket_request(
- request, client_redirect_url, ticket
+
+ # Maybe get a session ID (if this ticket is from user interactive
+ # authentication).
+ session = parse_string(request, "session")
+
+ # Either client_redirect_url or session must be provided.
+ if not client_redirect_url and not session:
+ message = "Missing string query parameter redirectUrl or session"
+ raise SynapseError(400, message, errcode=Codes.MISSING_PARAM)
+
+ await self._cas_handler.handle_ticket(
+ request, ticket, client_redirect_url, session
)
diff --git a/synapse/rest/client/v2_alpha/auth.py b/synapse/rest/client/v2_alpha/auth.py
index 1787562b90..13f9604407 100644
--- a/synapse/rest/client/v2_alpha/auth.py
+++ b/synapse/rest/client/v2_alpha/auth.py
@@ -111,6 +111,11 @@ class AuthRestServlet(RestServlet):
self._saml_enabled = hs.config.saml2_enabled
if self._saml_enabled:
self._saml_handler = hs.get_saml_handler()
+ self._cas_enabled = hs.config.cas_enabled
+ if self._cas_enabled:
+ self._cas_handler = hs.get_cas_handler()
+ self._cas_server_url = hs.config.cas_server_url
+ self._cas_service_url = hs.config.cas_service_url
def on_GET(self, request, stagetype):
session = parse_string(request, "session")
@@ -133,14 +138,27 @@ class AuthRestServlet(RestServlet):
% (CLIENT_API_PREFIX, LoginType.TERMS),
}
- elif stagetype == LoginType.SSO and self._saml_enabled:
+ elif stagetype == LoginType.SSO:
# Display a confirmation page which prompts the user to
# re-authenticate with their SSO provider.
- client_redirect_url = ""
- sso_redirect_url = self._saml_handler.handle_redirect_request(
- client_redirect_url, session
- )
+ if self._cas_enabled:
+ # Generate a request to CAS that redirects back to an endpoint
+ # to verify the successful authentication.
+ sso_redirect_url = self._cas_handler.get_redirect_url(
+ {"session": session},
+ )
+
+ elif self._saml_enabled:
+ client_redirect_url = ""
+ sso_redirect_url = self._saml_handler.handle_redirect_request(
+ client_redirect_url, session
+ )
+
+ else:
+ raise SynapseError(400, "Homeserver not configured for SSO.")
+
html = self.auth_handler.start_sso_ui_auth(sso_redirect_url, session)
+
else:
raise SynapseError(404, "Unknown auth stage type")
|