Metadata-Version: 2.5
Name: synapse-token-authenticator
Version: 0.14.1
Summary: Synapse authentication module which allows for authenticating and registering using JWTs
Project-URL: Documentation, https://github.com/famedly/synapse-token-authenticator
Project-URL: Issues, https://github.com/famedly/synapse-token-authenticator/issues
Project-URL: Source, https://github.com/famedly/synapse-token-authenticator
Author-email: Sorunome <mail@sorunome.de>, Amanda Graven <amanda@graven.dev>, Jan Christian Grünhage <jan.christian@gruenhage.xyz>
License-Expression: AGPL-3.0-only
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Requires-Python: >=3.11
Requires-Dist: jwcrypto
Requires-Dist: pydantic
Requires-Dist: twisted
Description-Content-Type: text/markdown

# Synapse Token Authenticator

[![PyPI - Version](https://img.shields.io/pypi/v/synapse-token-authenticator.svg)](https://pypi.org/project/synapse-token-authenticator)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/synapse-token-authenticator.svg)](https://pypi.org/project/synapse-token-authenticator)

Synapse Token Authenticator is a synapse auth provider which allows for token authentication (and optional registration) using JWTs (Json Web Tokens) and OIDC.

---

**Table of Contents**

- [Installation](#installation)
- [Configuration](#configuration)
    - [JWTConfig](#jwtconfig)
    - [OIDCConfig](#oidcconfig)
    - [OAuthConfig](#oauthconfig)
      - [JwtValidationConfig](#jwtvalidationconfig)
      - [IntrospectionValidationConfig](#introspectionvalidationconfig)
      - [NotifyOnRegistration](#notifyonregistration)
    - [ePAConfig](#epaconfig)
    - [ConfigTypes](#types)
      - [Path](#path)
      - [PathList](#pathlist)
    - [HttpAuth](#httpauth)
      - [BasicAuth](#basicauth)
      - [BearerAuth](#bearerauth)
    - [Validator](#validator)
      - [Exist](#exist)
      - [Not](#not)
      - [Equal](#equal)
      - [MatchesRegex](#matchesregex)
      - [AnyOf](#anyof)
      - [AllOf](#allof)
      - [In](#in)
      - [ListAllOf](#listallof)
      - [ListAnyOf](#listanyof)
- [Usage](#usage)
    - [JWT Authentication](#jwt-authentication)
    - [OIDC Authentication](#oidc-authentication)
    - [ePA Authentication](#epa-authentication)
- [Testing](#testing)
- [Releasing](#releasing)
- [License](#license)

## Installation

```console
pip install synapse-token-authenticator
```

## Configuration

Here are the available configuration options:

```yaml
jwt:
  # provide only one of secret, keyfile
  secret: symetrical secret
  keyfile: path to asymetrical keyfile

  # Algorithm of the tokens, defaults to HS512 (optional)
  algorithm: HS512
  # Allow registration of new users, defaults to false (optional)
  allow_registration: false
  # Require tokens to have an expiry set, defaults to true (optional)
  require_expiry: true
oidc:
  # Include trailing slash
  issuer: "https://idp.example.com/"
  client_id: "<IDP client id>"
  client_secret: "<IDP client secret>"
  # Zitadel Organization ID, used for masking. (Optional)
  organization_id: "1234"
  # Zitadel Project ID, used for validating the audience of the returned token.
  project_id: "5678"
  # Limits access to specified clients. Allows any client if not set (optional)
  allowed_client_ids: ['2897827328738@project_name']
  # Allow registration of new users, defaults to false (optional)
  allow_registration: false
epa:
  # see ePaConfig section
oauth:
  # see OAuthConfig section
```

### JWTConfig

| Parameter            | Type                          |
| -------------------- | ----------------------------- |
| `secret`             | String (optional)             |
| `keyfile`            | String (optional)             |
| `algorithm`          | String (defaults to `HS512` ) |
| `allow_registration` | Bool (defaults to `false`)    |
| `require_expiry`     | Bool (defaults to `true`)     |

**Requirements**

- Either `secret` or `keyfile` must be specified.
- `algorithm` should be one of the followings:
  HS256,
  HS384,
  HS512,
  RS256,
  RS384,
  RS512,
  ES256,
  ES384,
  ES512,
  PS256,
  PS384,
  PS512,
  EdDSA

**Recommendations**

- Leave `require_expiry` set to `true` (default). Requiring JWT to have an expiration could lower the security risk with compromised token.
- If you only want to be able to log in *existing* users, leave `allow_registration` at `false` (default). Set it to `true` if a new user can register simply by logging in.

### OIDCConfig

| Parameter            | Type                                                     |
| -------------------- | -------------------------------------------------------- |
| `issuer`             | String                                                   |
| `client_id`          | String                                                   |
| `client_secret`      | String                                                   |
| `project_id`         | String or Integer                                        |
| `organization_id`    | String or Integer                                        |
| `allowed_client_ids` | A list of strings or a space separated string (optional) |
| `allow_registration` | Bool (defaults to `false`)                               |

`project_id` and `organization_id` accept both String and Integer types. Integer will be automatically converted into String.

`allowed_client_ids` accepts both a list or a space-separated string. A space-separated string will be converted into a list.

### OAuthConfig

| Parameter                  | Type                                                                         |
| -------------------------- | ---------------------------------------------------------------------------- |
| `jwt_validation`           | [`JwtValidationConfig`](#jwtvalidationconfig) (optional)                     |
| `introspection_validation` | [`IntrospectionValidationConfig`](#introspectionvalidationconfig) (optional) |
| `username_type`            | One of `'fq_uid'`, `'localpart'`, `'user_id'` (optional)                     |
| `notify_on_registration`   | [`NotifyOnRegistration`](#notifyonregistration) (optional)                   |
| `expose_metadata_resource` | Object with required `name` (String) (optional)                              |
| `registration_enabled`     | Bool (defaults to `false`)                                                   |
| `check_external_id`        | Bool (defaults to `true`)                                                    |

`jwt_validation` and `introspection_validation` contain several `*_path` optional fields. Each of these, if specified, will be used to source either localpart, fully qualified user id, admin permission, or email address from jwt claims and introspection response. The values will be compared for equality. If they differ, authentication will fail.

**WARNING**: It is possible to configure in such a way that authentication would always fail. If neither `localpart_path` nor `fq_uid_path` are specified in any config section, then no user id data can be sourced for validation leading to failure.
If `username_type` is `null`, but either `localpart_path` or `fq_uid_path` is provided, the authentication process can continue. However, if either `jwt_validation` and/or `introspection_validation` have `alternative_fq_uids_path` set then use of `localpart_path` or `fq_uid_path` in the other section is forbidden. `username_type` will be ignored if `alternative_fq_uids_path` is declared as only fully qualified user ids are currently supported.

If `notify_on_registration` is set then `notify_on_registration.url` will be called when a new user is registered with this body:

```json
{
    "localpart": "alice",
    "fully_qualified_uid": "@alice:example.test",
    "displayname": "Alice",
},
```

`username_type` specifies the role of `identifier.user`:

- `'fq_uid'` — must be fully qualified username, e.g. `@alice:example.test`
- `'localpart'` — must be localpart, e.g. `alice`
- `'user_id'` — could be localpart or fully qualified username
- `null` — the username is ignored, it will be source from the token or introspection response

**Requirements**

- At least one of `jwt_validation` or `introspection_validation` must be defined.
- `expose_metadata_resource` must be an object with `name` field. The object will be exposed at `/_famedly/login/{expose_metadata_resource.name}`.

#### JwtValidationConfig

[RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)


| Parameter                  | Type                                                                                                                                           |
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
| `validator`                | [`Validator`](#validator) (defaults to [`Exist`](#exist))                                                                                      |
| `require_expiry`           | Bool (defaults to `false`)                                                                                                                     |
| `localpart_path`           | [`Path`](#path) (optional)                                                                                                                     |
| `fq_uid_path`              | [`Path`](#path) (optional)                                                                                                                     |
| `alternative_fq_uids_path` | [`Path`](#path) (optional)                                                                                                                     |
| `displayname_path`         | [`Path`](#path) (optional)                                                                                                                     |
| `admin_path`               | [`PathList`](#pathlist) (optional)                                                                                                             |
| `email_path`               | [`Path`](#path) (optional)                                                                                                                     |
| `required_scopes`          | Space separated string or a list of strings (optional)                                                                                         |
| `jwk_set`                  | [JWKSet](https://datatracker.ietf.org/doc/html/rfc7517#section-5) or [JWK](https://datatracker.ietf.org/doc/html/rfc7517#section-4) (optional) |
| `jwk_file`                 | String (optional)                                                                                                                              |
| `jwks_endpoint`            | String (optional)                                                                                                                              |

**Requirements**

- Exactly one of `jwk_set`, `jwk_file`, or `jwks_endpoint` must be specified. They are mutually exclusive and configuring more than one will result in a configuration error.
- If using `alternative_fq_uids_path`, then omit `localpart_path` and `fq_uid_path`. This or those, not both.

#### IntrospectionValidationConfig

[RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)

| Parameter                  | Type                                                      |
|----------------------------|-----------------------------------------------------------|
| `endpoint`                 | String                                                    |
| `validator`                | [`Validator`](#validator) (defaults to [`Exist`](#exist)) |
| `auth`                     | [`HttpAuth`](#httpauth) (optional)                        |
| `localpart_path`           | [`Path`](#path) (optional)                                |
| `fq_uid_path`              | [`Path`](#path) (optional)                                |
| `alternative_fq_uids_path` | [`Path`](#path) (optional)                                |
| `displayname_path`         | [`Path`](#path) (optional)                                |
| `admin_path`               | [`PathList`](#pathlist) (optional)                        |
| `email_path`               | [`Path`](#path) (optional)                                |
| `required_scopes`          | Space separated string or a list of strings (optional)    |
**Requirements**

- If using `alternative_fq_uids_path`, then omit `localpart_path` and `fq_uid_path`. This or those, not both.

**Notes**

Keep in mind, that default validator will always pass. According to the [spec](https://datatracker.ietf.org/doc/html/rfc7662), you probably want at least

```yaml
type: in
path: 'active'
validator:
  type: equal
  value: true
```

or

```yaml
['in', 'active', ['equal', true]]
```

#### NotifyOnRegistration

| Parameter            | Type                               |
| -------------------- | ---------------------------------- |
| `url`                | String                             |
| `auth`               | [`HttpAuth`](#HttpAuth) (optional) |
| `interrupt_on_error` | Bool (defaults to `true`)          |

### ePaConfig

| Parameter                  | Type                                                                                                                                           |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `iss`                      | String                                                                                                                                         |
| `resource_id`              | String                                                                                                                                         |
| `validator`                | [`Validator`](#validator) (defaults to [`Exist`](#exist))                                                                                      |
| `expose_metadata_resource` | Any (optional)                                                                                                                                 |
| `registration_enabled`     | Bool (defaults to `false`)                                                                                                                     |
| `enc_jwk`                  | [JWK](https://datatracker.ietf.org/doc/html/rfc7517#section-4) (optional)                                                                      |
| `enc_jwk_file`             | String (optional)                                                                                                                              |
| `enc_jwks_endpoint`        | String (optional) (defaults to `/.well-known/jwks.json`)                                                                                       |
| `jwk_set`                  | [JWKSet](https://datatracker.ietf.org/doc/html/rfc7517#section-5) or [JWK](https://datatracker.ietf.org/doc/html/rfc7517#section-4) (optional) |
| `jwk_file`                 | String (optional)                                                                                                                              |
| `jwks_endpoint`            | String (optional)                                                                                                                              |
| `localpart_path`           | [`Path`](#path) (optional)                                                                                                                     |
| `displayname_path`         | [`Path`](#path) (optional)                                                                                                                     |
| `lowercase_localpart`      | Bool (defaults to `false`)                                                                                                                     |

- `iss` is the expected issuer of the token and this will be checked against the claim `iss` of the token.
- `resource_id` is an id for the synapse token authenticator. The same id must be present on the claim `aud` of the received token.
- `enc_jwks_endpoint` is the endpoint where the synapse token authenticator will publish the public keys for encrypting the JWEs. The full path of the endpoint will be `https://<homeserver>/<enc_jwks_endpoint>`. This endpoint will contain only a [JWKSet](https://datatracker.ietf.org/doc/html/rfc7517#section-5) in json format and the JWKSet will have only one key in it.
- If `lowercase_localpart` is set to `true` the flow will transform all localparts to lowercase

**Requirements**

- Exactly one of `enc_jwk` or `enc_jwk_file` must be specified. They are mutually exclusive and configuring more than one will result in a configuration error.
- Exactly one of `jwk_set`, `jwk_file`, or `jwks_endpoint` must be specified. They are mutually exclusive and configuring more than one will result in a configuration error.

## Types

### Path

A path is either a string or a list of strings. A path is used to get a value inside a nested dictionary/object.

### PathList

A path is either a string, a list of strings or a list of a list of strings. A pathlist is used to get a value inside a nested dictionary/object. If it's a string or a list of string it will behave just like a Path. If it's a list of lists it will handle every list as a Path and return the value gotten by the first Path that gets a not `None` value. For example, given the PathList `[["a", "b"], ["c", "d"]]` and the dictionary `{"a":{"e": 1}, "c":{"d":2}}`, the PathList will get the value 2.

#### Examples

- `'foo'` is an existing path in `{'foo': 3}`, resulting in value `3`
- `['foo']` is an existing path in `{'foo': 3}`, resulting in value `3`
- `['foo', 'bar']` is an existing path in `{'foo': {'bar': 3}}`, resulting in value `3`

### HttpAuth

| Parameter | Type                    |
| --------- | ----------------------- |
| `type`    | `'basic'` \| `'bearer'` |

Possible authentication options: [`BasicAuth`](#BasicAuth), [`BearerAuth`](#BearerAuth).

When no authentication needed, do not use the `auth` field.

#### BasicAuth

| Parameter  | Type   |
| ---------- | ------ |
| `username` | String |
| `password` | String |

#### BearerAuth

| Parameter | Type   |
| --------- | ------ |
| `token`   | String |

### Validator

A validator is any of these types:
    [`Exist`](#Exist),
    [`Not`](#Not),
    [`Equal`](#Equal),
    [`MatchesRegex`](#MatchesRegex),
    [`AnyOf`](#AnyOf),
    [`AllOf`](#AllOf),
    [`In`](#In),
    [`ListAnyOf`](#ListAnyOf),
    [`ListAllOf`](#ListAllOf)

Each validator has `type` field

#### Exist

Validator that always returns true.

##### Examples

```yaml
{'type': 'exist'}
```

or

```yaml
['exist']
```

#### Not

Validator that inverses the result of the inner validator.

| Parameter   | Type                      |
| ----------- | ------------------------- |
| `validator` | [`Validator`](#Validator) |

##### Examples

```yaml
{'type': 'not', 'validator': 'exist'}
```

or

```yaml
['not', 'exist']
```

#### Equal

Validator that checks for equality with the specified constant.

| Parameter | Type  |
| --------- | ----- |
| `value`   | `Any` |

##### Examples

```yaml
{'type': 'equal', 'value': 3}
```

or

```yaml
['equal', 3]
```

#### MatchesRegex

Validator that checks if a value is a string and matches the specified regex.


| Parameter                                  | Type   | Description                 |
| ------------------------------------------ | ------ | --------------------------- |
| `regex`                                    | `str`  | Python regex syntax         |
| `full_match` (optional, `true` by default) | `bool` | Full match or partial match |


##### Examples

```yaml
{'type': 'regex', 'regex': 'hello.'}
```

or

```yaml
['regex', 'hello.', false]
```

#### AnyOf

Validator that checks if **any** of the inner validators pass.


| Parameter    | Type                              |
| ------------ | --------------------------------- |
| `validators` | List of [`Validator`](#Validator) |


##### Examples

```yaml
type: any_of
validators:
  - ['in', 'foo', ['equal', 3]]
  - ['in', 'bar' ['exist']]
```

or

```yaml
['any_of', [['in', 'bar' ['exist']], ['in', 'foo', ['equal', 3]]]]
```

#### AllOf

Validator that checks if **all** of the inner validators pass.


| Parameter    | Type                              |
| ------------ | --------------------------------- |
| `validators` | List of [`Validator`](#Validator) |


##### Examples

```yaml
type: all_of
validators:
  - ['exist']
  - ['in', 'foo', ['equal', 3]]
```

or

```yaml
['all_of', [['exist'], ['in', 'foo', ['equal', 3]]]]
```

#### In

Validator that modifies the context for the inner validator, *going inside* a dict key.
If the validated object is not a dict, or doesn't have specified `path`, validation fails.


| Parameter   | Type                                                                |
| ----------- | ------------------------------------------------------------------- |
| `path`      | [`Path`](#Path)                                                     |
| `validator` | [`Validator`](#Validator) (optional, defaults to [`Exist`](#Exist)) |


##### Examples

```yaml
['in', ['foo', 'bar'], ['equal', 3]]
```

#### ListAllOf

Validator that checks if the value is a list and **all** of its elements satisfy the specified validator.


| Parameter   | Type                      |
| ----------- | ------------------------- |
| `validator` | [`Validator`](#Validator) |


##### Examples

```yaml
type: list_all_of
validator:
  type: regex
  regex: 'ab..'
```

or

```yaml
['list_all_of', ['regex', 'ab..']]
```

#### ListAnyOf

Validator that checks if the value is a list and if **any** of its elements satisfy the specified validator.


| Parameter   | Type                      |
| ----------- | ------------------------- |
| `validator` | [`Validator`](#Validator) |


##### Examples

```yaml
type: list_all_of
validator:
  type: equal
  value: 3
```

or

```yaml
['list_any_of', ['equal', 3]]
```

## Usage

### JWT Authentication

First you have to generate a JWT with the correct claims. The `sub` claim is the localpart or full mxid of the user you want to log in as. Be sure that the algorithm and secret match those of the configuration. An example of the claims is as follows:

```json
{
  "sub": "alice",
  "exp": 1516239022
}
```

Next you need to post this token to the `/login` endpoint of synapse. Be sure that the `type` is `com.famedly.login.token` and that `identifier.user` is, again, either the localpart or the full mxid. For example the post body could look as following:

```json
{
  "type": "com.famedly.login.token",
  "identifier": {
    "type": "m.id.user",
    "user": "alice"
  },
  "token": "<jwt here>"
}
```

### OIDC Authentication

First, the user needs to obtain an Access token and an ID token from the IDP:

```http
POST https://idp.example.org/oauth/v2/token

```

Next, the client needs to use these tokens and construct a payload to the login endpoint:

```jsonc
{
  "type": "com.famedly.login.token.oidc",
  "identifier": {
    "type": "m.id.user",
    "user": "alice" // The user's localpart, extracted from the localpart in the ID token returned by the IDP
  },
  "token": "<opaque access here>" // The access token returned by the IDP
}
```

### ePa Authentication

First the user needs to obtain an access token from the idp. This token need to be signed and later encrypted ([JWE](https://datatracker.ietf.org/doc/html/rfc7516)).

Next, the client needs to use these tokens and construct a payload to the login endpoint:

```jsonc
{
  "type": "com.famedly.login.token.epa",
  "identifier": {
    "type": "m.id.user",
    "user": "NOT_USED" // The user field will be ignored by this flow
  },
  "token": "<jwe here>" // The access token returned by the IDP
}
```

For this flow the `user` field will be ignored.

## Testing

To create virtual development env and install dependencies:

```console
hatch shell
```

The tests use pytest, with the development environment managed by hatch. Running the tests
can be done like this:

```console
hatch test
```

#### Additional optional testing arguments

Run the tests in parallel: `-p`

Collect coverage data(automatically output as `lcov.info`): `-c`

#### Running a specific test

Selecting a specific test to run can be as easy as providing the path to the test. All tests start from
the base test directory, `tests`. If running all tests, this can be left out. For specific tests, see 
the [pytest usage docs](https://docs.pytest.org/en/stable/how-to/usage.html#specifying-which-tests-to-run) for more information

## Code Quality

Use `hatch fmt` to automatically format code, enforce style rules, and check types using:

- `black` and `isort` for formatting
- `ruff` for linting
- `mypy` for static type checking

### Check Code Without Modifying It

To check code quality without modifying files:

- Check formatting with `isort` and `black`:
  ```console
  hatch fmt --check -f
  ```
- Check types and linting with `mypy` and `ruff`:
  ```console
  hatch fmt --check -l
  ```
- Check all of above, formatting, linting, and typing:
  ```console
  hatch fmt --check
  ```

### Auto-formatting Code

To automatically fix issues in the code:

- Format only using `black` and `isort`:
  ```console
  hatch fmt -f
  ```
- Type checks(`mypy`) and lint, fixing autofixable `ruff` issues:
  ```console
  hatch fmt -l
  ```
- Run all tools, format, lint, type-check:
  ```console
  hatch fmt
  ```

## Releasing

After tagging a new version, manually create a Github release based on the tag. This will publish the package on PyPI.

## License

`synapse-token-authenticator` is distributed under the terms of the
[AGPL-3.0](https://spdx.org/licenses/AGPL-3.0-only.html) license.
