Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[17.0][IMP] webservice: use refresh token #74

Draft
wants to merge 1 commit into
base: 17.0
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 43 additions & 9 deletions webservice/components/request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import json
import logging
import time

import requests
from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient
import urllib3
from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient, rfc6749
from requests_oauthlib import OAuth2Session

from odoo.addons.component.core import Component
from odoo.tools.safe_eval import safe_eval

_logger = logging.getLogger(__name__)
from odoo.addons.component.core import Component


class BaseRestRequestsAdapter(Component):
Expand Down Expand Up @@ -133,17 +133,32 @@
if self._is_token_valid(token):
self._token = token
else:
new_token = self._fetch_new_token(old_token=token)
new_token = {}
if backend.oauth2_use_refresh_token:
# pylint: disable=except-pass
try:
new_token = self._fetch_refresh_token(old_token=token)
except self._get_refresh_token_ignorable_exceptions():
pass # Do nothing and let Odoo try to fetch a new token

Check warning on line 142 in webservice/components/request_adapter.py

View check run for this annotation

Codecov / codecov/patch

webservice/components/request_adapter.py#L139-L142

Added lines #L139 - L142 were not covered by tests
if not new_token:
new_token = self._fetch_new_token(old_token=token)
json_token = json.dumps(new_token)
cr.execute(
"UPDATE webservice_backend " "SET oauth2_token=%s " "WHERE id=%s",
(json.dumps(new_token), backend.id),
"UPDATE webservice_backend SET oauth2_token = %s WHERE id = %s",
(json_token, backend.id),
)
backend._compute_oauth2_token_expiration_datetime()
self._token = new_token
return self._token

def _get_refresh_token_ignorable_exceptions(self):
return (

Check warning on line 155 in webservice/components/request_adapter.py

View check run for this annotation

Codecov / codecov/patch

webservice/components/request_adapter.py#L155

Added line #L155 was not covered by tests
rfc6749.errors.OAuth2Error,
requests.exceptions.RequestException,
urllib3.exceptions.HTTPError,
)

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",
Expand All @@ -163,6 +178,25 @@
)
return token

def _fetch_refresh_token(self, old_token):
backend = self.collection.sudo()
with OAuth2Session(client_id=backend.oauth2_clientid) as session:

Check warning on line 183 in webservice/components/request_adapter.py

View check run for this annotation

Codecov / codecov/patch

webservice/components/request_adapter.py#L182-L183

Added lines #L182 - L183 were not covered by tests
# NB: the following section partially mimics ``OAuth2Session.request()``,
# but allows more flexibility to make this work with different providers
session.token = {}
res = session.request(

Check warning on line 187 in webservice/components/request_adapter.py

View check run for this annotation

Codecov / codecov/patch

webservice/components/request_adapter.py#L186-L187

Added lines #L186 - L187 were not covered by tests
**safe_eval(
backend.oauth2_refresh_token_params.strip(),
{"webservice": backend, "adapter": self, "old_token": old_token},
)
)
for hook in session.compliance_hook["access_token_response"]:
res = hook(res)
client = session._client
client.parse_request_body_response(res.text, scope=session.scope)
session.token = client.token
return session.token

Check warning on line 198 in webservice/components/request_adapter.py

View check run for this annotation

Codecov / codecov/patch

webservice/components/request_adapter.py#L194-L198

Added lines #L194 - L198 were not covered by tests

def _request(self, method, url=None, url_params=None, **kwargs):
url = self._get_url(url=url, url_params=url_params)
new_kwargs = kwargs.copy()
Expand Down
67 changes: 67 additions & 0 deletions webservice/models/webservice_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
# @author Simone Orsi <[email protected]>
# @author Alexandre Fayolle <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import json
import logging
from datetime import datetime

from requests_oauthlib import OAuth2Session

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

Expand Down Expand Up @@ -57,6 +61,19 @@
help="random key generated when authorization flow starts "
"to ensure that no CSRF attack happen"
)
oauth2_token_expiration_datetime = fields.Datetime(
string="Token Expiration",
compute="_compute_oauth2_token_expiration_datetime",
compute_sudo=True,
store=True,
)
oauth2_use_refresh_token = fields.Boolean(string="Use Refresh Token")
oauth2_refresh_token_params = fields.Text(
string="Refresh Token Params",
help="Parameters used by ``OAuth2Session.request()`` method to fetch a new"
" token through refresh of the old one",
default=lambda self: self._get_default_oauth2_refresh_token_params(),
)
content_type = fields.Selection(
[
("application/json", "JSON"),
Expand All @@ -67,6 +84,31 @@
)
company_id = fields.Many2one("res.company", string="Company")

@api.model
def _get_default_oauth2_refresh_token_params(self):
code = OAuth2Session.request.__code__
varnames = [f"\n# - {v}" for v in code.co_varnames[1 : code.co_argcount + 1]]
txt = """
# Define a dictionary with keyword arguments to pass to ``OAuth2Session.request()``
#
# ``OAuth2Session.request()`` arguments:%s
#
# Available variables to use for dict definition (see example below):
# - webservice: current record
# - adapter: webservice's request adapter
# - old_token: pre-existing token (as dictionary)

{
'method': 'POST',
'url': webservice.oauth2_token_url,
'data': {
'key1': adapter.get_key1(),
'key2': old_token['key2'],
},
}
"""
return (txt % "".join(varnames)).lstrip()

@api.constrains("auth_type")
def _check_auth_type(self):
valid_fields = {
Expand Down Expand Up @@ -175,3 +217,28 @@
res = super()._compute_server_env()
self.filtered(lambda r: r.auth_type != "oauth2").oauth2_flow = None
return res

@api.depends("oauth2_token")
def _compute_oauth2_token_expiration_datetime(self):
from_ts = datetime.fromtimestamp
for ws in self:
token_str = ws.oauth2_token
try:
token = json.loads(token_str or "{}") or {}
ws.oauth2_token_expiration_datetime = from_ts(token.get("expires_at"))
except Exception as e:
if token_str:
_logger.warning(

Check warning on line 231 in webservice/models/webservice_backend.py

View check run for this annotation

Codecov / codecov/patch

webservice/models/webservice_backend.py#L231

Added line #L231 was not covered by tests
"Could not determine token expiration date from %s,"
" got error: %s: %s",
token_str,
e.__class__.__name__,
str(e),
)
ws.oauth2_token_expiration_datetime = None

def button_refresh_token(self):
self.ensure_one()
adapter = self._get_adapter()
new_token = adapter._fetch_refresh_token(json.loads(self.oauth2_token))
self.oauth2_token = json.dumps(new_token)

Check warning on line 244 in webservice/models/webservice_backend.py

View check run for this annotation

Codecov / codecov/patch

webservice/models/webservice_backend.py#L241-L244

Added lines #L241 - L244 were not covered by tests
25 changes: 25 additions & 0 deletions webservice/views/webservice_backend.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@
<field name="model">webservice.backend</field>
<field name="arch" type="xml">
<form>
<field name="oauth2_token" invisible="1" />
<header>
<button
type="object"
name="button_authorize"
string="OAuth Authorize"
invisible="auth_type != 'oauth2' or oauth2_flow != 'web_application'"
/>
<button
type="object"
name="button_refresh_token"
string="Refresh Token"
invisible="auth_type != 'oauth2' or not (oauth2_token and oauth2_token != '{}') or not oauth2_use_refresh_token"
/>
</header>
<sheet>
<div class="oe_title">
Expand Down Expand Up @@ -91,11 +98,29 @@
invisible="auth_type != 'oauth2'"
required="auth_type == 'oauth2'"
/>
<field
name="oauth2_token_expiration_datetime"
invisible="auth_type != 'oauth2'"
/>
<field
name="oauth2_audience"
invisible="auth_type != 'oauth2'"
/>
</group>
<group
name="oauth2_refresh_token_params_group"
invisible="auth_type != 'oauth2'"
>
<field
name="oauth2_use_refresh_token"
widget="boolean_toggle"
/>
<field
name="oauth2_refresh_token_params"
widget="code"
invisible="not oauth2_use_refresh_token"
/>
</group>
</sheet>
</form>
</field>
Expand Down
Loading