From 074cdf7d45f1400e780c12c5ddc553986453434e Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Thu, 9 Jun 2022 10:54:26 +0200 Subject: [PATCH 01/13] Replaced pyjwt with authlib --- docs/sample_config.yaml | 2 +- .../configuration/config_documentation.md | 2 +- synapse/config/jwt.py | 17 +------ synapse/rest/client/login.py | 44 +++++++++++++++---- tests/rest/client/test_login.py | 26 +++++------ 5 files changed, 52 insertions(+), 39 deletions(-) diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index 56a25c534f24..2840805d1c80 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -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 # # Required if 'enabled' is true. # diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index 392ae80a759a..3560bd9068d4 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -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. * `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 diff --git a/synapse/config/jwt.py b/synapse/config/jwt.py index 2a756d1a7cc6..5662afcf6a45 100644 --- a/synapse/config/jwt.py +++ b/synapse/config/jwt.py @@ -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): @@ -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) else: self.jwt_enabled = False self.jwt_secret = None @@ -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. # diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py index cf4196ac0a2b..c14fdb812ffb 100644 --- a/synapse/rest/client/login.py +++ b/synapse/rest/client/login.py @@ -26,6 +26,8 @@ Union, ) +from authlib.jose import JsonWebToken, JWTClaims +from authlib.jose.errors import BadSignatureError, InvalidClaimError, JoseError from typing_extensions import TypedDict from synapse.api.errors import Codes, LoginError, SynapseError @@ -420,17 +422,27 @@ async def _do_jwt_login( 403, "Token field for JWT is missing", errcode=Codes.FORBIDDEN ) - import jwt + jwt = JsonWebToken([self.jwt_algorithm]) + 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} 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 jwt.PyJWTError as e: + except BadSignatureError: + raise LoginError( + 403, + "JWT validation failed: Signature verification failed", + errcode=Codes.FORBIDDEN, + ) + except Exception as e: # A JWT error occurred, return some info back to the client. raise LoginError( 403, @@ -438,7 +450,23 @@ async def _do_jwt_login( errcode=Codes.FORBIDDEN, ) - 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") + except JoseError as e: + raise LoginError( + 403, + "JWT validation failed: %s" % (str(e),), + errcode=Codes.FORBIDDEN, + ) + + user = claims.get(self.jwt_subject_claim, None) if user is None: raise LoginError(403, "Invalid JWT", errcode=Codes.FORBIDDEN) diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py index f4ea1209d943..004129a27fdb 100644 --- a/tests/rest/client/test_login.py +++ b/tests/rest/client/test_login.py @@ -14,7 +14,7 @@ import json import time import urllib.parse -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional from unittest.mock import Mock from urllib.parse import urlencode @@ -41,7 +41,7 @@ from tests.unittest import HomeserverTestCase, override_config, skip_unless try: - import jwt + from authlib.jose import jwt, jwk HAS_JWT = True except ImportError: @@ -841,7 +841,7 @@ def test_deactivated_user(self) -> None: self.assertIn(b"SSO account deactivated", channel.result["body"]) -@skip_unless(HAS_JWT, "requires jwt") +@skip_unless(HAS_JWT, "requires authlib") class JWTTestCase(unittest.HomeserverTestCase): servlets = [ synapse.rest.admin.register_servlets_for_client_rest_resource, @@ -866,11 +866,9 @@ def default_config(self) -> Dict[str, Any]: return config def jwt_encode(self, payload: Dict[str, Any], secret: str = jwt_secret) -> str: - # PyJWT 2.0.0 changed the return type of jwt.encode from bytes to str. - result: Union[str, bytes] = jwt.encode(payload, secret, self.jwt_algorithm) - if isinstance(result, bytes): - return result.decode("ascii") - return result + header = {"alg": self.jwt_algorithm} + result: bytes = jwt.encode(header, payload, secret) + return result.decode("ascii") def jwt_login(self, *args: Any) -> FakeChannel: params = {"type": "org.matrix.login.jwt", "token": self.jwt_encode(*args)} @@ -1010,7 +1008,7 @@ def test_login_no_token(self) -> None: # The JWTPubKeyTestCase is a complement to JWTTestCase where we instead use # RSS256, with a public key configured in synapse as "jwt_secret", and tokens # signed by the private key. -@skip_unless(HAS_JWT, "requires jwt") +@skip_unless(HAS_JWT, "requires authlib") class JWTPubKeyTestCase(unittest.HomeserverTestCase): servlets = [ login.register_servlets, @@ -1071,11 +1069,11 @@ def default_config(self) -> Dict[str, Any]: return config def jwt_encode(self, payload: Dict[str, Any], secret: str = jwt_privatekey) -> str: - # PyJWT 2.0.0 changed the return type of jwt.encode from bytes to str. - result: Union[bytes, str] = jwt.encode(payload, secret, "RS256") - if isinstance(result, bytes): - return result.decode("ascii") - return result + header = {"alg": "RS256"} + if secret.startswith("-----BEGIN RSA PRIVATE KEY-----"): + secret = jwk.dumps(secret, kty="RSA") + result: bytes = jwt.encode(header, payload, secret) + return result.decode("ascii") def jwt_login(self, *args: Any) -> FakeChannel: params = {"type": "org.matrix.login.jwt", "token": self.jwt_encode(*args)} From 338ae350cca13846097483d811a7bbb293f41c19 Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Thu, 9 Jun 2022 15:54:54 +0200 Subject: [PATCH 02/13] Fixed unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Due to the move from PyJWT to authlib the error messages have changed. Instead of mapping messages in the code just to please the tests I changed the expĆ¼cted error messages in the unit tests. --- tests/rest/client/test_login.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py index 004129a27fdb..f6efa5fe37f5 100644 --- a/tests/rest/client/test_login.py +++ b/tests/rest/client/test_login.py @@ -41,7 +41,7 @@ from tests.unittest import HomeserverTestCase, override_config, skip_unless try: - from authlib.jose import jwt, jwk + from authlib.jose import jwk, jwt HAS_JWT = True except ImportError: @@ -900,7 +900,8 @@ def test_login_jwt_expired(self) -> None: self.assertEqual(channel.result["code"], b"403", channel.result) self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( - channel.json_body["error"], "JWT validation failed: Signature has expired" + channel.json_body["error"], + "JWT validation failed: expired_token: The token is expired", ) def test_login_jwt_not_before(self) -> None: @@ -910,7 +911,7 @@ def test_login_jwt_not_before(self) -> None: self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( channel.json_body["error"], - "JWT validation failed: The token is not yet valid (nbf)", + "JWT validation failed: invalid_token: The token is not valid yet", ) def test_login_no_sub(self) -> None: @@ -932,7 +933,8 @@ def test_login_iss(self) -> None: self.assertEqual(channel.result["code"], b"403", channel.result) self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( - channel.json_body["error"], "JWT validation failed: Invalid issuer" + channel.json_body["error"], + 'JWT validation failed: invalid_claim: Invalid claim "iss"', ) # Not providing an issuer. @@ -941,7 +943,7 @@ def test_login_iss(self) -> None: self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( channel.json_body["error"], - 'JWT validation failed: Token is missing the "iss" claim', + 'JWT validation failed: missing_claim: Missing "iss" claim', ) def test_login_iss_no_config(self) -> None: @@ -963,7 +965,8 @@ def test_login_aud(self) -> None: self.assertEqual(channel.result["code"], b"403", channel.result) self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( - channel.json_body["error"], "JWT validation failed: Invalid audience" + channel.json_body["error"], + 'JWT validation failed: invalid_claim: Invalid claim "aud"', ) # Not providing an audience. @@ -972,7 +975,7 @@ def test_login_aud(self) -> None: self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( channel.json_body["error"], - 'JWT validation failed: Token is missing the "aud" claim', + 'JWT validation failed: missing_claim: Missing "aud" claim', ) def test_login_aud_no_config(self) -> None: @@ -981,7 +984,8 @@ def test_login_aud_no_config(self) -> None: self.assertEqual(channel.result["code"], b"403", channel.result) self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") self.assertEqual( - channel.json_body["error"], "JWT validation failed: Invalid audience" + channel.json_body["error"], + 'JWT validation failed: invalid_claim: Invalid claim "aud"', ) def test_login_default_sub(self) -> None: From 13bccbb035670e4e6a261eb853407dce4f24df81 Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Thu, 9 Jun 2022 15:56:47 +0200 Subject: [PATCH 03/13] Added a helper script to create JWTs for development Seems like PyJWT provides a script to create JWTs from the command line. Authlib does not provide such a script so I added this tiny script as a replacement. It doesn't parse parameters nor writes files but since this feature is only required by developer while doing some local tests I'd say that's OK. --- scripts-dev/build_custom_jwt.py | 58 +++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100755 scripts-dev/build_custom_jwt.py diff --git a/scripts-dev/build_custom_jwt.py b/scripts-dev/build_custom_jwt.py new file mode 100755 index 000000000000..9d8deeb5c696 --- /dev/null +++ b/scripts-dev/build_custom_jwt.py @@ -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__": + example_rsa() From 3891e0ab39b1a992806c4e1ed3846cf15b25e9eb Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Thu, 9 Jun 2022 16:15:48 +0200 Subject: [PATCH 04/13] Adapted documentation to changed lib --- docs/jwt.md | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/docs/jwt.md b/docs/jwt.md index 346daf78ad1e..388224ab7af2 100644 --- a/docs/jwt.md +++ b/docs/jwt.md @@ -36,20 +36,7 @@ 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 +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. @@ -57,7 +44,7 @@ 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: @@ -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: From 5a7e7b0a056792eaac9aa7dea3893afb21f4dd2b Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Thu, 9 Jun 2022 17:06:28 +0200 Subject: [PATCH 05/13] Added changelog entry Signed-off-by: Hannes Lerchl --- changelog.d/13011.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/13011.misc diff --git a/changelog.d/13011.misc b/changelog.d/13011.misc new file mode 100644 index 000000000000..b45a7518df2a --- /dev/null +++ b/changelog.d/13011.misc @@ -0,0 +1 @@ +Replaced usage of PyJWT with methods from Authlib in `org.matrix.login.jwt`. Contributed by Hannes Lerchl From 629ef4159da7ec3e1b9c07785b210f0319805b06 Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Thu, 9 Jun 2022 23:30:23 +0200 Subject: [PATCH 06/13] Added a dot to changelog entry --- changelog.d/13011.misc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/13011.misc b/changelog.d/13011.misc index b45a7518df2a..4da223219fab 100644 --- a/changelog.d/13011.misc +++ b/changelog.d/13011.misc @@ -1 +1 @@ -Replaced usage of PyJWT with methods from Authlib in `org.matrix.login.jwt`. Contributed by Hannes Lerchl +Replaced usage of PyJWT with methods from Authlib in `org.matrix.login.jwt`. Contributed by Hannes Lerchl. From 8e78b796ae471dd9a484dff6ee32ef4027e9b5ac Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Fri, 10 Jun 2022 16:23:23 +0200 Subject: [PATCH 07/13] Moved import statements for authlib In case a server hoster doesn't use oidc or jwt there should be no need to install authlib. So for such cases the imports need to 'come later'. --- synapse/rest/client/login.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py index c14fdb812ffb..f5340a1ee8f5 100644 --- a/synapse/rest/client/login.py +++ b/synapse/rest/client/login.py @@ -26,8 +26,6 @@ Union, ) -from authlib.jose import JsonWebToken, JWTClaims -from authlib.jose.errors import BadSignatureError, InvalidClaimError, JoseError from typing_extensions import TypedDict from synapse.api.errors import Codes, LoginError, SynapseError @@ -422,6 +420,9 @@ async def _do_jwt_login( 403, "Token field for JWT is missing", errcode=Codes.FORBIDDEN ) + from authlib.jose import JsonWebToken, JWTClaims + from authlib.jose.errors import BadSignatureError, InvalidClaimError, JoseError + jwt = JsonWebToken([self.jwt_algorithm]) claim_options = {} if self.jwt_issuer is not None: From b4e56a1dd7c40e0ac5f6cf33731a9c44824cb4a9 Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Sat, 11 Jun 2022 13:13:47 +0200 Subject: [PATCH 08/13] Removed pyjwt frm pyproject.toml Updated poetry.lock file with --no-update Signed-off-by: Hannes Lerchl --- poetry.lock | 7 +++---- pyproject.toml | 4 ---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8a54a939fea0..3771b6c8a1de 100644 --- a/poetry.lock +++ b/poetry.lock @@ -815,7 +815,7 @@ python-versions = ">=3.5" name = "pyjwt" version = "2.4.0" description = "JSON Web Token implementation in Python" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" @@ -1546,9 +1546,8 @@ docs = ["sphinx", "repoze.sphinx.autointerface"] test = ["zope.i18nmessageid", "zope.testing", "zope.testrunner"] [extras] -all = ["matrix-synapse-ldap3", "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", "pysaml2", "authlib", "lxml", "sentry-sdk", "jaeger-client", "opentracing", "pyjwt", "txredisapi", "hiredis", "Pympler"] +all = ["matrix-synapse-ldap3", "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", "pysaml2", "authlib", "lxml", "sentry-sdk", "jaeger-client", "opentracing", "txredisapi", "hiredis", "Pympler"] cache_memory = ["Pympler"] -jwt = ["pyjwt"] matrix-synapse-ldap3 = ["matrix-synapse-ldap3"] oidc = ["authlib"] opentracing = ["jaeger-client", "opentracing"] @@ -1563,7 +1562,7 @@ url_preview = ["lxml"] [metadata] lock-version = "1.1" python-versions = "^3.7.1" -content-hash = "c1bb4dabba1e87517e25ca7bf778e8082fbc960a51d83819aec3a154110a374f" +content-hash = "174acaed1e13aed289dd9db8032d2a4e5f026d825291365614ea3903e5134e98" [metadata.files] attrs = [ diff --git a/pyproject.toml b/pyproject.toml index fde6a4f4242b..e098875acf8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 } @@ -196,7 +195,6 @@ systemd = ["systemd-python"] url_preview = ["lxml"] sentry = ["sentry-sdk"] opentracing = ["jaeger-client", "opentracing"] -jwt = ["pyjwt"] # 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"] @@ -230,8 +228,6 @@ all = [ "sentry-sdk", # opentracing "jaeger-client", "opentracing", - # jwt - "pyjwt", # redis "txredisapi", "hiredis", # cache_memory From a650138e42b6797b285bf126fac1d9d06ea6da27 Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Wed, 15 Jun 2022 01:00:25 +0200 Subject: [PATCH 09/13] Applied changes from review findings Review performed by DMRobertson Signed-off-by: Hannes Lerchl --- docs/jwt.md | 34 ++++++++--- docs/sample_config.yaml | 4 +- .../configuration/config_documentation.md | 6 +- poetry.lock | 3 +- pyproject.toml | 3 +- scripts-dev/build_custom_jwt.py | 58 ------------------- synapse/config/jwt.py | 4 +- 7 files changed, 41 insertions(+), 71 deletions(-) delete mode 100755 scripts-dev/build_custom_jwt.py diff --git a/docs/jwt.md b/docs/jwt.md index 388224ab7af2..8f859d59a6d9 100644 --- a/docs/jwt.md +++ b/docs/jwt.md @@ -36,7 +36,20 @@ As with other login types, there are additional fields (e.g. `device_id` and ## Preparing Synapse -To enable the JSON web token integration, you should add a `jwt_config` section +The JSON Web Token integration in Synapse uses the +[`Authlib`](https://docs.authlib.org/en/latest/index.html) 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[jwt]` 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 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. @@ -57,14 +70,21 @@ example below uses a locally generated JWT. ``` 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 + You can use the following short Python snippet to generate a JWT + protected by an HMAC. + Take care that the `secret` and the algorithm given in the `header` match + the entries from `jwt_config` above. - ```bash - $ poetry run scripts-dev/build_custom_jwt.py - eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsImF1ZCI6WyJhdWRpZW5jZSJdfQ.fRrThuWvok5_gOYKyiIVtKTqZuFhYffiiBLTsIIZPwD-cqwICcSNkLtdhfzfau2Yje48XUiqh19VqP17MnnjGbjBTlotyHonXeXRtIKi5nK1DdKoibUkY8ILeXcDfhHe_lCItzjVtmZm7t4ePe6861Y3TQnbCgM2PBQszYOh1KU + ```python + from authlib.jose import jwt + + header = {"alg": "HS256"} + payload = {"sub": "user1", "aud": ["audience"]} + secret = "my-secret-token" + result = jwt.encode(header, payload, secret) + print(result.decode("ascii")) ``` + 3. Query for the login types and ensure `org.matrix.login.jwt` is there: ```bash diff --git a/docs/sample_config.yaml b/docs/sample_config.yaml index 2840805d1c80..2f84ffba3b51 100644 --- a/docs/sample_config.yaml +++ b/docs/sample_config.yaml @@ -2184,7 +2184,9 @@ sso: # The algorithm used to sign the JSON web token. # # Supported algorithms are listed at - # https://docs.authlib.org/en/latest/specs/rfc7518.html + # https://docs.authlib.org/en/latest/specs/rfc7518.html (section JWS). + # (Although you could use an "encrypted JWT" (JWE) this doesn't make + # sense for authentication.) # # Required if 'enabled' is true. # diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index 3560bd9068d4..e88f68d2b864 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -2946,8 +2946,10 @@ Additional sub-options for this setting include: tokens. Defaults to false. * `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://docs.authlib.org/en/latest/specs/rfc7518.html Required if `enabled` is set to true. +* `algorithm`: The algorithm used to sign (or HMAC) the JSON web token. + Supported algorithms are listed + [here (section JWS)](https://docs.authlib.org/en/latest/specs/rfc7518.html). + Required if `enabled` is set to true. * `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 diff --git a/poetry.lock b/poetry.lock index 3771b6c8a1de..707dfa4bf93a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1548,6 +1548,7 @@ test = ["zope.i18nmessageid", "zope.testing", "zope.testrunner"] [extras] all = ["matrix-synapse-ldap3", "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", "pysaml2", "authlib", "lxml", "sentry-sdk", "jaeger-client", "opentracing", "txredisapi", "hiredis", "Pympler"] cache_memory = ["Pympler"] +jwt = ["authlib"] matrix-synapse-ldap3 = ["matrix-synapse-ldap3"] oidc = ["authlib"] opentracing = ["jaeger-client", "opentracing"] @@ -1562,7 +1563,7 @@ url_preview = ["lxml"] [metadata] lock-version = "1.1" python-versions = "^3.7.1" -content-hash = "174acaed1e13aed289dd9db8032d2a4e5f026d825291365614ea3903e5134e98" +content-hash = "e197d6384a1955eb6daccc319c6353a9794d8f20c31d438803511ee251c0626a" [metadata.files] attrs = [ diff --git a/pyproject.toml b/pyproject.toml index e098875acf8e..14327418e442 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,6 +195,7 @@ systemd = ["systemd-python"] url_preview = ["lxml"] sentry = ["sentry-sdk"] opentracing = ["jaeger-client", "opentracing"] +jwt = ["authlib"] # 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"] @@ -220,7 +221,7 @@ all = [ "psycopg2", "psycopg2cffi", "psycopg2cffi-compat", # saml2 "pysaml2", - # oidc + # oidc and jwt "authlib", # url_preview "lxml", diff --git a/scripts-dev/build_custom_jwt.py b/scripts-dev/build_custom_jwt.py deleted file mode 100755 index 9d8deeb5c696..000000000000 --- a/scripts-dev/build_custom_jwt.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/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__": - example_rsa() diff --git a/synapse/config/jwt.py b/synapse/config/jwt.py index 5662afcf6a45..77ab8dcb79f9 100644 --- a/synapse/config/jwt.py +++ b/synapse/config/jwt.py @@ -76,7 +76,9 @@ def generate_config_section(self, **kwargs: Any) -> str: # The algorithm used to sign the JSON web token. # # Supported algorithms are listed at - # https://docs.authlib.org/en/latest/specs/rfc7518.html + # https://docs.authlib.org/en/latest/specs/rfc7518.html (section JWS). + # (Although you could use an "encrypted JWT" (JWE) this doesn't make + # sense for authentication.) # # Required if 'enabled' is true. # From 382d02fb02fa39e4b2f66febb5c86ba6b766380b Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Wed, 15 Jun 2022 01:52:03 +0200 Subject: [PATCH 10/13] Re-added a check for installed dependencies The pyjwt based version of the jwt login flow contained a check (like this) for PyJWT. Since authlib is an optional dependency I add this check when `jwt_config` is configured. --- synapse/config/jwt.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/synapse/config/jwt.py b/synapse/config/jwt.py index 757869483d44..73b28473da16 100644 --- a/synapse/config/jwt.py +++ b/synapse/config/jwt.py @@ -18,6 +18,12 @@ from ._base import Config +MISSING_AUTHLIB = """Missing authlib library. This is required for jwt login. + + Install by running: + pip install synapse[jwt] + """ + class JWTConfig(Config): section = "jwt" @@ -35,6 +41,13 @@ 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: + from authlib.jose import JsonWebToken + + JsonWebToken # To stop unused lint. + except ImportError: + raise ConfigError(MISSING_AUTHLIB) else: self.jwt_enabled = False self.jwt_secret = None From 8aa6a11baca01765fe4d059a84bc90f42527f54a Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Wed, 15 Jun 2022 14:45:22 +0200 Subject: [PATCH 11/13] Added a comment on why there's an extra 'except' block --- synapse/rest/client/login.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py index f5340a1ee8f5..c3cbea187fe4 100644 --- a/synapse/rest/client/login.py +++ b/synapse/rest/client/login.py @@ -438,6 +438,7 @@ async def _do_jwt_login( claims_options=claim_options, ) except BadSignatureError: + # We handle this case separately to provide a better error message raise LoginError( 403, "JWT validation failed: Signature verification failed", From 395997e66b0b329457fa0c7154c44cf3b3eb998c Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Wed, 15 Jun 2022 14:59:16 +0200 Subject: [PATCH 12/13] Two more fixes --- synapse/config/jwt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/synapse/config/jwt.py b/synapse/config/jwt.py index 73b28473da16..49aaca7cf653 100644 --- a/synapse/config/jwt.py +++ b/synapse/config/jwt.py @@ -16,7 +16,7 @@ from synapse.types import JsonDict -from ._base import Config +from ._base import Config, ConfigError MISSING_AUTHLIB = """Missing authlib library. This is required for jwt login. @@ -45,7 +45,7 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: try: from authlib.jose import JsonWebToken - JsonWebToken # To stop unused lint. + JsonWebToken # To stop unused lint. except ImportError: raise ConfigError(MISSING_AUTHLIB) else: From 81c525c7bd7db42192352cd30b65432fcbfb758a Mon Sep 17 00:00:00 2001 From: Hannes Lerchl Date: Wed, 15 Jun 2022 16:55:34 +0200 Subject: [PATCH 13/13] Narrowed 'except' clause to catch only OAuth2 related errors --- synapse/rest/client/login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py index c3cbea187fe4..dd75e40f347c 100644 --- a/synapse/rest/client/login.py +++ b/synapse/rest/client/login.py @@ -444,7 +444,7 @@ async def _do_jwt_login( "JWT validation failed: Signature verification failed", errcode=Codes.FORBIDDEN, ) - except Exception as e: + except JoseError as e: # A JWT error occurred, return some info back to the client. raise LoginError( 403,