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

Replace pyjwt with authlib in org.matrix.login.jwt #13011

Merged
Merged
Show file tree
Hide file tree
Changes from 8 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/13011.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Replaced usage of PyJWT with methods from Authlib in `org.matrix.login.jwt`. Contributed by Hannes Lerchl.
25 changes: 8 additions & 17 deletions docs/jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,28 +36,15 @@ As with other login types, there are additional fields (e.g. `device_id` and

## Preparing Synapse

The JSON Web Token integration in Synapse uses the
[`PyJWT`](https://pypi.org/project/pyjwt/) library, which must be installed
as follows:

* The relevant libraries are included in the Docker images and Debian packages
provided by `matrix.org` so no further action is needed.

* If you installed Synapse into a virtualenv, run `/path/to/env/bin/pip
install synapse[pyjwt]` to install the necessary dependencies.

* For other installation mechanisms, see the documentation provided by the
maintainer.

To enable the JSON web token integration, you should then add an `jwt_config` section
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
To enable the JSON web token integration, you should add a `jwt_config` section
to your configuration file (or uncomment the `enabled: true` line in the
existing section). See [sample_config.yaml](./sample_config.yaml) for some
sample settings.

## How to test JWT as a developer

Although JSON Web Tokens are typically generated from an external server, the
examples below use [PyJWT](https://pyjwt.readthedocs.io/en/latest/) directly.
example below uses a locally generated JWT.

1. Configure Synapse with JWT logins, note that this example uses a pre-shared
secret and an algorithm of HS256:
Expand All @@ -70,9 +57,13 @@ examples below use [PyJWT](https://pyjwt.readthedocs.io/en/latest/) directly.
```
2. Generate a JSON web token:

There's a small script for doing so locally:
`scripts-dev/build_custom_jwt.py`. Have a look inside and set key/secret
and the algorithm to be used (`HS256` or `RS256`) as well as the payload

```bash
$ pyjwt --key=my-secret-token --alg=HS256 encode sub=test-user
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIifQ.Ag71GT8v01UO3w80aqRPTeuVPBIBZkYhNTJJ-_-zQIc
$ poetry run scripts-dev/build_custom_jwt.py
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsImF1ZCI6WyJhdWRpZW5jZSJdfQ.fRrThuWvok5_gOYKyiIVtKTqZuFhYffiiBLTsIIZPwD-cqwICcSNkLtdhfzfau2Yje48XUiqh19VqP17MnnjGbjBTlotyHonXeXRtIKi5nK1DdKoibUkY8ILeXcDfhHe_lCItzjVtmZm7t4ePe6861Y3TQnbCgM2PBQszYOh1KU
```
3. Query for the login types and ensure `org.matrix.login.jwt` is there:

Expand Down
2 changes: 1 addition & 1 deletion docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2184,7 +2184,7 @@ sso:
# The algorithm used to sign the JSON web token.
#
# Supported algorithms are listed at
# https://pyjwt.readthedocs.io/en/latest/algorithms.html
# https://docs.authlib.org/en/latest/specs/rfc7518.html
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
#
# Required if 'enabled' is true.
#
Expand Down
2 changes: 1 addition & 1 deletion docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -2947,7 +2947,7 @@ Additional sub-options for this setting include:
* `secret`: This is either the private shared secret or the public key used to
decode the contents of the JSON web token. Required if `enabled` is set to true.
* `algorithm`: The algorithm used to sign the JSON web token. Supported algorithms are listed at
https://pyjwt.readthedocs.io/en/latest/algorithms.html Required if `enabled` is set to true.
https://docs.authlib.org/en/latest/specs/rfc7518.html Required if `enabled` is set to true.
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
* `subject_claim`: Name of the claim containing a unique identifier for the user.
Optional, defaults to `sub`.
* `issuer`: The issuer to validate the "iss" claim against. Optional. If provided the
Expand Down
7 changes: 3 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ lxml = { version = ">=4.2.0", optional = true }
sentry-sdk = { version = ">=0.7.2", optional = true }
opentracing = { version = ">=2.2.0", optional = true }
jaeger-client = { version = ">=4.0.0", optional = true }
pyjwt = { version = ">=1.6.4", optional = true }
txredisapi = { version = ">=1.4.7", optional = true }
hiredis = { version = "*", optional = true }
Pympler = { version = "*", optional = true }
Expand All @@ -196,7 +195,6 @@ systemd = ["systemd-python"]
url_preview = ["lxml"]
sentry = ["sentry-sdk"]
opentracing = ["jaeger-client", "opentracing"]
jwt = ["pyjwt"]
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
# hiredis is not a *strict* dependency, but it makes things much faster.
# (if it is not installed, we fall back to slow code.)
redis = ["txredisapi", "hiredis"]
Expand Down Expand Up @@ -230,8 +228,6 @@ all = [
"sentry-sdk",
# opentracing
"jaeger-client", "opentracing",
# jwt
"pyjwt",
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
# redis
"txredisapi", "hiredis",
# cache_memory
Expand Down
58 changes: 58 additions & 0 deletions scripts-dev/build_custom_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
from typing import Any, Dict

from authlib.jose import jwk, jwt


def create_RS256_jwt(payload: Dict[str, Any], key: str) -> str:
if key.startswith("-----BEGIN RSA PRIVATE KEY-----"):
key = jwk.dumps(key, kty="RSA")
if key.startswith("-----BEGIN PRIVATE KEY-----"):
key = jwk.dumps(key, kty="RSA")

header = {"alg": "RS256"}
result: bytes = jwt.encode(header, payload, key)
return result.decode("ascii")


def create_HS256_jwt(payload: Dict[str, Any], secret: str) -> str:
header = {"alg": "HS256"}
result: bytes = jwt.encode(header, payload, secret)
return result.decode("ascii")


def example_rsa() -> None:
payload = {"sub": "user1", "aud": ["audience"]}

key = "\n".join(
[
"-----BEGIN PRIVATE KEY-----",
"MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKZ51yIlJkxrY4U9",
"5r87tr7gmPPDEdJVo7FIxgqJzTZ2C/PnCfWz0L+vNepyotBMf4aAb8msMPq2tCLf",
"vb3SD8WJ6ZLV5VRfJz40WLA6pg6D1bQBN3SF6Nr1YistbJZmfQcwk9uSoHcE4yTj",
"bWzRWijCtbbUmvh9QwF8PFc0fZJ1AgMBAAECgYBQddTnuOLQzpp0HJ340WiayrzC",
"HAbyDNgn6E9naoDXkKhoQsNKkJUVAB7j6HIOkNqV7F+bLnEhy8o2jMMNCoj6HadX",
"i5Urj0u1bxSHEDVCAFwo83zuy77Gf3nycofd8/PwJjMQl9kQ9z35Gb8CJe0y6EB2",
"DxE8EbEkro80z4WKAQJBANtzyUvcW+Yq0nt/vKePMri0QFnwbSeRiBHLBZjpBxfd",
"KVA+KB86JZnvL7co8ngOAmPTdUvOELa1+ovlNlOY3vUCQQDCM3CYsr/xV5z1tsVr",
"gCqa3wntLBjUE4eAWM9v+vf6yjdLnVedYXp21YiyjOkQ2MuvnvAUMJKRGCIGOC3c",
"SrWBAkEA050HUtue0oggh25ZoMn5AxrtosywtSMkruOy9gxfBqgBGpuVXOdZMuLu",
"hBQ8G4CG1XQm+34tp8I7Y4MXq+0RsQJAYa4GAIhIS1hKNr1L55p705JEJ+t6QZHh",
"IgmJrUWK3bZAwePOYfbZ5lPZghWmVTb2nMtQ7pbP4fNFieNQDfH2AQJBAMdc/saT",
"lAlfA2po0IC/IpqNw2DJk/Ky7QShDJg8mp9QxoKwRy4sUPCOglcjVyE8CTaaar7E",
"ZV3OjK9+FXn8Mkw=",
"-----END PRIVATE KEY-----",
]
)

print(create_RS256_jwt(payload, key))


def example_hsa() -> None:
payload = {"sub": "user1", "aud": ["audience"]}
secret = "MyVeryPrivateSecret"
print(create_HS256_jwt(payload, secret))


if __name__ == "__main__":
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
example_rsa()
17 changes: 2 additions & 15 deletions synapse/config/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,7 @@

from synapse.types import JsonDict

from ._base import Config, ConfigError

MISSING_JWT = """Missing jwt library. This is required for jwt login.

Install by running:
pip install pyjwt
"""
from ._base import Config


class JWTConfig(Config):
Expand All @@ -41,13 +35,6 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
# that the claims exist on the JWT.
self.jwt_issuer = jwt_config.get("issuer")
self.jwt_audiences = jwt_config.get("audiences")

try:
import jwt

jwt # To stop unused lint.
except ImportError:
raise ConfigError(MISSING_JWT)
Copy link
Contributor

Choose a reason for hiding this comment

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

We want a replacement for this, to warn users who've configured JWT but haven't installed authlib.

check_dependencies("jwt") should suffice after the poetry-related changes are made. For an existing example elsewhere, see:

check_requirements("redis")

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is still outstanding.

else:
self.jwt_enabled = False
self.jwt_secret = None
Expand Down Expand Up @@ -89,7 +76,7 @@ def generate_config_section(self, **kwargs: Any) -> str:
# The algorithm used to sign the JSON web token.
#
# Supported algorithms are listed at
# https://pyjwt.readthedocs.io/en/latest/algorithms.html
# https://docs.authlib.org/en/latest/specs/rfc7518.html
#
# Required if 'enabled' is true.
#
Expand Down
45 changes: 37 additions & 8 deletions synapse/rest/client/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -420,25 +420,54 @@ async def _do_jwt_login(
403, "Token field for JWT is missing", errcode=Codes.FORBIDDEN
)

import jwt
from authlib.jose import JsonWebToken, JWTClaims
from authlib.jose.errors import BadSignatureError, InvalidClaimError, JoseError

jwt = JsonWebToken([self.jwt_algorithm])
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
claim_options = {}
if self.jwt_issuer is not None:
claim_options["iss"] = {"value": self.jwt_issuer, "essential": True}
if self.jwt_audiences is not None:
claim_options["aud"] = {"values": self.jwt_audiences, "essential": True}
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved

try:
payload = jwt.decode(
claims = jwt.decode(
token,
self.jwt_secret,
algorithms=[self.jwt_algorithm],
issuer=self.jwt_issuer,
audience=self.jwt_audiences,
key=self.jwt_secret,
claims_cls=JWTClaims,
claims_options=claim_options,
)
except BadSignatureError:
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
raise LoginError(
403,
"JWT validation failed: Signature verification failed",
errcode=Codes.FORBIDDEN,
)
except jwt.PyJWTError as e:
except Exception as e:
Copy link
Contributor

Choose a reason for hiding this comment

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

@sandhose pointed out that this is a pretty broad exception clause. Can you narrow this to a JOSE-specific exception?

Perhaps JoseError from here? https://github.com/lepture/authlib/blob/master/authlib/jose/errors.py

Copy link
Contributor

Choose a reason for hiding this comment

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

(That will mean that any other exception gets properly flagged as an application error rather than causing a 403 to the requester.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed and pushed it

# A JWT error occurred, return some info back to the client.
raise LoginError(
403,
"JWT validation failed: %s" % (str(e),),
errcode=Codes.FORBIDDEN,
)
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved

user = payload.get(self.jwt_subject_claim, None)
try:
claims.validate(leeway=120) # allows 2 min of clock skew

# Enforce the old behavior which is rolled out in productive
# servers: if the JWT contains an 'aud' claim but none is
# configured, the login attempt will fail
if claims.get("aud") is not None:
if self.jwt_audiences is None or len(self.jwt_audiences) == 0:
raise InvalidClaimError("aud")
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved
except JoseError as e:
raise LoginError(
403,
"JWT validation failed: %s" % (str(e),),
errcode=Codes.FORBIDDEN,
)
DMRobertson marked this conversation as resolved.
Show resolved Hide resolved

user = claims.get(self.jwt_subject_claim, None)
if user is None:
raise LoginError(403, "Invalid JWT", errcode=Codes.FORBIDDEN)

Expand Down
Loading