pypomes-iam 0.1.1__tar.gz → 0.1.3__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.
Potentially problematic release.
This version of pypomes-iam might be problematic. Click here for more details.
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/PKG-INFO +3 -1
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/pyproject.toml +3 -1
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/src/pypomes_iam/__init__.py +6 -1
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/src/pypomes_iam/jusbr_pomes.py +106 -62
- pypomes_iam-0.1.3/src/pypomes_iam/keycloak_pomes.py +213 -0
- pypomes_iam-0.1.3/src/pypomes_iam/token_pomes.py +101 -0
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/.gitignore +0 -0
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/LICENSE +0 -0
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/README.md +0 -0
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.3}/src/pypomes_iam/provider_pomes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pypomes_iam
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A collection of Python pomes, penyeach (IAM modules)
|
|
5
5
|
Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-IAM
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-IAM/issues
|
|
@@ -12,5 +12,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Requires-Dist: cachetools>=6.2.1
|
|
14
14
|
Requires-Dist: flask>=3.1.2
|
|
15
|
+
Requires-Dist: pyjwt>=2.10.1
|
|
15
16
|
Requires-Dist: pypomes-core>=2.8.0
|
|
17
|
+
Requires-Dist: pypomes-crypto>=0.4.8
|
|
16
18
|
Requires-Dist: requests>=2.32.5
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "pypomes_iam"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.3"
|
|
10
10
|
authors = [
|
|
11
11
|
{ name="GT Nunes", email="wisecoder01@gmail.com" }
|
|
12
12
|
]
|
|
@@ -21,7 +21,9 @@ classifiers = [
|
|
|
21
21
|
dependencies = [
|
|
22
22
|
"cachetools>=6.2.1",
|
|
23
23
|
"Flask>=3.1.2",
|
|
24
|
+
"PyJWT>=2.10.1",
|
|
24
25
|
"pypomes-core>=2.8.0",
|
|
26
|
+
"pypomes-crypto>=0.4.8",
|
|
25
27
|
"requests>=2.32.5"
|
|
26
28
|
]
|
|
27
29
|
|
|
@@ -4,12 +4,17 @@ from .jusbr_pomes import (
|
|
|
4
4
|
from .provider_pomes import (
|
|
5
5
|
provider_register, provider_get_token
|
|
6
6
|
)
|
|
7
|
+
from .token_pomes import (
|
|
8
|
+
token_validate
|
|
9
|
+
)
|
|
7
10
|
|
|
8
11
|
__all__ = [
|
|
9
12
|
# jusbr_pomes
|
|
10
13
|
"jusbr_setup", "jusbr_get_token", "jusbr_set_scope",
|
|
11
14
|
# provider_pomes
|
|
12
|
-
"provider_register", "provider_get_token"
|
|
15
|
+
"provider_register", "provider_get_token",
|
|
16
|
+
# token_pomes
|
|
17
|
+
"token_validate"
|
|
13
18
|
]
|
|
14
19
|
|
|
15
20
|
from importlib.metadata import version
|
|
@@ -24,38 +24,33 @@ JUSBR_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_ENDPOIN
|
|
|
24
24
|
JUSBR_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_ENDPOINT_TOKEN",
|
|
25
25
|
def_value="/iam/jusbr:get-token")
|
|
26
26
|
|
|
27
|
+
JUSBR_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_PUBLIC_KEY_LIFETIME",
|
|
28
|
+
def_value=86400) # 24 hours
|
|
29
|
+
JUSBR_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_BASE")
|
|
27
30
|
JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_CALLBACK")
|
|
28
|
-
JUSBR_URL_AUTH_LOGIN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_LOGIN")
|
|
29
|
-
JUSBR_URL_AUTH_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_TOKEN")
|
|
30
31
|
|
|
31
|
-
#
|
|
32
|
+
# registry structure:
|
|
32
33
|
# {
|
|
33
34
|
# "client-id": <str>,
|
|
34
35
|
# "client-secret": <str>,
|
|
35
|
-
# "auth-url": <str>,
|
|
36
|
-
# "token-url": <str>,
|
|
37
36
|
# "client-timeout": <int>,
|
|
37
|
+
# "public_key": <str>,
|
|
38
|
+
# "key-expiration": <int>,
|
|
39
|
+
# "auth-url": <str>,
|
|
40
|
+
# "callback-url": <str>,
|
|
38
41
|
# "users": {
|
|
39
42
|
# "<user-id>": {
|
|
40
43
|
# "cache-obj": <Cache>,
|
|
41
44
|
# "oauth-scope": <str>,
|
|
42
45
|
# "access-expiration": <timestamp>,
|
|
43
|
-
# data in <
|
|
46
|
+
# data in <Cache>:
|
|
44
47
|
# "oauth-state": <str>
|
|
45
48
|
# "access-token": <str>
|
|
46
49
|
# "refresh-token": <str>
|
|
47
50
|
# }
|
|
48
51
|
# }
|
|
49
52
|
# }
|
|
50
|
-
_jusbr_registry: dict[str, Any] =
|
|
51
|
-
"client-id": None,
|
|
52
|
-
"client-secret": None,
|
|
53
|
-
"client-timeout": None,
|
|
54
|
-
"auth-url": None,
|
|
55
|
-
"callback-url": None,
|
|
56
|
-
"token-url": None,
|
|
57
|
-
"users": {}
|
|
58
|
-
}
|
|
53
|
+
_jusbr_registry: dict[str, Any] | None = None
|
|
59
54
|
|
|
60
55
|
# dafault logger
|
|
61
56
|
_logger: Logger | None = None
|
|
@@ -65,13 +60,13 @@ def jusbr_setup(flask_app: Flask,
|
|
|
65
60
|
client_id: str = JUSBR_CLIENT_ID,
|
|
66
61
|
client_secret: str = JUSBR_CLIENT_SECRET,
|
|
67
62
|
client_timeout: int = JUSBR_CLIENT_TIMEOUT,
|
|
63
|
+
public_key_lifetime: int = JUSBR_PUBLIC_KEY_LIFETIME,
|
|
68
64
|
callback_endpoint: str = JUSBR_ENDPOINT_CALLBACK,
|
|
69
65
|
token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
|
|
70
66
|
login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
|
|
71
67
|
logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
|
|
72
|
-
auth_url: str =
|
|
68
|
+
auth_url: str = JUSBR_URL_AUTH_BASE,
|
|
73
69
|
callback_url: str = JUSBR_URL_AUTH_CALLBACK,
|
|
74
|
-
token_url: str = JUSBR_URL_AUTH_TOKEN,
|
|
75
70
|
logger: Logger = None) -> None:
|
|
76
71
|
"""
|
|
77
72
|
Configure the JusBR IAM.
|
|
@@ -82,13 +77,13 @@ def jusbr_setup(flask_app: Flask,
|
|
|
82
77
|
:param client_id: the client's identification with JusBR
|
|
83
78
|
:param client_secret: the client's password with JusBR
|
|
84
79
|
:param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
|
|
80
|
+
:param public_key_lifetime: how long to use JusBR's public key, before refreshing it (in seconds)
|
|
85
81
|
:param callback_endpoint: endpoint for the callback from JusBR
|
|
86
82
|
:param token_endpoint: endpoint for retrieving the JusBR authentication token
|
|
87
83
|
:param login_endpoint: endpoint for redirecting user to JusBR login page
|
|
88
84
|
:param logout_endpoint: endpoint for terminating user access to JusBR
|
|
89
|
-
:param auth_url: URL to
|
|
85
|
+
:param auth_url: base URL to request the JusBR services
|
|
90
86
|
:param callback_url: URL for JusBR to callback on login
|
|
91
|
-
:param token_url: URL for obtaing or refreshing the token
|
|
92
87
|
:param logger: optional logger
|
|
93
88
|
"""
|
|
94
89
|
# establish the logger
|
|
@@ -96,15 +91,17 @@ def jusbr_setup(flask_app: Flask,
|
|
|
96
91
|
_logger = logger
|
|
97
92
|
|
|
98
93
|
# configure the JusBR registry
|
|
99
|
-
global _jusbr_registry
|
|
100
|
-
_jusbr_registry
|
|
94
|
+
global _jusbr_registry
|
|
95
|
+
_jusbr_registry = {
|
|
101
96
|
"client-id": client_id,
|
|
102
97
|
"client-secret": client_secret,
|
|
103
98
|
"client-timeout": client_timeout,
|
|
104
99
|
"auth-url": auth_url,
|
|
105
100
|
"callback-url": callback_url,
|
|
106
|
-
"
|
|
107
|
-
|
|
101
|
+
"key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
|
|
102
|
+
"key-lifetime": public_key_lifetime,
|
|
103
|
+
"users": {}
|
|
104
|
+
}
|
|
108
105
|
|
|
109
106
|
# establish the endpoints
|
|
110
107
|
if token_endpoint:
|
|
@@ -135,48 +132,38 @@ def service_login() -> Response:
|
|
|
135
132
|
"""
|
|
136
133
|
Entry point for the JusBR login service.
|
|
137
134
|
|
|
138
|
-
Redirect the request to the JusBR authentication page, with the
|
|
135
|
+
Redirect the request to the JusBR authentication page, with the appropriate parameters.
|
|
139
136
|
|
|
140
137
|
:return: the response from the redirect operation
|
|
141
138
|
"""
|
|
142
139
|
global _jusbr_registry
|
|
143
140
|
|
|
144
|
-
#
|
|
145
|
-
result: Response
|
|
146
|
-
|
|
147
|
-
# retrieve user id
|
|
141
|
+
# retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
|
|
148
142
|
input_params: dict[str, Any] = request.values
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
safe_cache
|
|
159
|
-
|
|
160
|
-
safe_cache = TTLCache(maxsize=16,
|
|
161
|
-
ttl=600)
|
|
162
|
-
else:
|
|
163
|
-
safe_cache = FIFOCache(maxsize=16)
|
|
164
|
-
safe_cache["oauth-state"] = oauth_state
|
|
165
|
-
user_data["cache-obj"] = safe_cache
|
|
166
|
-
auth_url: str = (f"{_jusbr_registry["auth-url"]}?response_type=code"
|
|
167
|
-
f"&client_id={_jusbr_registry["client-id"]}"
|
|
168
|
-
f"&redirect_url={_jusbr_registry["callback-url"]}"
|
|
169
|
-
f"&state={oauth_state}")
|
|
170
|
-
if user_data.get("oauth-scope"):
|
|
171
|
-
auth_url += f"&scope={user_data.get("oauth-scope")}"
|
|
172
|
-
|
|
173
|
-
# redirect request
|
|
174
|
-
result = redirect(location=auth_url)
|
|
143
|
+
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
144
|
+
user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
|
|
145
|
+
# obtain user data
|
|
146
|
+
user_data: dict[str, Any] = __get_user_data(user_id=user_id,
|
|
147
|
+
logger=_logger)
|
|
148
|
+
# build redirect url
|
|
149
|
+
timeout: int = __get_login_timeout()
|
|
150
|
+
safe_cache: Cache
|
|
151
|
+
if timeout:
|
|
152
|
+
safe_cache = TTLCache(maxsize=16,
|
|
153
|
+
ttl=600)
|
|
175
154
|
else:
|
|
176
|
-
|
|
177
|
-
|
|
155
|
+
safe_cache = FIFOCache(maxsize=16)
|
|
156
|
+
safe_cache["oauth-state"] = oauth_state
|
|
157
|
+
user_data["cache-obj"] = safe_cache
|
|
158
|
+
auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
|
|
159
|
+
f"&client_id={_jusbr_registry["client-id"]}"
|
|
160
|
+
f"&redirect_url={_jusbr_registry["callback-url"]}"
|
|
161
|
+
f"&state={oauth_state}")
|
|
162
|
+
if user_data.get("oauth-scope"):
|
|
163
|
+
auth_url += f"&scope={user_data.get("oauth-scope")}"
|
|
178
164
|
|
|
179
|
-
|
|
165
|
+
# redirect request
|
|
166
|
+
return redirect(location=auth_url)
|
|
180
167
|
|
|
181
168
|
|
|
182
169
|
# @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:logout
|
|
@@ -213,14 +200,18 @@ def service_callback() -> Response:
|
|
|
213
200
|
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
214
201
|
"""
|
|
215
202
|
global _jusbr_registry
|
|
203
|
+
from .token_pomes import token_validate
|
|
216
204
|
|
|
217
205
|
# validate the OAuth2 state
|
|
218
206
|
oauth_state: str = request.args.get("state")
|
|
207
|
+
user_id: str | None = None
|
|
219
208
|
user_data: dict[str, Any] | None = None
|
|
220
209
|
if oauth_state:
|
|
221
|
-
for data in _jusbr_registry.get("users"):
|
|
222
|
-
safe_cache: Cache =
|
|
223
|
-
if
|
|
210
|
+
for user, data in _jusbr_registry.get("users").items():
|
|
211
|
+
safe_cache: Cache = data.get("cache-obj")
|
|
212
|
+
if user == oauth_state or \
|
|
213
|
+
(safe_cache and oauth_state == safe_cache.get("oauth-state")):
|
|
214
|
+
user_id = user
|
|
224
215
|
user_data = data
|
|
225
216
|
# 'oauth-state' is to be used only once
|
|
226
217
|
safe_cache["oauth-state"] = None
|
|
@@ -240,6 +231,20 @@ def service_callback() -> Response:
|
|
|
240
231
|
body_data=body_data,
|
|
241
232
|
errors=errors,
|
|
242
233
|
logger=_logger)
|
|
234
|
+
# retrieve the token's claims
|
|
235
|
+
if not errors:
|
|
236
|
+
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
237
|
+
issuer=_jusbr_registry.get("auth-url"),
|
|
238
|
+
public_key=_jusbr_registry.get("public_key"),
|
|
239
|
+
errors=errors,
|
|
240
|
+
logger=_logger)
|
|
241
|
+
if not errors:
|
|
242
|
+
token_user: str = token_claims["payload"].get("preferred_username")
|
|
243
|
+
if user_id == oauth_state:
|
|
244
|
+
user_id = token_user
|
|
245
|
+
_jusbr_registry["users"][user_id] = _jusbr_registry["users"].pop(oauth_state)
|
|
246
|
+
elif token_user != user_id:
|
|
247
|
+
errors.append(f"Token was issued to user '{token_user}'")
|
|
243
248
|
else:
|
|
244
249
|
msg: str = "Unknown OAuth2 code received"
|
|
245
250
|
if __get_login_timeout():
|
|
@@ -251,7 +256,9 @@ def service_callback() -> Response:
|
|
|
251
256
|
result = jsonify({"errors": "; ".join(errors)})
|
|
252
257
|
result.status_code = 400
|
|
253
258
|
else:
|
|
254
|
-
result = jsonify({
|
|
259
|
+
result = jsonify({
|
|
260
|
+
"user_id": user_id,
|
|
261
|
+
"access_token": token})
|
|
255
262
|
|
|
256
263
|
return result
|
|
257
264
|
|
|
@@ -351,6 +358,42 @@ def jusbr_set_scope(user_id: str,
|
|
|
351
358
|
logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
|
|
352
359
|
|
|
353
360
|
|
|
361
|
+
def __get_public_key(url: str,
|
|
362
|
+
logger: Logger | None) -> str:
|
|
363
|
+
"""
|
|
364
|
+
Obtain the public key used by JusBR to sign the authentication tokens.
|
|
365
|
+
|
|
366
|
+
:param url: the base URL to request the public key
|
|
367
|
+
:return: the public key, in *PEM* format
|
|
368
|
+
"""
|
|
369
|
+
from pypomes_crypto import crypto_jwk_convert
|
|
370
|
+
global _jusbr_registry
|
|
371
|
+
|
|
372
|
+
# initialize the return variable
|
|
373
|
+
result: str | None = None
|
|
374
|
+
|
|
375
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
376
|
+
if now > _jusbr_registry.get("key-expiration"):
|
|
377
|
+
# obtain a new public key
|
|
378
|
+
url: str = f"{url}/protocol/openid-connect/certs"
|
|
379
|
+
response: requests.Response = requests.get(url=url)
|
|
380
|
+
if response.status_code == 200:
|
|
381
|
+
# request succeeded
|
|
382
|
+
reply: dict[str, Any] = response.json()
|
|
383
|
+
result = crypto_jwk_convert(jwk=reply["keys"][0],
|
|
384
|
+
fmt="PEM")
|
|
385
|
+
_jusbr_registry["public-key"] = result
|
|
386
|
+
duration: int = _jusbr_registry.get("key-lifetime") or 0
|
|
387
|
+
_jusbr_registry["key-expiration"] = now + duration
|
|
388
|
+
elif logger:
|
|
389
|
+
logger.error(msg=f"GET '{url}': failed, "
|
|
390
|
+
f"status {response.status_code}, reason '{response.reason}'")
|
|
391
|
+
else:
|
|
392
|
+
result = _jusbr_registry.get("public-key")
|
|
393
|
+
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
|
|
354
397
|
def __get_login_timeout() -> int | None:
|
|
355
398
|
"""
|
|
356
399
|
Retrieve the timeout currently applicable for the login operation.
|
|
@@ -421,9 +464,10 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
421
464
|
if client_secret:
|
|
422
465
|
body_data["client_secret"] = client_secret
|
|
423
466
|
|
|
467
|
+
# obtain the token
|
|
424
468
|
err_msg: str | None = None
|
|
425
469
|
safe_cache: Cache = user_data.get("cache-obj")
|
|
426
|
-
url: str = _jusbr_registry.get("auth-url")
|
|
470
|
+
url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
|
|
427
471
|
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
428
472
|
try:
|
|
429
473
|
# JusBR return on a token request:
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# import requests
|
|
2
|
+
# import secrets
|
|
3
|
+
# import string
|
|
4
|
+
# import sys
|
|
5
|
+
# from cachetools import Cache, FIFOCache, TTLCache
|
|
6
|
+
# from datetime import datetime
|
|
7
|
+
from flask import Flask, Response, redirect, request, jsonify
|
|
8
|
+
from logging import Logger
|
|
9
|
+
from pypomes_core import (
|
|
10
|
+
APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str, exc_format
|
|
11
|
+
)
|
|
12
|
+
from typing import Any, Final
|
|
13
|
+
|
|
14
|
+
KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
|
|
15
|
+
KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
|
|
16
|
+
KEYCLOAK_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_TIMEOUT")
|
|
17
|
+
|
|
18
|
+
KEYCLOAK_ENDPOINT_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_CALLBACK",
|
|
19
|
+
def_value="/iam/keycloak:callback")
|
|
20
|
+
KEYCLOAK_ENDPOINT_LOGIN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_LOGIN",
|
|
21
|
+
def_value="/iam/keycloak:login")
|
|
22
|
+
KEYCLOAK_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_LOGOUT",
|
|
23
|
+
def_value="/iam/keycloak:logout")
|
|
24
|
+
KEYCLOAK_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_TOKEN",
|
|
25
|
+
def_value="/iam/keycloak:get-token")
|
|
26
|
+
|
|
27
|
+
KEYCLOAK_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_REALM")
|
|
28
|
+
KEYCLOAK_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_BASE")
|
|
29
|
+
KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_CALLBACK")
|
|
30
|
+
|
|
31
|
+
# registry structure:
|
|
32
|
+
# {
|
|
33
|
+
# "client-id": <str>,
|
|
34
|
+
# "client-secret": <str>,
|
|
35
|
+
# "client-timeout": <int>,
|
|
36
|
+
# "realm": <str>,
|
|
37
|
+
# "auth-url": <str>,
|
|
38
|
+
# "callback-url": <str>,
|
|
39
|
+
# "users": {
|
|
40
|
+
# "<user-id>": {
|
|
41
|
+
# "cache-obj": <Cache>,
|
|
42
|
+
# "oauth-scope": <str>,
|
|
43
|
+
# "access-expiration": <timestamp>,
|
|
44
|
+
# data in <Cache>:
|
|
45
|
+
# "oauth-state": <str>
|
|
46
|
+
# "access-token": <str>
|
|
47
|
+
# "refresh-token": <str>
|
|
48
|
+
# }
|
|
49
|
+
# }
|
|
50
|
+
# }
|
|
51
|
+
_keycloak_registry: dict[str, Any] = {
|
|
52
|
+
"client-id": None,
|
|
53
|
+
"client-secret": None,
|
|
54
|
+
"client-timeout": None,
|
|
55
|
+
"realm": None,
|
|
56
|
+
"auth-url": None,
|
|
57
|
+
"callback-url": None,
|
|
58
|
+
"users": {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# dafault logger
|
|
62
|
+
_logger: Logger | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def keycloak_setup(flask_app: Flask,
|
|
66
|
+
client_id: str = KEYCLOAK_CLIENT_ID,
|
|
67
|
+
client_secret: str = KEYCLOAK_CLIENT_SECRET,
|
|
68
|
+
client_timeout: int = KEYCLOAK_CLIENT_TIMEOUT,
|
|
69
|
+
realm: str = KEYCLOAK_REALM,
|
|
70
|
+
callback_endpoint: str = KEYCLOAK_ENDPOINT_CALLBACK,
|
|
71
|
+
token_endpoint: str = KEYCLOAK_ENDPOINT_TOKEN,
|
|
72
|
+
login_endpoint: str = KEYCLOAK_ENDPOINT_LOGIN,
|
|
73
|
+
logout_endpoint: str = KEYCLOAK_ENDPOINT_LOGOUT,
|
|
74
|
+
auth_url: str = KEYCLOAK_URL_AUTH_BASE,
|
|
75
|
+
callback_url: str = KEYCLOAK_URL_AUTH_CALLBACK,
|
|
76
|
+
logger: Logger = None) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Configure the Keycloak IAM.
|
|
79
|
+
|
|
80
|
+
This should be invoked only once, before the first access to a Keycloak service.
|
|
81
|
+
|
|
82
|
+
:param flask_app: the Flask application
|
|
83
|
+
:param client_id: the client's identification with JusBR
|
|
84
|
+
:param client_secret: the client's password with JusBR
|
|
85
|
+
:param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
|
|
86
|
+
:param realm: the Keycloak reals
|
|
87
|
+
:param callback_endpoint: endpoint for the callback from JusBR
|
|
88
|
+
:param token_endpoint: endpoint for retrieving the JusBR authentication token
|
|
89
|
+
:param login_endpoint: endpoint for redirecting user to JusBR login page
|
|
90
|
+
:param logout_endpoint: endpoint for terminating user access to JusBR
|
|
91
|
+
:param auth_url: base URL to request the JusBR services
|
|
92
|
+
:param callback_url: URL for Keycloak to callback on login
|
|
93
|
+
:param logger: optional logger
|
|
94
|
+
"""
|
|
95
|
+
global _keycloak_registry
|
|
96
|
+
|
|
97
|
+
# establish the logger
|
|
98
|
+
global _logger
|
|
99
|
+
_logger = logger
|
|
100
|
+
|
|
101
|
+
# configure the JusBR registry
|
|
102
|
+
_keycloak_registry.update({
|
|
103
|
+
"client-id": client_id,
|
|
104
|
+
"client-secret": client_secret,
|
|
105
|
+
"client-timeout": client_timeout,
|
|
106
|
+
"realm": realm,
|
|
107
|
+
"auth-url": auth_url,
|
|
108
|
+
"callback-url": callback_url,
|
|
109
|
+
"users": []
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
# establish the endpoints
|
|
113
|
+
if token_endpoint:
|
|
114
|
+
flask_app.add_url_rule(rule=token_endpoint,
|
|
115
|
+
endpoint="keycloak-token",
|
|
116
|
+
view_func=service_token,
|
|
117
|
+
methods=["GET"])
|
|
118
|
+
if login_endpoint:
|
|
119
|
+
flask_app.add_url_rule(rule=login_endpoint,
|
|
120
|
+
endpoint="keycloak-login",
|
|
121
|
+
view_func=service_login,
|
|
122
|
+
methods=["GET"])
|
|
123
|
+
if logout_endpoint:
|
|
124
|
+
flask_app.add_url_rule(rule=logout_endpoint,
|
|
125
|
+
endpoint="keycloak-logout",
|
|
126
|
+
view_func=service_logout,
|
|
127
|
+
methods=["GET"])
|
|
128
|
+
if callback_endpoint:
|
|
129
|
+
flask_app.add_url_rule(rule=callback_endpoint,
|
|
130
|
+
endpoint="keycloak-callback",
|
|
131
|
+
view_func=service_callback,
|
|
132
|
+
methods=["POST"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:login
|
|
136
|
+
# methods=["GET"])
|
|
137
|
+
def service_login() -> Response:
|
|
138
|
+
"""
|
|
139
|
+
Entry point for the Keycloak login service.
|
|
140
|
+
|
|
141
|
+
Redirect the request to the Keycloak authentication page, with the appropriate parameters.
|
|
142
|
+
|
|
143
|
+
:return: the response from the redirect operation
|
|
144
|
+
"""
|
|
145
|
+
global _keycloak_registry
|
|
146
|
+
|
|
147
|
+
# retrieve user id
|
|
148
|
+
input_params: dict[str, Any] = request.args
|
|
149
|
+
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
150
|
+
return Response()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
|
|
154
|
+
# methods=["GET"])
|
|
155
|
+
def service_logout() -> Response:
|
|
156
|
+
"""
|
|
157
|
+
Entry point for the JusBR logout service.
|
|
158
|
+
|
|
159
|
+
Remove all data associating the user with JusBR from the registry.
|
|
160
|
+
|
|
161
|
+
:return: response *OK*
|
|
162
|
+
"""
|
|
163
|
+
global _keycloak_registry
|
|
164
|
+
|
|
165
|
+
# retrieve user id
|
|
166
|
+
input_params: dict[str, Any] = request.args
|
|
167
|
+
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
168
|
+
return Response()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
|
|
172
|
+
# methods=["POST"])
|
|
173
|
+
def service_callback() -> Response:
|
|
174
|
+
"""
|
|
175
|
+
Entry point for the callback from Keycloak on authentication operation.
|
|
176
|
+
|
|
177
|
+
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
178
|
+
"""
|
|
179
|
+
global _keycloak_registry
|
|
180
|
+
return Response()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
|
|
184
|
+
# methods=["GET"])
|
|
185
|
+
def service_token() -> Response:
|
|
186
|
+
"""
|
|
187
|
+
Entry point for retrieving the Keycloak token.
|
|
188
|
+
|
|
189
|
+
:return: the response containing the token, or *UNAUTHORIZED*
|
|
190
|
+
"""
|
|
191
|
+
# retrieve user id
|
|
192
|
+
input_params: dict[str, Any] = request.args
|
|
193
|
+
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
194
|
+
return Response()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def keycloak_get_token(user_id: str,
|
|
198
|
+
errors: list[str] = None,
|
|
199
|
+
logger: Logger = None) -> str:
|
|
200
|
+
"""
|
|
201
|
+
Retrieve the authentication token for user *user_id*.
|
|
202
|
+
|
|
203
|
+
:param user_id: the user's identification
|
|
204
|
+
:param errors: incidental error messages
|
|
205
|
+
:param logger: optional logger
|
|
206
|
+
:return: the token for *user_id*, or *None* if error
|
|
207
|
+
"""
|
|
208
|
+
global _keycloak_registry
|
|
209
|
+
|
|
210
|
+
# initialize the return variable
|
|
211
|
+
result: str | None = None
|
|
212
|
+
return result
|
|
213
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
import sys
|
|
3
|
+
from jwt import PyJWK
|
|
4
|
+
from jwt.algorithms import RSAPublicKey
|
|
5
|
+
from logging import Logger
|
|
6
|
+
from pypomes_core import exc_format
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def token_validate(token: str,
|
|
11
|
+
issuer: str = None,
|
|
12
|
+
public_key: str | bytes | PyJWK | RSAPublicKey = None,
|
|
13
|
+
errors: list[str] = None,
|
|
14
|
+
logger: Logger = None) -> dict[str, dict[str, Any]] | None:
|
|
15
|
+
"""
|
|
16
|
+
Verify whether *token* is a valid JWT token, and return its claims (sections *header* and *payload*).
|
|
17
|
+
|
|
18
|
+
The supported public key types are:
|
|
19
|
+
- *DER*: Distinguished Encoding Rules (bytes)
|
|
20
|
+
- *PEM*: Privacy-Enhanced Mail (str)
|
|
21
|
+
- *PyJWK*: a formar from the *PyJWT* package
|
|
22
|
+
- *RSAPublicKey*: a format from the *PyJWT* package
|
|
23
|
+
|
|
24
|
+
If an asymmetric algorithm was used to sign the token and *public_key* is provided, then
|
|
25
|
+
the token is validated, by using the data in its *signature* section.
|
|
26
|
+
|
|
27
|
+
On failure, *errors* will contain the reason(s) for rejecting *token*.
|
|
28
|
+
On success, return the token's claims (*header* and *payload*).
|
|
29
|
+
|
|
30
|
+
:param token: the token to be validated
|
|
31
|
+
:param public_key: optional public key used to sign the token, in *PEM* format
|
|
32
|
+
:param issuer: optional value to compare with the token's *iss* (issuer) attribute in its *payload*
|
|
33
|
+
:param errors: incidental error messages
|
|
34
|
+
:param logger: optional logger
|
|
35
|
+
:return: The token's claims (*header* and *payload*) if it is valid, *None* otherwise
|
|
36
|
+
"""
|
|
37
|
+
# initialize the return variable
|
|
38
|
+
result: dict[str, dict[str, Any]] | None = None
|
|
39
|
+
|
|
40
|
+
if logger:
|
|
41
|
+
logger.debug(msg="Validate JWT token")
|
|
42
|
+
|
|
43
|
+
# make sure to have an errors list
|
|
44
|
+
if not isinstance(errors, list):
|
|
45
|
+
errors = []
|
|
46
|
+
|
|
47
|
+
# extract needed data from token header
|
|
48
|
+
token_header: dict[str, Any] | None = None
|
|
49
|
+
try:
|
|
50
|
+
token_header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
exc_err: str = exc_format(exc=e,
|
|
53
|
+
exc_info=sys.exc_info())
|
|
54
|
+
if logger:
|
|
55
|
+
logger.error(msg=f"Error retrieving the token's header: {exc_err}")
|
|
56
|
+
errors.append(exc_err)
|
|
57
|
+
|
|
58
|
+
# validate the token
|
|
59
|
+
if not errors:
|
|
60
|
+
token_alg: str = token_header.get("alg")
|
|
61
|
+
options: dict[str, Any] = {
|
|
62
|
+
"require": ["exp", "iat"],
|
|
63
|
+
"verify_aud": False,
|
|
64
|
+
"verify_exp": True,
|
|
65
|
+
"verify_iat": True,
|
|
66
|
+
"verify_iss": issuer is not None,
|
|
67
|
+
"verify_nbf": False,
|
|
68
|
+
"verify_signature": token_alg in ["RS256", "RS512"] and public_key is not None
|
|
69
|
+
}
|
|
70
|
+
if issuer:
|
|
71
|
+
options["require"].append("iss")
|
|
72
|
+
try:
|
|
73
|
+
# raises:
|
|
74
|
+
# InvalidTokenError: token is invalid
|
|
75
|
+
# InvalidKeyError: authentication key is not in the proper format
|
|
76
|
+
# ExpiredSignatureError: token and refresh period have expired
|
|
77
|
+
# InvalidSignatureError: signature does not match the one provided as part of the token
|
|
78
|
+
# ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
|
|
79
|
+
# InvalidAlgorithmError: the specified algorithm is not recognized
|
|
80
|
+
# InvalidIssuedAtError: 'iat' claim is non-numeric
|
|
81
|
+
# MissingRequiredClaimError: a required claim is not contained in the claimset
|
|
82
|
+
payload: dict[str, Any] = jwt.decode(jwt=token,
|
|
83
|
+
key=public_key,
|
|
84
|
+
algorithms=[token_alg],
|
|
85
|
+
options=options,
|
|
86
|
+
issuer=issuer)
|
|
87
|
+
result = {
|
|
88
|
+
"header": token_header,
|
|
89
|
+
"payload": payload
|
|
90
|
+
}
|
|
91
|
+
except Exception as e:
|
|
92
|
+
exc_err: str = exc_format(exc=e,
|
|
93
|
+
exc_info=sys.exc_info())
|
|
94
|
+
if logger:
|
|
95
|
+
logger.error(msg=f"Error decoding the token: {exc_err}")
|
|
96
|
+
errors.append(exc_err)
|
|
97
|
+
|
|
98
|
+
if not errors and logger:
|
|
99
|
+
logger.debug(msg="Token is valid")
|
|
100
|
+
|
|
101
|
+
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|