pypomes-iam 0.5.1__py3-none-any.whl → 0.6.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.
pypomes_iam/iam_common.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import requests
2
2
  import sys
3
3
  from datetime import datetime
4
- from enum import StrEnum
4
+ from enum import StrEnum, auto
5
5
  from logging import Logger
6
6
  from pypomes_core import TZ_LOCAL, exc_format
7
7
  from pypomes_crypto import crypto_jwk_convert
@@ -13,60 +13,211 @@ class IamServer(StrEnum):
13
13
  """
14
14
  Supported IAM servers.
15
15
  """
16
- IAM_JUSRBR = "iam-jusbr",
17
- IAM_KEYCLOAK = "iam-keycloak"
16
+ JUSBR = auto()
17
+ KEYCLOAK = auto()
18
18
 
19
19
 
20
+ class IamParam(StrEnum):
21
+ """
22
+ Parameters for configuring *IAM* servers.
23
+ """
24
+ ADMIN_ID = "admin-id"
25
+ ADMIN_SECRET = "admin-secret"
26
+ CLIENT_ID = "client-id"
27
+ CLIENT_REALM = "client-realm"
28
+ CLIENT_SECRET = "client-secret"
29
+ ENDPOINT_CALLBACK = "endpoint-callback"
30
+ ENDPOINT_LOGIN = "endpoint-login"
31
+ ENDPOINT_LOGOUT = "endpoint_logout"
32
+ ENDPOINT_TOKEN = "endpoint-token"
33
+ ENDPOINT_EXCHANGE = "endpoint-exchange"
34
+ LOGIN_TIMEOUT = "login-timeout"
35
+ PK_EXPIRATION = "pk-expiration"
36
+ PK_LIFETIME = "pk-lifetime"
37
+ PUBLIC_KEY = "public-key"
38
+ RECIPIENT_ATTR = "recipient-attr"
39
+ URL_BASE = "url-base"
40
+ USERS = "users"
41
+
42
+
43
+ class UserParam(StrEnum):
44
+ """
45
+ Parameters for handling *IAM* users.
46
+ """
47
+ ACCESS_TOKEN = "access-token"
48
+ REFRESH_TOKEN = "refresh-token"
49
+ ACCESS_EXPIRATION = "access-expiration"
50
+ REFRESH_EXPIRATION = "refresh-expiration"
51
+ # transient attributes
52
+ LOGIN_EXPIRATION = "login-expiration"
53
+ LOGIN_ID = "login-id"
54
+ REDIRECT_URI = "redirect-uri"
55
+
56
+
57
+ # The configuration parameters for the IAM servers are specified dynamically dynamically with *iam_setup()*
58
+ # Specifying configuration parameters with environment variables can be done in two ways:
59
+ #
60
+ # 1. for a single *IAM* server, specify the data set
61
+ # - *<APP_PREFIX>_IAM_ADMIN_ID* (optional, needed if administrative duties are performed)
62
+ # - *<APP_PREFIX>_IAM_ADMIN_PWD* (optional, needed if administrative duties are performed)
63
+ # - *<APP_PREFIX>_IAM_CLIENT_ID* (required)
64
+ # - *<APP_PREFIX>_IAM_CLIENT_REALM* (required)
65
+ # - *<APP_PREFIX>_IAM_CLIENT_SECRET* (required)
66
+ # - *<APP_PREFIX>_IAM_ENDPOINT_CALLBACK* (required)
67
+ # - *<APP_PREFIX>_IAM_ENDPOINT_EXCHANGE* (required)
68
+ # - *<APP_PREFIX>_IAM_ENDPOINT_LOGIN* (required)
69
+ # - *<APP_PREFIX>_IAM_ENDPOINT_LOGOUT* (required)
70
+ # - *<APP_PREFIX>_IAM_ENDPOINT_PROVIDER* (optional, needed if requesting tokens to providers)
71
+ # - *<APP_PREFIX>_IAM_ENDPOINT_TOKEN* (required)
72
+ # - *<APP_PREFIX>_IAM_LOGIN_TIMEOUT* (optional, defaults to no timeout)
73
+ # - *<APP_PREFIX>_IAM_PK_LIFETIME* (optional, defaults to non-terminating lifetime)
74
+ # - *<APP_PREFIX>_IAM_RECIPIENT_ATTR* (required)
75
+ # - *<APP_PREFIX>_IAM_URL_BASE* (required)
76
+ #
77
+ # 2. for multiple *IAM* servers, specify the data set above for each server,
78
+ # respectively replacing *IAM* with a name in *IamServer* (currently, *JUSBR* and *KEYCLOAK* are supported).
79
+ #
80
+ # 3. the parameters *PUBLIC_KEY*, *PK_EXPIRATION*, and *USERS* cannot be assigned values,
81
+ # as they are reserved for internal use
82
+
20
83
  # registry structure:
21
84
  # { <IamServer>:
22
85
  # {
23
86
  # "base-url": <str>,
87
+ # "admin-id": <str>,
88
+ # "admin-secret": <str>,
24
89
  # "client-id": <str>,
25
90
  # "client-secret": <str>,
91
+ # "client-realm": <str,
26
92
  # "client-timeout": <int>,
27
93
  # "recipient-attr": <str>,
28
94
  # "public-key": <str>,
29
95
  # "pk-lifetime": <int>,
30
96
  # "pk-expiration": <int>,
31
- # "cache": <FIFOCache>
97
+ # "users": {}
32
98
  # },
33
99
  # ...
34
100
  # }
35
- # data in "cache":
101
+ # data in "users":
36
102
  # {
37
- # "users": {
38
- # "<user-id>": {
39
- # "access-token": <str>
40
- # "refresh-token": <str>
41
- # "access-expiration": <timestamp>,
42
- # "refresh-expiration": <timestamp>,
43
- # # transient attributes:
44
- # "login-expiration": <timestamp>,
45
- # "login-id": <str>,
46
- # "redirect-uri": <str>
47
- # }
48
- # },
103
+ # "<user-id>": {
104
+ # "access-token": <str>
105
+ # "refresh-token": <str>
106
+ # "access-expiration": <timestamp>,
107
+ # "refresh-expiration": <timestamp>,
108
+ # # transient attributes
109
+ # "login-expiration": <timestamp>,
110
+ # "login-id": <str>,
111
+ # "redirect-uri": <str>
112
+ # },
49
113
  # ...
50
114
  # }
51
- _IAM_SERVERS: Final[dict[IamServer, dict[str, Any]]] = {}
115
+ _IAM_SERVERS: Final[dict[IamServer, dict[IamParam, Any]]] = {}
116
+
52
117
 
53
- # the lock protecting the data in '_IAM_SERVER'
118
+ # the lock protecting the data in '_IAM_SERVERS'
54
119
  # (because it is 'Final' and set at declaration time, it can be accessed through simple imports)
55
120
  _iam_lock: Final[RLock] = RLock()
56
121
 
57
122
 
123
+ def _iam_server_from_endpoint(endpoint: str,
124
+ errors: list[str] | None,
125
+ logger: Logger | None) -> IamServer | None:
126
+ """
127
+ Retrieve the registered *IAM* server associated with the service's invocation *endpoint*.
128
+
129
+ :param endpoint: the service's invocation endpoint
130
+ :param errors: incidental error messages
131
+ :param logger: optional logger
132
+ :return: the corresponding *IAM* server, or *None* if one could not be obtained
133
+ """
134
+ # initialize the return variable
135
+ result: IamServer | None = None
136
+
137
+ for iam_server in _IAM_SERVERS:
138
+ if endpoint.startswith(iam_server):
139
+ result = iam_server
140
+ break
141
+
142
+ if not result:
143
+ msg: str = f"Unable to find a IAM server to service endpoint '{endpoint}'"
144
+ if logger:
145
+ logger.error(msg=msg)
146
+ if isinstance(errors, list):
147
+ errors.append(msg)
148
+
149
+ return result
150
+
151
+
152
+ def _iam_server_from_issuer(issuer: str,
153
+ errors: list[str] | None,
154
+ logger: Logger | None) -> IamServer | None:
155
+ """
156
+ Retrieve the registered *IAM* server associated with the token's *issuer*.
157
+
158
+ :param issuer: the token's issuer
159
+ :param errors: incidental error messages
160
+ :param logger: optional logger
161
+ :return: the corresponding *IAM* server, or *None* if one could not be obtained
162
+ """
163
+ # initialize the return variable
164
+ result: IamServer | None = None
165
+
166
+ for iam_server, registry in _IAM_SERVERS.items():
167
+ base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
168
+ if base_url == issuer:
169
+ result = IamServer(iam_server)
170
+ break
171
+
172
+ if not result:
173
+ msg: str = f"Unable to find a IAM server associated with token issuer '{issuer}'"
174
+ if logger:
175
+ logger.error(msg=msg)
176
+ if isinstance(errors, list):
177
+ errors.append(msg)
178
+
179
+ return result
180
+
181
+
58
182
  def _get_public_key(iam_server: IamServer,
59
183
  errors: list[str] | None,
60
184
  logger: Logger | None) -> str:
61
185
  """
62
186
  Obtain the public key used by *iam_server* to sign the authentication tokens.
63
187
 
64
- The public key is saved in *iam_server*'s registry.
188
+ This is accomplished by requesting the token issuer for its *JWKS* (JSON Web Key Set),
189
+ containing the public keys used for various purposes, as indicated in the attribute *use*:
190
+ - *enc*: the key is intended for encryption
191
+ - *sig*: the key is intended for digital signature
192
+ - *wrap*: the key is intended for key wrapping
193
+
194
+ A typical JWKS set has the following format (for simplicity, 'n' and 'x5c' are truncated):
195
+ {
196
+ "keys": [
197
+ {
198
+ "kid": "X2QEcSQ4Tg2M2EK6s2nhRHZH_GwD_zxZtiWVwP4S0tg",
199
+ "kty": "RSA",
200
+ "alg": "RSA256",
201
+ "use": "sig",
202
+ "n": "tQmDmyM3tMFt5FMVMbqbQYpaDPf6A5l4e_kTVDBiHrK_bRlGfkk8hYm5SNzNzCZ...",
203
+ "e": "AQAB",
204
+ "x5c": [
205
+ "MIIClzCCAX8CBgGZY0bqrTANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDARpanVk..."
206
+ ],
207
+ "x5t": "MHfVp4kBjEZuYOtiaaGsfLCL15Q",
208
+ "x5t#S256": "QADezSLgD8emuonBz8hn8ghTnxo7AHX4NVNkr4luEhk"
209
+ },
210
+ ...
211
+ ]
212
+ }
213
+
214
+ Once the signature key is obtained, it is converted from its original *JWK* (JSON Web Key) format
215
+ to *PEM* (Privacy-Enhanced Mail) format. The public key is saved in *iam_server*'s registry.
65
216
 
66
217
  :param iam_server: the reference registered *IAM* server
67
218
  :param errors: incidental error messages
68
219
  :param logger: optional logger
69
- :return: the public key in *PEM* format, or *None* if the server is unknown
220
+ :return: the public key in *PEM* format, or *None* if error
70
221
  """
71
222
  # initialize the return variable
72
223
  result: str | None = None
@@ -76,10 +227,12 @@ def _get_public_key(iam_server: IamServer,
76
227
  logger=logger)
77
228
  if registry:
78
229
  now: int = int(datetime.now(tz=TZ_LOCAL).timestamp())
79
- if now > registry["pk-expiration"]:
80
- # obtain a new public key
81
- url: str = f"{registry["base-url"]}/protocol/openid-connect/certs"
230
+ if now > registry[IamParam.PK_EXPIRATION]:
231
+ # obtain the JWKS (JSON Web Key Set) from the token issuer
232
+ base_url: str = f"{registry[IamParam.URL_BASE]}/realms/{registry[IamParam.CLIENT_REALM]}"
233
+ url: str = f"{base_url}/protocol/openid-connect/certs"
82
234
  if logger:
235
+ logger.debug(msg=f"Obtaining signature public key used by IAM server '{iam_server}'")
83
236
  logger.debug(msg=f"GET {url}")
84
237
  try:
85
238
  response: requests.Response = requests.get(url=url)
@@ -87,14 +240,30 @@ def _get_public_key(iam_server: IamServer,
87
240
  # request succeeded
88
241
  if logger:
89
242
  logger.debug(msg=f"GET success, status {response.status_code}")
90
- reply: dict[str, Any] = response.json()
91
- result = crypto_jwk_convert(jwk=reply["keys"][0],
92
- fmt="PEM")
93
- registry["public-key"] = result
94
- lifetime: int = registry["pk-lifetime"] or 0
95
- registry["pk-expiration"] = now + lifetime
243
+ # select the appropriate JWK
244
+ reply: dict[str, list[dict[str, str]]] = response.json()
245
+ jwk: dict[str, str] | None = None
246
+ for key in reply["keys"]:
247
+ if key.get("use") == "sig":
248
+ jwk = key
249
+ break
250
+ if jwk:
251
+ # convert from 'JWK' to 'PEM' and save it for further use
252
+ result = crypto_jwk_convert(jwk=jwk,
253
+ fmt="PEM")
254
+ registry[IamParam.PUBLIC_KEY] = result
255
+ lifetime: int = registry[IamParam.PK_LIFETIME] or 0
256
+ registry[IamParam.PK_EXPIRATION] = now + lifetime if lifetime else sys.maxsize
257
+ if logger:
258
+ logger.debug("Public key obtained and saved")
259
+ else:
260
+ msg = "Signature public key missing from the token issuer's JWKS"
261
+ if logger:
262
+ logger.error(msg=msg)
263
+ if isinstance(errors, list):
264
+ errors.append(msg)
96
265
  elif logger:
97
- msg: str = f"GET failure, status {response.status_code}, reason '{response.reason}'"
266
+ msg: str = f"GET failure, status {response.status_code}, reason {response.reason}"
98
267
  if hasattr(response, "content") and response.content:
99
268
  msg += f", content {response.content}"
100
269
  logger.error(msg=msg)
@@ -109,7 +278,7 @@ def _get_public_key(iam_server: IamServer,
109
278
  if isinstance(errors, list):
110
279
  errors.append(msg)
111
280
  else:
112
- result = registry["public-key"]
281
+ result = registry[IamParam.PUBLIC_KEY]
113
282
 
114
283
  return result
115
284
 
@@ -164,10 +333,10 @@ def _get_user_data(iam_server: IamServer,
164
333
  result = users.get(user_id)
165
334
  if not result:
166
335
  result = {
167
- "access-token": None,
168
- "refresh-token": None,
169
- "access-expiration": int(datetime.now(tz=TZ_LOCAL).timestamp()),
170
- "refresh-expiration": sys.maxsize
336
+ UserParam.ACCESS_TOKEN: None,
337
+ UserParam.REFRESH_TOKEN: None,
338
+ UserParam.ACCESS_EXPIRATION: int(datetime.now(tz=TZ_LOCAL).timestamp()),
339
+ UserParam.REFRESH_EXPIRATION: sys.maxsize
171
340
  }
172
341
  users[user_id] = result
173
342
  if logger:
@@ -178,35 +347,6 @@ def _get_user_data(iam_server: IamServer,
178
347
  return result
179
348
 
180
349
 
181
- def _get_iam_server(endpoint: str,
182
- errors: list[str] | None,
183
- logger: Logger | None) -> IamServer | None:
184
- """
185
- Retrieve the registered *IAM* server associated with the service's invocation *endpoint*.
186
-
187
- :param endpoint: the service's invocation endpoint
188
- :param errors: incidental error messages
189
- :param logger: optional logger
190
- :return: the corresponding *IAM* server, or *None* if one could not be obtained
191
- """
192
- # declare the return variable
193
- result: IamServer | None
194
-
195
- if endpoint.startswith("jusbr"):
196
- result = IamServer.IAM_JUSRBR
197
- elif endpoint.startswith("keycloak"):
198
- result = IamServer.IAM_KEYCLOAK
199
- else:
200
- result = None
201
- msg: str = f"Unable to find a IAM server to service endpoint '{endpoint}'"
202
- if logger:
203
- logger.error(msg=msg)
204
- if isinstance(errors, list):
205
- errors.append(msg)
206
-
207
- return result
208
-
209
-
210
350
  def _get_iam_registry(iam_server: IamServer,
211
351
  errors: list[str] | None,
212
352
  logger: Logger | None) -> dict[str, Any]:
@@ -218,21 +358,15 @@ def _get_iam_registry(iam_server: IamServer,
218
358
  :param logger: optional logger
219
359
  :return: the registry associated with *iam_server*, or *None* if the server is unknown
220
360
  """
221
- # declare the return variable
222
- result: dict[str, Any] | None
223
-
224
- match iam_server:
225
- case IamServer.IAM_JUSRBR:
226
- result = _IAM_SERVERS[IamServer.IAM_JUSRBR]
227
- case IamServer.IAM_KEYCLOAK:
228
- result = _IAM_SERVERS[IamServer.IAM_KEYCLOAK]
229
- case _:
230
- result = None
231
- msg = f"Unknown IAM server '{iam_server}'"
232
- if logger:
233
- logger.error(msg=msg)
234
- if isinstance(errors, list):
235
- errors.append(msg)
361
+ # assign the return variable
362
+ result: dict[str, Any] = _IAM_SERVERS.get(iam_server)
363
+
364
+ if not result:
365
+ msg = f"Unknown IAM server '{iam_server}'"
366
+ if logger:
367
+ logger.error(msg=msg)
368
+ if isinstance(errors, list):
369
+ errors.append(msg)
236
370
 
237
371
  return result
238
372
 
@@ -241,14 +375,14 @@ def _get_iam_users(iam_server: IamServer,
241
375
  errors: list[str] | None,
242
376
  logger: Logger | None) -> dict[str, dict[str, Any]]:
243
377
  """
244
- Retrieve the cache storage in *iam_server*'s registry.
378
+ Retrieve the users data storage in *iam_server*'s registry.
245
379
 
246
380
  :param iam_server: the reference registered *IAM* server
247
381
  :param errors: incidental error messages
248
382
  :param logger: optional logger
249
- :return: the cache storage in *iam_server*'s registry, or *None* if the server is unknown
383
+ :return: the users data storage in *iam_server*'s registry, or *None* if the server is unknown
250
384
  """
251
385
  registry: dict[str, Any] = _get_iam_registry(iam_server=iam_server,
252
386
  errors=errors,
253
387
  logger=logger)
254
- return registry["cache"]["users"] if registry else None
388
+ return registry[IamParam.USERS] if registry else None