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

Add initial support for a "pick your IdP" page #9017

Merged
merged 3 commits into from
Jan 5, 2021
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/9017.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for multiple SSO Identity Providers.
25 changes: 25 additions & 0 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1909,6 +1909,31 @@ sso:
#
# Synapse will look for the following templates in this directory:
#
# * HTML page to prompt the user to choose an Identity Provider during
# login: 'sso_login_idp_picker.html'.
#
# This is only used if multiple SSO Identity Providers are configured.
#
# When rendering, this template is given the following variables:
# * redirect_url: the URL that the user will be redirected to after
# login. Needs manual escaping (see
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
#
# * server_name: the homeserver's name.
#
# * providers: a list of available Identity Providers. Each element is
# an object with the following attributes:
# * idp_id: unique identifier for the IdP
# * idp_name: user-facing name for the IdP
#
# The rendered HTML page should contain a form which submits its results
# back as a GET request, with the following query parameters:
#
# * redirectUrl: the client redirect URI (ie, the `redirect_url` passed
# to the template)
#
# * idp: the 'idp_id' of the chosen IDP.
#
# * HTML page for a confirmation step before redirecting back to the client
# with the login token: 'sso_redirect_confirm.html'.
#
Expand Down
2 changes: 2 additions & 0 deletions synapse/app/homeserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
from synapse.rest.admin import AdminRestResource
from synapse.rest.health import HealthResource
from synapse.rest.key.v2 import KeyApiV2Resource
from synapse.rest.synapse.client.pick_idp import PickIdpResource
from synapse.rest.synapse.client.pick_username import pick_username_resource
from synapse.rest.well_known import WellKnownResource
from synapse.server import HomeServer
Expand Down Expand Up @@ -194,6 +195,7 @@ def _configure_named_resource(self, name, compress=False):
"/.well-known/matrix/client": WellKnownResource(self),
"/_synapse/admin": AdminRestResource(self),
"/_synapse/client/pick_username": pick_username_resource(self),
"/_synapse/client/pick_idp": PickIdpResource(self),
}
)

Expand Down
27 changes: 27 additions & 0 deletions synapse/config/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ def read_config(self, config, **kwargs):

# Read templates from disk
(
self.sso_login_idp_picker_template,
self.sso_redirect_confirm_template,
self.sso_auth_confirm_template,
self.sso_error_template,
sso_account_deactivated_template,
sso_auth_success_template,
) = self.read_templates(
[
"sso_login_idp_picker.html",
"sso_redirect_confirm.html",
"sso_auth_confirm.html",
"sso_error.html",
Expand Down Expand Up @@ -98,6 +100,31 @@ def generate_config_section(self, **kwargs):
#
# Synapse will look for the following templates in this directory:
#
# * HTML page to prompt the user to choose an Identity Provider during
# login: 'sso_login_idp_picker.html'.
#
# This is only used if multiple SSO Identity Providers are configured.
#
# When rendering, this template is given the following variables:
# * redirect_url: the URL that the user will be redirected to after
# login. Needs manual escaping (see
# https://jinja.palletsprojects.com/en/2.11.x/templates/#html-escaping).
#
# * server_name: the homeserver's name.
#
# * providers: a list of available Identity Providers. Each element is
# an object with the following attributes:
# * idp_id: unique identifier for the IdP
# * idp_name: user-facing name for the IdP
#
# The rendered HTML page should contain a form which submits its results
# back as a GET request, with the following query parameters:
#
# * redirectUrl: the client redirect URI (ie, the `redirect_url` passed
# to the template)
#
# * idp: the 'idp_id' of the chosen IDP.
#
# * HTML page for a confirmation step before redirecting back to the client
# with the login token: 'sso_redirect_confirm.html'.
#
Expand Down
3 changes: 3 additions & 0 deletions synapse/handlers/cas_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def __init__(self, hs: "HomeServer"):
# identifier for the external_ids table
self.idp_id = "cas"

# user-facing name of this auth provider
self.idp_name = "CAS"

self._sso_handler = hs.get_sso_handler()

self._sso_handler.register_identity_provider(self)
Expand Down
3 changes: 3 additions & 0 deletions synapse/handlers/oidc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def __init__(self, hs: "HomeServer"):
# identifier for the external_ids table
self.idp_id = "oidc"

# user-facing name of this auth provider
self.idp_name = "OIDC"

self._sso_handler = hs.get_sso_handler()

self._sso_handler.register_identity_provider(self)
Expand Down
3 changes: 3 additions & 0 deletions synapse/handlers/saml_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def __init__(self, hs: "HomeServer"):
# identifier for the external_ids table
self.idp_id = "saml"

# user-facing name of this auth provider
self.idp_name = "SAML"

# a map from saml session id to Saml2SessionData object
self._outstanding_requests_dict = {} # type: Dict[str, Saml2SessionData]

Expand Down
18 changes: 15 additions & 3 deletions synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
# limitations under the License.
import abc
import logging
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Optional
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, List, Mapping, Optional
from urllib.parse import urlencode

import attr
from typing_extensions import NoReturn, Protocol
Expand Down Expand Up @@ -66,6 +67,11 @@ def idp_id(self) -> str:
Eg, "saml", "cas", "github"
"""

@property
@abc.abstractmethod
def idp_name(self) -> str:
"""User-facing name for this provider"""

@abc.abstractmethod
async def handle_redirect_request(
self,
Expand Down Expand Up @@ -156,6 +162,10 @@ def register_identity_provider(self, p: SsoIdentityProvider):
assert p_id not in self._identity_providers
self._identity_providers[p_id] = p

def get_identity_providers(self) -> Mapping[str, SsoIdentityProvider]:
"""Get the configured identity providers"""
return self._identity_providers

def render_error(
self,
request: Request,
Expand Down Expand Up @@ -203,8 +213,10 @@ async def handle_redirect_request(
ap = next(iter(self._identity_providers.values()))
return await ap.handle_redirect_request(request, client_redirect_url)

# otherwise, we have a configuration error
raise Exception("Multiple SSO identity providers have been configured!")
# otherwise, redirect to the IDP picker
return "/_synapse/client/pick_idp?" + urlencode(
(("redirectUrl", client_redirect_url),)
)
Comment on lines +216 to +219
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a brief thought that we could just return the HTML template here instead of the redirect to avoid encoding the redirectURL, then decoding it, putting it into a template, then submitting it again back to the server...I think this implementation is OK, just seems to involve slightly more back and forth.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a brief thought that we could just return the HTML template here

Yeah, we probably could. The main reason I didn't was because /_matrix/client/r0/login/sso/redirect is served as a JsonResource, so any errors get rendered as json objects, which is a bit silly. Of course, that doesn't mean we handle any errors which do happen during the /_matrix/client/r0/login/sso/redirect request any more gracefully :/.


async def get_sso_user_by_remote_user_id(
self, auth_provider_id: str, remote_user_id: str
Expand Down
28 changes: 28 additions & 0 deletions synapse/res/templates/sso_login_idp_picker.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="/_matrix/static/client/login/style.css">
<title>{{server_name | e}} Login</title>
</head>
<body>
<div id="container">
<h1 id="title">{{server_name | e}} Login</h1>
<div class="login_flow">
<p>Choose one of the following identity providers:</p>
<form>
<input type="hidden" name="redirectUrl" value="{{redirect_url | e}}">
<ul class="radiobuttons">
{% for p in providers %}
<li>
<input type="radio" name="idp" id="prov{{loop.index}}" value="{{p.idp_id}}">
<label for="prov{{loop.index}}">{{p.idp_name | e}}</label>
</li>
{% endfor %}
</ul>
<input type="submit" class="button button--full-width" id="button-submit" value="Submit">
</form>
</div>
</div>
</body>
</html>
82 changes: 82 additions & 0 deletions synapse/rest/synapse/client/pick_idp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# Copyright 2021 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import TYPE_CHECKING

from synapse.http.server import (
DirectServeHtmlResource,
finish_request,
respond_with_html,
)
from synapse.http.servlet import parse_string
from synapse.http.site import SynapseRequest

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)


class PickIdpResource(DirectServeHtmlResource):
"""IdP picker resource.

This resource gets mounted under /_synapse/client/pick_idp. It serves an HTML page
which prompts the user to choose an Identity Provider from the list.
"""

def __init__(self, hs: "HomeServer"):
super().__init__()
self._sso_handler = hs.get_sso_handler()
self._sso_login_idp_picker_template = (
hs.config.sso.sso_login_idp_picker_template
)
self._server_name = hs.hostname

async def _async_render_GET(self, request: SynapseRequest) -> None:
client_redirect_url = parse_string(request, "redirectUrl", required=True)
idp = parse_string(request, "idp", required=False)

# if we need to pick an IdP, do so
if not idp:
return await self._serve_id_picker(request, client_redirect_url)

# otherwise, redirect to the IdP's redirect URI
providers = self._sso_handler.get_identity_providers()
auth_provider = providers.get(idp)
if not auth_provider:
logger.info("Unknown idp %r", idp)
self._sso_handler.render_error(
request, "unknown_idp", "Unknown identity provider ID"
)
return

sso_url = await auth_provider.handle_redirect_request(
request, client_redirect_url.encode("utf8")
)
logger.info("Redirecting to %s", sso_url)
request.redirect(sso_url)
finish_request(request)

async def _serve_id_picker(
self, request: SynapseRequest, client_redirect_url: str
) -> None:
# otherwise, serve up the IdP picker
providers = self._sso_handler.get_identity_providers()
html = self._sso_login_idp_picker_template.render(
redirect_url=client_redirect_url,
server_name=self._server_name,
providers=providers.values(),
)
respond_with_html(request, 200, html)
5 changes: 5 additions & 0 deletions synapse/static/client/login/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ form {
margin: 10px 0 0 0;
}

ul.radiobuttons {
text-align: left;
list-style: none;
}

/*
* Add some padding to the viewport.
*/
Expand Down