summary refs log tree commit diff
path: root/synapse/config/oidc2.py
blob: 07f400e0b3762b1389a9969405aa4e020dcc53a2 (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
from enum import Enum
from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple

from pydantic import BaseModel, StrictBool, StrictStr, constr, validator
from pydantic.fields import ModelField

from synapse.util.stringutils import parse_and_validate_mxc_uri

# Ugly workaround for https://github.com/samuelcolvin/pydantic/issues/156. Mypy doesn't
# consider expressions like `constr(...)` to be valid types.
if TYPE_CHECKING:
    IDP_ID_TYPE = str
    IDP_BRAND_TYPE = str
else:
    IDP_ID_TYPE = constr(
        strict=True,
        min_length=1,
        max_length=250,
        regex="^[A-Za-z0-9._~-]+$",  # noqa: F722
    )
    IDP_BRAND_TYPE = constr(
        strict=True,
        min_length=1,
        max_length=255,
        regex="^[a-z][a-z0-9_.-]*$",  # noqa: F722
    )


# the following list of enum members is the same as the keys of
# authlib.oauth2.auth.ClientAuth.DEFAULT_AUTH_METHODS. We inline it
# to avoid importing authlib here.
class ClientAuthMethods(str, Enum):
    # The duplication is unfortunate. 3.11 should have StrEnum though,
    # and there is a backport available for 3.8.6.
    client_secret_basic = "client_secret_basic"
    client_secret_post = "client_secret_post"
    none = "none"


class UserProfileMethod(str, Enum):
    # The duplication is unfortunate. 3.11 should have StrEnum though,
    # and there is a backport available for 3.8.6.
    auto = "auto"
    userinfo_endpoint = "userinfo_endpoint"


class SSOAttributeRequirement(BaseModel):
    class Config:
        # Complain if someone provides a field that's not one of those listed here.
        # Pydantic suggests making your own BaseModel subclass if you want to do this,
        # see https://pydantic-docs.helpmanual.io/usage/model_config/#change-behaviour-globally
        extra = "forbid"

    attribute: StrictStr
    # Note: a comment in config/oidc.py suggests that `value` may be optional. But
    # The JSON schema seems to forbid this.
    value: StrictStr


class ClientSecretJWTKey(BaseModel):
    class Config:
        extra = "forbid"
    # a pem-encoded signing key
    # TODO: how should we handle key_file?
    key: StrictStr

    # properties to include in the JWT header
    # TODO: validator should enforce that jwt_header contains an 'alg'.
    jwt_header: Mapping[str, str]

    # properties to include in the JWT payload.
    jwt_payload: Mapping[str, str] = {}



class OIDCProviderModel(BaseModel):
    """
    Notes on Pydantic:
    - I've used StrictStr because a plain `str` e.g. accepts integers and calls str()
      on them
    - pulling out constr() into IDP_ID_TYPE is a little awkward, but necessary to keep
      mypy happy
    -
    """

    # 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: IDP_ID_TYPE

    @validator("idp_id")
    def ensure_idp_id_prefix(cls, idp_id: str) -> str:
        """Prefix the given IDP with a prefix specific to the SSO mechanism, to avoid
        clashes with other mechs (such as SAML, CAS).

        We allow "oidc" as an exception so that people migrating from old-style
        "oidc_config" format (which has long used "oidc" as its idp_id) can migrate to
        a new-style "oidc_providers" entry without changing the idp_id for their provider
        (and thereby invalidating their user_external_ids data).
        """
        if idp_id != "oidc":
            return "oidc-" + idp_id
        return idp_id

    # user-facing name for this identity provider.
    idp_name: StrictStr

    # Optional MXC URI for icon for this IdP.
    idp_icon: Optional[StrictStr]

    @validator("idp_icon")
    def idp_icon_is_an_mxc_url(cls, idp_icon: str) -> str:
        parse_and_validate_mxc_uri(idp_icon)
        return idp_icon

    # Optional brand identifier for this IdP.
    idp_brand: Optional[StrictStr]

    # whether the OIDC discovery mechanism is used to discover endpoints
    discover: StrictBool = True

    # the OIDC issuer. Used to validate tokens and (if discovery is enabled) to
    # discover the provider's endpoints.
    issuer: StrictStr

    # oauth2 client id to use
    client_id: StrictStr

    # oauth2 client secret to use. if `None`, use client_secret_jwt_key to generate
    # a secret.
    client_secret: Optional[StrictStr]

    # key to use to construct a JWT to use as a client secret. May be `None` if
    # `client_secret` is set.
    # TODO: test that ClientSecretJWTKey is being parsed correctly
    client_secret_jwt_key: Optional[ClientSecretJWTKey]

    # TODO: what is the precise relationship between client_auth_method, client_secret
    # and client_secret_jwt_key? Is there anything we should enforce with a validator?
    # auth method to use when exchanging the token.
    # Valid values are 'client_secret_basic', 'client_secret_post' and
    # 'none'.
    client_auth_method: ClientAuthMethods = ClientAuthMethods.client_secret_basic

    # list of scopes to request
    scopes: Tuple[StrictStr, ...] = ("openid",)

    # the oauth2 authorization endpoint. Required if discovery is disabled.
    authorization_endpoint: Optional[StrictStr]

    # the oauth2 token endpoint. Required if discovery is disabled.
    token_endpoint: Optional[StrictStr]

    # Normally, validators aren't run when fields don't have a value provided.
    # Using validate=True ensures we run the validator even in that situation.
    @validator("authorization_endpoint", "token_endpoint", always=True)
    def endpoints_required_if_discovery_disabled(
        cls,
        endpoint_url: Optional[str],
        values: Mapping[str, Any],
        field: ModelField,
    ) -> Optional[str]:
        # `if "discover" in values means: don't run our checks if "discover" didn't
        # pass validation. (NB: validation order is the field definition order)
        if "discover" in values and not values["discover"] and endpoint_url is None:
            raise ValueError(f"{field.name} is required if discovery is disabled")
        return endpoint_url

    # the OIDC userinfo endpoint. Required if discovery is disabled and the
    # "openid" scope is not requested.
    userinfo_endpoint: Optional[StrictStr]

    @validator("userinfo_endpoint", always=True)
    def userinfo_endpoint_required_without_discovery_and_without_openid_scope(
        cls, userinfo_endpoint: Optional[str], values: Mapping[str, object]
    ) -> Optional[str]:
        discovery_disabled = "discover" in values and not values["discover"]
        openid_scope_not_requested = (
            "scopes" in values and "openid" not in values["scopes"]
        )
        if (
            discovery_disabled
            and openid_scope_not_requested
            and userinfo_endpoint is None
        ):
            raise ValueError(
                "userinfo_requirement is required if discovery is disabled and"
                "the 'openid' scope is not requested"
            )
        return userinfo_endpoint

    # URI where to fetch the JWKS. Required if discovery is disabled and the
    # "openid" scope is used.
    jwks_uri: Optional[StrictStr]

    @validator("jwks_uri", always=True)
    def jwks_uri_required_without_discovery_but_with_openid_scope(
        cls, jwks_uri: Optional[str], values: Mapping[str, object]
    ) -> Optional[str]:
        discovery_disabled = "discover" in values and not values["discover"]
        openid_scope_requested = "scopes" in values and "openid" in values["scopes"]
        if discovery_disabled and openid_scope_requested and jwks_uri is None:
            raise ValueError(
                "jwks_uri is required if discovery is disabled and"
                "the 'openid' scope is not requested"
            )
        return jwks_uri

    # Whether to skip metadata verification
    skip_verification: StrictBool = False

    # Whether to fetch the user profile from the userinfo endpoint. Valid
    # values are: "auto" or "userinfo_endpoint".
    user_profile_method: UserProfileMethod = UserProfileMethod.auto

    # whether to allow a user logging in via OIDC to match a pre-existing account
    # instead of failing
    allow_existing_users: StrictBool = False

    # the class of the user mapping provider
    # TODO there was logic for this
    user_mapping_provider_class: Any  # TODO: Type

    # the config of the user mapping provider
    # TODO
    user_mapping_provider_config: Any

    # required attributes to require in userinfo to allow login/registration
    # TODO: wouldn't this be better expressed as a Mapping[str, str]?
    attribute_requirements: Tuple[SSOAttributeRequirement, ...] = ()


class LegacyOIDCProviderModel(OIDCProviderModel):
    # These fields could be omitted in the old scheme.
    idp_id: IDP_ID_TYPE = "oidc"
    idp_name: StrictStr = "OIDC"


# TODO
# top-level config: check we don't have any duplicate idp_ids now
# compute callback url