pypomes-iam 0.1.0__tar.gz → 0.1.2__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.
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/PKG-INFO +3 -1
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/pyproject.toml +3 -1
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/src/pypomes_iam/__init__.py +6 -1
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/src/pypomes_iam/jusbr_pomes.py +93 -39
- pypomes_iam-0.1.2/src/pypomes_iam/keycloak_pomes.py +212 -0
- pypomes_iam-0.1.2/src/pypomes_iam/token_pomes.py +101 -0
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/.gitignore +0 -0
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/LICENSE +0 -0
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/README.md +0 -0
- {pypomes_iam-0.1.0 → pypomes_iam-0.1.2}/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.2
|
|
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.2"
|
|
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
|
|
@@ -97,14 +92,15 @@ def jusbr_setup(flask_app: Flask,
|
|
|
97
92
|
|
|
98
93
|
# configure the JusBR registry
|
|
99
94
|
global _jusbr_registry # noqa: PLW0602
|
|
100
|
-
_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
|
+
}
|
|
108
104
|
|
|
109
105
|
# establish the endpoints
|
|
110
106
|
if token_endpoint:
|
|
@@ -135,21 +131,20 @@ def service_login() -> Response:
|
|
|
135
131
|
"""
|
|
136
132
|
Entry point for the JusBR login service.
|
|
137
133
|
|
|
138
|
-
Redirect the request to the JusBR authentication page, with the
|
|
134
|
+
Redirect the request to the JusBR authentication page, with the appropriate parameters.
|
|
139
135
|
|
|
140
136
|
:return: the response from the redirect operation
|
|
141
137
|
"""
|
|
142
138
|
global _jusbr_registry
|
|
143
139
|
|
|
144
|
-
# retrieve user
|
|
140
|
+
# retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
|
|
145
141
|
input_params: dict[str, Any] = request.values
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
#
|
|
149
|
-
user_data: dict[str, Any] = __get_user_data(user_id=user_id,
|
|
142
|
+
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
143
|
+
user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
|
|
144
|
+
# obtain user data
|
|
145
|
+
user_data: dict[str, Any] = __get_user_data(user_id=user_id or oauth_state,
|
|
150
146
|
logger=_logger)
|
|
151
147
|
# build redirect url
|
|
152
|
-
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
153
148
|
timeout: int = __get_login_timeout()
|
|
154
149
|
safe_cache: Cache
|
|
155
150
|
if timeout:
|
|
@@ -159,7 +154,7 @@ def service_login() -> Response:
|
|
|
159
154
|
safe_cache = FIFOCache(maxsize=16)
|
|
160
155
|
safe_cache["oauth-state"] = oauth_state
|
|
161
156
|
user_data["cache-obj"] = safe_cache
|
|
162
|
-
auth_url: str = (f"{_jusbr_registry["auth-url"]}?response_type=code"
|
|
157
|
+
auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
|
|
163
158
|
f"&client_id={_jusbr_registry["client-id"]}"
|
|
164
159
|
f"&redirect_url={_jusbr_registry["callback-url"]}"
|
|
165
160
|
f"&state={oauth_state}")
|
|
@@ -178,7 +173,7 @@ def service_logout() -> Response:
|
|
|
178
173
|
|
|
179
174
|
Remove all data associating the user with JusBR from the registry.
|
|
180
175
|
|
|
181
|
-
:return:
|
|
176
|
+
:return: response *OK*
|
|
182
177
|
"""
|
|
183
178
|
global _jusbr_registry
|
|
184
179
|
|
|
@@ -187,7 +182,7 @@ def service_logout() -> Response:
|
|
|
187
182
|
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
188
183
|
|
|
189
184
|
# remove user data
|
|
190
|
-
if user_id in _jusbr_registry.get("users"):
|
|
185
|
+
if user_id and user_id in _jusbr_registry.get("users"):
|
|
191
186
|
_jusbr_registry["users"].pop(user_id)
|
|
192
187
|
if _logger:
|
|
193
188
|
_logger.debug(f"User '{user_id}' removed from the registry")
|
|
@@ -204,14 +199,18 @@ def service_callback() -> Response:
|
|
|
204
199
|
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
205
200
|
"""
|
|
206
201
|
global _jusbr_registry
|
|
202
|
+
from .token_pomes import token_validate
|
|
207
203
|
|
|
208
204
|
# validate the OAuth2 state
|
|
209
205
|
oauth_state: str = request.args.get("state")
|
|
206
|
+
user_id: str | None = None
|
|
210
207
|
user_data: dict[str, Any] | None = None
|
|
211
208
|
if oauth_state:
|
|
212
|
-
for data in _jusbr_registry.get("users"):
|
|
213
|
-
safe_cache: Cache =
|
|
214
|
-
if
|
|
209
|
+
for user, data in _jusbr_registry.get("users").items():
|
|
210
|
+
safe_cache: Cache = data.get("cache-obj")
|
|
211
|
+
if user == oauth_state or \
|
|
212
|
+
(safe_cache and oauth_state == safe_cache.get("oauth-state")):
|
|
213
|
+
user_id = user
|
|
215
214
|
user_data = data
|
|
216
215
|
# 'oauth-state' is to be used only once
|
|
217
216
|
safe_cache["oauth-state"] = None
|
|
@@ -231,6 +230,20 @@ def service_callback() -> Response:
|
|
|
231
230
|
body_data=body_data,
|
|
232
231
|
errors=errors,
|
|
233
232
|
logger=_logger)
|
|
233
|
+
# retrieve the token's claims
|
|
234
|
+
if not errors:
|
|
235
|
+
token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
|
|
236
|
+
issuer=_jusbr_registry.get("auth-url"),
|
|
237
|
+
public_key=_jusbr_registry.get("public_key"),
|
|
238
|
+
errors=errors,
|
|
239
|
+
logger=_logger)
|
|
240
|
+
if not errors:
|
|
241
|
+
token_user: str = token_claims["payload"].get("preferred_username")
|
|
242
|
+
if user_id == oauth_state:
|
|
243
|
+
user_id = token_user
|
|
244
|
+
_jusbr_registry["users"][user_id] = _jusbr_registry["users"].pop(oauth_state)
|
|
245
|
+
elif token_user != user_id:
|
|
246
|
+
errors.append(f"Token was issued to user '{token_user}'")
|
|
234
247
|
else:
|
|
235
248
|
msg: str = "Unknown OAuth2 code received"
|
|
236
249
|
if __get_login_timeout():
|
|
@@ -242,7 +255,9 @@ def service_callback() -> Response:
|
|
|
242
255
|
result = jsonify({"errors": "; ".join(errors)})
|
|
243
256
|
result.status_code = 400
|
|
244
257
|
else:
|
|
245
|
-
result = jsonify({
|
|
258
|
+
result = jsonify({
|
|
259
|
+
"user_id": user_id,
|
|
260
|
+
"access_token": token})
|
|
246
261
|
|
|
247
262
|
return result
|
|
248
263
|
|
|
@@ -253,20 +268,22 @@ def service_token() -> Response:
|
|
|
253
268
|
"""
|
|
254
269
|
Entry point for retrieving the JusBR token.
|
|
255
270
|
|
|
256
|
-
:return: the response containing the token, or *
|
|
271
|
+
:return: the response containing the token, or *UNAUTHORIZED*
|
|
257
272
|
"""
|
|
258
273
|
# retrieve user id
|
|
259
274
|
input_params: dict[str, Any] = request.args
|
|
260
275
|
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
261
276
|
|
|
262
277
|
# retrieve the token
|
|
278
|
+
errors: list[str] = []
|
|
263
279
|
token: str = jusbr_get_token(user_id=user_id,
|
|
264
280
|
logger=_logger)
|
|
265
281
|
result: Response
|
|
266
282
|
if token:
|
|
267
283
|
result = jsonify({"token": token})
|
|
268
284
|
else:
|
|
269
|
-
result = Response(
|
|
285
|
+
result = Response("; ".join(errors))
|
|
286
|
+
result.status_code = 401
|
|
270
287
|
|
|
271
288
|
return result
|
|
272
289
|
|
|
@@ -340,6 +357,42 @@ def jusbr_set_scope(user_id: str,
|
|
|
340
357
|
logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
|
|
341
358
|
|
|
342
359
|
|
|
360
|
+
def __get_public_key(url: str,
|
|
361
|
+
logger: Logger | None) -> str:
|
|
362
|
+
"""
|
|
363
|
+
Obtain the public key used by JusBR to sign the authentication tokens.
|
|
364
|
+
|
|
365
|
+
:param url: the base URL to request the public key
|
|
366
|
+
:return: the public key, in *PEM* format
|
|
367
|
+
"""
|
|
368
|
+
from pypomes_crypto import crypto_jwk_convert
|
|
369
|
+
global _jusbr_registry
|
|
370
|
+
|
|
371
|
+
# initialize the return variable
|
|
372
|
+
result: str | None = None
|
|
373
|
+
|
|
374
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
375
|
+
if now > _jusbr_registry.get("key-expiration"):
|
|
376
|
+
# obtain a new public key
|
|
377
|
+
url: str = f"{url}/protocol/openid-connect/certs"
|
|
378
|
+
response: requests.Response = requests.get(url=url)
|
|
379
|
+
if response.status_code == 200:
|
|
380
|
+
# request succeeded
|
|
381
|
+
reply: dict[str, Any] = response.json()
|
|
382
|
+
result = crypto_jwk_convert(jwk=reply["keys"][0],
|
|
383
|
+
fmt="PEM")
|
|
384
|
+
_jusbr_registry["public-key"] = result
|
|
385
|
+
duration: int = _jusbr_registry.get("key-lifetime") or 0
|
|
386
|
+
_jusbr_registry["key-expiration"] = now + duration
|
|
387
|
+
elif logger:
|
|
388
|
+
logger.error(msg=f"GET '{url}': failed, "
|
|
389
|
+
f"status {response.status_code}, reason '{response.reason}'")
|
|
390
|
+
else:
|
|
391
|
+
result = _jusbr_registry.get("public-key")
|
|
392
|
+
|
|
393
|
+
return result
|
|
394
|
+
|
|
395
|
+
|
|
343
396
|
def __get_login_timeout() -> int | None:
|
|
344
397
|
"""
|
|
345
398
|
Retrieve the timeout currently applicable for the login operation.
|
|
@@ -410,9 +463,10 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
410
463
|
if client_secret:
|
|
411
464
|
body_data["client_secret"] = client_secret
|
|
412
465
|
|
|
466
|
+
# obtain the token
|
|
413
467
|
err_msg: str | None = None
|
|
414
468
|
safe_cache: Cache = user_data.get("cache-obj")
|
|
415
|
-
url: str = _jusbr_registry.get("auth-url")
|
|
469
|
+
url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
|
|
416
470
|
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
417
471
|
try:
|
|
418
472
|
# JusBR return on a token request:
|
|
@@ -0,0 +1,212 @@
|
|
|
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 # noqa: PLW0602
|
|
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
|
+
})
|
|
110
|
+
|
|
111
|
+
# establish the endpoints
|
|
112
|
+
if token_endpoint:
|
|
113
|
+
flask_app.add_url_rule(rule=token_endpoint,
|
|
114
|
+
endpoint="keycloak-token",
|
|
115
|
+
view_func=service_token,
|
|
116
|
+
methods=["GET"])
|
|
117
|
+
if login_endpoint:
|
|
118
|
+
flask_app.add_url_rule(rule=login_endpoint,
|
|
119
|
+
endpoint="keycloak-login",
|
|
120
|
+
view_func=service_login,
|
|
121
|
+
methods=["GET"])
|
|
122
|
+
if logout_endpoint:
|
|
123
|
+
flask_app.add_url_rule(rule=logout_endpoint,
|
|
124
|
+
endpoint="keycloak-logout",
|
|
125
|
+
view_func=service_logout,
|
|
126
|
+
methods=["GET"])
|
|
127
|
+
if callback_endpoint:
|
|
128
|
+
flask_app.add_url_rule(rule=callback_endpoint,
|
|
129
|
+
endpoint="keycloak-callback",
|
|
130
|
+
view_func=service_callback,
|
|
131
|
+
methods=["POST"])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:login
|
|
135
|
+
# methods=["GET"])
|
|
136
|
+
def service_login() -> Response:
|
|
137
|
+
"""
|
|
138
|
+
Entry point for the Keycloak login service.
|
|
139
|
+
|
|
140
|
+
Redirect the request to the Keycloak authentication page, with the appropriate parameters.
|
|
141
|
+
|
|
142
|
+
:return: the response from the redirect operation
|
|
143
|
+
"""
|
|
144
|
+
global _keycloak_registry
|
|
145
|
+
|
|
146
|
+
# retrieve user id
|
|
147
|
+
input_params: dict[str, Any] = request.args
|
|
148
|
+
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
149
|
+
return Response()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
|
|
153
|
+
# methods=["GET"])
|
|
154
|
+
def service_logout() -> Response:
|
|
155
|
+
"""
|
|
156
|
+
Entry point for the JusBR logout service.
|
|
157
|
+
|
|
158
|
+
Remove all data associating the user with JusBR from the registry.
|
|
159
|
+
|
|
160
|
+
:return: response *OK*
|
|
161
|
+
"""
|
|
162
|
+
global _keycloak_registry
|
|
163
|
+
|
|
164
|
+
# retrieve user id
|
|
165
|
+
input_params: dict[str, Any] = request.args
|
|
166
|
+
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
167
|
+
return Response()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
|
|
171
|
+
# methods=["POST"])
|
|
172
|
+
def service_callback() -> Response:
|
|
173
|
+
"""
|
|
174
|
+
Entry point for the callback from Keycloak on authentication operation.
|
|
175
|
+
|
|
176
|
+
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
177
|
+
"""
|
|
178
|
+
global _keycloak_registry
|
|
179
|
+
return Response()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
|
|
183
|
+
# methods=["GET"])
|
|
184
|
+
def service_token() -> Response:
|
|
185
|
+
"""
|
|
186
|
+
Entry point for retrieving the Keycloak token.
|
|
187
|
+
|
|
188
|
+
:return: the response containing the token, or *UNAUTHORIZED*
|
|
189
|
+
"""
|
|
190
|
+
# retrieve user id
|
|
191
|
+
input_params: dict[str, Any] = request.args
|
|
192
|
+
_user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
193
|
+
return Response()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def keycloak_get_token(user_id: str,
|
|
197
|
+
errors: list[str] = None,
|
|
198
|
+
logger: Logger = None) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Retrieve the authentication token for user *user_id*.
|
|
201
|
+
|
|
202
|
+
:param user_id: the user's identification
|
|
203
|
+
:param errors: incidental error messages
|
|
204
|
+
:param logger: optional logger
|
|
205
|
+
:return: the token for *user_id*, or *None* if error
|
|
206
|
+
"""
|
|
207
|
+
global _keycloak_registry
|
|
208
|
+
|
|
209
|
+
# initialize the return variable
|
|
210
|
+
result: str | None = None
|
|
211
|
+
return result
|
|
212
|
+
|
|
@@ -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
|