pypomes-iam 0.1.6__tar.gz → 0.1.8__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_iam
3
- Version: 0.1.6
3
+ Version: 0.1.8
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
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_iam"
9
- version = "0.1.6"
9
+ version = "0.1.8"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -0,0 +1,111 @@
1
+ import json
2
+ import requests
3
+ from datetime import datetime
4
+ from flask import Request
5
+ from logging import Logger
6
+ from pypomes_core import TZ_LOCAL
7
+ from typing import Any
8
+
9
+
10
+ def _get_public_key(registry: dict[str, Any],
11
+ url: str,
12
+ logger: Logger | None) -> bytes:
13
+ """
14
+ Obtain the public key used by the *IAM* to sign the authentication tokens.
15
+
16
+ The public key is saved in *registry*.
17
+
18
+ :param url: the base URL to request the public key
19
+ :return: the public key, in *DER* format
20
+ """
21
+ from pypomes_crypto import crypto_jwk_convert
22
+
23
+ # initialize the return variable
24
+ result: bytes | None = None
25
+
26
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
27
+ if now > registry.get("key-expiration"):
28
+ # obtain a new public key
29
+ url: str = f"{url}/protocol/openid-connect/certs"
30
+ if logger:
31
+ logger.debug(msg=f"GET '{url}'")
32
+ response: requests.Response = requests.get(url=url)
33
+ if response.status_code == 200:
34
+ # request succeeded
35
+ if logger:
36
+ logger.debug(msg=f"GET success, status {response.status_code}")
37
+ reply: dict[str, Any] = response.json()
38
+ result = crypto_jwk_convert(jwk=reply["keys"][0],
39
+ fmt="DER")
40
+ registry["public-key"] = result
41
+ duration: int = registry.get("key-lifetime") or 0
42
+ registry["key-expiration"] = now + duration
43
+ elif logger:
44
+ msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
45
+ if hasattr(response, "content") and response.content:
46
+ msg += f", content '{response.content}'"
47
+ logger.error(msg=msg)
48
+ else:
49
+ result = registry.get("public-key")
50
+
51
+ return result
52
+
53
+
54
+ def _get_login_timeout(registry: dict[str, Any]) -> int | None:
55
+ """
56
+ Retrieve from *registry* the timeout currently applicable for the login operation.
57
+
58
+ :return: the current login timeout, or *None* if none has been set.
59
+ """
60
+ timeout: int = registry.get("client-timeout")
61
+ return timeout if isinstance(timeout, int) and timeout > 0 else None
62
+
63
+
64
+ def _get_user_data(registry: dict[str, Any],
65
+ user_id: str,
66
+ logger: Logger | None) -> dict[str, Any]:
67
+ """
68
+ Retrieve the data for *user_id* from *registry*.
69
+
70
+ If an entry is not found for *user_id* in the registry, it is created.
71
+ It will remain there until the user is logged out.
72
+
73
+ :param user_id:
74
+ :return: the data for *user_id* in the registry
75
+ """
76
+ result: dict[str, Any] = registry["users"].get(user_id)
77
+ if not result:
78
+ result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
79
+ registry["users"][user_id] = result
80
+ if logger:
81
+ logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
82
+ elif logger:
83
+ logger.debug(msg=f"Entry for user '{user_id}' obtained from the registry")
84
+
85
+ return result
86
+
87
+
88
+ def _user_logout(registry: dict[str, Any],
89
+ user_id: str,
90
+ logger: Logger | None) -> None:
91
+ """
92
+ Remove all data associating *user_id* from *registry*.
93
+ """
94
+ # remove the user data
95
+ if user_id and user_id in registry.get("users"):
96
+ registry["users"].pop(user_id)
97
+ if logger:
98
+ logger.debug(msg=f"User '{user_id}' removed from the registry")
99
+
100
+
101
+ def _log_init(request: Request) -> str:
102
+ """
103
+ Build the messages for logging the request entry.
104
+
105
+ :param request: the Request object
106
+ :return: the log message
107
+ """
108
+
109
+ params: str = json.dumps(obj=request.args,
110
+ ensure_ascii=False)
111
+ return f"Request {request.method}:{request.path}, params {params}"
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import requests
2
3
  import secrets
3
4
  import string
@@ -11,6 +12,10 @@ from pypomes_core import (
11
12
  )
12
13
  from typing import Any, Final
13
14
 
15
+ from .common_pomes import (
16
+ _get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
17
+ )
18
+
14
19
  JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
15
20
  JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
16
21
  JUSBR_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_CLIENT_TIMEOUT")
@@ -34,17 +39,19 @@ JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_A
34
39
  # "client-id": <str>,
35
40
  # "client-secret": <str>,
36
41
  # "client-timeout": <int>,
37
- # "public_key": <str>,
42
+ # "public_key": <bytes>,
43
+ # "key-lifetime": <int>,
38
44
  # "key-expiration": <int>,
39
- # "auth-url": <str>,
45
+ # "base-url": <str>,
40
46
  # "callback-url": <str>,
41
47
  # "users": {
42
48
  # "<user-id>": {
43
49
  # "cache-obj": <Cache>,
44
50
  # "oauth-scope": <str>,
45
51
  # "access-expiration": <timestamp>,
52
+ # "login-expiration": <int>, <-- transient
53
+ # "login-id": <str>, <-- transient
46
54
  # data in <Cache>:
47
- # "oauth-state": <str>
48
55
  # "access-token": <str>
49
56
  # "refresh-token": <str>
50
57
  # }
@@ -65,7 +72,7 @@ def jusbr_setup(flask_app: Flask,
65
72
  token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
66
73
  login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
67
74
  logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
68
- auth_url: str = JUSBR_URL_AUTH_BASE,
75
+ base_url: str = JUSBR_URL_AUTH_BASE,
69
76
  callback_url: str = JUSBR_URL_AUTH_CALLBACK,
70
77
  logger: Logger = None) -> None:
71
78
  """
@@ -82,7 +89,7 @@ def jusbr_setup(flask_app: Flask,
82
89
  :param token_endpoint: endpoint for retrieving the JusBR authentication token
83
90
  :param login_endpoint: endpoint for redirecting user to JusBR login page
84
91
  :param logout_endpoint: endpoint for terminating user access to JusBR
85
- :param auth_url: base URL to request the JusBR services
92
+ :param base_url: base URL to request the JusBR services
86
93
  :param callback_url: URL for JusBR to callback on login
87
94
  :param logger: optional logger
88
95
  """
@@ -96,7 +103,7 @@ def jusbr_setup(flask_app: Flask,
96
103
  "client-id": client_id,
97
104
  "client-secret": client_secret,
98
105
  "client-timeout": client_timeout,
99
- "auth-url": auth_url,
106
+ "base-url": base_url,
100
107
  "callback-url": callback_url,
101
108
  "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
102
109
  "key-lifetime": public_key_lifetime,
@@ -138,32 +145,44 @@ def service_login() -> Response:
138
145
  """
139
146
  global _jusbr_registry
140
147
 
141
- # retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
148
+ # log the request
149
+ if _logger:
150
+ msg: str = _log_init(request=request)
151
+ _logger.debug(msg=msg)
152
+
153
+ # retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state')
142
154
  input_params: dict[str, Any] = request.values
143
155
  oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
144
156
  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()
157
+ # obtain the user data
158
+ user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
159
+ user_id=user_id,
160
+ logger=_logger)
161
+ # build the redirect url
162
+ timeout: int = _get_login_timeout(registry=_jusbr_registry)
150
163
  safe_cache: Cache
151
164
  if timeout:
152
165
  safe_cache = TTLCache(maxsize=16,
153
- ttl=600)
166
+ ttl=timeout)
154
167
  else:
155
168
  safe_cache = FIFOCache(maxsize=16)
156
169
  safe_cache["oauth-state"] = oauth_state
157
170
  user_data["cache-obj"] = safe_cache
158
- auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
171
+ auth_url: str = (f"{_jusbr_registry["base-url"]}/protocol/openid-connect/auth?response_type=code"
159
172
  f"&client_id={_jusbr_registry["client-id"]}"
160
173
  f"&redirect_uri={_jusbr_registry["callback-url"]}"
161
174
  f"&state={oauth_state}")
162
175
  if user_data.get("oauth-scope"):
163
176
  auth_url += f"&scope={user_data.get("oauth-scope")}"
164
177
 
165
- # redirect request
166
- return redirect(location=auth_url)
178
+ # redirect the request
179
+ result: Response = redirect(location=auth_url)
180
+
181
+ # log the response
182
+ if _logger:
183
+ _logger.debug(msg=f"Response {result}")
184
+
185
+ return result
167
186
 
168
187
 
169
188
  # @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:logout
@@ -178,17 +197,27 @@ def service_logout() -> Response:
178
197
  """
179
198
  global _jusbr_registry
180
199
 
181
- # retrieve user id
200
+ # log the request
201
+ if _logger:
202
+ msg: str = _log_init(request=request)
203
+ _logger.debug(msg=msg)
204
+
205
+ # retrieve the user id
182
206
  input_params: dict[str, Any] = request.args
183
207
  user_id: str = input_params.get("user-id") or input_params.get("login")
184
208
 
185
- # remove user data
186
- if user_id and user_id in _jusbr_registry.get("users"):
187
- _jusbr_registry["users"].pop(user_id)
188
- if _logger:
189
- _logger.debug(f"User '{user_id}' removed from the registry")
209
+ # logout the user
210
+ _user_logout(registry=_jusbr_registry,
211
+ user_id=user_id,
212
+ logger=_logger)
190
213
 
191
- return Response(status=200)
214
+ result: Response = Response(status=200)
215
+
216
+ # log the response
217
+ if _logger:
218
+ _logger.debug(msg=f"Response {result}")
219
+
220
+ return result
192
221
 
193
222
 
194
223
  # @flask_app.route(rule=<callback_endpoint>, # JUSBR_CALLBACK_ENDPOINT: /iam/jusbr:callback
@@ -202,6 +231,11 @@ def service_callback() -> Response:
202
231
  global _jusbr_registry
203
232
  from .token_pomes import token_validate
204
233
 
234
+ # log the request
235
+ if _logger:
236
+ msg: str = _log_init(request=request)
237
+ _logger.debug(msg=msg)
238
+
205
239
  # validate the OAuth2 state
206
240
  oauth_state: str = request.args.get("state")
207
241
  user_id: str | None = None
@@ -225,7 +259,7 @@ def service_callback() -> Response:
225
259
  body_data: dict[str, Any] = {
226
260
  "grant_type": "authorization_code",
227
261
  "code": code,
228
- "redirec_url": _jusbr_registry.get("callback-url"),
262
+ "redirect_uri": _jusbr_registry.get("callback-url"),
229
263
  }
230
264
  token = __post_jusbr(user_data=user_data,
231
265
  body_data=body_data,
@@ -233,9 +267,12 @@ def service_callback() -> Response:
233
267
  logger=_logger)
234
268
  # retrieve the token's claims
235
269
  if not errors:
270
+ public_key: bytes = _get_public_key(registry=_jusbr_registry,
271
+ url=_jusbr_registry["base-url"],
272
+ logger=_logger)
236
273
  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"),
274
+ issuer=_jusbr_registry["base-url"],
275
+ public_key=public_key,
239
276
  errors=errors,
240
277
  logger=_logger)
241
278
  if not errors:
@@ -247,7 +284,7 @@ def service_callback() -> Response:
247
284
  errors.append(f"Token was issued to user '{token_user}'")
248
285
  else:
249
286
  msg: str = "Unknown OAuth2 code received"
250
- if __get_login_timeout():
287
+ if _get_login_timeout(registry=_jusbr_registry):
251
288
  msg += " - possible operation timeout"
252
289
  errors.append(msg)
253
290
 
@@ -260,6 +297,10 @@ def service_callback() -> Response:
260
297
  "user_id": user_id,
261
298
  "access_token": token})
262
299
 
300
+ # log the response
301
+ if _logger:
302
+ _logger.debug(msg=f"Response {result}")
303
+
263
304
  return result
264
305
 
265
306
 
@@ -271,11 +312,14 @@ def service_token() -> Response:
271
312
 
272
313
  :return: the response containing the token, or *UNAUTHORIZED*
273
314
  """
274
- # retrieve user id
275
- input_params: dict[str, Any] = request.args
276
- user_id: str = input_params.get("user-id") or input_params.get("login")
315
+ # log the request
316
+ if _logger:
317
+ msg: str = _log_init(request=request)
318
+ _logger.debug(msg=msg)
277
319
 
278
320
  # retrieve the token
321
+ input_params: dict[str, Any] = request.args
322
+ user_id: str = input_params.get("user-id") or input_params.get("login")
279
323
  errors: list[str] = []
280
324
  token: str = jusbr_get_token(user_id=user_id,
281
325
  logger=_logger)
@@ -286,6 +330,10 @@ def service_token() -> Response:
286
330
  result = Response("; ".join(errors))
287
331
  result.status_code = 401
288
332
 
333
+ # log the response
334
+ if _logger:
335
+ _logger.debug(msg=f"Response {result}")
336
+
289
337
  return result
290
338
 
291
339
 
@@ -305,8 +353,9 @@ def jusbr_get_token(user_id: str,
305
353
  # initialize the return variable
306
354
  result: str | None = None
307
355
 
308
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
309
- logger=logger)
356
+ user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
357
+ user_id=user_id,
358
+ logger=logger)
310
359
  safe_cache: Cache = user_data.get("cache-obj")
311
360
  if safe_cache:
312
361
  access_expiration: int = user_data.get("access-expiration")
@@ -350,83 +399,13 @@ def jusbr_set_scope(user_id: str,
350
399
  global _jusbr_registry
351
400
 
352
401
  # retrieve user data
353
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
354
- logger=logger)
402
+ user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
403
+ user_id=user_id,
404
+ logger=logger)
355
405
  # set the OAuth2 scope
356
406
  user_data["oauth-scope"] = scope
357
407
  if logger:
358
- logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
359
-
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
-
397
- def __get_login_timeout() -> int | None:
398
- """
399
- Retrieve the timeout currently applicable for the login operation.
400
-
401
- :return: the current login timeout, or *None* if none has been set.
402
- """
403
- global _jusbr_registry
404
-
405
- timeout: int = _jusbr_registry.get("client-timeout")
406
- return timeout if isinstance(timeout, int) and timeout > 0 else None
407
-
408
-
409
- def __get_user_data(user_id: str,
410
- logger: Logger | None) -> dict[str, Any]:
411
- """
412
- Retrieve the data for *user_id* from the registry.
413
-
414
- If an entry is not found for *user_id* in the registry, it is created.
415
- It will remain there until the user is logged out.
416
-
417
- :param user_id:
418
- :return: the data for *user_id* in the registry
419
- """
420
- global _jusbr_registry
421
-
422
- result: dict[str, Any] = _jusbr_registry["users"].get(user_id)
423
- if not result:
424
- result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
425
- _jusbr_registry["users"][user_id] = result
426
- if logger:
427
- logger.debug(f"Entry for user '{user_id}' added to registry")
428
-
429
- return result
408
+ logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
430
409
 
431
410
 
432
411
  def __post_jusbr(user_data: dict[str, Any],
@@ -436,7 +415,7 @@ def __post_jusbr(user_data: dict[str, Any],
436
415
  """
437
416
  Send a POST request to JusBR to obtain the authentication token data, and return the access token.
438
417
 
439
- For code for token exchange, *body_data* will have the attributes
418
+ For token exchange, *body_data* will have the attributes
440
419
  - "grant_type": "authorization_code"
441
420
  - "code": <16-character-random-code>
442
421
  - "redirect_uri": <callback-url>
@@ -467,8 +446,11 @@ def __post_jusbr(user_data: dict[str, Any],
467
446
  # obtain the token
468
447
  err_msg: str | None = None
469
448
  safe_cache: Cache = user_data.get("cache-obj")
470
- url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
449
+ url: str = _jusbr_registry.get("base-url") + "/protocol/openid-connect/token"
471
450
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
451
+ if logger:
452
+ logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
453
+ ensure_ascii=False)}")
472
454
  try:
473
455
  # JusBR return on a token request:
474
456
  # {
@@ -481,6 +463,8 @@ def __post_jusbr(user_data: dict[str, Any],
481
463
  data=body_data)
482
464
  if response.status_code == 200:
483
465
  # request succeeded
466
+ if logger:
467
+ logger.debug(msg=f"POST success, status {response.status_code}")
484
468
  reply: dict[str, Any] = response.json()
485
469
  result = reply.get("access_token")
486
470
  safe_cache: Cache = FIFOCache(maxsize=1024)
@@ -489,12 +473,9 @@ def __post_jusbr(user_data: dict[str, Any],
489
473
  safe_cache["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
490
474
  user_data["cache-obj"] = safe_cache
491
475
  user_data["access-expiration"] = now + reply.get("expires_in")
492
- if logger:
493
- logger.debug(msg=f"POST '{url}': status {response.status_code}")
494
476
  else:
495
477
  # request resulted in error
496
- err_msg = (f"POST '{url}': failed, "
497
- f"status {response.status_code}, reason '{response.reason}'")
478
+ err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
498
479
  if hasattr(response, "content") and response.content:
499
480
  err_msg += f", content '{response.content}'"
500
481
  if response.status_code == 401 and "refresh_token" in body_data:
@@ -0,0 +1,340 @@
1
+ import secrets
2
+ import string
3
+ # import sys
4
+ from cachetools import FIFOCache
5
+ from datetime import datetime
6
+ from flask import Flask, Response, redirect, request, jsonify
7
+ from logging import Logger
8
+ from pypomes_core import (
9
+ APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
10
+ )
11
+ from typing import Any, Final
12
+
13
+ from .common_pomes import (
14
+ _get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
15
+ )
16
+
17
+ KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
18
+ KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
19
+ KEYCLOAK_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_TIMEOUT")
20
+
21
+ KEYCLOAK_ENDPOINT_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_CALLBACK",
22
+ def_value="/iam/keycloak:callback")
23
+ KEYCLOAK_ENDPOINT_LOGIN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_LOGIN",
24
+ def_value="/iam/keycloak:login")
25
+ KEYCLOAK_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_LOGOUT",
26
+ def_value="/iam/keycloak:logout")
27
+ KEYCLOAK_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_TOKEN",
28
+ def_value="/iam/keycloak:get-token")
29
+
30
+ KEYCLOAK_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_PUBLIC_KEY_LIFETIME",
31
+ def_value=86400) # 24 hours
32
+ KEYCLOAK_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_REALM")
33
+ KEYCLOAK_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_BASE")
34
+ KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_CALLBACK")
35
+
36
+ # registry structure:
37
+ # {
38
+ # "client-id": <str>,
39
+ # "client-secret": <str>,
40
+ # "client-timeout": <int>,
41
+ # "public_key": <str>,
42
+ # "key-lifetime": <int>,
43
+ # "key-expiration": <int>,
44
+ # "base-url": <str>,
45
+ # "callback-url": <str>,
46
+ # "users": {
47
+ # "<user-id>": {
48
+ # "cache-obj": <FIFOCache>,
49
+ # "access-expiration": <timestamp>,
50
+ # "login-expiration": <int>, <-- transient
51
+ # "login-id": <str>, <-- transient
52
+ # data in <FIFOCache>:
53
+ # "access-token": <str>
54
+ # "refresh-token": <str>
55
+ # }
56
+ # }
57
+ # }
58
+ _keycloak_registry: dict[str, Any] = {}
59
+
60
+ # dafault logger
61
+ _logger: Logger | None = None
62
+
63
+
64
+ def keycloak_setup(flask_app: Flask,
65
+ client_id: str = KEYCLOAK_CLIENT_ID,
66
+ client_secret: str = KEYCLOAK_CLIENT_SECRET,
67
+ client_timeout: int = KEYCLOAK_CLIENT_TIMEOUT,
68
+ public_key_lifetime: int = KEYCLOAK_PUBLIC_KEY_LIFETIME,
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
+ base_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 public_key_lifetime: how long to use Keycloak's public key, before refreshing it (in seconds)
87
+ :param realm: the Keycloak realm
88
+ :param callback_endpoint: endpoint for the callback from JusBR
89
+ :param token_endpoint: endpoint for retrieving the JusBR authentication token
90
+ :param login_endpoint: endpoint for redirecting user to JusBR login page
91
+ :param logout_endpoint: endpoint for terminating user access to JusBR
92
+ :param base_url: base URL to request the JusBR services
93
+ :param callback_url: URL for Keycloak to callback on login
94
+ :param logger: optional logger
95
+ """
96
+ global _keycloak_registry
97
+
98
+ # establish the logger
99
+ global _logger
100
+ _logger = logger
101
+
102
+ # configure the JusBR registry
103
+ _keycloak_registry = {
104
+ "client-id": client_id,
105
+ "client-secret": client_secret,
106
+ "client-timeout": client_timeout,
107
+ "base-url": f"{base_url}/realms/{realm}",
108
+ "callback-url": callback_url,
109
+ "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
110
+ "key-lifetime": public_key_lifetime,
111
+ "users": []
112
+ }
113
+
114
+ # establish the endpoints
115
+ if token_endpoint:
116
+ flask_app.add_url_rule(rule=token_endpoint,
117
+ endpoint="keycloak-token",
118
+ view_func=service_token,
119
+ methods=["GET"])
120
+ if login_endpoint:
121
+ flask_app.add_url_rule(rule=login_endpoint,
122
+ endpoint="keycloak-login",
123
+ view_func=service_login,
124
+ methods=["GET"])
125
+ if logout_endpoint:
126
+ flask_app.add_url_rule(rule=logout_endpoint,
127
+ endpoint="keycloak-logout",
128
+ view_func=service_logout,
129
+ methods=["GET"])
130
+ if callback_endpoint:
131
+ flask_app.add_url_rule(rule=callback_endpoint,
132
+ endpoint="keycloak-callback",
133
+ view_func=service_callback,
134
+ methods=["POST"])
135
+
136
+
137
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:login
138
+ # methods=["GET"])
139
+ def service_login() -> Response:
140
+ """
141
+ Entry point for the Keycloak login service.
142
+
143
+ Redirect the request to the Keycloak authentication page, with the appropriate parameters.
144
+
145
+ :return: the response from the redirect operation
146
+ """
147
+ global _keycloak_registry
148
+
149
+ # log the request
150
+ if _logger:
151
+ msg: str = _log_init(request=request)
152
+ _logger.debug(msg=msg)
153
+
154
+ # build the OAuth2 state, and temporarily use it as 'user_id'
155
+ oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
156
+ # obtain the user data
157
+ user_data: dict[str, Any] = _get_user_data(registry=_keycloak_registry,
158
+ user_id=oauth_state,
159
+ logger=_logger)
160
+ # build the redirect url
161
+ timeout: int = _get_login_timeout(registry=_keycloak_registry)
162
+ safe_cache: FIFOCache
163
+ if timeout:
164
+ safe_cache = FIFOCache(maxsize=16)
165
+ else:
166
+ safe_cache = FIFOCache(maxsize=16)
167
+ safe_cache["valid"] = True
168
+ user_data["cache-obj"] = safe_cache
169
+ auth_url: str = (
170
+ f"{_keycloak_registry["base-url"]}/protocol/openid-connect/auth"
171
+ f"?client_id={_keycloak_registry["client-id"]}"
172
+ f"&response_type=code"
173
+ f"&scope=openid"
174
+ f"&redirect_uri={_keycloak_registry["callback-url"]}"
175
+ )
176
+
177
+ # redirect the request
178
+ result: Response = redirect(location=auth_url)
179
+
180
+ # log the response
181
+ if _logger:
182
+ _logger.debug(msg=f"Response {result}")
183
+
184
+ return result
185
+
186
+
187
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
188
+ # methods=["GET"])
189
+ def service_logout() -> Response:
190
+ """
191
+ Entry point for the Keycloak logout service.
192
+
193
+ Remove all data associating the user with Keycloak from the registry.
194
+
195
+ :return: response *OK*
196
+ """
197
+ global _keycloak_registry
198
+
199
+ # log the request
200
+ if _logger:
201
+ msg: str = _log_init(request=request)
202
+ _logger.debug(msg=msg)
203
+
204
+ # retrieve the user id
205
+ input_params: dict[str, Any] = request.args
206
+ user_id: str = input_params.get("user-id") or input_params.get("login")
207
+
208
+ # logout the user
209
+ _user_logout(registry=_keycloak_registry,
210
+ user_id=user_id,
211
+ logger=_logger)
212
+
213
+ result: Response = Response(status=200)
214
+
215
+ # log the response
216
+ if _logger:
217
+ _logger.debug(msg=f"Response {result}")
218
+
219
+ return result
220
+
221
+
222
+ # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
223
+ # methods=["POST"])
224
+ def service_callback() -> Response:
225
+ """
226
+ Entry point for the callback from Keycloak on authentication operation.
227
+
228
+ :return: the response containing the token, or *NOT AUTHORIZED*
229
+ """
230
+ global _keycloak_registry
231
+ from .token_pomes import token_validate
232
+
233
+ # log the request
234
+ if _logger:
235
+ msg: str = _log_init(request=request)
236
+ _logger.debug(msg=msg)
237
+
238
+ # validate the OAuth2 state
239
+ oauth_state: str = request.args.get("state")
240
+ user_id: str | None = None
241
+ user_data: dict[str, Any] | None = None
242
+ if oauth_state:
243
+ for user, data in _keycloak_registry.get("users").items():
244
+ safe_cache: FIFOCache = data.get("cache-obj")
245
+ if user == oauth_state:
246
+ if data.get("valid"):
247
+ user_id = user
248
+ user_data = data
249
+ else:
250
+ msg = "Operation timeout"
251
+ break
252
+
253
+ # exchange 'code' for the token
254
+ token: str | None = None
255
+ errors: list[str] = []
256
+ if user_data:
257
+ code: str = request.args.get("code")
258
+ body_data: dict[str, Any] = {
259
+ "grant_type": "authorization_code",
260
+ "code": code,
261
+ "redirec_url": _keycloak_registry.get("callback-url"),
262
+ }
263
+ # token = __post_jusbr(user_data=user_data,
264
+ # body_data=body_data,
265
+ # errors=errors,
266
+ # logger=_logger)
267
+ # retrieve the token's claims
268
+ if not errors:
269
+ public_key: bytes = _get_public_key(registry=_keycloak_registry,
270
+ url=_keycloak_registry["base-url"],
271
+ logger=_logger)
272
+ token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
273
+ issuer=_keycloak_registry["base-url"],
274
+ public_key=public_key,
275
+ errors=errors,
276
+ logger=_logger)
277
+ if not errors:
278
+ token_user: str = token_claims["payload"].get("preferred_username")
279
+ if user_id == oauth_state:
280
+ user_id = token_user
281
+ _keycloak_registry["users"][user_id] = _keycloak_registry["users"].pop(oauth_state)
282
+ elif token_user != user_id:
283
+ errors.append(f"Token was issued to user '{token_user}'")
284
+ else:
285
+ msg: str = "Unknown OAuth2 code received"
286
+ if _get_login_timeout(registry=_keycloak_registry):
287
+ msg += " - possible operation timeout"
288
+ errors.append(msg)
289
+
290
+ result: Response
291
+ if errors:
292
+ result = jsonify({"errors": "; ".join(errors)})
293
+ result.status_code = 400
294
+ else:
295
+ result = jsonify({
296
+ "user_id": user_id,
297
+ "access_token": token})
298
+
299
+ # log the response
300
+ if _logger:
301
+ _logger.debug(msg=f"Response {result}")
302
+
303
+ return result
304
+
305
+
306
+ # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
307
+ # methods=["GET"])
308
+ def service_token() -> Response:
309
+ """
310
+ Entry point for retrieving the Keycloak token.
311
+
312
+ :return: the response containing the token, or *UNAUTHORIZED*
313
+ """
314
+ # log the request
315
+ if _logger:
316
+ msg: str = _log_init(request=request)
317
+ _logger.debug(msg=msg)
318
+
319
+ # retrieve the user id
320
+ input_params: dict[str, Any] = request.args
321
+ _user_id: str = input_params.get("user-id") or input_params.get("login")
322
+ return Response()
323
+
324
+
325
+ def keycloak_get_token(user_id: str,
326
+ errors: list[str] = None,
327
+ logger: Logger = None) -> str:
328
+ """
329
+ Retrieve the authentication token for user *user_id*.
330
+
331
+ :param user_id: the user's identification
332
+ :param errors: incidental error messages
333
+ :param logger: optional logger
334
+ :return: the token for *user_id*, or *None* if error
335
+ """
336
+ global _keycloak_registry
337
+
338
+ # initialize the return variable
339
+ result: str | None = None
340
+ return result
@@ -1,213 +0,0 @@
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
-
File without changes
File without changes
File without changes