summary refs log tree commit diff
path: root/synapse/config/oidc_config.py
blob: fddca192238a2369006aaa6f594882805fe5b652 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# -*- coding: utf-8 -*-
# Copyright 2020 Quentin Gliech
# Copyright 2020-2021 The Matrix.org Foundation C.I.C.
#
# 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 string
from typing import Optional, Type

import attr

from synapse.config._util import validate_config
from synapse.python_dependencies import DependencyException, check_requirements
from synapse.types import Collection, JsonDict
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):
        validate_config(MAIN_CONFIG_SCHEMA, config, ())

        self.oidc_provider = None  # type: Optional[OidcProviderConfig]

        oidc_config = config.get("oidc_config")
        if oidc_config and oidc_config.get("enabled", False):
            validate_config(OIDC_PROVIDER_CONFIG_SCHEMA, oidc_config, ("oidc_config",))
            self.oidc_provider = _parse_oidc_config_dict(oidc_config)

        if not self.oidc_provider:
            return

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

        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"

    @property
    def oidc_enabled(self) -> bool:
        # OIDC is enabled if we have a provider
        return bool(self.oidc_provider)

    def generate_config_section(self, config_dir_path, server_name, **kwargs):
        return """\
        # Enable OpenID Connect (OIDC) / OAuth 2.0 for registration and login.
        #
        # See https://github.com/matrix-org/synapse/blob/master/docs/openid.md
        # for some example configurations.
        #
        oidc_config:
          # Uncomment the following to enable authorization against an OpenID Connect
          # server. Defaults to false.
          #
          #enabled: true

          # Uncomment the following to disable use of the OIDC discovery mechanism to
          # discover endpoints. Defaults to true.
          #
          #discover: false

          # the OIDC issuer. Used to validate tokens and (if discovery is enabled) to
          # discover the provider's endpoints.
          #
          # Required if 'enabled' is true.
          #
          #issuer: "https://accounts.example.com/"

          # oauth2 client id to use.
          #
          # Required if 'enabled' is true.
          #
          #client_id: "provided-by-your-issuer"

          # oauth2 client secret to use.
          #
          # Required if 'enabled' is true.
          #
          #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_secret_post

          # list of scopes to request. This should normally include the "openid" scope.
          # Defaults to ["openid"].
          #
          #scopes: ["openid", "profile"]

          # 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 requested.
          #
          #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"

          # Uncomment to 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: true

          # Whether to fetch the user profile from the userinfo endpoint. Valid
          # values are: "auto" or "userinfo_endpoint".
          #
          # Defaults to "auto", which fetches the userinfo endpoint if "openid" is included
          # in `scopes`. Uncomment the following to always fetch the userinfo endpoint.
          #
          #user_profile_method: "userinfo_endpoint"

          # Uncomment to allow a user logging in via OIDC to match a pre-existing account instead
          # of failing. This could be used if switching from password logins to OIDC. Defaults to false.
          #
          #allow_existing_users: true

          # 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}.
            #
            # See https://github.com/matrix-org/synapse/blob/master/docs/sso_mapping_providers.md#openid-mapping-providers
            # for information on implementing a custom mapping provider.
            #
            #module: mapping_provider.OidcMappingProvider

            # Custom configuration values for the module. This section will be passed as
            # a Python dictionary to the user mapping provider module's `parse_config`
            # method.
            #
            # The examples below are intended for the default provider: they should be
            # changed if using a custom provider.
            #
            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.
              #
              # When rendering, this template is given the following variables:
              #   * user: The claims returned by the UserInfo Endpoint and/or in the ID
              #     Token
              #
              # If this is not set, the user will be prompted to choose their
              # own username.
              #
              #localpart_template: "{{{{ user.preferred_username }}}}"

              # Jinja2 template for the display name to set on first login.
              #
              # If unset, no displayname will be set.
              #
              #display_name_template: "{{{{ user.given_name }}}} {{{{ user.last_name }}}}"

              # Jinja2 templates for extra attributes to send back to the client during
              # login.
              #
              # Note that these are non-standard and clients will ignore them without modifications.
              #
              #extra_attributes:
                #birthdate: "{{{{ user.birthdate }}}}"
        """.format(
            mapping_provider=DEFAULT_USER_MAPPING_PROVIDER
        )


# jsonschema definition of the configuration settings for an oidc identity provider
OIDC_PROVIDER_CONFIG_SCHEMA = {
    "type": "object",
    "required": ["issuer", "client_id", "client_secret"],
    "properties": {
        "idp_id": {"type": "string", "minLength": 1, "maxLength": 128},
        "idp_name": {"type": "string"},
        "discover": {"type": "boolean"},
        "issuer": {"type": "string"},
        "client_id": {"type": "string"},
        "client_secret": {"type": "string"},
        "client_auth_method": {
            "type": "string",
            # the following list is the same as the keys of
            # authlib.oauth2.auth.ClientAuth.DEFAULT_AUTH_METHODS. We inline it
            # to avoid importing authlib here.
            "enum": ["client_secret_basic", "client_secret_post", "none"],
        },
        "scopes": {"type": "array", "items": {"type": "string"}},
        "authorization_endpoint": {"type": "string"},
        "token_endpoint": {"type": "string"},
        "userinfo_endpoint": {"type": "string"},
        "jwks_uri": {"type": "string"},
        "skip_verification": {"type": "boolean"},
        "user_profile_method": {
            "type": "string",
            "enum": ["auto", "userinfo_endpoint"],
        },
        "allow_existing_users": {"type": "boolean"},
        "user_mapping_provider": {"type": ["object", "null"]},
    },
}

# the `oidc_config` setting can either be None (as it is in the default
# config), or an object. If an object, it is ignored unless it has an "enabled: True"
# property.
#
# It's *possible* to represent this with jsonschema, but the resultant errors aren't
# particularly clear, so we just check for either an object or a null here, and do
# additional checks in the code.
OIDC_CONFIG_SCHEMA = {"oneOf": [{"type": "null"}, {"type": "object"}]}

MAIN_CONFIG_SCHEMA = {
    "type": "object",
    "properties": {"oidc_config": OIDC_CONFIG_SCHEMA},
}


def _parse_oidc_config_dict(oidc_config: JsonDict) -> "OidcProviderConfig":
    """Take the configuration dict and parse it into an OidcProviderConfig

    Raises:
        ConfigError if the configuration is malformed.
    """
    ump_config = oidc_config.get("user_mapping_provider", {})
    ump_config.setdefault("module", DEFAULT_USER_MAPPING_PROVIDER)
    ump_config.setdefault("config", {})

    (user_mapping_provider_class, user_mapping_provider_config,) = load_module(
        ump_config, ("oidc_config", "user_mapping_provider")
    )

    # 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(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),)
        )

    # MSC2858 will appy certain limits in what can be used as an IdP id, so let's
    # enforce those limits now.
    idp_id = oidc_config.get("idp_id", "oidc")
    valid_idp_chars = set(string.ascii_letters + string.digits + "-._~")

    if any(c not in valid_idp_chars for c in idp_id):
        raise ConfigError('idp_id may only contain A-Z, a-z, 0-9, "-", ".", "_", "~"')

    return OidcProviderConfig(
        idp_id=idp_id,
        idp_name=oidc_config.get("idp_name", "OIDC"),
        discover=oidc_config.get("discover", True),
        issuer=oidc_config["issuer"],
        client_id=oidc_config["client_id"],
        client_secret=oidc_config["client_secret"],
        client_auth_method=oidc_config.get("client_auth_method", "client_secret_basic"),
        scopes=oidc_config.get("scopes", ["openid"]),
        authorization_endpoint=oidc_config.get("authorization_endpoint"),
        token_endpoint=oidc_config.get("token_endpoint"),
        userinfo_endpoint=oidc_config.get("userinfo_endpoint"),
        jwks_uri=oidc_config.get("jwks_uri"),
        skip_verification=oidc_config.get("skip_verification", False),
        user_profile_method=oidc_config.get("user_profile_method", "auto"),
        allow_existing_users=oidc_config.get("allow_existing_users", False),
        user_mapping_provider_class=user_mapping_provider_class,
        user_mapping_provider_config=user_mapping_provider_config,
    )


@attr.s(slots=True, frozen=True)
class OidcProviderConfig:
    # a unique identifier for this identity provider. Used in the 'user_external_ids'
    # table, as well as the query/path parameter used in the login protocol.
    idp_id = attr.ib(type=str)

    # user-facing name for this identity provider.
    idp_name = attr.ib(type=str)

    # whether the OIDC discovery mechanism is used to discover endpoints
    discover = attr.ib(type=bool)

    # the OIDC issuer. Used to validate tokens and (if discovery is enabled) to
    # discover the provider's endpoints.
    issuer = attr.ib(type=str)

    # oauth2 client id to use
    client_id = attr.ib(type=str)

    # oauth2 client secret to use
    client_secret = attr.ib(type=str)

    # auth method to use when exchanging the token.
    # Valid values are 'client_secret_basic', 'client_secret_post' and
    # 'none'.
    client_auth_method = attr.ib(type=str)

    # list of scopes to request
    scopes = attr.ib(type=Collection[str])

    # the oauth2 authorization endpoint. Required if discovery is disabled.
    authorization_endpoint = attr.ib(type=Optional[str])

    # the oauth2 token endpoint. Required if discovery is disabled.
    token_endpoint = attr.ib(type=Optional[str])

    # the OIDC userinfo endpoint. Required if discovery is disabled and the
    # "openid" scope is not requested.
    userinfo_endpoint = attr.ib(type=Optional[str])

    # URI where to fetch the JWKS. Required if discovery is disabled and the
    # "openid" scope is used.
    jwks_uri = attr.ib(type=Optional[str])

    # Whether to skip metadata verification
    skip_verification = attr.ib(type=bool)

    # Whether to fetch the user profile from the userinfo endpoint. Valid
    # values are: "auto" or "userinfo_endpoint".
    user_profile_method = attr.ib(type=str)

    # whether to allow a user logging in via OIDC to match a pre-existing account
    # instead of failing
    allow_existing_users = attr.ib(type=bool)

    # the class of the user mapping provider
    user_mapping_provider_class = attr.ib(type=Type)

    # the config of the user mapping provider
    user_mapping_provider_config = attr.ib()