diff --git a/README.md b/README.md index d0c7c36..0bedbe3 100644 --- a/README.md +++ b/README.md @@ -5,36 +5,43 @@ 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) - * [OAuthConfig](#oauthconfig) - * [JwtValidationConfig](#jwtvalidationconfig) - * [IntrospectionValidationConfig](#introspectionvalidationconfig) - * [NotifyOnRegistration](#notifyonregistration) - * [Path](#path) - * [BasicAuth](#basicauth) - * [BearerAuth](#bearerauth) - * [HttpAuth](#httpauth) - * [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) -* [Testing](#testing) -* [Releasing](#releasing) -* [License](#license) +- [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) + - [NoAuth](#noauth) + - [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 @@ -43,7 +50,9 @@ pip install synapse-token-authenticator ``` ## Configuration + Here are the available configuration options: + ```yaml jwt: # provide only one of secret, keyfile @@ -62,9 +71,9 @@ oidc: client_id: "" client_secret: "" # Zitadel Organization ID, used for masking. (Optional) - organization_id: 1234 + organization_id: "1234" # Zitadel Project ID, used for validating the audience of the returned token. - project_id: 5678 + 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) @@ -74,9 +83,58 @@ epa: oauth: # see OAuthConfig section ``` -It is recommended to have `require_expiry` set to `true` (default). As for `allow_registration`, it depends on usecase: If you only want to be able to log in *existing* users, leave it at `false` (default). If nonexistant users should be simply registered upon hitting the login endpoint, set it to `true`. + +### 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 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) | @@ -85,16 +143,15 @@ It is recommended to have `require_expiry` set to `true` (default). As for `allo | `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`) | -At least one of `jwt_validation` or `introspection_validation` must be defined. +`jwt_validation` and `introspection_validation` contain several `*_path` optional fields. Each of these, if specified, will be used to source either localpart, user id, 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. -`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 +**WARNING**: It is possible to configure in such a way that authentication would always fail. If `username_type` is `null`, and `localpart_path` and `fq_uid_path` are also not specified, no user id data can be sourced, thus also leading to failure. +But if `username_type` is `null`, but either `localpart_path` or `fq_uid_path` is provided, the authentication process can continue. 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", @@ -103,33 +160,46 @@ If `notify_on_registration` is set then `notify_on_registration.url` will be cal }, ``` -`expose_metadata_resource` must be an object with `name` field. The object will be exposed at `/_famedly/login/{expose_metadata_resource.name}`. +`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** -`jwt_validation` and `introspection_validation` contain a bunch of `*_path` optional fields. Each of these, if specified will be used to source either localpart, user id, fully qualified user id, admin permission, or email from jwt claims and introspection response. They values are going to be compared for equality, if they differ, authentication would fail. Be careful with these, as it is possible to configure in such a way that authentication would always fail, or, if `username_type` is `null`, no user id data can be sourced, thus also leading to failure. +- 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 -### 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) | -| `user_id_path` | [`Path`](#path) (optional) | -| `fq_uid_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) | + +| Parameter | Type | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `validator` | [`Validator`](#validator) (defaults to [`Exist`](#exist)) | +| `require_expiry` | Bool (defaults to `true`) | +| `localpart_path` | [`Path`](#path) (optional) | +| `user_id_path` | [`Path`](#path) (optional) | +| `fq_uid_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) | +| `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. +- 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. + +#### IntrospectionValidationConfig -### IntrospectionValidationConfig [RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) + | Parameter | Type | | ------------------ | --------------------------------------------------------- | | `endpoint` | String | @@ -144,6 +214,7 @@ Exactly one of `jwk_set`, `jwk_file`, or `jwks_endpoint` must be specified. They | `required_scopes` | Space separated string or a list of strings (optional) | 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' @@ -151,49 +222,93 @@ validator: type: equal value: true ``` + or + ```yaml ['in', 'active', ['equal', true]] ``` -### NotifyOnRegistration: +#### 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:///`. 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` -### BasicAuth +### HttpAuth + +| Parameter | Type | +| --------- | ----------------------- | +| `type` | `'basic'` \| `'bearer'` | + +Possible authentication options: [`NoAuth`](#NoAuth), [`BasicAuth`](#BasicAuth), [`BearerAuth`](#BearerAuth). + +#### NoAuth + +When no authentication needed, just use empty dictionary {} + +#### BasicAuth + | Parameter | Type | | ---------- | ------ | | `username` | String | | `password` | String | -### BearerAuth +#### BearerAuth + | Parameter | Type | | --------- | ------ | | `token` | String | -### HttpAuth -Authentication options, always optional -| Parameter | Type | -| --------- | ----------------------- | -| `type` | `'basic'` \| `'bearer'` | - -Possible options: [`BasicAuth`](#BasicAuth), [`BearerAuth`](#BearerAuth), - ### Validator + A validator is any of these types: [`Exist`](#Exist), [`Not`](#Not), @@ -207,68 +322,87 @@ A validator is any of these types: Each validator has `type` field -### Exist +#### Exist + Validator that always returns true. -#### Examples +##### Examples + ```yaml {'type': 'exist'} ``` + or + ```yaml ['exist'] ``` -### Not +#### Not + Validator that inverses the result of the inner validator. | Parameter | Type | | ----------- | ------------------------- | | `validator` | [`Validator`](#Validator) | -#### Examples +##### Examples + ```yaml {'type': 'not', 'validator': 'exist'} ``` + or + ```yaml ['not', 'exist'] ``` -### Equal +#### Equal + Validator that checks for equality with the specified constant. | Parameter | Type | | --------- | ----- | | `value` | `Any` | -#### Examples +##### Examples + ```yaml {'type': 'equal', 'value': 3} ``` + or + ```yaml ['equal', 3] ``` -### MatchesRegex +#### 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 + +##### Examples + ```yaml {'type': 'regex', 'regex': 'hello.'} ``` + or + ```yaml ['regex', 'hello.', false] ``` -### AnyOf +#### AnyOf + Validator that checks if **any** of the inner validators pass. @@ -276,124 +410,121 @@ Validator that checks if **any** of the inner validators pass. | ------------ | --------------------------------- | | `validators` | List of [`Validator`](#Validator) | -#### Examples + +##### 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 +#### AllOf + Validator that checks if **all** of the inner validators pass. + | Parameter | Type | | ------------ | --------------------------------- | | `validators` | List of [`Validator`](#Validator) | -#### Examples + +##### Examples + ```yaml type: all_of validators: - ['exist'] - ['in', 'foo', ['equal', 3]] ``` + or + ```yaml ['all_of', [['exist'], ['in', 'foo', ['equal', 3]]]] ``` -### In +#### 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 + +##### Examples + ```yaml ['in', ['foo', 'bar'], ['equal', 3]] ``` -### ListAllOf +#### 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 + +##### Examples + ```yaml type: list_all_of validator: type: regex regex: 'ab..' ``` + or + ```yaml ['list_all_of', ['regex', 'ab..']] ``` -### ListAnyOf +#### 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 + +##### Examples + ```yaml type: list_all_of validator: type: equal value: 3 ``` + or + ```yaml ['list_any_of', ['equal', 3]] ``` -### ePaConfig - -| Parameter | Type | -| -------------------------- | --------------------------------------------------------------------------| -| `iss` | String | -| `resource_id` | String | -| `registration_enabled` | Bool (defaults to `false`) | -| `expose_metadata_resource` | Object with required `name` (String) (optional) | -| `validator` | [`Validator`](#validator) (defaults to [`Exist`](#exist)) | -| `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) | -| `enc_jwk_file` | String (optional) | -| `enc_jwk` | [JWK](https://datatracker.ietf.org/doc/html/rfc7517#section-4) (optional) | -| `enc_jwks_endpoint` | String (optional) (defaults to `/.well-known/jwks.json`) | -| `displayname_path` | [`Path`](#path) (optional) | -| `localpart_path` | [`Path`](#path) (optional) | -| `lowercase_localpart` | Bool (defaults to `false`) | - -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. - -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. - -`resource_id` is an id for the synapse token authenticator. The same id must be present on the claim `aud` of the received token. - -`iss` is the expected issuer of the token and this will be checked against the claim `iss` of the 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:///`. 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 - ## 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", @@ -402,6 +533,7 @@ First you have to generate a JWT with the correct claims. The `sub` claim is the ``` 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", @@ -412,9 +544,11 @@ Next you need to post this token to the `/login` endpoint of synapse. Be sure th "token": "" } ``` + ### 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 @@ -455,6 +589,7 @@ For this flow the `user` field will be ignored. ## Testing To create virtual development env and install dependencies: + ```console hatch shell ``` @@ -466,12 +601,14 @@ can be done like this: hatch test ``` -#### Additional optional testing arguments: +#### Additional optional testing arguments + Run the tests in parallel: `-p` Collect coverage data(automatically output as `lcov.info`): `-c` -#### Running a specific test: +#### 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 diff --git a/synapse_token_authenticator/config/oauth.py b/synapse_token_authenticator/config/oauth.py index 21cd7c2..d140d00 100644 --- a/synapse_token_authenticator/config/oauth.py +++ b/synapse_token_authenticator/config/oauth.py @@ -29,7 +29,7 @@ @dataclass(config=ConfigDict(arbitrary_types_allowed=True, extra="ignore")) class JwtValidationConfig: validator: Validator = Field(default_factory=Exist) - require_expiry: bool = False + require_expiry: bool = True localpart_path: Path | None = None user_id_path: Path | None = None fq_uid_path: Path | None = None diff --git a/synapse_token_authenticator/token_authenticator.py b/synapse_token_authenticator/token_authenticator.py index 146d669..2ce774a 100644 --- a/synapse_token_authenticator/token_authenticator.py +++ b/synapse_token_authenticator/token_authenticator.py @@ -441,9 +441,7 @@ async def check_oauth( oauth_config.introspection_validation, "localpart_path", ) - username_localpart = self._localpart_from_username( - username, oauth_config.username_type - ) + username_localpart = self._localpart_from_username(username, username_type) # reconcile localpart from different sources localpart = all_list_elems_are_equal_return_the_elem( [ diff --git a/tests/test_config/test_oauth.py b/tests/test_config/test_oauth.py index 04fe686..6be82ff 100644 --- a/tests/test_config/test_oauth.py +++ b/tests/test_config/test_oauth.py @@ -22,7 +22,7 @@ class TestJwtValidationConfig: def test_jwt_validation_config_defaults(self): config = JwtValidationConfig(jwk_set=get_jwk()) assert config.validator == Exist() - assert config.require_expiry is False + assert config.require_expiry is True assert config.localpart_path is None assert config.user_id_path is None assert config.fq_uid_path is None