pypomes-iam 0.1.7__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.7
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.7"
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}"
@@ -5,13 +5,17 @@ import string
5
5
  import sys
6
6
  from cachetools import Cache, FIFOCache, TTLCache
7
7
  from datetime import datetime
8
- from flask import Flask, Request, Response, redirect, request, jsonify
8
+ from flask import Flask, Response, redirect, request, jsonify
9
9
  from logging import Logger
10
10
  from pypomes_core import (
11
11
  APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str, exc_format
12
12
  )
13
13
  from typing import Any, Final
14
14
 
15
+ from .common_pomes import (
16
+ _get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
17
+ )
18
+
15
19
  JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
16
20
  JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
17
21
  JUSBR_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_CLIENT_TIMEOUT")
@@ -35,17 +39,19 @@ JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_A
35
39
  # "client-id": <str>,
36
40
  # "client-secret": <str>,
37
41
  # "client-timeout": <int>,
38
- # "public_key": <str>,
42
+ # "public_key": <bytes>,
43
+ # "key-lifetime": <int>,
39
44
  # "key-expiration": <int>,
40
- # "auth-url": <str>,
45
+ # "base-url": <str>,
41
46
  # "callback-url": <str>,
42
47
  # "users": {
43
48
  # "<user-id>": {
44
49
  # "cache-obj": <Cache>,
45
50
  # "oauth-scope": <str>,
46
51
  # "access-expiration": <timestamp>,
52
+ # "login-expiration": <int>, <-- transient
53
+ # "login-id": <str>, <-- transient
47
54
  # data in <Cache>:
48
- # "oauth-state": <str>
49
55
  # "access-token": <str>
50
56
  # "refresh-token": <str>
51
57
  # }
@@ -66,7 +72,7 @@ def jusbr_setup(flask_app: Flask,
66
72
  token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
67
73
  login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
68
74
  logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
69
- auth_url: str = JUSBR_URL_AUTH_BASE,
75
+ base_url: str = JUSBR_URL_AUTH_BASE,
70
76
  callback_url: str = JUSBR_URL_AUTH_CALLBACK,
71
77
  logger: Logger = None) -> None:
72
78
  """
@@ -83,7 +89,7 @@ def jusbr_setup(flask_app: Flask,
83
89
  :param token_endpoint: endpoint for retrieving the JusBR authentication token
84
90
  :param login_endpoint: endpoint for redirecting user to JusBR login page
85
91
  :param logout_endpoint: endpoint for terminating user access to JusBR
86
- :param auth_url: base URL to request the JusBR services
92
+ :param base_url: base URL to request the JusBR services
87
93
  :param callback_url: URL for JusBR to callback on login
88
94
  :param logger: optional logger
89
95
  """
@@ -97,7 +103,7 @@ def jusbr_setup(flask_app: Flask,
97
103
  "client-id": client_id,
98
104
  "client-secret": client_secret,
99
105
  "client-timeout": client_timeout,
100
- "auth-url": auth_url,
106
+ "base-url": base_url,
101
107
  "callback-url": callback_url,
102
108
  "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
103
109
  "key-lifetime": public_key_lifetime,
@@ -141,27 +147,28 @@ def service_login() -> Response:
141
147
 
142
148
  # log the request
143
149
  if _logger:
144
- msg: str = __log_init(request=request)
150
+ msg: str = _log_init(request=request)
145
151
  _logger.debug(msg=msg)
146
152
 
147
- # retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
153
+ # retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state')
148
154
  input_params: dict[str, Any] = request.values
149
155
  oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
150
156
  user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
151
- # obtain user data
152
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
153
- logger=_logger)
154
- # build redirect url
155
- 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)
156
163
  safe_cache: Cache
157
164
  if timeout:
158
165
  safe_cache = TTLCache(maxsize=16,
159
- ttl=600)
166
+ ttl=timeout)
160
167
  else:
161
168
  safe_cache = FIFOCache(maxsize=16)
162
169
  safe_cache["oauth-state"] = oauth_state
163
170
  user_data["cache-obj"] = safe_cache
164
- 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"
165
172
  f"&client_id={_jusbr_registry["client-id"]}"
166
173
  f"&redirect_uri={_jusbr_registry["callback-url"]}"
167
174
  f"&state={oauth_state}")
@@ -192,18 +199,17 @@ def service_logout() -> Response:
192
199
 
193
200
  # log the request
194
201
  if _logger:
195
- msg: str = __log_init(request=request)
202
+ msg: str = _log_init(request=request)
196
203
  _logger.debug(msg=msg)
197
204
 
198
- # retrieve user id
205
+ # retrieve the user id
199
206
  input_params: dict[str, Any] = request.args
200
207
  user_id: str = input_params.get("user-id") or input_params.get("login")
201
208
 
202
- # remove user data
203
- if user_id and user_id in _jusbr_registry.get("users"):
204
- _jusbr_registry["users"].pop(user_id)
205
- if _logger:
206
- _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)
207
213
 
208
214
  result: Response = Response(status=200)
209
215
 
@@ -227,7 +233,7 @@ def service_callback() -> Response:
227
233
 
228
234
  # log the request
229
235
  if _logger:
230
- msg: str = __log_init(request=request)
236
+ msg: str = _log_init(request=request)
231
237
  _logger.debug(msg=msg)
232
238
 
233
239
  # validate the OAuth2 state
@@ -253,7 +259,7 @@ def service_callback() -> Response:
253
259
  body_data: dict[str, Any] = {
254
260
  "grant_type": "authorization_code",
255
261
  "code": code,
256
- "redirec_url": _jusbr_registry.get("callback-url"),
262
+ "redirect_uri": _jusbr_registry.get("callback-url"),
257
263
  }
258
264
  token = __post_jusbr(user_data=user_data,
259
265
  body_data=body_data,
@@ -261,9 +267,12 @@ def service_callback() -> Response:
261
267
  logger=_logger)
262
268
  # retrieve the token's claims
263
269
  if not errors:
270
+ public_key: bytes = _get_public_key(registry=_jusbr_registry,
271
+ url=_jusbr_registry["base-url"],
272
+ logger=_logger)
264
273
  token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
265
- issuer=_jusbr_registry.get("auth-url"),
266
- public_key=_jusbr_registry.get("public_key"),
274
+ issuer=_jusbr_registry["base-url"],
275
+ public_key=public_key,
267
276
  errors=errors,
268
277
  logger=_logger)
269
278
  if not errors:
@@ -275,7 +284,7 @@ def service_callback() -> Response:
275
284
  errors.append(f"Token was issued to user '{token_user}'")
276
285
  else:
277
286
  msg: str = "Unknown OAuth2 code received"
278
- if __get_login_timeout():
287
+ if _get_login_timeout(registry=_jusbr_registry):
279
288
  msg += " - possible operation timeout"
280
289
  errors.append(msg)
281
290
 
@@ -305,7 +314,7 @@ def service_token() -> Response:
305
314
  """
306
315
  # log the request
307
316
  if _logger:
308
- msg: str = __log_init(request=request)
317
+ msg: str = _log_init(request=request)
309
318
  _logger.debug(msg=msg)
310
319
 
311
320
  # retrieve the token
@@ -344,8 +353,9 @@ def jusbr_get_token(user_id: str,
344
353
  # initialize the return variable
345
354
  result: str | None = None
346
355
 
347
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
348
- logger=logger)
356
+ user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
357
+ user_id=user_id,
358
+ logger=logger)
349
359
  safe_cache: Cache = user_data.get("cache-obj")
350
360
  if safe_cache:
351
361
  access_expiration: int = user_data.get("access-expiration")
@@ -389,83 +399,13 @@ def jusbr_set_scope(user_id: str,
389
399
  global _jusbr_registry
390
400
 
391
401
  # retrieve user data
392
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
393
- logger=logger)
402
+ user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
403
+ user_id=user_id,
404
+ logger=logger)
394
405
  # set the OAuth2 scope
395
406
  user_data["oauth-scope"] = scope
396
407
  if logger:
397
- logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
398
-
399
-
400
- def __get_public_key(url: str,
401
- logger: Logger | None) -> str:
402
- """
403
- Obtain the public key used by JusBR to sign the authentication tokens.
404
-
405
- :param url: the base URL to request the public key
406
- :return: the public key, in *PEM* format
407
- """
408
- from pypomes_crypto import crypto_jwk_convert
409
- global _jusbr_registry
410
-
411
- # initialize the return variable
412
- result: str | None = None
413
-
414
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
415
- if now > _jusbr_registry.get("key-expiration"):
416
- # obtain a new public key
417
- url: str = f"{url}/protocol/openid-connect/certs"
418
- response: requests.Response = requests.get(url=url)
419
- if response.status_code == 200:
420
- # request succeeded
421
- reply: dict[str, Any] = response.json()
422
- result = crypto_jwk_convert(jwk=reply["keys"][0],
423
- fmt="PEM")
424
- _jusbr_registry["public-key"] = result
425
- duration: int = _jusbr_registry.get("key-lifetime") or 0
426
- _jusbr_registry["key-expiration"] = now + duration
427
- elif logger:
428
- logger.error(msg=f"GET '{url}': failed, "
429
- f"status {response.status_code}, reason '{response.reason}'")
430
- else:
431
- result = _jusbr_registry.get("public-key")
432
-
433
- return result
434
-
435
-
436
- def __get_login_timeout() -> int | None:
437
- """
438
- Retrieve the timeout currently applicable for the login operation.
439
-
440
- :return: the current login timeout, or *None* if none has been set.
441
- """
442
- global _jusbr_registry
443
-
444
- timeout: int = _jusbr_registry.get("client-timeout")
445
- return timeout if isinstance(timeout, int) and timeout > 0 else None
446
-
447
-
448
- def __get_user_data(user_id: str,
449
- logger: Logger | None) -> dict[str, Any]:
450
- """
451
- Retrieve the data for *user_id* from the registry.
452
-
453
- If an entry is not found for *user_id* in the registry, it is created.
454
- It will remain there until the user is logged out.
455
-
456
- :param user_id:
457
- :return: the data for *user_id* in the registry
458
- """
459
- global _jusbr_registry
460
-
461
- result: dict[str, Any] = _jusbr_registry["users"].get(user_id)
462
- if not result:
463
- result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
464
- _jusbr_registry["users"][user_id] = result
465
- if logger:
466
- logger.debug(f"Entry for user '{user_id}' added to registry")
467
-
468
- return result
408
+ logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
469
409
 
470
410
 
471
411
  def __post_jusbr(user_data: dict[str, Any],
@@ -475,7 +415,7 @@ def __post_jusbr(user_data: dict[str, Any],
475
415
  """
476
416
  Send a POST request to JusBR to obtain the authentication token data, and return the access token.
477
417
 
478
- For code for token exchange, *body_data* will have the attributes
418
+ For token exchange, *body_data* will have the attributes
479
419
  - "grant_type": "authorization_code"
480
420
  - "code": <16-character-random-code>
481
421
  - "redirect_uri": <callback-url>
@@ -506,8 +446,11 @@ def __post_jusbr(user_data: dict[str, Any],
506
446
  # obtain the token
507
447
  err_msg: str | None = None
508
448
  safe_cache: Cache = user_data.get("cache-obj")
509
- url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
449
+ url: str = _jusbr_registry.get("base-url") + "/protocol/openid-connect/token"
510
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)}")
511
454
  try:
512
455
  # JusBR return on a token request:
513
456
  # {
@@ -520,6 +463,8 @@ def __post_jusbr(user_data: dict[str, Any],
520
463
  data=body_data)
521
464
  if response.status_code == 200:
522
465
  # request succeeded
466
+ if logger:
467
+ logger.debug(msg=f"POST success, status {response.status_code}")
523
468
  reply: dict[str, Any] = response.json()
524
469
  result = reply.get("access_token")
525
470
  safe_cache: Cache = FIFOCache(maxsize=1024)
@@ -528,12 +473,9 @@ def __post_jusbr(user_data: dict[str, Any],
528
473
  safe_cache["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
529
474
  user_data["cache-obj"] = safe_cache
530
475
  user_data["access-expiration"] = now + reply.get("expires_in")
531
- if logger:
532
- logger.debug(msg=f"POST '{url}': status {response.status_code}")
533
476
  else:
534
477
  # request resulted in error
535
- err_msg = (f"POST '{url}': failed, "
536
- f"status {response.status_code}, reason '{response.reason}'")
478
+ err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
537
479
  if hasattr(response, "content") and response.content:
538
480
  err_msg += f", content '{response.content}'"
539
481
  if response.status_code == 401 and "refresh_token" in body_data:
@@ -552,10 +494,3 @@ def __post_jusbr(user_data: dict[str, Any],
552
494
  logger.error(msg=err_msg)
553
495
 
554
496
  return result
555
-
556
-
557
- def __log_init(request: Request) -> str:
558
-
559
- params: str = json.dumps(obj=request.args,
560
- ensure_ascii=False)
561
- return f"Request {request.method}:{request.path}, params {params}"
@@ -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