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

Support UI Authentication for OpenID Connect accounts #7457

Merged
merged 7 commits into from
May 15, 2020
Merged
Show file tree
Hide file tree
Changes from 5 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/7457.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add OpenID Connect login/registration support. Contributed by Quentin Gliech, on behalf of [les Connecteurs](https://connecteu.rs).
4 changes: 3 additions & 1 deletion synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ def __init__(self, hs):
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._sso_enabled = hs.config.saml2_enabled or hs.config.cas_enabled
self._sso_enabled = (
hs.config.cas_enabled or hs.config.saml2_enabled or hs.config.oidc_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
Expand Down
64 changes: 50 additions & 14 deletions synapse/handlers/oidc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,10 @@ async def _parse_id_token(self, token: Token, nonce: str) -> UserInfo:
return UserInfo(claims)

async def handle_redirect_request(
self, request: SynapseRequest, client_redirect_url: bytes
self,
request: SynapseRequest,
client_redirect_url: bytes,
ui_auth_session_id: Optional[str] = None,
) -> None:
clokep marked this conversation as resolved.
Show resolved Hide resolved
"""Handle an incoming request to /login/sso/redirect

Expand All @@ -522,13 +525,19 @@ async def handle_redirect_request(
We'll respond to it with a redirect and a cookie.
client_redirect_url: the URL that we should redirect the client to
when everything is done
ui_auth_session_id: The session ID of the ongoing UI Auth (or
None if this is a login).

"""

state = generate_token()
nonce = generate_token()

cookie = self._generate_oidc_session_token(
state=state, nonce=nonce, client_redirect_url=client_redirect_url.decode(),
state=state,
nonce=nonce,
client_redirect_url=client_redirect_url.decode(),
ui_auth_session_id=ui_auth_session_id,
)
request.addCookie(
SESSION_COOKIE_NAME,
Expand All @@ -541,7 +550,7 @@ async def handle_redirect_request(

metadata = await self.load_metadata()
authorization_endpoint = metadata.get("authorization_endpoint")
uri = prepare_grant_uri(
return prepare_grant_uri(
authorization_endpoint,
client_id=self._client_auth.client_id,
response_type="code",
Expand All @@ -550,8 +559,6 @@ async def handle_redirect_request(
state=state,
nonce=nonce,
)
request.redirect(uri)
finish_request(request)

async def handle_oidc_callback(self, request: SynapseRequest) -> None:
"""Handle an incoming request to /_synapse/oidc/callback
Expand Down Expand Up @@ -625,7 +632,11 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:

# Deserialize the session token and verify it.
try:
nonce, client_redirect_url = self._verify_oidc_session_token(session, state)
(
nonce,
client_redirect_url,
ui_auth_session_id,
) = self._verify_oidc_session_token(session, state)
except MacaroonDeserializationException as e:
logger.exception("Invalid session")
self._render_error(request, "invalid_session", str(e))
Expand Down Expand Up @@ -678,15 +689,21 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
return

# and finally complete the login
await self._auth_handler.complete_sso_login(
user_id, request, client_redirect_url
)
if ui_auth_session_id:
await self._auth_handler.complete_sso_ui_auth(
user_id, ui_auth_session_id, request
)
else:
await self._auth_handler.complete_sso_login(
user_id, request, client_redirect_url
)

def _generate_oidc_session_token(
self,
state: str,
nonce: str,
client_redirect_url: str,
ui_auth_session_id: Optional[str],
duration_in_ms: int = (60 * 60 * 1000),
) -> str:
"""Generates a signed token storing data about an OIDC session.
Expand All @@ -702,6 +719,8 @@ def _generate_oidc_session_token(
nonce: The ``nonce`` parameter passed to the OIDC provider.
client_redirect_url: The URL the client gave when it initiated the
flow.
ui_auth_session_id: The session ID of the ongoing UI Auth (or
None if this is a login).
duration_in_ms: An optional duration for the token in milliseconds.
Defaults to an hour.

Expand All @@ -718,12 +737,19 @@ def _generate_oidc_session_token(
macaroon.add_first_party_caveat(
"client_redirect_url = %s" % (client_redirect_url,)
)
if ui_auth_session_id:
macaroon.add_first_party_caveat(
"ui_auth_session_id = %s" % (ui_auth_session_id,)
)
now = self._clock.time_msec()
expiry = now + duration_in_ms
macaroon.add_first_party_caveat("time < %d" % (expiry,))

return macaroon.serialize()

def _verify_oidc_session_token(self, session: str, state: str) -> Tuple[str, str]:
def _verify_oidc_session_token(
self, session: str, state: str
) -> Tuple[str, str, Optional[str]]:
"""Verifies and extract an OIDC session token.

This verifies that a given session token was issued by this homeserver
Expand All @@ -734,7 +760,7 @@ def _verify_oidc_session_token(self, session: str, state: str) -> Tuple[str, str
state: The state the OIDC provider gave back

Returns:
The nonce and the client_redirect_url for this session
The nonce, client_redirect_url, and ui_auth_session_id for this session
"""
macaroon = pymacaroons.Macaroon.deserialize(session)

Expand All @@ -744,17 +770,27 @@ def _verify_oidc_session_token(self, session: str, state: str) -> Tuple[str, str
v.satisfy_exact("state = %s" % (state,))
v.satisfy_general(lambda c: c.startswith("nonce = "))
v.satisfy_general(lambda c: c.startswith("client_redirect_url = "))
# Sometimes there's a UI auth session ID, it seems to be OK to attempt
# to always satisfy this.
v.satisfy_general(lambda c: c.startswith("ui_auth_session_id = "))
v.satisfy_general(self._verify_expiry)

v.verify(macaroon, self._macaroon_secret_key)

# Extract the `nonce` and `client_redirect_url` from the token
# Extract the `nonce`, `client_redirect_url`, and maybe the
# `ui_auth_session_id` from the token.
nonce = self._get_value_from_macaroon(macaroon, "nonce")
client_redirect_url = self._get_value_from_macaroon(
macaroon, "client_redirect_url"
)
try:
ui_auth_session_id = self._get_value_from_macaroon(
macaroon, "ui_auth_session_id"
) # type: Optional[str]
except ValueError:
ui_auth_session_id = None

return nonce, client_redirect_url
return nonce, client_redirect_url, ui_auth_session_id

def _get_value_from_macaroon(self, macaroon: pymacaroons.Macaroon, key: str) -> str:
"""Extracts a caveat value from a macaroon token.
Expand All @@ -773,7 +809,7 @@ def _get_value_from_macaroon(self, macaroon: pymacaroons.Macaroon, key: str) ->
for caveat in macaroon.caveats:
if caveat.caveat_id.startswith(prefix):
return caveat.caveat_id[len(prefix) :]
raise Exception("No %s caveat in macaroon" % (key,))
raise ValueError("No %s caveat in macaroon" % (key,))

def _verify_expiry(self, caveat: str) -> bool:
prefix = "time < "
Expand Down
31 changes: 19 additions & 12 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,19 +401,22 @@ class BaseSSORedirectServlet(RestServlet):

PATTERNS = client_patterns("/login/(cas|sso)/redirect", v1=True)

def on_GET(self, request: SynapseRequest):
async def on_GET(self, request: SynapseRequest):
args = request.args
if b"redirectUrl" not in args:
return 400, "Redirect URL not specified for SSO auth"
client_redirect_url = args[b"redirectUrl"][0]
sso_url = self.get_sso_url(client_redirect_url)
sso_url = await self.get_sso_url(request, client_redirect_url)
request.redirect(sso_url)
finish_request(request)

def get_sso_url(self, client_redirect_url: bytes) -> bytes:
async def get_sso_url(
self, request: SynapseRequest, client_redirect_url: bytes
) -> bytes:
"""Get the URL to redirect to, to perform SSO auth

Args:
request: The client request to redirect.
client_redirect_url: the URL that we should redirect the
client to when everything is done

Expand All @@ -428,7 +431,9 @@ class CasRedirectServlet(BaseSSORedirectServlet):
def __init__(self, hs):
self._cas_handler = hs.get_cas_handler()

def get_sso_url(self, client_redirect_url: bytes) -> bytes:
async def get_sso_url(
self, request: SynapseRequest, client_redirect_url: bytes
) -> bytes:
return self._cas_handler.get_redirect_url(
{"redirectUrl": client_redirect_url}
).encode("ascii")
Expand Down Expand Up @@ -465,24 +470,26 @@ class SAMLRedirectServlet(BaseSSORedirectServlet):
def __init__(self, hs):
self._saml_handler = hs.get_saml_handler()

def get_sso_url(self, client_redirect_url: bytes) -> bytes:
async def get_sso_url(
self, request: SynapseRequest, client_redirect_url: bytes
) -> bytes:
return self._saml_handler.handle_redirect_request(client_redirect_url)


class OIDCRedirectServlet(RestServlet):
class OIDCRedirectServlet(BaseSSORedirectServlet):
"""Implementation for /login/sso/redirect for the OIDC login flow."""

PATTERNS = client_patterns("/login/sso/redirect", v1=True)

def __init__(self, hs):
self._oidc_handler = hs.get_oidc_handler()

async def on_GET(self, request):
args = request.args
if b"redirectUrl" not in args:
return 400, "Redirect URL not specified for SSO auth"
client_redirect_url = args[b"redirectUrl"][0]
await self._oidc_handler.handle_redirect_request(request, client_redirect_url)
async def get_sso_url(
self, request: SynapseRequest, client_redirect_url: bytes
) -> bytes:
return await self._oidc_handler.handle_redirect_request(
request, client_redirect_url
)


def register_servlets(hs, http_server):
Expand Down
19 changes: 15 additions & 4 deletions synapse/rest/client/v2_alpha/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,19 @@ def __init__(self, hs):
self.registration_handler = hs.get_registration_handler()

# SSO configuration.
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
self._saml_enabled = hs.config.saml2_enabled
if self._saml_enabled:
self._saml_handler = hs.get_saml_handler()
self._oidc_enabled = hs.config.oidc_enabled
if self._oidc_enabled:
self._oidc_handler = hs.get_oidc_handler()
self._cas_server_url = hs.config.cas_server_url
self._cas_service_url = hs.config.cas_service_url

async def on_GET(self, request, stagetype):
session = parse_string(request, "session")
Expand Down Expand Up @@ -172,11 +177,17 @@ async def on_GET(self, request, stagetype):
)

elif self._saml_enabled:
client_redirect_url = ""
client_redirect_url = b""
sso_redirect_url = self._saml_handler.handle_redirect_request(
client_redirect_url, session
)

elif self._oidc_enabled:
client_redirect_url = b""
sso_redirect_url = await self._oidc_handler.handle_redirect_request(
request, client_redirect_url, session
)

else:
raise SynapseError(400, "Homeserver not configured for SSO.")

Expand Down
15 changes: 10 additions & 5 deletions tests/handlers/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,10 @@ def test_skip_verification(self):
@defer.inlineCallbacks
def test_redirect_request(self):
"""The redirect request has the right arguments & generates a valid session cookie."""
req = Mock(spec=["addCookie", "redirect", "finish"])
yield defer.ensureDeferred(
req = Mock(spec=["addCookie"])
url = yield defer.ensureDeferred(
self.handler.handle_redirect_request(req, b"http://client/redirect")
)
url = req.redirect.call_args[0][0]
url = urlparse(url)
auth_endpoint = urlparse(AUTHORIZATION_ENDPOINT)

Expand Down Expand Up @@ -382,7 +381,10 @@ def test_callback(self):
nonce = "nonce"
client_redirect_url = "http://client/redirect"
session = self.handler._generate_oidc_session_token(
state=state, nonce=nonce, client_redirect_url=client_redirect_url,
state=state,
nonce=nonce,
client_redirect_url=client_redirect_url,
ui_auth_session_id=None,
)
request.getCookie.return_value = session

Expand Down Expand Up @@ -472,7 +474,10 @@ def test_callback_session(self):

# Mismatching session
session = self.handler._generate_oidc_session_token(
state="state", nonce="nonce", client_redirect_url="http://client/redirect",
state="state",
nonce="nonce",
client_redirect_url="http://client/redirect",
ui_auth_session_id=None,
)
request.args = {}
request.args[b"state"] = [b"mismatching state"]
Expand Down