summary refs log tree commit diff
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2025-06-28 02:25:58 +0200
committerRory& <root@rory.gay>2025-06-28 02:25:58 +0200
commit77b806eb4ec604412c2ca8c43f0eeff94a8d4ce9 (patch)
tree7d0b2bbcb22d10aac8a739d31ebcd7cdbf2cbfc3
parentRemove CAS auth (diff)
downloadsynapse-77b806eb4ec604412c2ca8c43f0eeff94a8d4ce9.tar.xz
Remove SAML/2 auth
-rw-r--r--docs/SUMMARY.md5
-rw-r--r--docs/admin_api/user_admin_api.md2
-rw-r--r--docs/development/saml.md40
-rw-r--r--docs/setup/installation.md2
-rw-r--r--docs/sso_mapping_providers.md89
-rw-r--r--docs/usage/configuration/config_documentation.md124
-rw-r--r--docs/usage/configuration/user_authentication/README.md2
-rw-r--r--docs/usage/configuration/user_authentication/single_sign_on/README.md6
-rw-r--r--docs/usage/configuration/user_authentication/single_sign_on/saml.md8
-rw-r--r--docs/workers.md3
-rw-r--r--mypy.ini3
-rw-r--r--pyproject.toml4
-rw-r--r--schema/synapse-config.schema.yaml219
-rwxr-xr-xscripts-dev/gen_config_documentation.py9
-rw-r--r--synapse/config/_base.pyi2
-rw-r--r--synapse/config/experimental.py1
-rw-r--r--synapse/config/homeserver.py2
-rw-r--r--synapse/config/oidc.py2
-rw-r--r--synapse/config/saml2.py248
-rw-r--r--synapse/handlers/auth.py2
-rw-r--r--synapse/handlers/saml.py524
-rw-r--r--synapse/handlers/sso.py21
-rw-r--r--synapse/module_api/callbacks/spamchecker_callbacks.py8
-rw-r--r--synapse/rest/client/login.py14
-rw-r--r--synapse/rest/synapse/client/__init__.py10
-rw-r--r--synapse/rest/synapse/client/saml2/__init__.py42
-rw-r--r--synapse/rest/synapse/client/saml2/metadata_resource.py46
-rw-r--r--synapse/rest/synapse/client/saml2/response_resource.py52
-rw-r--r--synapse/server.py7
-rw-r--r--tests/handlers/test_saml.py427
-rw-r--r--tests/rest/client/test_login.py74
-rw-r--r--tests/utils.py1
32 files changed, 35 insertions, 1964 deletions
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md

index 33289ccaad..f91d290f2f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md
@@ -27,8 +27,6 @@ - [User Authentication](usage/configuration/user_authentication/README.md) - [Single-Sign On](usage/configuration/user_authentication/single_sign_on/README.md) - [OpenID Connect](openid.md) - - [SAML](usage/configuration/user_authentication/single_sign_on/saml.md) - - [CAS](usage/configuration/user_authentication/single_sign_on/cas.md) - [SSO Mapping Providers](sso_mapping_providers.md) - [Password Auth Providers](password_auth_providers.md) - [JSON Web Tokens](jwt.md) @@ -106,9 +104,6 @@ - [TCP Replication](tcp_replication.md) - [Faster remote joins](development/synapse_architecture/faster_joins.md) - [Internal Documentation](development/internal_documentation/README.md) - - [Single Sign-On]() - - [SAML](development/saml.md) - - [CAS](development/cas.md) - [Room DAG concepts](development/room-dag-concepts.md) - [State Resolution]() - [The Auth Chain Difference Algorithm](auth_chain_difference_algorithm.md) diff --git a/docs/admin_api/user_admin_api.md b/docs/admin_api/user_admin_api.md
index 31baf96e58..0f9f1924c0 100644 --- a/docs/admin_api/user_admin_api.md +++ b/docs/admin_api/user_admin_api.md
@@ -1405,7 +1405,7 @@ When a user matched the given ID for the given provider, an HTTP code `200` with The following parameters should be set in the URL: - `provider` - The ID of the authentication provider, as advertised by the [`GET /_matrix/client/v3/login`](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3login) API in the `m.login.sso` authentication method. -- `external_id` - The user ID from the authentication provider. Usually corresponds to the `sub` claim for OIDC providers, or to the `uid` attestation for SAML2 providers. +- `external_id` - The user ID from the authentication provider. Usually corresponds to the `sub` claim for OIDC providers. The `external_id` may have characters that are not URL-safe (typically `/`, `:` or `@`), so it is advised to URL-encode those parameters. diff --git a/docs/development/saml.md b/docs/development/saml.md deleted file mode 100644
index b08bcb7419..0000000000 --- a/docs/development/saml.md +++ /dev/null
@@ -1,40 +0,0 @@ -# How to test SAML as a developer without a server - -https://fujifish.github.io/samling/samling.html (https://github.com/fujifish/samling) is a great resource for being able to tinker with the -SAML options within Synapse without needing to deploy and configure a complicated software stack. - -To make Synapse (and therefore Element) use it: - -1. Use the samling.html URL above or deploy your own and visit the IdP Metadata tab. -2. Copy the XML to your clipboard. -3. On your Synapse server, create a new file `samling.xml` next to your `homeserver.yaml` with - the XML from step 2 as the contents. -4. Edit your `homeserver.yaml` to include: - ```yaml - saml2_config: - sp_config: - allow_unknown_attributes: true # Works around a bug with AVA Hashes: https://github.com/IdentityPython/pysaml2/issues/388 - metadata: - local: ["samling.xml"] - ``` -5. Ensure that your `homeserver.yaml` has a setting for `public_baseurl`: - ```yaml - public_baseurl: http://localhost:8080/ - ``` -6. Run `apt-get install xmlsec1` and `pip install --upgrade --force 'pysaml2>=4.5.0'` to ensure - the dependencies are installed and ready to go. -7. Restart Synapse. - -Then in Element: - -1. Visit the login page and point Element towards your homeserver using the `public_baseurl` above. -2. Click the Single Sign-On button. -3. On the samling page, enter a Name Identifier and add a SAML Attribute for `uid=your_localpart`. - The response must also be signed. -4. Click "Next". -5. Click "Post Response" (change nothing). -6. You should be logged in. - -If you try and repeat this process, you may be automatically logged in using the information you -gave previously. To fix this, open your developer console (`F12` or `Ctrl+Shift+I`) while on the -samling page and clear the site data. In Chrome, this will be a button on the Application tab. diff --git a/docs/setup/installation.md b/docs/setup/installation.md
index 0853496ab7..22f6950625 100644 --- a/docs/setup/installation.md +++ b/docs/setup/installation.md
@@ -376,7 +376,7 @@ If you're struggling to get icu discovered, and see: ``` despite it being installed and having your `PATH` updated, you can omit this dependency by not specifying `--extras all` to `poetry`. If using postgres, you can install Synapse via -`poetry install --extras saml2 --extras oidc --extras postgres --extras opentracing --extras redis --extras sentry`. +`poetry install --extras oidc --extras postgres --extras opentracing --extras redis --extras sentry`. ICU is not a hard dependency on getting a working installation. On ARM-based Macs you may also need to install libjpeg and libpq: diff --git a/docs/sso_mapping_providers.md b/docs/sso_mapping_providers.md
index 4d33c8da75..e09b733ce7 100644 --- a/docs/sso_mapping_providers.md +++ b/docs/sso_mapping_providers.md
@@ -12,7 +12,7 @@ It may choose `John Smith`, or `Smith, John [Example.com]` or any number of variations. As each Synapse configuration may want something different, this is where SSO mapping providers come into play. -SSO mapping providers are currently supported for OpenID and SAML SSO +SSO mapping providers are currently supported for OpenID SSO configurations. Please see the details below for how to implement your own. It is up to the mapping provider whether the user should be assigned a predefined @@ -119,90 +119,3 @@ A custom mapping provider must specify the following methods: Synapse has a built-in OpenID mapping provider if a custom provider isn't specified in the config. It is located at [`synapse.handlers.oidc.JinjaOidcMappingProvider`](https://github.com/element-hq/synapse/blob/develop/synapse/handlers/oidc.py). - -## SAML Mapping Providers - -The SAML mapping provider can be customized by editing the -[`saml2_config.user_mapping_provider.module`](usage/configuration/config_documentation.md#saml2_config) -config option. - -`saml2_config.user_mapping_provider.config` allows you to provide custom -configuration options to the module. Check with the module's documentation for -what options it provides (if any). The options listed by default are for the -user mapping provider built in to Synapse. If using a custom module, you should -comment these options out and use those specified by the module instead. - -### Building a Custom SAML Mapping Provider - -A custom mapping provider must specify the following methods: - -* `def __init__(self, parsed_config, module_api)` - - Arguments: - - `parsed_config` - A configuration object that is the return value of the - `parse_config` method. You should set any configuration options needed by - the module here. - - `module_api` - a `synapse.module_api.ModuleApi` object which provides the - stable API available for extension modules. -* `def parse_config(config)` - - **This method should have the `@staticmethod` decoration.** - - Arguments: - - `config` - A `dict` representing the parsed content of the - `saml_config.user_mapping_provider.config` homeserver config option. - Runs on homeserver startup. Providers should extract and validate - any option values they need here. - - Whatever is returned will be passed back to the user mapping provider module's - `__init__` method during construction. -* `def get_saml_attributes(config)` - - **This method should have the `@staticmethod` decoration.** - - Arguments: - - `config` - A object resulting from a call to `parse_config`. - - Returns a tuple of two sets. The first set equates to the SAML auth - response attributes that are required for the module to function, whereas - the second set consists of those attributes which can be used if available, - but are not necessary. -* `def get_remote_user_id(self, saml_response, client_redirect_url)` - - Arguments: - - `saml_response` - A `saml2.response.AuthnResponse` object to extract user - information from. - - `client_redirect_url` - A string, the URL that the client will be - redirected to. - - This method must return a string, which is the unique, immutable identifier - for the user. Commonly the `uid` claim of the response. -* `def saml_response_to_user_attributes(self, saml_response, failures, client_redirect_url)` - - Arguments: - - `saml_response` - A `saml2.response.AuthnResponse` object to extract user - information from. - - `failures` - An `int` that represents the amount of times the returned - mxid localpart mapping has failed. This should be used - to create a deduplicated mxid localpart which should be - returned instead. For example, if this method returns - `john.doe` as the value of `mxid_localpart` in the returned - dict, and that is already taken on the homeserver, this - method will be called again with the same parameters but - with failures=1. The method should then return a different - `mxid_localpart` value, such as `john.doe1`. - - `client_redirect_url` - A string, the URL that the client will be - redirected to. - - This method must return a dictionary, which will then be used by Synapse - to build a new user. The following keys are allowed: - * `mxid_localpart` - A string, the mxid localpart of the new user. If this is - `None`, the user is prompted to pick their own username. This is only used - during a user's first login. Once a localpart has been associated with a - remote user ID (see `get_remote_user_id`) it cannot be updated. - * `displayname` - The displayname of the new user. If not provided, will default to - the value of `mxid_localpart`. - * `emails` - A list of emails for the new user. If not provided, will - default to an empty list. - - Alternatively it can raise a `synapse.api.errors.RedirectException` to - redirect the user to another page. This is useful to prompt the user for - additional information, e.g. if you want them to provide their own username. - It is the responsibility of the mapping provider to either redirect back - to `client_redirect_url` (including any additional information) or to - complete registration using methods from the `ModuleApi`. - -### Default SAML Mapping Provider - -Synapse has a built-in SAML mapping provider if a custom provider isn't -specified in the config. It is located at -[`synapse.handlers.saml.DefaultSamlMappingProvider`](https://github.com/element-hq/synapse/blob/develop/synapse/handlers/saml.py). diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md
index b72fb36439..999a4c1a12 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md
@@ -3039,7 +3039,7 @@ use_appservice_legacy_authorization: true *(string|null)* A secret which is used to sign - access token for guest users, -- short-term login token used during SSO logins (OIDC or SAML2) and +- short-term login token used during SSO logins (OIDC) and - token used for unsubscribing from email notifications. If none is specified, the `registration_shared_secret` is used, if one is given; otherwise, a secret key is derived from the signing key. @@ -3211,126 +3211,6 @@ You will probably also want to set the following options to `false` to disable t * [`password_config.enabled`](#password_config) --- -### `saml2_config` - -*(object)* Enable SAML2 for registration and login. Uses pysaml2. To learn more about pysaml and to find a full list options for configuring pysaml, read the docs [here](https://pysaml2.readthedocs.io/en/latest/). - -At least one of `sp_config` or `config_path` must be set in this section to enable SAML login. You can either put your entire pysaml config inline using the `sp_config` option, or you can specify a path to a psyaml config file with the sub-option `config_path`. - -Once SAML support is enabled, a metadata file will be exposed at `https://<server>:<port>/_synapse/client/saml2/metadata.xml`, which you may be able to use to configure your SAML IdP with. Alternatively, you can manually configure the IdP to use an ACS location of `https://<server>:<port>/_synapse/client/saml2/authn_response`. - -This setting has the following sub-options: - -* `idp_name` (string): A user-facing name for this identity provider, which is used to offer the user a choice of login mechanisms. - -* `idp_icon` (string|null): An optional icon for this identity provider, which is presented by clients and Synapse's own IdP picker page. If given, must be an MXC URI of the format `mxc://<server-name>/<media-id>`. (An easy way to obtain such an MXC URI is to upload an image to an (unencrypted) room and then copy the URL from the source of the event.) - -* `idp_brand`: An optional brand for this identity provider, allowing clients to style the login flow according to the identity provider in question. See the [spec](https://spec.matrix.org/latest/) for possible options here. - -* `sp_config` (object|null): Configuration for the pysaml2 Service Provider. See pysaml2 docs for format of config. Default values will be used for the `entityid` and `service` settings, so it is not normally necessary to specify them unless you need to override them. Here are a few useful sub-options for configuring pysaml: - * `metadata`: Point this to the IdP's metadata. You must provide either a local file via the `local` attribute or (preferably) a URL via the `remote` attribute. - * `accepted_time_diff: 3`: Allowed clock difference in seconds between the homeserver and IdP. Defaults to 0. - * `service`: By default, the user has to go to our login page first. If you'd like to allow IdP-initiated login, set `allow_unsolicited` to true under `sp` in the `service` section. Defaults to `null`. - -* `config_path` (string|null): Specify a separate pysaml2 configuration file. Defaults to `null`. - -* `saml_session_lifetime` (duration): The lifetime of a SAML session. This defines how long a user has to complete the authentication process, if `allow_unsolicited` is unset. Defaults to `"15m"`. - -* `user_mapping_provider` (object): Using this option, an external module can be provided as a custom solution to mapping attributes returned from a saml provider onto a matrix user. - - This setting has the following sub-options: - - * `module` (string): The custom module's class. - - * `config` (object): Custom configuration values for the module. Use the values provided in the example if you are using the built-in user_mapping_provider, or provide your own config values for a custom class if you are using one. This section will be passed as a Python dictionary to the module's `parse_config` method. The built-in provider takes the following two options: - * `mxid_source_attribute`: The SAML attribute (after mapping via the attribute maps) to use to derive the Matrix ID from. It is "uid" by default. Note: This used to be configured by the `saml2_config.mxid_source_attribute option`. If that is still defined, its value will be used instead. - * `mxid_mapping`: The mapping system to use for mapping the saml attribute onto a matrix ID. Options include: `hexencode` (which maps unpermitted characters to `=xx`) and `dotreplace` (which replaces unpermitted characters with `.`). The default is `hexencode`. Note: This used to be configured by the `saml2_config.mxid_mapping option`. If that is still defined, its value will be used instead. - -* `grandfathered_mxid_source_attribute` (string): In previous versions of synapse, the mapping from SAML attribute to MXID was always calculated dynamically rather than stored in a table. For backwards-compatibility, we will look for `user_ids` matching such a pattern before creating a new account. This setting controls the SAML attribute which will be used for this backwards-compatibility lookup. Typically it should be "uid", but if the attribute maps are changed, it may be necessary to change it. Defaults to `"uid"`. - -* `attribute_requirements` (array): It is possible to configure Synapse to only allow logins if SAML attributes match particular values. The requirements can be listed under `attribute_requirements` as shown in the example. All of the listed attributes must match for the login to be permitted. Values can be specified in a `one_of` list to allow multiple values for an attribute. - - Options for each entry include: - - * `attribute` (string): SAML attribute for which to allow logins. - - * `value` (string): Value the SAML attribute must match. - - * `one_of` (array): List of values the SAML attribute must all match. - -* `idp_entityid` (string|null): If the metadata XML contains multiple IdP entities then the `idp_entityid` option must be set to the entity to redirect users to. Most deployments only have a single IdP entity and so should omit this option. Defaults to `null`. - -Example configuration: -```yaml -saml2_config: - sp_config: - metadata: - local: - - saml2/idp.xml - remote: - - url: https://our_idp/metadata.xml - accepted_time_diff: 3 - service: - sp: - allow_unsolicited: true - description: - - My awesome SP - - en - name: - - Test SP - - en - ui_info: - display_name: - - lang: en - text: Display Name is the descriptive name of your service. - description: - - lang: en - text: Description should be a short paragraph explaining the purpose of the - service. - information_url: - - lang: en - text: https://example.com/terms-of-service - privacy_statement_url: - - lang: en - text: https://example.com/privacy-policy - keywords: - - lang: en - text: - - Matrix - - Element - logo: - - lang: en - text: https://example.com/logo.svg - width: '200' - height: '80' - organization: - name: Example com - display_name: - - - Example co - - en - url: http://example.com - contact_person: - - given_name: Bob - sur_name: the Sysadmin - email_address: - - admin@example.com - contact_type: technical - saml_session_lifetime: 5m - user_mapping_provider: - config: - mxid_source_attribute: displayName - mxid_mapping: dotreplace - grandfathered_mxid_source_attribute: upn - attribute_requirements: - - attribute: userGroup - value: staff - - attribute: department - one_of: - - sales - - admins - idp_entityid: https://our_idp/entityid -``` ---- ### `oidc_providers` *(array)* List of OpenID Connect (OIDC) / OAuth 2.0 identity providers, for registration and login. See [here](../../openid.md) for information on how to configure these options. @@ -3516,7 +3396,7 @@ oidc_providers: --- ### `sso` -*(object)* Additional settings to use with single-sign on systems such as OpenID Connect, SAML2 and CAS. +*(object)* Additional settings to use with single-sign on systems such as OpenID Connect. Server admins can configure custom templates for pages related to SSO. See [here](../../templates.md) for more information. diff --git a/docs/usage/configuration/user_authentication/README.md b/docs/usage/configuration/user_authentication/README.md
index 087ae053cf..644ca66445 100644 --- a/docs/usage/configuration/user_authentication/README.md +++ b/docs/usage/configuration/user_authentication/README.md
@@ -7,7 +7,7 @@ Included in Synapse is support for authenticating users via: * A username and password. * An email address and password. -* Single Sign-On through the SAML, Open ID Connect or CAS protocols. +* Single Sign-On through the Open ID Connect protocol. * JSON Web Tokens. * An administrator's shared secret. diff --git a/docs/usage/configuration/user_authentication/single_sign_on/README.md b/docs/usage/configuration/user_authentication/single_sign_on/README.md
index b94aad92cf..b6e0b080b5 100644 --- a/docs/usage/configuration/user_authentication/single_sign_on/README.md +++ b/docs/usage/configuration/user_authentication/single_sign_on/README.md
@@ -1,5 +1,7 @@ # Single Sign-On -Synapse supports single sign-on through the SAML, Open ID Connect or CAS protocols. +Synapse supports single sign-on through the Open ID Connect protocol. LDAP and other login methods are supported through first and third-party password -auth provider modules. \ No newline at end of file +auth provider modules. + +Note that this patchset removes SAML and CAS protocol support. \ No newline at end of file diff --git a/docs/usage/configuration/user_authentication/single_sign_on/saml.md b/docs/usage/configuration/user_authentication/single_sign_on/saml.md deleted file mode 100644
index 2b6f052cc1..0000000000 --- a/docs/usage/configuration/user_authentication/single_sign_on/saml.md +++ /dev/null
@@ -1,8 +0,0 @@ -# SAML - -Synapse supports authenticating users via the [Security Assertion -Markup Language](https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language) -(SAML) protocol natively. - -Please see the `saml2_config` and `sso` sections of the [Synapse configuration -file](../../../configuration/homeserver_sample_config.md) for more details. \ No newline at end of file diff --git a/docs/workers.md b/docs/workers.md
index a254740251..5397f36280 100644 --- a/docs/workers.md +++ b/docs/workers.md
@@ -312,9 +312,6 @@ using): # OpenID Connect requests. ^/_synapse/client/oidc/callback$ - # SAML requests. - ^/_synapse/client/saml2/authn_response$ - Ensure that all SSO logins go to a single process. For multiple workers not handling the SSO endpoints properly, see [#7530](https://github.com/matrix-org/synapse/issues/7530) and diff --git a/mypy.ini b/mypy.ini
index cf64248cc5..0a0e33361c 100644 --- a/mypy.ini +++ b/mypy.ini
@@ -87,9 +87,6 @@ ignore_missing_imports = True [mypy-rust_python_jaeger_reporter.*] ignore_missing_imports = True -[mypy-saml2.*] -ignore_missing_imports = True - [mypy-srvlookup.*] ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml
index 103cb3c758..ed900288b4 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -234,7 +234,6 @@ matrix-synapse-ldap3 = { version = ">=0.1", optional = true } psycopg2 = { version = ">=2.8", markers = "platform_python_implementation != 'PyPy'", optional = true } psycopg2cffi = { version = ">=2.8", markers = "platform_python_implementation == 'PyPy'", optional = true } psycopg2cffi-compat = { version = "==1.1", markers = "platform_python_implementation == 'PyPy'", optional = true } -pysaml2 = { version = ">=4.5.0", optional = true } authlib = { version = ">=0.15.1", optional = true } # systemd-python is necessary for logging to the systemd journal via # `systemd.journal.JournalHandler`, as is documented in @@ -257,7 +256,6 @@ pyicu = { version = ">=2.10.2", optional = true } # twice: once here, and once in the `all` extra. matrix-synapse-ldap3 = ["matrix-synapse-ldap3"] postgres = ["psycopg2", "psycopg2cffi", "psycopg2cffi-compat"] -saml2 = ["pysaml2"] oidc = ["authlib"] # systemd-python is necessary for logging to the systemd journal via # `systemd.journal.JournalHandler`, as is documented in @@ -294,8 +292,6 @@ all = [ "matrix-synapse-ldap3", # postgres "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", - # saml2 - "pysaml2", # oidc and jwt "authlib", # url-preview diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml
index d3756cb074..f7ea335484 100644 --- a/schema/synapse-config.schema.yaml +++ b/schema/synapse-config.schema.yaml
@@ -3458,7 +3458,7 @@ properties: - access token for guest users, - - short-term login token used during SSO logins (OIDC or SAML2) and + - short-term login token used during SSO logins (OIDC) and - token used for unsubscribing from email notifications. @@ -3654,221 +3654,6 @@ properties: default: null examples: - key_server_signing_keys.key - saml2_config: - type: object - description: >- - Enable SAML2 for registration and login. Uses pysaml2. To learn more about - pysaml and to find a full list options for configuring pysaml, read the - docs [here](https://pysaml2.readthedocs.io/en/latest/). - - - At least one of `sp_config` or `config_path` must be set in this section - to enable SAML login. You can either put your entire pysaml config inline - using the `sp_config` option, or you can specify a path to a psyaml config - file with the sub-option `config_path`. - - - Once SAML support is enabled, a metadata file will be exposed at - `https://<server>:<port>/_synapse/client/saml2/metadata.xml`, which you - may be able to use to configure your SAML IdP with. Alternatively, you can - manually configure the IdP to use an ACS location of - `https://<server>:<port>/_synapse/client/saml2/authn_response`. - properties: - idp_name: - type: string - description: >- - A user-facing name for this identity provider, which is used to offer - the user a choice of login mechanisms. - idp_icon: - type: ["string", "null"] - description: >- - An optional icon for this identity provider, which is presented by - clients and Synapse's own IdP picker page. If given, must be an MXC - URI of the format `mxc://<server-name>/<media-id>`. (An easy way to - obtain such an MXC URI is to upload an image to an (unencrypted) room - and then copy the URL from the source of the event.) - idp_brand: - description: >- - An optional brand for this identity provider, allowing clients to - style the login flow according to the identity provider in question. - See the [spec](https://spec.matrix.org/latest/) for possible options - here. - sp_config: - type: ["object", "null"] - description: >- - Configuration for the pysaml2 Service Provider. See pysaml2 docs for - format of config. Default values will be used for the `entityid` and - `service` settings, so it is not normally necessary to specify them - unless you need to override them. Here are a few useful sub-options - for configuring pysaml: - - * `metadata`: Point this to the IdP's metadata. You must provide - either a local file via the `local` attribute or (preferably) a URL - via the `remote` attribute. - - * `accepted_time_diff: 3`: Allowed clock difference in seconds between - the homeserver and IdP. Defaults to 0. - - * `service`: By default, the user has to go to our login page first. - If you'd like to allow IdP-initiated login, set `allow_unsolicited` to - true under `sp` in the `service` section. - default: null - config_path: - type: ["string", "null"] - description: Specify a separate pysaml2 configuration file. - default: null - saml_session_lifetime: - $ref: "#/$defs/duration" - description: >- - The lifetime of a SAML session. This defines how long a user has to - complete the authentication process, if `allow_unsolicited` is unset. - default: 15m - user_mapping_provider: - type: object - description: >- - Using this option, an external module can be provided as a custom - solution to mapping attributes returned from a saml provider onto a - matrix user. - properties: - module: - type: string - description: The custom module's class. - config: - type: object - description: >- - Custom configuration values for the module. Use the values - provided in the example if you are using the built-in - user_mapping_provider, or provide your own config values for a - custom class if you are using one. This section will be passed as - a Python dictionary to the module's `parse_config` method. The - built-in provider takes the following two options: - - * `mxid_source_attribute`: The SAML attribute (after mapping via - the attribute maps) to use to derive the Matrix ID from. It is - "uid" by default. Note: This used to be configured by the - `saml2_config.mxid_source_attribute option`. If that is still - defined, its value will be used instead. - - * `mxid_mapping`: The mapping system to use for mapping the saml - attribute onto a matrix ID. Options include: `hexencode` (which - maps unpermitted characters to `=xx`) and `dotreplace` (which - replaces unpermitted characters with `.`). The default is - `hexencode`. Note: This used to be configured by the - `saml2_config.mxid_mapping option`. If that is still defined, its - value will be used instead. - grandfathered_mxid_source_attribute: - type: string - description: >- - In previous versions of synapse, the mapping from SAML attribute to - MXID was always calculated dynamically rather than stored in a table. - For backwards-compatibility, we will look for `user_ids` matching such - a pattern before creating a new account. This setting controls the - SAML attribute which will be used for this backwards-compatibility - lookup. Typically it should be "uid", but if the attribute maps are - changed, it may be necessary to change it. - default: uid - attribute_requirements: - type: array - description: >- - It is possible to configure Synapse to only allow logins if SAML - attributes match particular values. The requirements can be listed - under `attribute_requirements` as shown in the example. All of the - listed attributes must match for the login to be permitted. Values can - be specified in a `one_of` list to allow multiple values for an - attribute. - items: - type: object - description: Item allowing a specific SAML attribute. - properties: - attribute: - type: string - description: SAML attribute for which to allow logins. - value: - type: string - description: Value the SAML attribute must match. - one_of: - type: array - description: List of values the SAML attribute must all match. - items: - type: string - required: - - attribute - idp_entityid: - type: ["string", "null"] - description: >- - If the metadata XML contains multiple IdP entities then the - `idp_entityid` option must be set to the entity to redirect users to. - Most deployments only have a single IdP entity and so should omit this - option. - default: null - examples: - - sp_config: - metadata: - local: - - saml2/idp.xml - remote: - - url: "https://our_idp/metadata.xml" - accepted_time_diff: 3 - service: - sp: - allow_unsolicited: true - description: - - My awesome SP - - en - name: - - Test SP - - en - ui_info: - display_name: - - lang: en - text: Display Name is the descriptive name of your service. - description: - - lang: en - text: >- - Description should be a short paragraph explaining the purpose - of the service. - information_url: - - lang: en - text: "https://example.com/terms-of-service" - privacy_statement_url: - - lang: en - text: "https://example.com/privacy-policy" - keywords: - - lang: en - text: - - Matrix - - Element - logo: - - lang: en - text: "https://example.com/logo.svg" - width: "200" - height: "80" - organization: - name: Example com - display_name: - - - Example co - - en - url: "http://example.com" - contact_person: - - given_name: Bob - sur_name: the Sysadmin - email_address: - - admin@example.com - contact_type: technical - saml_session_lifetime: 5m - user_mapping_provider: - config: - mxid_source_attribute: displayName - mxid_mapping: dotreplace - grandfathered_mxid_source_attribute: upn - attribute_requirements: - - attribute: userGroup - value: staff - - attribute: department - one_of: - - sales - - admins - idp_entityid: "https://our_idp/entityid" oidc_providers: type: array description: >- @@ -4275,7 +4060,7 @@ properties: type: object description: >- Additional settings to use with single-sign on systems such as OpenID - Connect, SAML2 and CAS. + Connect. Server admins can configure custom templates for pages related to SSO. See diff --git a/scripts-dev/gen_config_documentation.py b/scripts-dev/gen_config_documentation.py
index 8e9d402c6a..a169fd323f 100755 --- a/scripts-dev/gen_config_documentation.py +++ b/scripts-dev/gen_config_documentation.py
@@ -188,15 +188,6 @@ SECTION_HEADERS = { "title": "Signing Keys", "description": ("Config options relating to signing keys."), }, - "saml2_config": { - "title": "Single sign-on integration", - "description": ( - "The following settings can be used to make Synapse use a single sign-on provider for authentication, instead of its internal password database.\n\n" - "You will probably also want to set the following options to `false` to disable the regular login/registration flows:\n" - "* [`enable_registration`](#enable_registration)\n" - "* [`password_config.enabled`](#password_config)" - ), - }, "push": { "title": "Push", "description": ("Configuration settings related to push notifications."), diff --git a/synapse/config/_base.pyi b/synapse/config/_base.pyi
index 336745be87..aee71d697f 100644 --- a/synapse/config/_base.pyi +++ b/synapse/config/_base.pyi
@@ -49,7 +49,6 @@ from synapse.config import ( # noqa: F401 retention, room, room_directory, - saml2, server, server_notices, spam_checker, @@ -97,7 +96,6 @@ class RootConfig: api: api.ApiConfig appservice: appservice.AppServiceConfig key: key.KeyConfig - saml2: saml2.SAML2Config sso: sso.SSOConfig oidc: oidc.OIDCConfig jwt: jwt.JWTConfig diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index adc37ded95..35b5389b6b 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py
@@ -304,7 +304,6 @@ class MSC3861: if ( root.oidc.oidc_enabled - or root.saml2.saml2_enabled or root.jwt.jwt_enabled ): raise ConfigError("SSO cannot be enabled when OAuth delegation is enabled") diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py
index 430df67a20..d9f0e792ff 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py
@@ -48,7 +48,6 @@ from .repository import ContentRepositoryConfig from .retention import RetentionConfig from .room import RoomConfig from .room_directory import RoomDirectoryConfig -from .saml2 import SAML2Config from .server import ServerConfig from .server_notices import ServerNoticesConfig from .spam_checker import SpamCheckerConfig @@ -84,7 +83,6 @@ class HomeServerConfig(RootConfig): ApiConfig, AppServiceConfig, KeyConfig, - SAML2Config, OIDCConfig, SSOConfig, JWTConfig, diff --git a/synapse/config/oidc.py b/synapse/config/oidc.py
index 3ddf65a3e9..b18654ff6a 100644 --- a/synapse/config/oidc.py +++ b/synapse/config/oidc.py
@@ -255,7 +255,7 @@ def _parse_oidc_config_dict( idp_id = oidc_config.get("idp_id", "oidc") # prefix the given IDP with a prefix specific to the SSO mechanism, to avoid - # clashes with other mechs (such as SAML, CAS). + # clashes with other mechs). # # We allow "oidc" as an exception so that people migrating from old-style # "oidc_config" format (which has long used "oidc" as its idp_id) can migrate to diff --git a/synapse/config/saml2.py b/synapse/config/saml2.py deleted file mode 100644
index 9d7ef94507..0000000000 --- a/synapse/config/saml2.py +++ /dev/null
@@ -1,248 +0,0 @@ -# -# This file is licensed under the Affero General Public License (AGPL) version 3. -# -# Copyright 2019-2021 The Matrix.org Foundation C.I.C. -# Copyright (C) 2023 New Vector, Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# See the GNU Affero General Public License for more details: -# <https://www.gnu.org/licenses/agpl-3.0.html>. -# -# Originally licensed under the Apache License, Version 2.0: -# <http://www.apache.org/licenses/LICENSE-2.0>. -# -# [This file includes modifications made by New Vector Limited] -# -# - -import logging -from typing import Any, List, Set - -from synapse.config.sso import SsoAttributeRequirement -from synapse.types import JsonDict -from synapse.util.check_dependencies import check_requirements -from synapse.util.module_loader import load_module, load_python_module - -from ._base import Config, ConfigError -from ._util import validate_config - -logger = logging.getLogger(__name__) - -DEFAULT_USER_MAPPING_PROVIDER = "synapse.handlers.saml.DefaultSamlMappingProvider" -# The module that DefaultSamlMappingProvider is in was renamed, we want to -# transparently handle both the same. -LEGACY_USER_MAPPING_PROVIDER = ( - "synapse.handlers.saml_handler.DefaultSamlMappingProvider" -) - - -def _dict_merge(merge_dict: dict, into_dict: dict) -> None: - """Do a deep merge of two dicts - - Recursively merges `merge_dict` into `into_dict`: - * For keys where both `merge_dict` and `into_dict` have a dict value, the values - are recursively merged - * For all other keys, the values in `into_dict` (if any) are overwritten with - the value from `merge_dict`. - - Args: - merge_dict: dict to merge - into_dict: target dict to be modified - """ - for k, v in merge_dict.items(): - if k not in into_dict: - into_dict[k] = v - continue - - current_val = into_dict[k] - - if isinstance(v, dict) and isinstance(current_val, dict): - _dict_merge(v, current_val) - continue - - # otherwise we just overwrite - into_dict[k] = v - - -class SAML2Config(Config): - section = "saml2" - - def read_config(self, config: JsonDict, **kwargs: Any) -> None: - self.saml2_enabled = False - - saml2_config = config.get("saml2_config") - - if not saml2_config or not saml2_config.get("enabled", True): - return - - if not saml2_config.get("sp_config") and not saml2_config.get("config_path"): - return - - check_requirements("saml2") - - self.saml2_enabled = True - - attribute_requirements = saml2_config.get("attribute_requirements") or [] - self.attribute_requirements = _parse_attribute_requirements_def( - attribute_requirements - ) - - self.saml2_grandfathered_mxid_source_attribute = saml2_config.get( - "grandfathered_mxid_source_attribute", "uid" - ) - - # refers to a SAML IdP entity ID - self.saml2_idp_entityid = saml2_config.get("idp_entityid", None) - - # IdP properties for Matrix clients - self.idp_name = saml2_config.get("idp_name", "SAML") - self.idp_icon = saml2_config.get("idp_icon") - self.idp_brand = saml2_config.get("idp_brand") - - # user_mapping_provider may be None if the key is present but has no value - ump_dict = saml2_config.get("user_mapping_provider") or {} - - # Use the default user mapping provider if not set - ump_dict.setdefault("module", DEFAULT_USER_MAPPING_PROVIDER) - if ump_dict.get("module") == LEGACY_USER_MAPPING_PROVIDER: - ump_dict["module"] = DEFAULT_USER_MAPPING_PROVIDER - - # Ensure a config is present - ump_dict["config"] = ump_dict.get("config") or {} - - if ump_dict["module"] == DEFAULT_USER_MAPPING_PROVIDER: - # Load deprecated options for use by the default module - old_mxid_source_attribute = saml2_config.get("mxid_source_attribute") - if old_mxid_source_attribute: - logger.warning( - "The config option saml2_config.mxid_source_attribute is deprecated. " - "Please use saml2_config.user_mapping_provider.config" - ".mxid_source_attribute instead." - ) - ump_dict["config"]["mxid_source_attribute"] = old_mxid_source_attribute - - old_mxid_mapping = saml2_config.get("mxid_mapping") - if old_mxid_mapping: - logger.warning( - "The config option saml2_config.mxid_mapping is deprecated. Please " - "use saml2_config.user_mapping_provider.config.mxid_mapping instead." - ) - ump_dict["config"]["mxid_mapping"] = old_mxid_mapping - - # Retrieve an instance of the module's class - # Pass the config dictionary to the module for processing - ( - self.saml2_user_mapping_provider_class, - self.saml2_user_mapping_provider_config, - ) = load_module(ump_dict, ("saml2_config", "user_mapping_provider")) - - # Ensure loaded user mapping module has defined all necessary methods - # Note parse_config() is already checked during the call to load_module - required_methods = [ - "get_saml_attributes", - "saml_response_to_user_attributes", - "get_remote_user_id", - ] - missing_methods = [ - method - for method in required_methods - if not hasattr(self.saml2_user_mapping_provider_class, method) - ] - if missing_methods: - raise ConfigError( - "Class specified by saml2_config." - "user_mapping_provider.module is missing required " - "methods: %s" % (", ".join(missing_methods),) - ) - - # Get the desired saml auth response attributes from the module - saml2_config_dict = self._default_saml_config_dict( - *self.saml2_user_mapping_provider_class.get_saml_attributes( - self.saml2_user_mapping_provider_config - ) - ) - _dict_merge( - merge_dict=saml2_config.get("sp_config", {}), into_dict=saml2_config_dict - ) - - config_path = saml2_config.get("config_path", None) - if config_path is not None: - mod = load_python_module(config_path) - config_dict_from_file = getattr(mod, "CONFIG", None) - if config_dict_from_file is None: - raise ConfigError( - "Config path specified by saml2_config.config_path does not " - "have a CONFIG property." - ) - _dict_merge(merge_dict=config_dict_from_file, into_dict=saml2_config_dict) - - import saml2.config - - self.saml2_sp_config = saml2.config.SPConfig() - self.saml2_sp_config.load(saml2_config_dict) - - # session lifetime: in milliseconds - self.saml2_session_lifetime = self.parse_duration( - saml2_config.get("saml_session_lifetime", "15m") - ) - - def _default_saml_config_dict( - self, required_attributes: Set[str], optional_attributes: Set[str] - ) -> JsonDict: - """Generate a configuration dictionary with required and optional attributes that - will be needed to process new user registration - - Args: - required_attributes: SAML auth response attributes that are - necessary to function - optional_attributes: SAML auth response attributes that can be used to add - additional information to Synapse user accounts, but are not required - - Returns: - A SAML configuration dictionary - """ - import saml2 - - if self.saml2_grandfathered_mxid_source_attribute: - optional_attributes.add(self.saml2_grandfathered_mxid_source_attribute) - optional_attributes -= required_attributes - - public_baseurl = self.root.server.public_baseurl - metadata_url = public_baseurl + "_synapse/client/saml2/metadata.xml" - response_url = public_baseurl + "_synapse/client/saml2/authn_response" - return { - "entityid": metadata_url, - "service": { - "sp": { - "endpoints": { - "assertion_consumer_service": [ - (response_url, saml2.BINDING_HTTP_POST) - ] - }, - "required_attributes": list(required_attributes), - "optional_attributes": list(optional_attributes), - # "name_id_format": saml2.saml.NAMEID_FORMAT_PERSISTENT, - } - }, - } - - -ATTRIBUTE_REQUIREMENTS_SCHEMA = { - "type": "array", - "items": SsoAttributeRequirement.JSON_SCHEMA, -} - - -def _parse_attribute_requirements_def( - attribute_requirements: Any, -) -> List[SsoAttributeRequirement]: - validate_config( - ATTRIBUTE_REQUIREMENTS_SCHEMA, - attribute_requirements, - config_path=("saml2_config", "attribute_requirements"), - ) - return [SsoAttributeRequirement(**x) for x in attribute_requirements] diff --git a/synapse/handlers/auth.py b/synapse/handlers/auth.py
index e96922c08d..e857ebb179 100644 --- a/synapse/handlers/auth.py +++ b/synapse/handlers/auth.py
@@ -187,7 +187,7 @@ def login_id_phone_to_thirdparty(identifier: JsonDict) -> Dict[str, str]: @attr.s(slots=True, auto_attribs=True) class SsoLoginExtraAttributes: - """Data we track about SAML2 sessions""" + """Data we track about SAML2 sessions""" # Not other SSO types...? # time the session was created, in milliseconds creation_time: int diff --git a/synapse/handlers/saml.py b/synapse/handlers/saml.py deleted file mode 100644
index 8ebd3d4ff9..0000000000 --- a/synapse/handlers/saml.py +++ /dev/null
@@ -1,524 +0,0 @@ -# -# This file is licensed under the Affero General Public License (AGPL) version 3. -# -# Copyright 2019 The Matrix.org Foundation C.I.C. -# Copyright (C) 2023 New Vector, Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# See the GNU Affero General Public License for more details: -# <https://www.gnu.org/licenses/agpl-3.0.html>. -# -# Originally licensed under the Apache License, Version 2.0: -# <http://www.apache.org/licenses/LICENSE-2.0>. -# -# [This file includes modifications made by New Vector Limited] -# -# -import logging -import re -from typing import TYPE_CHECKING, Callable, Dict, Optional, Set, Tuple - -import attr -import saml2 -import saml2.response -from saml2.client import Saml2Client - -from synapse.api.errors import SynapseError -from synapse.config import ConfigError -from synapse.handlers.sso import MappingException, UserAttributes -from synapse.http.servlet import parse_string -from synapse.http.site import SynapseRequest -from synapse.module_api import ModuleApi -from synapse.types import ( - MXID_LOCALPART_ALLOWED_CHARACTERS, - UserID, - map_username_to_mxid_localpart, -) -from synapse.util.iterutils import chunk_seq - -if TYPE_CHECKING: - from synapse.server import HomeServer - -logger = logging.getLogger(__name__) - - -@attr.s(slots=True, auto_attribs=True) -class Saml2SessionData: - """Data we track about SAML2 sessions""" - - # time the session was created, in milliseconds - creation_time: int - # The user interactive authentication session ID associated with this SAML - # session (or None if this SAML session is for an initial login). - ui_auth_session_id: Optional[str] = None - - -class SamlHandler: - def __init__(self, hs: "HomeServer"): - self.store = hs.get_datastores().main - self.clock = hs.get_clock() - self.server_name = hs.hostname - self._saml_client = Saml2Client(hs.config.saml2.saml2_sp_config) - self._saml_idp_entityid = hs.config.saml2.saml2_idp_entityid - - self._saml2_session_lifetime = hs.config.saml2.saml2_session_lifetime - self._grandfathered_mxid_source_attribute = ( - hs.config.saml2.saml2_grandfathered_mxid_source_attribute - ) - self._saml2_attribute_requirements = hs.config.saml2.attribute_requirements - - # plugin to do custom mapping from saml response to mxid - self._user_mapping_provider = hs.config.saml2.saml2_user_mapping_provider_class( - hs.config.saml2.saml2_user_mapping_provider_config, - ModuleApi(hs, hs.get_auth_handler()), - ) - - # identifier for the external_ids table - self.idp_id = "saml" - - # user-facing name of this auth provider - self.idp_name = hs.config.saml2.idp_name - - # MXC URI for icon for this auth provider - self.idp_icon = hs.config.saml2.idp_icon - - # optional brand identifier for this auth provider - self.idp_brand = hs.config.saml2.idp_brand - - # a map from saml session id to Saml2SessionData object - self._outstanding_requests_dict: Dict[str, Saml2SessionData] = {} - - self._sso_handler = hs.get_sso_handler() - self._sso_handler.register_identity_provider(self) - - async def handle_redirect_request( - self, - request: SynapseRequest, - client_redirect_url: Optional[bytes], - ui_auth_session_id: Optional[str] = None, - ) -> str: - """Handle an incoming request to /login/sso/redirect - - Args: - request: the incoming HTTP request - client_redirect_url: the URL that we should redirect the - client to after login (or None for UI Auth). - ui_auth_session_id: The session ID of the ongoing UI Auth (or - None if this is a login). - - Returns: - URL to redirect to - """ - if not client_redirect_url: - # Some SAML identity providers (e.g. Google) require a - # RelayState parameter on requests, so pass in a dummy redirect URL - # (which will never get used). - client_redirect_url = b"unused" - - reqid, info = self._saml_client.prepare_for_authenticate( - entityid=self._saml_idp_entityid, relay_state=client_redirect_url - ) - - # Since SAML sessions timeout it is useful to log when they were created. - logger.info("Initiating a new SAML session: %s" % (reqid,)) - - now = self.clock.time_msec() - self._outstanding_requests_dict[reqid] = Saml2SessionData( - creation_time=now, - ui_auth_session_id=ui_auth_session_id, - ) - - for key, value in info["headers"]: - if key == "Location": - return value - - # this shouldn't happen! - raise Exception("prepare_for_authenticate didn't return a Location header") - - async def handle_saml_response(self, request: SynapseRequest) -> None: - """Handle an incoming request to /_synapse/client/saml2/authn_response - - Args: - request: the incoming request from the browser. We'll - respond to it with a redirect. - - Returns: - Completes once we have handled the request. - """ - resp_bytes = parse_string(request, "SAMLResponse", required=True) - relay_state = parse_string(request, "RelayState", required=True) - - # expire outstanding sessions before parse_authn_request_response checks - # the dict. - self.expire_sessions() - - try: - saml2_auth = self._saml_client.parse_authn_request_response( - resp_bytes, - saml2.BINDING_HTTP_POST, - outstanding=self._outstanding_requests_dict, - ) - except saml2.response.UnsolicitedResponse as e: - # the pysaml2 library helpfully logs an ERROR here, but neglects to log - # the session ID. I don't really want to put the full text of the exception - # in the (user-visible) exception message, so let's log the exception here - # so we can track down the session IDs later. - logger.warning(str(e)) - self._sso_handler.render_error( - request, "unsolicited_response", "Unexpected SAML2 login." - ) - return - except Exception as e: - self._sso_handler.render_error( - request, - "invalid_response", - "Unable to parse SAML2 response: %s." % (e,), - ) - return - - if saml2_auth.not_signed: - self._sso_handler.render_error( - request, "unsigned_respond", "SAML2 response was not signed." - ) - return - - logger.debug("SAML2 response: %s", saml2_auth.origxml) - - await self._handle_authn_response(request, saml2_auth, relay_state) - - async def _handle_authn_response( - self, - request: SynapseRequest, - saml2_auth: saml2.response.AuthnResponse, - relay_state: str, - ) -> None: - """Handle an AuthnResponse, having parsed it from the request params - - Assumes that the signature on the response object has been checked. Maps - the user onto an MXID, registering them if necessary, and returns a response - to the browser. - - Args: - request: the incoming request from the browser. We'll respond to it with an - HTML page or a redirect - saml2_auth: the parsed AuthnResponse object - relay_state: the RelayState query param, which encodes the URI to rediret - back to - """ - - for assertion in saml2_auth.assertions: - # kibana limits the length of a log field, whereas this is all rather - # useful, so split it up. - count = 0 - for part in chunk_seq(str(assertion), 10000): - logger.info( - "SAML2 assertion: %s%s", "(%i)..." % (count,) if count else "", part - ) - count += 1 - - logger.info("SAML2 mapped attributes: %s", saml2_auth.ava) - - current_session = self._outstanding_requests_dict.pop( - saml2_auth.in_response_to, None - ) - - # first check if we're doing a UIA - if current_session and current_session.ui_auth_session_id: - try: - remote_user_id = self._remote_id_from_saml_response(saml2_auth, None) - except MappingException as e: - logger.exception("Failed to extract remote user id from SAML response") - self._sso_handler.render_error(request, "mapping_error", str(e)) - return - - return await self._sso_handler.complete_sso_ui_auth_request( - self.idp_id, - remote_user_id, - current_session.ui_auth_session_id, - request, - ) - - # otherwise, we're handling a login request. - - # Ensure that the attributes of the logged in user meet the required - # attributes. - if not self._sso_handler.check_required_attributes( - request, saml2_auth.ava, self._saml2_attribute_requirements - ): - return - - # Call the mapper to register/login the user - try: - await self._complete_saml_login(saml2_auth, request, relay_state) - except MappingException as e: - logger.exception("Could not map user") - self._sso_handler.render_error(request, "mapping_error", str(e)) - - async def _complete_saml_login( - self, - saml2_auth: saml2.response.AuthnResponse, - request: SynapseRequest, - client_redirect_url: str, - ) -> None: - """ - Given a SAML response, complete the login flow - - Retrieves the remote user ID, registers the user if necessary, and serves - a redirect back to the client with a login-token. - - Args: - saml2_auth: The parsed SAML2 response. - request: The request to respond to - client_redirect_url: The redirect URL passed in by the client. - - Raises: - MappingException if there was a problem mapping the response to a user. - RedirectException: some mapping providers may raise this if they need - to redirect to an interstitial page. - """ - remote_user_id = self._remote_id_from_saml_response( - saml2_auth, client_redirect_url - ) - - async def saml_response_to_remapped_user_attributes( - failures: int, - ) -> UserAttributes: - """ - Call the mapping provider to map a SAML response to user attributes and coerce the result into the standard form. - - This is backwards compatibility for abstraction for the SSO handler. - """ - # Call the mapping provider. - result = self._user_mapping_provider.saml_response_to_user_attributes( - saml2_auth, failures, client_redirect_url - ) - # Remap some of the results. - return UserAttributes( - localpart=result.get("mxid_localpart"), - display_name=result.get("displayname"), - emails=result.get("emails", []), - ) - - async def grandfather_existing_users() -> Optional[str]: - # backwards-compatibility hack: see if there is an existing user with a - # suitable mapping from the uid - if ( - self._grandfathered_mxid_source_attribute - and self._grandfathered_mxid_source_attribute in saml2_auth.ava - ): - attrval = saml2_auth.ava[self._grandfathered_mxid_source_attribute][0] - user_id = UserID( - map_username_to_mxid_localpart(attrval), self.server_name - ).to_string() - - logger.debug( - "Looking for existing account based on mapped %s %s", - self._grandfathered_mxid_source_attribute, - user_id, - ) - - users = await self.store.get_users_by_id_case_insensitive(user_id) - if users: - registered_user_id = list(users.keys())[0] - logger.info("Grandfathering mapping to %s", registered_user_id) - return registered_user_id - - return None - - await self._sso_handler.complete_sso_login_request( - self.idp_id, - remote_user_id, - request, - client_redirect_url, - saml_response_to_remapped_user_attributes, - grandfather_existing_users, - ) - - def _remote_id_from_saml_response( - self, - saml2_auth: saml2.response.AuthnResponse, - client_redirect_url: Optional[str], - ) -> str: - """Extract the unique remote id from a SAML2 AuthnResponse - - Args: - saml2_auth: The parsed SAML2 response. - client_redirect_url: The redirect URL passed in by the client. - Returns: - remote user id - - Raises: - MappingException if there was an error extracting the user id - """ - # It's not obvious why we need to pass in the redirect URI to the mapping - # provider, but we do :/ - remote_user_id = self._user_mapping_provider.get_remote_user_id( - saml2_auth, client_redirect_url - ) - - if not remote_user_id: - raise MappingException( - "Failed to extract remote user id from SAML response" - ) - - return remote_user_id - - def expire_sessions(self) -> None: - expire_before = self.clock.time_msec() - self._saml2_session_lifetime - to_expire = set() - for reqid, data in self._outstanding_requests_dict.items(): - if data.creation_time < expire_before: - to_expire.add(reqid) - for reqid in to_expire: - logger.debug("Expiring session id %s", reqid) - del self._outstanding_requests_dict[reqid] - - -DOT_REPLACE_PATTERN = re.compile( - "[^%s]" % (re.escape("".join(MXID_LOCALPART_ALLOWED_CHARACTERS)),) -) - - -def dot_replace_for_mxid(username: str) -> str: - """Replace any characters which are not allowed in Matrix IDs with a dot.""" - username = username.lower() - username = DOT_REPLACE_PATTERN.sub(".", username) - - # regular mxids aren't allowed to start with an underscore either - username = re.sub("^_", "", username) - return username - - -MXID_MAPPER_MAP: Dict[str, Callable[[str], str]] = { - "hexencode": map_username_to_mxid_localpart, - "dotreplace": dot_replace_for_mxid, -} - - -@attr.s(auto_attribs=True) -class SamlConfig: - mxid_source_attribute: str - mxid_mapper: Callable[[str], str] - - -class DefaultSamlMappingProvider: - __version__ = "0.0.1" - - def __init__(self, parsed_config: SamlConfig, module_api: ModuleApi): - """The default SAML user mapping provider - - Args: - parsed_config: Module configuration - module_api: module api proxy - """ - self._mxid_source_attribute = parsed_config.mxid_source_attribute - self._mxid_mapper = parsed_config.mxid_mapper - - self._grandfathered_mxid_source_attribute = ( - module_api._hs.config.saml2.saml2_grandfathered_mxid_source_attribute - ) - - def get_remote_user_id( - self, saml_response: saml2.response.AuthnResponse, client_redirect_url: str - ) -> str: - """Extracts the remote user id from the SAML response""" - try: - return saml_response.ava["uid"][0] - except KeyError: - logger.warning("SAML2 response lacks a 'uid' attestation") - raise MappingException("'uid' not in SAML2 response") - - def saml_response_to_user_attributes( - self, - saml_response: saml2.response.AuthnResponse, - failures: int, - client_redirect_url: str, - ) -> dict: - """Maps some text from a SAML response to attributes of a new user - - Args: - saml_response: A SAML auth response object - - failures: How many times a call to this function with this - saml_response has resulted in a failure - - client_redirect_url: where the client wants to redirect to - - Returns: - A dict containing new user attributes. Possible keys: - * mxid_localpart (str): Required. The localpart of the user's mxid - * displayname (str): The displayname of the user - * emails (list[str]): Any emails for the user - """ - try: - mxid_source = saml_response.ava[self._mxid_source_attribute][0] - except KeyError: - logger.warning( - "SAML2 response lacks a '%s' attestation", - self._mxid_source_attribute, - ) - raise SynapseError( - 400, "%s not in SAML2 response" % (self._mxid_source_attribute,) - ) - - # Use the configured mapper for this mxid_source - localpart = self._mxid_mapper(mxid_source) - - # Append suffix integer if last call to this function failed to produce - # a usable mxid. - localpart += str(failures) if failures else "" - - # Retrieve the display name from the saml response - # If displayname is None, the mxid_localpart will be used instead - displayname = saml_response.ava.get("displayName", [None])[0] - - # Retrieve any emails present in the saml response - emails = saml_response.ava.get("email", []) - - return { - "mxid_localpart": localpart, - "displayname": displayname, - "emails": emails, - } - - @staticmethod - def parse_config(config: dict) -> SamlConfig: - """Parse the dict provided by the homeserver's config - Args: - config: A dictionary containing configuration options for this provider - Returns: - A custom config object for this module - """ - # Parse config options and use defaults where necessary - mxid_source_attribute = config.get("mxid_source_attribute", "uid") - mapping_type = config.get("mxid_mapping", "hexencode") - - # Retrieve the associating mapping function - try: - mxid_mapper = MXID_MAPPER_MAP[mapping_type] - except KeyError: - raise ConfigError( - "saml2_config.user_mapping_provider.config: '%s' is not a valid " - "mxid_mapping value" % (mapping_type,) - ) - - return SamlConfig(mxid_source_attribute, mxid_mapper) - - @staticmethod - def get_saml_attributes(config: SamlConfig) -> Tuple[Set[str], Set[str]]: - """Returns the required attributes of a SAML - - Args: - config: A SamlConfig object containing configuration params for this provider - - Returns: - The first set equates to the saml auth response - attributes that are required for the module to function, whereas the - second set consists of those attributes which can be used if - available, but are not necessary - """ - return {"uid", config.mxid_source_attribute}, {"displayName", "email"} diff --git a/synapse/handlers/sso.py b/synapse/handlers/sso.py
index 07827cf95b..2795b282e5 100644 --- a/synapse/handlers/sso.py +++ b/synapse/handlers/sso.py
@@ -81,8 +81,7 @@ class SsoIdentityProvider(Protocol): An Identity Provider, or IdP, is an external HTTP service which authenticates a user to say whether they should be allowed to log in, or perform a given action. - Synapse supports various implementations of IdPs, including OpenID Connect, SAML, - and CAS. + Synapse supports various implementations of IdPs, including OpenID Connect. The main entry point is `handle_redirect_request`, which should return a URI to redirect the user's browser to the IdP's authentication page. @@ -97,7 +96,7 @@ class SsoIdentityProvider(Protocol): def idp_id(self) -> str: """A unique identifier for this SSO provider - Eg, "saml", "cas", "github" + Eg. "github" """ @property @@ -157,7 +156,7 @@ class UserAttributes: class UsernameMappingSession: """Data we track about SSO sessions""" - # A unique identifier for this SSO provider, e.g. "oidc" or "saml". + # A unique identifier for this SSO provider, e.g. "oidc". auth_provider_id: str # An optional session ID from the IdP. @@ -351,7 +350,7 @@ class SsoHandler: Args: auth_provider_id: A unique identifier for this SSO provider, e.g. - "oidc" or "saml". + "oidc". remote_user_id: The user ID according to the remote IdP. This might be an e-mail address, a GUID, or some other form. It must be unique and immutable. @@ -418,7 +417,7 @@ class SsoHandler: Args: auth_provider_id: A unique identifier for this SSO provider, e.g. - "oidc" or "saml". + "oidc". remote_user_id: The unique identifier from the SSO provider. @@ -634,7 +633,7 @@ class SsoHandler: Args: auth_provider_id: A unique identifier for this SSO provider, e.g. - "oidc" or "saml". + "oidc". remote_user_id: The unique identifier from the SSO provider. @@ -704,7 +703,7 @@ class SsoHandler: including a non-empty localpart. auth_provider_id: A unique identifier for this SSO provider, e.g. - "oidc" or "saml". + "oidc". remote_user_id: The unique identifier from the SSO provider. @@ -856,12 +855,12 @@ class SsoHandler: Given an SSO ID, retrieve the user ID for it and complete UIA. Note that this requires that the user is mapped in the "user_external_ids" - table. This will be the case if they have ever logged in via SAML or OIDC in + table. This will be the case if they have ever logged in via OIDC in recentish synapse versions, but may not be for older users. Args: auth_provider_id: A unique identifier for this SSO provider, e.g. - "oidc" or "saml". + "oidc". remote_user_id: The unique identifier from the SSO provider. ui_auth_session_id: The ID of the user-interactive auth session. request: The request to complete. @@ -1185,7 +1184,7 @@ class SsoHandler: Args: auth_provider_id: A unique identifier for this SSO provider, e.g. - "oidc" or "saml". + "oidc". 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. diff --git a/synapse/module_api/callbacks/spamchecker_callbacks.py b/synapse/module_api/callbacks/spamchecker_callbacks.py
index bea5c5badf..d37f2efb3b 100644 --- a/synapse/module_api/callbacks/spamchecker_callbacks.py +++ b/synapse/module_api/callbacks/spamchecker_callbacks.py
@@ -866,8 +866,8 @@ class SpamCheckerModuleApiCallbacks: username: The request user name, if any request_info: List of tuples of user agent and IP that were used during the registration process. - auth_provider_id: The SSO IdP the user used, e.g "oidc", "saml", - "cas". If any. Note this does not include users registered + auth_provider_id: The SSO IdP the user used, e.g "oidc". + If any. Note this does not include users registered via a password provider. Returns: @@ -955,8 +955,8 @@ class SpamCheckerModuleApiCallbacks: user_id: The request user ID request_info: List of tuples of user agent and IP that were used during the registration process. - auth_provider_id: The SSO IdP the user used, e.g "oidc", "saml", - "cas". If any. Note this does not include users registered + auth_provider_id: The SSO IdP the user used, e.g "oidc". + If any. Note this does not include users registered via a password provider. Returns: diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index f65f8f2130..cc6863cadc 100644 --- a/synapse/rest/client/login.py +++ b/synapse/rest/client/login.py
@@ -96,7 +96,6 @@ class LoginRestServlet(RestServlet): self.jwt_enabled = hs.config.jwt.jwt_enabled # SSO configuration. - self.saml2_enabled = hs.config.saml2.saml2_enabled self.oidc_enabled = hs.config.oidc.oidc_enabled self._refresh_tokens_enabled = ( hs.config.registration.refreshable_access_token_lifetime is not None @@ -133,7 +132,7 @@ class LoginRestServlet(RestServlet): cfg=self.hs.config.ratelimiting.rc_login_account, ) - # ensure the SAML/OIDC handlers are loaded on this worker instance. + # ensure the OIDC handlers are loaded on this worker instance. # The reason for this is to ensure that the auth_provider_ids are registered # with SsoHandler, which in turn ensures that the login/registration prometheus # counters are initialised for the auth_provider_ids. @@ -147,7 +146,7 @@ class LoginRestServlet(RestServlet): # The login token flow requires m.login.token to be advertised. support_login_token_flow = self._get_login_token_enabled - if self.saml2_enabled or self.oidc_enabled: + if self.oidc_enabled: flows.append( { "type": LoginRestServlet.SSO_TYPE, @@ -317,7 +316,7 @@ class LoginRestServlet(RestServlet): *, request_info: RequestInfo, ) -> LoginResponse: - """Handle non-token/saml/jwt logins + """Handle non-token/jwt logins Args: login_submission: @@ -687,8 +686,7 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: ): RefreshTokenServlet(hs).register(http_server) if ( - hs.config.saml2.saml2_enabled - or hs.config.oidc.oidc_enabled + hs.config.oidc.oidc_enabled ): SsoRedirectServlet(hs).register(http_server) @@ -696,12 +694,10 @@ def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: def _load_sso_handlers(hs: "HomeServer") -> None: """Ensure that the SSO handlers are loaded, if they are enabled by configuration. - This is mostly useful to ensure that the SAML/OIDC handlers register themselves + This is mostly useful to ensure that the OIDC handler registers itself with the main SsoHandler. It's safe to call this multiple times. """ - if hs.config.saml2.saml2_enabled: - hs.get_saml_handler() if hs.config.oidc.oidc_enabled: hs.get_oidc_handler() diff --git a/synapse/rest/synapse/client/__init__.py b/synapse/rest/synapse/client/__init__.py
index 7b5bfc0421..3afeb97be2 100644 --- a/synapse/rest/synapse/client/__init__.py +++ b/synapse/rest/synapse/client/__init__.py
@@ -68,16 +68,6 @@ def build_synapse_client_resource_tree(hs: "HomeServer") -> Mapping[str, Resourc resources["/_synapse/client/oidc"] = OIDCResource(hs) - if hs.config.saml2.saml2_enabled: - from synapse.rest.synapse.client.saml2 import SAML2Resource - - res = SAML2Resource(hs) - resources["/_synapse/client/saml2"] = res - - # This is also mounted under '/_matrix' for backwards-compatibility. - # To be removed in Synapse v1.32.0. - resources["/_matrix/saml2"] = res - if hs.config.federation.federation_whitelist_endpoint_enabled: resources[FederationWhitelistResource.PATH] = FederationWhitelistResource(hs) diff --git a/synapse/rest/synapse/client/saml2/__init__.py b/synapse/rest/synapse/client/saml2/__init__.py deleted file mode 100644
index 3658c6a0e3..0000000000 --- a/synapse/rest/synapse/client/saml2/__init__.py +++ /dev/null
@@ -1,42 +0,0 @@ -# -# This file is licensed under the Affero General Public License (AGPL) version 3. -# -# Copyright (C) 2023 New Vector, Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# See the GNU Affero General Public License for more details: -# <https://www.gnu.org/licenses/agpl-3.0.html>. -# -# Originally licensed under the Apache License, Version 2.0: -# <http://www.apache.org/licenses/LICENSE-2.0>. -# -# [This file includes modifications made by New Vector Limited] -# -# - -import logging -from typing import TYPE_CHECKING - -from twisted.web.resource import Resource - -from synapse.rest.synapse.client.saml2.metadata_resource import SAML2MetadataResource -from synapse.rest.synapse.client.saml2.response_resource import SAML2ResponseResource - -if TYPE_CHECKING: - from synapse.server import HomeServer - -logger = logging.getLogger(__name__) - - -class SAML2Resource(Resource): - def __init__(self, hs: "HomeServer"): - Resource.__init__(self) - self.putChild(b"metadata.xml", SAML2MetadataResource(hs)) - self.putChild(b"authn_response", SAML2ResponseResource(hs)) - - -__all__ = ["SAML2Resource"] diff --git a/synapse/rest/synapse/client/saml2/metadata_resource.py b/synapse/rest/synapse/client/saml2/metadata_resource.py deleted file mode 100644
index bcd5195108..0000000000 --- a/synapse/rest/synapse/client/saml2/metadata_resource.py +++ /dev/null
@@ -1,46 +0,0 @@ -# -# This file is licensed under the Affero General Public License (AGPL) version 3. -# -# Copyright (C) 2023 New Vector, Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# See the GNU Affero General Public License for more details: -# <https://www.gnu.org/licenses/agpl-3.0.html>. -# -# Originally licensed under the Apache License, Version 2.0: -# <http://www.apache.org/licenses/LICENSE-2.0>. -# -# [This file includes modifications made by New Vector Limited] -# -# - -from typing import TYPE_CHECKING - -import saml2.metadata - -from twisted.web.resource import Resource -from twisted.web.server import Request - -if TYPE_CHECKING: - from synapse.server import HomeServer - - -class SAML2MetadataResource(Resource): - """A Twisted web resource which renders the SAML metadata""" - - isLeaf = 1 - - def __init__(self, hs: "HomeServer"): - Resource.__init__(self) - self.sp_config = hs.config.saml2.saml2_sp_config - - def render_GET(self, request: Request) -> bytes: - metadata_xml = saml2.metadata.create_metadata_string( - configfile=None, config=self.sp_config - ) - request.setHeader(b"Content-Type", b"text/xml; charset=utf-8") - return metadata_xml diff --git a/synapse/rest/synapse/client/saml2/response_resource.py b/synapse/rest/synapse/client/saml2/response_resource.py deleted file mode 100644
index 7b8667e04c..0000000000 --- a/synapse/rest/synapse/client/saml2/response_resource.py +++ /dev/null
@@ -1,52 +0,0 @@ -# -# This file is licensed under the Affero General Public License (AGPL) version 3. -# -# Copyright (C) 2023 New Vector, Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# See the GNU Affero General Public License for more details: -# <https://www.gnu.org/licenses/agpl-3.0.html>. -# -# Originally licensed under the Apache License, Version 2.0: -# <http://www.apache.org/licenses/LICENSE-2.0>. -# -# [This file includes modifications made by New Vector Limited] -# -# - -from typing import TYPE_CHECKING - -from twisted.web.server import Request - -from synapse.http.server import DirectServeHtmlResource -from synapse.http.site import SynapseRequest - -if TYPE_CHECKING: - from synapse.server import HomeServer - - -class SAML2ResponseResource(DirectServeHtmlResource): - """A Twisted web resource which handles the SAML response""" - - isLeaf = 1 - - def __init__(self, hs: "HomeServer"): - super().__init__() - self._saml_handler = hs.get_saml_handler() - self._sso_handler = hs.get_sso_handler() - - async def _async_render_GET(self, request: Request) -> None: - # We're not expecting any GET request on that resource if everything goes right, - # but some IdPs sometimes end up responding with a 302 redirect on this endpoint. - # In this case, just tell the user that something went wrong and they should - # try to authenticate again. - self._sso_handler.render_error( - request, "unexpected_get", "Unexpected GET request on /saml2/authn_response" - ) - - async def _async_render_POST(self, request: SynapseRequest) -> None: - await self._saml_handler.handle_saml_response(request) diff --git a/synapse/server.py b/synapse/server.py
index add34b8e8d..fca2b04161 100644 --- a/synapse/server.py +++ b/synapse/server.py
@@ -163,7 +163,6 @@ if TYPE_CHECKING: from synapse.handlers.jwt import JwtHandler from synapse.handlers.oidc import OidcHandler - from synapse.handlers.saml import SamlHandler from synapse.storage._base import SQLBaseStore @@ -792,12 +791,6 @@ class HomeServer(metaclass=abc.ABCMeta): return AccountValidityHandler(self) @cache_in_self - def get_saml_handler(self) -> "SamlHandler": - from synapse.handlers.saml import SamlHandler - - return SamlHandler(self) - - @cache_in_self def get_oidc_handler(self) -> "OidcHandler": from synapse.handlers.oidc import OidcHandler diff --git a/tests/handlers/test_saml.py b/tests/handlers/test_saml.py deleted file mode 100644
index 1aca354826..0000000000 --- a/tests/handlers/test_saml.py +++ /dev/null
@@ -1,427 +0,0 @@ -# -# This file is licensed under the Affero General Public License (AGPL) version 3. -# -# Copyright 2020 The Matrix.org Foundation C.I.C. -# Copyright (C) 2023 New Vector, Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# See the GNU Affero General Public License for more details: -# <https://www.gnu.org/licenses/agpl-3.0.html>. -# -# Originally licensed under the Apache License, Version 2.0: -# <http://www.apache.org/licenses/LICENSE-2.0>. -# -# [This file includes modifications made by New Vector Limited] -# -# - -from typing import Any, Dict, Optional, Set, Tuple -from unittest.mock import AsyncMock, Mock - -import attr - -from twisted.test.proto_helpers import MemoryReactor - -from synapse.api.errors import RedirectException -from synapse.module_api import ModuleApi -from synapse.server import HomeServer -from synapse.types import JsonDict -from synapse.util import Clock - -from tests.unittest import HomeserverTestCase, override_config - -# Check if we have the dependencies to run the tests. -try: - import saml2.config - import saml2.response - from saml2.sigver import SigverError - - has_saml2 = True - - # pysaml2 can be installed and imported, but might not be able to find xmlsec1. - config = saml2.config.SPConfig() - try: - config.load({"metadata": {}}) - has_xmlsec1 = True - except SigverError: - has_xmlsec1 = False -except ImportError: - has_saml2 = False - has_xmlsec1 = False - -# These are a few constants that are used as config parameters in the tests. -BASE_URL = "https://synapse/" - - -@attr.s -class FakeAuthnResponse: - ava = attr.ib(type=dict) - assertions = attr.ib(type=list, factory=list) - in_response_to = attr.ib(type=Optional[str], default=None) - - -class TestMappingProvider: - def __init__(self, config: None, module: ModuleApi): - pass - - @staticmethod - def parse_config(config: JsonDict) -> None: - return None - - @staticmethod - def get_saml_attributes(config: None) -> Tuple[Set[str], Set[str]]: - return {"uid"}, {"displayName"} - - def get_remote_user_id( - self, saml_response: "saml2.response.AuthnResponse", client_redirect_url: str - ) -> str: - return saml_response.ava["uid"] - - def saml_response_to_user_attributes( - self, - saml_response: "saml2.response.AuthnResponse", - failures: int, - client_redirect_url: str, - ) -> dict: - localpart = saml_response.ava["username"] + (str(failures) if failures else "") - return {"mxid_localpart": localpart, "displayname": None} - - -class TestRedirectMappingProvider(TestMappingProvider): - def saml_response_to_user_attributes( - self, - saml_response: "saml2.response.AuthnResponse", - failures: int, - client_redirect_url: str, - ) -> dict: - raise RedirectException(b"https://custom-saml-redirect/") - - -class SamlHandlerTestCase(HomeserverTestCase): - def default_config(self) -> Dict[str, Any]: - config = super().default_config() - config["public_baseurl"] = BASE_URL - saml_config: Dict[str, Any] = { - "sp_config": {"metadata": {}}, - # Disable grandfathering. - "grandfathered_mxid_source_attribute": None, - "user_mapping_provider": {"module": __name__ + ".TestMappingProvider"}, - } - - # Update this config with what's in the default config so that - # override_config works as expected. - saml_config.update(config.get("saml2_config", {})) - config["saml2_config"] = saml_config - - return config - - def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: - hs = self.setup_test_homeserver() - - self.handler = hs.get_saml_handler() - - # Reduce the number of attempts when generating MXIDs. - sso_handler = hs.get_sso_handler() - sso_handler._MAP_USERNAME_RETRIES = 3 - - return hs - - if not has_saml2: - skip = "Requires pysaml2" - elif not has_xmlsec1: - skip = "Requires xmlsec1" - - def test_map_saml_response_to_user(self) -> None: - """Ensure that mapping the SAML response returned from a provider to an MXID works properly.""" - - # stub out the auth handler - auth_handler = self.hs.get_auth_handler() - auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign] - - # send a mocked-up SAML response to the callback - saml_response = FakeAuthnResponse({"uid": "test_user", "username": "test_user"}) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "redirect_uri") - ) - - # check that the auth handler got called as expected - auth_handler.complete_sso_login.assert_called_once_with( - "@test_user:test", - "saml", - request, - "redirect_uri", - None, - new_user=True, - auth_provider_session_id=None, - ) - - @override_config({"saml2_config": {"grandfathered_mxid_source_attribute": "mxid"}}) - def test_map_saml_response_to_existing_user(self) -> None: - """Existing users can log in with SAML account.""" - store = self.hs.get_datastores().main - self.get_success( - store.register_user(user_id="@test_user:test", password_hash=None) - ) - - # stub out the auth handler - auth_handler = self.hs.get_auth_handler() - auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign] - - # Map a user via SSO. - saml_response = FakeAuthnResponse( - {"uid": "tester", "mxid": ["test_user"], "username": "test_user"} - ) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "") - ) - - # check that the auth handler got called as expected - auth_handler.complete_sso_login.assert_called_once_with( - "@test_user:test", - "saml", - request, - "", - None, - new_user=False, - auth_provider_session_id=None, - ) - - # Subsequent calls should map to the same mxid. - auth_handler.complete_sso_login.reset_mock() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "") - ) - auth_handler.complete_sso_login.assert_called_once_with( - "@test_user:test", - "saml", - request, - "", - None, - new_user=False, - auth_provider_session_id=None, - ) - - def test_map_saml_response_to_invalid_localpart(self) -> None: - """If the mapping provider generates an invalid localpart it should be rejected.""" - - # stub out the auth handler - auth_handler = self.hs.get_auth_handler() - auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign] - - # mock out the error renderer too - sso_handler = self.hs.get_sso_handler() - sso_handler.render_error = Mock(return_value=None) # type: ignore[method-assign] - - saml_response = FakeAuthnResponse({"uid": "test", "username": "föö"}) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, ""), - ) - sso_handler.render_error.assert_called_once_with( - request, "mapping_error", "localpart is invalid: föö" - ) - auth_handler.complete_sso_login.assert_not_called() - - def test_map_saml_response_to_user_retries(self) -> None: - """The mapping provider can retry generating an MXID if the MXID is already in use.""" - - # stub out the auth handler and error renderer - auth_handler = self.hs.get_auth_handler() - auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign] - sso_handler = self.hs.get_sso_handler() - sso_handler.render_error = Mock(return_value=None) # type: ignore[method-assign] - - # register a user to occupy the first-choice MXID - store = self.hs.get_datastores().main - self.get_success( - store.register_user(user_id="@test_user:test", password_hash=None) - ) - - # send the fake SAML response - saml_response = FakeAuthnResponse({"uid": "test", "username": "test_user"}) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, ""), - ) - - # test_user is already taken, so test_user1 gets registered instead. - auth_handler.complete_sso_login.assert_called_once_with( - "@test_user1:test", - "saml", - request, - "", - None, - new_user=True, - auth_provider_session_id=None, - ) - auth_handler.complete_sso_login.reset_mock() - - # Register all of the potential mxids for a particular SAML username. - self.get_success( - store.register_user(user_id="@tester:test", password_hash=None) - ) - for i in range(1, 3): - self.get_success( - store.register_user(user_id="@tester%d:test" % i, password_hash=None) - ) - - # Now attempt to map to a username, this will fail since all potential usernames are taken. - saml_response = FakeAuthnResponse({"uid": "tester", "username": "tester"}) - self.get_success( - self.handler._handle_authn_response(request, saml_response, ""), - ) - sso_handler.render_error.assert_called_once_with( - request, - "mapping_error", - "Unable to generate a Matrix ID from the SSO response", - ) - auth_handler.complete_sso_login.assert_not_called() - - @override_config( - { - "saml2_config": { - "user_mapping_provider": { - "module": __name__ + ".TestRedirectMappingProvider" - }, - } - } - ) - def test_map_saml_response_redirect(self) -> None: - """Test a mapping provider that raises a RedirectException""" - - saml_response = FakeAuthnResponse({"uid": "test", "username": "test_user"}) - request = _mock_request() - e = self.get_failure( - self.handler._handle_authn_response(request, saml_response, ""), - RedirectException, - ) - self.assertEqual(e.value.location, b"https://custom-saml-redirect/") - - @override_config( - { - "saml2_config": { - "attribute_requirements": [ - {"attribute": "userGroup", "value": "staff"}, - {"attribute": "department", "value": "sales"}, - ], - }, - } - ) - def test_attribute_requirements(self) -> None: - """The required attributes must be met from the SAML response.""" - - # stub out the auth handler - auth_handler = self.hs.get_auth_handler() - auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign] - - # The response doesn't have the proper userGroup or department. - saml_response = FakeAuthnResponse({"uid": "test_user", "username": "test_user"}) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "redirect_uri") - ) - auth_handler.complete_sso_login.assert_not_called() - - # The response doesn't have the proper department. - saml_response = FakeAuthnResponse( - {"uid": "test_user", "username": "test_user", "userGroup": ["staff"]} - ) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "redirect_uri") - ) - auth_handler.complete_sso_login.assert_not_called() - - # Add the proper attributes and it should succeed. - saml_response = FakeAuthnResponse( - { - "uid": "test_user", - "username": "test_user", - "userGroup": ["staff", "admin"], - "department": ["sales"], - } - ) - request.reset_mock() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "redirect_uri") - ) - - # check that the auth handler got called as expected - auth_handler.complete_sso_login.assert_called_once_with( - "@test_user:test", - "saml", - request, - "redirect_uri", - None, - new_user=True, - auth_provider_session_id=None, - ) - - @override_config( - { - "saml2_config": { - "attribute_requirements": [ - {"attribute": "userGroup", "one_of": ["staff", "admin"]}, - ], - }, - } - ) - def test_attribute_requirements_one_of(self) -> None: - """The required attributes can be comma-separated.""" - - # stub out the auth handler - auth_handler = self.hs.get_auth_handler() - auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign] - - # The response doesn't have the proper department. - saml_response = FakeAuthnResponse( - {"uid": "test_user", "username": "test_user", "userGroup": ["nogroup"]} - ) - request = _mock_request() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "redirect_uri") - ) - auth_handler.complete_sso_login.assert_not_called() - - # Add the proper attributes and it should succeed. - saml_response = FakeAuthnResponse( - {"uid": "test_user", "username": "test_user", "userGroup": ["admin"]} - ) - request.reset_mock() - self.get_success( - self.handler._handle_authn_response(request, saml_response, "redirect_uri") - ) - - # check that the auth handler got called as expected - auth_handler.complete_sso_login.assert_called_once_with( - "@test_user:test", - "saml", - request, - "redirect_uri", - None, - new_user=True, - auth_provider_session_id=None, - ) - - -def _mock_request() -> Mock: - """Returns a mock which will stand in as a SynapseRequest""" - mock = Mock( - spec=[ - "finish", - "getClientAddress", - "getHeader", - "setHeader", - "setResponseCode", - "write", - ] - ) - # `_disconnected` musn't be another `Mock`, otherwise it will be truthy. - mock._disconnected = False - return mock diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py
index 0cc7f60921..a7e85fbbf1 100644 --- a/tests/rest/client/test_login.py +++ b/tests/rest/client/test_login.py
@@ -56,7 +56,6 @@ from synapse.util import Clock from tests import unittest from tests.handlers.test_oidc import HAS_OIDC -from tests.handlers.test_saml import has_saml2 from tests.rest.client.utils import TEST_OIDC_CONFIG from tests.server import FakeChannel from tests.test_utils.html_parsers import TestHtmlParser @@ -84,18 +83,6 @@ SYNAPSE_SERVER_PUBLIC_HOSTNAME = "synapse" # https://.... PUBLIC_BASEURL = "http://%s/" % (SYNAPSE_SERVER_PUBLIC_HOSTNAME,) -# just enough to tell pysaml2 where to redirect to -SAML_SERVER = "https://test.saml.server/idp/sso" -TEST_SAML_METADATA = """ -<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"> - <md:IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> - <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="%(SAML_SERVER)s"/> - </md:IDPSSODescriptor> -</md:EntityDescriptor> -""" % { - "SAML_SERVER": SAML_SERVER, -} - LOGIN_URL = b"/_matrix/client/r0/login" TEST_URL = b"/_matrix/client/r0/account/whoami" @@ -622,7 +609,7 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase): ) -@skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC") +@skip_unless(HAS_OIDC, "Requires OIDC") class MultiSSOTestCase(unittest.HomeserverTestCase): """Tests for homeservers with multiple SSO providers enabled""" @@ -635,14 +622,6 @@ class MultiSSOTestCase(unittest.HomeserverTestCase): config["public_baseurl"] = PUBLIC_BASEURL - config["saml2_config"] = { - "sp_config": { - "metadata": {"inline": [TEST_SAML_METADATA]}, - # use the XMLSecurity backend to avoid relying on xmlsec1 - "crypto_backend": "XMLSecurity", - }, - } - # default OIDC provider config["oidc_config"] = TEST_OIDC_CONFIG @@ -693,7 +672,6 @@ class MultiSSOTestCase(unittest.HomeserverTestCase): self.assertCountEqual( flows["m.login.sso"]["identity_providers"], [ - {"id": "saml", "name": "SAML"}, {"id": "oidc-idp1", "name": "IDP1"}, {"id": "oidc", "name": "OIDC"}, ], @@ -727,55 +705,7 @@ class MultiSSOTestCase(unittest.HomeserverTestCase): self.assertEqual(params["redirectUrl"], [TEST_CLIENT_REDIRECT_URL]) returned_idps.append(params["idp"][0]) - self.assertCountEqual(returned_idps, ["oidc", "oidc-idp1", "saml"]) - - def test_multi_sso_redirect_to_saml(self) -> None: - """If SAML is chosen, should redirect to the SAML server""" - channel = self.make_request( - "GET", - "/_synapse/client/pick_idp?redirectUrl=" - + urllib.parse.quote_plus(TEST_CLIENT_REDIRECT_URL) - + "&idp=saml", - ) - self.assertEqual(channel.code, 302, channel.result) - location_headers = channel.headers.getRawHeaders("Location") - assert location_headers - sso_login_redirect_uri = location_headers[0] - - # it should redirect us to the standard login SSO redirect flow - self.assertEqual( - sso_login_redirect_uri, - self.login_sso_redirect_url_builder.build_login_sso_redirect_uri( - idp_id="saml", client_redirect_url=TEST_CLIENT_REDIRECT_URL - ), - ) - - # follow the redirect - channel = self.make_request( - "GET", - # We have to make this relative to be compatible with `make_request(...)` - get_relative_uri_from_absolute_uri(sso_login_redirect_uri), - # We have to set the Host header to match the `public_baseurl` to avoid - # the extra redirect in the `SsoRedirectServlet` in order for the - # cookies to be visible. - custom_headers=[ - ("Host", SYNAPSE_SERVER_PUBLIC_HOSTNAME), - ], - ) - - self.assertEqual(channel.code, 302, channel.result) - location_headers = channel.headers.getRawHeaders("Location") - assert location_headers - saml_uri = location_headers[0] - saml_uri_path, saml_uri_query = saml_uri.split("?", 1) - - # it should redirect us to the login page of the SAML server - self.assertEqual(saml_uri_path, SAML_SERVER) - - # the RelayState is used to carry the client redirect url - saml_uri_params = urllib.parse.parse_qs(saml_uri_query) - relay_state_param = saml_uri_params["RelayState"][0] - self.assertEqual(relay_state_param, TEST_CLIENT_REDIRECT_URL) + self.assertCountEqual(returned_idps, ["oidc", "oidc-idp1"]) def test_login_via_oidc(self) -> None: """If OIDC is chosen, should redirect to the OIDC auth endpoint""" diff --git a/tests/utils.py b/tests/utils.py
index 0006bd7a8d..5a57c015a9 100644 --- a/tests/utils.py +++ b/tests/utils.py
@@ -202,7 +202,6 @@ def default_config( }, "rc_3pid_validation": {"per_second": 10000, "burst_count": 10000}, "rc_presence": {"per_user": {"per_second": 10000, "burst_count": 10000}}, - "saml2_enabled": False, "public_baseurl": None, "default_identity_server": None, "key_refresh_interval": 24 * 60 * 60 * 1000,