From 8b12ca9a727a09984c76395be04c7702f345b3f1 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Wed, 28 Feb 2024 11:02:24 +0100 Subject: [PATCH 1/4] [IMP] webservice: add support for oauth2 Allow using oauth2 with Backend Application Flow / Client Credentials Grant. --- requirements.txt | 2 + webservice/README.rst | 3 +- webservice/__manifest__.py | 1 + webservice/components/request_adapter.py | 95 ++++++++++++++++ webservice/models/webservice_backend.py | 25 ++++- webservice/readme/CONTRIBUTORS.rst | 1 + webservice/static/description/index.html | 2 + webservice/tests/__init__.py | 2 +- webservice/tests/common.py | 22 +++- webservice/tests/test_oauth2.py | 137 +++++++++++++++++++++++ webservice/views/webservice_backend.xml | 16 +++ 11 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 requirements.txt create mode 100644 webservice/tests/test_oauth2.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..ae0ca88c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +requests-oauthlib diff --git a/webservice/README.rst b/webservice/README.rst index c673a2ff..282d3aa3 100644 --- a/webservice/README.rst +++ b/webservice/README.rst @@ -7,7 +7,7 @@ WebService !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:b21de3647819aeba7178e146f697f7d79b8cf865eaf19d5cf45f3bdd0bb5802f + !! source digest: sha256:99e7076dd22cda2bbbd7f2d22620e80360e0a566ea013f425ce9a7022c432a38 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png @@ -58,6 +58,7 @@ Contributors ~~~~~~~~~~~~ * Enric Tobella +* Alexandre Fayolle Maintainers ~~~~~~~~~~~ diff --git a/webservice/__manifest__.py b/webservice/__manifest__.py index a9d40e5d..85e64dc5 100644 --- a/webservice/__manifest__.py +++ b/webservice/__manifest__.py @@ -15,6 +15,7 @@ "author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/web-api", "depends": ["component", "server_environment"], + "external_dependencies": {"python": ["requests-oauthlib"]}, "data": [ "security/ir.model.access.csv", "security/ir_rule.xml", diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py index b92f1feb..35a05b6d 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -3,7 +3,12 @@ # @author Simone Orsi # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import time + import requests +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2Session from odoo.addons.component.core import Component @@ -65,3 +70,93 @@ def _get_url(self, url=None, url_params=None, **kwargs): url = self.collection.url url_params = url_params or kwargs return url.format(**url_params) + + +class OAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests" + _webservice_protocol = "http+oauth2" + _inherit = "base.requests" + + def __init__(self, *args, **kw): + super().__init__(*args, **kw) + # cached value to avoid hitting the database each time we need the token + self._token = {} + + def _is_token_valid(self, token): + """Validate given oauth2 token. + + We consider that a token in valid if it has at least 10% of + its valid duration. So if a token has a validity of 1h, we will + renew it if we try to use it 6 minutes before its expiration date. + """ + expires_at = token.get("expires_at", 0) + expires_in = token.get("expires_in", 3600) # default to 1h + now = time.time() + return now <= (expires_at - 0.1 * expires_in) + + @property + def token(self): + """Return a valid oauth2 token. + + The tokens are stored in the database, and we check if they are still + valid, and renew them if needed. + """ + if self._is_token_valid(self._token): + return self._token + backend = self.collection + with backend.env.registry.cursor() as cr: + cr.execute( + "SELECT oauth2_token FROM webservice_backend " + "WHERE id=%s " + "FOR NO KEY UPDATE", # prevent concurrent token fetching + (backend.id,), + ) + token_str = cr.fetchone()[0] or "{}" + token = json.loads(token_str) + if self._is_token_valid(token): + self._token = token + else: + new_token = self._fetch_new_token(old_token=token) + cr.execute( + "UPDATE webservice_backend " "SET oauth2_token=%s " "WHERE id=%s", + (json.dumps(new_token), backend.id), + ) + self._token = new_token + return self._token + + def _fetch_new_token(self, old_token): + # TODO: check if the old token has a refresh_token that can + # be used (and use it in that case) + oauth_params = self.collection.sudo().read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + ] + )[0] + client = BackendApplicationClient(client_id=oauth_params["oauth2_clientid"]) + with OAuth2Session(client=client) as session: + token = session.fetch_token( + token_url=oauth_params["oauth2_token_url"], + cliend_id=oauth_params["oauth2_clientid"], + client_secret=oauth_params["oauth2_client_secret"], + audience=oauth_params.get("oauth2_audience") or "", + ) + return token + + def _request(self, method, url=None, url_params=None, **kwargs): + url = self._get_url(url=url, url_params=url_params) + new_kwargs = kwargs.copy() + new_kwargs.update( + { + "headers": self._get_headers(**kwargs), + "timeout": None, + } + ) + client = BackendApplicationClient(client_id=self.collection.oauth2_clientid) + with OAuth2Session(client=client, token=self.token) as session: + # pylint: disable=E8106 + request = session.request(method, url, **new_kwargs) + request.raise_for_status() + return request.content diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py index 84567f96..c4dc0d8f 100644 --- a/webservice/models/webservice_backend.py +++ b/webservice/models/webservice_backend.py @@ -1,8 +1,10 @@ # Copyright 2020 Creu Blanca # Copyright 2022 Camptocamp SA # @author Simone Orsi +# @author Alexandre Fayolle # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + from odoo import _, api, exceptions, fields, models @@ -21,14 +23,22 @@ class WebserviceBackend(models.Model): ("none", "Public"), ("user_pwd", "Username & password"), ("api_key", "API Key"), + ("oauth2", "OAuth2 Backend Application Flow (Client Credentials Grant)"), ], - default="user_pwd", required=True, ) username = fields.Char(auth_type="user_pwd") password = fields.Char(auth_type="user_pwd") api_key = fields.Char(string="API Key", auth_type="api_key") api_key_header = fields.Char(string="API Key header", auth_type="api_key") + oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2") + oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2") + oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2") + oauth2_audience = fields.Char( + string="Audience" + # no auth_type because not required + ) + oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)") content_type = fields.Selection( [ ("application/json", "JSON"), @@ -78,9 +88,16 @@ def call(self, method, *args, **kwargs): def _get_adapter(self): with self.work_on(self._name) as work: return work.component( - usage="webservice.request", webservice_protocol=self.protocol + usage="webservice.request", + webservice_protocol=self._get_adapter_protocol(), ) + def _get_adapter_protocol(self): + protocol = self.protocol + if self.auth_type.startswith("oauth2"): + protocol += f"+{self.auth_type}" + return protocol + @property def _server_env_fields(self): base_fields = super()._server_env_fields @@ -93,6 +110,10 @@ def _server_env_fields(self): "api_key": {}, "api_key_header": {}, "content_type": {}, + "oauth2_clientid": {}, + "oauth2_client_secret": {}, + "oauth2_token_url": {}, + "oauth2_audience": {}, } webservice_fields.update(base_fields) return webservice_fields diff --git a/webservice/readme/CONTRIBUTORS.rst b/webservice/readme/CONTRIBUTORS.rst index 93ec993e..bfbf32a6 100644 --- a/webservice/readme/CONTRIBUTORS.rst +++ b/webservice/readme/CONTRIBUTORS.rst @@ -1 +1,2 @@ * Enric Tobella +* Alexandre Fayolle diff --git a/webservice/static/description/index.html b/webservice/static/description/index.html index 55b7d1c0..a93365cd 100644 --- a/webservice/static/description/index.html +++ b/webservice/static/description/index.html @@ -1,3 +1,4 @@ + @@ -403,6 +404,7 @@

Authors

Contributors

diff --git a/webservice/tests/__init__.py b/webservice/tests/__init__.py index c7c6b2c0..44111f05 100644 --- a/webservice/tests/__init__.py +++ b/webservice/tests/__init__.py @@ -1 +1 @@ -from . import test_webservice +from . import test_webservice, test_oauth2 diff --git a/webservice/tests/common.py b/webservice/tests/common.py index 07266402..81627131 100644 --- a/webservice/tests/common.py +++ b/webservice/tests/common.py @@ -1,5 +1,7 @@ # Copyright 2020 Creu Blanca # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from contextlib import contextmanager +from unittest import mock from odoo.tests.common import tagged @@ -11,7 +13,9 @@ class CommonWebService(TransactionComponentCase): @classmethod def _setup_context(cls): return dict( - cls.env.context, tracking_disable=True, test_queue_job_no_delay=True + cls.env.context, + tracking_disable=True, + test_queue_job_no_delay=True, ) @classmethod @@ -27,3 +31,19 @@ def setUpClass(cls): super().setUpClass() cls._setup_env() cls._setup_records() + + +@contextmanager +def mock_cursor(cr): + with mock.patch("odoo.sql_db.Connection.cursor") as mocked_cursor_call: + org_close = cr.close + org_autocommit = cr.autocommit + try: + cr.close = mock.Mock() + cr.autocommit = mock.Mock() + cr.commit = mock.Mock() + mocked_cursor_call.return_value = cr + yield + finally: + cr.close = org_close + cr.autocommit = org_autocommit diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py new file mode 100644 index 00000000..d34b8b04 --- /dev/null +++ b/webservice/tests/test_oauth2.py @@ -0,0 +1,137 @@ +# Copyright 2023 Camptocamp SA +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import json +import os +import time + +import responses +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError + +from .common import CommonWebService, mock_cursor + + +class TestWebService(CommonWebService): + @classmethod + def _setup_records(cls): + + res = super()._setup_records() + cls.url = "https://localhost.demo.odoo/" + os.environ["SERVER_ENV_CONFIG"] = "\n".join( + [ + "[webservice_backend.test_oauth2]", + "auth_type = oauth2", + "oauth2_clientid = some_client_id", + "oauth2_client_secret = shh_secret", + f"oauth2_token_url = {cls.url}oauth2/token", + f"oauth2_audience = {cls.url}", + ] + ) + cls.webservice = cls.env["webservice.backend"].create( + { + "name": "WebService OAuth2", + "tech_name": "test_oauth2", + "auth_type": "oauth2", + "protocol": "http", + "url": cls.url, + "content_type": "application/xml", + "oauth2_clientid": "some_client_id", + "oauth2_client_secret": "shh_secret", + "oauth2_token_url": f"{cls.url}oauth2/token", + "oauth2_audience": cls.url, + } + ) + return res + + def test_get_adapter_protocol(self): + protocol = self.webservice._get_adapter_protocol() + self.assertEqual(protocol, "http+oauth2") + + @responses.activate + def test_fetch_token(self): + duration = 3600 + expires_timestamp = time.time() + duration + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "expires_at": expires_timestamp, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + + with mock_cursor(self.env.cr): + result = self.webservice.call("get", url=f"{self.url}endpoint") + self.webservice.refresh() + self.assertTrue("cool_token" in self.webservice.oauth2_token) + self.assertEqual(result, b"OK") + + @responses.activate + def test_update_token(self): + duration = 3600 + self.webservice.oauth2_token = json.dumps( + { + "access_token": "old_token", + "expires_at": time.time() + 10, # in the near future + "expires_in": duration, + "token_type": "Bearer", + } + ) + self.webservice.flush_model() + + expires_timestamp = time.time() + duration + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "expires_at": expires_timestamp, + "expires_in": duration, + "token_type": "Bearer", + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + + with mock_cursor(self.env.cr): + result = self.webservice.call("get", url=f"{self.url}endpoint") + self.env.cr.commit.assert_called_once_with() # one call with no args + + self.webservice.refresh() + self.assertTrue("cool_token" in self.webservice.oauth2_token) + self.assertEqual(result, b"OK") + + @responses.activate + def test_update_token_with_error(self): + duration = 3600 + self.webservice.oauth2_token = json.dumps( + { + "access_token": "old_token", + "expires_at": time.time() + 10, # in the near future + "expires_in": duration, + "token_type": "Bearer", + } + ) + self.webservice.flush_model() + + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "error": "invalid_grant", + "error_description": "invalid grant", + }, + status=404, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="NOK", status=403) + + with mock_cursor(self.env.cr): + with self.assertRaises(InvalidGrantError): + self.webservice.call("get", url=f"{self.url}endpoint") + self.env.cr.commit.assert_not_called() + self.env.cr.close.assert_called_once_with() # one call with no args + + self.webservice.refresh() + self.assertTrue("old_token" in self.webservice.oauth2_token) diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml index 9fcf304e..e88ebb06 100644 --- a/webservice/views/webservice_backend.xml +++ b/webservice/views/webservice_backend.xml @@ -58,6 +58,22 @@ 'required': [('auth_type', '=', 'api_key')], }" /> + + + + From f81a3b70b8db0f8ef56993d26a5b8999f95fa21f Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Tue, 16 Apr 2024 12:01:03 +0200 Subject: [PATCH 2/4] add support for oauth2 web application flow --- webservice/README.rst | 2 +- webservice/__init__.py | 1 + webservice/components/request_adapter.py | 68 ++++++++++++++++++++++-- webservice/controllers/__init__.py | 1 + webservice/controllers/oauth2.py | 40 ++++++++++++++ webservice/models/webservice_backend.py | 48 ++++++++++++++++- webservice/tests/test_oauth2.py | 10 ++-- webservice/views/webservice_backend.xml | 21 +++++++- 8 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 webservice/controllers/__init__.py create mode 100644 webservice/controllers/oauth2.py diff --git a/webservice/README.rst b/webservice/README.rst index 282d3aa3..e9a1e3f6 100644 --- a/webservice/README.rst +++ b/webservice/README.rst @@ -7,7 +7,7 @@ WebService !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:99e7076dd22cda2bbbd7f2d22620e80360e0a566ea013f425ce9a7022c432a38 + !! source digest: sha256:0aa627676ddb529ec39b4d402da89e45c2658ece70763b4074ee9a989ee328af !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png diff --git a/webservice/__init__.py b/webservice/__init__.py index f24d3e24..f64c35ff 100644 --- a/webservice/__init__.py +++ b/webservice/__init__.py @@ -1,2 +1,3 @@ from . import components from . import models +from . import controllers diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py index 35a05b6d..f5b5a8d1 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -7,7 +7,7 @@ import time import requests -from oauthlib.oauth2 import BackendApplicationClient +from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient from requests_oauthlib import OAuth2Session from odoo.addons.component.core import Component @@ -72,11 +72,14 @@ def _get_url(self, url=None, url_params=None, **kwargs): return url.format(**url_params) -class OAuth2RestRequestsAdapter(Component): - _name = "oauth2.requests" - _webservice_protocol = "http+oauth2" +class BackendApplicationOAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests.backend.application" + _webservice_protocol = "http+oauth2-backend_application" _inherit = "base.requests" + def get_client(self, oauth_params: dict): + return BackendApplicationClient(client_id=oauth_params["oauth2_clientid"]) + def __init__(self, *args, **kw): super().__init__(*args, **kw) # cached value to avoid hitting the database each time we need the token @@ -135,7 +138,7 @@ def _fetch_new_token(self, old_token): "oauth2_audience", ] )[0] - client = BackendApplicationClient(client_id=oauth_params["oauth2_clientid"]) + client = self.get_client(oauth_params) with OAuth2Session(client=client) as session: token = session.fetch_token( token_url=oauth_params["oauth2_token_url"], @@ -160,3 +163,58 @@ def _request(self, method, url=None, url_params=None, **kwargs): request = session.request(method, url, **new_kwargs) request.raise_for_status() return request.content + + +class WebApplicationOAuth2RestRequestsAdapter(Component): + _name = "oauth2.requests.web.application" + _webservice_protocol = "http+oauth2-authorization_code" + _inherit = "oauth2.requests.backend.application" + + def get_client(self, oauth_params: dict): + return WebApplicationClient( + client_id=oauth_params["oauth2_clientid"], + code=oauth_params.get("oauth2_autorization"), + redirect_uri=oauth_params["oauth2_re"], + ) + + def _fetch_token_from_authorization(self, authorization_code): + + oauth_params = self.collection.sudo().read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + "oauth2_authorization", + ] + )[0] + client = WebApplicationClient(client_id=oauth_params["oauth2_clientid"]) + with OAuth2Session(client=client) as session: + token = session.fetch_token( + token_url=oauth_params["oauth2_token_url"], + cliend_id=oauth_params["oauth2_clientid"], + client_secret=oauth_params["oauth2_client_secret"], + audience=oauth_params.get("oauth2_audience") or "", + ) + return token + + def redirect_to_authorize(self, **authorization_url_extra_params): + # we are normally authenticated at this stage, so no need to sudo() + backend = self.collection + oauth_params = backend.read( + [ + "oauth2_clientid", + "oauth2_client_secret", + "oauth2_token_url", + "oauth2_audience", + "oauth2_authorization", + ] + )[0] + client = WebApplicationClient(client_id=oauth_params["oauth2_clientid"]) + + with OAuth2Session(client=client) as session: + authorization_url, state = session.authorization_url( + backend.oauth2_authorization_url, **authorization_url_extra_params + ) + backend.oauth2_state = state + return authorization_url diff --git a/webservice/controllers/__init__.py b/webservice/controllers/__init__.py new file mode 100644 index 00000000..005c729e --- /dev/null +++ b/webservice/controllers/__init__.py @@ -0,0 +1 @@ +from . import oauth2 diff --git a/webservice/controllers/oauth2.py b/webservice/controllers/oauth2.py new file mode 100644 index 00000000..141f0fa5 --- /dev/null +++ b/webservice/controllers/oauth2.py @@ -0,0 +1,40 @@ +# Copyright 2024 Camptocamp SA +# @author Alexandre Fayolle +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +from oauthlib.oauth2.rfc6749 import errors + +from odoo import http + +_logger = logging.getLogger(__name__) + + +class OAuth2Controller(http.Controller): + @http.route( + "/webservice/{backend_id}/oauth2/redirect", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def redirect(self, backend_id, **params): + backend = self.env["webservice.backend"].browse(backend_id).sudo() + if backend.auth_type != "oauth2" or backend.oauth2_flow != "authorization_code": + _logger.error("unexpected backed config for backend %d", backend_id) + raise errors.MismatchingRedirectURIError() + expected_state = backend.oauth2_state + state = params.get("state") + if state != expected_state: + _logger.error("unexpected state: %s", state) + raise errors.MismatchingStateError() + params.get("code") + adapter = ( + backend.get_adapter() + ) # if expect an adapter support authorization_code + adapter._fetch_token_from_authorization() + action = self.env["ir.action"]._for_xml_id( + "webservice.webservice_backend_act_window" + ) + action["res_id"] = backend_id + return action diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py index c4dc0d8f..ab87b003 100644 --- a/webservice/models/webservice_backend.py +++ b/webservice/models/webservice_backend.py @@ -31,14 +31,33 @@ class WebserviceBackend(models.Model): password = fields.Char(auth_type="user_pwd") api_key = fields.Char(string="API Key", auth_type="api_key") api_key_header = fields.Char(string="API Key header", auth_type="api_key") + oauth2_flow = fields.Selection( + [ + ("backend_application", "Backend Application (Client Credentials Grant)"), + ("authorization_code", "Web Application (Authorization Code Grant)"), + ], + readonly=False, + store=True, + compute="_compute_oauth2_flow", + ) oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2") oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2") oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2") + oauth2_authorization_url = fields.Char(string="Authorization URL") oauth2_audience = fields.Char( string="Audience" # no auth_type because not required ) + oauth2_scope = fields.Char(help="scope of the the authorization") oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)") + redirect_url = fields.Char( + compute="_compute_redirect_url", + help="The redirect URL to be used as part of the OAuth2 authorisation flow", + ) + oauth2_state = fields.Char( + help="random key generated when authorization flow starts " + "to ensure that no CSRF attack happen" + ) content_type = fields.Selection( [ ("application/json", "JSON"), @@ -95,9 +114,33 @@ def _get_adapter(self): def _get_adapter_protocol(self): protocol = self.protocol if self.auth_type.startswith("oauth2"): - protocol += f"+{self.auth_type}" + protocol += f"+{self.auth_type}-{self.oauth2_flow}" return protocol + @api.depends("auth_type") + def _compute_oauth2_flow(self): + for rec in self: + if rec.auth_type != "oauth2": + rec.oauth2_flow = False + + @api.depends("auth_type", "oauth2_flow") + def _compute_redirect_url(self): + get_param = self.env["ir.config_parameter"].sudo().get_param + base_url = get_param("web.base.url") + for rec in self: + if rec.auth_type == "oauth2" and rec.oauth2_flow == "authorization_flow": + rec.redirect_url = base_url + f"/webservice/{rec.id}/oauth2/redirect" + else: + rec.redirect_url = False + + def button_authorize(self): + self._get_adapter().redirect_to_authorize() + return { + "type": "ir.actions.act_url", + "url": "authorize_url", + "target": "self", + } + @property def _server_env_fields(self): base_fields = super()._server_env_fields @@ -110,8 +153,11 @@ def _server_env_fields(self): "api_key": {}, "api_key_header": {}, "content_type": {}, + "oauth2_flow": {}, + "oauth2_scope": {}, "oauth2_clientid": {}, "oauth2_client_secret": {}, + "oauth2_authorization_url": {}, "oauth2_token_url": {}, "oauth2_audience": {}, } diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py index d34b8b04..4f4f5e06 100644 --- a/webservice/tests/test_oauth2.py +++ b/webservice/tests/test_oauth2.py @@ -21,6 +21,7 @@ def _setup_records(cls): [ "[webservice_backend.test_oauth2]", "auth_type = oauth2", + "oauth2_flow = backend_application", "oauth2_clientid = some_client_id", "oauth2_client_secret = shh_secret", f"oauth2_token_url = {cls.url}oauth2/token", @@ -34,6 +35,7 @@ def _setup_records(cls): "auth_type": "oauth2", "protocol": "http", "url": cls.url, + "oauth2_flow": "backend_application", "content_type": "application/xml", "oauth2_clientid": "some_client_id", "oauth2_client_secret": "shh_secret", @@ -45,7 +47,7 @@ def _setup_records(cls): def test_get_adapter_protocol(self): protocol = self.webservice._get_adapter_protocol() - self.assertEqual(protocol, "http+oauth2") + self.assertEqual(protocol, "http+oauth2-backend_application") @responses.activate def test_fetch_token(self): @@ -65,7 +67,7 @@ def test_fetch_token(self): with mock_cursor(self.env.cr): result = self.webservice.call("get", url=f"{self.url}endpoint") - self.webservice.refresh() + self.webservice.invalidate_recordset() self.assertTrue("cool_token" in self.webservice.oauth2_token) self.assertEqual(result, b"OK") @@ -99,7 +101,7 @@ def test_update_token(self): result = self.webservice.call("get", url=f"{self.url}endpoint") self.env.cr.commit.assert_called_once_with() # one call with no args - self.webservice.refresh() + self.webservice.invalidate_recordset() self.assertTrue("cool_token" in self.webservice.oauth2_token) self.assertEqual(result, b"OK") @@ -133,5 +135,5 @@ def test_update_token_with_error(self): self.env.cr.commit.assert_not_called() self.env.cr.close.assert_called_once_with() # one call with no args - self.webservice.refresh() + self.webservice.invalidate_recordset() self.assertTrue("old_token" in self.webservice.oauth2_token) diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml index e88ebb06..beeeb134 100644 --- a/webservice/views/webservice_backend.xml +++ b/webservice/views/webservice_backend.xml @@ -8,7 +8,14 @@ webservice.backend
-
+
+
@@ -69,6 +69,10 @@ name="oauth2_flow" attrs="{'invisible': [('auth_type', '!=', 'oauth2')], 'required': [('auth_type', '=', 'oauth2')]}" /> + Date: Mon, 6 Oct 2025 14:42:35 +0200 Subject: [PATCH 4/4] Add scope fetching oauth2 session token --- webservice/components/request_adapter.py | 2 ++ webservice/static/description/index.html | 14 ++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py index fb31e973..8b1a7a8d 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -139,6 +139,7 @@ def _fetch_new_token(self, old_token): "oauth2_client_secret", "oauth2_token_url", "oauth2_audience", + "oauth2_scope", "redirect_url", ] )[0] @@ -149,6 +150,7 @@ def _fetch_new_token(self, old_token): cliend_id=oauth_params["oauth2_clientid"], client_secret=oauth_params["oauth2_client_secret"], audience=oauth_params.get("oauth2_audience") or "", + scope=oauth_params.get("oauth2_scope") or "", ) return token diff --git a/webservice/static/description/index.html b/webservice/static/description/index.html index a93365cd..3287a0ff 100644 --- a/webservice/static/description/index.html +++ b/webservice/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,7 +367,7 @@

WebService

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:b21de3647819aeba7178e146f697f7d79b8cf865eaf19d5cf45f3bdd0bb5802f +!! source digest: sha256:0aa627676ddb529ec39b4d402da89e45c2658ece70763b4074ee9a989ee328af !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

This module creates WebService frameworks to be used globally

@@ -410,7 +410,9 @@

Contributors

Maintainers

This module is maintained by the OCA.

-Odoo Community Association + +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.