Skip to content

Commit

Permalink
[IMP] webservice: allow web application flow
Browse files Browse the repository at this point in the history
  • Loading branch information
gurneyalex committed Apr 19, 2024
1 parent 4e8091a commit 0b6d8c6
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 14 deletions.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
# generated from manifests external_dependencies
oauthlib
requests-oauthlib
1 change: 1 addition & 0 deletions webservice/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from . import components
from . import models
from . import controllers
2 changes: 1 addition & 1 deletion webservice/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)",
"website": "https://github.com/OCA/web-api",
"depends": ["component", "server_environment"],
"external_dependencies": {"python": ["requests-oauthlib"]},
"external_dependencies": {"python": ["requests-oauthlib", "oauthlib"]},
"data": [
"security/ir.model.access.csv",
"views/webservice_backend.xml",
Expand Down
84 changes: 79 additions & 5 deletions webservice/components/request_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
# 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
from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient
from requests_oauthlib import OAuth2Session

from odoo.addons.component.core import Component

_logger = logging.getLogger(__name__)


class BaseRestRequestsAdapter(Component):
_name = "base.requests"
Expand Down Expand Up @@ -72,11 +75,14 @@ def _get_url(self, url=None, url_params=None, **kwargs):
return url.format(**url_params)


class OAuth2RestRequestsAdapter(Component):
_name = "oauth2.requests"
_webservice_protocol = "http+oauth2"
class BackendApplicationOAuth2RestRequestsAdapter(Component):
_name = "oauth2.requests.backend.application"
_webservice_protocol = "http+oauth2-backend_application"
_inherit = "base.requests"

def get_client(self, oauth_params: dict):
return BackendApplicationClient(client_id=oauth_params["oauth2_clientid"])

def __init__(self, *args, **kw):
super().__init__(*args, **kw)
# cached value to avoid hitting the database each time we need the token
Expand Down Expand Up @@ -135,7 +141,7 @@ def _fetch_new_token(self, old_token):
"oauth2_audience",
]
)[0]
client = BackendApplicationClient(client_id=oauth_params["oauth2_clientid"])
client = self.get_client(oauth_params)
with OAuth2Session(client=client) as session:
token = session.fetch_token(
token_url=oauth_params["oauth2_token_url"],
Expand All @@ -160,3 +166,71 @@ def _request(self, method, url=None, url_params=None, **kwargs):
request = session.request(method, url, **new_kwargs)
request.raise_for_status()
return request.content


class WebApplicationOAuth2RestRequestsAdapter(Component):
_name = "oauth2.requests.web.application"
_webservice_protocol = "http+oauth2-authorization_code"
_inherit = "oauth2.requests.backend.application"

def get_client(self, oauth_params: dict):
return WebApplicationClient(
client_id=oauth_params["oauth2_clientid"],
code=oauth_params.get("oauth2_autorization"),
redirect_uri=oauth_params["oauth2_re"],
)

def _fetch_token_from_authorization(self, authorization_code):

oauth_params = self.collection.sudo().read(
[
"oauth2_clientid",
"oauth2_client_secret",
"oauth2_token_url",
"oauth2_audience",
"redirect_url",
]
)[0]
client = WebApplicationClient(client_id=oauth_params["oauth2_clientid"])

with OAuth2Session(
client=client, redirect_uri=oauth_params.get("redirect_url")
) as session:
token = session.fetch_token(
oauth_params["oauth2_token_url"],
client_secret=oauth_params["oauth2_client_secret"],
code=authorization_code,
audience=oauth_params.get("oauth2_audience") or "",
include_client_id=True,
)
return token

def redirect_to_authorize(self, **authorization_url_extra_params):
"""set the oauth2_state on the backend
:return: the webservice authorization url with the proper parameters
"""
# we are normally authenticated at this stage, so no need to sudo()
backend = self.collection
oauth_params = backend.read(
[
"oauth2_clientid",
"oauth2_token_url",
"oauth2_audience",
"oauth2_authorization_url",
"oauth2_scope",
"redirect_url",
]
)[0]
client = WebApplicationClient(
client_id=oauth_params["oauth2_clientid"],
)

with OAuth2Session(
client=client,
redirect_uri=oauth_params.get("redirect_url"),
) as session:
authorization_url, state = session.authorization_url(
backend.oauth2_authorization_url, **authorization_url_extra_params
)
backend.oauth2_state = state
return authorization_url
61 changes: 58 additions & 3 deletions webservice/models/webservice_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
# @author Simone Orsi <[email protected]>
# @author Alexandre Fayolle <[email protected]>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import logging

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

_logger = logging.getLogger(__name__)


class WebserviceBackend(models.Model):

Expand All @@ -31,14 +33,33 @@ class WebserviceBackend(models.Model):
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_flow = fields.Selection(
[
("backend_application", "Backend Application (Client Credentials Grant)"),
("authorization_code", "Web Application (Authorization Code Grant)"),
],
readonly=False,
store=True,
compute="_compute_oauth2_flow",
)
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_authorization_url = fields.Char(string="Authorization URL")
oauth2_audience = fields.Char(
string="Audience"
# no auth_type because not required
)
oauth2_scope = fields.Char(help="scope of the the authorization")
oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)")
redirect_url = fields.Char(
compute="_compute_redirect_url",
help="The redirect URL to be used as part of the OAuth2 authorisation flow",
)
oauth2_state = fields.Char(
help="random key generated when authorization flow starts "
"to ensure that no CSRF attack happen"
)
content_type = fields.Selection(
[
("application/json", "JSON"),
Expand Down Expand Up @@ -82,7 +103,10 @@ def _valid_field_parameter(self, field, name):
return name in extra_params or super()._valid_field_parameter(field, name)

def call(self, method, *args, **kwargs):
return getattr(self._get_adapter(), method)(*args, **kwargs)
_logger.debug("backend %s: call %s %s %s", self.name, method, args, kwargs)
response = getattr(self._get_adapter(), method)(*args, **kwargs)
_logger.debug("backend %s: response: \n%s", self.name, response)
return response

def _get_adapter(self):
with self.work_on(self._name) as work:
Expand All @@ -94,9 +118,37 @@ def _get_adapter(self):
def _get_adapter_protocol(self):
protocol = self.protocol
if self.auth_type.startswith("oauth2"):
protocol += f"+{self.auth_type}"
protocol += f"+{self.auth_type}-{self.oauth2_flow}"
return protocol

@api.depends("auth_type")
def _compute_oauth2_flow(self):
for rec in self:
if rec.auth_type != "oauth2":
rec.oauth2_flow = False

@api.depends("auth_type", "oauth2_flow")
def _compute_redirect_url(self):
get_param = self.env["ir.config_parameter"].sudo().get_param
base_url = get_param("web.base.url")
for rec in self:
if rec.auth_type == "oauth2" and rec.oauth2_flow == "authorization_code":
rec.redirect_url = (
f"https://{base_url}/webservice/{rec.id}/oauth2/redirect"
)
else:
rec.redirect_url = False

def button_authorize(self):
_logger.info("Button OAuth2 Authorize")
authorize_url = self._get_adapter().redirect_to_authorize()
_logger.info("Redirecting to %s", authorize_url)
return {
"type": "ir.actions.act_url",
"url": authorize_url,
"target": "self",
}

@property
def _server_env_fields(self):
base_fields = super()._server_env_fields
Expand All @@ -109,8 +161,11 @@ def _server_env_fields(self):
"api_key": {},
"api_key_header": {},
"content_type": {},
"oauth2_flow": {},
"oauth2_scope": {},
"oauth2_clientid": {},
"oauth2_client_secret": {},
"oauth2_authorization_url": {},
"oauth2_token_url": {},
"oauth2_audience": {},
}
Expand Down
10 changes: 6 additions & 4 deletions webservice/tests/test_oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def _setup_records(cls):
[
"[webservice_backend.test_oauth2]",
"auth_type = oauth2",
"oauth2_flow = backend_application",
"oauth2_clientid = some_client_id",
"oauth2_client_secret = shh_secret",
f"oauth2_token_url = {cls.url}oauth2/token",
Expand All @@ -34,6 +35,7 @@ def _setup_records(cls):
"auth_type": "oauth2",
"protocol": "http",
"url": cls.url,
"oauth2_flow": "backend_application",
"content_type": "application/xml",
"oauth2_clientid": "some_client_id",
"oauth2_client_secret": "shh_secret",
Expand All @@ -45,7 +47,7 @@ def _setup_records(cls):

def test_get_adapter_protocol(self):
protocol = self.webservice._get_adapter_protocol()
self.assertEqual(protocol, "http+oauth2")
self.assertEqual(protocol, "http+oauth2-backend_application")

@responses.activate
def test_fetch_token(self):
Expand All @@ -65,7 +67,7 @@ def test_fetch_token(self):

with mock_cursor(self.env.cr):
result = self.webservice.call("get", url=f"{self.url}endpoint")
self.webservice.refresh()
self.webservice.invalidate_recordset()
self.assertTrue("cool_token" in self.webservice.oauth2_token)
self.assertEqual(result, b"OK")

Expand Down Expand Up @@ -99,7 +101,7 @@ def test_update_token(self):
result = self.webservice.call("get", url=f"{self.url}endpoint")
self.env.cr.commit.assert_called_once_with() # one call with no args

self.webservice.refresh()
self.webservice.invalidate_recordset()
self.assertTrue("cool_token" in self.webservice.oauth2_token)
self.assertEqual(result, b"OK")

Expand Down Expand Up @@ -133,5 +135,5 @@ def test_update_token_with_error(self):
self.env.cr.commit.assert_not_called()
self.env.cr.close.assert_called_once_with() # one call with no args

self.webservice.refresh()
self.webservice.invalidate_recordset()
self.assertTrue("old_token" in self.webservice.oauth2_token)
21 changes: 20 additions & 1 deletion webservice/views/webservice_backend.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
<field name="model">webservice.backend</field>
<field name="arch" type="xml">
<form>
<header />
<header>
<button
type="object"
name="button_authorize"
string="OAuth Authorize"
attrs="{'invisible': ['|', ('auth_type', '!=', 'oauth2'), ('oauth2_flow', '!=', 'authorization_code')]}"
/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only" />
Expand Down Expand Up @@ -57,6 +64,10 @@
'required': [('auth_type', '=', 'api_key')],
}"
/>
<field
name="oauth2_flow"
attrs="{'invisible': [('auth_type', '!=', 'oauth2')], 'required': [('auth_type', '=', 'oauth2')]}"
/>
<field
name="oauth2_clientid"
attrs="{'invisible': [('auth_type', '!=', 'oauth2')], 'required': [('auth_type', '=', 'oauth2')]}"
Expand All @@ -65,6 +76,14 @@
name="oauth2_client_secret"
attrs="{'invisible': [('auth_type', '!=', 'oauth2')], 'required': [('auth_type', '=', 'oauth2')]}"
/>
<field
name="oauth2_scope"
attrs="{'invisible': [('auth_type', '!=', 'oauth2'), ('oauth2_flow', '!=', 'authorization_code')], 'required': [('auth_type', '=', 'oauth2'), ('oauth2_flow', '=', 'authorization_code')]}"
/>
<field
name="oauth2_authorization_url"
attrs="{'invisible': [('auth_type', '!=', 'oauth2'), ('oauth2_flow', '!=', 'authorization_code')], 'required': [('auth_type', '=', 'oauth2'), ('oauth2_flow', '=', 'authorization_code')]}"
/>
<field
name="oauth2_token_url"
attrs="{'invisible': [('auth_type', '!=', 'oauth2')], 'required': [('auth_type', '=', 'oauth2')]}"
Expand Down

0 comments on commit 0b6d8c6

Please sign in to comment.