From 2a69e791e55ce5abe04dfe9d6f0d09df20d0e285 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/3] [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 | 41 +++++++ webservice/components/request_adapter.py | 41 +++++-- webservice/models/webservice_backend.py | 72 ++++++++++++- webservice/readme/CONFIGURE.md | 31 ++++++ webservice/static/description/index.html | 67 ++++++++++-- webservice/tests/test_oauth2.py | 132 +++++++++++++++++++++++ webservice/utils.py | 21 ++++ webservice/views/webservice_backend.xml | 31 +++++- 8 files changed, 413 insertions(+), 23 deletions(-) create mode 100644 webservice/readme/CONFIGURE.md diff --git a/webservice/README.rst b/webservice/README.rst index a01025ef..55180a64 100644 --- a/webservice/README.rst +++ b/webservice/README.rst @@ -43,6 +43,47 @@ HTTP call returns by default the content of the response. A context .. contents:: :local: +Configuration +============= + +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 a9732398..99645343 100644 --- a/webservice/components/request_adapter.py +++ b/webservice/components/request_adapter.py @@ -13,7 +13,7 @@ from odoo.addons.component.core import Component -from ..utils import sanitize_url_for_log +from ..utils import StaticHeaderAuth, sanitize_url_for_log _logger = logging.getLogger(__name__) @@ -160,23 +160,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"], - client_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) content_only = kwargs.pop("content_only", True) diff --git a/webservice/models/webservice_backend.py b/webservice/models/webservice_backend.py index 4116d618..e7301306 100644 --- a/webservice/models/webservice_backend.py +++ b/webservice/models/webservice_backend.py @@ -40,9 +40,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" @@ -83,6 +111,46 @@ def _check_auth_type(self): if missing: raise exceptions.UserError(rec._msg_missing_auth_param(missing)) + @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 _msg_missing_auth_param(self, missing_fields): def get_selection_value(fname): return self._fields.get(fname).convert_to_export(self[fname], self) diff --git a/webservice/readme/CONFIGURE.md b/webservice/readme/CONFIGURE.md new file mode 100644 index 00000000..5285301d --- /dev/null +++ b/webservice/readme/CONFIGURE.md @@ -0,0 +1,31 @@ +## 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 ebcfdd44..3f6f7f24 100644 --- a/webservice/static/description/index.html +++ b/webservice/static/description/index.html @@ -382,17 +382,64 @@

WebService

Table of contents

+
+

Configuration

+
+

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 <token>). 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 <token>
+
+
+
-

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 @@ -400,23 +447,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 diff --git a/webservice/tests/test_oauth2.py b/webservice/tests/test_oauth2.py index 49be4f1c..388d1548 100644 --- a/webservice/tests/test_oauth2.py +++ b/webservice/tests/test_oauth2.py @@ -1,6 +1,7 @@ # Copyright 2023 Camptocamp SA # @author Alexandre Fayolle # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 import json import time from urllib.parse import quote @@ -8,6 +9,8 @@ import responses from oauthlib.oauth2.rfc6749.errors import InvalidGrantError +from odoo import exceptions + from .common import CommonWebService, mock_cursor @@ -175,6 +178,135 @@ def test_call_with_content_only_false_returns_response(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"ok": True}) + @responses.activate + def test_fetch_token_client_secret_basic(self): + """Client credentials are sent as an HTTP Basic Authorization header. + + Scenario: + 1. Fetch a token with the default (HTTP Basic) authentication. + Expected: + - The token request carries a Basic Authorization header built from + the client id and secret. + - The client secret is not duplicated in the request body. + """ + self.webservice.oauth2_client_auth_method = "client_secret_basic" + now = time.time() + duration = 3600 + responses.add( + responses.POST, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "token_type": "Bearer", + "expires_in": duration, + "expires_at": now + duration, + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + with mock_cursor(self.env.cr): + self.webservice.call("get", url=f"{self.url}endpoint") + token_request = responses.calls[0].request + expected = base64.b64encode(b"some_client_id:shh_secret").decode() + self.assertEqual(token_request.headers["Authorization"], f"Basic {expected}") + self.assertNotIn("client_secret", token_request.body or "") + + @responses.activate + def test_fetch_token_custom_header_get(self): + """Client credentials are sent verbatim in a custom header over GET. + + Scenario: + 1. Configure the backend to authenticate with a custom Authorization + header and to request the token with a GET. + 2. Trigger a call so a new token is fetched. + Expected: + - The token request uses the GET method. + - It carries the configured custom Authorization header, unaltered. + """ + self.webservice.write( + { + "oauth2_client_auth_method": "custom_header", + "oauth2_token_method": "get", + "oauth2_client_auth_header": "Authorization", + "oauth2_client_auth_value": "SSWS some_static_token", + } + ) + now = time.time() + duration = 3600 + responses.add( + responses.GET, + f"{self.url}oauth2/token", + json={ + "access_token": "cool_token", + "token_type": "Bearer", + "expires_in": duration, + "expires_at": now + duration, + }, + ) + responses.add(responses.GET, f"{self.url}endpoint", body="OK") + with mock_cursor(self.env.cr): + self.webservice.call("get", url=f"{self.url}endpoint") + token_request = responses.calls[0].request + self.assertEqual(token_request.method, "GET") + self.assertEqual( + token_request.headers["Authorization"], "SSWS some_static_token" + ) + + def test_client_secret_basic_auth_validation(self): + """HTTP Basic auth requires the client id and secret. + + Scenario: + 1. Create an OAuth2 backend with the HTTP Basic authentication but + without a client id and secret. + Expected: + - Validation fails asking for the client id and secret. + """ + msg = ( + r"requires 'OAuth2' authentication. However, the following " + r"field\(s\) are not valued: Client ID, Client Secret" + ) + with self.assertRaisesRegex(exceptions.UserError, msg): + self.env["webservice.backend"].create( + { + "name": "WebService OAuth2 basic missing creds", + "tech_name": "test_oauth2_basic_missing", + "auth_type": "oauth2", + "protocol": "http", + "url": self.url, + "oauth2_flow": "backend_application", + "oauth2_client_auth_method": "client_secret_basic", + "oauth2_token_url": f"{self.url}oauth2/token", + } + ) + + def test_custom_header_auth_validation(self): + """Custom-header auth requires the header value, not the client id/secret. + + Scenario: + 1. Create an OAuth2 backend with the custom Authorization header + method, without a client id/secret and without a header value. + Expected: + - Validation fails asking only for the header value; the client id + and secret are not reported as missing. + """ + msg = ( + r"requires 'OAuth2' authentication. However, the following " + r"field\(s\) are not valued: Client Auth Header Value" + ) + with self.assertRaisesRegex(exceptions.UserError, msg): + self.env["webservice.backend"].create( + { + "name": "WebService OAuth2 custom header missing value", + "tech_name": "test_oauth2_customhdr_missing", + "auth_type": "oauth2", + "protocol": "http", + "url": self.url, + "oauth2_flow": "backend_application", + "oauth2_client_auth_method": "custom_header", + "oauth2_client_auth_header": "Authorization", + "oauth2_token_url": f"{self.url}oauth2/token", + } + ) + class TestWebServiceOauth2WebApplication(CommonWebService): @classmethod diff --git a/webservice/utils.py b/webservice/utils.py index 778e61da..1e2aa931 100644 --- a/webservice/utils.py +++ b/webservice/utils.py @@ -4,6 +4,27 @@ from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from requests.auth import AuthBase + + +class StaticHeaderAuth(AuthBase): + """Requests auth handler injecting a static header into the request. + + Useful to authenticate against endpoints expecting the credentials in a + non-standard Authorization header (e.g. ``Authorization: SSWS ``). + Passing it as ``auth`` (rather than via ``headers``) also prevents + requests-oauthlib from auto-generating an HTTP Basic ``Authorization`` + header out of the client id, which would otherwise override ours. + """ + + def __init__(self, header, value): + self._header = header + self._value = value + + def __call__(self, request): + request.headers[self._header] = self._value + return request + def sanitize_url_for_log(url, blacklisted_keys=None): """Sanitize url to avoid loggin sensitive data""" diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml index e6391d36..6a88c6fd 100644 --- a/webservice/views/webservice_backend.xml +++ b/webservice/views/webservice_backend.xml @@ -63,15 +63,32 @@ name="redirect_url" invisible="auth_type != 'oauth2' and oauth2_flow != 'web_application'" /> + + + + Date: Thu, 2 Jul 2026 15:28:21 -0300 Subject: [PATCH 2/3] [FIX] webservice: hide web application specific fields There was a misuse of `and` instead of `or` in the `invisible` attribute of some fields that are specific to the Web Application flow --- webservice/views/webservice_backend.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webservice/views/webservice_backend.xml b/webservice/views/webservice_backend.xml index 6a88c6fd..6b398d92 100644 --- a/webservice/views/webservice_backend.xml +++ b/webservice/views/webservice_backend.xml @@ -61,7 +61,7 @@ /> Date: Thu, 2 Jul 2026 14:57:45 -0300 Subject: [PATCH 3/3] [IMP] webservice_server_env: expose new OAuth2 options to server env The webservice module gained new OAuth2 configuration fields (the token request method, the client authentication method, and the custom Authorization header name and value). These are now also manageable through server environment configuration files, like the other webservice fields. --- webservice_server_env/models/webservice_backend.py | 4 ++++ webservice_server_env/tests/test_oauth2.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/webservice_server_env/models/webservice_backend.py b/webservice_server_env/models/webservice_backend.py index a4b364fb..8e9eae88 100644 --- a/webservice_server_env/models/webservice_backend.py +++ b/webservice_server_env/models/webservice_backend.py @@ -29,6 +29,10 @@ def _server_env_fields(self): "oauth2_authorization_url": {}, "oauth2_token_url": {}, "oauth2_audience": {}, + "oauth2_token_method": {}, + "oauth2_client_auth_method": {}, + "oauth2_client_auth_header": {}, + "oauth2_client_auth_value": {}, } webservice_fields.update(base_fields) return webservice_fields diff --git a/webservice_server_env/tests/test_oauth2.py b/webservice_server_env/tests/test_oauth2.py index 8e0d4166..353e9ccd 100644 --- a/webservice_server_env/tests/test_oauth2.py +++ b/webservice_server_env/tests/test_oauth2.py @@ -104,6 +104,10 @@ def test_oauth2_flow_compute_with_ui(self): ws_form.api_key_header = "Test Api Key Header" if auth_type == "oauth2": ws_form.oauth2_flow = oauth2_flow + # ``oauth2_client_auth_method`` is only shown (and drives the + # visibility of the client id/secret) for the backend flow. + if oauth2_flow == "backend_application": + ws_form.oauth2_client_auth_method = "client_secret_basic" ws_form.oauth2_clientid = "Test Client ID" ws_form.oauth2_client_secret = "Test Client Secret" ws_form.oauth2_token_url = f"{url}oauth2/token" @@ -122,6 +126,8 @@ def test_oauth2_flow_compute_with_ui(self): ws_form.auth_type = new_auth_type if new_auth_type == "oauth2": ws_form.oauth2_flow = oauth2_flow + if oauth2_flow == "backend_application": + ws_form.oauth2_client_auth_method = "client_secret_basic" ws_form.oauth2_clientid = "Test Client ID" ws_form.oauth2_client_secret = "Test Client Secret" ws_form.oauth2_token_url = f"{url}oauth2/token"