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

Commit a3cf36f

Browse files
authored
Support UI Authentication for OpenID Connect accounts (#7457)
1 parent 03aff4c commit a3cf36f

File tree

6 files changed

+105
-41
lines changed

6 files changed

+105
-41
lines changed

changelog.d/7457.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add OpenID Connect login/registration support. Contributed by Quentin Gliech, on behalf of [les Connecteurs](https://connecteu.rs).

synapse/handlers/auth.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ def __init__(self, hs):
8080
self.hs = hs # FIXME better possibility to access registrationHandler later?
8181
self.macaroon_gen = hs.get_macaroon_generator()
8282
self._password_enabled = hs.config.password_enabled
83-
self._sso_enabled = hs.config.saml2_enabled or hs.config.cas_enabled
83+
self._sso_enabled = (
84+
hs.config.cas_enabled or hs.config.saml2_enabled or hs.config.oidc_enabled
85+
)
8486

8587
# we keep this as a list despite the O(N^2) implication so that we can
8688
# keep PASSWORD first and avoid confusing clients which pick the first

synapse/handlers/oidc_handler.py

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ async def _exchange_code(self, code: str) -> Token:
311311
``ClientAuth`` to authenticate with the client with its ID and secret.
312312
313313
Args:
314-
code: The autorization code we got from the callback.
314+
code: The authorization code we got from the callback.
315315
316316
Returns:
317317
A dict containing various tokens.
@@ -497,11 +497,14 @@ async def _parse_id_token(self, token: Token, nonce: str) -> UserInfo:
497497
return UserInfo(claims)
498498

499499
async def handle_redirect_request(
500-
self, request: SynapseRequest, client_redirect_url: bytes
501-
) -> None:
500+
self,
501+
request: SynapseRequest,
502+
client_redirect_url: bytes,
503+
ui_auth_session_id: Optional[str] = None,
504+
) -> str:
502505
"""Handle an incoming request to /login/sso/redirect
503506
504-
It redirects the browser to the authorization endpoint with a few
507+
It returns a redirect to the authorization endpoint with a few
505508
parameters:
506509
507510
- ``client_id``: the client ID set in ``oidc_config.client_id``
@@ -511,24 +514,32 @@ async def handle_redirect_request(
511514
- ``state``: a random string
512515
- ``nonce``: a random string
513516
514-
In addition to redirecting the client, we are setting a cookie with
517+
In addition generating a redirect URL, we are setting a cookie with
515518
a signed macaroon token containing the state, the nonce and the
516519
client_redirect_url params. Those are then checked when the client
517520
comes back from the provider.
518521
519-
520522
Args:
521523
request: the incoming request from the browser.
522524
We'll respond to it with a redirect and a cookie.
523525
client_redirect_url: the URL that we should redirect the client to
524526
when everything is done
527+
ui_auth_session_id: The session ID of the ongoing UI Auth (or
528+
None if this is a login).
529+
530+
Returns:
531+
The redirect URL to the authorization endpoint.
532+
525533
"""
526534

527535
state = generate_token()
528536
nonce = generate_token()
529537

530538
cookie = self._generate_oidc_session_token(
531-
state=state, nonce=nonce, client_redirect_url=client_redirect_url.decode(),
539+
state=state,
540+
nonce=nonce,
541+
client_redirect_url=client_redirect_url.decode(),
542+
ui_auth_session_id=ui_auth_session_id,
532543
)
533544
request.addCookie(
534545
SESSION_COOKIE_NAME,
@@ -541,7 +552,7 @@ async def handle_redirect_request(
541552

542553
metadata = await self.load_metadata()
543554
authorization_endpoint = metadata.get("authorization_endpoint")
544-
uri = prepare_grant_uri(
555+
return prepare_grant_uri(
545556
authorization_endpoint,
546557
client_id=self._client_auth.client_id,
547558
response_type="code",
@@ -550,8 +561,6 @@ async def handle_redirect_request(
550561
state=state,
551562
nonce=nonce,
552563
)
553-
request.redirect(uri)
554-
finish_request(request)
555564

556565
async def handle_oidc_callback(self, request: SynapseRequest) -> None:
557566
"""Handle an incoming request to /_synapse/oidc/callback
@@ -625,7 +634,11 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
625634

626635
# Deserialize the session token and verify it.
627636
try:
628-
nonce, client_redirect_url = self._verify_oidc_session_token(session, state)
637+
(
638+
nonce,
639+
client_redirect_url,
640+
ui_auth_session_id,
641+
) = self._verify_oidc_session_token(session, state)
629642
except MacaroonDeserializationException as e:
630643
logger.exception("Invalid session")
631644
self._render_error(request, "invalid_session", str(e))
@@ -678,15 +691,21 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
678691
return
679692

680693
# and finally complete the login
681-
await self._auth_handler.complete_sso_login(
682-
user_id, request, client_redirect_url
683-
)
694+
if ui_auth_session_id:
695+
await self._auth_handler.complete_sso_ui_auth(
696+
user_id, ui_auth_session_id, request
697+
)
698+
else:
699+
await self._auth_handler.complete_sso_login(
700+
user_id, request, client_redirect_url
701+
)
684702

685703
def _generate_oidc_session_token(
686704
self,
687705
state: str,
688706
nonce: str,
689707
client_redirect_url: str,
708+
ui_auth_session_id: Optional[str],
690709
duration_in_ms: int = (60 * 60 * 1000),
691710
) -> str:
692711
"""Generates a signed token storing data about an OIDC session.
@@ -702,6 +721,8 @@ def _generate_oidc_session_token(
702721
nonce: The ``nonce`` parameter passed to the OIDC provider.
703722
client_redirect_url: The URL the client gave when it initiated the
704723
flow.
724+
ui_auth_session_id: The session ID of the ongoing UI Auth (or
725+
None if this is a login).
705726
duration_in_ms: An optional duration for the token in milliseconds.
706727
Defaults to an hour.
707728
@@ -718,12 +739,19 @@ def _generate_oidc_session_token(
718739
macaroon.add_first_party_caveat(
719740
"client_redirect_url = %s" % (client_redirect_url,)
720741
)
742+
if ui_auth_session_id:
743+
macaroon.add_first_party_caveat(
744+
"ui_auth_session_id = %s" % (ui_auth_session_id,)
745+
)
721746
now = self._clock.time_msec()
722747
expiry = now + duration_in_ms
723748
macaroon.add_first_party_caveat("time < %d" % (expiry,))
749+
724750
return macaroon.serialize()
725751

726-
def _verify_oidc_session_token(self, session: str, state: str) -> Tuple[str, str]:
752+
def _verify_oidc_session_token(
753+
self, session: str, state: str
754+
) -> Tuple[str, str, Optional[str]]:
727755
"""Verifies and extract an OIDC session token.
728756
729757
This verifies that a given session token was issued by this homeserver
@@ -734,7 +762,7 @@ def _verify_oidc_session_token(self, session: str, state: str) -> Tuple[str, str
734762
state: The state the OIDC provider gave back
735763
736764
Returns:
737-
The nonce and the client_redirect_url for this session
765+
The nonce, client_redirect_url, and ui_auth_session_id for this session
738766
"""
739767
macaroon = pymacaroons.Macaroon.deserialize(session)
740768

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

749780
v.verify(macaroon, self._macaroon_secret_key)
750781

751-
# Extract the `nonce` and `client_redirect_url` from the token
782+
# Extract the `nonce`, `client_redirect_url`, and maybe the
783+
# `ui_auth_session_id` from the token.
752784
nonce = self._get_value_from_macaroon(macaroon, "nonce")
753785
client_redirect_url = self._get_value_from_macaroon(
754786
macaroon, "client_redirect_url"
755787
)
788+
try:
789+
ui_auth_session_id = self._get_value_from_macaroon(
790+
macaroon, "ui_auth_session_id"
791+
) # type: Optional[str]
792+
except ValueError:
793+
ui_auth_session_id = None
756794

757-
return nonce, client_redirect_url
795+
return nonce, client_redirect_url, ui_auth_session_id
758796

759797
def _get_value_from_macaroon(self, macaroon: pymacaroons.Macaroon, key: str) -> str:
760798
"""Extracts a caveat value from a macaroon token.
@@ -773,7 +811,7 @@ def _get_value_from_macaroon(self, macaroon: pymacaroons.Macaroon, key: str) ->
773811
for caveat in macaroon.caveats:
774812
if caveat.caveat_id.startswith(prefix):
775813
return caveat.caveat_id[len(prefix) :]
776-
raise Exception("No %s caveat in macaroon" % (key,))
814+
raise ValueError("No %s caveat in macaroon" % (key,))
777815

778816
def _verify_expiry(self, caveat: str) -> bool:
779817
prefix = "time < "

synapse/rest/client/v1/login.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -401,19 +401,22 @@ class BaseSSORedirectServlet(RestServlet):
401401

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

404-
def on_GET(self, request: SynapseRequest):
404+
async def on_GET(self, request: SynapseRequest):
405405
args = request.args
406406
if b"redirectUrl" not in args:
407407
return 400, "Redirect URL not specified for SSO auth"
408408
client_redirect_url = args[b"redirectUrl"][0]
409-
sso_url = self.get_sso_url(client_redirect_url)
409+
sso_url = await self.get_sso_url(request, client_redirect_url)
410410
request.redirect(sso_url)
411411
finish_request(request)
412412

413-
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
413+
async def get_sso_url(
414+
self, request: SynapseRequest, client_redirect_url: bytes
415+
) -> bytes:
414416
"""Get the URL to redirect to, to perform SSO auth
415417
416418
Args:
419+
request: The client request to redirect.
417420
client_redirect_url: the URL that we should redirect the
418421
client to when everything is done
419422
@@ -428,7 +431,9 @@ class CasRedirectServlet(BaseSSORedirectServlet):
428431
def __init__(self, hs):
429432
self._cas_handler = hs.get_cas_handler()
430433

431-
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
434+
async def get_sso_url(
435+
self, request: SynapseRequest, client_redirect_url: bytes
436+
) -> bytes:
432437
return self._cas_handler.get_redirect_url(
433438
{"redirectUrl": client_redirect_url}
434439
).encode("ascii")
@@ -465,24 +470,26 @@ class SAMLRedirectServlet(BaseSSORedirectServlet):
465470
def __init__(self, hs):
466471
self._saml_handler = hs.get_saml_handler()
467472

468-
def get_sso_url(self, client_redirect_url: bytes) -> bytes:
473+
async def get_sso_url(
474+
self, request: SynapseRequest, client_redirect_url: bytes
475+
) -> bytes:
469476
return self._saml_handler.handle_redirect_request(client_redirect_url)
470477

471478

472-
class OIDCRedirectServlet(RestServlet):
479+
class OIDCRedirectServlet(BaseSSORedirectServlet):
473480
"""Implementation for /login/sso/redirect for the OIDC login flow."""
474481

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

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

480-
async def on_GET(self, request):
481-
args = request.args
482-
if b"redirectUrl" not in args:
483-
return 400, "Redirect URL not specified for SSO auth"
484-
client_redirect_url = args[b"redirectUrl"][0]
485-
await self._oidc_handler.handle_redirect_request(request, client_redirect_url)
487+
async def get_sso_url(
488+
self, request: SynapseRequest, client_redirect_url: bytes
489+
) -> bytes:
490+
return await self._oidc_handler.handle_redirect_request(
491+
request, client_redirect_url
492+
)
486493

487494

488495
def register_servlets(hs, http_server):

synapse/rest/client/v2_alpha/auth.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,19 @@ def __init__(self, hs):
131131
self.registration_handler = hs.get_registration_handler()
132132

133133
# SSO configuration.
134-
self._saml_enabled = hs.config.saml2_enabled
135-
if self._saml_enabled:
136-
self._saml_handler = hs.get_saml_handler()
137134
self._cas_enabled = hs.config.cas_enabled
138135
if self._cas_enabled:
139136
self._cas_handler = hs.get_cas_handler()
140137
self._cas_server_url = hs.config.cas_server_url
141138
self._cas_service_url = hs.config.cas_service_url
139+
self._saml_enabled = hs.config.saml2_enabled
140+
if self._saml_enabled:
141+
self._saml_handler = hs.get_saml_handler()
142+
self._oidc_enabled = hs.config.oidc_enabled
143+
if self._oidc_enabled:
144+
self._oidc_handler = hs.get_oidc_handler()
145+
self._cas_server_url = hs.config.cas_server_url
146+
self._cas_service_url = hs.config.cas_service_url
142147

143148
async def on_GET(self, request, stagetype):
144149
session = parse_string(request, "session")
@@ -172,11 +177,17 @@ async def on_GET(self, request, stagetype):
172177
)
173178

174179
elif self._saml_enabled:
175-
client_redirect_url = ""
180+
client_redirect_url = b""
176181
sso_redirect_url = self._saml_handler.handle_redirect_request(
177182
client_redirect_url, session
178183
)
179184

185+
elif self._oidc_enabled:
186+
client_redirect_url = b""
187+
sso_redirect_url = await self._oidc_handler.handle_redirect_request(
188+
request, client_redirect_url, session
189+
)
190+
180191
else:
181192
raise SynapseError(400, "Homeserver not configured for SSO.")
182193

tests/handlers/test_oidc.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,10 @@ def test_skip_verification(self):
292292
@defer.inlineCallbacks
293293
def test_redirect_request(self):
294294
"""The redirect request has the right arguments & generates a valid session cookie."""
295-
req = Mock(spec=["addCookie", "redirect", "finish"])
296-
yield defer.ensureDeferred(
295+
req = Mock(spec=["addCookie"])
296+
url = yield defer.ensureDeferred(
297297
self.handler.handle_redirect_request(req, b"http://client/redirect")
298298
)
299-
url = req.redirect.call_args[0][0]
300299
url = urlparse(url)
301300
auth_endpoint = urlparse(AUTHORIZATION_ENDPOINT)
302301

@@ -382,7 +381,10 @@ def test_callback(self):
382381
nonce = "nonce"
383382
client_redirect_url = "http://client/redirect"
384383
session = self.handler._generate_oidc_session_token(
385-
state=state, nonce=nonce, client_redirect_url=client_redirect_url,
384+
state=state,
385+
nonce=nonce,
386+
client_redirect_url=client_redirect_url,
387+
ui_auth_session_id=None,
386388
)
387389
request.getCookie.return_value = session
388390

@@ -472,7 +474,10 @@ def test_callback_session(self):
472474

473475
# Mismatching session
474476
session = self.handler._generate_oidc_session_token(
475-
state="state", nonce="nonce", client_redirect_url="http://client/redirect",
477+
state="state",
478+
nonce="nonce",
479+
client_redirect_url="http://client/redirect",
480+
ui_auth_session_id=None,
476481
)
477482
request.args = {}
478483
request.args[b"state"] = [b"mismatching state"]

0 commit comments

Comments
 (0)