diff --git a/auth_api_key/__init__.py b/auth_api_key/__init__.py index 0650744f6b..5bf67492bc 100644 --- a/auth_api_key/__init__.py +++ b/auth_api_key/__init__.py @@ -1 +1,2 @@ from . import models +from .hooks import post_load_hook diff --git a/auth_api_key/__manifest__.py b/auth_api_key/__manifest__.py index ce16e39fb5..8cdfac8ae5 100644 --- a/auth_api_key/__manifest__.py +++ b/auth_api_key/__manifest__.py @@ -11,6 +11,7 @@ "website": "https://github.com/OCA/server-auth", "development_status": "Production/Stable", "depends": ["base_setup"], + "post_load": "post_load_hook", "data": [ "security/ir.model.access.csv", "views/auth_api_key.xml", diff --git a/auth_api_key/hooks.py b/auth_api_key/hooks.py new file mode 100644 index 0000000000..6fb64d994d --- /dev/null +++ b/auth_api_key/hooks.py @@ -0,0 +1,52 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from werkzeug.exceptions import HTTPException +from werkzeug.wrappers import Response + +from odoo.http import Dispatcher + + +def patch_dispatcher_pre_dispatch(): + """Patch odoo.http.Dispatcher's pre_dispatch method. + + For routes with cors enabled, Odoo will add a handler for the OPTIONS method, + which will raise an abort HTTPException with a 204 response describing the allowed + methods and headers in the Access-Control-* respnose headers. + + For routes with api_key authentication, we must inform the client that the + API-KEY header is allowed. + """ + original_pre_dispatch = Dispatcher.pre_dispatch + + def pre_dispatch(self, rule, args): + get_header = self.request.future_response.headers.get + set_header = self.request.future_response.headers.set + routing = rule.endpoint.routing + if ( + routing.get("cors") + and routing.get("auth") == "api_key" + and self.request.httprequest.method == "OPTIONS" + ): + try: + return original_pre_dispatch(self, rule, args) + except HTTPException as e: + if ( + isinstance(e.response, Response) + and e.response.status_code == 204 + and get_header("Access-Control-Allow-Headers") + ): + set_header( + "Access-Control-Allow-Headers", + f"{get_header('Access-Control-Allow-Headers')}, API-Key", + ) + raise + else: + return original_pre_dispatch(self, rule, args) + + Dispatcher.pre_dispatch = pre_dispatch + Dispatcher.pre_dispatch._original_method = original_pre_dispatch + + +def post_load_hook(): + patch_dispatcher_pre_dispatch() diff --git a/auth_api_key/tests/__init__.py b/auth_api_key/tests/__init__.py index 56e3e32a3a..308b13b596 100644 --- a/auth_api_key/tests/__init__.py +++ b/auth_api_key/tests/__init__.py @@ -1 +1,2 @@ from . import test_auth_api_key +from . import test_controllers diff --git a/auth_api_key/tests/test_controllers.py b/auth_api_key/tests/test_controllers.py new file mode 100644 index 0000000000..b965d1b49e --- /dev/null +++ b/auth_api_key/tests/test_controllers.py @@ -0,0 +1,57 @@ +# Copyright 2026 Camptocamp SA (https://www.camptocamp.com). +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json + +from odoo.http import Controller, route +from odoo.tests import HttpCase, new_test_user +from odoo.tools import mute_logger + + +class TestControllers(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.AuthApiKey = cls.env["auth.api.key"] + cls.test_user = new_test_user( + cls.env, + name="Test User", + login="test", + password="test", + email="test@test.com", + group_ids=[cls.env.ref("base.group_user").id], + company_id=cls.env.company.id, + ) + cls.api_key = cls.AuthApiKey.create( + {"name": "good", "user_id": cls.test_user.id, "key": "api_key"} + ) + + class DummyController(Controller): + @route("/web/auth-api-key", type="http", auth="api_key", sitemap=False) + def auth_api_key(self, **params): + return json.dumps({"name": self.env.user.name}) + + @route("/web/auth-api-key-cors", type="http", auth="api_key", cors="*") + def auth_api_key_cors(self, **params): + return json.dumps({"name": self.env.user.name}) + + cls.env.registry.clear_cache("routing") + cls.addClassCleanup(cls.env.registry.clear_cache, "routing") + + def test_auth_api_key_ok(self): + res = self.url_open("/web/auth-api-key", headers={"API-KEY": self.api_key.key}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json(), {"name": self.test_user.name}) + + @mute_logger("odoo.addons.base.models.ir_http") + def test_auth_api_key_wrong(self): + with self.assertLogs("odoo.http") as cm: + res = self.url_open("/web/auth-api-key", headers={"API-KEY": "wrong"}) + self.assertEqual(res.status_code, 403) + self.assertIn("Access Denied", cm.output[0]) + + def test_auth_api_key_cors_options(self): + res = self.url_open("/web/auth-api-key-cors", method="OPTIONS") + self.assertEqual(res.status_code, 204) + self.assertEqual(res.headers["Access-Control-Allow-Origin"], "*") + self.assertIn("API-Key", res.headers["Access-Control-Allow-Headers"])