Support non-OpenID compliant user info endpoints (#14753)

OpenID specifies the format of the user info endpoint and some
OAuth 2.0 IdPs do not follow it, e.g. NextCloud and Twitter.

This adds subject_template and picture_template options to the
default mapping provider for more flexibility in matching those user
info responses.
This commit is contained in:
Patrick Cloke 2023-01-04 08:26:10 -05:00 committed by GitHub
parent db1cfe9c80
commit 906dfaa2cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 42 additions and 8 deletions

View file

@ -0,0 +1 @@
Support non-OpenID compliant userinfo claims for subject and picture.

View file

@ -3098,10 +3098,26 @@ Options for each entry include:
For the default provider, the following settings are available: For the default provider, the following settings are available:
* `subject_template`: Jinja2 template for a unique identifier for the user.
Defaults to `{{ user.sub }}`, which OpenID Connect compliant providers should provide.
This replaces and overrides `subject_claim`.
* `subject_claim`: name of the claim containing a unique identifier * `subject_claim`: name of the claim containing a unique identifier
for the user. Defaults to 'sub', which OpenID Connect for the user. Defaults to 'sub', which OpenID Connect
compliant providers should provide. compliant providers should provide.
*Deprecated in Synapse v1.75.0.*
* `picture_template`: Jinja2 template for an url for the user's profile picture.
Defaults to `{{ user.picture }}`, which OpenID Connect compliant providers should
provide and has to refer to a direct image file such as PNG, JPEG, or GIF image file.
This replaces and overrides `picture_claim`.
Currently only supported in monolithic (single-process) server configurations
where the media repository runs within the Synapse process.
* `picture_claim`: name of the claim containing an url for the user's profile picture. * `picture_claim`: name of the claim containing an url for the user's profile picture.
Defaults to 'picture', which OpenID Connect compliant providers should provide Defaults to 'picture', which OpenID Connect compliant providers should provide
and has to refer to a direct image file such as PNG, JPEG, or GIF image file. and has to refer to a direct image file such as PNG, JPEG, or GIF image file.
@ -3109,6 +3125,8 @@ Options for each entry include:
Currently only supported in monolithic (single-process) server configurations Currently only supported in monolithic (single-process) server configurations
where the media repository runs within the Synapse process. where the media repository runs within the Synapse process.
*Deprecated in Synapse v1.75.0.*
* `localpart_template`: Jinja2 template for the localpart of the MXID. * `localpart_template`: Jinja2 template for the localpart of the MXID.
If this is not set, the user will be prompted to choose their If this is not set, the user will be prompted to choose their
own username (see the documentation for the `sso_auth_account_details.html` own username (see the documentation for the `sso_auth_account_details.html`

View file

@ -1520,8 +1520,8 @@ env.filters.update(
@attr.s(slots=True, frozen=True, auto_attribs=True) @attr.s(slots=True, frozen=True, auto_attribs=True)
class JinjaOidcMappingConfig: class JinjaOidcMappingConfig:
subject_claim: str subject_template: Template
picture_claim: str picture_template: Template
localpart_template: Optional[Template] localpart_template: Optional[Template]
display_name_template: Optional[Template] display_name_template: Optional[Template]
email_template: Optional[Template] email_template: Optional[Template]
@ -1540,8 +1540,23 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
@staticmethod @staticmethod
def parse_config(config: dict) -> JinjaOidcMappingConfig: def parse_config(config: dict) -> JinjaOidcMappingConfig:
subject_claim = config.get("subject_claim", "sub") def parse_template_config_with_claim(
picture_claim = config.get("picture_claim", "picture") option_name: str, default_claim: str
) -> Template:
template_name = f"{option_name}_template"
template = config.get(template_name)
if not template:
# Convert the legacy subject_claim into a template.
claim = config.get(f"{option_name}_claim", default_claim)
template = "{{ user.%s }}" % (claim,)
try:
return env.from_string(template)
except Exception as e:
raise ConfigError("invalid jinja template", path=[template_name]) from e
subject_template = parse_template_config_with_claim("subject", "sub")
picture_template = parse_template_config_with_claim("picture", "picture")
def parse_template_config(option_name: str) -> Optional[Template]: def parse_template_config(option_name: str) -> Optional[Template]:
if option_name not in config: if option_name not in config:
@ -1574,8 +1589,8 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
raise ConfigError("must be a bool", path=["confirm_localpart"]) raise ConfigError("must be a bool", path=["confirm_localpart"])
return JinjaOidcMappingConfig( return JinjaOidcMappingConfig(
subject_claim=subject_claim, subject_template=subject_template,
picture_claim=picture_claim, picture_template=picture_template,
localpart_template=localpart_template, localpart_template=localpart_template,
display_name_template=display_name_template, display_name_template=display_name_template,
email_template=email_template, email_template=email_template,
@ -1584,7 +1599,7 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
) )
def get_remote_user_id(self, userinfo: UserInfo) -> str: def get_remote_user_id(self, userinfo: UserInfo) -> str:
return userinfo[self._config.subject_claim] return self._config.subject_template.render(user=userinfo).strip()
async def map_user_attributes( async def map_user_attributes(
self, userinfo: UserInfo, token: Token, failures: int self, userinfo: UserInfo, token: Token, failures: int
@ -1615,7 +1630,7 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
if email: if email:
emails.append(email) emails.append(email)
picture = userinfo.get(self._config.picture_claim) picture = self._config.picture_template.render(user=userinfo).strip()
return UserAttributeDict( return UserAttributeDict(
localpart=localpart, localpart=localpart,