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

Abstract shared SSO code #8765

Merged
merged 7 commits into from
Nov 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/8765.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Consolidate logic between the OpenID Connect and SAML code.
92 changes: 33 additions & 59 deletions synapse/handlers/oidc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
from twisted.web.client import readBody

from synapse.config import ConfigError
from synapse.http.server import respond_with_html
from synapse.handlers._base import BaseHandler
from synapse.handlers.sso import MappingException
from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
Expand Down Expand Up @@ -83,17 +84,12 @@ def __str__(self):
return self.error


class MappingException(Exception):
"""Used to catch errors when mapping the UserInfo object
"""


class OidcHandler:
class OidcHandler(BaseHandler):
"""Handles requests related to the OpenID Connect login flow.
"""

def __init__(self, hs: "HomeServer"):
self.hs = hs
super().__init__(hs)
self._callback_url = hs.config.oidc_callback_url # type: str
self._scopes = hs.config.oidc_scopes # type: List[str]
self._user_profile_method = hs.config.oidc_user_profile_method # type: str
Expand All @@ -120,36 +116,13 @@ def __init__(self, hs: "HomeServer"):
self._http_client = hs.get_proxied_http_client()
self._auth_handler = hs.get_auth_handler()
self._registration_handler = hs.get_registration_handler()
self._datastore = hs.get_datastore()
self._clock = hs.get_clock()
self._hostname = hs.hostname # type: str
self._server_name = hs.config.server_name # type: str
self._macaroon_secret_key = hs.config.macaroon_secret_key
self._error_template = hs.config.sso_error_template

# identifier for the external_ids table
self._auth_provider_id = "oidc"

def _render_error(
self, request, error: str, error_description: Optional[str] = None
) -> None:
"""Render the error template and respond to the request with it.

This is used to show errors to the user. The template of this page can
be found under `synapse/res/templates/sso_error.html`.

Args:
request: The incoming request from the browser.
We'll respond with an HTML page describing the error.
error: A technical identifier for this error. Those include
well-known OAuth2/OIDC error types like invalid_request or
access_denied.
error_description: A human-readable description of the error.
"""
html = self._error_template.render(
error=error, error_description=error_description
)
respond_with_html(request, 400, html)
self._sso_handler = hs.get_sso_handler()

def _validate_metadata(self):
"""Verifies the provider metadata.
Expand Down Expand Up @@ -571,7 +544,7 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:

Since we might want to display OIDC-related errors in a user-friendly
way, we don't raise SynapseError from here. Instead, we call
``self._render_error`` which displays an HTML page for the error.
``self._sso_handler.render_error`` which displays an HTML page for the error.

Most of the OpenID Connect logic happens here:

Expand Down Expand Up @@ -609,7 +582,7 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
if error != "access_denied":
logger.error("Error from the OIDC provider: %s %s", error, description)

self._render_error(request, error, description)
self._sso_handler.render_error(request, error, description)
return

# otherwise, it is presumably a successful response. see:
Expand All @@ -619,7 +592,9 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
session = request.getCookie(SESSION_COOKIE_NAME) # type: Optional[bytes]
if session is None:
logger.info("No session cookie found")
self._render_error(request, "missing_session", "No session cookie found")
self._sso_handler.render_error(
request, "missing_session", "No session cookie found"
)
return

# Remove the cookie. There is a good chance that if the callback failed
Expand All @@ -637,7 +612,9 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
# Check for the state query parameter
if b"state" not in request.args:
logger.info("State parameter is missing")
self._render_error(request, "invalid_request", "State parameter is missing")
self._sso_handler.render_error(
request, "invalid_request", "State parameter is missing"
)
return

state = request.args[b"state"][0].decode()
Expand All @@ -651,17 +628,19 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
) = self._verify_oidc_session_token(session, state)
except MacaroonDeserializationException as e:
logger.exception("Invalid session")
self._render_error(request, "invalid_session", str(e))
self._sso_handler.render_error(request, "invalid_session", str(e))
return
except MacaroonInvalidSignatureException as e:
logger.exception("Could not verify session")
self._render_error(request, "mismatching_session", str(e))
self._sso_handler.render_error(request, "mismatching_session", str(e))
return

# Exchange the code with the provider
if b"code" not in request.args:
logger.info("Code parameter is missing")
self._render_error(request, "invalid_request", "Code parameter is missing")
self._sso_handler.render_error(
request, "invalid_request", "Code parameter is missing"
)
return

logger.debug("Exchanging code")
Expand All @@ -670,7 +649,7 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
token = await self._exchange_code(code)
except OidcError as e:
logger.exception("Could not exchange code")
self._render_error(request, e.error, e.error_description)
self._sso_handler.render_error(request, e.error, e.error_description)
return

logger.debug("Successfully obtained OAuth2 access token")
Expand All @@ -683,15 +662,15 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
userinfo = await self._fetch_userinfo(token)
except Exception as e:
logger.exception("Could not fetch userinfo")
self._render_error(request, "fetch_error", str(e))
self._sso_handler.render_error(request, "fetch_error", str(e))
return
else:
logger.debug("Extracting userinfo from id_token")
try:
userinfo = await self._parse_id_token(token, nonce=nonce)
except Exception as e:
logger.exception("Invalid id_token")
self._render_error(request, "invalid_token", str(e))
self._sso_handler.render_error(request, "invalid_token", str(e))
return

# Pull out the user-agent and IP from the request.
Expand All @@ -705,7 +684,7 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
)
except MappingException as e:
logger.exception("Could not map user")
self._render_error(request, "mapping_error", str(e))
self._sso_handler.render_error(request, "mapping_error", str(e))
return

# Mapping providers might not have get_extra_attributes: only call this
Expand Down Expand Up @@ -770,7 +749,7 @@ def _generate_oidc_session_token(
macaroon.add_first_party_caveat(
"ui_auth_session_id = %s" % (ui_auth_session_id,)
)
now = self._clock.time_msec()
now = self.clock.time_msec()
expiry = now + duration_in_ms
macaroon.add_first_party_caveat("time < %d" % (expiry,))

Expand Down Expand Up @@ -845,7 +824,7 @@ def _verify_expiry(self, caveat: str) -> bool:
if not caveat.startswith(prefix):
return False
expiry = int(caveat[len(prefix) :])
now = self._clock.time_msec()
now = self.clock.time_msec()
return now < expiry

async def _map_userinfo_to_user(
Expand Down Expand Up @@ -885,20 +864,14 @@ async def _map_userinfo_to_user(
# to be strings.
remote_user_id = str(remote_user_id)

logger.info(
"Looking for existing mapping for user %s:%s",
self._auth_provider_id,
remote_user_id,
)

registered_user_id = await self._datastore.get_user_by_external_id(
# first of all, check if we already have a mapping for this user
previously_registered_user_id = await self._sso_handler.get_sso_user_by_remote_user_id(
self._auth_provider_id, remote_user_id,
)
if previously_registered_user_id:
return previously_registered_user_id

if registered_user_id is not None:
logger.info("Found existing mapping %s", registered_user_id)
return registered_user_id

# Otherwise, generate a new user.
try:
attributes = await self._user_mapping_provider.map_user_attributes(
userinfo, token
Expand All @@ -917,8 +890,8 @@ async def _map_userinfo_to_user(

localpart = map_username_to_mxid_localpart(attributes["localpart"])

user_id = UserID(localpart, self._hostname).to_string()
users = await self._datastore.get_users_by_id_case_insensitive(user_id)
user_id = UserID(localpart, self.server_name).to_string()
users = await self.store.get_users_by_id_case_insensitive(user_id)
if users:
if self._allow_existing_users:
if len(users) == 1:
Expand All @@ -942,7 +915,8 @@ async def _map_userinfo_to_user(
default_display_name=attributes["display_name"],
user_agent_ips=(user_agent, ip_address),
)
await self._datastore.record_user_external_id(

await self.store.record_user_external_id(
self._auth_provider_id, remote_user_id, registered_user_id,
)
return registered_user_id
Expand Down
Loading