pypomes-iam 0.3.0__py3-none-any.whl → 0.3.2__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/iam_pomes.py CHANGED
@@ -1,216 +1,326 @@
1
- import json
2
- import requests
3
- from flask import Response, request, jsonify
1
+ import secrets
2
+ import string
3
+ import sys
4
+ from cachetools import Cache
5
+ from datetime import datetime
4
6
  from logging import Logger
7
+ from pypomes_core import TZ_LOCAL
5
8
  from typing import Any
6
9
 
7
10
  from .iam_common import (
8
- IAM_SERVERS, IamServer,
9
- _service_login, _service_logout,
10
- _service_callback, _service_token, _log_init
11
+ IamServer,
12
+ _register_logger, _post_for_token,
13
+ _get_iam_cache, _get_iam_registry,
14
+ _get_login_timeout, _get_user_data, _get_public_key
11
15
  )
12
16
 
13
17
 
14
- # @flask_app.route(rule=<login_endpoint>, # JUSBR_LOGIN_ENDPOINT: /iam/jusbr:login
15
- # methods=["GET"])
16
- # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGIN_ENDPOINT: /iam/keycloak:logout
17
- # methods=["GET"])
18
- def service_login() -> Response:
18
+ def register_logger(logger: Logger) -> None:
19
19
  """
20
- Entry point for the JusBR login service.
20
+ Register the logger for IAM operations.
21
21
 
22
- Redirect the request to the JusBR authentication page, with the appropriate parameters.
23
-
24
- :return: the response from the redirect operation
22
+ :param logger: the logger to be registered
25
23
  """
26
- # retrieve logger and registry
27
- registry: dict[str, Any] = __get_iam_registry(endpoint=request.endpoint)
28
- logger: Logger = registry["logger"]
29
-
30
- # log the request
31
- if logger:
32
- logger.debug(msg=_log_init(request=request))
24
+ _register_logger(logger=logger)
33
25
 
34
- # obtain the login URL
35
- login_data: dict[str, str] = _service_login(registry=registry,
36
- args=request.args,
37
- logger=logger)
38
- result = jsonify(login_data)
39
26
 
40
- # log the response
41
- if logger:
42
- logger.debug(msg=f"Response {result}")
27
+ def user_login(iam_server: IamServer,
28
+ args: dict[str, Any],
29
+ errors: list[str] = None,
30
+ logger: Logger = None) -> dict[str, str]:
31
+ """
32
+ Build the callback URL for redirecting the request to *iam_server*'s authentication page.
43
33
 
34
+ :param iam_server: the reference registered *IAM* server
35
+ :param args: the arguments passed when requesting the service
36
+ :param errors: incidental error messages
37
+ :param logger: optional logger
38
+ :return: the callback URL, with the appropriate parameters, of *None* if error
39
+ """
40
+ # initialize the return variable
41
+ result: dict[str, str] | None = None
42
+
43
+ # obtain the optional user's identification
44
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
45
+
46
+ # build the user data
47
+ # ('oauth_state' is a randomly-generated string, thus 'user_data' is always a new entry)
48
+ oauth_state: str = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16))
49
+ user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
50
+ user_id=oauth_state,
51
+ errors=errors,
52
+ logger=logger)
53
+ if user_data:
54
+ user_data["login-id"] = user_id
55
+ timeout: int = _get_login_timeout(iam_server=iam_server,
56
+ errors=errors,
57
+ logger=logger)
58
+ if not errors:
59
+ user_data["login-expiration"] = int(datetime.now(tz=TZ_LOCAL).timestamp()) + timeout if timeout else None
60
+ redirect_uri: str = args.get("redirect-uri")
61
+
62
+ # build the login url
63
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
64
+ errors=errors,
65
+ logger=logger)
66
+ if registry:
67
+ registry["redirect-uri"] = redirect_uri
68
+ result = {"login-url": (f"{registry["base-url"]}/protocol/openid-connect/auth"
69
+ f"?response_type=code&scope=openid"
70
+ f"&client_id={registry["client-id"]}"
71
+ f"&redirect_uri={redirect_uri}"
72
+ f"&state={oauth_state}")}
44
73
  return result
45
74
 
46
75
 
47
- # @flask_app.route(rule=<logout_endpoint>, # JUSBR_LOGOUT_ENDPOINT: /iam/jusbr:logout
48
- # methods=["GET"])
49
- # @flask_app.route(rule=<login_endpoint>, # KEYCLOAK_LOGOUT_ENDPOINT: /iam/keycloak:logout
50
- # methods=["GET"])
51
- def service_logout() -> Response:
76
+ def user_logout(iam_server: IamServer,
77
+ args: dict[str, Any],
78
+ errors: list[str] = None,
79
+ logger: Logger = None) -> None:
52
80
  """
53
- Entry point for the JusBR logout service.
81
+ Logout the user, by removing all data associating it from *iam_server*'s registry.
54
82
 
55
- Remove all data associating the user with JusBR from the registry.
83
+ The user is identified by the attribute *user-id*, *user_id*, or "login", provided in *args*.
84
+ If unsuccessful, this operation fails silently, unless an error has ocurred.
56
85
 
57
- :return: response *OK*
86
+ :param iam_server: the reference registered *IAM* server
87
+ :param args: the arguments passed when requesting the service
88
+ :param errors: incidental error messages
89
+ :param logger: optional logger
58
90
  """
59
- # retrieve logger and registry
60
- registry: dict[str, Any] = __get_iam_registry(endpoint=request.endpoint)
61
- logger: Logger = registry["logger"]
62
-
63
- # log the request
64
- if logger:
65
- logger.debug(msg=_log_init(request=request))
66
-
67
- # logout the user
68
- _service_logout(registry=registry,
69
- args=request.args,
70
- logger=logger)
71
-
72
- result: Response = Response(status=200)
73
-
74
- # log the response
75
- if logger:
76
- logger.debug(msg=f"Response {result}")
77
-
78
- return result
79
-
80
-
81
- # @flask_app.route(rule=<callback_endpoint>, # JUSBR_CALLBACK_ENDPOINT: /iam/jusbr:callback
82
- # methods=["GET", "POST"])
83
- # @flask_app.route(rule=<callback_endpoint>, # KEYCLOAK_CALLBACK_ENDPOINT: /iam/keycloak:callback
84
- # methods=["POST"])
85
- def service_callback() -> Response:
91
+ # obtain the user's identification
92
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
93
+
94
+ if user_id:
95
+ # retrieve the IAM server's cache storage
96
+ cache: Cache = _get_iam_cache(iam_server=iam_server,
97
+ errors=errors,
98
+ logger=logger)
99
+ if cache:
100
+ users: dict[str, dict[str, Any]] = cache.get("users") or {}
101
+ if user_id in users:
102
+ users.pop(user_id)
103
+ if logger:
104
+ logger.debug(msg=f"User '{user_id}' removed from {iam_server}'s registry")
105
+
106
+
107
+ def user_token(iam_server: IamServer,
108
+ args: dict[str, Any],
109
+ errors: list[str] = None,
110
+ logger: Logger = None) -> str:
86
111
  """
87
- Entry point for the callback from JusBR on authentication operation.
112
+ Retrieve the authentication token for the user, from *iam_server*.
88
113
 
89
- This callback is typically invoked from a front-end application after a successful login at the
90
- JusBR login page, forwarding the data received.
114
+ The user is identified by the attribute *user-id*, *user_id*, or "login", provided in *args*.
91
115
 
92
- :return: the response containing the token, or *BAD REQUEST*
116
+ :param iam_server: the reference registered *IAM* server
117
+ :param args: the arguments passed when requesting the service
118
+ :param errors: incidental error messages
119
+ :param logger: optional logger
120
+ :return: the token for *user_id*, or *None* if error
93
121
  """
94
- # retrieve logger and registry
95
- registry: dict[str, Any] = __get_iam_registry(endpoint=request.endpoint)
96
- logger: Logger = registry["logger"]
97
-
98
- # log the request
99
- if logger:
100
- logger.debug(msg=_log_init(request=request))
101
-
102
- # process the callback operation
103
- errors: list[str] = []
104
- token_data: tuple[str, str] = _service_callback(registry=registry,
105
- args=request.args,
106
- errors=errors,
107
- logger=logger)
108
- # exchange the token
109
- if request.endpoint.startswith("jusbr-"):
110
- keycloak_registry: dict[str, Any] = __get_iam_registry(endpoint="keycloak-token")
111
- payload: dict[str, str] = {
112
- "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
113
- "subject_token": token_data[1],
114
- "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
115
- "client_id": keycloak_registry["client-id"],
116
- "client_secret": keycloak_registry["client-secret"],
117
- "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
118
- "audience": token_data[0],
119
- "subject_issuer": "oidc"
120
- }
121
- exchange_url = f"{keycloak_registry['base-url']}/protocol/openid-connect/token"
122
- if logger:
123
- logger.debug(msg=f"POST '{exchange_url}', data {json.dumps(obj=payload,
124
- ensure_ascii=False)}")
125
- headers: dict[str, str] = {
126
- "Content-Type": "application/x-www-form-urlencoded"
127
- }
128
- response: requests.Response = requests.post(url=exchange_url,
129
- data=payload,
130
- headers=headers)
131
- if response.status_code == 200:
132
- # request succeeded
133
- if logger:
134
- logger.debug(msg=f"POST success, status {response.status_code}")
135
- reply: dict[str, Any] = response.json()
136
- token_data = (token_data[0], reply.get("access_token"))
122
+ # initialize the return variable
123
+ result: str | None = None
124
+
125
+ # obtain the user's identification
126
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
127
+
128
+ err_msg: str | None = None
129
+ if user_id:
130
+ user_data: dict[str, Any] = _get_user_data(iam_server=iam_server,
131
+ user_id=user_id,
132
+ errors=errors,
133
+ logger=logger)
134
+ token: str = user_data["access-token"] if user_data else None
135
+ if token:
136
+ access_expiration: int = user_data.get("access-expiration")
137
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
138
+ if now < access_expiration:
139
+ result = token
140
+ else:
141
+ # access token has expired
142
+ refresh_token: str = user_data["refresh-token"]
143
+ if refresh_token:
144
+ refresh_expiration = user_data["refresh-expiration"]
145
+ if now < refresh_expiration:
146
+ body_data: dict[str, str] = {
147
+ "grant_type": "refresh_token",
148
+ "refresh_token": refresh_token
149
+ }
150
+ token_data: dict[str, Any] = _post_for_token(iam_server=iam_server,
151
+ body_data=body_data,
152
+ errors=errors,
153
+ logger=logger)
154
+ if token_data:
155
+ result = token_data.get("access_token")
156
+ user_data["access-token"] = result
157
+ # keep current refresh token if a new one is not provided
158
+ user_data["refresh-token"] = (token_data.get("refresh_token") or
159
+ body_data.get("refresh_token"))
160
+ user_data["access-expiration"] = now + token_data.get("expires_in")
161
+ refresh_expiration: int = user_data.get("refresh_expires_in")
162
+ user_data["refresh-expiration"] = (now + refresh_expiration) \
163
+ if refresh_expiration else sys.maxsize
164
+ else:
165
+ # refresh token is no longer valid
166
+ user_data["refresh-token"] = None
167
+ else:
168
+ # refresh token has expired
169
+ err_msg = "Access and refresh tokens expired"
170
+ if logger:
171
+ logger.error(msg=err_msg)
172
+ else:
173
+ err_msg = "Access token expired, no refresh token available"
174
+ if logger:
175
+ logger.error(msg=err_msg)
137
176
  else:
138
- # request resulted in error
139
- err_msg = f"POST failure, status {response.status_code}, reason '{response.reason}'"
140
- if hasattr(response, "content") and response.content:
141
- err_msg += f", content '{response.content}'"
142
- errors.append(err_msg)
143
-
144
- result: Response
145
- if errors:
146
- result = jsonify({"errors": "; ".join(errors)})
147
- result.status_code = 400
148
- if logger:
149
- logger.error(msg=json.dumps(obj=result))
177
+ err_msg = f"User '{user_id}' not authenticated"
178
+ if logger:
179
+ logger.error(msg=err_msg)
150
180
  else:
151
- result = jsonify({
152
- "user-id": token_data[0],
153
- "access-token": token_data[1]})
181
+ err_msg = "User identification not provided"
182
+ if logger:
183
+ logger.error(msg=err_msg)
154
184
 
155
- # log the response
156
- if logger:
157
- logger.debug(msg=f"Response {result}")
185
+ if err_msg and isinstance(errors, list):
186
+ errors.append(err_msg)
158
187
 
159
188
  return result
160
189
 
161
190
 
162
- # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
163
- # methods=["GET"])
164
- # @flask_app.route(rule=<token_endpoint>, # JUSBR_TOKEN_ENDPOINT: /iam/jusbr:get-token
165
- # methods=["GET"])
166
- def service_token() -> Response:
191
+ def login_callback(iam_server: IamServer,
192
+ args: dict[str, Any],
193
+ errors: list[str] = None,
194
+ logger: Logger = None) -> tuple[str, str] | None:
167
195
  """
168
- Entry point for retrieving the JusBR token.
196
+ Entry point for the callback from *iam_server* via the front-end application, on authentication operation.
169
197
 
170
- :return: the response containing the token, or *UNAUTHORIZED*
198
+ :param iam_server: the reference registered *IAM* server
199
+ :param args: the arguments passed when requesting the service
200
+ :param errors: incidental errors
201
+ :param logger: optional logger
202
+ :return: a tuple cotaining the reference user identification and the token obtained, or *None* if error
171
203
  """
172
- # retrieve logger and registry
173
- registry: dict[str, Any] = __get_iam_registry(endpoint=request.endpoint)
174
- logger: Logger = registry["logger"]
175
-
176
- # log the request
177
- if logger:
178
- logger.debug(msg=_log_init(request=request))
179
-
180
- # retrieve the token
181
- errors: list[str] = []
182
- token: str = _service_token(registry=registry,
183
- args=request.args,
184
- errors=errors,
185
- logger=logger)
186
- result: Response
187
- if token:
188
- result = jsonify({"token": token})
189
- else:
190
- result = Response("; ".join(errors))
191
- result.status_code = 401
204
+ from .token_pomes import token_validate
205
+
206
+ # initialize the return variable
207
+ result: tuple[str, str] | None = None
192
208
 
193
- # log the response
194
- if logger:
195
- logger.debug(msg=f"Response {result}")
209
+ # retrieve the users authentication data
210
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
211
+ errors=errors,
212
+ logger=logger)
213
+ cache: Cache = registry["cache"] if registry else None
214
+ if cache:
215
+ users: dict[str, dict[str, Any]] = cache.get("users")
216
+
217
+ # validate the OAuth2 state
218
+ oauth_state: str = args.get("state")
219
+ user_data: dict[str, Any] | None = None
220
+ if oauth_state:
221
+ for user, data in users.items():
222
+ if user == oauth_state:
223
+ user_data = data
224
+ break
225
+
226
+ # exchange 'code' for the token
227
+ if user_data:
228
+ expiration: int = user_data["login-expiration"] or sys.maxsize
229
+ if int(datetime.now(tz=TZ_LOCAL).timestamp()) > expiration:
230
+ errors.append("Operation timeout")
231
+ else:
232
+ users.pop(oauth_state)
233
+ code: str = args.get("code")
234
+ body_data: dict[str, Any] = {
235
+ "grant_type": "authorization_code",
236
+ "code": code,
237
+ "redirect_uri": registry["redirect-uri"]
238
+ }
239
+ now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
240
+ token_data: dict[str, Any] = _post_for_token(iam_server=iam_server,
241
+ body_data=body_data,
242
+ errors=errors,
243
+ logger=logger)
244
+ # process the token data
245
+ if token_data:
246
+ token: str = token_data.get("access_token")
247
+ user_data["access-token"] = token
248
+ # keep current refresh token if a new one is not provided
249
+ user_data["refresh-token"] = token_data.get("refresh_token") or body_data.get("refresh_token")
250
+ user_data["access-expiration"] = now + token_data.get("expires_in")
251
+ refresh_exp: int = user_data.get("refresh_expires_in")
252
+ user_data["refresh-expiration"] = (now + refresh_exp) if refresh_exp else sys.maxsize
253
+ public_key: str = _get_public_key(iam_server=iam_server,
254
+ errors=errors,
255
+ logger=logger)
256
+ if public_key:
257
+ recipient_attr = registry["recipient_attr"]
258
+ login_id = user_data.pop("login-id", None)
259
+ token_claims: dict[str, dict[str, Any]] = token_validate(token=token,
260
+ issuer=registry["base-url"],
261
+ recipient_id=login_id,
262
+ recipient_attr=recipient_attr,
263
+ public_key=public_key,
264
+ errors=errors,
265
+ logger=logger)
266
+ if token_claims:
267
+ token_user: str = token_claims["payload"].get(recipient_attr)
268
+ result = (token_user, token)
269
+ else:
270
+ msg: str = "Unknown state received"
271
+ if logger:
272
+ logger.error(msg=msg)
273
+ if isinstance(errors, list):
274
+ errors.append(msg)
196
275
 
197
276
  return result
198
277
 
199
278
 
200
- def __get_iam_registry(endpoint: str) -> dict[str, Any]:
279
+ def token_exchange(iam_server: IamServer,
280
+ args: dict[str, Any],
281
+ errors: list[str] = None,
282
+ logger: Logger = None) -> dict[str, Any]:
201
283
  """
202
- Retrieve the registry associated the the IAM identifies by *endpoint*.
284
+ Requst *iam_server* to issue a token in exchange for the token obtained from another *IAM* server.
203
285
 
204
- :param endpoint: the service enpoint identifying the IAM.
205
- :return: the tuple (*logger*, *registry*) associated with *endpoint*
286
+ :param iam_server: the reference registered *IAM* server
287
+ :param args: the arguments passed when requesting the service
288
+ :param errors: incidental errors
289
+ :param logger: optional logger
290
+ :return: the data for the new token, or *None* if error
206
291
  """
207
292
  # initialize the return variable
208
293
  result: dict[str, Any] | None = None
209
294
 
210
- if endpoint.startswith("jusbr-"):
211
- result = IAM_SERVERS[IamServer.IAM_JUSRBR]
212
- elif endpoint.startswith("keycloak-"):
213
- result = IAM_SERVERS[IamServer.IAM_KEYCLOAK]
295
+ # obtain the user's identification
296
+ user_id: str = args.get("user-id") or args.get("user_id") or args.get("login")
297
+
298
+ # retrieve the token to be exchanges
299
+ token: str = args.get("token")
300
+
301
+ if user_id and token:
302
+ # HAZARD: only 'IAM_KEYCLOAK' supported
303
+ registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
304
+ errors=errors,
305
+ logger=logger)
306
+ if registry:
307
+ body_data: dict[str, str] = {
308
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
309
+ "subject_token": token,
310
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
311
+ "requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
312
+ "audience": registry["client-id"],
313
+ "subject_issuer": "oidc"
314
+ }
315
+ result = _post_for_token(iam_server=IamServer.IAM_KEYCLOAK,
316
+ body_data=body_data,
317
+ errors=errors,
318
+ logger=logger)
319
+ else:
320
+ msg: str = "User identification and token must be provided"
321
+ if logger:
322
+ logger.error(msg=msg)
323
+ if isinstance(errors, list):
324
+ errors.append(msg)
214
325
 
215
326
  return result
216
-