Skip to content

Commit

Permalink
[IMP] webservice: add support for oauth2
Browse files Browse the repository at this point in the history
Allow using oauth2 with Backend Application Flow / Client Credentials
Grant.
  • Loading branch information
gurneyalex committed Apr 16, 2024
1 parent ca571de commit 6c44edc
Show file tree
Hide file tree
Showing 11 changed files with 305 additions and 7 deletions.
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# generated from manifests external_dependencies
requests-oauthlib
3 changes: 2 additions & 1 deletion webservice/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ WebService
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:3c8e3b258f995f0eb2c12d74904d41e163f8e67c98cb545dd100c1575e105904
!! source digest: sha256:a554d8b0213fbcf9d0a3516b0eb65705733d42498e3c009f6105dea07ae6da67
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png
Expand Down Expand Up @@ -58,6 +58,7 @@ Contributors
~~~~~~~~~~~~

* Enric Tobella <[email protected]>
* Alexandre Fayolle <[email protected]>

Maintainers
~~~~~~~~~~~
Expand Down
6 changes: 5 additions & 1 deletion webservice/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
"author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/web-api",
"depends": ["component", "server_environment"],
"data": ["security/ir.model.access.csv", "views/webservice_backend.xml"],
"external_dependencies": {"python": ["requests-oauthlib"]},
"data": [
"security/ir.model.access.csv",
"views/webservice_backend.xml",
],
"demo": [],
}
95 changes: 95 additions & 0 deletions webservice/components/request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
# @author Simone Orsi <[email protected]>
# 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

Expand Down Expand Up @@ -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
25 changes: 23 additions & 2 deletions webservice/models/webservice_backend.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Copyright 2020 Creu Blanca
# Copyright 2022 Camptocamp SA
# @author Simone Orsi <[email protected]>
# @author Alexandre Fayolle <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).


from odoo import _, api, exceptions, fields, models


Expand All @@ -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"),
Expand Down Expand Up @@ -77,9 +87,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
Expand All @@ -92,6 +109,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
1 change: 1 addition & 0 deletions webservice/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
* Enric Tobella <[email protected]>
* Alexandre Fayolle <[email protected]>
3 changes: 2 additions & 1 deletion webservice/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ <h1 class="title">WebService</h1>
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:3c8e3b258f995f0eb2c12d74904d41e163f8e67c98cb545dd100c1575e105904
!! source digest: sha256:a554d8b0213fbcf9d0a3516b0eb65705733d42498e3c009f6105dea07ae6da67
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Production/Stable" src="https://img.shields.io/badge/maturity-Production%2FStable-green.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/web-api/tree/15.0/webservice"><img alt="OCA/web-api" src="https://img.shields.io/badge/github-OCA%2Fweb--api-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/web-api-15-0/web-api-15-0-webservice"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/web-api&amp;target_branch=15.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module creates WebService frameworks to be used globally</p>
Expand Down Expand Up @@ -404,6 +404,7 @@ <h2><a class="toc-backref" href="#toc-entry-3">Authors</a></h2>
<h2><a class="toc-backref" href="#toc-entry-4">Contributors</a></h2>
<ul class="simple">
<li>Enric Tobella &lt;<a class="reference external" href="mailto:etobella&#64;creublanca.es">etobella&#64;creublanca.es</a>&gt;</li>
<li>Alexandre Fayolle &lt;<a class="reference external" href="mailto:alexandre.fayolle&#64;camptocamp.com">alexandre.fayolle&#64;camptocamp.com</a>&gt;</li>
</ul>
</div>
<div class="section" id="maintainers">
Expand Down
2 changes: 1 addition & 1 deletion webservice/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
from . import test_webservice
from . import test_webservice, test_oauth2
22 changes: 21 additions & 1 deletion webservice/tests/common.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
Loading

0 comments on commit 6c44edc

Please sign in to comment.