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.
- adalib_auth-1.1.1/PKG-INFO +53 -0
- adalib_auth-1.1.1/README.md +30 -0
- adalib_auth-1.1.1/adalib_auth/__init__.py +13 -0
- adalib_auth-1.1.1/adalib_auth/config/__init__.py +12 -0
- adalib_auth-1.1.1/adalib_auth/config/config.py +222 -0
- adalib_auth-1.1.1/adalib_auth/jupyterhub/__init__.py +8 -0
- adalib_auth-1.1.1/adalib_auth/jupyterhub/jupyterhub.py +34 -0
- adalib_auth-1.1.1/adalib_auth/keycloak/__init__.py +28 -0
- adalib_auth-1.1.1/adalib_auth/keycloak/keycloak.py +274 -0
- adalib_auth-1.1.1/pyproject.toml +104 -0
|
@@ -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,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
|