diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 62a145f..99e3927 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -1,15 +1,22 @@
- version: 2
+version: 2
- build:
- os: "ubuntu-24.04"
- tools:
- python: "3.13"
- jobs:
- pre_install:
- - pip install --upgrade pip
- - pip install uv
- - uv pip install --group dev --group docs
- - uv run pytest
+build:
+ os: "ubuntu-24.04"
+ tools:
+ python: "3.13"
+ apt_packages:
+ - libldap2-dev
+ - libsasl2-dev
+ jobs:
+ pre_install:
+ - pip install --upgrade pip
+ - pip install uv
+ - uv venv
+ - uv pip install --group dev --group docs ".[contrib]"
+ - uv run pytest --mock-docker=true
+ build:
+ html:
+ - uv run python -m mkdocs build --clean --site-dir $READTHEDOCS_OUTPUT/html --config-file mkdocs.rtd.yml
- mkdocs:
- configuration: mkdocs.rtd.yml
+mkdocs:
+ configuration: mkdocs.rtd.yml
diff --git a/amqtt/contrib/cert.py b/amqtt/contrib/cert.py
index 879007d..1903dea 100644
--- a/amqtt/contrib/cert.py
+++ b/amqtt/contrib/cert.py
@@ -27,7 +27,7 @@ from amqtt.session import Session
logger = logging.getLogger(__name__)
-class CertificateAuthPlugin(BaseAuthPlugin):
+class UserAuthCertPlugin(BaseAuthPlugin):
"""Used a *signed* x509 certificate's `Subject AlternativeName` or `SAN` to verify client authentication.
Often used for IoT devices, this method provides the most secure form of identification. A root
diff --git a/docs/plugins/auth_db.md b/docs/plugins/auth_db.md
index b4620c1..f512e19 100644
--- a/docs/plugins/auth_db.md
+++ b/docs/plugins/auth_db.md
@@ -9,7 +9,7 @@ For ease of use, the [`user_mgr` command-line utility](auth_db.md/#user_mgr) to
list clients. And the [`topic_mgr` command-line utility](auth_db.md/#topic_mgr) to add client access to
subscribe, publish and receive messages on topics.
-## Authentication Configuration
+# Authentication Configuration
::: amqtt.contrib.auth_db.UserAuthDBPlugin.Config
options:
@@ -17,7 +17,7 @@ subscribe, publish and receive messages on topics.
extra:
class_style: "simple"
-## Authorization Configuration
+# Authorization Configuration
::: amqtt.contrib.auth_db.TopicAuthDBPlugin.Config
options:
diff --git a/docs/plugins/cert.md b/docs/plugins/cert.md
index 4c07336..1349495 100644
--- a/docs/plugins/cert.md
+++ b/docs/plugins/cert.md
@@ -65,9 +65,32 @@ async def main():
asyncio.run(main())
```
-::: amqtt.contrib.cert.CertificateAuthPlugin
+## Background
-### Root & Certificate Credentials
+Often used for IoT devices, this method provides the most secure form of identification. A root
+certificate, often referenced as a CA certificate -- either issued by a known authority (such as LetsEncrypt)
+or a self-signed certificate) is used to sign a private key and certificate for the server. Each device/client
+also gets a unique private key and certificate signed by the same CA certificate; also included in the device
+certificate is a 'SAN' or SubjectAlternativeName which is the device's unique identifier.
+
+Since both server and device certificates are signed by the same CA certificate, the client can
+verify the server's authenticity; and the server can verify the client's authenticity. And since
+the device's certificate contains a x509 SAN, the server (with this plugin) can identify the device securely.
+
+!!! note "URI and Client ID configuration"
+ `uri_domain` configuration must be set to the same uri used to generate the device credentials
+
+ when a device is connecting with private key and certificate, the `client_id` must
+ match the device id used to generate the device credentials.
+
+Available ore three scripts to help with the key generation and certificate signing: `ca_creds`, `server_creds`
+and `device_creds`.
+
+!!! note "Configuring broker & client for using Self-signed root CA"
+ If using self-signed root credentials, the `cafile` configuration for both broker and client need to be
+ configured with `cafile` set to the `ca.crt`.
+
+## Root & Certificate Credentials
The process for generating a server's private key and certificate is only done once. If you have a private key & certificate --
such as one from verifying your webserver's domain with LetsEncrypt -- that you want to use, pass them to the `server_creds` cli.
@@ -99,7 +122,7 @@ flowchart LR
ssi --> skc
```
-### Device credentials
+## Device credentials
For each device, create a device id to generate a device-specific private key and certificate using the `device_creds` cli.
Use the same CA as was used for the server (above) so the client & server recognize each other.
@@ -127,9 +150,9 @@ flowchart LR
con["country, org
common name
& device id"] --> ccsr
```
-### Configuration
+## Configuration
-::: amqtt.contrib.cert.CertificateAuthPlugin.Config
+::: amqtt.contrib.cert.UserAuthCertPlugin.Config
options:
show_source: false
heading_level: 4
@@ -137,7 +160,7 @@ flowchart LR
class_style: "simple"
-### Key and Certificate Generation
+## Key and Certificate Generation
::: mkdocs-typer2
:module: amqtt.scripts.ca_creds
diff --git a/docs/plugins/contrib.md b/docs/plugins/contrib.md
index 87c8e11..79df63d 100644
--- a/docs/plugins/contrib.md
+++ b/docs/plugins/contrib.md
@@ -5,22 +5,21 @@ These are fully supported plugins but require additional dependencies to be inst
`$ pip install '.[contrib]'`
-- Relational Database Auth
- _includes manager script to add, remove and create db entries_
- - [DB Client Authentication](auth_db.md)
- Authenticate a client's connection to broker based on entries in a relational db (mysql, postgres, maria, sqlite).
- `amqtt.contrib.auth_db.AuthUserDBPlugin`
- - [DB Client Authorization](auth_db.md)
- Determine a client's access to topics.
- `amqtt.contrib.auth_db.AuthTopicDBPlugin`
+- [Relational Database Auth](auth_db.md)
+ Grant or deny access to clients based on entries in a relational db (mysql, postgres, maria, sqlite). _Includes
+ manager script to add, remove and create db entries_
+ - `amqtt.contrib.auth_db.UserAuthDBPlugin`
+ - `amqtt.contrib.auth_db.TopicAuthDBPlugin`
- [HTTP Auth](http.md)
- Determine client authentication and authorization based on response from a separate HTTP server.
- `amqtt.contrib.http.HttpAuthTopicPlugin`
+ Determine client authentication and/or authorization based on response from a separate HTTP server.
+ - `amqtt.contrib.http.UserAuthHttpPlugin`
+ - `amqtt.contrib.http.TopicAuthHttpPlugin`
- [LDAP Auth](ldap.md)
Authenticate a user with an LDAP directory server.
- `amqtt.contrib.ldap.LDAPAuthPlugin`
+ - `amqtt.contrib.ldap.UserAuthLdapPlugin`
+ - `amqtt.contrib.ldap.TopicAuthLdapPlugin`
- [Shadows](shadows.md)
Device shadows provide a persistent, cloud-based representation of the state of a device,
@@ -31,6 +30,16 @@ These are fully supported plugins but require additional dependencies to be inst
- [Certificate Auth](cert.md)
Using client-specific certificates, signed by a common authority (even if self-signed) provides
a highly secure way of authenticating mqtt clients. Often used with IoT devices where a unique
- certificate can be initialized on initial provisioning. Includes command line utilities to generate
- root, broker and device certificates with the correct X509 attributes to enable authenticity.
- `amqtt.contrib.cert.CertificateAuthPlugin.Config`
+ certificate can be initialized on initial provisioning. _Includes command line utilities to generate
+ root, broker and device certificates with the correct X509
+ attributes to enable authenticity._
+ `amqtt.contrib.cert.UserAuthCertPlugin`
+
+- [JWT Auth](jwt.md)
+ Plugin to determine user authentication and topic authorization based on claims in a JWT.
+ - `amqtt.contrib.jwt.UserAuthJwtPlugin` (client authentication)
+ - `amqtt.contrib.jwt.TopicAuthJwtPlugin` (topic authorization)
+
+- [Session Persistence](session.md)
+ Plugin to store session information and retained topic messages in the event that the broker terminates abnormally.
+ `amqtt.contrib.persistence.SessionDBPlugin`
diff --git a/docs/plugins/http.md b/docs/plugins/http.md
index 9ef639c..2e1d69d 100644
--- a/docs/plugins/http.md
+++ b/docs/plugins/http.md
@@ -8,7 +8,7 @@ that respond with information about client authentication and/or topic-level aut
Configuration of these plugins is identical (except for the uri name) so that they can be used independently, if desired.
-## User Auth
+# User Auth
See the [Request and Response Modes](#request-response-modes) section below for details on `params_mode` and `response_mode`.
@@ -49,7 +49,7 @@ See the [Request and Response Modes](#request-response-modes) section below for
extra:
class_style: "simple"
-## Topic ACL
+# Topic ACL
See the [Request and Response Modes](#request-response-modes) section below for details on `params_mode` and `response_mode`.
diff --git a/docs/plugins/jwt.md b/docs/plugins/jwt.md
index e69de29..63edcb8 100644
--- a/docs/plugins/jwt.md
+++ b/docs/plugins/jwt.md
@@ -0,0 +1,50 @@
+# Authentication & Authorization from JWT
+
+- `amqtt.contrib.jwt.UserAuthJwtPlugin` (client authentication)
+- `amqtt.contrib.jwt.TopicAuthJwtPlugin` (topic authorization)
+
+Plugin to determine user authentication and topic authorization based on claims in a JWT.
+
+# User Authentication
+
+For auth, the JWT should include a key as specified in the configuration as `user_clam`:
+
+```python
+from datetime import datetime, UTC, timedelta
+claims = {
+ "username": "example_user",
+ "exp": datetime.now(UTC) + timedelta(hours=1),
+}
+```
+
+::: amqtt.contrib.jwt.UserAuthJwtPlugin.Config
+ options:
+ show_source: false
+ heading_level: 4
+ extra:
+ class_style: "simple"
+
+
+# Topic Authorization
+
+For authorizing a client for certain topics, the token should also include claims for publish, subscribe and receive;
+keys based on how `publish_claim`, `subscribe_claim` and `receive_claim` are specified in the plugin's configuration.
+
+```python
+from datetime import datetime, UTC, timedelta
+
+ claims = {
+ "username": "example_user",
+ "exp": datetime.now(UTC) + timedelta(hours=1),
+ "publish_acl": ['my/topic/#', 'my/+/other'],
+ "subscribe_acl": ['my/+/other'],
+ "receive_acl": ['#']
+ }
+```
+
+::: amqtt.contrib.jwt.TopicAuthJwtPlugin.Config
+ options:
+ show_source: false
+ heading_level: 4
+ extra:
+ class_style: "simple"
diff --git a/docs/plugins/ldap.md b/docs/plugins/ldap.md
index 229f254..9ff83e9 100644
--- a/docs/plugins/ldap.md
+++ b/docs/plugins/ldap.md
@@ -8,7 +8,7 @@ for client authentication and/or topic-level authorization.
Authenticate a user with an LDAP directory server.
-## User Auth
+# User Auth
::: amqtt.contrib.ldap.UserAuthLdapPlugin.Config
options:
@@ -16,7 +16,7 @@ Authenticate a user with an LDAP directory server.
extra:
class_style: "simple"
-## Topic Auth (ACL)
+# Topic Auth (ACL)
::: amqtt.contrib.ldap.TopicAuthLdapPlugin.Config
options:
diff --git a/docs/plugins/session.md b/docs/plugins/session.md
index a9f45ea..753a7a0 100644
--- a/docs/plugins/session.md
+++ b/docs/plugins/session.md
@@ -1,10 +1,10 @@
# Session Persistence
-`amqtt.plugins.persistence.SessionDBPlugin`
+`amqtt.contrib.persistence.SessionDBPlugin`
Plugin to store session information and retained topic messages in the event that the broker terminates abnormally.
-::: amqtt.plugins.persistence.SessionDBPlugin.Config
+::: amqtt.contrib.persistence.SessionDBPlugin.Config
options:
show_source: false
heading_level: 4
diff --git a/docs/plugins/shadows.md b/docs/plugins/shadows.md
index c1e6bb1..5f5421a 100644
--- a/docs/plugins/shadows.md
+++ b/docs/plugins/shadows.md
@@ -171,7 +171,7 @@ state (last row in table).
| `{ "levels": [1, 10, 4]}` | `{"levels": [1, 4, 10]}` | `{"levels": [1, 4, 10]}` |
| `{ "brightness": 100, "mode": "eco" }` | `{ "brightness": 80 }` | `{ "brightness": 80, "mode": null }` |
-### Configuration
+## Configuration
::: amqtt.contrib.shadows.ShadowPlugin.Config
options:
diff --git a/docs/scripts/exts.py b/docs/scripts/exts.py
index 0f0bcdc..0677ef2 100644
--- a/docs/scripts/exts.py
+++ b/docs/scripts/exts.py
@@ -1,13 +1,9 @@
import ast
-import json
import pprint
from typing import Any
import griffe
-from _griffe.agents.inspector import Inspector
-from _griffe.agents.nodes.runtime import ObjectNode
-from _griffe.agents.visitor import Visitor
-from _griffe.models import Attribute
+from griffe import Inspector, ObjectNode, Visitor, Attribute
from amqtt.contexts import default_listeners, default_broker_plugins, default_client_plugins
from amqtt.contrib.auth_db.plugin import default_hash_scheme
diff --git a/mkdocs.rtd.yml b/mkdocs.rtd.yml
index 2157c8a..3a40ec4 100644
--- a/mkdocs.rtd.yml
+++ b/mkdocs.rtd.yml
@@ -50,6 +50,8 @@ nav:
- LDAP Auth: plugins/ldap.md
- Shadows: plugins/shadows.md
- Certificate Auth: plugins/cert.md
+ - JWT Auth: plugins/jwt.md
+ - Session persistence: plugins/session.md
- Reference:
- Containerization: docker.md
- Support: support.md
diff --git a/pyproject.toml b/pyproject.toml
index 6dfe6d3..6cc814e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,7 +33,6 @@ dependencies = [
"passlib==1.7.4", # https://pypi.org/project/passlib
"PyYAML==6.0.2", # https://pypi.org/project/PyYAML
"typer==0.15.4",
- "aiohttp>=3.12.7",
"dacite>=1.9.2",
"psutil>=7.0.0"
]
@@ -67,6 +66,7 @@ dev = [
]
docs = [
+ "griffe>=1.11.1",
"markdown-callouts>=0.4",
"markdown-exec>=1.8",
"mkdocs>=1.6",
diff --git a/tests/conftest.py b/tests/conftest.py
index 2b40a1a..14fea72 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -155,3 +155,12 @@ def ca_file_fixture():
for file in temp_dir.iterdir():
file.unlink()
temp_dir.rmdir()
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--mock-docker",
+ action="store",
+ default="false",
+ help="for environments where docker isn't available, mock calls which require docker",
+ )
\ No newline at end of file
diff --git a/tests/contrib/test_cert.py b/tests/contrib/test_cert.py
index 27f51ee..c56155e 100644
--- a/tests/contrib/test_cert.py
+++ b/tests/contrib/test_cert.py
@@ -13,7 +13,7 @@ import pytest
from amqtt.broker import BrokerContext, Broker
from amqtt.client import MQTTClient
-from amqtt.contrib.cert import CertificateAuthPlugin
+from amqtt.contrib.cert import UserAuthCertPlugin
from amqtt.errors import ConnectError
from amqtt.scripts.server_creds import server_creds as get_server_creds
from amqtt.scripts.device_creds import device_creds as get_device_creds
@@ -99,9 +99,9 @@ async def test_cert_plugin(ssl_object_mock, uri_domain, client_id, expected_resu
}
bc = BrokerContext(broker=Broker(config=empty_cfg))
- bc.config = CertificateAuthPlugin.Config(uri_domain=uri_domain)
+ bc.config = UserAuthCertPlugin.Config(uri_domain=uri_domain)
- cert_auth_plugin = CertificateAuthPlugin(bc)
+ cert_auth_plugin = UserAuthCertPlugin(bc)
s = Session()
s.client_id = client_id
@@ -128,7 +128,7 @@ async def test_client_broker_cert_authentication(ca_creds, server_creds, device_
},
'plugins': {
'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{},
- 'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'},
+ 'amqtt.contrib.cert.UserAuthCertPlugin': {'uri_domain': 'test.amqtt.io'},
}
}
@@ -187,7 +187,7 @@ async def test_client_broker_wrong_certs(ca_creds, server_creds, device_creds):
},
'plugins': {
'amqtt.plugins.logging_amqtt.PacketLoggerPlugin':{},
- 'amqtt.contrib.cert.CertificateAuthPlugin': {'uri_domain': 'test.amqtt.io'},
+ 'amqtt.contrib.cert.UserAuthCertPlugin': {'uri_domain': 'test.amqtt.io'},
}
}
diff --git a/tests/contrib/test_ldap.py b/tests/contrib/test_ldap.py
index 9f6803a..1360b1a 100644
--- a/tests/contrib/test_ldap.py
+++ b/tests/contrib/test_ldap.py
@@ -1,17 +1,17 @@
import asyncio
+import ldap
import time
from pathlib import Path
+from unittest.mock import patch, MagicMock
import pytest
from amqtt.broker import BrokerContext, Broker
from amqtt.client import MQTTClient
from amqtt.contexts import BrokerConfig, ListenerConfig, ClientConfig, Action
-from amqtt.contrib.auth_db.user_mgr_cli import user_app
from amqtt.contrib.ldap import UserAuthLdapPlugin, TopicAuthLdapPlugin
from amqtt.errors import ConnectError
from amqtt.session import Session
-from tests.test_cli import broker
# Pin the project name to avoid creating multiple stacks
@@ -29,7 +29,7 @@ def docker_compose_file(pytestconfig):
return Path(pytestconfig.rootdir) / "tests/fixtures/ldap" / "docker-compose.yml"
@pytest.fixture(scope="session")
-def ldap_service(docker_ip, docker_services):
+def ldap_service_docker(docker_ip, docker_services):
"""Ensure that HTTP service is up and responsive."""
# `port_for` takes a container port and returns the corresponding host port
@@ -38,6 +38,36 @@ def ldap_service(docker_ip, docker_services):
time.sleep(2)
return url
+@pytest.fixture(scope="session")
+def ldap_service_mock():
+ """Ensure that HTTP service is up and responsive."""
+
+ # `port_for` takes a container port and returns the corresponding host port
+ url = "ldap://localhost:0"
+ mock_ldap_object = MagicMock()
+
+ def mock_simple_s(*args, **kwargs):
+ return [ ('dn', {'uid':'jdoe', 'publishACL':[b'my/topic/one', b'my/topic/two']}), ]
+
+ def mock_simple_bind_s(*args):
+ p = args[1]
+ if p in ("adminpassword", "johndoepassword"):
+ return
+ raise ldap.INVALID_CREDENTIALS
+
+ mock_ldap_object.search_s.side_effect = mock_simple_s
+ mock_ldap_object.simple_bind_s.side_effect = mock_simple_bind_s
+ with patch("ldap.initialize", return_value=mock_ldap_object):
+ yield url
+
+@pytest.fixture(scope="session")
+def ldap_service(request):
+ mode = request.config.getoption("--mock-docker")
+ if mode:
+ return request.getfixturevalue("ldap_service_mock")
+ return request.getfixturevalue("ldap_service_docker")
+
+
@pytest.mark.asyncio
async def test_ldap_user_plugin(ldap_service):
ctx = BrokerContext(Broker())
diff --git a/uv.lock b/uv.lock
index b22c439..c257946 100644
--- a/uv.lock
+++ b/uv.lock
@@ -132,7 +132,6 @@ name = "amqtt"
version = "0.11.3"
source = { editable = "." }
dependencies = [
- { name = "aiohttp" },
{ name = "dacite" },
{ name = "passlib" },
{ name = "psutil" },
@@ -188,6 +187,7 @@ dev = [
{ name = "types-setuptools" },
]
docs = [
+ { name = "griffe" },
{ name = "markdown-callouts" },
{ name = "markdown-exec" },
{ name = "mkdocs" },
@@ -207,7 +207,6 @@ docs = [
[package.metadata]
requires-dist = [
- { name = "aiohttp", specifier = ">=3.12.7" },
{ name = "aiohttp", marker = "extra == 'contrib'", specifier = ">=3.12.13" },
{ name = "aiosqlite", marker = "extra == 'contrib'", specifier = ">=0.21.0" },
{ name = "argon2-cffi", marker = "extra == 'contrib'", specifier = ">=25.1.0" },
@@ -258,6 +257,7 @@ dev = [
{ name = "types-setuptools", specifier = ">=78.1.0.20250329" },
]
docs = [
+ { name = "griffe", specifier = ">=1.11.1" },
{ name = "markdown-callouts", specifier = ">=0.4" },
{ name = "markdown-exec", specifier = ">=1.8" },
{ name = "mkdocs", specifier = ">=1.6" },
@@ -936,14 +936,14 @@ wheels = [
[[package]]
name = "griffe"
-version = "1.7.3"
+version = "1.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a9/3e/5aa9a61f7c3c47b0b52a1d930302992229d191bf4bc76447b324b731510a/griffe-1.7.3.tar.gz", hash = "sha256:52ee893c6a3a968b639ace8015bec9d36594961e156e23315c8e8e51401fa50b", size = 395137, upload-time = "2025-04-23T11:29:09.147Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/0f/9cbd56eb047de77a4b93d8d4674e70cd19a1ff64d7410651b514a1ed93d5/griffe-1.11.1.tar.gz", hash = "sha256:d54ffad1ec4da9658901eb5521e9cddcdb7a496604f67d8ae71077f03f549b7e", size = 410996, upload-time = "2025-08-11T11:38:35.528Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/58/c6/5c20af38c2a57c15d87f7f38bee77d63c1d2a3689f74fefaf35915dd12b2/griffe-1.7.3-py3-none-any.whl", hash = "sha256:c6b3ee30c2f0f17f30bcdef5068d6ab7a2a4f1b8bf1a3e74b56fffd21e1c5f75", size = 129303, upload-time = "2025-04-23T11:29:07.145Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/a3/451ffd422ce143758a39c0290aaa7c9727ecc2bcc19debd7a8f3c6075ce9/griffe-1.11.1-py3-none-any.whl", hash = "sha256:5799cf7c513e4b928cfc6107ee6c4bc4a92e001f07022d97fd8dee2f612b6064", size = 138745, upload-time = "2025-08-11T11:38:33.964Z" },
]
[[package]]