From 2afcbb38f81fc4c87612fa64c492f90cf4f5f36a Mon Sep 17 00:00:00 2001 From: Vincent Van Rossem Date: Tue, 3 Feb 2026 13:54:23 +0100 Subject: [PATCH 1/3] [ADD] webservice_client_certificate_auth: new module --- webservice_client_certificate_auth/README.rst | 110 +++++ .../__init__.py | 2 + .../__manifest__.py | 14 + .../components/__init__.py | 1 + .../components/request_adapter.py | 30 ++ .../models/__init__.py | 1 + .../models/webservice_backend.py | 28 ++ .../pyproject.toml | 3 + .../readme/CONFIGURE.md | 10 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 1 + .../readme/USAGE.md | 9 + .../static/description/index.html | 455 ++++++++++++++++++ .../tests/__init__.py | 1 + ...test_webservice_client_certificate_auth.py | 96 ++++ .../views/webservice_backend.xml | 20 + 16 files changed, 782 insertions(+) create mode 100644 webservice_client_certificate_auth/README.rst create mode 100644 webservice_client_certificate_auth/__init__.py create mode 100644 webservice_client_certificate_auth/__manifest__.py create mode 100644 webservice_client_certificate_auth/components/__init__.py create mode 100644 webservice_client_certificate_auth/components/request_adapter.py create mode 100644 webservice_client_certificate_auth/models/__init__.py create mode 100644 webservice_client_certificate_auth/models/webservice_backend.py create mode 100644 webservice_client_certificate_auth/pyproject.toml create mode 100644 webservice_client_certificate_auth/readme/CONFIGURE.md create mode 100644 webservice_client_certificate_auth/readme/CONTRIBUTORS.md create mode 100644 webservice_client_certificate_auth/readme/DESCRIPTION.md create mode 100644 webservice_client_certificate_auth/readme/USAGE.md create mode 100644 webservice_client_certificate_auth/static/description/index.html create mode 100644 webservice_client_certificate_auth/tests/__init__.py create mode 100644 webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py create mode 100644 webservice_client_certificate_auth/views/webservice_backend.xml diff --git a/webservice_client_certificate_auth/README.rst b/webservice_client_certificate_auth/README.rst new file mode 100644 index 00000000..98f4de5d --- /dev/null +++ b/webservice_client_certificate_auth/README.rst @@ -0,0 +1,110 @@ +============================================== +WebService - Client Certificate Authentication +============================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:4a2f96ce5a7f3c69babb934fd5a9e2bb530fd01e335c3526d5f499774b59fd36 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github + :target: https://github.com/OCA/web-api/tree/18.0/webservice_client_certificate_auth + :alt: OCA/web-api +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-api-18-0/web-api-18-0-webservice_client_certificate_auth + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web-api&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds support for Client Side Certificates to the ``webservice`` module. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Certificate paths are configured via ``server_environment``. Add the +configuration section matching your backend's ``tech_name`` to your +server environment files: + +:: + + [webservice_backend.webservice_client_certificate_auth] + auth_type = client_certificate + client_certificate_path = /path/to/client.cert + # Optional: Leave empty if the private key is bundled in the certificate file + client_private_key_path = /path/to/client.key + +Usage +===== + +When a call is made using the backend, the adapter automatically injects +the ``cert`` parameter into the underlying Python ``requests`` call +based on the provided configuration: + +- **Certificate only:** Passed as ``cert='/path/to/file'`` (a single + file containing the private key and the certificate). +- **Certificate and Key:** Passed as a tuple + ``cert=('/path/to/crt', '/path/to/key')``. + +Warning: the private key to your local certificate must be unencrypted. +Currently, ``requests`` does not support using encrypted keys. + +See `Requests: Client Side +Certificates `__ +for underlying implementation details. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Vincent Van Rossem + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web-api `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/webservice_client_certificate_auth/__init__.py b/webservice_client_certificate_auth/__init__.py new file mode 100644 index 00000000..f24d3e24 --- /dev/null +++ b/webservice_client_certificate_auth/__init__.py @@ -0,0 +1,2 @@ +from . import components +from . import models diff --git a/webservice_client_certificate_auth/__manifest__.py b/webservice_client_certificate_auth/__manifest__.py new file mode 100644 index 00000000..ae4b2aab --- /dev/null +++ b/webservice_client_certificate_auth/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "WebService - Client Certificate Authentication", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Camptocamp, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web-api", + "depends": ["webservice"], + "data": [ + "views/webservice_backend.xml", + ], +} diff --git a/webservice_client_certificate_auth/components/__init__.py b/webservice_client_certificate_auth/components/__init__.py new file mode 100644 index 00000000..4f4c700d --- /dev/null +++ b/webservice_client_certificate_auth/components/__init__.py @@ -0,0 +1 @@ +from . import request_adapter diff --git a/webservice_client_certificate_auth/components/request_adapter.py b/webservice_client_certificate_auth/components/request_adapter.py new file mode 100644 index 00000000..b9b894a9 --- /dev/null +++ b/webservice_client_certificate_auth/components/request_adapter.py @@ -0,0 +1,30 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class ClientCertRestRequestsAdapter(Component): + _inherit = "base.requests" + + def _request(self, method, url=None, url_params=None, **kwargs): + if self.collection.auth_type == "client_certificate": + # ``requests`` ``cert`` parameter accepts: + # * A string: path to a file containing both certificate and private key + # * A tuple: ('/path/client.cert', '/path/client.key') + cert_path = self._get_cert_path() + key_path = self._get_key_path() + + if "cert" not in kwargs and cert_path: + if key_path: + kwargs["cert"] = (cert_path, key_path) + else: + kwargs["cert"] = cert_path + + return super()._request(method, url=url, url_params=url_params, **kwargs) + + def _get_cert_path(self): + return self.collection.client_certificate_path + + def _get_key_path(self): + return self.collection.client_private_key_path diff --git a/webservice_client_certificate_auth/models/__init__.py b/webservice_client_certificate_auth/models/__init__.py new file mode 100644 index 00000000..c08fb9a9 --- /dev/null +++ b/webservice_client_certificate_auth/models/__init__.py @@ -0,0 +1 @@ +from . import webservice_backend diff --git a/webservice_client_certificate_auth/models/webservice_backend.py b/webservice_client_certificate_auth/models/webservice_backend.py new file mode 100644 index 00000000..3400ff90 --- /dev/null +++ b/webservice_client_certificate_auth/models/webservice_backend.py @@ -0,0 +1,28 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class WebserviceBackend(models.Model): + _inherit = "webservice.backend" + + auth_type = fields.Selection( + selection_add=[("client_certificate", "Client Certificate")], + ondelete={"client_certificate": lambda recs: recs.write({"auth_type": "none"})}, + ) + client_certificate_path = fields.Char( + auth_type="client_certificate", + ) + client_private_key_path = fields.Char() + + @property + def _server_env_fields(self): + env_fields = super()._server_env_fields + env_fields.update( + { + "client_certificate_path": {}, + "client_private_key_path": {}, + } + ) + return env_fields diff --git a/webservice_client_certificate_auth/pyproject.toml b/webservice_client_certificate_auth/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/webservice_client_certificate_auth/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/webservice_client_certificate_auth/readme/CONFIGURE.md b/webservice_client_certificate_auth/readme/CONFIGURE.md new file mode 100644 index 00000000..7f6f8b5b --- /dev/null +++ b/webservice_client_certificate_auth/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +Certificate paths are configured via `server_environment`. +Add the configuration section matching your backend's `tech_name` to your server environment files: + +``` +[webservice_backend.webservice_client_certificate_auth] +auth_type = client_certificate +client_certificate_path = /path/to/client.cert +# Optional: Leave empty if the private key is bundled in the certificate file +client_private_key_path = /path/to/client.key +``` diff --git a/webservice_client_certificate_auth/readme/CONTRIBUTORS.md b/webservice_client_certificate_auth/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..77f9a96d --- /dev/null +++ b/webservice_client_certificate_auth/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Vincent Van Rossem \<\> diff --git a/webservice_client_certificate_auth/readme/DESCRIPTION.md b/webservice_client_certificate_auth/readme/DESCRIPTION.md new file mode 100644 index 00000000..9b1c1874 --- /dev/null +++ b/webservice_client_certificate_auth/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Adds support for Client Side Certificates to the `webservice` module. diff --git a/webservice_client_certificate_auth/readme/USAGE.md b/webservice_client_certificate_auth/readme/USAGE.md new file mode 100644 index 00000000..271f4f2f --- /dev/null +++ b/webservice_client_certificate_auth/readme/USAGE.md @@ -0,0 +1,9 @@ +When a call is made using the backend, the adapter automatically injects the `cert` parameter into the underlying Python `requests` +call based on the provided configuration: + +* **Certificate only:** Passed as `cert='/path/to/file'` (a single file containing the private key and the certificate). +* **Certificate and Key:** Passed as a tuple `cert=('/path/to/crt', '/path/to/key')`. + +Warning: the private key to your local certificate must be unencrypted. Currently, `requests` does not support using encrypted keys. + +See [Requests: Client Side Certificates](https://requests.readthedocs.io/en/latest/user/advanced/#client-side-certificates) for underlying implementation details. diff --git a/webservice_client_certificate_auth/static/description/index.html b/webservice_client_certificate_auth/static/description/index.html new file mode 100644 index 00000000..7e5bba7a --- /dev/null +++ b/webservice_client_certificate_auth/static/description/index.html @@ -0,0 +1,455 @@ + + + + + +WebService - Client Certificate Authentication + + + +
+

WebService - Client Certificate Authentication

+ + +

Beta License: AGPL-3 OCA/web-api Translate me on Weblate Try me on Runboat

+

Adds support for Client Side Certificates to the webservice module.

+

Table of contents

+ +
+

Configuration

+

Certificate paths are configured via server_environment. Add the +configuration section matching your backend’s tech_name to your +server environment files:

+
+[webservice_backend.webservice_client_certificate_auth]
+auth_type = client_certificate
+client_certificate_path = /path/to/client.cert
+# Optional: Leave empty if the private key is bundled in the certificate file
+client_private_key_path = /path/to/client.key
+
+
+
+

Usage

+

When a call is made using the backend, the adapter automatically injects +the cert parameter into the underlying Python requests call +based on the provided configuration:

+
    +
  • Certificate only: Passed as cert='/path/to/file' (a single +file containing the private key and the certificate).
  • +
  • Certificate and Key: Passed as a tuple +cert=('/path/to/crt', '/path/to/key').
  • +
+

Warning: the private key to your local certificate must be unencrypted. +Currently, requests does not support using encrypted keys.

+

See Requests: Client Side +Certificates +for underlying implementation details.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web-api project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/webservice_client_certificate_auth/tests/__init__.py b/webservice_client_certificate_auth/tests/__init__.py new file mode 100644 index 00000000..9b489b83 --- /dev/null +++ b/webservice_client_certificate_auth/tests/__init__.py @@ -0,0 +1 @@ +from . import test_webservice_client_certificate_auth diff --git a/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py b/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py new file mode 100644 index 00000000..7257dbd0 --- /dev/null +++ b/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py @@ -0,0 +1,96 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import os +from unittest import mock + +from odoo import exceptions + +from odoo.addons.webservice.tests.common import CommonWebService + + +class TestClientCertAuth(CommonWebService): + @classmethod + def _setup_records(cls): + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + # Certificate and private key configuration + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_client_certificate_and_key]", + "auth_type = client_certificate", + "client_certificate_path = /path/client.cert", + "client_private_key_path = /path/client.key", + ] + ) + cls.backend_certificate_and_key = cls.env["webservice.backend"].create( + { + "name": "Webservice Client Certificate & Key", + "tech_name": "test_client_certificate_and_key", + "protocol": "http", + "url": cls.url, + "auth_type": "client_certificate", + "client_certificate_path": "/path/client.cert", + "client_private_key_path": "/path/client.key", + } + ) + # Certificate only configuration (no private key) + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_client_certificate_only]", + "auth_type = client_certificate", + "client_certificate_path = /path/client.pem", + ] + ) + cls.backend_certificate_only = cls.env["webservice.backend"].create( + { + "name": "Webservice Client Certificate Only", + "tech_name": "test_client_certificate_only", + "protocol": "http", + "url": cls.url, + "auth_type": "client_certificate", + "client_certificate_path": "/path/client.pem", + } + ) + return res + + def test_request_adapter_certificate_and_key(self): + with mock.patch("requests.request") as mock_request: + mock_request.return_value.content = b"{}" + mock_request.return_value.status_code = 200 + + self.backend_certificate_and_key.call("get", url=f"{self.url}endpoint") + + self.assertTrue(mock_request.called) + _args, kwargs = mock_request.call_args + + # Verify 'cert' is passed as a tuple (cert, key) + self.assertIn("cert", kwargs) + self.assertEqual(kwargs["cert"], ("/path/client.cert", "/path/client.key")) + + def test_request_adapter_certificate_only(self): + with mock.patch("requests.request") as mock_request: + mock_request.return_value.content = b"{}" + mock_request.return_value.status_code = 200 + + self.backend_certificate_only.call("get", url=f"{self.url}endpoint") + + self.assertTrue(mock_request.called) + _args, kwargs = mock_request.call_args + + # Verify 'cert' is a simple string + self.assertEqual(kwargs["cert"], "/path/client.pem") + + def test_auth_type_validation(self): + with self.assertRaisesRegex( + exceptions.UserError, "requires 'Client Certificate'" + ): + self.env["webservice.backend"].create( + { + "name": "Broken Service", + "tech_name": "broken_config", + "protocol": "http", + "url": "http://localhost", + "auth_type": "client_certificate", + } + ) diff --git a/webservice_client_certificate_auth/views/webservice_backend.xml b/webservice_client_certificate_auth/views/webservice_backend.xml new file mode 100644 index 00000000..6ff58f1a --- /dev/null +++ b/webservice_client_certificate_auth/views/webservice_backend.xml @@ -0,0 +1,20 @@ + + + + webservice.backend + + + + + + + + + From 4b7b624656b9ab4bf7143755d85094ee6ab643b7 Mon Sep 17 00:00:00 2001 From: Vincent Van Rossem Date: Tue, 3 Feb 2026 14:43:07 +0100 Subject: [PATCH 2/3] [FIX] webservice: fix test failure with extended auth types The test `test_oauth2_flow_compute_with_ui` iterates over all available `auth_type` values. When a module extends `webservice` with an additional `auth_type` (e.g., `client_certificate`), this test fails because it does not know how to populate the new required fields for that type. This commit restricts the test loop to the authentication types defined in `webservice` module. --- webservice/tests/test_oauth2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py index dcd8d0a9..b279d445 100644 --- a/webservice/tests/test_oauth2.py +++ b/webservice/tests/test_oauth2.py @@ -285,7 +285,7 @@ def test_oauth2_flow_compute_with_ui(self): form_xmlid = "webservice.webservice_backend_form_view" for auth_type, oauth2_flow in [ (tp, fl) - for tp in ws._fields["auth_type"].get_values(ws.env) + for tp in ["none", "user_pwd", "api_key", "oauth2"] for fl in ws._fields["oauth2_flow"].get_values(ws.env) ]: next_ws_id = ws.sudo().search([], order="id desc", limit=1).id + 1 From f09340fe768bba0b743601b8ea8dbfc06203dc8a Mon Sep 17 00:00:00 2001 From: Vincent Van Rossem Date: Fri, 6 Feb 2026 17:29:29 +0100 Subject: [PATCH 3/3] fixup! [ADD] webservice_client_certificate_auth: new module --- .../test_webservice_client_certificate_auth.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py b/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py index 7257dbd0..c4f41bd4 100644 --- a/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py +++ b/webservice_client_certificate_auth/tests/test_webservice_client_certificate_auth.py @@ -1,7 +1,6 @@ # Copyright 2026 Camptocamp SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -import os from unittest import mock from odoo import exceptions @@ -15,14 +14,6 @@ def _setup_records(cls): res = super()._setup_records() cls.url = "https://localhost.demo.odoo/" # Certificate and private key configuration - os.environ["SERVER_ENV_CONFIG"] = "\n".join( - [ - "[webservice_backend.test_client_certificate_and_key]", - "auth_type = client_certificate", - "client_certificate_path = /path/client.cert", - "client_private_key_path = /path/client.key", - ] - ) cls.backend_certificate_and_key = cls.env["webservice.backend"].create( { "name": "Webservice Client Certificate & Key", @@ -35,13 +26,6 @@ def _setup_records(cls): } ) # Certificate only configuration (no private key) - os.environ["SERVER_ENV_CONFIG"] = "\n".join( - [ - "[webservice_backend.test_client_certificate_only]", - "auth_type = client_certificate", - "client_certificate_path = /path/client.pem", - ] - ) cls.backend_certificate_only = cls.env["webservice.backend"].create( { "name": "Webservice Client Certificate Only",