pypomes-iam 0.1.1__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.1 → pypomes_iam-0.1.2}/PKG-INFO +3 -1
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.2}/pyproject.toml +3 -1
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.2}/src/pypomes_iam/__init__.py +6 -1
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.2}/src/pypomes_iam/jusbr_pomes.py +104 -61
- 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.1 → pypomes_iam-0.1.2}/.gitignore +0 -0
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.2}/LICENSE +0 -0
- {pypomes_iam-0.1.1 → pypomes_iam-0.1.2}/README.md +0 -0
- {pypomes_iam-0.1.1 → 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,48 +131,38 @@ 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
|
-
#
|
|
145
|
-
result: Response
|
|
146
|
-
|
|
147
|
-
# retrieve user id
|
|
140
|
+
# retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
|
|
148
141
|
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)
|
|
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,
|
|
146
|
+
logger=_logger)
|
|
147
|
+
# build redirect url
|
|
148
|
+
timeout: int = __get_login_timeout()
|
|
149
|
+
safe_cache: Cache
|
|
150
|
+
if timeout:
|
|
151
|
+
safe_cache = TTLCache(maxsize=16,
|
|
152
|
+
ttl=600)
|
|
175
153
|
else:
|
|
176
|
-
|
|
177
|
-
|
|
154
|
+
safe_cache = FIFOCache(maxsize=16)
|
|
155
|
+
safe_cache["oauth-state"] = oauth_state
|
|
156
|
+
user_data["cache-obj"] = safe_cache
|
|
157
|
+
auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
|
|
158
|
+
f"&client_id={_jusbr_registry["client-id"]}"
|
|
159
|
+
f"&redirect_url={_jusbr_registry["callback-url"]}"
|
|
160
|
+
f"&state={oauth_state}")
|
|
161
|
+
if user_data.get("oauth-scope"):
|
|
162
|
+
auth_url += f"&scope={user_data.get("oauth-scope")}"
|
|
178
163
|
|
|
179
|
-
|
|
164
|
+
# redirect request
|
|
165
|
+
return redirect(location=auth_url)
|
|
180
166
|
|
|
181
167
|
|
|
182
168
|
# @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:logout
|
|
@@ -213,14 +199,18 @@ def service_callback() -> Response:
|
|
|
213
199
|
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
214
200
|
"""
|
|
215
201
|
global _jusbr_registry
|
|
202
|
+
from .token_pomes import token_validate
|
|
216
203
|
|
|
217
204
|
# validate the OAuth2 state
|
|
218
205
|
oauth_state: str = request.args.get("state")
|
|
206
|
+
user_id: str | None = None
|
|
219
207
|
user_data: dict[str, Any] | None = None
|
|
220
208
|
if oauth_state:
|
|
221
|
-
for data in _jusbr_registry.get("users"):
|
|
222
|
-
safe_cache: Cache =
|
|
223
|
-
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
|
|
224
214
|
user_data = data
|
|
225
215
|
# 'oauth-state' is to be used only once
|
|
226
216
|
safe_cache["oauth-state"] = None
|
|
@@ -240,6 +230,20 @@ def service_callback() -> Response:
|
|
|
240
230
|
body_data=body_data,
|
|
241
231
|
errors=errors,
|
|
242
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}'")
|
|
243
247
|
else:
|
|
244
248
|
msg: str = "Unknown OAuth2 code received"
|
|
245
249
|
if __get_login_timeout():
|
|
@@ -251,7 +255,9 @@ def service_callback() -> Response:
|
|
|
251
255
|
result = jsonify({"errors": "; ".join(errors)})
|
|
252
256
|
result.status_code = 400
|
|
253
257
|
else:
|
|
254
|
-
result = jsonify({
|
|
258
|
+
result = jsonify({
|
|
259
|
+
"user_id": user_id,
|
|
260
|
+
"access_token": token})
|
|
255
261
|
|
|
256
262
|
return result
|
|
257
263
|
|
|
@@ -351,6 +357,42 @@ def jusbr_set_scope(user_id: str,
|
|
|
351
357
|
logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
|
|
352
358
|
|
|
353
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
|
+
|
|
354
396
|
def __get_login_timeout() -> int | None:
|
|
355
397
|
"""
|
|
356
398
|
Retrieve the timeout currently applicable for the login operation.
|
|
@@ -421,9 +463,10 @@ def __post_jusbr(user_data: dict[str, Any],
|
|
|
421
463
|
if client_secret:
|
|
422
464
|
body_data["client_secret"] = client_secret
|
|
423
465
|
|
|
466
|
+
# obtain the token
|
|
424
467
|
err_msg: str | None = None
|
|
425
468
|
safe_cache: Cache = user_data.get("cache-obj")
|
|
426
|
-
url: str = _jusbr_registry.get("auth-url")
|
|
469
|
+
url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
|
|
427
470
|
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
428
471
|
try:
|
|
429
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
|