dash-auth-async 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ from .public_routes import add_public_routes, public_callback
2
+ from .basic_auth import BasicAuth
3
+ from .group_protection import list_groups, check_groups, protected, protected_callback
4
+
5
+ # oidc auth requires authlib, install with `pip install dash-auth[oidc]`
6
+ try:
7
+ from .oidc_auth import OIDCAuth, get_oauth
8
+ except ModuleNotFoundError:
9
+ pass
10
+ from .version import __version__
11
+
12
+
13
+ __all__ = [
14
+ "add_public_routes",
15
+ "check_groups",
16
+ "list_groups",
17
+ "get_oauth",
18
+ "protected",
19
+ "protected_callback",
20
+ "public_callback",
21
+ "BasicAuth",
22
+ "OIDCAuth",
23
+ "__version__",
24
+ ]
@@ -0,0 +1,94 @@
1
+ from __future__ import absolute_import
2
+ from abc import ABC, abstractmethod
3
+ from typing import Optional
4
+
5
+ from dash import Dash
6
+ from flask import request
7
+
8
+ from .public_routes import (
9
+ add_public_routes,
10
+ get_public_callbacks,
11
+ get_public_routes,
12
+ get_url_base,
13
+ )
14
+
15
+
16
+ class Auth(ABC):
17
+ def __init__(self, app: Dash, public_routes: Optional[list] = None, **obsolete):
18
+ """Auth base class for authentication in Dash.
19
+
20
+ :param app: Dash app
21
+ :param public_routes: list of public routes, routes should follow the
22
+ Flask route syntax
23
+ """
24
+
25
+ # Deprecated arguments
26
+ if obsolete:
27
+ raise TypeError(f"Auth got unexpected keyword arguments: {list(obsolete)}")
28
+
29
+ self.app = app
30
+ self._protect()
31
+ if public_routes is not None:
32
+ add_public_routes(app, public_routes)
33
+
34
+ def _protect(self):
35
+ """Add a before_request authentication check on all routes.
36
+
37
+ The authentication check will pass if either
38
+ * The endpoint is marked as public via `add_public_routes`
39
+ * The request is authorised by `Auth.is_authorised`
40
+ """
41
+
42
+ server = self.app.server
43
+
44
+ @server.before_request
45
+ def before_request_auth():
46
+ public_routes = get_public_routes(self.app)
47
+ public_callbacks = get_public_callbacks(self.app)
48
+ url_base = get_url_base(self.app)
49
+ # Handle Dash's callback route:
50
+ # * Check whether the callback is marked as public
51
+ # * Check whether the callback is performed on route change in
52
+ # which case the path should be checked against the public routes
53
+ callback_path = f"{url_base.rstrip('/')}/_dash-update-component"
54
+ if request.path == callback_path:
55
+ body = request.get_json(silent=True)
56
+
57
+ # Treat a missing or unparseable body as unauthorised rather
58
+ # than crashing with AttributeError/KeyError → 500.
59
+ if not body:
60
+ return self.login_request()
61
+
62
+ # Check whether the callback is marked as public
63
+ if body.get("output") in public_callbacks:
64
+ return None
65
+
66
+ # Check whether the callback has an input using the pathname,
67
+ # such a callback will be a routing callback and the pathname
68
+ # should be checked against the public routes
69
+ pathname = next(
70
+ (
71
+ inp.get("value")
72
+ for inp in body["inputs"]
73
+ if isinstance(inp, dict) and inp.get("property") == "pathname"
74
+ ),
75
+ None,
76
+ )
77
+ if pathname and public_routes.test(pathname):
78
+ return None
79
+
80
+ # If the route is not a callback route, check whether the path
81
+ # matches a public route, or whether the request is authorised
82
+ if public_routes.test(request.path) or self.is_authorized():
83
+ return None
84
+
85
+ # Otherwise, ask the user to log in
86
+ return self.login_request()
87
+
88
+ @abstractmethod
89
+ def is_authorized(self):
90
+ pass
91
+
92
+ @abstractmethod
93
+ def login_request(self):
94
+ pass
@@ -0,0 +1,113 @@
1
+ import base64
2
+ import logging
3
+ from typing import Dict, List, Optional, Union, Callable, cast
4
+ import flask
5
+ from dash import Dash
6
+
7
+ from .auth import Auth
8
+
9
+ UserGroups = Dict[str, List[str]]
10
+
11
+
12
+ class BasicAuth(Auth):
13
+ def __init__(
14
+ self,
15
+ app: Dash,
16
+ username_password_list: Union[list, dict] | None = None,
17
+ auth_func: Callable | None = None,
18
+ public_routes: Optional[list] = None,
19
+ user_groups: Optional[Union[UserGroups, Callable[[str], UserGroups]]] = None,
20
+ secret_key: str | None = None,
21
+ ):
22
+ """Add basic authentication to Dash.
23
+
24
+ :param app: Dash app
25
+ :param username_password_list: username:password list, either as a
26
+ list of tuples or a dict
27
+ :param auth_func: python function accepting two string
28
+ arguments (username, password) and returning a
29
+ boolean (True if the user has access otherwise False).
30
+ :param public_routes: list of public routes, routes should follow the
31
+ Flask route syntax
32
+ :param user_groups: a dict or a function returning a dict
33
+ Optional group for each user, allowing to protect routes and
34
+ callbacks depending on user groups
35
+ :param secret_key: Flask secret key
36
+ A string to protect the Flask session, by default None.
37
+ It is required if you need to store the current user
38
+ in the session.
39
+ Generate a secret key in your Python session
40
+ with the following commands:
41
+ >>> import os
42
+ >>> import base64
43
+ >>> base64.b64encode(os.urandom(30)).decode('utf-8')
44
+ Note that you should not do this dynamically:
45
+ you should create a key and then assign the value of
46
+ that key in your code.
47
+ """
48
+ super().__init__(app, public_routes=public_routes)
49
+ self._auth_func = auth_func
50
+ if isinstance(user_groups, dict):
51
+ self._user_groups_dict: UserGroups | None = cast(UserGroups, user_groups)
52
+ self._user_groups_func: Callable[[str], UserGroups] | None = None
53
+ else:
54
+ self._user_groups_dict = None
55
+ self._user_groups_func = user_groups # Callable or None after dict excluded
56
+ if secret_key is not None:
57
+ app.server.secret_key = secret_key
58
+
59
+ if self._auth_func is not None:
60
+ if username_password_list is not None:
61
+ raise ValueError(
62
+ "BasicAuth can only use authorization function "
63
+ "(auth_func kwarg) or username_password_list, "
64
+ "it cannot use both."
65
+ )
66
+ else:
67
+ if username_password_list is None:
68
+ raise ValueError(
69
+ "BasicAuth requires username/password map "
70
+ "or user-defined authorization function."
71
+ )
72
+ else:
73
+ self._users = (
74
+ username_password_list
75
+ if isinstance(username_password_list, dict)
76
+ else {k: v for k, v in username_password_list}
77
+ )
78
+
79
+ def is_authorized(self):
80
+ header = flask.request.headers.get("Authorization", None)
81
+ if not header:
82
+ return False
83
+ username_password = base64.b64decode(header.split("Basic ")[1])
84
+ username_password_utf8 = username_password.decode("utf-8")
85
+ username, password = username_password_utf8.split(":", 1)
86
+ authorized = False
87
+ if self._auth_func is not None:
88
+ try:
89
+ authorized = self._auth_func(username, password)
90
+ except Exception:
91
+ logging.exception("Error in authorization function.")
92
+ return False
93
+ else:
94
+ authorized = self._users.get(username) == password
95
+ if authorized:
96
+ try:
97
+ flask.session["user"] = {"email": username, "groups": []}
98
+ if self._user_groups_dict is not None:
99
+ flask.session["user"]["groups"] = self._user_groups_dict.get(
100
+ username, []
101
+ )
102
+ elif self._user_groups_func is not None:
103
+ flask.session["user"]["groups"] = self._user_groups_func(username)
104
+ except RuntimeError:
105
+ logging.warning("Session is not available. Have you set a secret key?")
106
+ return authorized
107
+
108
+ def login_request(self):
109
+ return flask.Response(
110
+ "Login Required",
111
+ headers={"WWW-Authenticate": 'Basic realm="User Visible Realm"'},
112
+ status=401,
113
+ )
@@ -0,0 +1,214 @@
1
+ import logging
2
+ import re
3
+ from typing import Any, Callable, List, Literal, Optional, Union
4
+
5
+ import dash
6
+ from dash.exceptions import PreventUpdate
7
+ from flask import session, has_request_context
8
+
9
+
10
+ OutputVal = Union[Callable[[], Any], Any]
11
+ CheckType = Literal["one_of", "all_of", "none_of"]
12
+
13
+
14
+ def list_groups(
15
+ *,
16
+ groups_key: str = "groups",
17
+ groups_str_split: str | None = None,
18
+ ) -> Optional[List[str]]:
19
+ """List all the groups the user belongs to.
20
+
21
+ :param groups_key: Groups key in the user data saved in the Flask session
22
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
23
+ :param groups_str_split: Used to split groups if provided as a string
24
+ :return: None or list[str]:
25
+ * None if the user is not authenticated
26
+ * list[str] otherwise
27
+ """
28
+ if not has_request_context() or "user" not in session:
29
+ return None
30
+
31
+ user_groups = session.get("user", {}).get(groups_key, [])
32
+ # Handle cases where groups are ,- or ;-separated string,
33
+ # may depend on OIDC provider
34
+ if isinstance(user_groups, str) and groups_str_split is not None:
35
+ user_groups = re.split(groups_str_split, user_groups)
36
+ return user_groups
37
+
38
+
39
+ def check_groups(
40
+ groups: Optional[List[str]] = None,
41
+ *,
42
+ groups_key: str = "groups",
43
+ groups_str_split: str | None = None,
44
+ check_type: CheckType = "one_of",
45
+ ) -> Optional[bool]:
46
+ """Check whether the current user is authenticated
47
+ and has the specified groups.
48
+
49
+ :param groups: List of groups to check for with check_type
50
+ :param groups_key: Groups key in the user data saved in the Flask session
51
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
52
+ :param groups_str_split: Used to split groups if provided as a string
53
+ :param check_type: Type of check to perform.
54
+ Either "one_of", "all_of" or "none_of"
55
+ :return: None or boolean:
56
+ * None if the user is not authenticated
57
+ * True if the user is authenticated and has the right permissions
58
+ * False if the user is authenticated but does not have
59
+ the right permissions
60
+ """
61
+ user_groups = list_groups(
62
+ groups_key=groups_key,
63
+ groups_str_split=groups_str_split,
64
+ )
65
+
66
+ if user_groups is None:
67
+ # User is not authenticated
68
+ return None
69
+
70
+ if groups is None:
71
+ return True
72
+
73
+ if check_type == "one_of":
74
+ return bool(set(user_groups).intersection(groups))
75
+ if check_type == "all_of":
76
+ return all(group in user_groups for group in groups)
77
+ if check_type == "none_of":
78
+ return not any(group in user_groups for group in groups)
79
+
80
+ raise ValueError(f"Invalid check_type: {check_type}")
81
+
82
+
83
+ def protected(
84
+ unauthenticated_output: OutputVal,
85
+ *,
86
+ missing_permissions_output: Optional[OutputVal] = None,
87
+ groups: Optional[List[str]] = None,
88
+ groups_key: str = "groups",
89
+ groups_str_split: str | None = None,
90
+ check_type: CheckType = "one_of",
91
+ ) -> Callable:
92
+ """Decorate a function or output to alter it depending on the state
93
+ of authentication and permissions.
94
+
95
+ :param unauthenticated_output: Output when the user is not authenticated.
96
+ Note: needs to be a function with no argument or static outputs.
97
+ :param missing_permissions_output: Output when the user is authenticated
98
+ but does not have the right permissions.
99
+ It defaults to unauthenticated_output when not set.
100
+ Note: needs to be a function with no argument or static outputs.
101
+ :param groups: List of authorized user groups. If no groups are passed,
102
+ the decorator will only check whether the user is authenticated.
103
+ :param groups_key: Groups key in the user data saved in the Flask session
104
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
105
+ :param groups_str_split: Used to split groups if provided as a string
106
+ :param check_type: Type of check to perform.
107
+ Either "one_of", "all_of" or "none_of"
108
+ """
109
+
110
+ if missing_permissions_output is None:
111
+ missing_permissions_output = unauthenticated_output
112
+
113
+ def decorator(output: OutputVal):
114
+ def wrap(*args, **kwargs):
115
+ def process_output(output, *args, **kwargs):
116
+ if isinstance(output, Callable):
117
+ return output(*args, **kwargs)
118
+ return output
119
+
120
+ authorized = check_groups(
121
+ groups=groups,
122
+ groups_key=groups_key,
123
+ groups_str_split=groups_str_split,
124
+ check_type=check_type,
125
+ )
126
+ if authorized is None:
127
+ return process_output(unauthenticated_output)
128
+ if authorized:
129
+ return process_output(output, *args, **kwargs)
130
+ return process_output(missing_permissions_output)
131
+
132
+ if isinstance(output, Callable):
133
+ return wrap
134
+ return wrap()
135
+
136
+ return decorator
137
+
138
+
139
+ def protected_callback(
140
+ *callback_args,
141
+ unauthenticated_output: Optional[OutputVal] = None,
142
+ missing_permissions_output: Optional[OutputVal] = None,
143
+ groups: List[str] | None = None,
144
+ groups_key: str = "groups",
145
+ groups_str_split: str | None = None,
146
+ check_type: CheckType = "one_of",
147
+ **callback_kwargs,
148
+ ) -> Callable:
149
+ """Protected Dash callback.
150
+
151
+ :param **: all args and kwargs passed to a Dash callback
152
+ :param unauthenticated_output: Output when the user is not authenticated.
153
+ **Note**: Needs to be a function with no argument or static outputs.
154
+ You can access the Dash callback context within the function call if
155
+ you need to use some of the inputs/states of the callback.
156
+ If left as None, it will simply raise PreventUpdate, stopping the
157
+ callback from processing.
158
+ :param missing_permissions_output: Output when the user is authenticated
159
+ but does not have the right permissions.
160
+ It defaults to unauthenticated_output when not set.
161
+ **Note**: Needs to be a function with no argument or static outputs.
162
+ You can access the Dash callback context within the function call if
163
+ you need to use some of the inputs/states of the callback.
164
+ If left as None, it will simply raise PreventUpdate, stopping the
165
+ callback from processing.
166
+ :param groups: List of authorized user groups
167
+ :param groups_key: Groups key in the user data saved in the Flask session
168
+ e.g. session["user"] == {"email": "a.b@mail.com", "groups": ["admin"]}
169
+ :param groups_str_split: Used to split groups if provided as a string
170
+ :param check_type: Type of check to perform.
171
+ Either "one_of", "all_of" or "none_of"
172
+ """
173
+
174
+ def decorator(func):
175
+ def prevent_unauthenticated():
176
+ logging.info(
177
+ "A user tried to run %s without being authenticated.",
178
+ func.__name__,
179
+ )
180
+ raise PreventUpdate
181
+
182
+ def prevent_unauthorised():
183
+ logging.info(
184
+ "%s tried to run %s but did not have the right permissions.",
185
+ session["user"]["email"],
186
+ func.__name__,
187
+ )
188
+ raise PreventUpdate
189
+
190
+ wrapped_func = dash.callback(*callback_args, **callback_kwargs)(
191
+ protected(
192
+ unauthenticated_output=(
193
+ unauthenticated_output
194
+ if unauthenticated_output is not None
195
+ else prevent_unauthenticated
196
+ ),
197
+ missing_permissions_output=(
198
+ missing_permissions_output
199
+ if missing_permissions_output is not None
200
+ else prevent_unauthorised
201
+ ),
202
+ groups=groups,
203
+ groups_key=groups_key,
204
+ groups_str_split=groups_str_split,
205
+ check_type=check_type,
206
+ )(func)
207
+ )
208
+
209
+ def wrap(*args, **kwargs):
210
+ return wrapped_func(*args, **kwargs)
211
+
212
+ return wrap
213
+
214
+ return decorator
@@ -0,0 +1,333 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from typing import Optional, Union, TYPE_CHECKING
5
+
6
+ import dash
7
+ from authlib.integrations.base_client import OAuthError
8
+ from authlib.integrations.flask_client import OAuth
9
+ from dash_auth_async.auth import Auth
10
+ from dash_auth_async.public_routes import get_url_base
11
+ from flask import Response, redirect, request, session, url_for
12
+ from werkzeug.routing import Map, Rule
13
+
14
+ if TYPE_CHECKING:
15
+ from authlib.integrations.flask_client.apps import (
16
+ FlaskOAuth1App,
17
+ FlaskOAuth2App,
18
+ )
19
+
20
+
21
+ class OIDCAuth(Auth):
22
+ """Implements auth via OpenID."""
23
+
24
+ def __init__(
25
+ self,
26
+ app: dash.Dash,
27
+ secret_key: str | None = None,
28
+ force_https_callback: Optional[Union[bool, str]] = None,
29
+ login_route: str = "/oidc/<idp>/login",
30
+ logout_route: str = "/oidc/logout",
31
+ callback_route: str = "/oidc/<idp>/callback",
32
+ idp_selection_route: str | None = None,
33
+ log_signins: bool = False,
34
+ public_routes: Optional[list] = None,
35
+ logout_page: Union[str, Response] | None = None,
36
+ secure_session: bool = False,
37
+ ):
38
+ """Secure a Dash app through OpenID Connect.
39
+
40
+ Parameters
41
+ ----------
42
+ app : Dash
43
+ The Dash app to secure
44
+ secret_key : str, optional
45
+ A string to protect the Flask session, by default None.
46
+ Generate a secret key in your Python session
47
+ with the following commands:
48
+ >>> import os
49
+ >>> import base64
50
+ >>> base64.b64encode(os.urandom(30)).decode('utf-8')
51
+ Note that you should not do this dynamically:
52
+ you should create a key and then assign the value of
53
+ that key in your code.
54
+ force_https_callback : Union[bool, str], optional
55
+ Whether to force redirection to https, by default None
56
+ This is useful when the HTTPS termination is upstream of the server
57
+ If a string is passed, this will check for the existence of
58
+ an envvar with that name and force https callback if it exists.
59
+ login_route : str, optional
60
+ The route for the login function, it requires a <idp>
61
+ placeholder, by default "/oidc/<idp>/login".
62
+ logout_route : str, optional
63
+ The route for the logout function, by default "/oidc/logout".
64
+ callback_route : str, optional
65
+ The route for the OIDC redirect URI, it requires a <idp>
66
+ placeholder, by default "/oidc/<idp>/callback".
67
+
68
+ NOTE: login_route, logout_route, and callback_route are
69
+ registered directly on the Flask server at the paths given here,
70
+ regardless of any ``url_base_pathname`` or
71
+ ``routes_pathname_prefix`` set on the Dash app. If your app is
72
+ deployed under a prefix (e.g. ``url_base_pathname="/app/"``), the
73
+ OIDC routes still live at the server root (e.g.
74
+ ``/oidc/<idp>/callback``), NOT under the prefix. Configure your
75
+ IDP's redirect URI accordingly.
76
+ idp_selection_route : str, optional
77
+ The route for the IDP selection function, by default None
78
+ log_signins : bool, optional
79
+ Whether to log signins, by default False
80
+ public_routes : list, optional
81
+ List of public routes, routes should follow the
82
+ Flask route syntax
83
+ logout_page : str or Response, optional
84
+ Page seen by the user after logging out,
85
+ by default None which will default to a simple logged out message
86
+ secure_session: bool, optional
87
+ Whether to ensure the session is secure, setting the flasck config
88
+ SESSION_COOKIE_SECURE and SESSION_COOKIE_HTTPONLY to True,
89
+ by default False
90
+
91
+ Raises
92
+ ------
93
+ Exception
94
+ Raise an exception if the app.server.secret_key is not defined
95
+ """
96
+ super().__init__(app, public_routes=public_routes)
97
+
98
+ if isinstance(force_https_callback, str):
99
+ self.force_https_callback = force_https_callback in os.environ
100
+ elif force_https_callback is not None:
101
+ self.force_https_callback = force_https_callback
102
+ else:
103
+ self.force_https_callback = False
104
+
105
+ self.login_route = login_route
106
+ self.logout_route = logout_route
107
+ self.callback_route = callback_route
108
+ self.log_signins = log_signins
109
+ self.idp_selection_route = idp_selection_route
110
+ self.logout_page = logout_page
111
+
112
+ if secret_key is not None:
113
+ app.server.secret_key = secret_key
114
+
115
+ if app.server.secret_key is None:
116
+ raise RuntimeError("""
117
+ app.server.secret_key is missing.
118
+ Generate a secret key in your Python session
119
+ with the following commands:
120
+ >>> import os
121
+ >>> import base64
122
+ >>> base64.b64encode(os.urandom(30)).decode('utf-8')
123
+ and assign it to the property app.server.secret_key
124
+ (where app is your dash app instance), or pass is as
125
+ the secret_key argument to OIDCAuth.__init__.
126
+ Note that you should not do this dynamically:
127
+ you should create a key and then assign the value of
128
+ that key in your code/via a secret.
129
+ """)
130
+
131
+ if secure_session:
132
+ app.server.config["SESSION_COOKIE_SECURE"] = True
133
+ app.server.config["SESSION_COOKIE_HTTPONLY"] = True
134
+
135
+ self.oauth = OAuth(app.server)
136
+
137
+ # Check that the login and callback rules have an <idp> placeholder
138
+ if not re.findall(r"/<idp>(?=/|$)", login_route):
139
+ raise Exception("The login route must contain a <idp> placeholder.")
140
+ if not re.findall(r"/<idp>(?=/|$)", callback_route):
141
+ raise Exception("The callback route must contain a <idp> placeholder.")
142
+
143
+ app.server.add_url_rule(
144
+ login_route,
145
+ endpoint="oidc_login",
146
+ view_func=self.login_request,
147
+ methods=["GET"],
148
+ )
149
+ app.server.add_url_rule(
150
+ logout_route,
151
+ endpoint="oidc_logout",
152
+ view_func=self.logout,
153
+ methods=["GET"],
154
+ )
155
+ app.server.add_url_rule(
156
+ callback_route,
157
+ endpoint="oidc_callback",
158
+ view_func=self.callback,
159
+ methods=["GET"],
160
+ )
161
+
162
+ def register_provider(self, idp_name: str, **kwargs):
163
+ """Register an OpenID Connect provider.
164
+
165
+ :param idp_name: The name of the provider
166
+ :param kwargs: Keyword arguments passed to OAuth.register.
167
+ See https://docs.authlib.org/en/latest/client/flask.html for
168
+ additional details.
169
+ Typical keyword arguments for OIDC include:
170
+ * client_id
171
+ * client_secret
172
+ * server_metadata_url
173
+ * token_endpoint_auth_method
174
+ * client_kwargs (defaults to {"scope": "openid email"})
175
+ """
176
+ if not re.match(r"^[\w\-\. ]+$", idp_name):
177
+ raise ValueError(
178
+ "`idp_name` should only contain letters, numbers, hyphens, "
179
+ "underscores, periods and spaces"
180
+ )
181
+ client_kwargs = kwargs.pop("client_kwargs", {})
182
+ client_kwargs.setdefault("scope", "openid email")
183
+ self.oauth.register(idp_name, client_kwargs=client_kwargs, **kwargs)
184
+
185
+ def get_oauth_client(self, idp: str):
186
+ """Get the OAuth client."""
187
+ if idp not in self.oauth._registry:
188
+ raise ValueError(f"'{idp}' is not a valid registered idp")
189
+
190
+ client: Union[FlaskOAuth1App, FlaskOAuth2App] = self.oauth.create_client(idp)
191
+ return client
192
+
193
+ def get_oauth_kwargs(self, idp: str):
194
+ """Get the OAuth kwargs."""
195
+ if idp not in self.oauth._registry:
196
+ raise ValueError(f"'{idp}' is not a valid registered idp")
197
+
198
+ kwargs: dict = self.oauth._registry[idp][1]
199
+ return kwargs
200
+
201
+ def _create_redirect_uri(self, idp: str):
202
+ """Create the redirect uri based on callback endpoint and idp."""
203
+ if self.force_https_callback:
204
+ redirect_uri = url_for(
205
+ "oidc_callback", idp=idp, _external=True, _scheme="https"
206
+ )
207
+ else:
208
+ redirect_uri = url_for("oidc_callback", idp=idp, _external=True)
209
+ host = request.headers.get("X-Forwarded-Host")
210
+ if host:
211
+ redirect_uri = redirect_uri.replace(request.host, host, 1)
212
+ return redirect_uri
213
+
214
+ def login_request(self, idp: str | None = None):
215
+ """Start the login process."""
216
+
217
+ # `idp` can be none here as login_request is called
218
+ # without arguments in the before_request hook
219
+ if idp not in self.oauth._registry:
220
+ # If only one provider is registered, we don't need to
221
+ # ask the user to pick one, just use the one
222
+ if len(self.oauth._registry) == 1:
223
+ idp = next(iter(self.oauth._clients))
224
+ # If there are several providers and a `idp_selection_route`
225
+ # was provided, redirect to it.
226
+ elif self.idp_selection_route:
227
+ return redirect(self.idp_selection_route)
228
+ else:
229
+ return (
230
+ "Several OAuth providers are registered. Please choose one.",
231
+ 400,
232
+ )
233
+
234
+ redirect_uri = self._create_redirect_uri(idp)
235
+ oauth_client = self.get_oauth_client(idp)
236
+ oauth_kwargs = self.get_oauth_kwargs(idp)
237
+ return oauth_client.authorize_redirect(
238
+ redirect_uri,
239
+ **oauth_kwargs.get("authorize_redirect_kwargs", {}),
240
+ )
241
+
242
+ def logout(self): # pylint: disable=C0116
243
+ """Logout the user."""
244
+ session.clear()
245
+ base_url = get_url_base(self.app) or "/"
246
+ page = (
247
+ self.logout_page
248
+ or f"""
249
+ <div style="display: flex; flex-direction: column;
250
+ gap: 0.75rem; padding: 3rem 5rem;">
251
+ <div>Logged out successfully</div>
252
+ <div><a href="{base_url}">Go back</a></div>
253
+ </div>
254
+ """
255
+ )
256
+ return page
257
+
258
+ def callback(self, idp: str): # pylint: disable=C0116
259
+ """Handle the OIDC dance and post-login actions."""
260
+ if idp not in self.oauth._registry:
261
+ return f"'{idp}' is not a valid registered idp", 400
262
+
263
+ oauth_client = self.get_oauth_client(idp)
264
+ oauth_kwargs = self.get_oauth_kwargs(idp)
265
+ try:
266
+ token = oauth_client.authorize_access_token(
267
+ **oauth_kwargs.get("authorize_token_kwargs", {}),
268
+ )
269
+ except OAuthError as err:
270
+ return str(err), 401
271
+
272
+ user = token.get("userinfo")
273
+ return self.after_logged_in(user, idp, token)
274
+
275
+ def after_logged_in(self, user: Optional[dict], idp: str, token: dict):
276
+ """
277
+ Post-login actions after successful OIDC authentication.
278
+ For example, allows to pass custom attributes to the user session:
279
+ class MyOIDCAuth(OIDCAuth):
280
+ def after_logged_in(self, user, idp, token):
281
+ if user:
282
+ user["params"] = value1
283
+ return super().after_logged_in(user, idp, token)
284
+ """
285
+ if user:
286
+ session["user"] = user
287
+ session["idp"] = idp
288
+ oauth_scope = self.get_oauth_client(idp).client_kwargs["scope"]
289
+ if "offline_access" in oauth_scope:
290
+ session["refresh_token"] = token.get("refresh_token")
291
+ if self.log_signins:
292
+ logging.info("User %s is logging in.", user.get("email"))
293
+
294
+ return redirect(get_url_base(self.app) or "/")
295
+
296
+ def is_authorized(self): # pylint: disable=C0116
297
+ """Check whether ther user is authenticated."""
298
+
299
+ map_adapter = Map(
300
+ [
301
+ Rule(x)
302
+ for x in [
303
+ self.login_route,
304
+ self.logout_route,
305
+ self.callback_route,
306
+ self.idp_selection_route,
307
+ ]
308
+ if x
309
+ ]
310
+ ).bind("")
311
+ return map_adapter.test(request.path) or "user" in session
312
+
313
+
314
+ def get_oauth(app: dash.Dash | None = None) -> OAuth:
315
+ """Retrieve the OAuth object.
316
+
317
+ :param app: dash.Dash
318
+ Dash app or None, if None the current app is used
319
+ calling `dash.get_app()`
320
+ """
321
+ if app is None:
322
+ app = dash.get_app()
323
+
324
+ oauth = getattr(app.server, "extensions", {}).get(
325
+ "authlib.integrations.flask_client"
326
+ )
327
+ if oauth is not None:
328
+ return oauth
329
+
330
+ raise RuntimeError(
331
+ "OAuth object is not yet defined. `OIDCAuth(app, **kwargs)` needs "
332
+ "to be run before `get_oauth` is called."
333
+ )
@@ -0,0 +1,128 @@
1
+ import inspect
2
+ import os
3
+
4
+ from dash import Dash, callback
5
+ from dash._callback import GLOBAL_CALLBACK_MAP
6
+ from dash import get_app
7
+ from werkzeug.routing import Map, MapAdapter, Rule
8
+
9
+ DASH_PUBLIC_ASSETS_EXTENSIONS = "js,css"
10
+ BASE_PUBLIC_ROUTES = [
11
+ f"/assets/<path:path>.{ext}"
12
+ for ext in os.getenv(
13
+ "DASH_PUBLIC_ASSETS_EXTENSIONS",
14
+ DASH_PUBLIC_ASSETS_EXTENSIONS,
15
+ ).split(",")
16
+ ] + [
17
+ "/_dash-component-suites/<path:path>",
18
+ "/_dash-layout",
19
+ "/_dash-dependencies",
20
+ "/_favicon.ico",
21
+ "/_reload-hash",
22
+ ]
23
+ PUBLIC_ROUTES = "PUBLIC_ROUTES"
24
+ PUBLIC_CALLBACKS = "PUBLIC_CALLBACKS"
25
+
26
+
27
+ def get_url_base(app: Dash) -> str:
28
+ """Return the URL prefix configured for the Dash app (e.g. '/app/').
29
+
30
+ Returns '' when no prefix is configured. Checks url_base_pathname first,
31
+ then requests_pathname_prefix, then routes_pathname_prefix. In normal Dash
32
+ usage these three values are always kept in sync by Dash itself; the
33
+ fallback order only matters in advanced deployments.
34
+
35
+ This reads from app.config at call time, so it must be invoked after the
36
+ Dash app's URL config is fully initialised. In particular, calling
37
+ add_public_routes() before url_base_pathname is set on the app will store
38
+ routes without the prefix and they will never match at request time.
39
+ """
40
+ return (
41
+ app.config.get("url_base_pathname")
42
+ or app.config.get("routes_pathname_prefix")
43
+ or ""
44
+ )
45
+
46
+
47
+ def add_public_routes(app: Dash, routes: list):
48
+ """Add routes to the public routes list.
49
+
50
+ The routes passed should follow the Flask route syntax.
51
+ e.g. "/login", "/user/<user_id>/public"
52
+
53
+ Some routes are made public by default:
54
+ * All dash scripts (_dash-dependencies, _dash-component-suites/**)
55
+ * All dash mechanics routes (_dash-layout, _reload-hash)
56
+ * All assets with extension .css, .js, .svg, .jpg, .png, .gif, .webp
57
+ Note: you can modify the extension by setting the
58
+ `DASH_ASSETS_PUBLIC_EXTENSIONS` envvar (comma-separated list of
59
+ extensions, e.g. "js,css,svg").
60
+ * The favicon
61
+
62
+ If you use callbacks on your public routes, you should use dash_auth_async's
63
+ `public_callback` rather than the standard dash callback.
64
+
65
+ :param app: Dash app
66
+ :param routes: list of public routes to be added
67
+ """
68
+
69
+ public_routes = get_public_routes(app)
70
+ url_base = get_url_base(app)
71
+
72
+ if not public_routes.map._rules:
73
+ routes = BASE_PUBLIC_ROUTES + routes
74
+
75
+ for route in routes:
76
+ if url_base and not route.startswith(url_base):
77
+ route = url_base.rstrip("/") + route
78
+ public_routes.map.add(Rule(route))
79
+
80
+ app.server.config[PUBLIC_ROUTES] = public_routes
81
+
82
+
83
+ def public_callback(*callback_args, **callback_kwargs):
84
+ """Public Dash callback.
85
+
86
+ This works by adding the callback id (from the callback map) to a list
87
+ of whitelisted callbacks in the Flask server's config.
88
+
89
+ :param **: all args and kwargs passed to a dash callback
90
+ """
91
+
92
+ def decorator(func):
93
+ wrapped_func = callback(*callback_args, **callback_kwargs)(func)
94
+ callback_id = next(
95
+ (
96
+ k
97
+ for k, v in GLOBAL_CALLBACK_MAP.items()
98
+ if inspect.getsource(v["callback"]) == inspect.getsource(func)
99
+ ),
100
+ None,
101
+ )
102
+ try:
103
+ app = get_app()
104
+ app.server.config[PUBLIC_CALLBACKS] = get_public_callbacks(app) + [
105
+ callback_id
106
+ ]
107
+ except Exception:
108
+ print(
109
+ "Could not set up the public callback as the Dash object "
110
+ "has not yet been instantiated."
111
+ )
112
+
113
+ def wrap(*args, **kwargs):
114
+ return wrapped_func(*args, **kwargs)
115
+
116
+ return wrap
117
+
118
+ return decorator
119
+
120
+
121
+ def get_public_routes(app: Dash) -> MapAdapter:
122
+ """Retrieve the public routes."""
123
+ return app.server.config.get(PUBLIC_ROUTES, Map([]).bind(""))
124
+
125
+
126
+ def get_public_callbacks(app: Dash) -> list:
127
+ """Retrieve the public callbacks ids."""
128
+ return app.server.config.get(PUBLIC_CALLBACKS, [])
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,348 @@
1
+ Metadata-Version: 2.4
2
+ Name: dash-auth-async
3
+ Version: 1.0.0
4
+ Summary: Dash Authorization Package.
5
+ Author-email: Jonas Schrage <119843859+joschrag@users.noreply.github.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/joschrag/dash-auth-async
8
+ Project-URL: Original project, https://github.com/plotly/dash-auth
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Flask
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: Intended Audience :: Healthcare Industry
16
+ Classifier: Intended Audience :: Manufacturing
17
+ Classifier: Intended Audience :: Science/Research
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Database :: Front-Ends
27
+ Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
28
+ Classifier: Topic :: Scientific/Engineering :: Visualization
29
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
30
+ Classifier: Topic :: Software Development :: Widget Sets
31
+ Requires-Python: >=3.10
32
+ Description-Content-Type: text/markdown
33
+ License-File: LICENSE
34
+ Requires-Dist: authlib>=1.7.2
35
+ Requires-Dist: dash>=2
36
+ Requires-Dist: flask>=3.1.3
37
+ Requires-Dist: requests[security]>=2.34.2
38
+ Requires-Dist: werkzeug>=3.1.8
39
+ Provides-Extra: oidc
40
+ Requires-Dist: authlib; extra == "oidc"
41
+ Dynamic: license-file
42
+
43
+ ## Dash Authorization and Login
44
+
45
+ Maintained by [joschrag](https://github.com/joschrag/). Forked from [plotly/dash-auth](https://github.com/plotly/dash-auth) with the goal to add support for the new 4.x dash backends.
46
+
47
+ License: MIT
48
+
49
+ For local testing, install [uv](https://docs.astral.sh/uv/getting-started/installation/), then install the dev dependencies and run individual tests:
50
+
51
+ ```
52
+ uv sync
53
+ uv run pytest -k ba001
54
+ ```
55
+
56
+ Note that Python 3.10 or greater is required.
57
+
58
+ ## Usage
59
+
60
+ ### Basic Authentication
61
+
62
+ To add basic authentication, add the following to your Dash app:
63
+
64
+ ```python
65
+ from dash import Dash
66
+ from dash_auth_async import BasicAuth
67
+
68
+ app = Dash(__name__)
69
+ USER_PWD = {
70
+ "username": "password",
71
+ "user2": "useSomethingMoreSecurePlease",
72
+ }
73
+ BasicAuth(app, USER_PWD)
74
+ ```
75
+
76
+ One can also use an authorization python function instead of a dictionary/list of usernames and passwords:
77
+
78
+ ```python
79
+ from dash import Dash
80
+ from dash_auth_async import BasicAuth
81
+
82
+ def authorization_function(username, password):
83
+ if (username == "hello") and (password == "world"):
84
+ return True
85
+ else:
86
+ return False
87
+
88
+
89
+ app = Dash(__name__)
90
+ BasicAuth(app, auth_func = authorization_function)
91
+ ```
92
+
93
+ ### Public routes
94
+
95
+ You can whitelist routes from authentication with the `add_public_routes` utility function,
96
+ or by passing a `public_routes` argument to the Auth constructor.
97
+ The public routes should follow [Flask's route syntax](https://flask.palletsprojects.com/en/2.3.x/quickstart/#routing).
98
+
99
+ ```python
100
+ from dash import Dash
101
+ from dash_auth_async import BasicAuth, add_public_routes
102
+
103
+ app = Dash(__name__)
104
+ USER_PWD = {
105
+ "username": "password",
106
+ "user2": "useSomethingMoreSecurePlease",
107
+ }
108
+ BasicAuth(app, USER_PWD, public_routes=["/"])
109
+
110
+ add_public_routes(app, public_routes=["/user/<user_id>/public"])
111
+ ```
112
+
113
+ NOTE: If you are using server-side callbacks on your public routes, you should also use dash_auth_async's new `public_callback` rather than the default Dash callback.
114
+ Below is an example of a public route and callbacks on a multi-page Dash app using Dash's pages API:
115
+
116
+ *app.py*
117
+ ```python
118
+ from dash import Dash, html, dcc, page_container
119
+ from dash_auth_async import BasicAuth
120
+
121
+ app = Dash(__name__, use_pages=True, suppress_callback_exceptions=True)
122
+ USER_PWD = {
123
+ "username": "password",
124
+ "user2": "useSomethingMoreSecurePlease",
125
+ }
126
+ BasicAuth(app, USER_PWD, public_routes=["/", "/user/<user_id>/public"])
127
+
128
+ app.layout = html.Div(
129
+ [
130
+ html.Div(
131
+ [
132
+ dcc.Link("Home", href="/"),
133
+ dcc.Link("John Doe", href="/user/john_doe/public"),
134
+ ],
135
+ style={"display": "flex", "gap": "1rem", "background": "lightgray", "padding": "0.5rem 1rem"},
136
+ ),
137
+ page_container,
138
+ ],
139
+ style={"display": "flex", "flexDirection": "column"},
140
+ )
141
+
142
+ if __name__ == "__main__":
143
+ app.run(debug=True)
144
+ ```
145
+
146
+ ---
147
+ *pages/home.py*
148
+ ```python
149
+ from dash import Input, Output, html, register_page
150
+ from dash_auth_async import public_callback
151
+
152
+ register_page(__name__, "/")
153
+
154
+ layout = [
155
+ html.H1("Home Page"),
156
+ html.Button("Click me", id="home-button"),
157
+ html.Div(id="home-contents"),
158
+ ]
159
+
160
+ # Note the use of public callback here rather than the default Dash callback
161
+ @public_callback(
162
+ Output("home-contents", "children"),
163
+ Input("home-button", "n_clicks"),
164
+ )
165
+ def home(n_clicks):
166
+ if not n_clicks:
167
+ return "You haven't clicked the button."
168
+ return "You clicked the button {} times".format(n_clicks)
169
+ ```
170
+
171
+ ---
172
+ *pages/public_user.py*
173
+ ```python
174
+ from dash import html, dcc, register_page
175
+
176
+ register_page(__name__, path_template="/user/<user_id>/public")
177
+
178
+ def layout(user_id: str):
179
+ return [
180
+ html.H1(f"User {user_id} (public)"),
181
+ dcc.Link("Authenticated user content", href=f"/user/{user_id}/private"),
182
+ ]
183
+ ```
184
+
185
+ ---
186
+ *pages/private_user.py*
187
+ ```python
188
+ from dash import html, register_page
189
+
190
+ register_page(__name__, path_template="/user/<user_id>/private")
191
+
192
+ def layout(user_id: str):
193
+ return [
194
+ html.H1(f"User {user_id} (authenticated only)"),
195
+ html.Div("Members-only information"),
196
+ ]
197
+ ```
198
+
199
+ ### OIDC Authentication
200
+
201
+ To add authentication with OpenID Connect, you will first need to set up an OpenID Connect provider (IDP).
202
+ This typically requires creating
203
+ * An application in your IDP
204
+ * Defining the redirect URI for your application, for testing locally you can use http://localhost:8050/oidc/callback
205
+ * A client ID and secret for the application
206
+
207
+ Once you have set up your IDP, you can add it to your Dash app as follows:
208
+
209
+ ```python
210
+ from dash import Dash
211
+ from dash_auth_async import OIDCAuth
212
+
213
+ app = Dash(__name__)
214
+
215
+ auth = OIDCAuth(app, secret_key="aStaticSecretKey!")
216
+ auth.register_provider(
217
+ "idp",
218
+ token_endpoint_auth_method="client_secret_post",
219
+ # Replace the below values with your own
220
+ # NOTE: Do not hardcode your client secret!
221
+ client_id="<my-client-id>",
222
+ client_secret="<my-client-secret>",
223
+ server_metadata_url="<my-idp-.well-known-configuration>",
224
+ )
225
+ ```
226
+
227
+ Once this is done, connecting to your app will automatically redirect to the IDP login page.
228
+
229
+ #### Multiple OIDC Providers
230
+
231
+ For multiple OIDC providers, you can use `register_provider` to add new ones after the OIDCAuth has been instantiated.
232
+
233
+ ```python
234
+ from dash import Dash, html
235
+ from dash_auth_async import OIDCAuth
236
+ from flask import request, redirect, url_for
237
+
238
+ app = Dash(__name__)
239
+
240
+ app.layout = html.Div([
241
+ html.Div("Hello world!"),
242
+ html.A("Logout", href="/oidc/logout"),
243
+ ])
244
+
245
+ auth = OIDCAuth(
246
+ app,
247
+ secret_key="aStaticSecretKey!",
248
+ # Set the route at which the user will select the IDP they wish to login with
249
+ idp_selection_route="/login",
250
+ )
251
+ auth.register_provider(
252
+ "IDP 1",
253
+ token_endpoint_auth_method="client_secret_post",
254
+ client_id="<my-client-id>",
255
+ client_secret="<my-client-secret>",
256
+ server_metadata_url="<my-idp-.well-known-configuration>",
257
+ )
258
+ auth.register_provider(
259
+ "IDP 2",
260
+ token_endpoint_auth_method="client_secret_post",
261
+ client_id="<my-client-id2>",
262
+ client_secret="<my-client-secret2>",
263
+ server_metadata_url="<my-idp2-.well-known-configuration>",
264
+ )
265
+
266
+ @app.server.route("/login", methods=["GET", "POST"])
267
+ def login_handler():
268
+ if request.method == "POST":
269
+ idp = request.form.get("idp")
270
+ else:
271
+ idp = request.args.get("idp")
272
+
273
+ if idp is not None:
274
+ return redirect(url_for("oidc_login", idp=idp))
275
+
276
+ return """<div>
277
+ <form>
278
+ <div>How do you wish to sign in:</div>
279
+ <select name="idp">
280
+ <option value="IDP 1">IDP 1</option>
281
+ <option value="IDP 2">IDP 2</option>
282
+ </select>
283
+ <input type="submit" value="Login">
284
+ </form>
285
+ </div>"""
286
+
287
+
288
+ if __name__ == "__main__":
289
+ app.run(debug=True)
290
+ ```
291
+
292
+ ### User-group-based permissions
293
+
294
+ `dash_auth_async` provides a convenient way to secure parts of your app based on user groups.
295
+
296
+ The following utilities are defined:
297
+ * `list_groups`: Returns the groups of the current user, or None if the user is not authenticated.
298
+ * `check_groups`: Checks the current user groups against the provided list of groups.
299
+ Available group checks are `one_of`, `all_of` and `none_of`.
300
+ The function returns None if the user is not authenticated.
301
+ * `protected`: A function decorator that modifies the output if the user is unauthenticated
302
+ or missing group permission.
303
+ * `protected_callback`: A callback that only runs if the user is authenticated
304
+ and with the right group permissions.
305
+
306
+ NOTE: user info is stored in the session so make sure you define a secret_key on the Flask server
307
+ to use this feature.
308
+
309
+ If you wish to use this feature with BasicAuth, you will need to define the groups for individual
310
+ basicauth users:
311
+
312
+ ```python
313
+ from dash_auth_async import BasicAuth
314
+
315
+ app = Dash(__name__)
316
+ USER_PWD = {
317
+ "username": "password",
318
+ "user2": "useSomethingMoreSecurePlease",
319
+ }
320
+ BasicAuth(
321
+ app,
322
+ USER_PWD,
323
+ user_groups={"user1": ["group1", "group2"], "user2": ["group2"]},
324
+ secret_key="Test!",
325
+ )
326
+
327
+ # You can also use a function to get user groups
328
+ def check_user(username, password):
329
+ if username == "user1" and password == "password":
330
+ return True
331
+ if username == "user2" and password == "useSomethingMoreSecurePlease":
332
+ return True
333
+ return False
334
+
335
+ def get_user_groups(user):
336
+ if user == "user1":
337
+ return ["group1", "group2"]
338
+ elif user == "user2":
339
+ return ["group2"]
340
+ return []
341
+
342
+ BasicAuth(
343
+ app,
344
+ auth_func=check_user,
345
+ user_groups=get_user_groups,
346
+ secret_key="Test!",
347
+ )
348
+ ```
@@ -0,0 +1,12 @@
1
+ dash_auth_async/__init__.py,sha256=stLxnOHjkFi6DsiBtrcN9OgmXjqlquI6u7h6n2pa9mM,594
2
+ dash_auth_async/auth.py,sha256=SGhzvNTOd_3XFlXKf7hkQawgZEplXX-Y61RAetTvUuo,3320
3
+ dash_auth_async/basic_auth.py,sha256=mmsApoRY70TI5KcmNWIqbAEKuSuxyMJ82nf3L8gQcTQ,4653
4
+ dash_auth_async/group_protection.py,sha256=S-dNJ-Oe6uiiNxbz-oSyW8ZRGePYHCfTzJ0FiOsIt_I,8041
5
+ dash_auth_async/oidc_auth.py,sha256=tCnjyZu5WzTpnbDzJywz_Pp5vzaLKnOY2LZ9DxCRX_U,12893
6
+ dash_auth_async/public_routes.py,sha256=UNQ2VYMmKoEr8ndlSpPUuABdQd4BL4JfMKEdmC4GUuQ,4103
7
+ dash_auth_async/version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
8
+ dash_auth_async-1.0.0.dist-info/licenses/LICENSE,sha256=0LSBkD8UPTZ8K0Smz10lC04UjSP7OHTpWPyXeS61dUQ,1102
9
+ dash_auth_async-1.0.0.dist-info/METADATA,sha256=8tNY_gcfE4zoHejBsw-I29c2GFiHIWuTjksllq8Bj-Y,10340
10
+ dash_auth_async-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ dash_auth_async-1.0.0.dist-info/top_level.txt,sha256=ij1JV4p2XDou9ara3XxqHRBGRSvIDKm7GG10fF6Bfww,16
12
+ dash_auth_async-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 Plotly, Inc.
4
+ Copyright (c) 2025 Jonas Schrage
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dash_auth_async