pypomes-iam 0.1.6__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}"
@@ -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:
@@ -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.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
@@ -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=152ugJ0OygQLMsfr35-KiKkSv2mBdP0IGsV5SxRIU0M,20385
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.6.dist-info/METADATA,sha256=vc4SRenbHL2SJ6IM9LKgWKq4roIyW963ie3EwxqZIlI,694
7
- pypomes_iam-0.1.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- pypomes_iam-0.1.6.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
9
- pypomes_iam-0.1.6.dist-info/RECORD,,