Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

New login flow: Use OAuth2 JWT as external identifier #12830

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/12830.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a new login flow: use OAuth2 JWT as external identifier. Contributed by Hannes Lerchl
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- [SSO Mapping Providers](sso_mapping_providers.md)
- [Password Auth Providers](password_auth_providers.md)
- [JSON Web Tokens](jwt.md)
- [JSON Web Tokens (as external_id)](sso_jwt.md)
- [Refresh Tokens](usage/configuration/user_authentication/refresh_tokens.md)
- [Registration Captcha](CAPTCHA_SETUP.md)
- [Application Services](application_services.md)
Expand Down
14 changes: 14 additions & 0 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1986,6 +1986,20 @@ saml2_config:
# match a pre-existing account instead of failing. This could be used if
# switching from password logins to OIDC. Defaults to false.
#
# sso_jwt_enabled: by using the login flow org.matrix.login.sso_jwt it
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this rather be called something like allow_standalone_jwt?

# is possible to login with a JWT token that was acquired (from this
# provider) via another application. If this should be disallowed
# _for this oidc_provider_ then set this value to 'false'.
#
# If not set this defaults to 'true'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should it not default to false? it seems like that would be safer.

also: if it is set, then there needs to be a JWKS for this provider, and some oidc providers do not have a jwks_uri. If allow_standalone_jwt is enabled for an oidc provider without a jwks_uri, we should detect that and report an error.

#
# standalone_jwt_audience: if sso_jwt_enabled is 'true' (or not set)
# then we accept JWT token acquired via another application. In this case
# there's an additional check: the audience claim given in the token
# must contain this entry.
#
# If there is no audience configured then this check is skipped.
Comment on lines +1996 to +2001
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this only apply in the standalone case? Is there not a usecase for restricting by audience in the OIDC case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also: I note that this is a list of acceptable audiences for jwt_config. We should probably have the same here, for conistency.

#
# user_mapping_provider: Configuration for how attributes returned from a OIDC
# provider are mapped onto a matrix user. This setting has the following
# sub-properties:
Expand Down
82 changes: 82 additions & 0 deletions docs/sso_jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# JWT Login Type (as external_id)

Synapse contains a non-standard login type to support "standalone"
[JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token)
as external identifiers.
The general mechanics is similar to other standard
[authentication types](https://spec.matrix.org/v1.2/client-server-api/#authentication-types).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This link refers to "user-interactive authentication", which is not used by /_matrix/client/v3/login.

A more relevant link is https://spec.matrix.org/v1.2/client-server-api/#login, which documents the authentication types currently supported by /login.


## API call

To log in using this way, clients should `POST` a request to `/_matrix/client/r0/login`
with the following body:

```json
{
"type": "org.matrix.login.sso_jwt"
}
```

Additionally, the request must contain an auth HTTP header with a JWT inside (which is usual
for OAuth2 ressource servers).
```
Authorization: Bearer <jwt>
```

that's it.

(Alternatively, if for some reason a client can't set this HTTP header, it can add an entry
`"token" : "<jwt>"` to the body's payload. This will then override the header if present.)

## User mapping

So which user will be logged in with a given JWT? This login flow
will decode and check the given JWT. It will extract the issuer (the `iss` claim) and the
OAuth2 principal (the `sub` claim) and search the
[user database](admin_api/user_admin_api.html) for a user with an `external_ids` entry
where `auth_provider` matches the issuer and `external_id` matches the principal.

If such a user is not contained in the database the login attempt will be rejected.
This login flow has no mechanism to automatically create users. This has to be done
in beforehand by an administrator.

## Required configuration

Configuration for this login flow is part of the [oidc_providers](openid.md) section.
There are two extra parameters which can be added per `oidc_provider`:
* `sso_jwt_enabled` - A boolean value which defaults to `true` if not given. This entry can be used
to disable this login flow for a specific OIDC provider.
* `standalone_jwt_audience` - The `aud` (audience) claim of a JWT says whether a JWT is intended to
be used for a specific service (see also [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3)).
This parameter can contain a string which will then be searched in the list of audiences
carried by the JWT. If the configured audience is not contained in the JWT, synapse will
reject the login attempt. If this parameter is not given, the `aud` claim in a given
JWT will be ignored.

There are very few requirements regarding the other parameters in the `oidc_providers` section:
* `issuer` - must be given so that the login mechanism can identify the config section for a received JWT
* `jwks_uri` - must be given so that the public key(s) of the issuer can be downloaded (required for
checking the JWT's signature)
* `discover` can be set to `false` as this flow doesn't need any other endpoints of the auth provider

## Requirements to a received JWT

There are some checks done with a received JWT before the user is actually logged in:

* The signature of the JWT is verified and rejected if wrong
* The JWT must contain an issuer (`iss`) entry. This issuer must have its own entry in
`oidc_providers` with `sso_jwt_enabled` set to `true` (or absent).
* The expiration time (`exp`), not before time (`nbf`), and issued at (`iat`)
claims are optional, but validated if present.
* The JWT must contain a `sub` claim which will be used to find the assigned
matrix user
* The audience (`aud`) claim is optional. If given it will be checked against
the configured `aud` expectation (if given)

In the case that the token is not valid, the homeserver will respond with
`403 Forbidden` and an error code of `M_FORBIDDEN`.

## Testing locally

During development, I found it helpful to start a
dockerized keycloak server and set `oidc_providers.skip_verification` to `true`.
11 changes: 11 additions & 0 deletions docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2712,6 +2712,17 @@ Options for each entry include:
you are connecting to a provider that is not OpenID Connect compliant.
Defaults to false. Avoid this in production.

* `sso_jwt_enabled`: Defaults to true. This allows a client to log in by
presenting a JWT token acquired from "somewhere else" (not synapse's SSO
flow). If set to false, such login attempts will be rejected.
See [JWT Login Type (as external_id)](../../sso_jwt.md) for further
details.

* `standalone_jwt_audience`: Relates to the
["JWT as external_id"](../../sso_jwt.md) login flow. If this flow is
enabled for this provider, then each JWT will be checked if it contains
this string in its `aud` claims.

* `user_profile_method`: Whether to fetch the user profile from the userinfo
endpoint, or to rely on the data returned in the id_token from the `token_endpoint`.
Valid values are: `auto` or `userinfo_endpoint`.
Expand Down
46 changes: 46 additions & 0 deletions synapse/config/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
class OIDCConfig(Config):
section = "oidc"

def __init__(self, *args: Any):
super().__init__(*args)

self._sso_jwt_enabled = False

def read_config(self, config: JsonDict, **kwargs: Any) -> None:
self.oidc_providers = tuple(_parse_oidc_provider_configs(config))
if not self.oidc_providers:
Expand All @@ -58,6 +63,8 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
"Multiple OIDC providers have the idp_id %r." % idp_id
)

self._sso_jwt_enabled = self._check_if_sso_jwt_enabled(self.oidc_providers)

public_baseurl = self.root.server.public_baseurl
self.oidc_callback_url = public_baseurl + "_synapse/client/oidc/callback"

Expand All @@ -66,6 +73,19 @@ def oidc_enabled(self) -> bool:
# OIDC is enabled if we have a provider
return bool(self.oidc_providers)

@property
def sso_jwt_enabled(self) -> bool:
return bool(self._sso_jwt_enabled)

def _check_if_sso_jwt_enabled(
self, oidc_providers: Iterable["OidcProviderConfig"]
) -> bool:
# SSO JWT is enabled if there is at least one oidc_provider with sso_jwt_enabled
for oidc in oidc_providers:
if oidc.sso_jwt_enabled:
return True
return False

def generate_config_section(self, **kwargs: Any) -> str:
return """\
# List of OpenID Connect (OIDC) / OAuth 2.0 identity providers, for registration
Expand Down Expand Up @@ -161,6 +181,20 @@ def generate_config_section(self, **kwargs: Any) -> str:
# match a pre-existing account instead of failing. This could be used if
# switching from password logins to OIDC. Defaults to false.
#
# sso_jwt_enabled: by using the login flow org.matrix.login.sso_jwt it
# is possible to login with a JWT token that was acquired (from this
# provider) via another application. If this should be disallowed
# _for this oidc_provider_ then set this value to 'false'.
#
# If not set this defaults to 'true'.
#
# standalone_jwt_audience: if sso_jwt_enabled is 'true' (or not set)
# then we accept JWT token acquired via another application. In this case
# there's an additional check: the audience claim given in the token
# must contain this entry.
#
# If there is no audience configured then this check is skipped.
#
# user_mapping_provider: Configuration for how attributes returned from a OIDC
# provider are mapped onto a matrix user. This setting has the following
# sub-properties:
Expand Down Expand Up @@ -330,6 +364,8 @@ def generate_config_section(self, **kwargs: Any) -> str:
"enum": ["auto", "userinfo_endpoint"],
},
"allow_existing_users": {"type": "boolean"},
"sso_jwt_enabled": {"type": "boolean"},
"standalone_jwt_audience": {"type": "string"},
"user_mapping_provider": {"type": ["object", "null"]},
"attribute_requirements": {
"type": "array",
Expand Down Expand Up @@ -498,6 +534,8 @@ def _parse_oidc_config_dict(
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),
sso_jwt_enabled=oidc_config.get("sso_jwt_enabled", True),
standalone_jwt_audience=oidc_config.get("standalone_jwt_audience"),
user_mapping_provider_class=user_mapping_provider_class,
user_mapping_provider_config=user_mapping_provider_config,
attribute_requirements=attribute_requirements,
Expand Down Expand Up @@ -582,6 +620,14 @@ class OidcProviderConfig:
# instead of failing
allow_existing_users: bool

# accept standalone JWT tokens from this provider (not acquired by this server
# but by someone else and now this JWT is used to log in)
sso_jwt_enabled: bool

# if sso_jwt_enabled is true then we only accept it if the audience
# contains this entry
standalone_jwt_audience: Optional[str]

# the class of the user mapping provider
user_mapping_provider_class: Type

Expand Down
59 changes: 59 additions & 0 deletions synapse/handlers/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,8 @@ def __init__(
)
self._skip_verification = provider.skip_verification
self._allow_existing_users = provider.allow_existing_users
self._sso_jwt_enabled = provider.sso_jwt_enabled
self._standalone_jwt_audience = provider.standalone_jwt_audience

self._http_client = hs.get_proxied_http_client()
self._server_name: str = hs.config.server.server_name
Expand Down Expand Up @@ -507,6 +509,7 @@ async def _load_jwks(self) -> JWKS:
if not uri:
# this should be unreachable: load_metadata validates that
# there is a jwks_uri in the metadata if _uses_userinfo is unset
logger.error("Missing 'jwks_uri' in metadata")
raise RuntimeError('Missing "jwks_uri" in metadata')

jwk_set = await self._http_client.get_json(uri)
Expand Down Expand Up @@ -719,6 +722,56 @@ async def _parse_id_token(self, token: Token, nonce: str) -> CodeIDToken:

return claims

async def _parse_standalone_id_token(self, id_token: str) -> Optional[CodeIDToken]:
"""Return an instance of UserInfo from to given ``id_token``.

Args:
id_token: a JWT token which was _not_ acquire via our login process
but from some other application

Returns:
The decoded claims in the id_token.
"""
if not self._sso_jwt_enabled:
return None

metadata = await self.load_metadata()

alg_values = metadata.get("id_token_signing_alg_values_supported", ["RS256"])
jwt = JsonWebToken(alg_values)

claim_options = {"iss": {"value": metadata["issuer"]}}
if not self._standalone_jwt_audience is None:
claim_options["aud"] = {"value": self._standalone_jwt_audience}

logger.debug("Attempting to decode JWT id_token %r", id_token)

# Try to decode the keys in cache first, then retry by forcing the keys
# to be reloaded
jwk_set = await self.load_jwks()
try:
claims = jwt.decode(
id_token,
key=jwk_set,
claims_cls=CodeIDToken,
claims_options=claim_options,
)
except ValueError:
logger.info("Reloading JWKS after decode error")
jwk_set = await self.load_jwks(force=True) # try reloading the jwks
claims = jwt.decode(
id_token,
key=jwk_set,
claims_cls=CodeIDToken,
claims_options=claim_options,
)

logger.debug("Decoded id_token JWT %r; validating", claims)

claims.validate(leeway=120) # allows 2 min of clock skew

return claims

async def handle_redirect_request(
self,
request: SynapseRequest,
Expand Down Expand Up @@ -901,6 +954,9 @@ async def handle_oidc_callback(
logger.exception("Could not map user")
self._sso_handler.render_error(request, "mapping_error", str(e))

async def verified_claims_of_standalone_token(self, token: str) -> CodeIDToken:
return await self._parse_standalone_id_token(token)

async def _complete_oidc_login(
self,
userinfo: UserInfo,
Expand Down Expand Up @@ -1036,6 +1092,9 @@ def _remote_id_from_userinfo(self, userinfo: UserInfo) -> str:
# to be strings.
return str(remote_user_id)

def get_issuer(self) -> Optional[str]:
return self._config.issuer


# number of seconds a newly-generated client secret should be valid for
CLIENT_SECRET_VALIDITY_SECONDS = 3600
Expand Down
Loading