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

-