pypomes-iam 0.1.7__py3-none-any.whl → 0.1.8__py3-none-any.whl

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.
@@ -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}"
@@ -1,16 +1,19 @@
1
- # import requests
2
- # import secrets
3
- # import string
1
+ import secrets
2
+ import string
4
3
  # import sys
5
- # from cachetools import Cache, FIFOCache, TTLCache
6
- # from datetime import datetime
4
+ from cachetools import FIFOCache
5
+ from datetime import datetime
7
6
  from flask import Flask, Response, redirect, request, jsonify
8
7
  from logging import Logger
9
8
  from pypomes_core import (
10
- APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str, exc_format
9
+ APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
11
10
  )
12
11
  from typing import Any, Final
13
12
 
13
+ from .common_pomes import (
14
+ _get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
15
+ )
16
+
14
17
  KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
15
18
  KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
16
19
  KEYCLOAK_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_TIMEOUT")
@@ -24,6 +27,8 @@ KEYCLOAK_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_E
24
27
  KEYCLOAK_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_TOKEN",
25
28
  def_value="/iam/keycloak:get-token")
26
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
27
32
  KEYCLOAK_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_REALM")
28
33
  KEYCLOAK_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_BASE")
29
34
  KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_CALLBACK")
@@ -33,30 +38,24 @@ KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK
33
38
  # "client-id": <str>,
34
39
  # "client-secret": <str>,
35
40
  # "client-timeout": <int>,
36
- # "realm": <str>,
37
- # "auth-url": <str>,
41
+ # "public_key": <str>,
42
+ # "key-lifetime": <int>,
43
+ # "key-expiration": <int>,
44
+ # "base-url": <str>,
38
45
  # "callback-url": <str>,
39
46
  # "users": {
40
47
  # "<user-id>": {
41
- # "cache-obj": <Cache>,
42
- # "oauth-scope": <str>,
48
+ # "cache-obj": <FIFOCache>,
43
49
  # "access-expiration": <timestamp>,
44
- # data in <Cache>:
45
- # "oauth-state": <str>
50
+ # "login-expiration": <int>, <-- transient
51
+ # "login-id": <str>, <-- transient
52
+ # data in <FIFOCache>:
46
53
  # "access-token": <str>
47
54
  # "refresh-token": <str>
48
55
  # }
49
56
  # }
50
57
  # }
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
- }
58
+ _keycloak_registry: dict[str, Any] = {}
60
59
 
61
60
  # dafault logger
62
61
  _logger: Logger | None = None
@@ -66,12 +65,13 @@ def keycloak_setup(flask_app: Flask,
66
65
  client_id: str = KEYCLOAK_CLIENT_ID,
67
66
  client_secret: str = KEYCLOAK_CLIENT_SECRET,
68
67
  client_timeout: int = KEYCLOAK_CLIENT_TIMEOUT,
68
+ public_key_lifetime: int = KEYCLOAK_PUBLIC_KEY_LIFETIME,
69
69
  realm: str = KEYCLOAK_REALM,
70
70
  callback_endpoint: str = KEYCLOAK_ENDPOINT_CALLBACK,
71
71
  token_endpoint: str = KEYCLOAK_ENDPOINT_TOKEN,
72
72
  login_endpoint: str = KEYCLOAK_ENDPOINT_LOGIN,
73
73
  logout_endpoint: str = KEYCLOAK_ENDPOINT_LOGOUT,
74
- auth_url: str = KEYCLOAK_URL_AUTH_BASE,
74
+ base_url: str = KEYCLOAK_URL_AUTH_BASE,
75
75
  callback_url: str = KEYCLOAK_URL_AUTH_CALLBACK,
76
76
  logger: Logger = None) -> None:
77
77
  """
@@ -83,12 +83,13 @@ def keycloak_setup(flask_app: Flask,
83
83
  :param client_id: the client's identification with JusBR
84
84
  :param client_secret: the client's password with JusBR
85
85
  :param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
86
- :param realm: the Keycloak reals
86
+ :param public_key_lifetime: how long to use Keycloak's public key, before refreshing it (in seconds)
87
+ :param realm: the Keycloak realm
87
88
  :param callback_endpoint: endpoint for the callback from JusBR
88
89
  :param token_endpoint: endpoint for retrieving the JusBR authentication token
89
90
  :param login_endpoint: endpoint for redirecting user to JusBR login page
90
91
  :param logout_endpoint: endpoint for terminating user access to JusBR
91
- :param auth_url: base URL to request the JusBR services
92
+ :param base_url: base URL to request the JusBR services
92
93
  :param callback_url: URL for Keycloak to callback on login
93
94
  :param logger: optional logger
94
95
  """
@@ -99,15 +100,16 @@ def keycloak_setup(flask_app: Flask,
99
100
  _logger = logger
100
101
 
101
102
  # configure the JusBR registry
102
- _keycloak_registry.update({
103
+ _keycloak_registry = {
103
104
  "client-id": client_id,
104
105
  "client-secret": client_secret,
105
106
  "client-timeout": client_timeout,
106
- "realm": realm,
107
- "auth-url": auth_url,
107
+ "base-url": f"{base_url}/realms/{realm}",
108
108
  "callback-url": callback_url,
109
+ "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
110
+ "key-lifetime": public_key_lifetime,
109
111
  "users": []
110
- })
112
+ }
111
113
 
112
114
  # establish the endpoints
113
115
  if token_endpoint:
@@ -144,28 +146,77 @@ def service_login() -> Response:
144
146
  """
145
147
  global _keycloak_registry
146
148
 
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()
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
151
185
 
152
186
 
153
187
  # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
154
188
  # methods=["GET"])
155
189
  def service_logout() -> Response:
156
190
  """
157
- Entry point for the JusBR logout service.
191
+ Entry point for the Keycloak logout service.
158
192
 
159
- Remove all data associating the user with JusBR from the registry.
193
+ Remove all data associating the user with Keycloak from the registry.
160
194
 
161
195
  :return: response *OK*
162
196
  """
163
197
  global _keycloak_registry
164
198
 
165
- # retrieve user id
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
166
205
  input_params: dict[str, Any] = request.args
167
- _user_id: str = input_params.get("user-id") or input_params.get("login")
168
- return Response()
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
169
220
 
170
221
 
171
222
  # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
@@ -177,7 +228,79 @@ def service_callback() -> Response:
177
228
  :return: the response containing the token, or *NOT AUTHORIZED*
178
229
  """
179
230
  global _keycloak_registry
180
- return Response()
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
181
304
 
182
305
 
183
306
  # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
@@ -188,7 +311,12 @@ def service_token() -> Response:
188
311
 
189
312
  :return: the response containing the token, or *UNAUTHORIZED*
190
313
  """
191
- # retrieve user id
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
192
320
  input_params: dict[str, Any] = request.args
193
321
  _user_id: str = input_params.get("user-id") or input_params.get("login")
194
322
  return Response()
@@ -210,4 +338,3 @@ def keycloak_get_token(user_id: str,
210
338
  # initialize the return variable
211
339
  result: str | None = None
212
340
  return result
213
-
@@ -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
@@ -0,0 +1,10 @@
1
+ pypomes_iam/__init__.py,sha256=lHnqNqW1stQjcM6cr9wf3GGnw5_zGf1HN3zyHGb8PCA,577
2
+ pypomes_iam/common_pomes.py,sha256=kdzyEJX275SmMa_zi6AJaC9gVxlXcOailyantPvNOyQ,3908
3
+ pypomes_iam/jusbr_pomes.py,sha256=kNgAgQAMDdODoNO4XKrSggFwQ7R2ID-LLz7tmT3PXH4,19510
4
+ pypomes_iam/keycloak_pomes.py,sha256=m4jM_4c_McVg74T7JG7j3tbMo9Yxp6IKgt8TuauIp7o,13204
5
+ pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
6
+ pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
7
+ pypomes_iam-0.1.8.dist-info/METADATA,sha256=fjisTEC7XbvWn37I-2Jez6vtk7Uo83Q1WCjq172hOok,694
8
+ pypomes_iam-0.1.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ pypomes_iam-0.1.8.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
10
+ pypomes_iam-0.1.8.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- pypomes_iam/__init__.py,sha256=lHnqNqW1stQjcM6cr9wf3GGnw5_zGf1HN3zyHGb8PCA,577
2
- pypomes_iam/jusbr_pomes.py,sha256=5igQW95f-Zv59w3tv8_wOfHfs6Lv2PB6-gLHpZFIc7s,21525
3
- pypomes_iam/keycloak_pomes.py,sha256=4vLaYQNY9S9xHmyiv9Ii8jgL5jA1-MgAgWduicCyofw,8059
4
- pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
5
- pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
6
- pypomes_iam-0.1.7.dist-info/METADATA,sha256=awYfm3GmoffocFbkPdNGnAecPQoleAcLCiCpD65LDZw,694
7
- pypomes_iam-0.1.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- pypomes_iam-0.1.7.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
9
- pypomes_iam-0.1.7.dist-info/RECORD,,