Skip to content

Commit 2a69e79

Browse files
[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 <token>"). 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.
1 parent 1713838 commit 2a69e79

8 files changed

Lines changed: 413 additions & 23 deletions

File tree

webservice/README.rst

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,47 @@ HTTP call returns by default the content of the response. A context
4343
.. contents::
4444
:local:
4545

46+
Configuration
47+
=============
48+
49+
OAuth2 (Client Credentials)
50+
---------------------------
51+
52+
For the *Backend Application (Client Credentials Grant)* flow, two extra
53+
options control how the token is requested, so that endpoints which
54+
deviate from the OAuth2 spec can still be used:
55+
56+
- **Token Request Method**: ``POST`` (default) or ``GET``. Most
57+
providers expose the token endpoint as a POST; some require a GET.
58+
- **Client Authentication**: how the client credentials are presented to
59+
the token endpoint:
60+
61+
- *Client ID & Secret (HTTP Basic)* (default): the client id and
62+
secret are sent as an
63+
``Authorization: Basic base64(client_id:client_secret)`` header
64+
(``client_secret_basic``).
65+
- *Custom Authorization header*: a static header value is sent
66+
verbatim. The **Client Auth Header** (default ``Authorization``) and
67+
**Client Auth Header Value** are configured directly; the Client ID
68+
/ Client Secret fields are not used in this case.
69+
70+
Example: custom Authorization header
71+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
72+
73+
Some providers require the credentials in a non-standard Authorization
74+
header (for instance Okta uses ``Authorization: SSWS <token>``). Such an
75+
endpoint can be configured as:
76+
77+
::
78+
79+
Auth Type = OAuth2
80+
OAuth2 Flow = Backend Application (Client Credentials Grant)
81+
Token URL = https://provider.example.com/oauth2/token
82+
Token Request Method = GET
83+
Client Authentication = Custom Authorization header
84+
Client Auth Header = Authorization
85+
Client Auth Value = SSWS <token>
86+
4687
Bug Tracker
4788
===========
4889

webservice/components/request_adapter.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from odoo.addons.component.core import Component
1515

16-
from ..utils import sanitize_url_for_log
16+
from ..utils import StaticHeaderAuth, sanitize_url_for_log
1717

1818
_logger = logging.getLogger(__name__)
1919

@@ -160,23 +160,50 @@ def _fetch_new_token(self, old_token):
160160
# be used (and use it in that case)
161161
oauth_params = self.collection.sudo().read(
162162
[
163+
"oauth2_client_auth_method",
163164
"oauth2_clientid",
164165
"oauth2_client_secret",
166+
"oauth2_client_auth_header",
167+
"oauth2_client_auth_value",
165168
"oauth2_token_url",
169+
"oauth2_token_method",
166170
"oauth2_audience",
167171
"redirect_url",
168172
]
169173
)[0]
170174
client = self.get_client(oauth_params)
171175
with OAuth2Session(client=client) as session:
172-
token = session.fetch_token(
173-
token_url=oauth_params["oauth2_token_url"],
174-
client_id=oauth_params["oauth2_clientid"],
175-
client_secret=oauth_params["oauth2_client_secret"],
176-
audience=oauth_params.get("oauth2_audience") or "",
177-
)
176+
token = session.fetch_token(**self._token_fetch_kwargs(oauth_params))
178177
return token
179178

179+
def _token_fetch_kwargs(self, oauth_params):
180+
"""Build the ``OAuth2Session.fetch_token`` keyword arguments.
181+
182+
Both the HTTP method used for the token request and the way the client
183+
credentials are presented to the token endpoint depend on the backend
184+
configuration, so that non fully spec-compliant endpoints can be used.
185+
"""
186+
kwargs = {
187+
"method": oauth_params["oauth2_token_method"].upper(),
188+
"token_url": oauth_params["oauth2_token_url"],
189+
"audience": oauth_params.get("oauth2_audience") or "",
190+
}
191+
match oauth_params["oauth2_client_auth_method"]:
192+
case "client_secret_basic":
193+
kwargs["client_id"] = oauth_params["oauth2_clientid"]
194+
kwargs["client_secret"] = oauth_params["oauth2_client_secret"]
195+
case "custom_header":
196+
kwargs["auth"] = StaticHeaderAuth(
197+
oauth_params["oauth2_client_auth_header"],
198+
oauth_params["oauth2_client_auth_value"],
199+
)
200+
case _:
201+
raise ValueError(
202+
"Unsupported OAuth2 client authentication method: "
203+
f"{oauth_params['oauth2_client_auth_method']!r}"
204+
)
205+
return kwargs
206+
180207
def _request(self, method, url=None, url_params=None, **kwargs):
181208
url = self._get_url(url=url, url_params=url_params)
182209
content_only = kwargs.pop("content_only", True)

webservice/models/webservice_backend.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,37 @@ class WebserviceBackend(models.Model):
4040
],
4141
readonly=False,
4242
)
43-
oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2")
44-
oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2")
43+
oauth2_client_auth_method = fields.Selection(
44+
[
45+
("client_secret_basic", "Client ID & Secret (HTTP Basic)"),
46+
("custom_header", "Custom Authorization header"),
47+
],
48+
default="client_secret_basic",
49+
string="Client Authentication",
50+
help="How the client credentials are presented to the token endpoint.",
51+
)
52+
oauth2_clientid = fields.Char(string="Client ID")
53+
oauth2_client_secret = fields.Char(string="Client Secret")
54+
oauth2_client_auth_header = fields.Char(
55+
string="Client Auth Header",
56+
default="Authorization",
57+
help="Header name used to send the client credentials when the client "
58+
"authentication method is a custom Authorization header.",
59+
)
60+
oauth2_client_auth_value = fields.Char(
61+
string="Client Auth Header Value",
62+
help="Full, static header value sent to the token endpoint when the "
63+
"client authentication method is a custom Authorization header "
64+
"(e.g. 'SSWS <token>').",
65+
)
4566
oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2")
67+
oauth2_token_method = fields.Selection(
68+
[("post", "POST"), ("get", "GET")],
69+
default="post",
70+
string="Token Request Method",
71+
help="HTTP method used to request the token from the token endpoint. "
72+
"Most providers use POST; some expose the token endpoint as GET.",
73+
)
4674
oauth2_authorization_url = fields.Char(string="Authorization URL")
4775
oauth2_audience = fields.Char(
4876
string="Audience"
@@ -83,6 +111,46 @@ def _check_auth_type(self):
83111
if missing:
84112
raise exceptions.UserError(rec._msg_missing_auth_param(missing))
85113

114+
@api.constrains(
115+
"auth_type",
116+
"oauth2_client_auth_method",
117+
"oauth2_clientid",
118+
"oauth2_client_secret",
119+
)
120+
def _check_oauth2_client_secret_basic(self):
121+
for rec in self:
122+
if rec.auth_type != "oauth2":
123+
continue
124+
if rec.oauth2_client_auth_method != "client_secret_basic":
125+
continue
126+
missing = [
127+
rec._fields[fname]
128+
for fname in ("oauth2_clientid", "oauth2_client_secret")
129+
if not rec[fname]
130+
]
131+
if missing:
132+
raise exceptions.UserError(rec._msg_missing_auth_param(missing))
133+
134+
@api.constrains(
135+
"auth_type",
136+
"oauth2_client_auth_method",
137+
"oauth2_client_auth_header",
138+
"oauth2_client_auth_value",
139+
)
140+
def _check_oauth2_custom_header(self):
141+
for rec in self:
142+
if rec.auth_type != "oauth2":
143+
continue
144+
if rec.oauth2_client_auth_method != "custom_header":
145+
continue
146+
missing = [
147+
rec._fields[fname]
148+
for fname in ("oauth2_client_auth_header", "oauth2_client_auth_value")
149+
if not rec[fname]
150+
]
151+
if missing:
152+
raise exceptions.UserError(rec._msg_missing_auth_param(missing))
153+
86154
def _msg_missing_auth_param(self, missing_fields):
87155
def get_selection_value(fname):
88156
return self._fields.get(fname).convert_to_export(self[fname], self)

webservice/readme/CONFIGURE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## OAuth2 (Client Credentials)
2+
3+
For the *Backend Application (Client Credentials Grant)* flow, two extra options
4+
control how the token is requested, so that endpoints which deviate from the
5+
OAuth2 spec can still be used:
6+
7+
- **Token Request Method**: `POST` (default) or `GET`. Most providers expose the
8+
token endpoint as a POST; some require a GET.
9+
- **Client Authentication**: how the client credentials are presented to the
10+
token endpoint:
11+
- *Client ID & Secret (HTTP Basic)* (default): the client id and secret are
12+
sent as an `Authorization: Basic base64(client_id:client_secret)` header
13+
(`client_secret_basic`).
14+
- *Custom Authorization header*: a static header value is sent verbatim. The
15+
**Client Auth Header** (default `Authorization`) and **Client Auth Header
16+
Value** are configured directly; the Client ID / Client Secret fields are
17+
not used in this case.
18+
19+
### Example: custom Authorization header
20+
21+
Some providers require the credentials in a non-standard Authorization header
22+
(for instance Okta uses `Authorization: SSWS <token>`). Such an endpoint can be
23+
configured as:
24+
25+
Auth Type = OAuth2
26+
OAuth2 Flow = Backend Application (Client Credentials Grant)
27+
Token URL = https://provider.example.com/oauth2/token
28+
Token Request Method = GET
29+
Client Authentication = Custom Authorization header
30+
Client Auth Header = Authorization
31+
Client Auth Value = SSWS <token>

webservice/static/description/index.html

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -382,41 +382,88 @@ <h1>WebService</h1>
382382
<p><strong>Table of contents</strong></p>
383383
<div class="contents local topic" id="contents">
384384
<ul class="simple">
385-
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-1">Bug Tracker</a></li>
386-
<li><a class="reference internal" href="#credits" id="toc-entry-2">Credits</a><ul>
387-
<li><a class="reference internal" href="#authors" id="toc-entry-3">Authors</a></li>
388-
<li><a class="reference internal" href="#contributors" id="toc-entry-4">Contributors</a></li>
389-
<li><a class="reference internal" href="#maintainers" id="toc-entry-5">Maintainers</a></li>
385+
<li><a class="reference internal" href="#configuration" id="toc-entry-1">Configuration</a><ul>
386+
<li><a class="reference internal" href="#oauth2-client-credentials" id="toc-entry-2">OAuth2 (Client Credentials)</a><ul>
387+
<li><a class="reference internal" href="#example-custom-authorization-header" id="toc-entry-3">Example: custom Authorization header</a></li>
390388
</ul>
391389
</li>
392390
</ul>
391+
</li>
392+
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
393+
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a><ul>
394+
<li><a class="reference internal" href="#authors" id="toc-entry-6">Authors</a></li>
395+
<li><a class="reference internal" href="#contributors" id="toc-entry-7">Contributors</a></li>
396+
<li><a class="reference internal" href="#maintainers" id="toc-entry-8">Maintainers</a></li>
397+
</ul>
398+
</li>
399+
</ul>
400+
</div>
401+
<div class="section" id="configuration">
402+
<h2><a class="toc-backref" href="#toc-entry-1">Configuration</a></h2>
403+
<div class="section" id="oauth2-client-credentials">
404+
<h3><a class="toc-backref" href="#toc-entry-2">OAuth2 (Client Credentials)</a></h3>
405+
<p>For the <em>Backend Application (Client Credentials Grant)</em> flow, two extra
406+
options control how the token is requested, so that endpoints which
407+
deviate from the OAuth2 spec can still be used:</p>
408+
<ul class="simple">
409+
<li><strong>Token Request Method</strong>: <tt class="docutils literal">POST</tt> (default) or <tt class="docutils literal">GET</tt>. Most
410+
providers expose the token endpoint as a POST; some require a GET.</li>
411+
<li><strong>Client Authentication</strong>: how the client credentials are presented to
412+
the token endpoint:<ul>
413+
<li><em>Client ID &amp; Secret (HTTP Basic)</em> (default): the client id and
414+
secret are sent as an
415+
<tt class="docutils literal">Authorization: Basic base64(client_id:client_secret)</tt> header
416+
(<tt class="docutils literal">client_secret_basic</tt>).</li>
417+
<li><em>Custom Authorization header</em>: a static header value is sent
418+
verbatim. The <strong>Client Auth Header</strong> (default <tt class="docutils literal">Authorization</tt>) and
419+
<strong>Client Auth Header Value</strong> are configured directly; the Client ID
420+
/ Client Secret fields are not used in this case.</li>
421+
</ul>
422+
</li>
423+
</ul>
424+
<div class="section" id="example-custom-authorization-header">
425+
<h4><a class="toc-backref" href="#toc-entry-3">Example: custom Authorization header</a></h4>
426+
<p>Some providers require the credentials in a non-standard Authorization
427+
header (for instance Okta uses <tt class="docutils literal">Authorization: SSWS &lt;token&gt;</tt>). Such an
428+
endpoint can be configured as:</p>
429+
<pre class="literal-block">
430+
Auth Type = OAuth2
431+
OAuth2 Flow = Backend Application (Client Credentials Grant)
432+
Token URL = https://provider.example.com/oauth2/token
433+
Token Request Method = GET
434+
Client Authentication = Custom Authorization header
435+
Client Auth Header = Authorization
436+
Client Auth Value = SSWS &lt;token&gt;
437+
</pre>
438+
</div>
439+
</div>
393440
</div>
394441
<div class="section" id="bug-tracker">
395-
<h2><a class="toc-backref" href="#toc-entry-1">Bug Tracker</a></h2>
442+
<h2><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h2>
396443
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/web-api/issues">GitHub Issues</a>.
397444
In case of trouble, please check there if your issue has already been reported.
398445
If you spotted it first, help us to smash it by providing a detailed and welcomed
399446
<a class="reference external" href="https://github.com/OCA/web-api/issues/new?body=module:%20webservice%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
400447
<p>Do not contact contributors directly about support or help with technical issues.</p>
401448
</div>
402449
<div class="section" id="credits">
403-
<h2><a class="toc-backref" href="#toc-entry-2">Credits</a></h2>
450+
<h2><a class="toc-backref" href="#toc-entry-5">Credits</a></h2>
404451
<div class="section" id="authors">
405-
<h3><a class="toc-backref" href="#toc-entry-3">Authors</a></h3>
452+
<h3><a class="toc-backref" href="#toc-entry-6">Authors</a></h3>
406453
<ul class="simple">
407454
<li>Creu Blanca</li>
408455
<li>Camptocamp</li>
409456
</ul>
410457
</div>
411458
<div class="section" id="contributors">
412-
<h3><a class="toc-backref" href="#toc-entry-4">Contributors</a></h3>
459+
<h3><a class="toc-backref" href="#toc-entry-7">Contributors</a></h3>
413460
<ul class="simple">
414461
<li>Enric Tobella &lt;<a class="reference external" href="mailto:etobella&#64;creublanca.es">etobella&#64;creublanca.es</a>&gt;</li>
415462
<li>Alexandre Fayolle &lt;<a class="reference external" href="mailto:alexandre.fayolle&#64;camptocamp.com">alexandre.fayolle&#64;camptocamp.com</a>&gt;</li>
416463
</ul>
417464
</div>
418465
<div class="section" id="maintainers">
419-
<h3><a class="toc-backref" href="#toc-entry-5">Maintainers</a></h3>
466+
<h3><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h3>
420467
<p>This module is maintained by the OCA.</p>
421468
<a class="reference external image-reference" href="https://odoo-community.org">
422469
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />

0 commit comments

Comments
 (0)