Skip to content

Commit 693fd6b

Browse files
thezultimateDafferianto Trinugroho
and
Dafferianto Trinugroho
authored
Add oauth2 authorization code flow support (#345)
*Added oauth2 workflow Co-authored-by: Dafferianto Trinugroho <DAFT@Dafferiantos-MacBook-Pro.local>
1 parent 8205089 commit 693fd6b

7 files changed

+233
-13
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [UNRELEASED] - YYYY-MM-DD
88

9+
### Added
10+
- [#345](https://github.com/equinor/webviz-config/pull/345) - Added Oauth2
11+
Authorization Code flow support for Azure AD applications.
12+
913
### Changed
1014
- [#374](https://github.com/equinor/webviz-config/pull/374) - Removed Webviz
1115
SSL certificate generation and forcing of HTTPS connections.

CONTRIBUTING.md

+30
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- [Data input](#data-input)
1010
- [Deattaching data from its original source](#deattaching-data-from-its-original-source)
1111
- [Custom ad-hoc plugins](#custom-ad-hoc-plugins)
12+
- [OAuth 2.0 Authorization Code flow](#oauth-2.0-authorization-code-flow)
1213
- [Run tests](#run-tests)
1314
- [Build documentation](#build-documentation)
1415

@@ -531,6 +532,35 @@ applies if the custom plugin is saved in a package with submodule(s),
531532
...
532533
```
533534

535+
### OAuth 2.0 Authorization Code flow
536+
537+
It is possible to use OAuth 2.0 Authorization Code flow to secure a `webviz` application.
538+
In order to do so, add `oauth2` attribute in a custom plugin with a boolean `True` value.
539+
The following is an example.
540+
541+
```python
542+
class OurCustomPlugin(WebvizPluginABC):
543+
544+
def __init__(self):
545+
super().__init__()
546+
self.use_oauth2 = True
547+
548+
@property
549+
def oauth2(self):
550+
return self.use_oauth2
551+
```
552+
553+
Information related to the application for OAuth 2.0 has to be provided in environment
554+
variables. These environment variables are `WEBVIZ_TENANT_ID`, `WEBVIZ_CLIENT_ID`,
555+
`WEBVIZ_CLIENT_SECRET`, `WEBVIZ_SCOPE`.
556+
557+
The values can be found in the Azure AD configuration page. Short explanation of these environment variables:
558+
559+
- `WEBVIZ_TENANT_ID`: The organization's Azure tenant ID (Equinor has exactly one tenant ID).
560+
- `WEBVIZ_CLIENT_ID`: ID of the Webviz Azure AD app.
561+
- `WEBVIZ_CLIENT_SECRET`: Webviz Azure AD app's client secret.
562+
- `WEBVIZ_SCOPE`: The API permission for this Webviz Azure AD app.
563+
534564
## Run tests
535565

536566
To run tests it is necessary to first install the [selenium chrome driver](https://github.com/SeleniumHQ/selenium/wiki/ChromeDriver).

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def get_long_description() -> str:
8080
"flask-talisman>=0.6",
8181
"jinja2>=2.10",
8282
"markdown>=3.0",
83+
"msal>=1.5.0",
8384
"pandas>=1.0",
8485
"pyarrow>=0.16",
8586
"pyyaml>=5.1",

webviz_config/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ._is_reload_process import is_reload_process
1212
from ._plugin_abc import WebvizPluginABC, EncodedFile, ZipFileMember
1313
from ._shared_settings_subscriptions import SHARED_SETTINGS_SUBSCRIPTIONS
14+
from ._oauth2 import Oauth2
1415

1516
try:
1617
__version__ = version("webviz-config")

webviz_config/_localhost_token.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import flask
55

66
from ._is_reload_process import is_reload_process
7+
from ._oauth2 import Oauth2
78

89

910
class LocalhostToken:
@@ -29,9 +30,10 @@ class LocalhostToken:
2930
two different localhost applications running simultaneously do not interfere.
3031
"""
3132

32-
def __init__(self, app: flask.app.Flask, port: int):
33+
def __init__(self, app: flask.app.Flask, port: int, oauth2: Oauth2 = None):
3334
self._app = app
3435
self._port = port
36+
self._oauth2 = oauth2
3537

3638
if not is_reload_process():
3739
# One time token (per run) user has to provide
@@ -63,6 +65,7 @@ def set_request_decorators(self) -> None:
6365
# pylint: disable=inconsistent-return-statements
6466
@self._app.before_request
6567
def _check_for_ott_or_cookie(): # type: ignore[no-untyped-def]
68+
6669
if not self._ott_validated and self._ott == flask.request.args.get("ott"):
6770
self._ott_validated = True
6871
flask.g.set_cookie_token = True
@@ -72,6 +75,16 @@ def _check_for_ott_or_cookie(): # type: ignore[no-untyped-def]
7275
f"cookie_token_{self._port}"
7376
):
7477
self._ott_validated = True
78+
79+
if self._oauth2:
80+
# The session of the request does not contain access token, redirect to /login
81+
is_redirected, redirect_url = self._oauth2.is_empty_token()
82+
if is_redirected:
83+
return flask.redirect(redirect_url)
84+
85+
# The session contains access token, check (and set) its expiration date
86+
self._oauth2.check_and_set_token_expiry()
87+
7588
else:
7689
flask.abort(401)
7790

webviz_config/_oauth2.py

+159
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import os
2+
import datetime
3+
from typing import Tuple
4+
5+
import msal
6+
import flask
7+
8+
9+
class Oauth2:
10+
"""Oauth2 authorization Code grant flow"""
11+
12+
def __init__(self, app: flask.app.Flask):
13+
self._app = app
14+
15+
# Azure AD app registration info (currently the values are taken from environment variables)
16+
self._tenant_id = os.environ["WEBVIZ_TENANT_ID"]
17+
self._client_id = os.environ["WEBVIZ_CLIENT_ID"]
18+
self._client_secret = os.environ["WEBVIZ_CLIENT_SECRET"]
19+
self._scope = os.environ["WEBVIZ_SCOPE"]
20+
21+
# Initiate msal
22+
self._msal_app = msal.ConfidentialClientApplication(
23+
client_id=self._client_id,
24+
client_credential=self._client_secret,
25+
authority=f"https://login.microsoftonline.com/{self._tenant_id}",
26+
)
27+
self._accounts = self._msal_app.get_accounts()
28+
29+
# Initiate oauth2 endpoints
30+
self.set_oauth2_endpoints()
31+
32+
def set_oauth2_endpoints(self) -> None:
33+
"""/login and /auth-return endpoints are added for Oauth2 authorization
34+
code flow.
35+
36+
At the end of the flow, a session cookie containing a valid access token
37+
and its expiration date is created. This flask session object can be
38+
accessed from Webviz plugin.
39+
40+
To get the access token: flask.session.get("access_token")
41+
To get the expiration date: flask.session.get("expiration_date")
42+
43+
An Azure AD application should be registered, and the following environment
44+
variables should be set: WEBVIZ_TENANT_ID, WEBVIZ_CLIENT_ID,
45+
WEBVIZ_CLIENT_SECRET, WEBVIZ_SCOPE.
46+
"""
47+
48+
@self._app.route("/login")
49+
def _login_controller(): # type: ignore[no-untyped-def]
50+
redirect_uri = get_auth_redirect_uri(flask.request.url_root)
51+
52+
# First leg of Oauth2 authorization code flow
53+
auth_url = self._msal_app.get_authorization_request_url(
54+
scopes=[self._scope], redirect_uri=redirect_uri
55+
)
56+
return flask.redirect(auth_url)
57+
58+
@self._app.route("/auth-return")
59+
def _auth_return_controller(): # type: ignore[no-untyped-def]
60+
redirect_uri = get_auth_redirect_uri(flask.request.url_root)
61+
returned_query_params = flask.request.args
62+
63+
# There is an error from the first leg of Oauth2 authorization code flow
64+
if "error" in returned_query_params:
65+
error_description = returned_query_params.get("error_description")
66+
print("Error description:", error_description)
67+
redirect_error_uri = flask.url_for("error_controller")
68+
return flask.redirect(redirect_error_uri)
69+
70+
code = returned_query_params.get("code")
71+
72+
# Second leg of Oauth2 authorization code flow
73+
tokens_result = self._msal_app.acquire_token_by_authorization_code(
74+
code=code, scopes=[self._scope], redirect_uri=redirect_uri
75+
)
76+
expires_in = tokens_result.get("expires_in")
77+
expiration_date = datetime.datetime.now() + datetime.timedelta(
78+
seconds=expires_in - 60
79+
)
80+
print("Access token expiration date:", expiration_date)
81+
82+
# Set expiration date in the session
83+
flask.session["expiration_date"] = expiration_date
84+
85+
# Set access token in the session
86+
flask.session["access_token"] = tokens_result.get("access_token")
87+
88+
return flask.redirect(flask.request.url_root)
89+
90+
@self._app.route("/error")
91+
def _error_controller(): # type: ignore[no-untyped-def]
92+
return "Error"
93+
94+
def set_oauth2_before_request_decorator(self) -> None:
95+
"""Check access token existence in session cookie before every request.
96+
If it does not exist, the browser is redirected to /login endpoint.
97+
98+
If access token exists, its expiration date is checked in session cookie.
99+
If the current date exceeds its expiration date, a new access token is
100+
retrieved and set in the session cookie. A new expiration date is also
101+
set in the session cookie.
102+
"""
103+
104+
# pylint: disable=inconsistent-return-statements
105+
@self._app.before_request
106+
def _check_access_token(): # type: ignore[no-untyped-def]
107+
# The session of the request does not contain access token, redirect to /login
108+
is_redirected, redirect_url = self.is_empty_token()
109+
if is_redirected:
110+
return flask.redirect(redirect_url)
111+
112+
# The session contains access token, check its expiration date
113+
self.check_and_set_token_expiry()
114+
115+
@staticmethod
116+
def is_empty_token() -> Tuple[bool, str]:
117+
if (
118+
not flask.session.get("access_token")
119+
and flask.request.path != "/login"
120+
and flask.request.path != "/auth-return"
121+
):
122+
login_uri = get_login_uri(flask.request.url_root)
123+
return True, login_uri
124+
125+
return False, ""
126+
127+
def check_and_set_token_expiry(self) -> None:
128+
if flask.session.get("access_token"):
129+
expiration_date = flask.session.get("expiration_date")
130+
current_date = datetime.datetime.now()
131+
if current_date > expiration_date:
132+
# Access token has expired
133+
print("Access token has expired.")
134+
if not self._accounts:
135+
self._accounts = self._msal_app.get_accounts()
136+
renewed_tokens_result = self._msal_app.acquire_token_silent(
137+
scopes=[self._scope], account=self._accounts[0]
138+
)
139+
expires_in = renewed_tokens_result.get("expires_in")
140+
new_expiration_date = datetime.datetime.now() + datetime.timedelta(
141+
seconds=expires_in - 60
142+
)
143+
print("New access token expiration date:", new_expiration_date)
144+
145+
# Set new expiration date in the session
146+
flask.session["expiration_date"] = new_expiration_date
147+
148+
# Set new access token in the session
149+
flask.session["access_token"] = renewed_tokens_result.get(
150+
"access_token"
151+
)
152+
153+
154+
def get_login_uri(url_root: str) -> str:
155+
return url_root + "login"
156+
157+
158+
def get_auth_redirect_uri(url_root: str) -> str:
159+
return url_root + "auth-return"

webviz_config/templates/webviz_template.py.jinja2

+24-12
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ app = dash.Dash(
3939
],
4040
)
4141
app.logger.setLevel(logging.WARNING)
42+
43+
# For signing session cookies
44+
app.server.secret_key = webviz_config.LocalhostToken.generate_token()
45+
4246
server = app.server
4347

4448
app.title = "{{ title }}"
@@ -66,13 +70,15 @@ app._deprecated_webviz_settings = {
6670
CACHE.init_app(server)
6771

6872
theme.adjust_csp({"script-src": app.csp_hashes()}, append=True)
69-
Talisman(server, content_security_policy=theme.csp, feature_policy=theme.feature_policy, force_https=False)
73+
Talisman(server, content_security_policy=theme.csp, feature_policy=theme.feature_policy, force_https=False, session_cookie_secure=False)
7074

7175
WEBVIZ_STORAGE.use_storage = {{portable}}
7276
WEBVIZ_STORAGE.storage_folder = Path(__file__).resolve().parent / "webviz_storage"
7377

7478
WEBVIZ_ASSETS.portable = {{ portable }}
7579

80+
use_oauth2 = False
81+
7682
if {{ not portable }} and not webviz_config.is_reload_process():
7783
# When Dash/Flask is started on localhost with hot module reload activated,
7884
# we do not want the main process to call expensive component functions in
@@ -82,16 +88,17 @@ if {{ not portable }} and not webviz_config.is_reload_process():
8288
else:
8389
page_content = {}
8490
{% for page in pages %}
85-
page_content["{{page.id}}"] = [
86-
{% for content in page.content -%}
87-
{%- if content is string -%}
88-
dcc.Markdown(r"""{{ content }}""")
89-
{%- else -%}
90-
webviz_config.plugins.{{ content._call_signature[0] }}.{{ content._call_signature[1] }}
91-
{%- endif -%}
92-
{{- "" if loop.last else ","}}
93-
{% endfor -%}
94-
]
91+
page_content["{{page.id}}"] = []
92+
{% for content in page.content -%}
93+
{% if content is string %}
94+
page_content["{{page.id}}"].append(dcc.Markdown(r"""{{ content }}"""))
95+
{% else %}
96+
plugin_content = webviz_config.plugins.{{ content._call_signature[0] }}
97+
if not use_oauth2:
98+
use_oauth2 = plugin_content.oauth2 if hasattr(plugin_content, "oauth2") else use_oauth2
99+
page_content["{{page.id}}"].append(plugin_content.{{ content._call_signature[1] }})
100+
{% endif %}
101+
{% endfor %}
95102
{% endfor %}
96103
app.layout = html.Div(
97104
className="layoutWrapper",
@@ -124,6 +131,7 @@ else:
124131
])]),
125132
html.Div(className="pageContent", id="page-content")])
126133

134+
oauth2 = webviz_config.Oauth2(app.server) if use_oauth2 else None
127135

128136
@app.callback(dash.dependencies.Output("page-content", "children"),
129137
dash.dependencies.Input("url", "pathname"))
@@ -144,7 +152,7 @@ if __name__ == "__main__":
144152

145153
port = webviz_config.utils.get_available_port(preferred_port=5000)
146154

147-
token = webviz_config.LocalhostToken(app.server, port).one_time_token
155+
token = webviz_config.LocalhostToken(app.server, port, oauth2).one_time_token
148156
webviz_config.utils.LocalhostOpenBrowser(port, token)
149157

150158
webviz_config.utils.silence_flask_startup()
@@ -161,3 +169,7 @@ if __name__ == "__main__":
161169
dev_tools_silence_routes_logging=False,
162170
{% endif %}
163171
)
172+
else:
173+
# This will be applied if not running on localhost
174+
if use_oauth2:
175+
oauth2.set_oauth2_before_request_decorator()

0 commit comments

Comments
 (0)