pypomes-iam 0.1.8__py3-none-any.whl → 0.1.9__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.

Potentially problematic release.


This version of pypomes-iam might be problematic. Click here for more details.

pypomes_iam/__init__.py CHANGED
@@ -1,6 +1,9 @@
1
1
  from .jusbr_pomes import (
2
2
  jusbr_setup, jusbr_get_token, jusbr_set_scope
3
3
  )
4
+ from .keycloak_pomes import (
5
+ keycloak_setup, keycloak_get_token, keycloak_set_scope
6
+ )
4
7
  from .provider_pomes import (
5
8
  provider_register, provider_get_token
6
9
  )
@@ -11,6 +14,8 @@ from .token_pomes import (
11
14
  __all__ = [
12
15
  # jusbr_pomes
13
16
  "jusbr_setup", "jusbr_get_token", "jusbr_set_scope",
17
+ # keycloak_pomes
18
+ "keycloak_setup", "keycloak_get_token", "keycloak_set_scope",
14
19
  # provider_pomes
15
20
  "provider_register", "provider_get_token",
16
21
  # token_pomes
@@ -1,21 +1,233 @@
1
1
  import json
2
2
  import requests
3
+ import secrets
4
+ import string
5
+ import sys
6
+ from cachetools import Cache
3
7
  from datetime import datetime
4
8
  from flask import Request
5
9
  from logging import Logger
6
- from pypomes_core import TZ_LOCAL
10
+ from pypomes_core import TZ_LOCAL, exc_format
7
11
  from typing import Any
8
12
 
13
+ # registry structure:
14
+ # {
15
+ # "client-id": <str>,
16
+ # "client-secret": <str>,
17
+ # "client-timeout": <int>,
18
+ # "public_key": <str>,
19
+ # "key-lifetime": <int>,
20
+ # "key-expiration": <int>,
21
+ # "base-url": <str>,
22
+ # "callback-url": <str>,
23
+ # "safe-cache": <FIFOCache>
24
+ # }
25
+ # data in "safe-cache":
26
+ # {
27
+ # "users": {
28
+ # "<user-id>": {
29
+ # "access-token": <str>
30
+ # "refresh-token": <str>
31
+ # "access-expiration": <timestamp>,
32
+ # "login-expiration": <timestamp>, <-- transient
33
+ # "login-id": <str>, <-- transient
34
+ # "oauth-scope": <str> <-- optional
35
+ # }
36
+ # }
37
+ # }
38
+
39
+
40
+ def _service_callback(registry: dict[str, Any],
41
+ args: dict[str, Any],
42
+ errors: list[str],
43
+ logger: Logger | None) -> tuple[str, str]:
44
+ """
45
+ Entry point for the callback from JusBR on authentication operation.
46
+
47
+ :param registry: the registry holding the authentication data
48
+ :param args: the arguments passed when requesting the service
49
+ :param errors: incidental errors
50
+ :param logger: optional logger
51
+ """
52
+ from .token_pomes import token_validate
53
+
54
+ # initialize the return variable
55
+ result: tuple[str, str] | None = None
56
+
57
+ # retrieve the users authentication data
58
+ cache: Cache = registry["safe-cache"]
59
+ users: dict[str, dict[str, Any]] = cache.get("users")
60
+
61
+ # validate the OAuth2 state
62
+ oauth_state: str = args.get("state")
63
+ user_data: dict[str, Any] | None = None
64
+ if oauth_state:
65
+ for user, data in users.items():
66
+ if user == oauth_state:
67
+ user_data = data
68
+ break
69
+
70
+ # exchange 'code' for the token
71
+ if user_data:
72
+ users.pop(oauth_state)
73
+ code: str = args.get("code")
74
+ body_data: dict[str, Any] = {
75
+ "grant_type": "authorization_code",
76
+ "code": code,
77
+ "redirect_uri": registry.get("callback-url"),
78
+ }
79
+ token = _post_for_token(registry=registry,
80
+ user_data=user_data,
81
+ body_data=body_data,
82
+ errors=errors,
83
+ logger=logger)
84
+ # retrieve the token's claims
85
+ if not errors:
86
+ public_key: bytes = _get_public_key(registry=registry,
87
+ logger=logger)
88
+ token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
89
+ issuer=registry["base-url"],
90
+ public_key=public_key,
91
+ errors=errors,
92
+ logger=logger)
93
+ if not errors:
94
+ token_user: str = token_claims["payload"].get("preferred_username")
95
+ if token_user == oauth_state:
96
+ users[token_user] = user_data
97
+ result = (token_user, token)
98
+ else:
99
+ errors.append(f"Token was issued to user '{token_user}'")
100
+ else:
101
+ msg: str = "Unknown OAuth2 code received"
102
+ if _get_login_timeout(registry=registry):
103
+ msg += " - possible operation timeout"
104
+ errors.append(msg)
105
+
106
+ return result
107
+
108
+
109
+ def _service_login(registry: dict[str, Any],
110
+ args: dict[str, Any],
111
+ logger: Logger | None) -> str:
112
+ """
113
+ Build the callback URL for redirecting the request to the IAM's authentication page.
114
+
115
+ :param registry: the registry holding the authentication data
116
+ :param args: the arguments passed when requesting the service
117
+ :param logger: optional logger
118
+ :return: the callback URL, with the appropriate parameters
119
+ """
120
+
121
+ # retrieve user data
122
+ oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
123
+
124
+ # build the user data
125
+ # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
126
+ user_data: dict[str, Any] = _get_user_data(registry=registry,
127
+ user_id=oauth_state,
128
+ logger=logger)
129
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
130
+ user_data["login-id"] = user_id
131
+ timeout: int = _get_login_timeout(registry=registry)
132
+ user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
133
+
134
+ # build the redirect url
135
+ result: str = (f"{registry["base-url"]}/protocol/openid-connect/auth?response_type=code"
136
+ f"&client_id={registry["client-id"]}"
137
+ f"&redirect_uri={registry["callback-url"]}"
138
+ f"&state={oauth_state}")
139
+ scope: str = _get_user_scope(registry=registry,
140
+ user_id=user_id)
141
+ if scope:
142
+ user_data["oauth-scope"] = scope
143
+ result += f"&scope={scope}"
144
+
145
+ # logout the user
146
+ _service_logout(registry=registry,
147
+ args=args,
148
+ logger=logger)
149
+ return result
150
+
151
+
152
+ def _service_logout(registry: dict[str, Any],
153
+ args: dict[str, Any],
154
+ logger: Logger | None) -> None:
155
+ """
156
+ Remove all data associating *user_id* from *registry*.
157
+
158
+ :param registry: the registry holding the authentication data
159
+ :param args: the arguments passed when requesting the service
160
+ :param logger: optional logger
161
+ """
162
+ # remove the user data
163
+ user_id: str = args.get("user-id") or args.get("login")
164
+ if user_id:
165
+ cache: Cache = registry["safe-cache"]
166
+ users: dict[str, dict[str, Any]] = cache.get("users")
167
+ if user_id in users:
168
+ users.pop(user_id)
169
+ if logger:
170
+ logger.debug(msg=f"User '{user_id}' removed from the registry")
171
+
172
+
173
+ def _service_token(registry: dict[str, Any],
174
+ args: dict[str, Any],
175
+ errors: list[str] = None,
176
+ logger: Logger = None) -> str:
177
+ """
178
+ Retrieve the authentication token for user *user_id*.
179
+
180
+ :param registry: the registry holding the authentication data
181
+ :param args: the arguments passed when requesting the service
182
+ :param errors: incidental error messages
183
+ :param logger: optional logger
184
+ :return: the token for *user_id*, or *None* if error
185
+ """
186
+ # initialize the return variable
187
+ result: str | None = None
188
+
189
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
190
+ user_data: dict[str, Any] = _get_user_data(registry=registry,
191
+ user_id=user_id,
192
+ logger=logger)
193
+ token: str = user_data["access-token"]
194
+ if token:
195
+ access_expiration: int = user_data.get("access-expiration")
196
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
197
+ if now < access_expiration:
198
+ result = token
199
+ else:
200
+ # access token has expired
201
+ refresh_token: str = user_data["refresh-token"]
202
+ if refresh_token:
203
+ body_data: dict[str, str] = {
204
+ "grant_type": "refresh_token",
205
+ "refresh_token": refresh_token
206
+ }
207
+ result = _post_for_token(registry=registry,
208
+ user_data=user_data,
209
+ body_data=body_data,
210
+ errors=errors,
211
+ logger=logger)
212
+
213
+ elif logger or isinstance(errors, list):
214
+ err_msg: str = f"User '{user_id}' not authenticated"
215
+ if isinstance(errors, list):
216
+ errors.append(err_msg)
217
+ if logger:
218
+ logger.error(msg=err_msg)
219
+
220
+ return result
221
+
9
222
 
10
223
  def _get_public_key(registry: dict[str, Any],
11
- url: str,
12
224
  logger: Logger | None) -> bytes:
13
225
  """
14
226
  Obtain the public key used by the *IAM* to sign the authentication tokens.
15
227
 
16
228
  The public key is saved in *registry*.
17
229
 
18
- :param url: the base URL to request the public key
230
+ :param registry: the registry holding the authentication data
19
231
  :return: the public key, in *DER* format
20
232
  """
21
233
  from pypomes_crypto import crypto_jwk_convert
@@ -24,9 +236,9 @@ def _get_public_key(registry: dict[str, Any],
24
236
  result: bytes | None = None
25
237
 
26
238
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
27
- if now > registry.get("key-expiration"):
239
+ if now > registry["key-expiration"]:
28
240
  # obtain a new public key
29
- url: str = f"{url}/protocol/openid-connect/certs"
241
+ url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
30
242
  if logger:
31
243
  logger.debug(msg=f"GET '{url}'")
32
244
  response: requests.Response = requests.get(url=url)
@@ -38,7 +250,7 @@ def _get_public_key(registry: dict[str, Any],
38
250
  result = crypto_jwk_convert(jwk=reply["keys"][0],
39
251
  fmt="DER")
40
252
  registry["public-key"] = result
41
- duration: int = registry.get("key-lifetime") or 0
253
+ duration: int = registry["key-lifetime"] or 0
42
254
  registry["key-expiration"] = now + duration
43
255
  elif logger:
44
256
  msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
@@ -46,7 +258,7 @@ def _get_public_key(registry: dict[str, Any],
46
258
  msg += f", content '{response.content}'"
47
259
  logger.error(msg=msg)
48
260
  else:
49
- result = registry.get("public-key")
261
+ result = registry["public-key"]
50
262
 
51
263
  return result
52
264
 
@@ -55,6 +267,7 @@ def _get_login_timeout(registry: dict[str, Any]) -> int | None:
55
267
  """
56
268
  Retrieve from *registry* the timeout currently applicable for the login operation.
57
269
 
270
+ :param registry: the registry holding the authentication data
58
271
  :return: the current login timeout, or *None* if none has been set.
59
272
  """
60
273
  timeout: int = registry.get("client-timeout")
@@ -70,13 +283,19 @@ def _get_user_data(registry: dict[str, Any],
70
283
  If an entry is not found for *user_id* in the registry, it is created.
71
284
  It will remain there until the user is logged out.
72
285
 
73
- :param user_id:
286
+ :param registry: the registry holding the authentication data
74
287
  :return: the data for *user_id* in the registry
75
288
  """
76
- result: dict[str, Any] = registry["users"].get(user_id)
289
+ cache: Cache = registry["safe-cache"]
290
+ users: dict[str, dict[str, Any]] = cache.get("users")
291
+ result: dict[str, Any] = users.get(user_id)
77
292
  if not result:
78
- result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
79
- registry["users"][user_id] = result
293
+ result = {
294
+ "access-token": None,
295
+ "refresh-token": None,
296
+ "access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())
297
+ }
298
+ users[user_id] = result
80
299
  if logger:
81
300
  logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
82
301
  elif logger:
@@ -85,17 +304,110 @@ def _get_user_data(registry: dict[str, Any],
85
304
  return result
86
305
 
87
306
 
88
- def _user_logout(registry: dict[str, Any],
89
- user_id: str,
90
- logger: Logger | None) -> None:
307
+ def _get_user_scope(registry: dict[str, Any],
308
+ user_id: str) -> str | None:
91
309
  """
92
- Remove all data associating *user_id* from *registry*.
310
+ Retrieve the OAuth2 scope associated with *user_id*.
311
+
312
+ :param registry: the registry holding the authentication data
313
+ :param user_id:
314
+ :return: the OAuth2 scope associated with *user_id*, or *None* if it does not exist
93
315
  """
94
- # remove the user data
95
- if user_id and user_id in registry.get("users"):
96
- registry["users"].pop(user_id)
316
+ # initialize the return variable
317
+ result: str | None = None
318
+
319
+ if user_id:
320
+ cache: Cache = registry["safe-cache"]
321
+ users: dict[str, dict[str, Any]] = cache.get("users")
322
+ if user_id in users:
323
+ result = users[user_id].get("oauth2-scope")
324
+
325
+ return result
326
+
327
+
328
+ def _post_for_token(registry: dict[str, Any],
329
+ user_data: dict[str, Any],
330
+ body_data: dict[str, Any],
331
+ errors: list[str] | None,
332
+ logger: Logger | None) -> str | None:
333
+ """
334
+ Send a POST request to obtain the authentication token data, and return the access token.
335
+
336
+ For token exchange, *body_data* will have the attributes
337
+ - "grant_type": "authorization_code"
338
+ - "code": <16-character-random-code>
339
+ - "redirect_uri": <callback-url>
340
+ For token refresh, *body_data* will have the attributes
341
+ - "grant_type": "refresh_token"
342
+ - "refresh_token": <current-refresh-token>
343
+
344
+ If the operation is successful, the token data is stored in the registry.
345
+ Otherwise, *errors* will contain the appropriate error message.
346
+
347
+ :param registry: the registry holding the authentication data
348
+ :param user_data: the user's data in the registry
349
+ :param body_data: the data to send in the body of the request
350
+ :param errors: incidental errors
351
+ :param logger: optional logger
352
+ :return: the access token obtained, or *None* if error
353
+ """
354
+ # initialize the return variable
355
+ result: str | None = None
356
+
357
+ # complete the data to send in body of request
358
+ body_data["client_id"] = registry["client-id"]
359
+ client_secret: str = registry["client-secret"]
360
+ if client_secret:
361
+ body_data["client_secret"] = client_secret
362
+
363
+ # obtain the token
364
+ err_msg: str | None = None
365
+ url: str = registry["base-url"] + "/protocol/openid-connect/token"
366
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
367
+ if logger:
368
+ logger.debug(msg=f"POST '{url}', data {json.dumps(obj=body_data,
369
+ ensure_ascii=False)}")
370
+ try:
371
+ # typical return on a token request:
372
+ # {
373
+ # "token_type": "Bearer",
374
+ # "access_token": <str>,
375
+ # "expires_in": <number-of-seconds>,
376
+ # "refresh_token": <str>
377
+ # }
378
+ response: requests.Response = requests.post(url=url,
379
+ data=body_data)
380
+ if response.status_code == 200:
381
+ # request succeeded
382
+ if logger:
383
+ logger.debug(msg=f"POST success, status {response.status_code}")
384
+ reply: dict[str, Any] = response.json()
385
+ result = reply.get("access_token")
386
+ user_data["access-token"] = result
387
+ # on token refresh, keep current refresh token if a new one is not provided
388
+ user_data["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
389
+ user_data["access-expiration"] = now + reply.get("expires_in")
390
+ else:
391
+ # request resulted in error
392
+ err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
393
+ if hasattr(response, "content") and response.content:
394
+ err_msg += f", content '{response.content}'"
395
+ if response.status_code == 400 and body_data.get("grant_type") == "refresh_token":
396
+ # refresh token is no longer valid
397
+ user_data["refresh-token"] = None
398
+ except Exception as e:
399
+ # the operation raised an exception
400
+ err_msg = exc_format(exc=e,
401
+ exc_info=sys.exc_info())
402
+ err_msg = f"POST '{url}': error '{err_msg}'"
403
+
404
+ if err_msg:
405
+ if isinstance(errors, list):
406
+ errors.append(err_msg)
97
407
  if logger:
98
- logger.debug(msg=f"User '{user_id}' removed from the registry")
408
+ logger.error(msg=err_msg)
409
+
410
+ return result
99
411
 
100
412
 
101
413
  def _log_init(request: Request) -> str:
@@ -1,19 +1,16 @@
1
- import json
2
- import requests
3
- import secrets
4
- import string
5
- import sys
6
- from cachetools import Cache, FIFOCache, TTLCache
1
+ from cachetools import FIFOCache
7
2
  from datetime import datetime
8
3
  from flask import Flask, Response, redirect, request, jsonify
9
4
  from logging import Logger
10
5
  from pypomes_core import (
11
- APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str, exc_format
6
+ APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
12
7
  )
13
8
  from typing import Any, Final
14
9
 
15
10
  from .common_pomes import (
16
- _get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
11
+ _service_login, _service_logout,
12
+ _service_callback, _service_token,
13
+ _get_user_data, _log_init
17
14
  )
18
15
 
19
16
  JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
@@ -39,21 +36,23 @@ JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_A
39
36
  # "client-id": <str>,
40
37
  # "client-secret": <str>,
41
38
  # "client-timeout": <int>,
42
- # "public_key": <bytes>,
39
+ # "public_key": <str>,
43
40
  # "key-lifetime": <int>,
44
41
  # "key-expiration": <int>,
45
42
  # "base-url": <str>,
46
43
  # "callback-url": <str>,
44
+ # "cache-obj": <FIFOCache>
45
+ # }
46
+ # data in "cache-obj":
47
+ # {
47
48
  # "users": {
48
49
  # "<user-id>": {
49
- # "cache-obj": <Cache>,
50
- # "oauth-scope": <str>,
50
+ # "access-token": <str>
51
+ # "refresh-token": <str>
51
52
  # "access-expiration": <timestamp>,
52
- # "login-expiration": <int>, <-- transient
53
- # "login-id": <str>, <-- transient
54
- # data in <Cache>:
55
- # "access-token": <str>
56
- # "refresh-token": <str>
53
+ # "login-expiration": <timestamp>, <-- transient
54
+ # "login-id": <str>, <-- transient
55
+ # "oauth-scope": <str> <-- optional
57
56
  # }
58
57
  # }
59
58
  # }
@@ -107,7 +106,7 @@ def jusbr_setup(flask_app: Flask,
107
106
  "callback-url": callback_url,
108
107
  "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
109
108
  "key-lifetime": public_key_lifetime,
110
- "users": {}
109
+ "cache-obj": FIFOCache(maxsize=1048576)
111
110
  }
112
111
 
113
112
  # establish the endpoints
@@ -147,34 +146,12 @@ def service_login() -> Response:
147
146
 
148
147
  # log the request
149
148
  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')
154
- input_params: dict[str, Any] = request.values
155
- oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
156
- user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
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)
163
- safe_cache: Cache
164
- if timeout:
165
- safe_cache = TTLCache(maxsize=16,
166
- ttl=timeout)
167
- else:
168
- safe_cache = FIFOCache(maxsize=16)
169
- safe_cache["oauth-state"] = oauth_state
170
- user_data["cache-obj"] = safe_cache
171
- auth_url: str = (f"{_jusbr_registry["base-url"]}/protocol/openid-connect/auth?response_type=code"
172
- f"&client_id={_jusbr_registry["client-id"]}"
173
- f"&redirect_uri={_jusbr_registry["callback-url"]}"
174
- f"&state={oauth_state}")
175
- if user_data.get("oauth-scope"):
176
- auth_url += f"&scope={user_data.get("oauth-scope")}"
149
+ _logger.debug(msg=_log_init(request=request))
177
150
 
151
+ # obtain the redirect URL
152
+ auth_url: str = _service_login(registry=_jusbr_registry,
153
+ args=request.args,
154
+ logger=_logger)
178
155
  # redirect the request
179
156
  result: Response = redirect(location=auth_url)
180
157
 
@@ -199,17 +176,12 @@ def service_logout() -> Response:
199
176
 
200
177
  # log the request
201
178
  if _logger:
202
- msg: str = _log_init(request=request)
203
- _logger.debug(msg=msg)
204
-
205
- # retrieve the user id
206
- input_params: dict[str, Any] = request.args
207
- user_id: str = input_params.get("user-id") or input_params.get("login")
179
+ _logger.debug(msg=_log_init(request=request))
208
180
 
209
181
  # logout the user
210
- _user_logout(registry=_jusbr_registry,
211
- user_id=user_id,
212
- logger=_logger)
182
+ _service_logout(registry=_jusbr_registry,
183
+ args=request.args,
184
+ logger=_logger)
213
185
 
214
186
  result: Response = Response(status=200)
215
187
 
@@ -226,76 +198,28 @@ def service_callback() -> Response:
226
198
  """
227
199
  Entry point for the callback from JusBR on authentication operation.
228
200
 
229
- :return: the response containing the token, or *NOT AUTHORIZED*
201
+ :return: the response containing the token, or *BAD REQUEST*
230
202
  """
231
203
  global _jusbr_registry
232
- from .token_pomes import token_validate
233
204
 
234
205
  # log the request
235
206
  if _logger:
236
- msg: str = _log_init(request=request)
237
- _logger.debug(msg=msg)
238
-
239
- # validate the OAuth2 state
240
- oauth_state: str = request.args.get("state")
241
- user_id: str | None = None
242
- user_data: dict[str, Any] | None = None
243
- if oauth_state:
244
- for user, data in _jusbr_registry.get("users").items():
245
- safe_cache: Cache = data.get("cache-obj")
246
- if user == oauth_state or \
247
- (safe_cache and oauth_state == safe_cache.get("oauth-state")):
248
- user_id = user
249
- user_data = data
250
- # 'oauth-state' is to be used only once
251
- safe_cache["oauth-state"] = None
252
- break
253
-
254
- # exchange 'code' for the token
255
- token: str | None = None
256
- errors: list[str] = []
257
- if user_data:
258
- code: str = request.args.get("code")
259
- body_data: dict[str, Any] = {
260
- "grant_type": "authorization_code",
261
- "code": code,
262
- "redirect_uri": _jusbr_registry.get("callback-url"),
263
- }
264
- token = __post_jusbr(user_data=user_data,
265
- body_data=body_data,
266
- errors=errors,
267
- logger=_logger)
268
- # retrieve the token's claims
269
- if not errors:
270
- public_key: bytes = _get_public_key(registry=_jusbr_registry,
271
- url=_jusbr_registry["base-url"],
272
- logger=_logger)
273
- token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
274
- issuer=_jusbr_registry["base-url"],
275
- public_key=public_key,
276
- errors=errors,
277
- logger=_logger)
278
- if not errors:
279
- token_user: str = token_claims["payload"].get("preferred_username")
280
- if user_id == oauth_state:
281
- user_id = token_user
282
- _jusbr_registry["users"][user_id] = _jusbr_registry["users"].pop(oauth_state)
283
- elif token_user != user_id:
284
- errors.append(f"Token was issued to user '{token_user}'")
285
- else:
286
- msg: str = "Unknown OAuth2 code received"
287
- if _get_login_timeout(registry=_jusbr_registry):
288
- msg += " - possible operation timeout"
289
- errors.append(msg)
207
+ _logger.debug(msg=_log_init(request=request))
290
208
 
209
+ # process the callback operation
210
+ errors: list[str] = []
211
+ token_data: tuple[str, str] = _service_callback(registry=_jusbr_registry,
212
+ args=request.args,
213
+ errors=errors,
214
+ logger=_logger)
291
215
  result: Response
292
216
  if errors:
293
217
  result = jsonify({"errors": "; ".join(errors)})
294
218
  result.status_code = 400
295
219
  else:
296
220
  result = jsonify({
297
- "user_id": user_id,
298
- "access_token": token})
221
+ "user_id": token_data[0],
222
+ "access_token": token_data[1]})
299
223
 
300
224
  # log the response
301
225
  if _logger:
@@ -312,17 +236,18 @@ def service_token() -> Response:
312
236
 
313
237
  :return: the response containing the token, or *UNAUTHORIZED*
314
238
  """
239
+ global _jusbr_registry
240
+
315
241
  # log the request
316
242
  if _logger:
317
- msg: str = _log_init(request=request)
318
- _logger.debug(msg=msg)
243
+ _logger.debug(msg=_log_init(request=request))
319
244
 
320
245
  # 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")
323
246
  errors: list[str] = []
324
- token: str = jusbr_get_token(user_id=user_id,
325
- logger=_logger)
247
+ token: str = _service_token(registry=_jusbr_registry,
248
+ args=request.args,
249
+ errors=errors,
250
+ logger=_logger)
326
251
  result: Response
327
252
  if token:
328
253
  result = jsonify({"token": token})
@@ -341,54 +266,26 @@ def jusbr_get_token(user_id: str,
341
266
  errors: list[str] = None,
342
267
  logger: Logger = None) -> str:
343
268
  """
344
- Retrieve the authentication token for user *user_id*.
269
+ Retrieve a JusBR authentication token for *user_id*.
345
270
 
346
271
  :param user_id: the user's identification
347
- :param errors: incidental error messages
272
+ :param errors: incidental errors
348
273
  :param logger: optional logger
349
- :return: the token for *user_id*, or *None* if error
274
+ :return: the uthentication tokem
350
275
  """
351
276
  global _jusbr_registry
352
277
 
353
- # initialize the return variable
354
- result: str | None = None
355
-
356
- user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
357
- user_id=user_id,
358
- logger=logger)
359
- safe_cache: Cache = user_data.get("cache-obj")
360
- if safe_cache:
361
- access_expiration: int = user_data.get("access-expiration")
362
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
363
- if now < access_expiration:
364
- result = safe_cache.get("access-token")
365
- else:
366
- # access token has expired
367
- safe_cache["access-token"] = None
368
- refresh_token: str = safe_cache.get("refresh-token")
369
- if refresh_token:
370
- body_data: dict[str, str] = {
371
- "grant_type": "refresh_token",
372
- "refresh_token": refresh_token
373
- }
374
- result = __post_jusbr(user_data=user_data,
375
- body_data=body_data,
376
- errors=errors,
377
- logger=logger)
378
-
379
- elif logger or isinstance(errors, list):
380
- err_msg: str = f"User '{user_id}' not authenticated with JusBR"
381
- if isinstance(errors, list):
382
- errors.append(err_msg)
383
- if logger:
384
- logger.error(msg=err_msg)
385
-
386
- return result
278
+ # retrieve the token
279
+ args: dict[str, Any] = {"user-id": user_id}
280
+ return _service_token(registry=_jusbr_registry,
281
+ args=args,
282
+ errors=errors,
283
+ logger=logger)
387
284
 
388
285
 
389
286
  def jusbr_set_scope(user_id: str,
390
287
  scope: str,
391
- logger: Logger | None) -> None:
288
+ logger: Logger = None) -> None:
392
289
  """
393
290
  Set the OAuth2 scope of *user_id* to *scope*.
394
291
 
@@ -406,91 +303,3 @@ def jusbr_set_scope(user_id: str,
406
303
  user_data["oauth-scope"] = scope
407
304
  if logger:
408
305
  logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
409
-
410
-
411
- def __post_jusbr(user_data: dict[str, Any],
412
- body_data: dict[str, Any],
413
- errors: list[str] | None,
414
- logger: Logger | None) -> str | None:
415
- """
416
- Send a POST request to JusBR to obtain the authentication token data, and return the access token.
417
-
418
- For token exchange, *body_data* will have the attributes
419
- - "grant_type": "authorization_code"
420
- - "code": <16-character-random-code>
421
- - "redirect_uri": <callback-url>
422
- For token refresh, *body_data* will have the attributes
423
- - "grant_type": "refresh_token"
424
- - "refresh_token": <current-refresh-token>
425
-
426
- If the operation is successful, the token data is stored in the registry.
427
- Otherwise, *errors* will contain the appropriate error message.
428
-
429
- :param user_data: the user's data in the registry
430
- :param body_data: the data to send in the body of the request
431
- :param errors: incidental errors
432
- :param logger: optional logger
433
- :return: the access token obtained, or *None* if error
434
- """
435
- global _jusbr_registry
436
-
437
- # initialize the return variable
438
- result: str | None = None
439
-
440
- # complete the data to send in body of request
441
- body_data["client_id"] = _jusbr_registry.get("client-id")
442
- client_secret: str = _jusbr_registry.get("client-secret")
443
- if client_secret:
444
- body_data["client_secret"] = client_secret
445
-
446
- # obtain the token
447
- err_msg: str | None = None
448
- safe_cache: Cache = user_data.get("cache-obj")
449
- url: str = _jusbr_registry.get("base-url") + "/protocol/openid-connect/token"
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)}")
454
- try:
455
- # JusBR return on a token request:
456
- # {
457
- # "token_type": "Bearer",
458
- # "access_token": <str>,
459
- # "expires_in": <number-of-seconds>,
460
- # "refresh_token": <str>,
461
- # }
462
- response: requests.Response = requests.post(url=url,
463
- data=body_data)
464
- if response.status_code == 200:
465
- # request succeeded
466
- if logger:
467
- logger.debug(msg=f"POST success, status {response.status_code}")
468
- reply: dict[str, Any] = response.json()
469
- result = reply.get("access_token")
470
- safe_cache: Cache = FIFOCache(maxsize=1024)
471
- safe_cache["access-token"] = result
472
- # on token refresh, keep current refresh token if a new one is not provided
473
- safe_cache["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
474
- user_data["cache-obj"] = safe_cache
475
- user_data["access-expiration"] = now + reply.get("expires_in")
476
- else:
477
- # request resulted in error
478
- err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
479
- if hasattr(response, "content") and response.content:
480
- err_msg += f", content '{response.content}'"
481
- if response.status_code == 401 and "refresh_token" in body_data:
482
- # refresh token is no longer valid
483
- safe_cache["refresh-token"] = None
484
- except Exception as e:
485
- # the operation raised an exception
486
- err_msg = exc_format(exc=e,
487
- exc_info=sys.exc_info())
488
- err_msg = f"POST '{url}': error '{err_msg}'"
489
-
490
- if err_msg:
491
- if isinstance(errors, list):
492
- errors.append(err_msg)
493
- if logger:
494
- logger.error(msg=err_msg)
495
-
496
- return result
@@ -1,6 +1,3 @@
1
- import secrets
2
- import string
3
- # import sys
4
1
  from cachetools import FIFOCache
5
2
  from datetime import datetime
6
3
  from flask import Flask, Response, redirect, request, jsonify
@@ -11,7 +8,9 @@ from pypomes_core import (
11
8
  from typing import Any, Final
12
9
 
13
10
  from .common_pomes import (
14
- _get_login_timeout, _get_public_key, _get_user_data, _user_logout, _log_init
11
+ _service_login, _service_logout,
12
+ _service_callback, _service_token,
13
+ _get_user_data, _log_init
15
14
  )
16
15
 
17
16
  KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
@@ -43,15 +42,18 @@ KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK
43
42
  # "key-expiration": <int>,
44
43
  # "base-url": <str>,
45
44
  # "callback-url": <str>,
45
+ # "safe-cache": <FIFOCache>
46
+ # }
47
+ # data in "safe-cache":
48
+ # {
46
49
  # "users": {
47
50
  # "<user-id>": {
48
- # "cache-obj": <FIFOCache>,
51
+ # "access-token": <str>
52
+ # "refresh-token": <str>
49
53
  # "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>
54
+ # "login-expiration": <timestamp>, <-- transient
55
+ # "login-id": <str>, <-- transient
56
+ # "oauth-scope": <str> <-- optional
55
57
  # }
56
58
  # }
57
59
  # }
@@ -108,7 +110,7 @@ def keycloak_setup(flask_app: Flask,
108
110
  "callback-url": callback_url,
109
111
  "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
110
112
  "key-lifetime": public_key_lifetime,
111
- "users": []
113
+ "safe-cache": FIFOCache(maxsize=1048576)
112
114
  }
113
115
 
114
116
  # establish the endpoints
@@ -148,32 +150,12 @@ def service_login() -> Response:
148
150
 
149
151
  # log the request
150
152
  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
- )
153
+ _logger.debug(msg=_log_init(request=request))
176
154
 
155
+ # obtain the redirect URL
156
+ auth_url: str = _service_login(registry=_keycloak_registry,
157
+ args=request.args,
158
+ logger=_logger)
177
159
  # redirect the request
178
160
  result: Response = redirect(location=auth_url)
179
161
 
@@ -198,17 +180,12 @@ def service_logout() -> Response:
198
180
 
199
181
  # log the request
200
182
  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")
183
+ _logger.debug(msg=_log_init(request=request))
207
184
 
208
185
  # logout the user
209
- _user_logout(registry=_keycloak_registry,
210
- user_id=user_id,
211
- logger=_logger)
186
+ _service_logout(registry=_keycloak_registry,
187
+ args=request.args,
188
+ logger=_logger)
212
189
 
213
190
  result: Response = Response(status=200)
214
191
 
@@ -228,73 +205,25 @@ def service_callback() -> Response:
228
205
  :return: the response containing the token, or *NOT AUTHORIZED*
229
206
  """
230
207
  global _keycloak_registry
231
- from .token_pomes import token_validate
232
208
 
233
209
  # log the request
234
210
  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)
211
+ _logger.debug(msg=_log_init(request=request))
289
212
 
213
+ # process the callback operation
214
+ errors: list[str] = []
215
+ token_data: tuple[str, str] = _service_callback(registry=_keycloak_registry,
216
+ args=request.args,
217
+ errors=errors,
218
+ logger=_logger)
290
219
  result: Response
291
220
  if errors:
292
221
  result = jsonify({"errors": "; ".join(errors)})
293
222
  result.status_code = 400
294
223
  else:
295
224
  result = jsonify({
296
- "user_id": user_id,
297
- "access_token": token})
225
+ "user_id": token_data[0],
226
+ "access_token": token_data[1]})
298
227
 
299
228
  # log the response
300
229
  if _logger:
@@ -311,30 +240,70 @@ def service_token() -> Response:
311
240
 
312
241
  :return: the response containing the token, or *UNAUTHORIZED*
313
242
  """
243
+ global _keycloak_registry
244
+
314
245
  # log the request
315
246
  if _logger:
316
- msg: str = _log_init(request=request)
317
- _logger.debug(msg=msg)
247
+ _logger.debug(msg=_log_init(request=request))
318
248
 
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()
249
+ # retrieve the token
250
+ errors: list[str] = []
251
+ token: str = _service_token(registry=_keycloak_registry,
252
+ args=request.args,
253
+ errors=errors,
254
+ logger=_logger)
255
+ result: Response
256
+ if token:
257
+ result = jsonify({"token": token})
258
+ else:
259
+ result = Response("; ".join(errors))
260
+ result.status_code = 401
261
+
262
+ # log the response
263
+ if _logger:
264
+ _logger.debug(msg=f"Response {result}")
265
+
266
+ return result
323
267
 
324
268
 
325
269
  def keycloak_get_token(user_id: str,
326
270
  errors: list[str] = None,
327
271
  logger: Logger = None) -> str:
328
272
  """
329
- Retrieve the authentication token for user *user_id*.
273
+ Retrieve a Keycloak authentication token for *user_id*.
330
274
 
331
275
  :param user_id: the user's identification
332
- :param errors: incidental error messages
276
+ :param errors: incidental errors
333
277
  :param logger: optional logger
334
- :return: the token for *user_id*, or *None* if error
278
+ :return: the uthentication tokem
335
279
  """
336
280
  global _keycloak_registry
337
281
 
338
- # initialize the return variable
339
- result: str | None = None
340
- return result
282
+ # retrieve the token
283
+ args: dict[str, Any] = {"user-id": user_id}
284
+ return _service_token(registry=_keycloak_registry,
285
+ args=args,
286
+ errors=errors,
287
+ logger=logger)
288
+
289
+
290
+ def keycloak_set_scope(user_id: str,
291
+ scope: str,
292
+ logger: Logger | None) -> None:
293
+ """
294
+ Set the OAuth2 scope of *user_id* to *scope*.
295
+
296
+ :param user_id: the user's identification
297
+ :param scope: the OAuth2 scope to set to the user
298
+ :param logger: optional logger
299
+ """
300
+ global _keycloak_registry
301
+
302
+ # retrieve user data
303
+ user_data: dict[str, Any] = _get_user_data(registry=_keycloak_registry,
304
+ user_id=user_id,
305
+ logger=logger)
306
+ # set the OAuth2 scope
307
+ user_data["oauth-scope"] = scope
308
+ if logger:
309
+ logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypomes_iam
3
- Version: 0.1.8
3
+ Version: 0.1.9
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
@@ -13,6 +13,6 @@ Requires-Python: >=3.12
13
13
  Requires-Dist: cachetools>=6.2.1
14
14
  Requires-Dist: flask>=3.1.2
15
15
  Requires-Dist: pyjwt>=2.10.1
16
- Requires-Dist: pypomes-core>=2.8.0
16
+ Requires-Dist: pypomes-core>=2.8.1
17
17
  Requires-Dist: pypomes-crypto>=0.4.8
18
18
  Requires-Dist: requests>=2.32.5
@@ -0,0 +1,10 @@
1
+ pypomes_iam/__init__.py,sha256=ieysDaKOQc3B50PvChh8DLDG5R3XgbTzX3bU0ekGoUk,760
2
+ pypomes_iam/common_pomes.py,sha256=bLDaoWM5KLccxsNSyiK5UbXRNBgqsQ7TB0Q4Nc72QoI,16415
3
+ pypomes_iam/jusbr_pomes.py,sha256=zpvSfQwteY7aL5noG7ARlLT9yNadfuCMdCZB89yvgI4,10932
4
+ pypomes_iam/keycloak_pomes.py,sha256=2KfAQb_-p8C7cXeKSqjHwMIh1ThncinM8VOLEB_JEng,11381
5
+ pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
6
+ pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
7
+ pypomes_iam-0.1.9.dist-info/METADATA,sha256=i0F_RcCVWNIfAjKhOlPl2t8-oGk-6bDfPPqURYPvJJo,694
8
+ pypomes_iam-0.1.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ pypomes_iam-0.1.9.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
10
+ pypomes_iam-0.1.9.dist-info/RECORD,,
@@ -1,10 +0,0 @@
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,,