Explorar el Código

Implement OpenID Connect-based login (#7256)

tags/v1.14.0rc1
Quentin Gliech hace 4 años
committed by GitHub
padre
commit
616af44137
No se encontró ninguna clave conocida en la base de datos para esta firma ID de clave GPG: 4AEE18F83AFDEB23
Se han modificado 21 ficheros con 2163 adiciones y 12 borrados
  1. +1
    -0
      changelog.d/7256.feature
  2. +175
    -0
      docs/dev/oidc.md
  3. +95
    -0
      docs/sample_config.yaml
  4. +3
    -0
      mypy.ini
  5. +12
    -0
      synapse/app/homeserver.py
  6. +2
    -0
      synapse/config/_base.pyi
  7. +2
    -0
      synapse/config/homeserver.py
  8. +177
    -0
      synapse/config/oidc_config.py
  9. +10
    -7
      synapse/config/sso.py
  10. +2
    -2
      synapse/handlers/auth.py
  11. +998
    -0
      synapse/handlers/oidc_handler.py
  12. +7
    -0
      synapse/http/client.py
  13. +1
    -0
      synapse/python_dependencies.py
  14. +18
    -0
      synapse/res/templates/sso_error.html
  15. +25
    -3
      synapse/rest/client/v1/login.py
  16. +27
    -0
      synapse/rest/oidc/__init__.py
  17. +31
    -0
      synapse/rest/oidc/callback_resource.py
  18. +6
    -0
      synapse/server.py
  19. +5
    -0
      synapse/server.pyi
  20. +565
    -0
      tests/handlers/test_oidc.py
  21. +1
    -0
      tox.ini

+ 1
- 0
changelog.d/7256.feature Ver fichero

@@ -0,0 +1 @@
Add OpenID Connect login/registration support. Contributed by Quentin Gliech, on behalf of [les Connecteurs](https://connecteu.rs).

+ 175
- 0
docs/dev/oidc.md Ver fichero

@@ -0,0 +1,175 @@
# How to test OpenID Connect

Any OpenID Connect Provider (OP) should work with Synapse, as long as it supports the authorization code flow.
There are a few options for that:

- start a local OP. Synapse has been tested with [Hydra][hydra] and [Dex][dex-idp].
Note that for an OP to work, it should be served under a secure (HTTPS) origin.
A certificate signed with a self-signed, locally trusted CA should work. In that case, start Synapse with a `SSL_CERT_FILE` environment variable set to the path of the CA.
- use a publicly available OP. Synapse has been tested with [Google][google-idp].
- setup a SaaS OP, like [Auth0][auth0] and [Okta][okta]. Auth0 has a free tier which has been tested with Synapse.

[google-idp]: https://developers.google.com/identity/protocols/OpenIDConnect#authenticatingtheuser
[auth0]: https://auth0.com/
[okta]: https://www.okta.com/
[dex-idp]: https://github.com/dexidp/dex
[hydra]: https://www.ory.sh/docs/hydra/


## Sample configs

Here are a few configs for providers that should work with Synapse.

### [Dex][dex-idp]

[Dex][dex-idp] is a simple, open-source, certified OpenID Connect Provider.
Although it is designed to help building a full-blown provider, with some external database, it can be configured with static passwords in a config file.

Follow the [Getting Started guide](https://github.com/dexidp/dex/blob/master/Documentation/getting-started.md) to install Dex.

Edit `examples/config-dev.yaml` config file from the Dex repo to add a client:

```yaml
staticClients:
- id: synapse
secret: secret
redirectURIs:
- '[synapse base url]/_synapse/oidc/callback'
name: 'Synapse'
```

Run with `dex serve examples/config-dex.yaml`

Synapse config:

```yaml
oidc_config:
enabled: true
skip_verification: true # This is needed as Dex is served on an insecure endpoint
issuer: "http://127.0.0.1:5556/dex"
discover: true
client_id: "synapse"
client_secret: "secret"
scopes:
- openid
- profile
user_mapping_provider:
config:
localpart_template: '{{ user.name }}'
display_name_template: '{{ user.name|capitalize }}'
```

### [Auth0][auth0]

1. Create a regular web application for Synapse
2. Set the Allowed Callback URLs to `[synapse base url]/_synapse/oidc/callback`
3. Add a rule to add the `preferred_username` claim.
<details>
<summary>Code sample</summary>

```js
function addPersistenceAttribute(user, context, callback) {
user.user_metadata = user.user_metadata || {};
user.user_metadata.preferred_username = user.user_metadata.preferred_username || user.user_id;
context.idToken.preferred_username = user.user_metadata.preferred_username;

auth0.users.updateUserMetadata(user.user_id, user.user_metadata)
.then(function(){
callback(null, user, context);
})
.catch(function(err){
callback(err);
});
}
```

</details>


```yaml
oidc_config:
enabled: true
issuer: "https://your-tier.eu.auth0.com/" # TO BE FILLED
discover: true
client_id: "your-client-id" # TO BE FILLED
client_secret: "your-client-secret" # TO BE FILLED
scopes:
- openid
- profile
user_mapping_provider:
config:
localpart_template: '{{ user.preferred_username }}'
display_name_template: '{{ user.name }}'
```

### GitHub

GitHub is a bit special as it is not an OpenID Connect compliant provider, but just a regular OAuth2 provider.
The `/user` API endpoint can be used to retrieve informations from the user.
As the OIDC login mechanism needs an attribute to uniquely identify users and that endpoint does not return a `sub` property, an alternative `subject_claim` has to be set.

1. Create a new OAuth application: https://github.com/settings/applications/new
2. Set the callback URL to `[synapse base url]/_synapse/oidc/callback`

```yaml
oidc_config:
enabled: true
issuer: "https://github.com/"
discover: false
client_id: "your-client-id" # TO BE FILLED
client_secret: "your-client-secret" # TO BE FILLED
authorization_endpoint: "https://github.com/login/oauth/authorize"
token_endpoint: "https://github.com/login/oauth/access_token"
userinfo_endpoint: "https://api.github.com/user"
scopes:
- read:user
user_mapping_provider:
config:
subject_claim: 'id'
localpart_template: '{{ user.login }}'
display_name_template: '{{ user.name }}'
```

### Google

1. Setup a project in the Google API Console
2. Obtain the OAuth 2.0 credentials (see <https://developers.google.com/identity/protocols/oauth2/openid-connect>)
3. Add this Authorized redirect URI: `[synapse base url]/_synapse/oidc/callback`

```yaml
oidc_config:
enabled: true
issuer: "https://accounts.google.com/"
discover: true
client_id: "your-client-id" # TO BE FILLED
client_secret: "your-client-secret" # TO BE FILLED
scopes:
- openid
- profile
user_mapping_provider:
config:
localpart_template: '{{ user.given_name|lower }}'
display_name_template: '{{ user.name }}'
```

### Twitch

1. Setup a developer account on [Twitch](https://dev.twitch.tv/)
2. Obtain the OAuth 2.0 credentials by [creating an app](https://dev.twitch.tv/console/apps/)
3. Add this OAuth Redirect URL: `[synapse base url]/_synapse/oidc/callback`

```yaml
oidc_config:
enabled: true
issuer: "https://id.twitch.tv/oauth2/"
discover: true
client_id: "your-client-id" # TO BE FILLED
client_secret: "your-client-secret" # TO BE FILLED
client_auth_method: "client_secret_post"
scopes:
- openid
user_mapping_provider:
config:
localpart_template: '{{ user.preferred_username }}'
display_name_template: '{{ user.name }}'
```

+ 95
- 0
docs/sample_config.yaml Ver fichero

@@ -1470,6 +1470,94 @@ saml2_config:
#template_dir: "res/templates"


# Enable OpenID Connect for registration and login. Uses authlib.
#
oidc_config:
# enable OpenID Connect. Defaults to false.
#
#enabled: true

# use the OIDC discovery mechanism to discover endpoints. Defaults to true.
#
#discover: true

# the OIDC issuer. Used to validate tokens and discover the providers endpoints. Required.
#
#issuer: "https://accounts.example.com/"

# oauth2 client id to use. Required.
#
#client_id: "provided-by-your-issuer"

# oauth2 client secret to use. Required.
#
#client_secret: "provided-by-your-issuer"

# auth method to use when exchanging the token.
# Valid values are "client_secret_basic" (default), "client_secret_post" and "none".
#
#client_auth_method: "client_auth_basic"

# list of scopes to ask. This should include the "openid" scope. Defaults to ["openid"].
#
#scopes: ["openid"]

# the oauth2 authorization endpoint. Required if provider discovery is disabled.
#
#authorization_endpoint: "https://accounts.example.com/oauth2/auth"

# the oauth2 token endpoint. Required if provider discovery is disabled.
#
#token_endpoint: "https://accounts.example.com/oauth2/token"

# the OIDC userinfo endpoint. Required if discovery is disabled and the "openid" scope is not asked.
#
#userinfo_endpoint: "https://accounts.example.com/userinfo"

# URI where to fetch the JWKS. Required if discovery is disabled and the "openid" scope is used.
#
#jwks_uri: "https://accounts.example.com/.well-known/jwks.json"

# skip metadata verification. Defaults to false.
# Use this if you are connecting to a provider that is not OpenID Connect compliant.
# Avoid this in production.
#
#skip_verification: false


# An external module can be provided here as a custom solution to mapping
# attributes returned from a OIDC provider onto a matrix user.
#
user_mapping_provider:
# The custom module's class. Uncomment to use a custom module.
# Default is 'synapse.handlers.oidc_handler.JinjaOidcMappingProvider'.
#
#module: mapping_provider.OidcMappingProvider

# Custom configuration values for the module. Below options are intended
# for the built-in provider, they should be changed if using a custom
# module. This section will be passed as a Python dictionary to the
# module's `parse_config` method.
#
# Below is the config of the default mapping provider, based on Jinja2
# templates. Those templates are used to render user attributes, where the
# userinfo object is available through the `user` variable.
#
config:
# name of the claim containing a unique identifier for the user.
# Defaults to `sub`, which OpenID Connect compliant providers should provide.
#
#subject_claim: "sub"

# Jinja2 template for the localpart of the MXID
#
localpart_template: "{{ user.preferred_username }}"

# Jinja2 template for the display name to set on first login. Optional.
#
#display_name_template: "{{ user.given_name }} {{ user.last_name }}"



# Enable CAS for registration and login.
#
@@ -1554,6 +1642,13 @@ sso:
#
# This template has no additional variables.
#
# * HTML page to display to users if something goes wrong during the
# OpenID Connect authentication process: 'sso_error.html'.
#
# When rendering, this template is given two variables:
# * error: the technical name of the error
# * error_description: a human-readable message for the error
#
# You can see the default templates at:
# https://github.com/matrix-org/synapse/tree/master/synapse/res/templates
#


+ 3
- 0
mypy.ini Ver fichero

@@ -75,3 +75,6 @@ ignore_missing_imports = True

[mypy-jwt.*]
ignore_missing_imports = True

[mypy-authlib.*]
ignore_missing_imports = True

+ 12
- 0
synapse/app/homeserver.py Ver fichero

@@ -192,6 +192,11 @@ class SynapseHomeServer(HomeServer):
}
)

if self.get_config().oidc_enabled:
from synapse.rest.oidc import OIDCResource

resources["/_synapse/oidc"] = OIDCResource(self)

if self.get_config().saml2_enabled:
from synapse.rest.saml2 import SAML2Resource

@@ -422,6 +427,13 @@ def setup(config_options):
# Check if it needs to be reprovisioned every day.
hs.get_clock().looping_call(reprovision_acme, 24 * 60 * 60 * 1000)

# Load the OIDC provider metadatas, if OIDC is enabled.
if hs.config.oidc_enabled:
oidc = hs.get_oidc_handler()
# Loading the provider metadata also ensures the provider config is valid.
yield defer.ensureDeferred(oidc.load_metadata())
yield defer.ensureDeferred(oidc.load_jwks())

_base.start(hs, config.listeners)

hs.get_datastore().db.updates.start_doing_background_updates()


+ 2
- 0
synapse/config/_base.pyi Ver fichero

@@ -13,6 +13,7 @@ from synapse.config import (
key,
logger,
metrics,
oidc_config,
password,
password_auth_providers,
push,
@@ -59,6 +60,7 @@ class RootConfig:
saml2: saml2_config.SAML2Config
cas: cas.CasConfig
sso: sso.SSOConfig
oidc: oidc_config.OIDCConfig
jwt: jwt_config.JWTConfig
password: password.PasswordConfig
email: emailconfig.EmailConfig


+ 2
- 0
synapse/config/homeserver.py Ver fichero

@@ -27,6 +27,7 @@ from .jwt_config import JWTConfig
from .key import KeyConfig
from .logger import LoggingConfig
from .metrics import MetricsConfig
from .oidc_config import OIDCConfig
from .password import PasswordConfig
from .password_auth_providers import PasswordAuthProviderConfig
from .push import PushConfig
@@ -66,6 +67,7 @@ class HomeServerConfig(RootConfig):
AppServiceConfig,
KeyConfig,
SAML2Config,
OIDCConfig,
CasConfig,
SSOConfig,
JWTConfig,


+ 177
- 0
synapse/config/oidc_config.py Ver fichero

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
# Copyright 2020 Quentin Gliech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from synapse.python_dependencies import DependencyException, check_requirements
from synapse.util.module_loader import load_module

from ._base import Config, ConfigError

DEFAULT_USER_MAPPING_PROVIDER = "synapse.handlers.oidc_handler.JinjaOidcMappingProvider"


class OIDCConfig(Config):
section = "oidc"

def read_config(self, config, **kwargs):
self.oidc_enabled = False

oidc_config = config.get("oidc_config")

if not oidc_config or not oidc_config.get("enabled", False):
return

try:
check_requirements("oidc")
except DependencyException as e:
raise ConfigError(e.message)

public_baseurl = self.public_baseurl
if public_baseurl is None:
raise ConfigError("oidc_config requires a public_baseurl to be set")
self.oidc_callback_url = public_baseurl + "_synapse/oidc/callback"

self.oidc_enabled = True
self.oidc_discover = oidc_config.get("discover", True)
self.oidc_issuer = oidc_config["issuer"]
self.oidc_client_id = oidc_config["client_id"]
self.oidc_client_secret = oidc_config["client_secret"]
self.oidc_client_auth_method = oidc_config.get(
"client_auth_method", "client_secret_basic"
)
self.oidc_scopes = oidc_config.get("scopes", ["openid"])
self.oidc_authorization_endpoint = oidc_config.get("authorization_endpoint")
self.oidc_token_endpoint = oidc_config.get("token_endpoint")
self.oidc_userinfo_endpoint = oidc_config.get("userinfo_endpoint")
self.oidc_jwks_uri = oidc_config.get("jwks_uri")
self.oidc_subject_claim = oidc_config.get("subject_claim", "sub")
self.oidc_skip_verification = oidc_config.get("skip_verification", False)

ump_config = oidc_config.get("user_mapping_provider", {})
ump_config.setdefault("module", DEFAULT_USER_MAPPING_PROVIDER)
ump_config.setdefault("config", {})

(
self.oidc_user_mapping_provider_class,
self.oidc_user_mapping_provider_config,
) = load_module(ump_config)

# Ensure loaded user mapping module has defined all necessary methods
required_methods = [
"get_remote_user_id",
"map_user_attributes",
]
missing_methods = [
method
for method in required_methods
if not hasattr(self.oidc_user_mapping_provider_class, method)
]
if missing_methods:
raise ConfigError(
"Class specified by oidc_config."
"user_mapping_provider.module is missing required "
"methods: %s" % (", ".join(missing_methods),)
)

def generate_config_section(self, config_dir_path, server_name, **kwargs):
return """\
# Enable OpenID Connect for registration and login. Uses authlib.
#
oidc_config:
# enable OpenID Connect. Defaults to false.
#
#enabled: true

# use the OIDC discovery mechanism to discover endpoints. Defaults to true.
#
#discover: true

# the OIDC issuer. Used to validate tokens and discover the providers endpoints. Required.
#
#issuer: "https://accounts.example.com/"

# oauth2 client id to use. Required.
#
#client_id: "provided-by-your-issuer"

# oauth2 client secret to use. Required.
#
#client_secret: "provided-by-your-issuer"

# auth method to use when exchanging the token.
# Valid values are "client_secret_basic" (default), "client_secret_post" and "none".
#
#client_auth_method: "client_auth_basic"

# list of scopes to ask. This should include the "openid" scope. Defaults to ["openid"].
#
#scopes: ["openid"]

# the oauth2 authorization endpoint. Required if provider discovery is disabled.
#
#authorization_endpoint: "https://accounts.example.com/oauth2/auth"

# the oauth2 token endpoint. Required if provider discovery is disabled.
#
#token_endpoint: "https://accounts.example.com/oauth2/token"

# the OIDC userinfo endpoint. Required if discovery is disabled and the "openid" scope is not asked.
#
#userinfo_endpoint: "https://accounts.example.com/userinfo"

# URI where to fetch the JWKS. Required if discovery is disabled and the "openid" scope is used.
#
#jwks_uri: "https://accounts.example.com/.well-known/jwks.json"

# skip metadata verification. Defaults to false.
# Use this if you are connecting to a provider that is not OpenID Connect compliant.
# Avoid this in production.
#
#skip_verification: false


# An external module can be provided here as a custom solution to mapping
# attributes returned from a OIDC provider onto a matrix user.
#
user_mapping_provider:
# The custom module's class. Uncomment to use a custom module.
# Default is {mapping_provider!r}.
#
#module: mapping_provider.OidcMappingProvider

# Custom configuration values for the module. Below options are intended
# for the built-in provider, they should be changed if using a custom
# module. This section will be passed as a Python dictionary to the
# module's `parse_config` method.
#
# Below is the config of the default mapping provider, based on Jinja2
# templates. Those templates are used to render user attributes, where the
# userinfo object is available through the `user` variable.
#
config:
# name of the claim containing a unique identifier for the user.
# Defaults to `sub`, which OpenID Connect compliant providers should provide.
#
#subject_claim: "sub"

# Jinja2 template for the localpart of the MXID
#
localpart_template: "{{{{ user.preferred_username }}}}"

# Jinja2 template for the display name to set on first login. Optional.
#
#display_name_template: "{{{{ user.given_name }}}} {{{{ user.last_name }}}}"
""".format(
mapping_provider=DEFAULT_USER_MAPPING_PROVIDER
)

+ 10
- 7
synapse/config/sso.py Ver fichero

@@ -36,17 +36,13 @@ class SSOConfig(Config):
if not template_dir:
template_dir = pkg_resources.resource_filename("synapse", "res/templates",)

self.sso_redirect_confirm_template_dir = template_dir
self.sso_template_dir = template_dir
self.sso_account_deactivated_template = self.read_file(
os.path.join(
self.sso_redirect_confirm_template_dir, "sso_account_deactivated.html"
),
os.path.join(self.sso_template_dir, "sso_account_deactivated.html"),
"sso_account_deactivated_template",
)
self.sso_auth_success_template = self.read_file(
os.path.join(
self.sso_redirect_confirm_template_dir, "sso_auth_success.html"
),
os.path.join(self.sso_template_dir, "sso_auth_success.html"),
"sso_auth_success_template",
)

@@ -137,6 +133,13 @@ class SSOConfig(Config):
#
# This template has no additional variables.
#
# * HTML page to display to users if something goes wrong during the
# OpenID Connect authentication process: 'sso_error.html'.
#
# When rendering, this template is given two variables:
# * error: the technical name of the error
# * error_description: a human-readable message for the error
#
# You can see the default templates at:
# https://github.com/matrix-org/synapse/tree/master/synapse/res/templates
#


+ 2
- 2
synapse/handlers/auth.py Ver fichero

@@ -126,13 +126,13 @@ class AuthHandler(BaseHandler):
# It notifies the user they are about to give access to their matrix account
# to the client.
self._sso_redirect_confirm_template = load_jinja2_templates(
hs.config.sso_redirect_confirm_template_dir, ["sso_redirect_confirm.html"],
hs.config.sso_template_dir, ["sso_redirect_confirm.html"],
)[0]
# The following template is shown during user interactive authentication
# in the fallback auth scenario. It notifies the user that they are
# authenticating for an operation to occur on their account.
self._sso_auth_confirm_template = load_jinja2_templates(
hs.config.sso_redirect_confirm_template_dir, ["sso_auth_confirm.html"],
hs.config.sso_template_dir, ["sso_auth_confirm.html"],
)[0]
# The following template is shown after a successful user interactive
# authentication session. It tells the user they can close the window.


+ 998
- 0
synapse/handlers/oidc_handler.py
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 7
- 0
synapse/http/client.py Ver fichero

@@ -359,6 +359,7 @@ class SimpleHttpClient(object):
actual_headers = {
b"Content-Type": [b"application/x-www-form-urlencoded"],
b"User-Agent": [self.user_agent],
b"Accept": [b"application/json"],
}
if headers:
actual_headers.update(headers)
@@ -399,6 +400,7 @@ class SimpleHttpClient(object):
actual_headers = {
b"Content-Type": [b"application/json"],
b"User-Agent": [self.user_agent],
b"Accept": [b"application/json"],
}
if headers:
actual_headers.update(headers)
@@ -434,6 +436,10 @@ class SimpleHttpClient(object):

ValueError: if the response was not JSON
"""
actual_headers = {b"Accept": [b"application/json"]}
if headers:
actual_headers.update(headers)

body = yield self.get_raw(uri, args, headers=headers)
return json.loads(body)

@@ -467,6 +473,7 @@ class SimpleHttpClient(object):
actual_headers = {
b"Content-Type": [b"application/json"],
b"User-Agent": [self.user_agent],
b"Accept": [b"application/json"],
}
if headers:
actual_headers.update(headers)


+ 1
- 0
synapse/python_dependencies.py Ver fichero

@@ -92,6 +92,7 @@ CONDITIONAL_REQUIREMENTS = {
'eliot<1.8.0;python_version<"3.5.3"',
],
"saml2": ["pysaml2>=4.5.0"],
"oidc": ["authlib>=0.14.0"],
"systemd": ["systemd-python>=231"],
"url_preview": ["lxml>=3.5.0"],
"test": ["mock>=2.0", "parameterized"],


+ 18
- 0
synapse/res/templates/sso_error.html Ver fichero

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSO error</title>
</head>
<body>
<p>Oops! Something went wrong during authentication.</p>
<p>
Try logging in again from your Matrix client and if the problem persists
please contact the server's administrator.
</p>
<p>Error: <code>{{ error }}</code></p>
{% if error_description %}
<pre><code>{{ error_description }}</code></pre>
{% endif %}
</body>
</html>

+ 25
- 3
synapse/rest/client/v1/login.py Ver fichero

@@ -83,6 +83,7 @@ class LoginRestServlet(RestServlet):
self.jwt_algorithm = hs.config.jwt_algorithm
self.saml2_enabled = hs.config.saml2_enabled
self.cas_enabled = hs.config.cas_enabled
self.oidc_enabled = hs.config.oidc_enabled
self.auth_handler = self.hs.get_auth_handler()
self.registration_handler = hs.get_registration_handler()
self.handlers = hs.get_handlers()
@@ -96,9 +97,7 @@ class LoginRestServlet(RestServlet):
flows = []
if self.jwt_enabled:
flows.append({"type": LoginRestServlet.JWT_TYPE})
if self.saml2_enabled:
flows.append({"type": LoginRestServlet.SSO_TYPE})
flows.append({"type": LoginRestServlet.TOKEN_TYPE})

if self.cas_enabled:
flows.append({"type": LoginRestServlet.SSO_TYPE})

@@ -114,6 +113,11 @@ class LoginRestServlet(RestServlet):
# fall back to the fallback API if they don't understand one of the
# login flow types returned.
flows.append({"type": LoginRestServlet.TOKEN_TYPE})
elif self.saml2_enabled:
flows.append({"type": LoginRestServlet.SSO_TYPE})
flows.append({"type": LoginRestServlet.TOKEN_TYPE})
elif self.oidc_enabled:
flows.append({"type": LoginRestServlet.SSO_TYPE})

flows.extend(
({"type": t} for t in self.auth_handler.get_supported_login_types())
@@ -465,6 +469,22 @@ class SAMLRedirectServlet(BaseSSORedirectServlet):
return self._saml_handler.handle_redirect_request(client_redirect_url)


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

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

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

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


def register_servlets(hs, http_server):
LoginRestServlet(hs).register(http_server)
if hs.config.cas_enabled:
@@ -472,3 +492,5 @@ def register_servlets(hs, http_server):
CasTicketServlet(hs).register(http_server)
elif hs.config.saml2_enabled:
SAMLRedirectServlet(hs).register(http_server)
elif hs.config.oidc_enabled:
OIDCRedirectServlet(hs).register(http_server)

+ 27
- 0
synapse/rest/oidc/__init__.py Ver fichero

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Copyright 2020 Quentin Gliech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging

from twisted.web.resource import Resource

from synapse.rest.oidc.callback_resource import OIDCCallbackResource

logger = logging.getLogger(__name__)


class OIDCResource(Resource):
def __init__(self, hs):
Resource.__init__(self)
self.putChild(b"callback", OIDCCallbackResource(hs))

+ 31
- 0
synapse/rest/oidc/callback_resource.py Ver fichero

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
# Copyright 2020 Quentin Gliech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging

from synapse.http.server import DirectServeResource, wrap_html_request_handler

logger = logging.getLogger(__name__)


class OIDCCallbackResource(DirectServeResource):
isLeaf = 1

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

@wrap_html_request_handler
async def _async_render_GET(self, request):
return await self._oidc_handler.handle_oidc_callback(request)

+ 6
- 0
synapse/server.py Ver fichero

@@ -204,6 +204,7 @@ class HomeServer(object):
"account_validity_handler",
"cas_handler",
"saml_handler",
"oidc_handler",
"event_client_serializer",
"password_policy_handler",
"storage",
@@ -562,6 +563,11 @@ class HomeServer(object):

return SamlHandler(self)

def build_oidc_handler(self):
from synapse.handlers.oidc_handler import OidcHandler

return OidcHandler(self)

def build_event_client_serializer(self):
return EventClientSerializer(self)



+ 5
- 0
synapse/server.pyi Ver fichero

@@ -13,6 +13,7 @@ import synapse.handlers.device
import synapse.handlers.e2e_keys
import synapse.handlers.message
import synapse.handlers.presence
import synapse.handlers.register
import synapse.handlers.room
import synapse.handlers.room_member
import synapse.handlers.set_password
@@ -128,3 +129,7 @@ class HomeServer(object):
pass
def get_storage(self) -> synapse.storage.Storage:
pass
def get_registration_handler(self) -> synapse.handlers.register.RegistrationHandler:
pass
def get_macaroon_generator(self) -> synapse.handlers.auth.MacaroonGenerator:
pass

+ 565
- 0
tests/handlers/test_oidc.py Ver fichero

@@ -0,0 +1,565 @@
# -*- coding: utf-8 -*-
# Copyright 2020 Quentin Gliech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
from urllib.parse import parse_qs, urlparse

from mock import Mock, patch

import attr
import pymacaroons

from twisted.internet import defer
from twisted.python.failure import Failure
from twisted.web._newclient import ResponseDone

from synapse.handlers.oidc_handler import (
MappingException,
OidcError,
OidcHandler,
OidcMappingProvider,
)
from synapse.types import UserID

from tests.unittest import HomeserverTestCase, override_config


@attr.s
class FakeResponse:
code = attr.ib()
body = attr.ib()
phrase = attr.ib()

def deliverBody(self, protocol):
protocol.dataReceived(self.body)
protocol.connectionLost(Failure(ResponseDone()))


# These are a few constants that are used as config parameters in the tests.
ISSUER = "https://issuer/"
CLIENT_ID = "test-client-id"
CLIENT_SECRET = "test-client-secret"
BASE_URL = "https://synapse/"
CALLBACK_URL = BASE_URL + "_synapse/oidc/callback"
SCOPES = ["openid"]

AUTHORIZATION_ENDPOINT = ISSUER + "authorize"
TOKEN_ENDPOINT = ISSUER + "token"
USERINFO_ENDPOINT = ISSUER + "userinfo"
WELL_KNOWN = ISSUER + ".well-known/openid-configuration"
JWKS_URI = ISSUER + ".well-known/jwks.json"

# config for common cases
COMMON_CONFIG = {
"discover": False,
"authorization_endpoint": AUTHORIZATION_ENDPOINT,
"token_endpoint": TOKEN_ENDPOINT,
"jwks_uri": JWKS_URI,
}


# The cookie name and path don't really matter, just that it has to be coherent
# between the callback & redirect handlers.
COOKIE_NAME = b"oidc_session"
COOKIE_PATH = "/_synapse/oidc"

MockedMappingProvider = Mock(OidcMappingProvider)


def simple_async_mock(return_value=None, raises=None):
# AsyncMock is not available in python3.5, this mimics part of its behaviour
async def cb(*args, **kwargs):
if raises:
raise raises
return return_value

return Mock(side_effect=cb)


async def get_json(url):
# Mock get_json calls to handle jwks & oidc discovery endpoints
if url == WELL_KNOWN:
# Minimal discovery document, as defined in OpenID.Discovery
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
return {
"issuer": ISSUER,
"authorization_endpoint": AUTHORIZATION_ENDPOINT,
"token_endpoint": TOKEN_ENDPOINT,
"jwks_uri": JWKS_URI,
"userinfo_endpoint": USERINFO_ENDPOINT,
"response_types_supported": ["code"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
}
elif url == JWKS_URI:
return {"keys": []}


class OidcHandlerTestCase(HomeserverTestCase):
def make_homeserver(self, reactor, clock):

self.http_client = Mock(spec=["get_json"])
self.http_client.get_json.side_effect = get_json
self.http_client.user_agent = "Synapse Test"

config = self.default_config()
config["public_baseurl"] = BASE_URL
oidc_config = config.get("oidc_config", {})
oidc_config["enabled"] = True
oidc_config["client_id"] = CLIENT_ID
oidc_config["client_secret"] = CLIENT_SECRET
oidc_config["issuer"] = ISSUER
oidc_config["scopes"] = SCOPES
oidc_config["user_mapping_provider"] = {
"module": __name__ + ".MockedMappingProvider"
}
config["oidc_config"] = oidc_config

hs = self.setup_test_homeserver(
http_client=self.http_client,
proxied_http_client=self.http_client,
config=config,
)

self.handler = OidcHandler(hs)

return hs

def metadata_edit(self, values):
return patch.dict(self.handler._provider_metadata, values)

def assertRenderedError(self, error, error_description=None):
args = self.handler._render_error.call_args[0]
self.assertEqual(args[1], error)
if error_description is not None:
self.assertEqual(args[2], error_description)
# Reset the render_error mock
self.handler._render_error.reset_mock()

def test_config(self):
"""Basic config correctly sets up the callback URL and client auth correctly."""
self.assertEqual(self.handler._callback_url, CALLBACK_URL)
self.assertEqual(self.handler._client_auth.client_id, CLIENT_ID)
self.assertEqual(self.handler._client_auth.client_secret, CLIENT_SECRET)

@override_config({"oidc_config": {"discover": True}})
@defer.inlineCallbacks
def test_discovery(self):
"""The handler should discover the endpoints from OIDC discovery document."""
# This would throw if some metadata were invalid
metadata = yield defer.ensureDeferred(self.handler.load_metadata())
self.http_client.get_json.assert_called_once_with(WELL_KNOWN)

self.assertEqual(metadata.issuer, ISSUER)
self.assertEqual(metadata.authorization_endpoint, AUTHORIZATION_ENDPOINT)
self.assertEqual(metadata.token_endpoint, TOKEN_ENDPOINT)
self.assertEqual(metadata.jwks_uri, JWKS_URI)
# FIXME: it seems like authlib does not have that defined in its metadata models
# self.assertEqual(metadata.userinfo_endpoint, USERINFO_ENDPOINT)

# subsequent calls should be cached
self.http_client.reset_mock()
yield defer.ensureDeferred(self.handler.load_metadata())
self.http_client.get_json.assert_not_called()

@override_config({"oidc_config": COMMON_CONFIG})
@defer.inlineCallbacks
def test_no_discovery(self):
"""When discovery is disabled, it should not try to load from discovery document."""
yield defer.ensureDeferred(self.handler.load_metadata())
self.http_client.get_json.assert_not_called()

@override_config({"oidc_config": COMMON_CONFIG})
@defer.inlineCallbacks
def test_load_jwks(self):
"""JWKS loading is done once (then cached) if used."""
jwks = yield defer.ensureDeferred(self.handler.load_jwks())
self.http_client.get_json.assert_called_once_with(JWKS_URI)
self.assertEqual(jwks, {"keys": []})

# subsequent calls should be cached…
self.http_client.reset_mock()
yield defer.ensureDeferred(self.handler.load_jwks())
self.http_client.get_json.assert_not_called()

# …unless forced
self.http_client.reset_mock()
yield defer.ensureDeferred(self.handler.load_jwks(force=True))
self.http_client.get_json.assert_called_once_with(JWKS_URI)

# Throw if the JWKS uri is missing
with self.metadata_edit({"jwks_uri": None}):
with self.assertRaises(RuntimeError):
yield defer.ensureDeferred(self.handler.load_jwks(force=True))

# Return empty key set if JWKS are not used
self.handler._scopes = [] # not asking the openid scope
self.http_client.get_json.reset_mock()
jwks = yield defer.ensureDeferred(self.handler.load_jwks(force=True))
self.http_client.get_json.assert_not_called()
self.assertEqual(jwks, {"keys": []})

@override_config({"oidc_config": COMMON_CONFIG})
def test_validate_config(self):
"""Provider metadatas are extensively validated."""
h = self.handler

# Default test config does not throw
h._validate_metadata()

with self.metadata_edit({"issuer": None}):
self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata)

with self.metadata_edit({"issuer": "http://insecure/"}):
self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata)

with self.metadata_edit({"issuer": "https://invalid/?because=query"}):
self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata)

with self.metadata_edit({"authorization_endpoint": None}):
self.assertRaisesRegex(
ValueError, "authorization_endpoint", h._validate_metadata
)

with self.metadata_edit({"authorization_endpoint": "http://insecure/auth"}):
self.assertRaisesRegex(
ValueError, "authorization_endpoint", h._validate_metadata
)

with self.metadata_edit({"token_endpoint": None}):
self.assertRaisesRegex(ValueError, "token_endpoint", h._validate_metadata)

with self.metadata_edit({"token_endpoint": "http://insecure/token"}):
self.assertRaisesRegex(ValueError, "token_endpoint", h._validate_metadata)

with self.metadata_edit({"jwks_uri": None}):
self.assertRaisesRegex(ValueError, "jwks_uri", h._validate_metadata)

with self.metadata_edit({"jwks_uri": "http://insecure/jwks.json"}):
self.assertRaisesRegex(ValueError, "jwks_uri", h._validate_metadata)

with self.metadata_edit({"response_types_supported": ["id_token"]}):
self.assertRaisesRegex(
ValueError, "response_types_supported", h._validate_metadata
)

with self.metadata_edit(
{"token_endpoint_auth_methods_supported": ["client_secret_basic"]}
):
# should not throw, as client_secret_basic is the default auth method
h._validate_metadata()

with self.metadata_edit(
{"token_endpoint_auth_methods_supported": ["client_secret_post"]}
):
self.assertRaisesRegex(
ValueError,
"token_endpoint_auth_methods_supported",
h._validate_metadata,
)

# Tests for configs that the userinfo endpoint
self.assertFalse(h._uses_userinfo)
h._scopes = [] # do not request the openid scope
self.assertTrue(h._uses_userinfo)
self.assertRaisesRegex(ValueError, "userinfo_endpoint", h._validate_metadata)

with self.metadata_edit(
{"userinfo_endpoint": USERINFO_ENDPOINT, "jwks_uri": None}
):
# Shouldn't raise with a valid userinfo, even without
h._validate_metadata()

@override_config({"oidc_config": {"skip_verification": True}})
def test_skip_verification(self):
"""Provider metadata validation can be disabled by config."""
with self.metadata_edit({"issuer": "http://insecure"}):
# This should not throw
self.handler._validate_metadata()

@defer.inlineCallbacks
def test_redirect_request(self):
"""The redirect request has the right arguments & generates a valid session cookie."""
req = Mock(spec=["addCookie", "redirect", "finish"])
yield defer.ensureDeferred(
self.handler.handle_redirect_request(req, b"http://client/redirect")
)
url = req.redirect.call_args[0][0]
url = urlparse(url)
auth_endpoint = urlparse(AUTHORIZATION_ENDPOINT)

self.assertEqual(url.scheme, auth_endpoint.scheme)
self.assertEqual(url.netloc, auth_endpoint.netloc)
self.assertEqual(url.path, auth_endpoint.path)

params = parse_qs(url.query)
self.assertEqual(params["redirect_uri"], [CALLBACK_URL])
self.assertEqual(params["response_type"], ["code"])
self.assertEqual(params["scope"], [" ".join(SCOPES)])
self.assertEqual(params["client_id"], [CLIENT_ID])
self.assertEqual(len(params["state"]), 1)
self.assertEqual(len(params["nonce"]), 1)

# Check what is in the cookie
# note: python3.5 mock does not have the .called_once() method
calls = req.addCookie.call_args_list
self.assertEqual(len(calls), 1) # called once
# For some reason, call.args does not work with python3.5
args = calls[0][0]
kwargs = calls[0][1]
self.assertEqual(args[0], COOKIE_NAME)
self.assertEqual(kwargs["path"], COOKIE_PATH)
cookie = args[1]

macaroon = pymacaroons.Macaroon.deserialize(cookie)
state = self.handler._get_value_from_macaroon(macaroon, "state")
nonce = self.handler._get_value_from_macaroon(macaroon, "nonce")
redirect = self.handler._get_value_from_macaroon(
macaroon, "client_redirect_url"
)

self.assertEqual(params["state"], [state])
self.assertEqual(params["nonce"], [nonce])
self.assertEqual(redirect, "http://client/redirect")

@defer.inlineCallbacks
def test_callback_error(self):
"""Errors from the provider returned in the callback are displayed."""
self.handler._render_error = Mock()
request = Mock(args={})
request.args[b"error"] = [b"invalid_client"]
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_client", "")

request.args[b"error_description"] = [b"some description"]
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_client", "some description")

@defer.inlineCallbacks
def test_callback(self):
"""Code callback works and display errors if something went wrong.

A lot of scenarios are tested here:
- when the callback works, with userinfo from ID token
- when the user mapping fails
- when ID token verification fails
- when the callback works, with userinfo fetched from the userinfo endpoint
- when the userinfo fetching fails
- when the code exchange fails
"""
token = {
"type": "bearer",
"id_token": "id_token",
"access_token": "access_token",
}
userinfo = {
"sub": "foo",
"preferred_username": "bar",
}
user_id = UserID("foo", "domain.org")
self.handler._render_error = Mock(return_value=None)
self.handler._exchange_code = simple_async_mock(return_value=token)
self.handler._parse_id_token = simple_async_mock(return_value=userinfo)
self.handler._fetch_userinfo = simple_async_mock(return_value=userinfo)
self.handler._map_userinfo_to_user = simple_async_mock(return_value=user_id)
self.handler._auth_handler.complete_sso_login = simple_async_mock()
request = Mock(spec=["args", "getCookie", "addCookie"])

code = "code"
state = "state"
nonce = "nonce"
client_redirect_url = "http://client/redirect"
session = self.handler._generate_oidc_session_token(
state=state, nonce=nonce, client_redirect_url=client_redirect_url,
)
request.getCookie.return_value = session

request.args = {}
request.args[b"code"] = [code.encode("utf-8")]
request.args[b"state"] = [state.encode("utf-8")]

yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))

self.handler._auth_handler.complete_sso_login.assert_called_once_with(
user_id, request, client_redirect_url,
)
self.handler._exchange_code.assert_called_once_with(code)
self.handler._parse_id_token.assert_called_once_with(token, nonce=nonce)
self.handler._map_userinfo_to_user.assert_called_once_with(userinfo, token)
self.handler._fetch_userinfo.assert_not_called()
self.handler._render_error.assert_not_called()

# Handle mapping errors
self.handler._map_userinfo_to_user = simple_async_mock(
raises=MappingException()
)
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("mapping_error")
self.handler._map_userinfo_to_user = simple_async_mock(return_value=user_id)

# Handle ID token errors
self.handler._parse_id_token = simple_async_mock(raises=Exception())
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_token")

self.handler._auth_handler.complete_sso_login.reset_mock()
self.handler._exchange_code.reset_mock()
self.handler._parse_id_token.reset_mock()
self.handler._map_userinfo_to_user.reset_mock()
self.handler._fetch_userinfo.reset_mock()

# With userinfo fetching
self.handler._scopes = [] # do not ask the "openid" scope
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))

self.handler._auth_handler.complete_sso_login.assert_called_once_with(
user_id, request, client_redirect_url,
)
self.handler._exchange_code.assert_called_once_with(code)
self.handler._parse_id_token.assert_not_called()
self.handler._map_userinfo_to_user.assert_called_once_with(userinfo, token)
self.handler._fetch_userinfo.assert_called_once_with(token)
self.handler._render_error.assert_not_called()

# Handle userinfo fetching error
self.handler._fetch_userinfo = simple_async_mock(raises=Exception())
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("fetch_error")

# Handle code exchange failure
self.handler._exchange_code = simple_async_mock(
raises=OidcError("invalid_request")
)
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_request")

@defer.inlineCallbacks
def test_callback_session(self):
"""The callback verifies the session presence and validity"""
self.handler._render_error = Mock(return_value=None)
request = Mock(spec=["args", "getCookie", "addCookie"])

# Missing cookie
request.args = {}
request.getCookie.return_value = None
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("missing_session", "No session cookie found")

# Missing session parameter
request.args = {}
request.getCookie.return_value = "session"
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_request", "State parameter is missing")

# Invalid cookie
request.args = {}
request.args[b"state"] = [b"state"]
request.getCookie.return_value = "session"
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_session")

# Mismatching session
session = self.handler._generate_oidc_session_token(
state="state", nonce="nonce", client_redirect_url="http://client/redirect",
)
request.args = {}
request.args[b"state"] = [b"mismatching state"]
request.getCookie.return_value = session
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("mismatching_session")

# Valid session
request.args = {}
request.args[b"state"] = [b"state"]
request.getCookie.return_value = session
yield defer.ensureDeferred(self.handler.handle_oidc_callback(request))
self.assertRenderedError("invalid_request")

@override_config({"oidc_config": {"client_auth_method": "client_secret_post"}})
@defer.inlineCallbacks
def test_exchange_code(self):
"""Code exchange behaves correctly and handles various error scenarios."""
token = {"type": "bearer"}
token_json = json.dumps(token).encode("utf-8")
self.http_client.request = simple_async_mock(
return_value=FakeResponse(code=200, phrase=b"OK", body=token_json)
)
code = "code"
ret = yield defer.ensureDeferred(self.handler._exchange_code(code))
kwargs = self.http_client.request.call_args[1]

self.assertEqual(ret, token)
self.assertEqual(kwargs["method"], "POST")
self.assertEqual(kwargs["uri"], TOKEN_ENDPOINT)

args = parse_qs(kwargs["data"].decode("utf-8"))
self.assertEqual(args["grant_type"], ["authorization_code"])
self.assertEqual(args["code"], [code])
self.assertEqual(args["client_id"], [CLIENT_ID])
self.assertEqual(args["client_secret"], [CLIENT_SECRET])
self.assertEqual(args["redirect_uri"], [CALLBACK_URL])

# Test error handling
self.http_client.request = simple_async_mock(
return_value=FakeResponse(
code=400,
phrase=b"Bad Request",
body=b'{"error": "foo", "error_description": "bar"}',
)
)
with self.assertRaises(OidcError) as exc:
yield defer.ensureDeferred(self.handler._exchange_code(code))
self.assertEqual(exc.exception.error, "foo")
self.assertEqual(exc.exception.error_description, "bar")

# Internal server error with no JSON body
self.http_client.request = simple_async_mock(
return_value=FakeResponse(
code=500, phrase=b"Internal Server Error", body=b"Not JSON",
)
)
with self.assertRaises(OidcError) as exc:
yield defer.ensureDeferred(self.handler._exchange_code(code))
self.assertEqual(exc.exception.error, "server_error")

# Internal server error with JSON body
self.http_client.request = simple_async_mock(
return_value=FakeResponse(
code=500,
phrase=b"Internal Server Error",
body=b'{"error": "internal_server_error"}',
)
)
with self.assertRaises(OidcError) as exc:
yield defer.ensureDeferred(self.handler._exchange_code(code))
self.assertEqual(exc.exception.error, "internal_server_error")

# 4xx error without "error" field
self.http_client.request = simple_async_mock(
return_value=FakeResponse(code=400, phrase=b"Bad request", body=b"{}",)
)
with self.assertRaises(OidcError) as exc:
yield defer.ensureDeferred(self.handler._exchange_code(code))
self.assertEqual(exc.exception.error, "server_error")

# 2xx error with "error" field
self.http_client.request = simple_async_mock(
return_value=FakeResponse(
code=200, phrase=b"OK", body=b'{"error": "some_error"}',
)
)
with self.assertRaises(OidcError) as exc:
yield defer.ensureDeferred(self.handler._exchange_code(code))
self.assertEqual(exc.exception.error, "some_error")

+ 1
- 0
tox.ini Ver fichero

@@ -185,6 +185,7 @@ commands = mypy \
synapse/handlers/auth.py \
synapse/handlers/cas_handler.py \
synapse/handlers/directory.py \
synapse/handlers/oidc_handler.py \
synapse/handlers/presence.py \
synapse/handlers/saml_handler.py \
synapse/handlers/sync.py \


Cargando…
Cancelar
Guardar