@@ -0,0 +1 @@ | |||
Add OpenID Connect login/registration support. Contributed by Quentin Gliech, on behalf of [les Connecteurs](https://connecteu.rs). |
@@ -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 }}' | |||
``` | |||
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 }}' | |||
``` |
@@ -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 | |||
# | |||
@@ -75,3 +75,6 @@ ignore_missing_imports = True | |||
[mypy-jwt.*] | |||
ignore_missing_imports = True | |||
[mypy-authlib.*] | |||
ignore_missing_imports = True |
@@ -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() | |||
@@ -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 | |||
@@ -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, | |||
@@ -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 | |||
) |
@@ -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 | |||
# | |||
@@ -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. | |||
@@ -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) | |||
@@ -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"], | |||
@@ -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> |
@@ -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) |
@@ -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)) |
@@ -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) |
@@ -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) | |||
@@ -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 |
@@ -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") |
@@ -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 \ | |||