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

Add soft logout possibility for OIDC backchannel_logout #15976

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
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/15976.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow setting OIDC backchannel logout to be a soft-logout in OIDC provider configuration via `backchannel_logout_is_soft` which defaults to false. Contributed by @hachem2001.
6 changes: 6 additions & 0 deletions docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3335,6 +3335,12 @@ Options for each entry include:

You might want to disable this if the `subject_claim` returned by the mapping provider is not `sub`.

* `backchannel_logout_is_soft`: by default all OIDC Back-Channel Logouts correspond to hard logouts on
the server side. This may not leave users the ability to recover their encryption keys before being logged-out.
This can be set to `true` to treat all OIDC Back-Channel logouts as soft-logouts,
allowing users to reconnect to the same device if necessary to recover their keys.
Defaults to `false`.
Comment on lines +3338 to +3342
Copy link
Member

Choose a reason for hiding this comment

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

@sandhose Do you have any thoughts on if it makes sense for this to be configurable or not? (Previous conversation at #11414 (comment))

The backchannel-logout is used when a user manually logs out of an IdP? Or is it also used when a user's session expires with an IdP?

The former seems like it would make sense with the current (hard-)logout, while the latter should likely be a soft-logout.

Copy link
Member

Choose a reason for hiding this comment

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

I think keeping the option to do both is good. Usually, a backchannel logout happens because the user expressed the intent of logging out, but it might also happen when for example an admin kicks out a user to force logging back in, and in this case you might want to do a soft_logout in clients.

Copy link
Author

@hachem2001 hachem2001 Aug 16, 2023

Choose a reason for hiding this comment

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

It's indeed for the case where the user doesn't launch the backchannel logout on the server directly. Backchannel logout on synapse can occur without the user intentionally provoking it in some cases.

I made this as an option for safety upon upgrade, not to break any OIDC behavior servers currently rely on. Although I don't envision cases where a server would prefer a hard-logout for users, it's better to have the choice I suppose.

Another alternative would be to set backchannel_logout_is_soft to true by default, and in a further version eventually rule out the option and make it default behavior (basically an ephemeral temporary option). This is the best compromise in my opinion.


It is possible to configure Synapse to only allow logins if certain attributes
match particular values in the OIDC userinfo. The requirements can be listed under
`attribute_requirements` as shown here:
Expand Down
5 changes: 5 additions & 0 deletions synapse/config/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def oidc_enabled(self) -> bool:
"skip_verification": {"type": "boolean"},
"backchannel_logout_enabled": {"type": "boolean"},
"backchannel_logout_ignore_sub": {"type": "boolean"},
"backchannel_logout_is_soft": {"type": "boolean"},
"user_profile_method": {
"type": "string",
"enum": ["auto", "userinfo_endpoint"],
Expand Down Expand Up @@ -301,6 +302,7 @@ def _parse_oidc_config_dict(
backchannel_logout_ignore_sub=oidc_config.get(
"backchannel_logout_ignore_sub", False
),
backchannel_logout_is_soft=oidc_config.get("backchannel_logout_is_soft", False),
skip_verification=oidc_config.get("skip_verification", False),
user_profile_method=oidc_config.get("user_profile_method", "auto"),
allow_existing_users=oidc_config.get("allow_existing_users", False),
Expand Down Expand Up @@ -388,6 +390,9 @@ class OidcProviderConfig:
# Whether Synapse should ignore the `sub` claim in backchannel logouts or not.
backchannel_logout_ignore_sub: bool

# Whether Synapse should consider backchannel logouts as soft-logouts. Default false
backchannel_logout_is_soft: bool

# Whether to skip metadata verification
skip_verification: bool

Expand Down
37 changes: 31 additions & 6 deletions synapse/handlers/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1340,19 +1340,44 @@ async def handle_backchannel_logout(
self.idp_id, sub
)

# Back-Channel Logout can be set to only soft-logout users in the config, hence
# this check. The aim of the config is not to surprise the user with a sudden
# hard logout deleting his devices and keys in the process, thus not allowing
# him to set a recovery method/recover keys/... as a result of this back-channel
# logout.
if self._config.backchannel_logout_is_soft:
await self._handle_backchannel_soft_logout(request, sid, expected_user_id)
else:
# Invalidate any running user-mapping sessions, in-flight login tokens and
# active devices
await self._sso_handler.revoke_sessions_for_provider_session_id(
auth_provider_id=self.idp_id,
auth_provider_session_id=sid,
expected_user_id=expected_user_id,
)

request.setResponseCode(200)
request.setHeader(b"Cache-Control", b"no-cache, no-store")
request.setHeader(b"Pragma", b"no-cache")
finish_request(request)

async def _handle_backchannel_soft_logout(
self, request: SynapseRequest, sid: str, expected_user_id: Optional[str] = None
) -> None:
"""Helper function called when handling an incoming request to
/_synapse/client/oidc/backchannel_logout
ONLY when OIDC is set with parameter backchannel_logout_is_soft:true

Makes a soft_logout on all the user's tokens. (Does not delete devices)
"""
# Invalidate any running user-mapping sessions, in-flight login tokens and
# active devices
await self._sso_handler.revoke_sessions_for_provider_session_id(
await self._sso_handler.invalidate_sessions_for_provider_session_id(
auth_provider_id=self.idp_id,
auth_provider_session_id=sid,
expected_user_id=expected_user_id,
)

request.setResponseCode(200)
request.setHeader(b"Cache-Control", b"no-cache, no-store")
request.setHeader(b"Pragma", b"no-cache")
finish_request(request)


class LogoutToken(JWTClaims): # type: ignore[misc]
"""
Expand Down
85 changes: 85 additions & 0 deletions synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,91 @@ async def revoke_sessions_for_provider_session_id(
)
await self._device_handler.delete_devices(user_id, [device_id])

async def invalidate_sessions_for_provider_session_id(
Copy link
Member

Choose a reason for hiding this comment

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

This feels like it should be instead a parameter on the revoke_sessions_for_provider_session_id method instead, because most of the logic here is duplicated

Copy link
Author

Choose a reason for hiding this comment

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

I agree, though I avoided that in case this redundancy improves visibility, and in case we don't want to change the behavior of one of the functions in the long-run.

But merging it with revoke_sessions_for_provider_session_id by adding a new parameter (and modifying all subsequent calls of the function) is possible.

Copy link
Member

Choose a reason for hiding this comment

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

I suspect it would improve readability if there's less duplicated code, so we can see what the differences are based on the flag.

Copy link
Member

Choose a reason for hiding this comment

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

@hachem2001 Do you have interest in making this change?

self,
auth_provider_id: str,
auth_provider_session_id: str,
expected_user_id: Optional[str] = None,
) -> None:
"""Invalidates all access tokens and in-flight login tokens tied to a provider
session.
This causes them to be soft-logged out.

Can only be called from the main process.

Args:
auth_provider_id: A unique identifier for this SSO provider, e.g.
"oidc" or "saml".
auth_provider_session_id: The session ID from the provider to logout
expected_user_id: The user we're expecting to logout. If set, it will ignore
sessions belonging to other users and log an error.
"""

# It is expected that this is the main process.
assert isinstance(
self._device_handler, DeviceHandler
), "invalidating SSO sessions can only be called on the main process"

# Invalidate any running user-mapping sessions
to_delete = []
for session_id, session in self._username_mapping_sessions.items():
if (
session.auth_provider_id == auth_provider_id
and session.auth_provider_session_id == auth_provider_session_id
):
to_delete.append(session_id)

for session_id in to_delete:
logger.info("Revoking mapping session %s", session_id)
del self._username_mapping_sessions[session_id]

# Invalidate any in-flight login tokens
await self._store.invalidate_login_tokens_by_session_id(
auth_provider_id=auth_provider_id,
auth_provider_session_id=auth_provider_session_id,
)

# Fetch any device(s) in the store associated with the session ID.
devices = await self._store.get_devices_by_auth_provider_session_id(
auth_provider_id=auth_provider_id,
auth_provider_session_id=auth_provider_session_id,
)

# Iterate over the list of devices and soft-log them out one by one.
for device in devices:
user_id = device["user_id"]
device_id = device["device_id"]

# If the user_id associated with that device/session is not the one we got
# out of the `sub` claim, skip that device and show log an error.
if expected_user_id is not None and user_id != expected_user_id:
logger.error(
"Received a (soft) logout notification from SSO provider "
f"{auth_provider_id!r} for the user {expected_user_id!r}, but with "
f"a session ID ({auth_provider_session_id!r}) which belongs to "
f"{user_id!r}. This may happen when the SSO provider user mapper "
"uses something else than the standard attribute as mapping ID. "
"For OIDC providers, set `backchannel_logout_ignore_sub` to `true` "
"in the provider config if that is the case."
)
continue

logger.info(
"Soft-logging out %r (device %r) via SSO (%r) soft-logout notification (session %r).",
user_id,
device_id,
auth_provider_id,
auth_provider_session_id,
)

# Invalidate all tokens of user_id associated with device_id
await self._store.user_set_account_tokens_validity(
user_id,
validity_until_ms=self._clock.time_msec(),
except_token_id=None,
device_id=device_id,
)


def get_username_mapping_session_cookie_from_request(request: IRequest) -> str:
"""Extract the session ID from the cookie
Expand Down
61 changes: 61 additions & 0 deletions synapse/storage/databases/main/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -2616,6 +2616,67 @@ def f(txn: LoggingTransaction) -> List[Tuple[str, int, Optional[str]]]:

return await self.db_pool.runInteraction("user_delete_access_tokens", f)

async def user_set_account_tokens_validity(
self,
user_id: str,
validity_until_ms: int = 0,
except_token_id: Optional[int] = None,
device_id: Optional[str] = None,
) -> List[Tuple[str, int, Optional[str]]]:
"""
Set access tokens' validi_until_ms belonging to a user
Sets the same value for all of the concerned tokens

Args:
user_id: ID of user the tokens belong to
validity_until_ms: New validity_until value for all considered tokens
except_token_id: access_tokens ID which should *not* be updated
device_id: ID of device the tokens are associated with.
If None, tokens associated with any device (or no device) will
be updated
Returns:
A tuple of (token, token id, device id) for each of the updated tokens
"""
assert validity_until_ms >= 0

def f(txn: LoggingTransaction) -> List[Tuple[str, int, Optional[str]]]:
keyvalues = {"user_id": user_id}
if device_id is not None:
keyvalues["device_id"] = device_id

items = keyvalues.items()
where_clause = " AND ".join(k + " = ?" for k, _ in items)
values: List[Union[str, int]] = [v for _, v in items]

values.copy()
if except_token_id:
where_clause += " AND id != ?"
values.append(except_token_id)

txn.execute(
"SELECT token, id, device_id FROM access_tokens WHERE %s"
% where_clause,
values,
)
tokens_and_devices = [(r[0], r[1], r[2]) for r in txn]

for token, token_id, _ in tokens_and_devices:
self.db_pool.simple_update_txn(
txn,
table="access_tokens",
keyvalues={"id": token_id},
updatevalues={"valid_until_ms": validity_until_ms},
)
self._invalidate_cache_and_stream(
txn, self.get_user_by_access_token, (token,)
)

self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))

return tokens_and_devices

return await self.db_pool.runInteraction("user_set_account_tokens_validity", f)

async def delete_access_token(self, access_token: str) -> None:
def f(txn: LoggingTransaction) -> None:
self.db_pool.simple_delete_one_txn(
Expand Down
121 changes: 121 additions & 0 deletions tests/rest/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,59 @@ def test_simple_logout(self) -> None:
channel = self.submit_logout_token(logout_token)
self.assertEqual(channel.code, 200)

@override_config(
{
"oidc_providers": [
oidc_config(
id="oidc",
with_localpart_template=True,
backchannel_logout_enabled=True,
backchannel_logout_is_soft=True,
)
]
}
)
def test_simple_logout_is_soft(self) -> None:
"""
Soft-logout on back-channel option being enabled,
receiving a logout token should soft-logout the user
"""
fake_oidc_server = self.helper.fake_oidc_server()
user = "john"

login_resp, first_grant = self.helper.login_via_oidc(
fake_oidc_server, user, with_sid=True
)
first_access_token: str = login_resp["access_token"]
self.helper.whoami(first_access_token, expect_code=HTTPStatus.OK)

login_resp, second_grant = self.helper.login_via_oidc(
fake_oidc_server, user, with_sid=True
)
second_access_token: str = login_resp["access_token"]
self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)

self.assertNotEqual(first_grant.sid, second_grant.sid)
self.assertEqual(first_grant.userinfo["sub"], second_grant.userinfo["sub"])

# Soft-logging out of the first session
logout_token = fake_oidc_server.generate_logout_token(first_grant)
channel = self.submit_logout_token(logout_token)
self.assertEqual(channel.code, 200)

first_attempt_json_body = self.helper.whoami(
first_access_token, expect_code=HTTPStatus.UNAUTHORIZED
)
self.assertEqual(first_attempt_json_body["soft_logout"], True)
self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)

# Soft-logging out of the second session
logout_token = fake_oidc_server.generate_logout_token(second_grant)
channel = self.submit_logout_token(logout_token)
self.assertEqual(channel.code, 200)

# Soft-logout option does not change behaviour during mapping or login

@override_config(
{
"oidc_providers": [
Expand Down Expand Up @@ -1500,3 +1553,71 @@ def test_multiple_providers(self) -> None:
self.assertEqual(channel.code, 200)

self.helper.whoami(second_access_token, expect_code=HTTPStatus.UNAUTHORIZED)

@override_config(
{
"oidc_providers": [
oidc_config(
"first",
issuer="https://first-issuer.com/",
with_localpart_template=True,
backchannel_logout_enabled=True,
backchannel_logout_is_soft=True,
),
oidc_config(
"second",
issuer="https://second-issuer.com/",
with_localpart_template=True,
backchannel_logout_enabled=True,
backchannel_logout_is_soft=True,
),
]
}
)
def test_multiple_providers_is_soft(self) -> None:
"""
It should be able to distinguish login tokens from two different IdPs
"""
first_server = self.helper.fake_oidc_server(issuer="https://first-issuer.com/")
second_server = self.helper.fake_oidc_server(
issuer="https://second-issuer.com/"
)
user = "john"

login_resp, first_grant = self.helper.login_via_oidc(
first_server, user, with_sid=True, idp_id="oidc-first"
)
first_access_token: str = login_resp["access_token"]
self.helper.whoami(first_access_token, expect_code=HTTPStatus.OK)

login_resp, second_grant = self.helper.login_via_oidc(
second_server, user, with_sid=True, idp_id="oidc-second"
)
second_access_token: str = login_resp["access_token"]
self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)

# `sid` in the fake providers are generated by a counter, so the first grant of
# each provider should give the same SID
self.assertEqual(first_grant.sid, second_grant.sid)
self.assertEqual(first_grant.userinfo["sub"], second_grant.userinfo["sub"])

# Logging out of the first session
logout_token = first_server.generate_logout_token(first_grant)
channel = self.submit_logout_token(logout_token)
self.assertEqual(channel.code, 200)

first_attempt_json_body = self.helper.whoami(
first_access_token, expect_code=HTTPStatus.UNAUTHORIZED
)
self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)
self.assertEqual(first_attempt_json_body["soft_logout"], True)

# Logging out of the second session
logout_token = second_server.generate_logout_token(second_grant)
channel = self.submit_logout_token(logout_token)
self.assertEqual(channel.code, 200)

second_attempt_json_body = self.helper.whoami(
second_access_token, expect_code=HTTPStatus.UNAUTHORIZED
)
self.assertEqual(second_attempt_json_body["soft_logout"], True)