pypomes-iam 0.1.8__py3-none-any.whl → 0.2.0__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:
@@ -0,0 +1,176 @@
1
+ from flask import Response, redirect, request, jsonify
2
+ from logging import Logger
3
+ from typing import Any
4
+
5
+ from .common_pomes import (
6
+ _service_login, _service_logout,
7
+ _service_callback, _service_token, _log_init
8
+ )
9
+ from .jusbr_pomes import _jusbr_logger, _jusbr_registry
10
+ from .keycloak_pomes import _keycloak_logger, _keycloak_registry
11
+
12
+
13
+ # @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:login
14
+ # methods=["GET"])
15
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
16
+ # methods=["GET"])
17
+ def service_login() -> Response:
18
+ """
19
+ Entry point for the JusBR login service.
20
+
21
+ Redirect the request to the JusBR authentication page, with the appropriate parameters.
22
+
23
+ :return: the response from the redirect operation
24
+ """
25
+ logger: Logger
26
+ registry: dict[str, Any]
27
+ if request.endpoint == "jusbr-login":
28
+ logger = _jusbr_logger
29
+ registry = _jusbr_registry
30
+ else:
31
+ logger = _keycloak_logger
32
+ registry = _keycloak_registry
33
+
34
+ # log the request
35
+ if logger:
36
+ logger.debug(msg=_log_init(request=request))
37
+
38
+ # obtain the redirect URL
39
+ auth_url: str = _service_login(registry=registry,
40
+ args=request.args,
41
+ logger=logger)
42
+ # redirect the request
43
+ result: Response = redirect(location=auth_url)
44
+
45
+ # log the response
46
+ if logger:
47
+ logger.debug(msg=f"Response {result}")
48
+
49
+ return result
50
+
51
+
52
+ # @flask_app.route(rule=<logout_endpoint>, # JUSBR_LOGOUT_ENDPOINT: /iam/jusbr:logout
53
+ # methods=["GET"])
54
+ # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGOUT_ENDPOINT: /iam/keycloak:logout
55
+ # methods=["GET"])
56
+ def service_logout() -> Response:
57
+ """
58
+ Entry point for the JusBR logout service.
59
+
60
+ Remove all data associating the user with JusBR from the registry.
61
+
62
+ :return: response *OK*
63
+ """
64
+ logger: Logger
65
+ registry: dict[str, Any]
66
+ if request.endpoint == "jusbr-logout":
67
+ logger = _jusbr_logger
68
+ registry = _jusbr_registry
69
+ else:
70
+ logger = _keycloak_logger
71
+ registry = _keycloak_registry
72
+
73
+ # log the request
74
+ if logger:
75
+ logger.debug(msg=_log_init(request=request))
76
+
77
+ # logout the user
78
+ _service_logout(registry=registry,
79
+ args=request.args,
80
+ logger=logger)
81
+
82
+ result: Response = Response(status=200)
83
+
84
+ # log the response
85
+ if logger:
86
+ logger.debug(msg=f"Response {result}")
87
+
88
+ return result
89
+
90
+
91
+ # @flask_app.route(rule=<callback_endpoint>, # JUSBR_CALLBACK_ENDPOINT: /iam/jusbr:callback
92
+ # methods=["GET", "POST"])
93
+ # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
94
+ # methods=["POST"])
95
+ def service_callback() -> Response:
96
+ """
97
+ Entry point for the callback from JusBR on authentication operation.
98
+
99
+ :return: the response containing the token, or *BAD REQUEST*
100
+ """
101
+ logger: Logger
102
+ registry: dict[str, Any]
103
+ if request.endpoint == "jusbr-callback":
104
+ logger = _jusbr_logger
105
+ registry = _jusbr_registry
106
+ else:
107
+ logger = _keycloak_logger
108
+ registry = _keycloak_registry
109
+
110
+ # log the request
111
+ if logger:
112
+ logger.debug(msg=_log_init(request=request))
113
+
114
+ # process the callback operation
115
+ errors: list[str] = []
116
+ token_data: tuple[str, str] = _service_callback(registry=registry,
117
+ args=request.args,
118
+ errors=errors,
119
+ logger=logger)
120
+ result: Response
121
+ if errors:
122
+ result = jsonify({"errors": "; ".join(errors)})
123
+ result.status_code = 400
124
+ else:
125
+ result = jsonify({
126
+ "user_id": token_data[0],
127
+ "access_token": token_data[1]})
128
+
129
+ # log the response
130
+ if logger:
131
+ logger.debug(msg=f"Response {result}")
132
+
133
+ return result
134
+
135
+
136
+ # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
137
+ # methods=["GET"])
138
+ # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
139
+ # methods=["GET"])
140
+ def service_token() -> Response:
141
+ """
142
+ Entry point for retrieving the JusBR token.
143
+
144
+ :return: the response containing the token, or *UNAUTHORIZED*
145
+ """
146
+ logger: Logger
147
+ registry: dict[str, Any]
148
+ if request.endpoint == "jusbr-token":
149
+ logger = _jusbr_logger
150
+ registry = _jusbr_registry
151
+ else:
152
+ logger = _keycloak_logger
153
+ registry = _keycloak_registry
154
+
155
+ # log the request
156
+ if logger:
157
+ logger.debug(msg=_log_init(request=request))
158
+
159
+ # retrieve the token
160
+ errors: list[str] = []
161
+ token: str = _service_token(registry=registry,
162
+ args=request.args,
163
+ errors=errors,
164
+ logger=logger)
165
+ result: Response
166
+ if token:
167
+ result = jsonify({"token": token})
168
+ else:
169
+ result = Response("; ".join(errors))
170
+ result.status_code = 401
171
+
172
+ # log the response
173
+ if logger:
174
+ logger.debug(msg=f"Response {result}")
175
+
176
+ return result