pypomes-iam 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_iam
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -12,5 +12,7 @@ Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.12
13
13
  Requires-Dist: cachetools>=6.2.1
14
14
  Requires-Dist: flask>=3.1.2
15
+ Requires-Dist: pyjwt>=2.10.1
15
16
  Requires-Dist: pypomes-core>=2.8.0
17
+ Requires-Dist: pypomes-crypto>=0.4.8
16
18
  Requires-Dist: requests>=2.32.5
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "pypomes_iam"
9
- version = "0.1.0"
9
+ version = "0.1.2"
10
10
  authors = [
11
11
  { name="GT Nunes", email="wisecoder01@gmail.com" }
12
12
  ]
@@ -21,7 +21,9 @@ classifiers = [
21
21
  dependencies = [
22
22
  "cachetools>=6.2.1",
23
23
  "Flask>=3.1.2",
24
+ "PyJWT>=2.10.1",
24
25
  "pypomes-core>=2.8.0",
26
+ "pypomes-crypto>=0.4.8",
25
27
  "requests>=2.32.5"
26
28
  ]
27
29
 
@@ -4,12 +4,17 @@ from .jusbr_pomes import (
4
4
  from .provider_pomes import (
5
5
  provider_register, provider_get_token
6
6
  )
7
+ from .token_pomes import (
8
+ token_validate
9
+ )
7
10
 
8
11
  __all__ = [
9
12
  # jusbr_pomes
10
13
  "jusbr_setup", "jusbr_get_token", "jusbr_set_scope",
11
14
  # provider_pomes
12
- "provider_register", "provider_get_token"
15
+ "provider_register", "provider_get_token",
16
+ # token_pomes
17
+ "token_validate"
13
18
  ]
14
19
 
15
20
  from importlib.metadata import version
@@ -24,38 +24,33 @@ JUSBR_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_ENDPOIN
24
24
  JUSBR_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_ENDPOINT_TOKEN",
25
25
  def_value="/iam/jusbr:get-token")
26
26
 
27
+ JUSBR_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_PUBLIC_KEY_LIFETIME",
28
+ def_value=86400) # 24 hours
29
+ JUSBR_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_BASE")
27
30
  JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_AUTH_CALLBACK")
28
- JUSBR_URL_AUTH_LOGIN: Final[str] = env_get_str(key=f"{APP_PREFIX}JUSBR_URL_AUTH_LOGIN")
29
- JUSBR_URL_AUTH_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}JUSBR_URL_AUTH_TOKEN")
30
31
 
31
- # safe memory cache - structure:
32
+ # registry structure:
32
33
  # {
33
34
  # "client-id": <str>,
34
35
  # "client-secret": <str>,
35
- # "auth-url": <str>,
36
- # "token-url": <str>,
37
36
  # "client-timeout": <int>,
37
+ # "public_key": <str>,
38
+ # "key-expiration": <int>,
39
+ # "auth-url": <str>,
40
+ # "callback-url": <str>,
38
41
  # "users": {
39
42
  # "<user-id>": {
40
43
  # "cache-obj": <Cache>,
41
44
  # "oauth-scope": <str>,
42
45
  # "access-expiration": <timestamp>,
43
- # data in <TTLCache>:
46
+ # data in <Cache>:
44
47
  # "oauth-state": <str>
45
48
  # "access-token": <str>
46
49
  # "refresh-token": <str>
47
50
  # }
48
51
  # }
49
52
  # }
50
- _jusbr_registry: dict[str, Any] = {
51
- "client-id": None,
52
- "client-secret": None,
53
- "client-timeout": None,
54
- "auth-url": None,
55
- "callback-url": None,
56
- "token-url": None,
57
- "users": {}
58
- }
53
+ _jusbr_registry: dict[str, Any] | None = None
59
54
 
60
55
  # dafault logger
61
56
  _logger: Logger | None = None
@@ -65,13 +60,13 @@ def jusbr_setup(flask_app: Flask,
65
60
  client_id: str = JUSBR_CLIENT_ID,
66
61
  client_secret: str = JUSBR_CLIENT_SECRET,
67
62
  client_timeout: int = JUSBR_CLIENT_TIMEOUT,
63
+ public_key_lifetime: int = JUSBR_PUBLIC_KEY_LIFETIME,
68
64
  callback_endpoint: str = JUSBR_ENDPOINT_CALLBACK,
69
65
  token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
70
66
  login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
71
67
  logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
72
- auth_url: str = JUSBR_URL_AUTH_LOGIN,
68
+ auth_url: str = JUSBR_URL_AUTH_BASE,
73
69
  callback_url: str = JUSBR_URL_AUTH_CALLBACK,
74
- token_url: str = JUSBR_URL_AUTH_TOKEN,
75
70
  logger: Logger = None) -> None:
76
71
  """
77
72
  Configure the JusBR IAM.
@@ -82,13 +77,13 @@ def jusbr_setup(flask_app: Flask,
82
77
  :param client_id: the client's identification with JusBR
83
78
  :param client_secret: the client's password with JusBR
84
79
  :param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
80
+ :param public_key_lifetime: how long to use JusBR's public key, before refreshing it (in seconds)
85
81
  :param callback_endpoint: endpoint for the callback from JusBR
86
82
  :param token_endpoint: endpoint for retrieving the JusBR authentication token
87
83
  :param login_endpoint: endpoint for redirecting user to JusBR login page
88
84
  :param logout_endpoint: endpoint for terminating user access to JusBR
89
- :param auth_url: URL to access the JusBR login page
85
+ :param auth_url: base URL to request the JusBR services
90
86
  :param callback_url: URL for JusBR to callback on login
91
- :param token_url: URL for obtaing or refreshing the token
92
87
  :param logger: optional logger
93
88
  """
94
89
  # establish the logger
@@ -97,14 +92,15 @@ def jusbr_setup(flask_app: Flask,
97
92
 
98
93
  # configure the JusBR registry
99
94
  global _jusbr_registry # noqa: PLW0602
100
- _jusbr_registry.update({
95
+ _jusbr_registry = {
101
96
  "client-id": client_id,
102
97
  "client-secret": client_secret,
103
98
  "client-timeout": client_timeout,
104
99
  "auth-url": auth_url,
105
100
  "callback-url": callback_url,
106
- "token-url": token_url
107
- })
101
+ "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
102
+ "key-lifetime": public_key_lifetime
103
+ }
108
104
 
109
105
  # establish the endpoints
110
106
  if token_endpoint:
@@ -135,21 +131,20 @@ def service_login() -> Response:
135
131
  """
136
132
  Entry point for the JusBR login service.
137
133
 
138
- Redirect the request to the JusBR authentication page, with the apprpriate parameters.
134
+ Redirect the request to the JusBR authentication page, with the appropriate parameters.
139
135
 
140
136
  :return: the response from the redirect operation
141
137
  """
142
138
  global _jusbr_registry
143
139
 
144
- # retrieve user id
140
+ # retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
145
141
  input_params: dict[str, Any] = request.values
146
- user_id: str = input_params.get("user-id") or input_params.get("login")
147
-
148
- # retrieve user data
149
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
142
+ oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
143
+ user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
144
+ # obtain user data
145
+ user_data: dict[str, Any] = __get_user_data(user_id=user_id or oauth_state,
150
146
  logger=_logger)
151
147
  # build redirect url
152
- oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
153
148
  timeout: int = __get_login_timeout()
154
149
  safe_cache: Cache
155
150
  if timeout:
@@ -159,7 +154,7 @@ def service_login() -> Response:
159
154
  safe_cache = FIFOCache(maxsize=16)
160
155
  safe_cache["oauth-state"] = oauth_state
161
156
  user_data["cache-obj"] = safe_cache
162
- auth_url: str = (f"{_jusbr_registry["auth-url"]}?response_type=code"
157
+ auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
163
158
  f"&client_id={_jusbr_registry["client-id"]}"
164
159
  f"&redirect_url={_jusbr_registry["callback-url"]}"
165
160
  f"&state={oauth_state}")
@@ -178,7 +173,7 @@ def service_logout() -> Response:
178
173
 
179
174
  Remove all data associating the user with JusBR from the registry.
180
175
 
181
- :return: the response from the redirect operation
176
+ :return: response *OK*
182
177
  """
183
178
  global _jusbr_registry
184
179
 
@@ -187,7 +182,7 @@ def service_logout() -> Response:
187
182
  user_id: str = input_params.get("user-id") or input_params.get("login")
188
183
 
189
184
  # remove user data
190
- if user_id in _jusbr_registry.get("users"):
185
+ if user_id and user_id in _jusbr_registry.get("users"):
191
186
  _jusbr_registry["users"].pop(user_id)
192
187
  if _logger:
193
188
  _logger.debug(f"User '{user_id}' removed from the registry")
@@ -204,14 +199,18 @@ def service_callback() -> Response:
204
199
  :return: the response containing the token, or *NOT AUTHORIZED*
205
200
  """
206
201
  global _jusbr_registry
202
+ from .token_pomes import token_validate
207
203
 
208
204
  # validate the OAuth2 state
209
205
  oauth_state: str = request.args.get("state")
206
+ user_id: str | None = None
210
207
  user_data: dict[str, Any] | None = None
211
208
  if oauth_state:
212
- for data in _jusbr_registry.get("users"):
213
- safe_cache: Cache = user_data.get("cache-obj")
214
- if safe_cache and oauth_state == safe_cache.get("oauth-state"):
209
+ for user, data in _jusbr_registry.get("users").items():
210
+ safe_cache: Cache = data.get("cache-obj")
211
+ if user == oauth_state or \
212
+ (safe_cache and oauth_state == safe_cache.get("oauth-state")):
213
+ user_id = user
215
214
  user_data = data
216
215
  # 'oauth-state' is to be used only once
217
216
  safe_cache["oauth-state"] = None
@@ -231,6 +230,20 @@ def service_callback() -> Response:
231
230
  body_data=body_data,
232
231
  errors=errors,
233
232
  logger=_logger)
233
+ # retrieve the token's claims
234
+ if not errors:
235
+ token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
236
+ issuer=_jusbr_registry.get("auth-url"),
237
+ public_key=_jusbr_registry.get("public_key"),
238
+ errors=errors,
239
+ logger=_logger)
240
+ if not errors:
241
+ token_user: str = token_claims["payload"].get("preferred_username")
242
+ if user_id == oauth_state:
243
+ user_id = token_user
244
+ _jusbr_registry["users"][user_id] = _jusbr_registry["users"].pop(oauth_state)
245
+ elif token_user != user_id:
246
+ errors.append(f"Token was issued to user '{token_user}'")
234
247
  else:
235
248
  msg: str = "Unknown OAuth2 code received"
236
249
  if __get_login_timeout():
@@ -242,7 +255,9 @@ def service_callback() -> Response:
242
255
  result = jsonify({"errors": "; ".join(errors)})
243
256
  result.status_code = 400
244
257
  else:
245
- result = jsonify({"access_token": token})
258
+ result = jsonify({
259
+ "user_id": user_id,
260
+ "access_token": token})
246
261
 
247
262
  return result
248
263
 
@@ -253,20 +268,22 @@ def service_token() -> Response:
253
268
  """
254
269
  Entry point for retrieving the JusBR token.
255
270
 
256
- :return: the response containing the token, or *NOT AUTHORIZED*
271
+ :return: the response containing the token, or *UNAUTHORIZED*
257
272
  """
258
273
  # retrieve user id
259
274
  input_params: dict[str, Any] = request.args
260
275
  user_id: str = input_params.get("user-id") or input_params.get("login")
261
276
 
262
277
  # retrieve the token
278
+ errors: list[str] = []
263
279
  token: str = jusbr_get_token(user_id=user_id,
264
280
  logger=_logger)
265
281
  result: Response
266
282
  if token:
267
283
  result = jsonify({"token": token})
268
284
  else:
269
- result = Response(status=401)
285
+ result = Response("; ".join(errors))
286
+ result.status_code = 401
270
287
 
271
288
  return result
272
289
 
@@ -340,6 +357,42 @@ def jusbr_set_scope(user_id: str,
340
357
  logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
341
358
 
342
359
 
360
+ def __get_public_key(url: str,
361
+ logger: Logger | None) -> str:
362
+ """
363
+ Obtain the public key used by JusBR to sign the authentication tokens.
364
+
365
+ :param url: the base URL to request the public key
366
+ :return: the public key, in *PEM* format
367
+ """
368
+ from pypomes_crypto import crypto_jwk_convert
369
+ global _jusbr_registry
370
+
371
+ # initialize the return variable
372
+ result: str | None = None
373
+
374
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
375
+ if now > _jusbr_registry.get("key-expiration"):
376
+ # obtain a new public key
377
+ url: str = f"{url}/protocol/openid-connect/certs"
378
+ response: requests.Response = requests.get(url=url)
379
+ if response.status_code == 200:
380
+ # request succeeded
381
+ reply: dict[str, Any] = response.json()
382
+ result = crypto_jwk_convert(jwk=reply["keys"][0],
383
+ fmt="PEM")
384
+ _jusbr_registry["public-key"] = result
385
+ duration: int = _jusbr_registry.get("key-lifetime") or 0
386
+ _jusbr_registry["key-expiration"] = now + duration
387
+ elif logger:
388
+ logger.error(msg=f"GET '{url}': failed, "
389
+ f"status {response.status_code}, reason '{response.reason}'")
390
+ else:
391
+ result = _jusbr_registry.get("public-key")
392
+
393
+ return result
394
+
395
+
343
396
  def __get_login_timeout() -> int | None:
344
397
  """
345
398
  Retrieve the timeout currently applicable for the login operation.
@@ -410,9 +463,10 @@ def __post_jusbr(user_data: dict[str, Any],
410
463
  if client_secret:
411
464
  body_data["client_secret"] = client_secret
412
465
 
466
+ # obtain the token
413
467
  err_msg: str | None = None
414
468
  safe_cache: Cache = user_data.get("cache-obj")
415
- url: str = _jusbr_registry.get("auth-url")
469
+ url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
416
470
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
417
471
  try:
418
472
  # JusBR return on a token request:
@@ -0,0 +1,212 @@
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 # noqa: PLW0602
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
+ })
110
+
111
+ # establish the endpoints
112
+ if token_endpoint:
113
+ flask_app.add_url_rule(rule=token_endpoint,
114
+ endpoint="keycloak-token",
115
+ view_func=service_token,
116
+ methods=["GET"])
117
+ if login_endpoint:
118
+ flask_app.add_url_rule(rule=login_endpoint,
119
+ endpoint="keycloak-login",
120
+ view_func=service_login,
121
+ methods=["GET"])
122
+ if logout_endpoint:
123
+ flask_app.add_url_rule(rule=logout_endpoint,
124
+ endpoint="keycloak-logout",
125
+ view_func=service_logout,
126
+ methods=["GET"])
127
+ if callback_endpoint:
128
+ flask_app.add_url_rule(rule=callback_endpoint,
129
+ endpoint="keycloak-callback",
130
+ view_func=service_callback,
131
+ methods=["POST"])
132
+
133
+
134
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:login
135
+ # methods=["GET"])
136
+ def service_login() -> Response:
137
+ """
138
+ Entry point for the Keycloak login service.
139
+
140
+ Redirect the request to the Keycloak authentication page, with the appropriate parameters.
141
+
142
+ :return: the response from the redirect operation
143
+ """
144
+ global _keycloak_registry
145
+
146
+ # retrieve user id
147
+ input_params: dict[str, Any] = request.args
148
+ _user_id: str = input_params.get("user-id") or input_params.get("login")
149
+ return Response()
150
+
151
+
152
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
153
+ # methods=["GET"])
154
+ def service_logout() -> Response:
155
+ """
156
+ Entry point for the JusBR logout service.
157
+
158
+ Remove all data associating the user with JusBR from the registry.
159
+
160
+ :return: response *OK*
161
+ """
162
+ global _keycloak_registry
163
+
164
+ # retrieve user id
165
+ input_params: dict[str, Any] = request.args
166
+ _user_id: str = input_params.get("user-id") or input_params.get("login")
167
+ return Response()
168
+
169
+
170
+ # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
171
+ # methods=["POST"])
172
+ def service_callback() -> Response:
173
+ """
174
+ Entry point for the callback from Keycloak on authentication operation.
175
+
176
+ :return: the response containing the token, or *NOT AUTHORIZED*
177
+ """
178
+ global _keycloak_registry
179
+ return Response()
180
+
181
+
182
+ # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
183
+ # methods=["GET"])
184
+ def service_token() -> Response:
185
+ """
186
+ Entry point for retrieving the Keycloak token.
187
+
188
+ :return: the response containing the token, or *UNAUTHORIZED*
189
+ """
190
+ # retrieve user id
191
+ input_params: dict[str, Any] = request.args
192
+ _user_id: str = input_params.get("user-id") or input_params.get("login")
193
+ return Response()
194
+
195
+
196
+ def keycloak_get_token(user_id: str,
197
+ errors: list[str] = None,
198
+ logger: Logger = None) -> str:
199
+ """
200
+ Retrieve the authentication token for user *user_id*.
201
+
202
+ :param user_id: the user's identification
203
+ :param errors: incidental error messages
204
+ :param logger: optional logger
205
+ :return: the token for *user_id*, or *None* if error
206
+ """
207
+ global _keycloak_registry
208
+
209
+ # initialize the return variable
210
+ result: str | None = None
211
+ return result
212
+
@@ -0,0 +1,101 @@
1
+ import jwt
2
+ import sys
3
+ from jwt import PyJWK
4
+ from jwt.algorithms import RSAPublicKey
5
+ from logging import Logger
6
+ from pypomes_core import exc_format
7
+ from typing import Any
8
+
9
+
10
+ def token_validate(token: str,
11
+ issuer: str = None,
12
+ public_key: str | bytes | PyJWK | RSAPublicKey = None,
13
+ errors: list[str] = None,
14
+ logger: Logger = None) -> dict[str, dict[str, Any]] | None:
15
+ """
16
+ Verify whether *token* is a valid JWT token, and return its claims (sections *header* and *payload*).
17
+
18
+ The supported public key types are:
19
+ - *DER*: Distinguished Encoding Rules (bytes)
20
+ - *PEM*: Privacy-Enhanced Mail (str)
21
+ - *PyJWK*: a formar from the *PyJWT* package
22
+ - *RSAPublicKey*: a format from the *PyJWT* package
23
+
24
+ If an asymmetric algorithm was used to sign the token and *public_key* is provided, then
25
+ the token is validated, by using the data in its *signature* section.
26
+
27
+ On failure, *errors* will contain the reason(s) for rejecting *token*.
28
+ On success, return the token's claims (*header* and *payload*).
29
+
30
+ :param token: the token to be validated
31
+ :param public_key: optional public key used to sign the token, in *PEM* format
32
+ :param issuer: optional value to compare with the token's *iss* (issuer) attribute in its *payload*
33
+ :param errors: incidental error messages
34
+ :param logger: optional logger
35
+ :return: The token's claims (*header* and *payload*) if it is valid, *None* otherwise
36
+ """
37
+ # initialize the return variable
38
+ result: dict[str, dict[str, Any]] | None = None
39
+
40
+ if logger:
41
+ logger.debug(msg="Validate JWT token")
42
+
43
+ # make sure to have an errors list
44
+ if not isinstance(errors, list):
45
+ errors = []
46
+
47
+ # extract needed data from token header
48
+ token_header: dict[str, Any] | None = None
49
+ try:
50
+ token_header: dict[str, Any] = jwt.get_unverified_header(jwt=token)
51
+ except Exception as e:
52
+ exc_err: str = exc_format(exc=e,
53
+ exc_info=sys.exc_info())
54
+ if logger:
55
+ logger.error(msg=f"Error retrieving the token's header: {exc_err}")
56
+ errors.append(exc_err)
57
+
58
+ # validate the token
59
+ if not errors:
60
+ token_alg: str = token_header.get("alg")
61
+ options: dict[str, Any] = {
62
+ "require": ["exp", "iat"],
63
+ "verify_aud": False,
64
+ "verify_exp": True,
65
+ "verify_iat": True,
66
+ "verify_iss": issuer is not None,
67
+ "verify_nbf": False,
68
+ "verify_signature": token_alg in ["RS256", "RS512"] and public_key is not None
69
+ }
70
+ if issuer:
71
+ options["require"].append("iss")
72
+ try:
73
+ # raises:
74
+ # InvalidTokenError: token is invalid
75
+ # InvalidKeyError: authentication key is not in the proper format
76
+ # ExpiredSignatureError: token and refresh period have expired
77
+ # InvalidSignatureError: signature does not match the one provided as part of the token
78
+ # ImmatureSignatureError: 'nbf' or 'iat' claim represents a timestamp in the future
79
+ # InvalidAlgorithmError: the specified algorithm is not recognized
80
+ # InvalidIssuedAtError: 'iat' claim is non-numeric
81
+ # MissingRequiredClaimError: a required claim is not contained in the claimset
82
+ payload: dict[str, Any] = jwt.decode(jwt=token,
83
+ key=public_key,
84
+ algorithms=[token_alg],
85
+ options=options,
86
+ issuer=issuer)
87
+ result = {
88
+ "header": token_header,
89
+ "payload": payload
90
+ }
91
+ except Exception as e:
92
+ exc_err: str = exc_format(exc=e,
93
+ exc_info=sys.exc_info())
94
+ if logger:
95
+ logger.error(msg=f"Error decoding the token: {exc_err}")
96
+ errors.append(exc_err)
97
+
98
+ if not errors and logger:
99
+ logger.debug(msg="Token is valid")
100
+
101
+ return result
File without changes
File without changes
File without changes