pypomes-iam 0.1.7__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
@@ -0,0 +1,423 @@
1
+ import json
2
+ import requests
3
+ import secrets
4
+ import string
5
+ import sys
6
+ from cachetools import Cache
7
+ from datetime import datetime
8
+ from flask import Request
9
+ from logging import Logger
10
+ from pypomes_core import TZ_LOCAL, exc_format
11
+ from typing import Any
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
+
222
+
223
+ def _get_public_key(registry: dict[str, Any],
224
+ logger: Logger | None) -> bytes:
225
+ """
226
+ Obtain the public key used by the *IAM* to sign the authentication tokens.
227
+
228
+ The public key is saved in *registry*.
229
+
230
+ :param registry: the registry holding the authentication data
231
+ :return: the public key, in *DER* format
232
+ """
233
+ from pypomes_crypto import crypto_jwk_convert
234
+
235
+ # initialize the return variable
236
+ result: bytes | None = None
237
+
238
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
239
+ if now > registry["key-expiration"]:
240
+ # obtain a new public key
241
+ url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
242
+ if logger:
243
+ logger.debug(msg=f"GET '{url}'")
244
+ response: requests.Response = requests.get(url=url)
245
+ if response.status_code == 200:
246
+ # request succeeded
247
+ if logger:
248
+ logger.debug(msg=f"GET success, status {response.status_code}")
249
+ reply: dict[str, Any] = response.json()
250
+ result = crypto_jwk_convert(jwk=reply["keys"][0],
251
+ fmt="DER")
252
+ registry["public-key"] = result
253
+ duration: int = registry["key-lifetime"] or 0
254
+ registry["key-expiration"] = now + duration
255
+ elif logger:
256
+ msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
257
+ if hasattr(response, "content") and response.content:
258
+ msg += f", content '{response.content}'"
259
+ logger.error(msg=msg)
260
+ else:
261
+ result = registry["public-key"]
262
+
263
+ return result
264
+
265
+
266
+ def _get_login_timeout(registry: dict[str, Any]) -> int | None:
267
+ """
268
+ Retrieve from *registry* the timeout currently applicable for the login operation.
269
+
270
+ :param registry: the registry holding the authentication data
271
+ :return: the current login timeout, or *None* if none has been set.
272
+ """
273
+ timeout: int = registry.get("client-timeout")
274
+ return timeout if isinstance(timeout, int) and timeout > 0 else None
275
+
276
+
277
+ def _get_user_data(registry: dict[str, Any],
278
+ user_id: str,
279
+ logger: Logger | None) -> dict[str, Any]:
280
+ """
281
+ Retrieve the data for *user_id* from *registry*.
282
+
283
+ If an entry is not found for *user_id* in the registry, it is created.
284
+ It will remain there until the user is logged out.
285
+
286
+ :param registry: the registry holding the authentication data
287
+ :return: the data for *user_id* in the registry
288
+ """
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)
292
+ if not 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
299
+ if logger:
300
+ logger.debug(msg=f"Entry for user '{user_id}' added to the registry")
301
+ elif logger:
302
+ logger.debug(msg=f"Entry for user '{user_id}' obtained from the registry")
303
+
304
+ return result
305
+
306
+
307
+ def _get_user_scope(registry: dict[str, Any],
308
+ user_id: str) -> str | None:
309
+ """
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
315
+ """
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)
407
+ if logger:
408
+ logger.error(msg=err_msg)
409
+
410
+ return result
411
+
412
+
413
+ def _log_init(request: Request) -> str:
414
+ """
415
+ Build the messages for logging the request entry.
416
+
417
+ :param request: the Request object
418
+ :return: the log message
419
+ """
420
+
421
+ params: str = json.dumps(obj=request.args,
422
+ ensure_ascii=False)
423
+ return f"Request {request.method}:{request.path}, params {params}"
@@ -1,17 +1,18 @@
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
- from flask import Flask, Request, Response, redirect, request, jsonify
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
 
10
+ from .common_pomes import (
11
+ _service_login, _service_logout,
12
+ _service_callback, _service_token,
13
+ _get_user_data, _log_init
14
+ )
15
+
15
16
  JUSBR_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_ID")
16
17
  JUSBR_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_CLIENT_SECRET")
17
18
  JUSBR_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_JUSBR_CLIENT_TIMEOUT")
@@ -36,18 +37,22 @@ JUSBR_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_JUSBR_URL_A
36
37
  # "client-secret": <str>,
37
38
  # "client-timeout": <int>,
38
39
  # "public_key": <str>,
40
+ # "key-lifetime": <int>,
39
41
  # "key-expiration": <int>,
40
- # "auth-url": <str>,
42
+ # "base-url": <str>,
41
43
  # "callback-url": <str>,
44
+ # "cache-obj": <FIFOCache>
45
+ # }
46
+ # data in "cache-obj":
47
+ # {
42
48
  # "users": {
43
49
  # "<user-id>": {
44
- # "cache-obj": <Cache>,
45
- # "oauth-scope": <str>,
50
+ # "access-token": <str>
51
+ # "refresh-token": <str>
46
52
  # "access-expiration": <timestamp>,
47
- # data in <Cache>:
48
- # "oauth-state": <str>
49
- # "access-token": <str>
50
- # "refresh-token": <str>
53
+ # "login-expiration": <timestamp>, <-- transient
54
+ # "login-id": <str>, <-- transient
55
+ # "oauth-scope": <str> <-- optional
51
56
  # }
52
57
  # }
53
58
  # }
@@ -66,7 +71,7 @@ def jusbr_setup(flask_app: Flask,
66
71
  token_endpoint: str = JUSBR_ENDPOINT_TOKEN,
67
72
  login_endpoint: str = JUSBR_ENDPOINT_LOGIN,
68
73
  logout_endpoint: str = JUSBR_ENDPOINT_LOGOUT,
69
- auth_url: str = JUSBR_URL_AUTH_BASE,
74
+ base_url: str = JUSBR_URL_AUTH_BASE,
70
75
  callback_url: str = JUSBR_URL_AUTH_CALLBACK,
71
76
  logger: Logger = None) -> None:
72
77
  """
@@ -83,7 +88,7 @@ def jusbr_setup(flask_app: Flask,
83
88
  :param token_endpoint: endpoint for retrieving the JusBR authentication token
84
89
  :param login_endpoint: endpoint for redirecting user to JusBR login page
85
90
  :param logout_endpoint: endpoint for terminating user access to JusBR
86
- :param auth_url: base URL to request the JusBR services
91
+ :param base_url: base URL to request the JusBR services
87
92
  :param callback_url: URL for JusBR to callback on login
88
93
  :param logger: optional logger
89
94
  """
@@ -97,11 +102,11 @@ def jusbr_setup(flask_app: Flask,
97
102
  "client-id": client_id,
98
103
  "client-secret": client_secret,
99
104
  "client-timeout": client_timeout,
100
- "auth-url": auth_url,
105
+ "base-url": base_url,
101
106
  "callback-url": callback_url,
102
107
  "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
103
108
  "key-lifetime": public_key_lifetime,
104
- "users": {}
109
+ "cache-obj": FIFOCache(maxsize=1048576)
105
110
  }
106
111
 
107
112
  # establish the endpoints
@@ -141,33 +146,12 @@ def service_login() -> Response:
141
146
 
142
147
  # log the request
143
148
  if _logger:
144
- msg: str = __log_init(request=request)
145
- _logger.debug(msg=msg)
146
-
147
- # retrieve user data (if not provided, 'user_id' is temporarily set to 'oauth_state'
148
- input_params: dict[str, Any] = request.values
149
- oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
150
- user_id: str = input_params.get("user-id") or input_params.get("login") or oauth_state
151
- # obtain user data
152
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
153
- logger=_logger)
154
- # build redirect url
155
- timeout: int = __get_login_timeout()
156
- safe_cache: Cache
157
- if timeout:
158
- safe_cache = TTLCache(maxsize=16,
159
- ttl=600)
160
- else:
161
- safe_cache = FIFOCache(maxsize=16)
162
- safe_cache["oauth-state"] = oauth_state
163
- user_data["cache-obj"] = safe_cache
164
- auth_url: str = (f"{_jusbr_registry["auth-url"]}/protocol/openid-connect/auth?response_type=code"
165
- f"&client_id={_jusbr_registry["client-id"]}"
166
- f"&redirect_uri={_jusbr_registry["callback-url"]}"
167
- f"&state={oauth_state}")
168
- if user_data.get("oauth-scope"):
169
- auth_url += f"&scope={user_data.get("oauth-scope")}"
149
+ _logger.debug(msg=_log_init(request=request))
170
150
 
151
+ # obtain the redirect URL
152
+ auth_url: str = _service_login(registry=_jusbr_registry,
153
+ args=request.args,
154
+ logger=_logger)
171
155
  # redirect the request
172
156
  result: Response = redirect(location=auth_url)
173
157
 
@@ -192,18 +176,12 @@ def service_logout() -> Response:
192
176
 
193
177
  # log the request
194
178
  if _logger:
195
- msg: str = __log_init(request=request)
196
- _logger.debug(msg=msg)
197
-
198
- # retrieve user id
199
- input_params: dict[str, Any] = request.args
200
- user_id: str = input_params.get("user-id") or input_params.get("login")
179
+ _logger.debug(msg=_log_init(request=request))
201
180
 
202
- # remove user data
203
- if user_id and user_id in _jusbr_registry.get("users"):
204
- _jusbr_registry["users"].pop(user_id)
205
- if _logger:
206
- _logger.debug(f"User '{user_id}' removed from the registry")
181
+ # logout the user
182
+ _service_logout(registry=_jusbr_registry,
183
+ args=request.args,
184
+ logger=_logger)
207
185
 
208
186
  result: Response = Response(status=200)
209
187
 
@@ -220,73 +198,28 @@ def service_callback() -> Response:
220
198
  """
221
199
  Entry point for the callback from JusBR on authentication operation.
222
200
 
223
- :return: the response containing the token, or *NOT AUTHORIZED*
201
+ :return: the response containing the token, or *BAD REQUEST*
224
202
  """
225
203
  global _jusbr_registry
226
- from .token_pomes import token_validate
227
204
 
228
205
  # log the request
229
206
  if _logger:
230
- msg: str = __log_init(request=request)
231
- _logger.debug(msg=msg)
232
-
233
- # validate the OAuth2 state
234
- oauth_state: str = request.args.get("state")
235
- user_id: str | None = None
236
- user_data: dict[str, Any] | None = None
237
- if oauth_state:
238
- for user, data in _jusbr_registry.get("users").items():
239
- safe_cache: Cache = data.get("cache-obj")
240
- if user == oauth_state or \
241
- (safe_cache and oauth_state == safe_cache.get("oauth-state")):
242
- user_id = user
243
- user_data = data
244
- # 'oauth-state' is to be used only once
245
- safe_cache["oauth-state"] = None
246
- break
247
-
248
- # exchange 'code' for the token
249
- token: str | None = None
250
- errors: list[str] = []
251
- if user_data:
252
- code: str = request.args.get("code")
253
- body_data: dict[str, Any] = {
254
- "grant_type": "authorization_code",
255
- "code": code,
256
- "redirec_url": _jusbr_registry.get("callback-url"),
257
- }
258
- token = __post_jusbr(user_data=user_data,
259
- body_data=body_data,
260
- errors=errors,
261
- logger=_logger)
262
- # retrieve the token's claims
263
- if not errors:
264
- token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
265
- issuer=_jusbr_registry.get("auth-url"),
266
- public_key=_jusbr_registry.get("public_key"),
267
- errors=errors,
268
- logger=_logger)
269
- if not errors:
270
- token_user: str = token_claims["payload"].get("preferred_username")
271
- if user_id == oauth_state:
272
- user_id = token_user
273
- _jusbr_registry["users"][user_id] = _jusbr_registry["users"].pop(oauth_state)
274
- elif token_user != user_id:
275
- errors.append(f"Token was issued to user '{token_user}'")
276
- else:
277
- msg: str = "Unknown OAuth2 code received"
278
- if __get_login_timeout():
279
- msg += " - possible operation timeout"
280
- errors.append(msg)
207
+ _logger.debug(msg=_log_init(request=request))
281
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)
282
215
  result: Response
283
216
  if errors:
284
217
  result = jsonify({"errors": "; ".join(errors)})
285
218
  result.status_code = 400
286
219
  else:
287
220
  result = jsonify({
288
- "user_id": user_id,
289
- "access_token": token})
221
+ "user_id": token_data[0],
222
+ "access_token": token_data[1]})
290
223
 
291
224
  # log the response
292
225
  if _logger:
@@ -303,17 +236,18 @@ def service_token() -> Response:
303
236
 
304
237
  :return: the response containing the token, or *UNAUTHORIZED*
305
238
  """
239
+ global _jusbr_registry
240
+
306
241
  # log the request
307
242
  if _logger:
308
- msg: str = __log_init(request=request)
309
- _logger.debug(msg=msg)
243
+ _logger.debug(msg=_log_init(request=request))
310
244
 
311
245
  # retrieve the token
312
- input_params: dict[str, Any] = request.args
313
- user_id: str = input_params.get("user-id") or input_params.get("login")
314
246
  errors: list[str] = []
315
- token: str = jusbr_get_token(user_id=user_id,
316
- logger=_logger)
247
+ token: str = _service_token(registry=_jusbr_registry,
248
+ args=request.args,
249
+ errors=errors,
250
+ logger=_logger)
317
251
  result: Response
318
252
  if token:
319
253
  result = jsonify({"token": token})
@@ -332,53 +266,26 @@ def jusbr_get_token(user_id: str,
332
266
  errors: list[str] = None,
333
267
  logger: Logger = None) -> str:
334
268
  """
335
- Retrieve the authentication token for user *user_id*.
269
+ Retrieve a JusBR authentication token for *user_id*.
336
270
 
337
271
  :param user_id: the user's identification
338
- :param errors: incidental error messages
272
+ :param errors: incidental errors
339
273
  :param logger: optional logger
340
- :return: the token for *user_id*, or *None* if error
274
+ :return: the uthentication tokem
341
275
  """
342
276
  global _jusbr_registry
343
277
 
344
- # initialize the return variable
345
- result: str | None = None
346
-
347
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
348
- logger=logger)
349
- safe_cache: Cache = user_data.get("cache-obj")
350
- if safe_cache:
351
- access_expiration: int = user_data.get("access-expiration")
352
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
353
- if now < access_expiration:
354
- result = safe_cache.get("access-token")
355
- else:
356
- # access token has expired
357
- safe_cache["access-token"] = None
358
- refresh_token: str = safe_cache.get("refresh-token")
359
- if refresh_token:
360
- body_data: dict[str, str] = {
361
- "grant_type": "refresh_token",
362
- "refresh_token": refresh_token
363
- }
364
- result = __post_jusbr(user_data=user_data,
365
- body_data=body_data,
366
- errors=errors,
367
- logger=logger)
368
-
369
- elif logger or isinstance(errors, list):
370
- err_msg: str = f"User '{user_id}' not authenticated with JusBR"
371
- if isinstance(errors, list):
372
- errors.append(err_msg)
373
- if logger:
374
- logger.error(msg=err_msg)
375
-
376
- 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)
377
284
 
378
285
 
379
286
  def jusbr_set_scope(user_id: str,
380
287
  scope: str,
381
- logger: Logger | None) -> None:
288
+ logger: Logger = None) -> None:
382
289
  """
383
290
  Set the OAuth2 scope of *user_id* to *scope*.
384
291
 
@@ -389,173 +296,10 @@ def jusbr_set_scope(user_id: str,
389
296
  global _jusbr_registry
390
297
 
391
298
  # retrieve user data
392
- user_data: dict[str, Any] = __get_user_data(user_id=user_id,
393
- logger=logger)
299
+ user_data: dict[str, Any] = _get_user_data(registry=_jusbr_registry,
300
+ user_id=user_id,
301
+ logger=logger)
394
302
  # set the OAuth2 scope
395
303
  user_data["oauth-scope"] = scope
396
304
  if logger:
397
- logger.debug(f"Scope for user '{user_id}' set to '{scope}'")
398
-
399
-
400
- def __get_public_key(url: str,
401
- logger: Logger | None) -> str:
402
- """
403
- Obtain the public key used by JusBR to sign the authentication tokens.
404
-
405
- :param url: the base URL to request the public key
406
- :return: the public key, in *PEM* format
407
- """
408
- from pypomes_crypto import crypto_jwk_convert
409
- global _jusbr_registry
410
-
411
- # initialize the return variable
412
- result: str | None = None
413
-
414
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
415
- if now > _jusbr_registry.get("key-expiration"):
416
- # obtain a new public key
417
- url: str = f"{url}/protocol/openid-connect/certs"
418
- response: requests.Response = requests.get(url=url)
419
- if response.status_code == 200:
420
- # request succeeded
421
- reply: dict[str, Any] = response.json()
422
- result = crypto_jwk_convert(jwk=reply["keys"][0],
423
- fmt="PEM")
424
- _jusbr_registry["public-key"] = result
425
- duration: int = _jusbr_registry.get("key-lifetime") or 0
426
- _jusbr_registry["key-expiration"] = now + duration
427
- elif logger:
428
- logger.error(msg=f"GET '{url}': failed, "
429
- f"status {response.status_code}, reason '{response.reason}'")
430
- else:
431
- result = _jusbr_registry.get("public-key")
432
-
433
- return result
434
-
435
-
436
- def __get_login_timeout() -> int | None:
437
- """
438
- Retrieve the timeout currently applicable for the login operation.
439
-
440
- :return: the current login timeout, or *None* if none has been set.
441
- """
442
- global _jusbr_registry
443
-
444
- timeout: int = _jusbr_registry.get("client-timeout")
445
- return timeout if isinstance(timeout, int) and timeout > 0 else None
446
-
447
-
448
- def __get_user_data(user_id: str,
449
- logger: Logger | None) -> dict[str, Any]:
450
- """
451
- Retrieve the data for *user_id* from the registry.
452
-
453
- If an entry is not found for *user_id* in the registry, it is created.
454
- It will remain there until the user is logged out.
455
-
456
- :param user_id:
457
- :return: the data for *user_id* in the registry
458
- """
459
- global _jusbr_registry
460
-
461
- result: dict[str, Any] = _jusbr_registry["users"].get(user_id)
462
- if not result:
463
- result = {"access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp())}
464
- _jusbr_registry["users"][user_id] = result
465
- if logger:
466
- logger.debug(f"Entry for user '{user_id}' added to registry")
467
-
468
- return result
469
-
470
-
471
- def __post_jusbr(user_data: dict[str, Any],
472
- body_data: dict[str, Any],
473
- errors: list[str] | None,
474
- logger: Logger | None) -> str | None:
475
- """
476
- Send a POST request to JusBR to obtain the authentication token data, and return the access token.
477
-
478
- For code for token exchange, *body_data* will have the attributes
479
- - "grant_type": "authorization_code"
480
- - "code": <16-character-random-code>
481
- - "redirect_uri": <callback-url>
482
- For token refresh, *body_data* will have the attributes
483
- - "grant_type": "refresh_token"
484
- - "refresh_token": <current-refresh-token>
485
-
486
- If the operation is successful, the token data is stored in the registry.
487
- Otherwise, *errors* will contain the appropriate error message.
488
-
489
- :param user_data: the user's data in the registry
490
- :param body_data: the data to send in the body of the request
491
- :param errors: incidental errors
492
- :param logger: optional logger
493
- :return: the access token obtained, or *None* if error
494
- """
495
- global _jusbr_registry
496
-
497
- # initialize the return variable
498
- result: str | None = None
499
-
500
- # complete the data to send in body of request
501
- body_data["client_id"] = _jusbr_registry.get("client-id")
502
- client_secret: str = _jusbr_registry.get("client-secret")
503
- if client_secret:
504
- body_data["client_secret"] = client_secret
505
-
506
- # obtain the token
507
- err_msg: str | None = None
508
- safe_cache: Cache = user_data.get("cache-obj")
509
- url: str = _jusbr_registry.get("auth-url") + "/protocol/openid-connect/token"
510
- now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
511
- try:
512
- # JusBR return on a token request:
513
- # {
514
- # "token_type": "Bearer",
515
- # "access_token": <str>,
516
- # "expires_in": <number-of-seconds>,
517
- # "refresh_token": <str>,
518
- # }
519
- response: requests.Response = requests.post(url=url,
520
- data=body_data)
521
- if response.status_code == 200:
522
- # request succeeded
523
- reply: dict[str, Any] = response.json()
524
- result = reply.get("access_token")
525
- safe_cache: Cache = FIFOCache(maxsize=1024)
526
- safe_cache["access-token"] = result
527
- # on token refresh, keep current refresh token if a new one is not provided
528
- safe_cache["refresh-token"] = reply.get("refresh_token") or body_data.get("refresh_token")
529
- user_data["cache-obj"] = safe_cache
530
- user_data["access-expiration"] = now + reply.get("expires_in")
531
- if logger:
532
- logger.debug(msg=f"POST '{url}': status {response.status_code}")
533
- else:
534
- # request resulted in error
535
- err_msg = (f"POST '{url}': failed, "
536
- f"status {response.status_code}, reason '{response.reason}'")
537
- if hasattr(response, "content") and response.content:
538
- err_msg += f", content '{response.content}'"
539
- if response.status_code == 401 and "refresh_token" in body_data:
540
- # refresh token is no longer valid
541
- safe_cache["refresh-token"] = None
542
- except Exception as e:
543
- # the operation raised an exception
544
- err_msg = exc_format(exc=e,
545
- exc_info=sys.exc_info())
546
- err_msg = f"POST '{url}': error '{err_msg}'"
547
-
548
- if err_msg:
549
- if isinstance(errors, list):
550
- errors.append(err_msg)
551
- if logger:
552
- logger.error(msg=err_msg)
553
-
554
- return result
555
-
556
-
557
- def __log_init(request: Request) -> str:
558
-
559
- params: str = json.dumps(obj=request.args,
560
- ensure_ascii=False)
561
- return f"Request {request.method}:{request.path}, params {params}"
305
+ logger.debug(msg=f"Scope for user '{user_id}' set to '{scope}'")
@@ -1,16 +1,18 @@
1
- # import requests
2
- # import secrets
3
- # import string
4
- # import sys
5
- # from cachetools import Cache, FIFOCache, TTLCache
6
- # from datetime import datetime
1
+ from cachetools import FIFOCache
2
+ from datetime import datetime
7
3
  from flask import Flask, Response, redirect, request, jsonify
8
4
  from logging import Logger
9
5
  from pypomes_core import (
10
- APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str, exc_format
6
+ APP_PREFIX, TZ_LOCAL, env_get_int, env_get_str
11
7
  )
12
8
  from typing import Any, Final
13
9
 
10
+ from .common_pomes import (
11
+ _service_login, _service_logout,
12
+ _service_callback, _service_token,
13
+ _get_user_data, _log_init
14
+ )
15
+
14
16
  KEYCLOAK_CLIENT_ID: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_ID")
15
17
  KEYCLOAK_CLIENT_SECRET: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_SECRET")
16
18
  KEYCLOAK_CLIENT_TIMEOUT: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_CLIENT_TIMEOUT")
@@ -24,6 +26,8 @@ KEYCLOAK_ENDPOINT_LOGOUT: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_E
24
26
  KEYCLOAK_ENDPOINT_TOKEN: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_ENDPOINT_TOKEN",
25
27
  def_value="/iam/keycloak:get-token")
26
28
 
29
+ KEYCLOAK_PUBLIC_KEY_LIFETIME: Final[int] = env_get_int(key=f"{APP_PREFIX}_KEYCLOAK_PUBLIC_KEY_LIFETIME",
30
+ def_value=86400) # 24 hours
27
31
  KEYCLOAK_REALM: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_REALM")
28
32
  KEYCLOAK_URL_AUTH_BASE: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_BASE")
29
33
  KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK_URL_AUTH_CALLBACK")
@@ -33,30 +37,27 @@ KEYCLOAK_URL_AUTH_CALLBACK: Final[str] = env_get_str(key=f"{APP_PREFIX}_KEYCLOAK
33
37
  # "client-id": <str>,
34
38
  # "client-secret": <str>,
35
39
  # "client-timeout": <int>,
36
- # "realm": <str>,
37
- # "auth-url": <str>,
40
+ # "public_key": <str>,
41
+ # "key-lifetime": <int>,
42
+ # "key-expiration": <int>,
43
+ # "base-url": <str>,
38
44
  # "callback-url": <str>,
45
+ # "safe-cache": <FIFOCache>
46
+ # }
47
+ # data in "safe-cache":
48
+ # {
39
49
  # "users": {
40
50
  # "<user-id>": {
41
- # "cache-obj": <Cache>,
42
- # "oauth-scope": <str>,
51
+ # "access-token": <str>
52
+ # "refresh-token": <str>
43
53
  # "access-expiration": <timestamp>,
44
- # data in <Cache>:
45
- # "oauth-state": <str>
46
- # "access-token": <str>
47
- # "refresh-token": <str>
54
+ # "login-expiration": <timestamp>, <-- transient
55
+ # "login-id": <str>, <-- transient
56
+ # "oauth-scope": <str> <-- optional
48
57
  # }
49
58
  # }
50
59
  # }
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
+ _keycloak_registry: dict[str, Any] = {}
60
61
 
61
62
  # dafault logger
62
63
  _logger: Logger | None = None
@@ -66,12 +67,13 @@ def keycloak_setup(flask_app: Flask,
66
67
  client_id: str = KEYCLOAK_CLIENT_ID,
67
68
  client_secret: str = KEYCLOAK_CLIENT_SECRET,
68
69
  client_timeout: int = KEYCLOAK_CLIENT_TIMEOUT,
70
+ public_key_lifetime: int = KEYCLOAK_PUBLIC_KEY_LIFETIME,
69
71
  realm: str = KEYCLOAK_REALM,
70
72
  callback_endpoint: str = KEYCLOAK_ENDPOINT_CALLBACK,
71
73
  token_endpoint: str = KEYCLOAK_ENDPOINT_TOKEN,
72
74
  login_endpoint: str = KEYCLOAK_ENDPOINT_LOGIN,
73
75
  logout_endpoint: str = KEYCLOAK_ENDPOINT_LOGOUT,
74
- auth_url: str = KEYCLOAK_URL_AUTH_BASE,
76
+ base_url: str = KEYCLOAK_URL_AUTH_BASE,
75
77
  callback_url: str = KEYCLOAK_URL_AUTH_CALLBACK,
76
78
  logger: Logger = None) -> None:
77
79
  """
@@ -83,12 +85,13 @@ def keycloak_setup(flask_app: Flask,
83
85
  :param client_id: the client's identification with JusBR
84
86
  :param client_secret: the client's password with JusBR
85
87
  :param client_timeout: timeout for login authentication (in seconds,defaults to no timeout)
86
- :param realm: the Keycloak reals
88
+ :param public_key_lifetime: how long to use Keycloak's public key, before refreshing it (in seconds)
89
+ :param realm: the Keycloak realm
87
90
  :param callback_endpoint: endpoint for the callback from JusBR
88
91
  :param token_endpoint: endpoint for retrieving the JusBR authentication token
89
92
  :param login_endpoint: endpoint for redirecting user to JusBR login page
90
93
  :param logout_endpoint: endpoint for terminating user access to JusBR
91
- :param auth_url: base URL to request the JusBR services
94
+ :param base_url: base URL to request the JusBR services
92
95
  :param callback_url: URL for Keycloak to callback on login
93
96
  :param logger: optional logger
94
97
  """
@@ -99,15 +102,16 @@ def keycloak_setup(flask_app: Flask,
99
102
  _logger = logger
100
103
 
101
104
  # configure the JusBR registry
102
- _keycloak_registry.update({
105
+ _keycloak_registry = {
103
106
  "client-id": client_id,
104
107
  "client-secret": client_secret,
105
108
  "client-timeout": client_timeout,
106
- "realm": realm,
107
- "auth-url": auth_url,
109
+ "base-url": f"{base_url}/realms/{realm}",
108
110
  "callback-url": callback_url,
109
- "users": []
110
- })
111
+ "key-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
112
+ "key-lifetime": public_key_lifetime,
113
+ "safe-cache": FIFOCache(maxsize=1048576)
114
+ }
111
115
 
112
116
  # establish the endpoints
113
117
  if token_endpoint:
@@ -144,28 +148,52 @@ def service_login() -> Response:
144
148
  """
145
149
  global _keycloak_registry
146
150
 
147
- # retrieve user id
148
- input_params: dict[str, Any] = request.args
149
- _user_id: str = input_params.get("user-id") or input_params.get("login")
150
- return Response()
151
+ # log the request
152
+ if _logger:
153
+ _logger.debug(msg=_log_init(request=request))
154
+
155
+ # obtain the redirect URL
156
+ auth_url: str = _service_login(registry=_keycloak_registry,
157
+ args=request.args,
158
+ logger=_logger)
159
+ # redirect the request
160
+ result: Response = redirect(location=auth_url)
161
+
162
+ # log the response
163
+ if _logger:
164
+ _logger.debug(msg=f"Response {result}")
165
+
166
+ return result
151
167
 
152
168
 
153
169
  # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
154
170
  # methods=["GET"])
155
171
  def service_logout() -> Response:
156
172
  """
157
- Entry point for the JusBR logout service.
173
+ Entry point for the Keycloak logout service.
158
174
 
159
- Remove all data associating the user with JusBR from the registry.
175
+ Remove all data associating the user with Keycloak from the registry.
160
176
 
161
177
  :return: response *OK*
162
178
  """
163
179
  global _keycloak_registry
164
180
 
165
- # retrieve user id
166
- input_params: dict[str, Any] = request.args
167
- _user_id: str = input_params.get("user-id") or input_params.get("login")
168
- return Response()
181
+ # log the request
182
+ if _logger:
183
+ _logger.debug(msg=_log_init(request=request))
184
+
185
+ # logout the user
186
+ _service_logout(registry=_keycloak_registry,
187
+ args=request.args,
188
+ logger=_logger)
189
+
190
+ result: Response = Response(status=200)
191
+
192
+ # log the response
193
+ if _logger:
194
+ _logger.debug(msg=f"Response {result}")
195
+
196
+ return result
169
197
 
170
198
 
171
199
  # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
@@ -177,7 +205,31 @@ def service_callback() -> Response:
177
205
  :return: the response containing the token, or *NOT AUTHORIZED*
178
206
  """
179
207
  global _keycloak_registry
180
- return Response()
208
+
209
+ # log the request
210
+ if _logger:
211
+ _logger.debug(msg=_log_init(request=request))
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)
219
+ result: Response
220
+ if errors:
221
+ result = jsonify({"errors": "; ".join(errors)})
222
+ result.status_code = 400
223
+ else:
224
+ result = jsonify({
225
+ "user_id": token_data[0],
226
+ "access_token": token_data[1]})
227
+
228
+ # log the response
229
+ if _logger:
230
+ _logger.debug(msg=f"Response {result}")
231
+
232
+ return result
181
233
 
182
234
 
183
235
  # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
@@ -188,26 +240,70 @@ def service_token() -> Response:
188
240
 
189
241
  :return: the response containing the token, or *UNAUTHORIZED*
190
242
  """
191
- # retrieve user id
192
- input_params: dict[str, Any] = request.args
193
- _user_id: str = input_params.get("user-id") or input_params.get("login")
194
- return Response()
243
+ global _keycloak_registry
244
+
245
+ # log the request
246
+ if _logger:
247
+ _logger.debug(msg=_log_init(request=request))
248
+
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
195
267
 
196
268
 
197
269
  def keycloak_get_token(user_id: str,
198
270
  errors: list[str] = None,
199
271
  logger: Logger = None) -> str:
200
272
  """
201
- Retrieve the authentication token for user *user_id*.
273
+ Retrieve a Keycloak authentication token for *user_id*.
202
274
 
203
275
  :param user_id: the user's identification
204
- :param errors: incidental error messages
276
+ :param errors: incidental errors
205
277
  :param logger: optional logger
206
- :return: the token for *user_id*, or *None* if error
278
+ :return: the uthentication tokem
207
279
  """
208
280
  global _keycloak_registry
209
281
 
210
- # initialize the return variable
211
- result: str | None = None
212
- 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
213
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.7
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,9 +0,0 @@
1
- pypomes_iam/__init__.py,sha256=lHnqNqW1stQjcM6cr9wf3GGnw5_zGf1HN3zyHGb8PCA,577
2
- pypomes_iam/jusbr_pomes.py,sha256=5igQW95f-Zv59w3tv8_wOfHfs6Lv2PB6-gLHpZFIc7s,21525
3
- pypomes_iam/keycloak_pomes.py,sha256=4vLaYQNY9S9xHmyiv9Ii8jgL5jA1-MgAgWduicCyofw,8059
4
- pypomes_iam/provider_pomes.py,sha256=eP8XzjTUEpwejTkO0wmDiqKjqbIEOzRNCR2ju5E15og,5856
5
- pypomes_iam/token_pomes.py,sha256=McjKB8omCjuicenwvDVPiWYu3-7gQeLg1AzgAVKK32M,4309
6
- pypomes_iam-0.1.7.dist-info/METADATA,sha256=awYfm3GmoffocFbkPdNGnAecPQoleAcLCiCpD65LDZw,694
7
- pypomes_iam-0.1.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- pypomes_iam-0.1.7.dist-info/licenses/LICENSE,sha256=YvUELgV8qvXlaYsy9hXG5EW3Bmsrkw-OJmmILZnonAc,1086
9
- pypomes_iam-0.1.7.dist-info/RECORD,,