From e7ddeab0cbe97c29bed044da34b69dde98a00a58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Todorovich?= Date: Thu, 2 Jul 2026 14:57:37 -0300 Subject: [PATCH 1/5] [IMP] webservice: configurable OAuth2 token request Until now the OAuth2 "Backend Application (Client Credentials)" flow always requested the token in one fixed way: an HTTP POST where the client id and secret were turned into an HTTP Basic Authorization header (this is what oauthlib does by default). Providers that deviate from that could not be used. Two configuration options are added to the webservice backend so those providers can be supported through configuration only: - Token Request Method: POST (default) or GET, for providers that expose the token endpoint as a GET. - Client Authentication: how the client credentials are presented to the token endpoint: * Client ID & Secret (HTTP Basic) (default): the previous behavior, unchanged. * Custom Authorization header: a static, verbatim header value (for example "SSWS "). In this case the Client ID / Client Secret fields are not used; the header name and value are configured directly instead. The defaults keep the exact same behavior as before, so existing backends are not affected. The custom header is injected through a small requests auth handler so that oauthlib does not overwrite it with its automatic Basic Authorization header. Two validation rules make sure the right fields are filled in depending on the chosen client authentication: the client id and secret for the HTTP Basic method, or the header name and value for the custom header method. --- webservice/README.rst | 43 +++++++- webservice/components/request_adapter.py | 41 +++++-- webservice/models/webservice_backend.py | 74 ++++++++++++- webservice/readme/CONFIGURE.md | 34 ++++++ webservice/static/description/index.html | 73 ++++++++++--- webservice/tests/test_oauth2.py | 131 +++++++++++++++++++++++ webservice/views/webservice_backend.xml | 31 +++++- webservice_core/utils.py | 21 ++++ 8 files changed, 420 insertions(+), 28 deletions(-) diff --git a/webservice/README.rst b/webservice/README.rst index bfe48010..0316abd1 100644 --- a/webservice/README.rst +++ b/webservice/README.rst @@ -11,7 +11,7 @@ WebService !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:0b0daf37b6803213b2c7cc10a8c0fa77aa36d8fcca336c709aab9bed0cc8aaf1 + !! source digest: sha256:9a5ad3534f5dd2efb2d013cb924461f3fdf7e1aadd3a95e05eb7dc7efbdaf50b !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png @@ -50,6 +50,9 @@ support. Configuration ============= +Upgrade from versions prior to 18.0.2.0.1 +----------------------------------------- + On upgrade from a version prior to ``18.0.2.0.1``, a ``webservice.request_content_only`` system parameter is created automatically (see that version's migration script) to preserve the @@ -61,6 +64,44 @@ now. Once it's adapted to use the full response object (see *Usage*), delete the parameter under *Settings > Technical > System Parameters* - new installs never get it set. +OAuth2 (Client Credentials) +--------------------------- + +For the *Backend Application (Client Credentials Grant)* flow, two extra +options control how the token is requested, so that endpoints which +deviate from the OAuth2 spec can still be used: + +- **Token Request Method**: ``POST`` (default) or ``GET``. Most + providers expose the token endpoint as a POST; some require a GET. +- **Client Authentication**: how the client credentials are presented to + the token endpoint: + + - *Client ID & Secret (HTTP Basic)* (default): the client id and + secret are sent as an + ``Authorization: Basic base64(client_id:client_secret)`` header + (``client_secret_basic``). + - *Custom Authorization header*: a static header value is sent + verbatim. The **Client Auth Header** (default ``Authorization``) and + **Client Auth Header Value** are configured directly; the Client ID + / Client Secret fields are not used in this case. + +Example: custom Authorization header +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some providers require the credentials in a non-standard Authorization +header (for instance Okta uses ``Authorization: SSWS ``). Such an +endpoint can be configured as: + +:: + + Auth Type = OAuth2 + OAuth2 Flow = Backend Application (Client Credentials Grant) + Token URL = https://provider.example.com/oauth2/token + Token Request Method = GET + Client Authentication = Custom Authorization header + Client Auth Header = Authorization + Client Auth Value = SSWS + Bug Tracker =========== diff --git a/webservice/components/request_adapter.py b/webservice/components/request_adapter.py index 837fcb94..1e471836 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -12,7 +12,7 @@ from requests_oauthlib import OAuth2Session from odoo.addons.component.core import Component -from odoo.addons.webservice_core.utils import sanitize_url_for_log +from odoo.addons.webservice_core.utils import StaticHeaderAuth, sanitize_url_for_log _logger = logging.getLogger(__name__) @@ -159,23 +159,50 @@ def _fetch_new_token(self, old_token): # be used (and use it in that case) oauth_params = self.collection.sudo().read( [ + "oauth2_client_auth_method", "oauth2_clientid", "oauth2_client_secret", + "oauth2_client_auth_header", + "oauth2_client_auth_value", "oauth2_token_url", + "oauth2_token_method", "oauth2_audience", "redirect_url", ] )[0] client = self.get_client(oauth_params) 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 "", - ) + token = session.fetch_token(**self._token_fetch_kwargs(oauth_params)) return token + def _token_fetch_kwargs(self, oauth_params): + """Build the ``OAuth2Session.fetch_token`` keyword arguments. + + Both the HTTP method used for the token request and the way the client + credentials are presented to the token endpoint depend on the backend + configuration, so that non fully spec-compliant endpoints can be used. + """ + kwargs = { + "method": oauth_params["oauth2_token_method"].upper(), + "token_url": oauth_params["oauth2_token_url"], + "audience": oauth_params.get("oauth2_audience") or "", + } + match oauth_params["oauth2_client_auth_method"]: + case "client_secret_basic": + kwargs["client_id"] = oauth_params["oauth2_clientid"] + kwargs["client_secret"] = oauth_params["oauth2_client_secret"] + case "custom_header": + kwargs["auth"] = StaticHeaderAuth( + oauth_params["oauth2_client_auth_header"], + oauth_params["oauth2_client_auth_value"], + ) + case _: + raise ValueError( + "Unsupported OAuth2 client authentication method: " + f"{oauth_params['oauth2_client_auth_method']!r}" + ) + return kwargs + def _request(self, method, url=None, url_params=None, **kwargs): url = self._get_url(url=url, url_params=url_params) self.collection._pop_deprecated_content_only_kwarg(kwargs) diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py index 83c71ee8..9ad696f2 100644 --- a/webservice/models/webservice_backend.py +++ b/webservice/models/webservice_backend.py @@ -5,7 +5,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging -from odoo import api, fields, models +from odoo import api, exceptions, fields, models from odoo.tools import config _logger = logging.getLogger(__name__) @@ -37,9 +37,37 @@ class WebserviceBackend(models.Model): ], readonly=False, ) - oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2") - oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2") + oauth2_client_auth_method = fields.Selection( + [ + ("client_secret_basic", "Client ID & Secret (HTTP Basic)"), + ("custom_header", "Custom Authorization header"), + ], + default="client_secret_basic", + string="Client Authentication", + help="How the client credentials are presented to the token endpoint.", + ) + oauth2_clientid = fields.Char(string="Client ID") + oauth2_client_secret = fields.Char(string="Client Secret") + oauth2_client_auth_header = fields.Char( + string="Client Auth Header", + default="Authorization", + help="Header name used to send the client credentials when the client " + "authentication method is a custom Authorization header.", + ) + oauth2_client_auth_value = fields.Char( + string="Client Auth Header Value", + help="Full, static header value sent to the token endpoint when the " + "client authentication method is a custom Authorization header " + "(e.g. 'SSWS ').", + ) oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2") + oauth2_token_method = fields.Selection( + [("post", "POST"), ("get", "GET")], + default="post", + string="Token Request Method", + help="HTTP method used to request the token from the token endpoint. " + "Most providers use POST; some expose the token endpoint as GET.", + ) oauth2_authorization_url = fields.Char(string="Authorization URL") oauth2_audience = fields.Char( string="Audience" @@ -72,6 +100,46 @@ def create(self, vals_list): ).oauth2_flow = False return records + @api.constrains( + "auth_type", + "oauth2_client_auth_method", + "oauth2_clientid", + "oauth2_client_secret", + ) + def _check_oauth2_client_secret_basic(self): + for rec in self: + if rec.auth_type != "oauth2": + continue + if rec.oauth2_client_auth_method != "client_secret_basic": + continue + missing = [ + rec._fields[fname] + for fname in ("oauth2_clientid", "oauth2_client_secret") + if not rec[fname] + ] + if missing: + raise exceptions.UserError(rec._msg_missing_auth_param(missing)) + + @api.constrains( + "auth_type", + "oauth2_client_auth_method", + "oauth2_client_auth_header", + "oauth2_client_auth_value", + ) + def _check_oauth2_custom_header(self): + for rec in self: + if rec.auth_type != "oauth2": + continue + if rec.oauth2_client_auth_method != "custom_header": + continue + missing = [ + rec._fields[fname] + for fname in ("oauth2_client_auth_header", "oauth2_client_auth_value") + if not rec[fname] + ] + if missing: + raise exceptions.UserError(rec._msg_missing_auth_param(missing)) + def write(self, vals): res = super().write(vals) if "auth_type" in vals: diff --git a/webservice/readme/CONFIGURE.md b/webservice/readme/CONFIGURE.md index 491de0ca..d7f2cb33 100644 --- a/webservice/readme/CONFIGURE.md +++ b/webservice/readme/CONFIGURE.md @@ -1,3 +1,5 @@ +## Upgrade from versions prior to 18.0.2.0.1 + On upgrade from a version prior to `18.0.2.0.1`, a `webservice.request_content_only` system parameter is created automatically (see that version's migration script) to preserve the previous default @@ -8,3 +10,35 @@ If your code relies on that implicit default, keep the parameter for now. Once it's adapted to use the full response object (see *Usage*), delete the parameter under *Settings > Technical > System Parameters* - new installs never get it set. + +## OAuth2 (Client Credentials) + +For the *Backend Application (Client Credentials Grant)* flow, two extra options +control how the token is requested, so that endpoints which deviate from the +OAuth2 spec can still be used: + +- **Token Request Method**: `POST` (default) or `GET`. Most providers expose the + token endpoint as a POST; some require a GET. +- **Client Authentication**: how the client credentials are presented to the + token endpoint: + - *Client ID & Secret (HTTP Basic)* (default): the client id and secret are + sent as an `Authorization: Basic base64(client_id:client_secret)` header + (`client_secret_basic`). + - *Custom Authorization header*: a static header value is sent verbatim. The + **Client Auth Header** (default `Authorization`) and **Client Auth Header + Value** are configured directly; the Client ID / Client Secret fields are + not used in this case. + +### Example: custom Authorization header + +Some providers require the credentials in a non-standard Authorization header +(for instance Okta uses `Authorization: SSWS `). Such an endpoint can be +configured as: + + Auth Type = OAuth2 + OAuth2 Flow = Backend Application (Client Credentials Grant) + Token URL = https://provider.example.com/oauth2/token + Token Request Method = GET + Client Authentication = Custom Authorization header + Client Auth Header = Authorization + Client Auth Value = SSWS diff --git a/webservice/static/description/index.html b/webservice/static/description/index.html index fb136b31..3c4e022b 100644 --- a/webservice/static/description/index.html +++ b/webservice/static/description/index.html @@ -3,7 +3,7 @@ -WebService +README.rst -
+
+

WebService Server Environment

- - -Odoo Community Association - -
-

WebService Server Environment

-

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

+

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

Glue module to make Server Environment features available for the Webservice addon.

Table of contents

@@ -390,7 +385,7 @@

WebService Server Environment

-

Bug Tracker

+

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 @@ -398,23 +393,23 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Creu Blanca
  • Camptocamp
-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -429,6 +424,5 @@

Maintainers

-
From 05879415a8542b62fdbb5db33a1ac73fe13b5bc9 Mon Sep 17 00:00:00 2001 From: Ricardoalso Date: Thu, 24 Sep 2026 20:52:00 +0200 Subject: [PATCH 5/5] fixup! [IMP] webservice_server_env: expose new OAuth2 options to server env --- webservice_server_env/hooks.py | 4 +++ .../models/webservice_backend.py | 35 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/webservice_server_env/hooks.py b/webservice_server_env/hooks.py index a3c3db44..b860e489 100644 --- a/webservice_server_env/hooks.py +++ b/webservice_server_env/hooks.py @@ -19,6 +19,10 @@ "oauth2_authorization_url", "oauth2_token_url", "oauth2_audience", + "oauth2_token_method", + "oauth2_client_auth_method", + "oauth2_client_auth_header", + "oauth2_client_auth_value", ] diff --git a/webservice_server_env/models/webservice_backend.py b/webservice_server_env/models/webservice_backend.py index 8e9eae88..a11889a4 100644 --- a/webservice_server_env/models/webservice_backend.py +++ b/webservice_server_env/models/webservice_backend.py @@ -4,6 +4,9 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). from odoo import models +from odoo.tools import mute_logger + +from odoo.addons.server_environment.models.server_env_mixin import _partialmethod class WebserviceBackend(models.Model): @@ -37,9 +40,31 @@ def _server_env_fields(self): webservice_fields.update(base_fields) return webservice_fields - def _compute_server_env(self): - # OVERRIDE: reset ``oauth2_flow`` when ``auth_type`` is not "oauth2", even if - # defined otherwise in server env vars - res = super()._compute_server_env() - self.filtered(lambda r: r.auth_type != "oauth2").oauth2_flow = None + def _server_env_transform_field_to_read_from_env(self, field): + # OVERRIDE: give each env-managed field its own compute method. With the + # shared ``_compute_server_env``, Odoo groups them all together: writing + # one protects the others, and those not cached yet read as False + # (e.g. ``auth_type`` in the OAuth2 constraints when writing + # ``oauth2_client_auth_method``). + res = super()._server_env_transform_field_to_read_from_env(field) + compute_name = f"_compute_server_env_{field.name}" + compute_method = _partialmethod( + type(self)._compute_server_env_field, field.name, __name__=compute_name + ) + # Mute message related to new safeguard (PR odoo/odoo#247151) + with mute_logger("odoo.tests.common"): + setattr(type(self), compute_name, compute_method) + field.compute = compute_name return res + + def _compute_server_env_field(self, field_name): + options = self._server_env_fields[field_name] + for record in self: + if record._server_env_has_key_defined(field_name): + record._compute_server_env_from_config(field_name, options) + else: + record._compute_server_env_from_default(field_name, options) + if field_name == "oauth2_flow": + # reset ``oauth2_flow`` when ``auth_type`` is not "oauth2", even if + # defined otherwise in server env vars + self.filtered(lambda r: r.auth_type != "oauth2").oauth2_flow = None