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

Add JWT documentation and improve sample config #7776

Merged
merged 8 commits into from
Jul 6, 2020
Merged
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/7776.doc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve the documentation of the non-standard JSON web token login type.
90 changes: 90 additions & 0 deletions docs/jwt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# JWT Login Type

Synapse comes with a non-standard login type to support
[JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token). In general the
documentation for
[the login endpoint](https://matrix.org/docs/spec/client_server/r0.6.1#login)
is still valid (and the mechanism works similarly to the
[token based login](https://matrix.org/docs/spec/client_server/r0.6.1#token-based)).

To log in using a JSON Web Token, clients should submit a `/login` request as
follows:

```json
{
"type": "org.matrix.login.jwt",
"token": "<jwt>"
}
```

Note that the login type of `m.login.jwt` is supported, but is deprecated. This
will be removed in a future version of Synapse.

The `jwt` should encode the local part of the user ID as the standard `sub`
claim. In the case that the token is not valid, the homeserver must respond with
`401 Unauthorized` and an error code of `M_UNAUTHORIZED`.
Copy link
Member

Choose a reason for hiding this comment

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

I am somewhat tempted to cheekily change this to a 400. If we ever move login to be more UIA like we won't be able to use 401

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah so similar to my comment:

I think we should probably update the code to be the same as this, but I wanted to keep this PR to documentation only.

Doing this likely makes sense, but the first step was to document what is going on.


(Note that this differs from the token based logins which return a
`403 Forbidden` and an error code of `M_FORBIDDEN` if an error occurs.)

As with other login types, there are additional fields (e.g. `device_id` and
`initial_device_display_name`) which can be included in the above request.

## 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 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.

1. Configure Synapse with JWT logins:

```yaml
jwt_config:
enabled: true
secret: "my-secret-token"
algorithm: "HS256"
Comment on lines +63 to +64
Copy link
Member Author

Choose a reason for hiding this comment

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

I should note also that I did test this using a private key / public key pair, but for testing using the shared secret is much simpler.

```
2. Generate a JSON web token:

```bash
$ pyjwt --key=my-secret-token --alg=HS256 encode sub=test-user
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIifQ.Ag71GT8v01UO3w80aqRPTeuVPBIBZkYhNTJJ-_-zQIc
```
3. Query for the login types and ensure `org.matrix.login.jwt` is there:

```bash
curl http://localhost:8080/_matrix/client/r0/login
```
4. Login used the generated JSON web token from above:

```bash
$ curl http://localhost:8082/_matrix/client/r0/login -X POST \
--data '{"type":"org.matrix.login.jwt","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXIifQ.Ag71GT8v01UO3w80aqRPTeuVPBIBZkYhNTJJ-_-zQIc"}'
{
"access_token": "<access token>",
"device_id": "ACBDEFGHI",
"home_server": "localhost:8080",
"user_id": "@test-user:localhost:8480"
}
```

You should now be able to use the returned access token to query the client API.
35 changes: 31 additions & 4 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1804,12 +1804,39 @@ sso:
#template_dir: "res/templates"


# The JWT needs to contain a globally unique "sub" (subject) claim.
# JSON web token integration. The following settings can be used to make
# Synapse JSON web tokens for authentication, instead of its internal
# password database.
#
# Each JSON Web Token needs to contain a "sub" (subject) claim, which is
# used as the localpart of the mxid.
#
# Note that this is a non-standard login type and client support is
# expected to be non-existant.
#
# See https://github.com/matrix-org/synapse/blob/master/docs/jwt.md.
#
#jwt_config:
# enabled: true
# secret: "a secret"
# algorithm: "HS256"
# Uncomment the following to enable authorization using JSON web
# tokens. Defaults to false.
#
#enabled: true

# 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 true.
#
#secret: "provided-by-your-issuer"

# 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 true.
#
#algorithm: "provided-by-your-issuer"
clokep marked this conversation as resolved.
Show resolved Hide resolved


password_config:
Expand Down
35 changes: 31 additions & 4 deletions synapse/config/jwt_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,37 @@ def read_config(self, config, **kwargs):

def generate_config_section(self, **kwargs):
return """\
# The JWT needs to contain a globally unique "sub" (subject) claim.
# JSON web token integration. The following settings can be used to make
# Synapse JSON web tokens for authentication, instead of its internal
# password database.
#
# Each JSON Web Token needs to contain a "sub" (subject) claim, which is
# used as the localpart of the mxid.
Copy link
Member

Choose a reason for hiding this comment

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

There's no guarantee the sub will meet mxid character restrictions. How do we map under this scenario?

Copy link
Member Author

Choose a reason for hiding this comment

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

Currently it is assumed that it meets the restrictions.

Copy link
Member Author

Choose a reason for hiding this comment

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

I looked at this again since I wasn't 100% sure this was true and the string just gets jammed into one of our UserID objects:

user = payload.get("sub", None)
if user is None:
raise LoginError(401, "Invalid JWT", errcode=Codes.UNAUTHORIZED)
user_id = UserID(user, self.hs.hostname).to_string()

So yeah, this is essentially completely unchecked. 😢

#
# Note that this is a non-standard login type and client support is
# expected to be non-existant.
#
# See https://github.com/matrix-org/synapse/blob/master/docs/jwt.md.
#
#jwt_config:
# enabled: true
# secret: "a secret"
# algorithm: "HS256"
# Uncomment the following to enable authorization using JSON web
# tokens. Defaults to false.
#
#enabled: true

# 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 true.
#
#secret: "provided-by-your-issuer"

# 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 true.
#
#algorithm: "provided-by-your-issuer"
"""
48 changes: 27 additions & 21 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
# limitations under the License.

import logging
from typing import Awaitable, Callable, Dict, Optional

from synapse.api.errors import Codes, LoginError, SynapseError
from synapse.api.ratelimiting import Ratelimiter
Expand All @@ -26,7 +27,7 @@
from synapse.http.site import SynapseRequest
from synapse.rest.client.v2_alpha._base import client_patterns
from synapse.rest.well_known import WellKnownBuilder
from synapse.types import UserID
from synapse.types import JsonDict, UserID
from synapse.util.msisdn import phone_number_to_msisdn

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -113,7 +114,7 @@ def __init__(self, hs):
burst_count=self.hs.config.rc_login_failed_attempts.burst_count,
)

def on_GET(self, request):
def on_GET(self, request: SynapseRequest):
flows = []
if self.jwt_enabled:
flows.append({"type": LoginRestServlet.JWT_TYPE})
Expand Down Expand Up @@ -141,10 +142,10 @@ def on_GET(self, request):

return 200, {"flows": flows}

def on_OPTIONS(self, request):
def on_OPTIONS(self, request: SynapseRequest):
return 200, {}

async def on_POST(self, request):
async def on_POST(self, request: SynapseRequest):
self._address_ratelimiter.ratelimit(request.getClientIP())

login_submission = parse_json_object_from_request(request)
Expand All @@ -153,9 +154,9 @@ async def on_POST(self, request):
login_submission["type"] == LoginRestServlet.JWT_TYPE
or login_submission["type"] == LoginRestServlet.JWT_TYPE_DEPRECATED
):
result = await self.do_jwt_login(login_submission)
result = await self._do_jwt_login(login_submission)
elif login_submission["type"] == LoginRestServlet.TOKEN_TYPE:
result = await self.do_token_login(login_submission)
result = await self._do_token_login(login_submission)
else:
result = await self._do_other_login(login_submission)
except KeyError:
Expand All @@ -166,14 +167,14 @@ async def on_POST(self, request):
result["well_known"] = well_known_data
return 200, result

async def _do_other_login(self, login_submission):
async def _do_other_login(self, login_submission: JsonDict) -> Dict[str, str]:
"""Handle non-token/saml/jwt logins

Args:
login_submission:

Returns:
dict: HTTP response
HTTP response
"""
# Log the request we got, but only certain fields to minimise the chance of
# logging someone's password (even if they accidentally put it in the wrong
Expand Down Expand Up @@ -288,25 +289,30 @@ async def _do_other_login(self, login_submission):
return result

async def _complete_login(
self, user_id, login_submission, callback=None, create_non_existent_users=False
):
self,
user_id: str,
login_submission: JsonDict,
callback: Optional[
Callable[[Dict[str, str]], Awaitable[Dict[str, str]]]
] = None,
create_non_existent_users: bool = False,
) -> Dict[str, str]:
"""Called when we've successfully authed the user and now need to
actually login them in (e.g. create devices). This gets called on
all succesful logins.
all successful logins.

Applies the ratelimiting for succesful login attempts against an
Applies the ratelimiting for successful login attempts against an
account.

Args:
user_id (str): ID of the user to register.
login_submission (dict): Dictionary of login information.
callback (func|None): Callback function to run after registration.
create_non_existent_users (bool): Whether to create the user if
they don't exist. Defaults to False.
user_id: ID of the user to register.
login_submission: Dictionary of login information.
callback: Callback function to run after registration.
create_non_existent_users: Whether to create the user if they don't
exist. Defaults to False.

Returns:
result (Dict[str,str]): Dictionary of account information after
successful registration.
result: Dictionary of account information after successful registration.
"""

# Before we actually log them in we check if they've already logged in
Expand Down Expand Up @@ -340,7 +346,7 @@ async def _complete_login(

return result

async def do_token_login(self, login_submission):
async def _do_token_login(self, login_submission: JsonDict) -> Dict[str, str]:
token = login_submission["token"]
auth_handler = self.auth_handler
user_id = await auth_handler.validate_short_term_login_token_and_get_user_id(
Expand All @@ -350,7 +356,7 @@ async def do_token_login(self, login_submission):
result = await self._complete_login(user_id, login_submission)
return result

async def do_jwt_login(self, login_submission):
async def _do_jwt_login(self, login_submission: JsonDict) -> Dict[str, str]:
token = login_submission.get("token", None)
if token is None:
raise LoginError(
Expand Down