Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions auth_api_key/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import models
from .hooks import post_load_hook
1 change: 1 addition & 0 deletions auth_api_key/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions auth_api_key/hooks.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions auth_api_key/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_auth_api_key
from . import test_controllers
57 changes: 57 additions & 0 deletions auth_api_key/tests/test_controllers.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading