Skip to content

Commit aab4692

Browse files
committed
[IMP] webservice: allow web application flow
1 parent bad61b1 commit aab4692

File tree

10 files changed

+319
-15
lines changed

10 files changed

+319
-15
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
# generated from manifests external_dependencies
2+
oauthlib
23
requests-oauthlib

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
responses

webservice/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from . import components
22
from . import models
3+
from . import controllers

webservice/__manifest__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"author": "Creu Blanca, Camptocamp, Odoo Community Association (OCA)",
1616
"website": "https://github.com/OCA/web-api",
1717
"depends": ["component", "server_environment"],
18-
"external_dependencies": {"python": ["requests-oauthlib"]},
18+
"external_dependencies": {"python": ["requests-oauthlib", "oauthlib"]},
1919
"data": [
2020
"security/ir.model.access.csv",
2121
"views/webservice_backend.xml",

webservice/components/request_adapter.py

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
55

66
import json
7+
import logging
78
import time
89

910
import requests
10-
from oauthlib.oauth2 import BackendApplicationClient
11+
from oauthlib.oauth2 import BackendApplicationClient, WebApplicationClient
1112
from requests_oauthlib import OAuth2Session
1213

1314
from odoo.addons.component.core import Component
1415

16+
_logger = logging.getLogger(__name__)
17+
1518

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

7477

75-
class OAuth2RestRequestsAdapter(Component):
76-
_name = "oauth2.requests"
77-
_webservice_protocol = "http+oauth2"
78+
class BackendApplicationOAuth2RestRequestsAdapter(Component):
79+
_name = "oauth2.requests.backend.application"
80+
_webservice_protocol = "http+oauth2-backend_application"
7881
_inherit = "base.requests"
7982

83+
def get_client(self, oauth_params: dict):
84+
return BackendApplicationClient(client_id=oauth_params["oauth2_clientid"])
85+
8086
def __init__(self, *args, **kw):
8187
super().__init__(*args, **kw)
8288
# cached value to avoid hitting the database each time we need the token
@@ -133,9 +139,10 @@ def _fetch_new_token(self, old_token):
133139
"oauth2_client_secret",
134140
"oauth2_token_url",
135141
"oauth2_audience",
142+
"redirect_url",
136143
]
137144
)[0]
138-
client = BackendApplicationClient(client_id=oauth_params["oauth2_clientid"])
145+
client = self.get_client(oauth_params)
139146
with OAuth2Session(client=client) as session:
140147
token = session.fetch_token(
141148
token_url=oauth_params["oauth2_token_url"],
@@ -160,3 +167,71 @@ def _request(self, method, url=None, url_params=None, **kwargs):
160167
request = session.request(method, url, **new_kwargs)
161168
request.raise_for_status()
162169
return request.content
170+
171+
172+
class WebApplicationOAuth2RestRequestsAdapter(Component):
173+
_name = "oauth2.requests.web.application"
174+
_webservice_protocol = "http+oauth2-web_application"
175+
_inherit = "oauth2.requests.backend.application"
176+
177+
def get_client(self, oauth_params: dict):
178+
return WebApplicationClient(
179+
client_id=oauth_params["oauth2_clientid"],
180+
code=oauth_params.get("oauth2_autorization"),
181+
redirect_uri=oauth_params["redirect_url"],
182+
)
183+
184+
def _fetch_token_from_authorization(self, authorization_code):
185+
186+
oauth_params = self.collection.sudo().read(
187+
[
188+
"oauth2_clientid",
189+
"oauth2_client_secret",
190+
"oauth2_token_url",
191+
"oauth2_audience",
192+
"redirect_url",
193+
]
194+
)[0]
195+
client = WebApplicationClient(client_id=oauth_params["oauth2_clientid"])
196+
197+
with OAuth2Session(
198+
client=client, redirect_uri=oauth_params.get("redirect_url")
199+
) as session:
200+
token = session.fetch_token(
201+
oauth_params["oauth2_token_url"],
202+
client_secret=oauth_params["oauth2_client_secret"],
203+
code=authorization_code,
204+
audience=oauth_params.get("oauth2_audience") or "",
205+
include_client_id=True,
206+
)
207+
return token
208+
209+
def redirect_to_authorize(self, **authorization_url_extra_params):
210+
"""set the oauth2_state on the backend
211+
:return: the webservice authorization url with the proper parameters
212+
"""
213+
# we are normally authenticated at this stage, so no need to sudo()
214+
backend = self.collection
215+
oauth_params = backend.read(
216+
[
217+
"oauth2_clientid",
218+
"oauth2_token_url",
219+
"oauth2_audience",
220+
"oauth2_authorization_url",
221+
"oauth2_scope",
222+
"redirect_url",
223+
]
224+
)[0]
225+
client = WebApplicationClient(
226+
client_id=oauth_params["oauth2_clientid"],
227+
)
228+
229+
with OAuth2Session(
230+
client=client,
231+
redirect_uri=oauth_params.get("redirect_url"),
232+
) as session:
233+
authorization_url, state = session.authorization_url(
234+
backend.oauth2_authorization_url, **authorization_url_extra_params
235+
)
236+
backend.oauth2_state = state
237+
return authorization_url

webservice/controllers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import oauth2

webservice/controllers/oauth2.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Copyright 2024 Camptocamp SA
2+
# @author Alexandre Fayolle <[email protected]>
3+
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
4+
import json
5+
import logging
6+
7+
from oauthlib.oauth2.rfc6749 import errors
8+
from werkzeug.urls import url_encode
9+
10+
from odoo import http
11+
from odoo.http import request
12+
13+
_logger = logging.getLogger(__name__)
14+
15+
16+
class OAuth2Controller(http.Controller):
17+
@http.route(
18+
"/webservice/<int:backend_id>/oauth2/redirect",
19+
type="http",
20+
auth="public",
21+
csrf=False,
22+
)
23+
def redirect(self, backend_id, **params):
24+
backend = request.env["webservice.backend"].browse(backend_id).sudo()
25+
if backend.auth_type != "oauth2" or backend.oauth2_flow != "web_application":
26+
_logger.error("unexpected backed config for backend %d", backend_id)
27+
raise errors.MismatchingRedirectURIError()
28+
expected_state = backend.oauth2_state
29+
state = params.get("state")
30+
if state != expected_state:
31+
_logger.error("unexpected state: %s", state)
32+
raise errors.MismatchingStateError()
33+
code = params.get("code")
34+
adapter = (
35+
backend._get_adapter()
36+
) # we expect an adapter that supports web_application
37+
token = adapter._fetch_token_from_authorization(code)
38+
backend.write(
39+
{
40+
"oauth2_token": json.dumps(token),
41+
"oauth2_state": False,
42+
}
43+
)
44+
# after saving the token, redirect to the backend form view
45+
cids = request.httprequest.cookies.get("cids", "")
46+
if cids:
47+
cids = [int(cid) for cid in cids.split(",")]
48+
else:
49+
cids = []
50+
record_action = backend.get_access_action()
51+
url_params = {
52+
"model": backend._name,
53+
"id": backend.id,
54+
"active_id": backend.id,
55+
"action": record_action.get("id"),
56+
}
57+
view_id = backend.get_formview_id()
58+
if view_id:
59+
url_params["view_id"] = view_id
60+
61+
if cids:
62+
url_params["cids"] = ",".join([str(cid) for cid in cids])
63+
url = "/web?#%s" % url_encode(url_params)
64+
return request.redirect(url)

webservice/models/webservice_backend.py

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
# @author Simone Orsi <[email protected]>
44
# @author Alexandre Fayolle <[email protected]>
55
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
6-
6+
import logging
77

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

10+
_logger = logging.getLogger(__name__)
11+
1012

1113
class WebserviceBackend(models.Model):
1214

@@ -23,22 +25,41 @@ class WebserviceBackend(models.Model):
2325
("none", "Public"),
2426
("user_pwd", "Username & password"),
2527
("api_key", "API Key"),
26-
("oauth2", "OAuth2 Backend Application Flow (Client Credentials Grant)"),
28+
("oauth2", "OAuth2"),
2729
],
2830
required=True,
2931
)
3032
username = fields.Char(auth_type="user_pwd")
3133
password = fields.Char(auth_type="user_pwd")
3234
api_key = fields.Char(string="API Key", auth_type="api_key")
3335
api_key_header = fields.Char(string="API Key header", auth_type="api_key")
36+
oauth2_flow = fields.Selection(
37+
[
38+
("backend_application", "Backend Application (Client Credentials Grant)"),
39+
("web_application", "Web Application (Authorization Code Grant)"),
40+
],
41+
readonly=False,
42+
store=True,
43+
compute="_compute_oauth2_flow",
44+
)
3445
oauth2_clientid = fields.Char(string="Client ID", auth_type="oauth2")
3546
oauth2_client_secret = fields.Char(string="Client Secret", auth_type="oauth2")
3647
oauth2_token_url = fields.Char(string="Token URL", auth_type="oauth2")
48+
oauth2_authorization_url = fields.Char(string="Authorization URL")
3749
oauth2_audience = fields.Char(
3850
string="Audience"
3951
# no auth_type because not required
4052
)
53+
oauth2_scope = fields.Char(help="scope of the the authorization")
4154
oauth2_token = fields.Char(help="the OAuth2 token (serialized JSON)")
55+
redirect_url = fields.Char(
56+
compute="_compute_redirect_url",
57+
help="The redirect URL to be used as part of the OAuth2 authorisation flow",
58+
)
59+
oauth2_state = fields.Char(
60+
help="random key generated when authorization flow starts "
61+
"to ensure that no CSRF attack happen"
62+
)
4263
content_type = fields.Selection(
4364
[
4465
("application/json", "JSON"),
@@ -82,7 +103,10 @@ def _valid_field_parameter(self, field, name):
82103
return name in extra_params or super()._valid_field_parameter(field, name)
83104

84105
def call(self, method, *args, **kwargs):
85-
return getattr(self._get_adapter(), method)(*args, **kwargs)
106+
_logger.debug("backend %s: call %s %s %s", self.name, method, args, kwargs)
107+
response = getattr(self._get_adapter(), method)(*args, **kwargs)
108+
_logger.debug("backend %s: response: \n%s", self.name, response)
109+
return response
86110

87111
def _get_adapter(self):
88112
with self.work_on(self._name) as work:
@@ -94,9 +118,42 @@ def _get_adapter(self):
94118
def _get_adapter_protocol(self):
95119
protocol = self.protocol
96120
if self.auth_type.startswith("oauth2"):
97-
protocol += f"+{self.auth_type}"
121+
protocol += f"+{self.auth_type}-{self.oauth2_flow}"
98122
return protocol
99123

124+
@api.depends("auth_type")
125+
def _compute_oauth2_flow(self):
126+
for rec in self:
127+
if rec.auth_type != "oauth2":
128+
rec.oauth2_flow = False
129+
130+
@api.depends("auth_type", "oauth2_flow")
131+
def _compute_redirect_url(self):
132+
get_param = self.env["ir.config_parameter"].sudo().get_param
133+
base_url = get_param("web.base.url")
134+
if base_url.startswith("http://"):
135+
_logger.warning(
136+
"web.base.url is configured in http. Oauth2 requires using https"
137+
)
138+
base_url = base_url[len("http://") :]
139+
if not base_url.startswith("https://"):
140+
base_url = f"https://{base_url}"
141+
for rec in self:
142+
if rec.auth_type == "oauth2" and rec.oauth2_flow == "web_application":
143+
rec.redirect_url = f"{base_url}/webservice/{rec.id}/oauth2/redirect"
144+
else:
145+
rec.redirect_url = False
146+
147+
def button_authorize(self):
148+
_logger.info("Button OAuth2 Authorize")
149+
authorize_url = self._get_adapter().redirect_to_authorize()
150+
_logger.info("Redirecting to %s", authorize_url)
151+
return {
152+
"type": "ir.actions.act_url",
153+
"url": authorize_url,
154+
"target": "self",
155+
}
156+
100157
@property
101158
def _server_env_fields(self):
102159
base_fields = super()._server_env_fields
@@ -109,8 +166,11 @@ def _server_env_fields(self):
109166
"api_key": {},
110167
"api_key_header": {},
111168
"content_type": {},
169+
"oauth2_flow": {},
170+
"oauth2_scope": {},
112171
"oauth2_clientid": {},
113172
"oauth2_client_secret": {},
173+
"oauth2_authorization_url": {},
114174
"oauth2_token_url": {},
115175
"oauth2_audience": {},
116176
}

0 commit comments

Comments
 (0)