adalib-auth 1.1.1__tar.gz

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,53 @@
1
+ Metadata-Version: 2.1
2
+ Name: adalib-auth
3
+ Version: 1.1.1
4
+ Summary: The programmatic tool to authenticate with AdaLab.
5
+ Home-page: https://adamatics.com
6
+ Author: Adamatics ApS
7
+ Author-email: info@adamatics.com
8
+ Requires-Python: >=3.10.0,<4.0.0
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: python-keycloak (>=3.3.0,<4.0.0)
19
+ Requires-Dist: requests-oauthlib (>=1.3.1,<2.0.0)
20
+ Project-URL: Repository, https://gitlab.com/adamatics/python/adalib-auth
21
+ Description-Content-Type: text/markdown
22
+
23
+ # adalib-auth
24
+
25
+ This repository contains the source code of `adalib-auth`, the Python library used to authenticate with the AdaLab platform.
26
+
27
+ ## Installation
28
+
29
+ `adalib-auth` can be installed from PyPI or a `devpi` index:
30
+
31
+ ```sh
32
+ # PyPI
33
+ pip install adalib-auth
34
+ # devpi
35
+ pip install --extra-index-url <devpi_index_url> adalib-auth
36
+ ```
37
+
38
+ In order to add it to the dependencies of a Python project using `poetry` use:
39
+
40
+ ```sh
41
+ poetry source add --priority=primary <repo_name> <devpi_index_url>
42
+ poetry source add --priority=primary PyPI
43
+ poetry add --source <repo_name> adalib-auth
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ See the corresponding `adalib` example notebooks.
49
+
50
+ ## Contributing
51
+
52
+ See the [contributor's guide](CONTRIBUTING.md).
53
+
@@ -0,0 +1,30 @@
1
+ # adalib-auth
2
+
3
+ This repository contains the source code of `adalib-auth`, the Python library used to authenticate with the AdaLab platform.
4
+
5
+ ## Installation
6
+
7
+ `adalib-auth` can be installed from PyPI or a `devpi` index:
8
+
9
+ ```sh
10
+ # PyPI
11
+ pip install adalib-auth
12
+ # devpi
13
+ pip install --extra-index-url <devpi_index_url> adalib-auth
14
+ ```
15
+
16
+ In order to add it to the dependencies of a Python project using `poetry` use:
17
+
18
+ ```sh
19
+ poetry source add --priority=primary <repo_name> <devpi_index_url>
20
+ poetry source add --priority=primary PyPI
21
+ poetry add --source <repo_name> adalib-auth
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ See the corresponding `adalib` example notebooks.
27
+
28
+ ## Contributing
29
+
30
+ See the [contributor's guide](CONTRIBUTING.md).
@@ -0,0 +1,13 @@
1
+ """The adalib-auth package handles the authentication workflow with the AdaLab platform.
2
+ """
3
+
4
+ import importlib.metadata
5
+
6
+ from . import config, jupyterhub, keycloak
7
+
8
+ _DISTRIBUTION_METADATA = importlib.metadata.metadata("adalib-auth")
9
+ __project__ = _DISTRIBUTION_METADATA["name"]
10
+ __version__ = _DISTRIBUTION_METADATA["version"]
11
+ __description__ = _DISTRIBUTION_METADATA["description"]
12
+
13
+ __all__ = ["config", "jupyterhub", "keycloak"]
@@ -0,0 +1,12 @@
1
+ """The Config sub-package sets up the environment for adalib. It also fetches all the
2
+ configuration values needed in the library.
3
+
4
+ The first client call to import this module will initialize it, after which it
5
+ will be a singleton instantiation for the duration of the process.
6
+ """
7
+
8
+ from .config import Configuration, get_config
9
+
10
+ __all__ = ["Configuration", "get_config"]
11
+
12
+ __title__ = "adalib-auth Config"
@@ -0,0 +1,222 @@
1
+ import logging
2
+ import os
3
+ import threading
4
+ import urllib.parse
5
+
6
+ import requests
7
+
8
+
9
+ class Singleton(type):
10
+ """Class to ensure singleton behaviour of the Configuration class."""
11
+
12
+ _instances = {}
13
+ _lock = threading.Lock()
14
+
15
+ def __call__(cls, *args, **kwargs):
16
+ """Instantiate Configuration singleton."""
17
+ if cls not in cls._instances:
18
+ with cls._lock:
19
+ cls._instances[cls] = super(Singleton, cls).__call__(
20
+ *args, **kwargs
21
+ )
22
+ return cls._instances[cls]
23
+
24
+ def reset_instances(cls):
25
+ """Reset Configuration singleton instance."""
26
+ if cls in cls._instances:
27
+ with cls._lock:
28
+ del cls._instances[cls]
29
+ else:
30
+ raise ValueError("No instance exists, therefore nothing to reset.")
31
+
32
+
33
+ class Configuration(metaclass=Singleton):
34
+ """Configuration singleton class."""
35
+
36
+ def __init__(self, adaboard_api_url=None, **kwargs):
37
+ """Initialize Configuration instance"""
38
+ self.LOG_LEVEL = os.getenv("ADALIB_LOG_LEVEL", "INFO")
39
+ self.HARBOR_AUTH_FILE = os.path.join(
40
+ os.getenv("XDG_RUNTIME_DIR", ".docker"), "config.json"
41
+ )
42
+ self.__configure_logging()
43
+ self.__configure_environment()
44
+ self.__configure_adaboard(adaboard_api_url=adaboard_api_url)
45
+ self.__configure_clients()
46
+ self.__configure_services()
47
+ self.__configure_credentials(**kwargs)
48
+ match self.ENVIRONMENT:
49
+ case "jupyterhub":
50
+ logging.info("adalib configured, running in JupyterHub.")
51
+ case "nonpub-user-app":
52
+ logging.info(
53
+ "adalib configured, running in a non-public deployed app."
54
+ )
55
+ case "external":
56
+ logging.info(
57
+ "adalib configured, running in an external environment."
58
+ )
59
+
60
+ def __configure_credentials(self, **kwargs):
61
+ """access token and refresh token refer to the JH client"""
62
+ if self.ENVIRONMENT == "jupyterhub":
63
+ self.CREDENTIALS = {
64
+ "username": os.environ["LOGNAME"],
65
+ "jh_token": os.environ["JUPYTERHUB_API_TOKEN"],
66
+ "access_token": None,
67
+ "refresh_token": None,
68
+ }
69
+ elif self.ENVIRONMENT == "nonpub-user-app":
70
+ self.CREDENTIALS = {
71
+ "client_id": os.environ["_UA_CLIENT_ID"],
72
+ "client_secret": os.environ["_UA_CLIENT_SECRET"],
73
+ "app_access_token": kwargs.get(
74
+ "app_access_token"
75
+ ), # Deployed app client
76
+ "app_refresh_token": kwargs.get(
77
+ "app_refresh_token"
78
+ ), # Deployed app client
79
+ "access_token": None,
80
+ "refresh_token": None,
81
+ }
82
+ elif self.ENVIRONMENT == "external":
83
+ self.CREDENTIALS = {
84
+ "access_token": None,
85
+ "refresh_token": None,
86
+ "token": kwargs.get("token"),
87
+ }
88
+
89
+ def __configure_environment(self, **kwargs):
90
+ """
91
+ Find out in which environment is adalib running.
92
+ """
93
+ if "JUPYTERHUB_API_TOKEN" in os.environ:
94
+ self.ENVIRONMENT = "jupyterhub"
95
+ elif "_UA_CLIENT_ID" in os.environ:
96
+ self.ENVIRONMENT = "nonpub-user-app"
97
+ else:
98
+ self.ENVIRONMENT = "external"
99
+
100
+ def __configure_adaboard(self, adaboard_api_url=None):
101
+ """Confgiure the target Adaboard API URL."""
102
+ if self.ENVIRONMENT == "external" and (
103
+ adaboard_api_url is None and "ADABOARD_API_URL" not in os.environ
104
+ ):
105
+ raise ValueError(
106
+ "adaboard_api_url must be provided for external environment"
107
+ )
108
+ if adaboard_api_url:
109
+ self.ADABOARD_API_URL = adaboard_api_url
110
+ elif "ADABOARD_API_URL" in os.environ:
111
+ self.ADABOARD_API_URL = os.environ["ADABOARD_API_URL"]
112
+ elif "_NAMESPACE" in os.environ:
113
+ self.ADABOARD_API_URL = (
114
+ f"http://adaboard-api-svc.{os.environ['_NAMESPACE']}:8000"
115
+ )
116
+ else:
117
+ self.ADABOARD_API_URL = "http://adaboard-api-svc:8000"
118
+
119
+ def __configure_service_x(self, x: str, given_name: str, apps: list[dict]):
120
+ """Configure AdaLab app or service."""
121
+ url_type = "external" if self.ENVIRONMENT == "external" else "internal"
122
+ for app in apps:
123
+ if app["name"] == x:
124
+ url_parsed = urllib.parse.urlparse(url=app[url_type])
125
+ self.SERVICES[given_name] = {
126
+ "url": app["internal"],
127
+ "external": app["external"],
128
+ "netloc": url_parsed.netloc,
129
+ "path": url_parsed.path,
130
+ }
131
+
132
+ def __configure_services(self):
133
+ """Configure all services present in the AdaLab deployment."""
134
+ resp = requests.get(
135
+ self.ADABOARD_API_URL,
136
+ params={"include_apps": True},
137
+ timeout=10,
138
+ ).json()
139
+
140
+ apps = resp["apps"]
141
+
142
+ if (
143
+ resp["jh_secret"] == ""
144
+ and "ADALAB_CLIENT_SECRET" not in os.environ
145
+ ):
146
+ raise ValueError("AdaLab client secret was not set")
147
+ elif resp["jh_secret"] == "":
148
+ self.KEYCLOAK_CLIENTS["jupyterhub_secret"] = os.environ[
149
+ "ADALAB_CLIENT_SECRET"
150
+ ]
151
+ else:
152
+ self.KEYCLOAK_CLIENTS["jupyterhub_secret"] = resp["jh_secret"]
153
+
154
+ namespace = resp["namespace"]
155
+ network_host = resp["network_host"]
156
+
157
+ self.NAMESPACE = namespace
158
+ self.NETWORK_HOST = network_host
159
+ self.SERVICES = dict()
160
+ self.__configure_service_x(
161
+ x="adaboard-api", given_name="adaboard-api", apps=apps
162
+ )
163
+ self.__configure_service_x(
164
+ x="container-registry", given_name="harbor", apps=apps
165
+ )
166
+ self.__configure_service_x(
167
+ x="jupyterhub", given_name="jupyterhub", apps=apps
168
+ )
169
+ self.__configure_service_x(
170
+ x="keycloak", given_name="keycloak", apps=apps
171
+ )
172
+ self.__configure_service_x(x="mlflow", given_name="mlflow", apps=apps)
173
+ self.__configure_service_x(
174
+ x="superset", given_name="superset", apps=apps
175
+ )
176
+
177
+ def __configure_clients(self):
178
+ """Configure clients for services present in the AdaLab deployment."""
179
+ self.KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "adalab")
180
+ self.KEYCLOAK_CLIENTS = dict()
181
+ self.KEYCLOAK_CLIENTS["adaboard-api"] = os.environ.get(
182
+ "KEYCLOAK_ADABOARD_CLIENT_ID", "adaboard"
183
+ )
184
+ self.KEYCLOAK_CLIENTS["harbor"] = os.environ.get(
185
+ "KEYCLOAK_HARBOR_CLIENT_ID", "harbor"
186
+ )
187
+ self.KEYCLOAK_CLIENTS["jupyterhub"] = os.environ.get(
188
+ "KEYCLOAK_JUPYTERHUB_CLIENT_ID", "jupyterhub"
189
+ )
190
+ self.KEYCLOAK_CLIENTS["superset"] = os.environ.get(
191
+ "KEYCLOAK_SUPERSET_CLIENT_ID", "superset"
192
+ )
193
+
194
+ def __configure_logging(self):
195
+ """Set up execution logs."""
196
+ log_level = getattr(logging, self.LOG_LEVEL.upper(), logging.INFO)
197
+ logging.basicConfig(
198
+ level=log_level,
199
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
200
+ )
201
+
202
+ @classmethod
203
+ def clean(cls):
204
+ """
205
+ Delete existing instances of the class.
206
+ """
207
+ cls.reset_instances()
208
+
209
+ def reset(self, adaboard_api_url=None, **kwargs):
210
+ """
211
+ Clean up and create a new class instance with the new parameters.
212
+ """
213
+ self.clean()
214
+ self.__init__(adaboard_api_url=adaboard_api_url, **kwargs)
215
+
216
+
217
+ def get_config(adaboard_api_url=None, **kwargs):
218
+ """
219
+ Initialize the Configuration singleton. This is called automatically when
220
+ the Configuration class is imported.
221
+ """
222
+ return Configuration(adaboard_api_url=adaboard_api_url, **kwargs)
@@ -0,0 +1,8 @@
1
+ """Module to work with JupyterHub on the AdaLab platform.
2
+ """
3
+
4
+ from .jupyterhub import get_token_from_auth_state, is_this_jupyterhub
5
+
6
+ __all__ = ["get_token_from_auth_state", "is_this_jupyterhub"]
7
+
8
+ __title__ = "adalib-auth JupyterHub"
@@ -0,0 +1,34 @@
1
+ import os
2
+
3
+ import requests_oauthlib
4
+
5
+
6
+ def get_token_from_auth_state(
7
+ username: str,
8
+ jh_token: str,
9
+ jh_api_url: str,
10
+ ) -> dict:
11
+ """Get a keycloak token from jupyter auth state.
12
+
13
+ :return: Token to access jupyterhub.
14
+ :rtype: dict
15
+ """
16
+ if jh_api_url.startswith("http://"):
17
+ os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
18
+ session = requests_oauthlib.OAuth2Session(token={"access_token": jh_token})
19
+ response = session.get(f"{jh_api_url}/users/{username}")
20
+ response.raise_for_status()
21
+ user_info = response.json()
22
+ auth_state = user_info["auth_state"]
23
+ if not auth_state:
24
+ raise ValueError("No auth state returned by JupyterHub API")
25
+ return auth_state
26
+
27
+
28
+ def is_this_jupyterhub():
29
+ """Checks whether this package is running in a JupyterHub environment.
30
+
31
+ :return: `true` if called from a program in JupyterHub, `false` otherwise
32
+ :rtype: bool
33
+ """
34
+ return "JUPYTERHUB_API_TOKEN" in os.environ
@@ -0,0 +1,28 @@
1
+ """Keycloak authentication module for adalib-auth."""
2
+
3
+ from .. import config, jupyterhub
4
+ from .keycloak import (
5
+ app_authentication,
6
+ authenticate_with_stored_credentials,
7
+ external_authentication,
8
+ get_client,
9
+ get_client_token,
10
+ get_token_from_exchange,
11
+ jupyterhub_authentication,
12
+ update_tokens_in_config,
13
+ )
14
+
15
+ __all__ = [
16
+ "app_authentication",
17
+ "authenticate_with_stored_credentials",
18
+ "external_authentication",
19
+ "get_client",
20
+ "get_client_token",
21
+ "get_token_from_exchange",
22
+ "jupyterhub_authentication",
23
+ "update_tokens_in_config",
24
+ "config",
25
+ "jupyterhub",
26
+ ]
27
+
28
+ __title__ = "adalib-auth Keycloak"
@@ -0,0 +1,274 @@
1
+ import logging
2
+ from typing import Union
3
+
4
+ import requests
5
+
6
+ import keycloak
7
+ import keycloak.exceptions
8
+
9
+ from . import config, jupyterhub
10
+
11
+
12
+ def app_authentication(audience: str, scope: str = "openid") -> dict:
13
+ """
14
+ Returns an audience authentication token from an exchange with an app token.
15
+
16
+ :param audience: The audience for which the token is requested.
17
+ :type audience: str
18
+ :param scope: The scope of the token exchange, defaults to "openid"
19
+ :type scope: str, optional
20
+ :return: The audience token.
21
+ :rtype: dict
22
+ """
23
+ adalib_config = config.get_config()
24
+ app_token = {
25
+ "access_token": adalib_config.CREDENTIALS["app_access_token"],
26
+ "refresh_token": adalib_config.CREDENTIALS["app_refresh_token"],
27
+ }
28
+
29
+ # Exchange the app token for a Jupyterhub token
30
+ jh_token = get_token_from_exchange(
31
+ audience=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
32
+ token=app_token,
33
+ client_id=adalib_config.CREDENTIALS["client_id"],
34
+ client_secret=adalib_config.CREDENTIALS["client_secret"],
35
+ scope=scope,
36
+ )
37
+ update_tokens_in_config(
38
+ new_token=jh_token,
39
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
40
+ )
41
+ # Exchange the Jupyterhub token for an audience token
42
+ audience_token = get_token_from_exchange(
43
+ audience=audience,
44
+ token=jh_token,
45
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
46
+ client_secret=adalib_config.KEYCLOAK_CLIENTS["jupyterhub_secret"],
47
+ scope=scope,
48
+ )
49
+ return audience_token
50
+
51
+
52
+ def authenticate_with_stored_credentials(
53
+ audience_client_id: str,
54
+ ) -> Union[dict, None]:
55
+ """Attempt to get token for audience_client using stored credentials.
56
+
57
+ :param audience_client_id: The client ID of the audience.
58
+ :type audience_client_id: str
59
+ :return: The token for the current user to authenticate to the audience_client with.
60
+ :rtype: dict or None
61
+ """
62
+
63
+ adalib_config = config.get_config()
64
+ token = {
65
+ "access_token": adalib_config.CREDENTIALS["access_token"],
66
+ "refresh_token": adalib_config.CREDENTIALS["refresh_token"],
67
+ }
68
+ if token["access_token"] is None and token["refresh_token"] is None:
69
+ logging.debug("No stored credentials.")
70
+ return None
71
+ try:
72
+ audience_token = get_token_from_exchange(
73
+ audience=audience_client_id,
74
+ token=token,
75
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
76
+ client_secret=adalib_config.KEYCLOAK_CLIENTS["jupyterhub_secret"],
77
+ )
78
+ except AssertionError:
79
+ logging.warning("Stored credentials are no longer valid.")
80
+ return None
81
+ return audience_token
82
+
83
+
84
+ def external_authentication(audience: str, scope: str = "openid") -> dict:
85
+ """
86
+ Returns an audience authentication token from an exchange with external user credentials.
87
+
88
+ :param audience: The audience for which the token is requested.
89
+ :type audience: str
90
+ :param scope: The scope of the token exchange, defaults to "openid"
91
+ :type scope: str, optional
92
+ :return: The audience token.
93
+ :rtype: dict
94
+ """
95
+ adalib_config = config.get_config()
96
+ resp = requests.get(
97
+ adalib_config.ADABOARD_API_URL + "/adalib/token",
98
+ headers={
99
+ "Authorization": f"Token {adalib_config.CREDENTIALS['token']}"
100
+ },
101
+ )
102
+ if resp.status_code != 200:
103
+ raise AssertionError(
104
+ "Failed to authenticate with external credentials."
105
+ )
106
+
107
+ jh_token = resp.json()["access_token"]
108
+ client = get_client(
109
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
110
+ client_secret=adalib_config.KEYCLOAK_CLIENTS["jupyterhub_secret"],
111
+ )
112
+ new_token = client.exchange_token(
113
+ token=jh_token,
114
+ audience=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
115
+ )
116
+ update_tokens_in_config(
117
+ new_token=new_token,
118
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
119
+ )
120
+ audience_token = get_token_from_exchange(
121
+ audience=audience,
122
+ token=new_token,
123
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
124
+ client_secret=adalib_config.KEYCLOAK_CLIENTS["jupyterhub_secret"],
125
+ scope=scope,
126
+ )
127
+ return audience_token
128
+
129
+
130
+ def get_client(
131
+ client_id: str = None,
132
+ client_secret: str = None,
133
+ ) -> keycloak.KeycloakOpenID:
134
+ """Returns a Keycloak client configured for a specific client. Defaults to
135
+ the Jupyterhub client.
136
+
137
+ :param client_id: The ID of a client that is registered in Keycloak
138
+ :param client_secret: The secret matching the given client ID
139
+ :return: Keycloak client for the given client ID.
140
+ :rtype: KeycloakClient
141
+ """
142
+ adalib_config = config.get_config()
143
+ external = True if adalib_config.ENVIRONMENT == "external" else False
144
+ if external:
145
+ server_url = adalib_config.SERVICES["keycloak"]["external"] + "/auth/"
146
+ else:
147
+ server_url = adalib_config.SERVICES["keycloak"]["url"] + "/auth/"
148
+ return keycloak.KeycloakOpenID(
149
+ server_url=server_url,
150
+ client_id=client_id,
151
+ client_secret_key=client_secret,
152
+ realm_name=adalib_config.KEYCLOAK_REALM,
153
+ verify=True,
154
+ )
155
+
156
+
157
+ def get_client_token(audience_client_id: str) -> dict:
158
+ """Get the token for the current user to authenticate to adaboard with depending on adalib's
159
+ environment configuration.
160
+
161
+ :param audience_client_id: The client ID of the audience.
162
+ :type audience_client_id: str
163
+ :return: The token for the current user to authenticate to adaboard with.
164
+ :rtype: dict
165
+ """
166
+
167
+ # First try to authenticate with stored credentials
168
+ logging.debug("Attempting to authenticate with stored credentials.")
169
+ token_from_stored_credentials = authenticate_with_stored_credentials(
170
+ audience_client_id=audience_client_id
171
+ )
172
+ if token_from_stored_credentials is not None:
173
+ return token_from_stored_credentials
174
+
175
+ # If that fails, try to authenticate with user credentials
176
+ adalib_config = config.get_config()
177
+ logging.debug("Attempting to authenticate with provided credentials.")
178
+ if adalib_config.ENVIRONMENT == "jupyterhub":
179
+ return jupyterhub_authentication(audience=audience_client_id)
180
+ elif adalib_config.ENVIRONMENT == "nonpub-user-app":
181
+ return app_authentication(audience=audience_client_id)
182
+ elif adalib_config.ENVIRONMENT == "external":
183
+ return external_authentication(audience=audience_client_id)
184
+
185
+
186
+ def get_token_from_exchange(
187
+ audience: str,
188
+ token: str,
189
+ client_id: str,
190
+ client_secret: str,
191
+ scope: str = "openid",
192
+ ) -> dict:
193
+ """Get a token for the audience given a valid client token.
194
+
195
+ Note for developers: The scope defaults to `openid` to be consistent with the
196
+ subsequent call to `keycloakOpenID.exchange_token(...)`. If this default is
197
+ changed it should be done so with respect to this underlying method.
198
+
199
+ :param audience: Client that we need to get a token for.
200
+ :type audience: str
201
+ :param token: Token to exchange for audience token.
202
+ :type token: str
203
+ :param client_id: Client ID of the client that the token is for.
204
+ :type client_id: str
205
+ :param client_secret: Client secret of the client that the token is for.
206
+ :type client_secret: str
207
+ :param scope: Scope of token requested, defaults to ""
208
+ :type scope: str, optional
209
+ :return: Token to access audience
210
+ :rtype: dict
211
+ """
212
+ client = get_client(client_id=client_id, client_secret=client_secret)
213
+ if not client.introspect(token=token["access_token"])["active"]:
214
+ assert (
215
+ "refresh_token" in token and token["refresh_token"] is not None
216
+ ), "Access token is no longer valid and a refresh token was not provided"
217
+ try:
218
+ token = client.refresh_token(refresh_token=token["refresh_token"])
219
+ except Exception as exc:
220
+ raise AssertionError("Refresh token has expired.") from exc
221
+ update_tokens_in_config(new_token=token, client_id=client_id)
222
+
223
+ audience_token = client.exchange_token(
224
+ token=token["access_token"],
225
+ audience=audience,
226
+ subject=None,
227
+ scope=scope,
228
+ )
229
+ return audience_token
230
+
231
+
232
+ def jupyterhub_authentication(audience: str, scope: str = "openid") -> dict:
233
+ """
234
+ Returns an audience authentication token from an exchange with a Jupyterhub token.
235
+
236
+ :param audience: The audience for which the token is requested.
237
+ :type audience: str
238
+ :param scope: The scope of the token exchange, defaults to "openid"
239
+ :type scope: str, optional
240
+ :return: The audience token.
241
+ :rtype: dict
242
+ """
243
+ adalib_config = config.get_config()
244
+ jh_token = jupyterhub.get_token_from_auth_state(
245
+ username=adalib_config.CREDENTIALS["username"],
246
+ jh_token=adalib_config.CREDENTIALS["jh_token"],
247
+ jh_api_url=adalib_config.SERVICES["jupyterhub"]["url"] + "/hub/api",
248
+ )
249
+ update_tokens_in_config(
250
+ new_token=jh_token,
251
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
252
+ )
253
+ audience_token = get_token_from_exchange(
254
+ audience=audience,
255
+ token=jh_token,
256
+ client_id=adalib_config.KEYCLOAK_CLIENTS["jupyterhub"],
257
+ client_secret=adalib_config.KEYCLOAK_CLIENTS["jupyterhub_secret"],
258
+ scope=scope,
259
+ )
260
+ return audience_token
261
+
262
+
263
+ def update_tokens_in_config(new_token: dict, client_id: str):
264
+ """Update the tokens in the config object.
265
+
266
+ :param new_token: The new token to update the config with.
267
+ :type new_token: dict
268
+ :param client_id: The client ID of the client that the token is for.
269
+ :type client_id: str
270
+ """
271
+ adalib_config = config.get_config()
272
+ if client_id == adalib_config.KEYCLOAK_CLIENTS["jupyterhub"]:
273
+ adalib_config.CREDENTIALS["access_token"] = new_token["access_token"]
274
+ adalib_config.CREDENTIALS["refresh_token"] = new_token["refresh_token"]
@@ -0,0 +1,104 @@
1
+ [project]
2
+ name = "adalib-auth"
3
+ version = "1.1.1"
4
+ description = "The programmatic tool to authenticate with AdaLab."
5
+ keywords = [ "authentication", "keycloak"]
6
+
7
+ [[project.authors]]
8
+ name = "Adamatics ApS"
9
+ email = "info@adamatics.com"
10
+
11
+ [tool.poetry]
12
+ name = "adalib-auth"
13
+ version = "1.1.1"
14
+ description = "The programmatic tool to authenticate with AdaLab."
15
+ authors = ["Adamatics ApS <info@adamatics.com>"]
16
+ readme = "README.md"
17
+ homepage = "https://adamatics.com"
18
+ repository = "https://gitlab.com/adamatics/python/adalib-auth"
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ "Typing :: Typed",
28
+ ]
29
+ packages = [
30
+ { include = "adalib_auth" }
31
+ ]
32
+
33
+ [tool.poetry.dependencies]
34
+ python = "^3.10.0"
35
+ python-keycloak = "^3.3.0"
36
+ requests-oauthlib = "^1.3.1"
37
+
38
+ [tool.poetry.group.dev.dependencies]
39
+ black = "^23.9.1"
40
+ flake8 = "^6.1.0"
41
+ interrogate = "^1.5.0"
42
+ isort = "^5.12.0"
43
+ pre-commit = "^3.4.0"
44
+ pytest = "^7.4.2"
45
+ pytest-cov = "^4.1.0"
46
+ pyproject-flake8 = "^6.1.0"
47
+ requests-mock = "^1.11.0"
48
+ tox = "^4.11.3"
49
+
50
+ [build-system]
51
+ requires = ["poetry-core>=1.0.0"]
52
+ build-backend = "poetry.core.masonry.api"
53
+
54
+ [tool.isort]
55
+ profile = "black"
56
+ src_paths = ["adalib_auth", "tests"]
57
+ multi_line_output = 3
58
+ include_trailing_comma = true
59
+ force_grid_wrap = 0
60
+ use_parentheses = true
61
+ line_length = 79
62
+
63
+ [tool.black]
64
+ target-version = ["py310"]
65
+ include = '\.pyi?$'
66
+ line-length = 79
67
+
68
+ [tool.flake8]
69
+ ignore = ["F401", "E203", "E266", "E501", "W503"]
70
+ max-complexity = 18
71
+ select = ["B","C","E","F","W","T4"]
72
+ exclude = [".git",".venv",".tox"]
73
+ max-line-length = 79
74
+
75
+ [tool.pytest]
76
+ testpaths = "tests/"
77
+
78
+ [tool.pytest.ini_options]
79
+ addopts = """\
80
+ --cov adalib_auth \
81
+ --cov tests \
82
+ --cov-report term-missing \
83
+ --no-cov-on-fail \
84
+ """
85
+
86
+ [tool.coverage.report]
87
+ fail_under = 100
88
+ exclude_lines = [
89
+ 'if TYPE_CHECKING:',
90
+ 'pragma: no cover'
91
+ ]
92
+
93
+ [tool.mypy]
94
+ disallow_any_unimported = true
95
+ disallow_untyped_defs = true
96
+ no_implicit_optional = true
97
+ strict_equality = true
98
+ warn_unused_ignores = true
99
+ warn_redundant_casts = true
100
+ warn_return_any = true
101
+ check_untyped_defs = true
102
+ show_error_codes = true
103
+ files = "src/"
104
+ ignore_missing_imports = true