pypomes-iam 0.0.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.
- pypomes_iam-0.0.1/.gitignore +16 -0
- pypomes_iam-0.0.1/LICENSE +21 -0
- pypomes_iam-0.0.1/PKG-INFO +17 -0
- pypomes_iam-0.0.1/README.md +0 -0
- pypomes_iam-0.0.1/pyproject.toml +31 -0
- pypomes_iam-0.0.1/src/pypomes_iam/__init__.py +17 -0
- pypomes_iam-0.0.1/src/pypomes_iam/iam_jusbr.py +385 -0
- pypomes_iam-0.0.1/src/pypomes_iam/iam_provider.py +139 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 GT Nunes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pypomes_iam
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A collection of Python pomes, penyeach (IAM modules)
|
|
5
|
+
Project-URL: Homepage, https://github.com/TheWiseCoder/PyPomes-IAM
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/TheWiseCoder/PyPomes-IAM/issues
|
|
7
|
+
Author-email: GT Nunes <wisecoder01@gmail.com>
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: cachetools>=6.2.1
|
|
14
|
+
Requires-Dist: flask>=3.1.2
|
|
15
|
+
Requires-Dist: pypomes-core>=2.8.0
|
|
16
|
+
Requires-Dist: requests>=2.32.5
|
|
17
|
+
Requires-Dist: uuid>=1.3.0
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"hatchling>=1.27.0"
|
|
4
|
+
]
|
|
5
|
+
build-backend = "hatchling.build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "pypomes_iam"
|
|
9
|
+
version = "0.0.1"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name="GT Nunes", email="wisecoder01@gmail.com" }
|
|
12
|
+
]
|
|
13
|
+
description = "A collection of Python pomes, penyeach (IAM modules)"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
requires-python = ">=3.12"
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent"
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"cachetools>=6.2.1",
|
|
23
|
+
"Flask>=3.1.2",
|
|
24
|
+
"pypomes-core>=2.8.0",
|
|
25
|
+
"requests>=2.32.5",
|
|
26
|
+
"uuid>=1.3.0"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
"Homepage" = "https://github.com/TheWiseCoder/PyPomes-IAM"
|
|
31
|
+
"Bug Tracker" = "https://github.com/TheWiseCoder/PyPomes-IAM/issues"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from iam_jusbr import (
|
|
2
|
+
jusbr_get_token
|
|
3
|
+
)
|
|
4
|
+
from .iam_provider import (
|
|
5
|
+
provider_register, provider_get_token
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
# iam_jusbr
|
|
10
|
+
"jusbr_get_token",
|
|
11
|
+
# jwt_provider
|
|
12
|
+
"provider_register", "provider_get_token"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
__version__ = version("pypomes_iam")
|
|
17
|
+
__version_info__ = tuple(int(i) for i in __version__.split(".") if i.isdigit())
|
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
|
|
15
|
+
JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
|
|
16
|
+
JUSBR_LOGIN_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_LOGIN_TIMEOUT")
|
|
17
|
+
JUSBR_CALLBACK_ENDPOINT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CALLBACK_ENDPOINT",
|
|
18
|
+
def_value="/iam/jusbr:callback")
|
|
19
|
+
JUSBR_TOKEN_ENDPOINT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_TOKEN_ENDPOINT",
|
|
20
|
+
def_value="/iam/jusbr:get-token")
|
|
21
|
+
JUSBR_LOGIN_ENDPOINT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_LOGIN_ENDPOINT",
|
|
22
|
+
def_value="/iam/jusbr:login")
|
|
23
|
+
JUSBR_LOGOUT_ENDPOINT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_LOGOUT_ENDPOINT",
|
|
24
|
+
def_value="/iam/jusbr:logout")
|
|
25
|
+
JUSBR_AUTH_URL: Final[str] = env_get_str(
|
|
26
|
+
key=f"{APP_PREFIX}JUSBR_AUTH_URL",
|
|
27
|
+
def_value="https://sso.stg.cloud.pje.jus.br/auth/realms/pje/protocol/openid-connect/auth"
|
|
28
|
+
)
|
|
29
|
+
JUSBR_TOKEN_URL: Final[str] = env_get_str(
|
|
30
|
+
key=f"{APP_PREFIX}JUSBR_TOKEN_URL",
|
|
31
|
+
def_value="https://sso.stg.cloud.pje.jus.br/auth/realms/pje/protocol/openid-connect/token"
|
|
32
|
+
)
|
|
33
|
+
JUSBR_CALLBACK_URL: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CALLBACK_URL")
|
|
34
|
+
|
|
35
|
+
# safe memory cache - structure:
|
|
36
|
+
# {
|
|
37
|
+
# "client-id": <str>,
|
|
38
|
+
# "client-secret": <str>,
|
|
39
|
+
# "auth-url": <str>,
|
|
40
|
+
# "token-url": <str>,
|
|
41
|
+
# "login-timeout": <int>,
|
|
42
|
+
# "users": [
|
|
43
|
+
# "<user-id>": {
|
|
44
|
+
# "cache-obj": <Cache>,
|
|
45
|
+
# "oauth-scope": <str>,
|
|
46
|
+
# "access-expiration": <timestamp>,
|
|
47
|
+
# data in <TTLCache>:
|
|
48
|
+
# "oauth-state": <str>
|
|
49
|
+
# "access-token": <str>
|
|
50
|
+
# "refresh-token": <str>
|
|
51
|
+
# }
|
|
52
|
+
# ]
|
|
53
|
+
# }
|
|
54
|
+
_jusbr_registry: dict[str, Any] = {
|
|
55
|
+
"client-id": None,
|
|
56
|
+
"client-secret": None,
|
|
57
|
+
"login-timeout": None,
|
|
58
|
+
"auth-url": None,
|
|
59
|
+
"token-url": None,
|
|
60
|
+
"users": []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# dafault logger
|
|
64
|
+
_logger: Logger | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def jusbr_config(flask_app: Flask,
|
|
68
|
+
client_id: str = JUSBR_CLIENT_ID,
|
|
69
|
+
client_secret: str = JUSBR_CLIENT_SECRET,
|
|
70
|
+
login_timeout: int = JUSBR_LOGIN_TIMEOUT,
|
|
71
|
+
callback_endpoint: str = JUSBR_CALLBACK_ENDPOINT,
|
|
72
|
+
token_endpoint: str = JUSBR_TOKEN_ENDPOINT,
|
|
73
|
+
login_endpoint: str = JUSBR_LOGIN_ENDPOINT,
|
|
74
|
+
logout_endpoint: str = JUSBR_LOGOUT_ENDPOINT,
|
|
75
|
+
auth_url: str = JUSBR_AUTH_URL,
|
|
76
|
+
token_url: str = JUSBR_TOKEN_URL,
|
|
77
|
+
logger: Logger = None) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Configure the JusBR IAM.
|
|
80
|
+
|
|
81
|
+
This should be invoked only once, before the first access to a JusBR service.
|
|
82
|
+
|
|
83
|
+
:param flask_app: the Flask application
|
|
84
|
+
:param client_id: the client's identification with JusBR
|
|
85
|
+
:param client_secret: the client's password with JusBR
|
|
86
|
+
:param login_timeout: timeout for login authentication (in seconds,defaults to no timeout)
|
|
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: URL to access the JusBR login page
|
|
92
|
+
:param token_url: URL for obtaing or refreshing the token
|
|
93
|
+
:param logger: optional logger
|
|
94
|
+
"""
|
|
95
|
+
# establish the logger
|
|
96
|
+
global _logger
|
|
97
|
+
_logger = logger
|
|
98
|
+
|
|
99
|
+
# configure the JusBR registry
|
|
100
|
+
global _jusbr_registry # noqa: PLW0602
|
|
101
|
+
_jusbr_registry.update({
|
|
102
|
+
"client-id": client_id,
|
|
103
|
+
"client-secret": client_secret,
|
|
104
|
+
"login-timeout": login_timeout,
|
|
105
|
+
"auth-url": auth_url,
|
|
106
|
+
"token-url": token_url
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
# establish the endpoints
|
|
110
|
+
if token_endpoint:
|
|
111
|
+
flask_app.add_url_rule(rule=token_endpoint,
|
|
112
|
+
endpoint="jusbr-token",
|
|
113
|
+
view_func=jusbr_token,
|
|
114
|
+
methods=["GET"])
|
|
115
|
+
if login_endpoint:
|
|
116
|
+
flask_app.add_url_rule(rule=login_endpoint,
|
|
117
|
+
endpoint="jusbr-login",
|
|
118
|
+
view_func=jusbr_login,
|
|
119
|
+
methods=["GET"])
|
|
120
|
+
if logout_endpoint:
|
|
121
|
+
flask_app.add_url_rule(rule=logout_endpoint,
|
|
122
|
+
endpoint="jusbr-logout",
|
|
123
|
+
view_func=jusbr_logout,
|
|
124
|
+
methods=["GET"])
|
|
125
|
+
if callback_endpoint:
|
|
126
|
+
flask_app.add_url_rule(rule=callback_endpoint,
|
|
127
|
+
endpoint="jusbr-callback",
|
|
128
|
+
view_func=jusbr_callback,
|
|
129
|
+
methods=["POST"])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:login
|
|
133
|
+
# methods=["GET"])
|
|
134
|
+
def jusbr_login() -> Response:
|
|
135
|
+
"""
|
|
136
|
+
Entry point for the JusBR login service.
|
|
137
|
+
|
|
138
|
+
Redirect the request to the JusBR authentication page, with the apprpriate parameters.
|
|
139
|
+
|
|
140
|
+
:return: the response from the redirect operation
|
|
141
|
+
"""
|
|
142
|
+
# retrieve user id
|
|
143
|
+
input_params: dict[str, Any] = request.values
|
|
144
|
+
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
145
|
+
|
|
146
|
+
# retrieve user data
|
|
147
|
+
global _jusbr_registry
|
|
148
|
+
user_data: dict[str, Any] = _jusbr_registry["users"].get(user_id)
|
|
149
|
+
if not user_data:
|
|
150
|
+
user_data = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
|
|
151
|
+
_jusbr_registry["users"][user_id] = user_data
|
|
152
|
+
|
|
153
|
+
# build redirect url
|
|
154
|
+
oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
|
|
155
|
+
login_timeout: int = _jusbr_registry.get("login-timeout")
|
|
156
|
+
safe_cache: Cache
|
|
157
|
+
if isinstance(login_timeout, int) and login_timeout > 0:
|
|
158
|
+
safe_cache = TTLCache(maxsize=16,
|
|
159
|
+
ttl=600)
|
|
160
|
+
else:
|
|
161
|
+
safe_cache = FIFOCache(maxsize=16)
|
|
162
|
+
safe_cache["oauth-state"] = oauth_state
|
|
163
|
+
user_data["cache-obj"] = safe_cache
|
|
164
|
+
auth_url: str = (f"{_jusbr_registry["auth-url"]}?response_type=code"
|
|
165
|
+
f"&client_id={_jusbr_registry["client-id"]}"
|
|
166
|
+
f"&redirect_url={_jusbr_registry["redirect-url"]}"
|
|
167
|
+
f"&state={oauth_state}")
|
|
168
|
+
if user_data.get("oauth-scope"):
|
|
169
|
+
auth_url += f"&scope={user_data.get("oauth-scope")}"
|
|
170
|
+
|
|
171
|
+
# redirect request
|
|
172
|
+
return redirect(location=auth_url)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:logout
|
|
176
|
+
# methods=["GET"])
|
|
177
|
+
def jusbr_logout() -> Response:
|
|
178
|
+
"""
|
|
179
|
+
Entry point for the JusBR logout service.
|
|
180
|
+
|
|
181
|
+
Delete all data associating the user with JusBR.
|
|
182
|
+
|
|
183
|
+
:return: the response from the redirect operation
|
|
184
|
+
"""
|
|
185
|
+
# retrieve user id
|
|
186
|
+
input_params: dict[str, Any] = request.values
|
|
187
|
+
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
188
|
+
|
|
189
|
+
# retrieve user data
|
|
190
|
+
global _jusbr_registry
|
|
191
|
+
if user_id in _jusbr_registry.get("users"):
|
|
192
|
+
_jusbr_registry.pop(user_id)
|
|
193
|
+
logger: Logger = _jusbr_registry.get("logger")
|
|
194
|
+
if logger:
|
|
195
|
+
logger.debug(f"User '{user_id}' removed from the registry")
|
|
196
|
+
|
|
197
|
+
return Response(status=200)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# @flask_app.route(rule=<callback_endpoint>, # JUSBR_CALLBACK_ENDPOINT: /iam/jusbr:callback
|
|
201
|
+
# methods=["POST"])
|
|
202
|
+
def jusbr_callback() -> Response:
|
|
203
|
+
"""
|
|
204
|
+
Entry point for the callback from JusBR on authentication.
|
|
205
|
+
|
|
206
|
+
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
207
|
+
"""
|
|
208
|
+
global _jusbr_registry
|
|
209
|
+
|
|
210
|
+
# validate the OAuth2 state
|
|
211
|
+
oauth_state: str = request.args.get("state")
|
|
212
|
+
user_data: dict[str, Any] | None = None
|
|
213
|
+
if oauth_state:
|
|
214
|
+
for user in _jusbr_registry.get("users"):
|
|
215
|
+
safe_cache: Cache = user_data.get("cache-obj")
|
|
216
|
+
if safe_cache and oauth_state == safe_cache.get("oauth-state"):
|
|
217
|
+
user_data = user
|
|
218
|
+
# 'oauth-state' is to be used only once
|
|
219
|
+
safe_cache["oauth-state"] = None
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
# exchange 'code' for the token
|
|
223
|
+
errors: list[str] = []
|
|
224
|
+
if user_data:
|
|
225
|
+
code: str = request.args.get("code")
|
|
226
|
+
body_data: dict[str, Any] = {
|
|
227
|
+
"grant_type": "authorization_code",
|
|
228
|
+
"code": code,
|
|
229
|
+
"redirec_url": _jusbr_registry.get("redirect-url"),
|
|
230
|
+
}
|
|
231
|
+
__post_jusbr(user_data=user_data,
|
|
232
|
+
body_data=body_data,
|
|
233
|
+
errors=errors,
|
|
234
|
+
logger=_jusbr_registry.get("logger"))
|
|
235
|
+
else:
|
|
236
|
+
# login operation timed-out
|
|
237
|
+
errors.append("Operation timeout")
|
|
238
|
+
|
|
239
|
+
result: Response
|
|
240
|
+
if errors:
|
|
241
|
+
result = jsonify({"errors": "; ".join(errors)})
|
|
242
|
+
result.status_code = 400
|
|
243
|
+
else:
|
|
244
|
+
result = Response(status=200)
|
|
245
|
+
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
|
|
250
|
+
# methods=["GET"])
|
|
251
|
+
def jusbr_token() -> Response:
|
|
252
|
+
"""
|
|
253
|
+
Entry point for retrieving the JusBR token.
|
|
254
|
+
|
|
255
|
+
:return: the response containing the token, or *NOT AUTHORIZED*
|
|
256
|
+
"""
|
|
257
|
+
# retrieve user id
|
|
258
|
+
input_params: dict[str, Any] = request.values
|
|
259
|
+
user_id: str = input_params.get("user-id") or input_params.get("login")
|
|
260
|
+
|
|
261
|
+
# retrieve the token
|
|
262
|
+
token: str = jusbr_get_token(user_id=user_id,
|
|
263
|
+
logger=_jusbr_registry.get("logger"))
|
|
264
|
+
result: Response
|
|
265
|
+
if token:
|
|
266
|
+
result = jsonify({"token": token})
|
|
267
|
+
else:
|
|
268
|
+
result = Response(status=401)
|
|
269
|
+
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def jusbr_get_token(user_id: str,
|
|
274
|
+
errors: list[str] = None,
|
|
275
|
+
logger: Logger = None) -> str:
|
|
276
|
+
"""
|
|
277
|
+
Retrieve the authentication token for user *user_id*.
|
|
278
|
+
|
|
279
|
+
:param user_id: the user's identification
|
|
280
|
+
:param errors: incidental error messages
|
|
281
|
+
:param logger: optional logger
|
|
282
|
+
"""
|
|
283
|
+
global _jusbr_registry
|
|
284
|
+
|
|
285
|
+
# initialize the return variable
|
|
286
|
+
result: str | None = None
|
|
287
|
+
|
|
288
|
+
user_data: dict[str, Any] = _jusbr_registry["users"].get(user_id)
|
|
289
|
+
safe_cache: Cache = user_data["cache-obj"] if user_data else None
|
|
290
|
+
if user_data and safe_cache:
|
|
291
|
+
access_expiration: int = user_data.get("access-expiration")
|
|
292
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
293
|
+
if now < access_expiration:
|
|
294
|
+
result = safe_cache.get("access-token")
|
|
295
|
+
else:
|
|
296
|
+
# access token has expired
|
|
297
|
+
safe_cache["access-token"] = None
|
|
298
|
+
refresh_token: str = safe_cache.get("refresh-token")
|
|
299
|
+
if refresh_token:
|
|
300
|
+
body_data: dict[str, str] = {
|
|
301
|
+
"grant_type": "refresh_token",
|
|
302
|
+
"refresh_token": refresh_token
|
|
303
|
+
}
|
|
304
|
+
__post_jusbr(user_data=user_data,
|
|
305
|
+
body_data=body_data,
|
|
306
|
+
errors=errors,
|
|
307
|
+
logger=logger)
|
|
308
|
+
if not errors:
|
|
309
|
+
result = safe_cache.get("access_token")
|
|
310
|
+
|
|
311
|
+
elif logger or isinstance(errors, list):
|
|
312
|
+
err_msg: str = f"User '{user_id}' not authenticated with JusBR"
|
|
313
|
+
if isinstance(errors, list):
|
|
314
|
+
errors.append(err_msg)
|
|
315
|
+
if logger:
|
|
316
|
+
logger.error(msg=err_msg)
|
|
317
|
+
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def __post_jusbr(user_data: dict[str, Any],
|
|
322
|
+
body_data: dict[str, Any],
|
|
323
|
+
errors: list[str] | None,
|
|
324
|
+
logger: Logger | None) -> None:
|
|
325
|
+
"""
|
|
326
|
+
Send a POST request to JusBR to obtain the authorization token.
|
|
327
|
+
|
|
328
|
+
If successful, the token data is stored in the registry, and the token itself is returned.
|
|
329
|
+
Otherwise, *errors* will contain the appropriate error message.
|
|
330
|
+
|
|
331
|
+
:param user_data: the user data in the registry
|
|
332
|
+
:param body_data: the data to send in the body of the request
|
|
333
|
+
:param errors: incidental errors
|
|
334
|
+
:param logger: optional logger
|
|
335
|
+
"""
|
|
336
|
+
global _jusbr_registry
|
|
337
|
+
|
|
338
|
+
# complete the data to send in body of request
|
|
339
|
+
body_data["client_id"] = _jusbr_registry.get("client-id")
|
|
340
|
+
client_secret: str = _jusbr_registry.get("client-secret")
|
|
341
|
+
if client_secret:
|
|
342
|
+
body_data["client_secret"] = client_secret
|
|
343
|
+
|
|
344
|
+
err_msg: str | None = None
|
|
345
|
+
safe_cache: Cache = user_data.get("cache-obj")
|
|
346
|
+
url: str = _jusbr_registry.get("auth-url")
|
|
347
|
+
now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
|
|
348
|
+
try:
|
|
349
|
+
# JusBR return on a token request:
|
|
350
|
+
# {
|
|
351
|
+
# "token_type": "Bearer",
|
|
352
|
+
# "access_token": <str>,
|
|
353
|
+
# "expires_in": <number-of-seconds>,
|
|
354
|
+
# "refresh_token": <str>,
|
|
355
|
+
# }
|
|
356
|
+
response: requests.Response = requests.post(url=url,
|
|
357
|
+
data=body_data)
|
|
358
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
359
|
+
# request resulted in error, report the problem
|
|
360
|
+
err_msg = (f"POST '{url}': failed, "
|
|
361
|
+
f"status {response.status_code}, reason '{response.reason}'")
|
|
362
|
+
if response.status_code == 401 and "refresh_token" in body_data:
|
|
363
|
+
# refresh token is no longer valid
|
|
364
|
+
safe_cache["refresh-token"] = None
|
|
365
|
+
else:
|
|
366
|
+
reply: dict[str, Any] = response.json()
|
|
367
|
+
result = reply.get("access_token")
|
|
368
|
+
safe_cache: Cache = FIFOCache(maxsize=1024)
|
|
369
|
+
safe_cache["access-token"] = result
|
|
370
|
+
safe_cache["refresh-token"] = reply.get("refresh_token")
|
|
371
|
+
user_data["cache-obj"] = safe_cache
|
|
372
|
+
user_data["access-expiration"] = now + reply.get("expires_in")
|
|
373
|
+
if logger:
|
|
374
|
+
logger.debug(msg=f"POST '{url}': status {response.status_code}")
|
|
375
|
+
except Exception as e:
|
|
376
|
+
# the operation raised an exception
|
|
377
|
+
err_msg = exc_format(exc=e,
|
|
378
|
+
exc_info=sys.exc_info())
|
|
379
|
+
err_msg = f"POST '{url}': error, '{err_msg}'"
|
|
380
|
+
|
|
381
|
+
if err_msg:
|
|
382
|
+
if isinstance(errors, list):
|
|
383
|
+
errors.append(err_msg)
|
|
384
|
+
if logger:
|
|
385
|
+
logger.error(msg=err_msg)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import sys
|
|
3
|
+
from base64 import b64encode
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from logging import Logger
|
|
6
|
+
from pypomes_core import TZ_LOCAL, exc_format
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# structure:
|
|
10
|
+
# {
|
|
11
|
+
# <provider-id>: {
|
|
12
|
+
# "url": <strl>,
|
|
13
|
+
# "user": <str>,
|
|
14
|
+
# "pwd": <str>,
|
|
15
|
+
# "basic-auth": <bool>,
|
|
16
|
+
# "headers-data": <dict[str, str]>,
|
|
17
|
+
# "body-data": <dict[str, str],
|
|
18
|
+
# "token": <str>,
|
|
19
|
+
# "expiration": <timestamp>
|
|
20
|
+
# }
|
|
21
|
+
# }
|
|
22
|
+
_provider_registry: dict[str, dict[str, Any]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def provider_register(provider_id: str,
|
|
26
|
+
auth_url: str,
|
|
27
|
+
auth_user: str,
|
|
28
|
+
auth_pwd: str,
|
|
29
|
+
custom_auth: tuple[str, str] = None,
|
|
30
|
+
headers_data: dict[str, str] = None,
|
|
31
|
+
body_data: dict[str, str] = None) -> None:
|
|
32
|
+
"""
|
|
33
|
+
Register an external authentication token provider.
|
|
34
|
+
|
|
35
|
+
If specified, *custom_auth* provides key names for sending credentials (username and password, in this order)
|
|
36
|
+
as key-value pairs in the body of the request. Otherwise, the external provider *provider_id* uses the standard
|
|
37
|
+
HTTP Basic Authorization scheme, wherein the credentials are B64-encoded and sent in the request headers.
|
|
38
|
+
|
|
39
|
+
Optional constant key-value pairs (such as ['Content-Type', 'application/x-www-form-urlencoded']), to be
|
|
40
|
+
added to the request headers, may be specified in *headers_data*. Likewise, optional constant key-value pairs
|
|
41
|
+
(such as ['grant_type', 'client_credentials']), to be added to the request body, may be specified in *body_data*.
|
|
42
|
+
|
|
43
|
+
:param provider_id: the provider's identification
|
|
44
|
+
:param auth_url: the url to request authentication tokens with
|
|
45
|
+
:param auth_user: the basic authorization user
|
|
46
|
+
:param auth_pwd: the basic authorization password
|
|
47
|
+
:param custom_auth: optional key names for sending the credentials as key-value pairs in the body of the request
|
|
48
|
+
:param headers_data: optional key-value pairs to be added to the request headers
|
|
49
|
+
:param body_data: optional key-value pairs to be added to the request body
|
|
50
|
+
"""
|
|
51
|
+
global _provider_registry # noqa: PLW0602
|
|
52
|
+
|
|
53
|
+
_provider_registry[provider_id] = {
|
|
54
|
+
"url": auth_url,
|
|
55
|
+
"user": auth_user,
|
|
56
|
+
"pwd": auth_pwd,
|
|
57
|
+
"custom-auth": custom_auth,
|
|
58
|
+
"headers-data": headers_data,
|
|
59
|
+
"body-data": body_data,
|
|
60
|
+
"token": None,
|
|
61
|
+
"expiration": datetime.now(tz=TZ_LOCAL).timestamp()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def provider_get_token(provider_id: str,
|
|
66
|
+
errors: list[str] = None,
|
|
67
|
+
logger: Logger = None) -> str | None:
|
|
68
|
+
"""
|
|
69
|
+
Obtain an authentication token from the external provider *provider_id*.
|
|
70
|
+
|
|
71
|
+
:param provider_id: the provider's identification
|
|
72
|
+
:param errors: incidental error messages
|
|
73
|
+
:param logger: optional logger
|
|
74
|
+
"""
|
|
75
|
+
global _provider_registry # noqa: PLW0602
|
|
76
|
+
|
|
77
|
+
# initialize the return variable
|
|
78
|
+
result: str | None = None
|
|
79
|
+
|
|
80
|
+
err_msg: str | None = None
|
|
81
|
+
provider: dict[str, Any] = _provider_registry.get(provider_id)
|
|
82
|
+
if provider:
|
|
83
|
+
now: float = datetime.now(tz=TZ_LOCAL).timestamp()
|
|
84
|
+
if now > provider.get("expiration"):
|
|
85
|
+
user: str = provider.get("user")
|
|
86
|
+
pwd: str = provider.get("pwd")
|
|
87
|
+
headers_data: dict[str, str] = provider.get("headers-data") or {}
|
|
88
|
+
body_data: dict[str, str] = provider.get("body-data") or {}
|
|
89
|
+
custom_auth: tuple[str, str] = provider.get("custom-auth")
|
|
90
|
+
if custom_auth:
|
|
91
|
+
body_data[custom_auth[0]] = user
|
|
92
|
+
body_data[custom_auth[1]] = pwd
|
|
93
|
+
else:
|
|
94
|
+
enc_bytes: bytes = b64encode(f"{user}:{pwd}".encode())
|
|
95
|
+
headers_data["Authorization"] = f"Basic {enc_bytes.decode()}"
|
|
96
|
+
url: str = provider.get("url")
|
|
97
|
+
try:
|
|
98
|
+
# typical return on a token request:
|
|
99
|
+
# {
|
|
100
|
+
# "token_type": "Bearer",
|
|
101
|
+
# "access_token": <str>,
|
|
102
|
+
# "expires_in": <number-of-seconds>,
|
|
103
|
+
# optional data:
|
|
104
|
+
# "refresh_token": <str>,
|
|
105
|
+
# "refresh_expires_in": <number-of-seconds>
|
|
106
|
+
# }
|
|
107
|
+
response: requests.Response = requests.post(url=url,
|
|
108
|
+
data=body_data,
|
|
109
|
+
headers=headers_data,
|
|
110
|
+
timeout=None)
|
|
111
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
112
|
+
# request resulted in error, report the problem
|
|
113
|
+
err_msg = (f"POST '{url}': failed, "
|
|
114
|
+
f"status {response.status_code}, reason '{response.reason}'")
|
|
115
|
+
else:
|
|
116
|
+
reply: dict[str, Any] = response.json()
|
|
117
|
+
provider["token"] = reply.get("access_token")
|
|
118
|
+
provider["expiration"] = now + int(reply.get("expires_in"))
|
|
119
|
+
if logger:
|
|
120
|
+
logger.debug(msg=f"POST '{url}': status {response.status_code}")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
# the operation raised an exception
|
|
123
|
+
err_msg = exc_format(exc=e,
|
|
124
|
+
exc_info=sys.exc_info())
|
|
125
|
+
err_msg = f"POST '{url}': error, '{err_msg}'"
|
|
126
|
+
else:
|
|
127
|
+
err_msg: str = f"Provider '{provider_id}' not registered"
|
|
128
|
+
|
|
129
|
+
if err_msg:
|
|
130
|
+
if isinstance(errors, list):
|
|
131
|
+
errors.append(err_msg)
|
|
132
|
+
if logger:
|
|
133
|
+
logger.error(msg=err_msg)
|
|
134
|
+
else:
|
|
135
|
+
result = provider.get("token")
|
|
136
|
+
|
|
137
|
+
return result
|
|
138
|
+
|
|
139
|
+
|