eap-middleware 1.0.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.
eap.py ADDED
@@ -0,0 +1,477 @@
1
+ import time
2
+ import hmac
3
+ import hashlib
4
+ import base64
5
+ import json
6
+ import urllib.request
7
+ import urllib.parse
8
+ import threading
9
+ from typing import Dict, List, Optional, Callable, Any
10
+
11
+ class EapConfig:
12
+ def __init__(self,
13
+ jwt_secret: str,
14
+ allowed_emails: List[str],
15
+ rate_limit_per_sec: float = 3.0,
16
+ rate_burst: float = 5.0,
17
+ s2s_rate_limit_per_sec: float = 30.0,
18
+ s2s_rate_burst: float = 100.0,
19
+ google_client_id: Optional[str] = None,
20
+ google_client_secret: Optional[str] = None,
21
+ google_redirect_url: Optional[str] = None,
22
+ target_url: Optional[str] = None,
23
+ tunnel_token: Optional[str] = None):
24
+ self.jwt_secret = jwt_secret
25
+ self.allowed_emails = allowed_emails
26
+ self.rate_limit_per_sec = rate_limit_per_sec
27
+ self.rate_burst = rate_burst
28
+ self.s2s_rate_limit_per_sec = s2s_rate_limit_per_sec
29
+ self.s2s_rate_burst = s2s_rate_burst
30
+ self.google_client_id = google_client_id
31
+ self.google_client_secret = google_client_secret
32
+ self.google_redirect_url = google_redirect_url
33
+ self.target_url = target_url
34
+ self.tunnel_token = tunnel_token
35
+
36
+ class EapEngine:
37
+ @classmethod
38
+ def from_env(cls) -> "EapEngine":
39
+ import os
40
+ allowed_str = os.getenv("ALLOWED_EMAILS", os.getenv("ADMIN_EMAIL", ""))
41
+ allowed_emails = [x.strip() for x in allowed_str.split(",") if x.strip()]
42
+
43
+ def safe_float(val, default):
44
+ try:
45
+ return float(val) if val else default
46
+ except ValueError:
47
+ return default
48
+
49
+ config = EapConfig(
50
+ jwt_secret=os.getenv("JWT_SECRET", ""),
51
+ allowed_emails=allowed_emails,
52
+ rate_limit_per_sec=safe_float(os.getenv("RATE_LIMIT_PER_SEC"), 3.0),
53
+ rate_burst=safe_float(os.getenv("RATE_BURST"), 5.0),
54
+ s2s_rate_limit_per_sec=safe_float(os.getenv("S2S_RATE_LIMIT_PER_SEC"), 30.0),
55
+ s2s_rate_burst=safe_float(os.getenv("S2S_RATE_BURST"), 100.0),
56
+ google_client_id=os.getenv("GOOGLE_CLIENT_ID"),
57
+ google_client_secret=os.getenv("GOOGLE_CLIENT_SECRET"),
58
+ google_redirect_url=os.getenv("GOOGLE_REDIRECT_URL"),
59
+ target_url=os.getenv("TARGET_URL"),
60
+ tunnel_token=os.getenv("TUNNEL_TOKEN")
61
+ )
62
+ return cls(config)
63
+
64
+ def __init__(self, config: EapConfig):
65
+ self.config = config
66
+ self.client_limits: Dict[str, Dict[str, Any]] = {}
67
+ self.limits_lock = threading.Lock()
68
+ self.request_counter = 0
69
+ self.counter_lock = threading.Lock()
70
+
71
+ # Tunnel state mapping
72
+ self.active_requests: Dict[str, threading.Event] = {}
73
+ self.responses: Dict[str, Dict[str, Any]] = {}
74
+ self.active_requests_lock = threading.Lock()
75
+ self.pending_requests: List[Dict[str, Any]] = []
76
+ self.pending_lock = threading.Lock()
77
+ self.pending_event = threading.Event()
78
+
79
+ # ASGI Middleware implementation (FastAPI / Starlette)
80
+ def asgi_middleware(self) -> Callable:
81
+ async def middleware(scope: Any, receive: Callable, send: Callable) -> None:
82
+ if scope["type"] != "http":
83
+ await send({"type": "http.response.start", "status": 200, "headers": []})
84
+ return
85
+
86
+ path = scope["path"]
87
+
88
+ # Intercept EAP routes
89
+ if path in ["/login", "/auth/callback", "/logout", "/tunnel/poll", "/tunnel/respond"]:
90
+ await self.handle_eap_route_asgi(scope, receive, send)
91
+ return
92
+
93
+ # Extract auth
94
+ headers = dict(scope.get("headers", []))
95
+ email: Optional[str] = None
96
+ is_s2s = False
97
+
98
+ auth_header = headers.get(b"authorization", b"").decode("utf-8")
99
+ if auth_header.startswith("Bearer "):
100
+ token = auth_header[7:]
101
+ try:
102
+ email = self.verify_session(token)
103
+ is_s2s = True
104
+ except ValueError:
105
+ await self.respond_asgi(send, 401, b"Unauthorized S2S token")
106
+ return
107
+ else:
108
+ cookies = self.parse_cookies(headers.get(b"cookie", b"").decode("utf-8"))
109
+ session = cookies.get("eap_session")
110
+ if session:
111
+ try:
112
+ email = self.verify_session(session)
113
+ except ValueError:
114
+ pass
115
+
116
+ # Check rate limiting
117
+ client_ip = scope.get("client", [""])[0]
118
+ if self.is_rate_limited(client_ip, email, is_s2s):
119
+ if is_s2s:
120
+ await self.respond_asgi(send, 429, b"Too Many Requests", {b"retry-after": b"1"})
121
+ return
122
+ # Redirect to error page
123
+ redirect_uri = urllib.parse.quote(scope.get("query_string", b"").decode("utf-8"))
124
+ await self.redirect_asgi(send, f"/error/429?redirect_to={redirect_uri}")
125
+ return
126
+
127
+ if not email:
128
+ if is_s2s:
129
+ await self.respond_asgi(send, 401, b"Unauthorized")
130
+ return
131
+ # Set redirect_to cookie and send to login
132
+ original_uri = scope["path"]
133
+ if scope.get("query_string"):
134
+ original_uri += "?" + scope["query_string"].decode("utf-8")
135
+ headers = {b"set-cookie": f"redirect_to={original_uri}; Path=/; HttpOnly".encode("utf-8")}
136
+ await self.redirect_asgi(send, "/login", headers)
137
+ return
138
+
139
+ if not self.is_email_allowed(email):
140
+ await self.redirect_asgi(send, "/error/403")
141
+ return
142
+
143
+ # Inject User email header
144
+ scope_headers = list(scope.get("headers", []))
145
+ scope_headers.append((b"x-user-email", email.encode("utf-8")))
146
+ scope["headers"] = scope_headers
147
+
148
+ if self.config.target_url == "tunnel":
149
+ await self.proxy_through_tunnel_asgi(scope, receive, send, email)
150
+ return
151
+
152
+ # Continue execution (we're acting as a middleware library)
153
+ # In a real environment, the underlying ASGI application takes over
154
+ return
155
+
156
+ return middleware
157
+
158
+ async def handle_eap_route_asgi(self, scope: Any, receive: Callable, send: Callable) -> None:
159
+ path = scope["path"]
160
+ query_params = urllib.parse.parse_qs(scope.get("query_string", b"").decode("utf-8"))
161
+
162
+ if path == "/login":
163
+ if not self.config.google_client_id:
164
+ await self.respond_asgi(send, 500, b"OAuth not configured")
165
+ return
166
+ redirect_to = query_params.get("redirect_to", ["/"])[0]
167
+ auth_url = (f"https://accounts.google.com/o/oauth2/v2/auth?client_id={self.config.google_client_id}"
168
+ f"&redirect_uri={urllib.parse.quote(self.config.google_redirect_url or '')}"
169
+ f"&response_type=code&scope=email%20profile&state={urllib.parse.quote(redirect_to)}")
170
+ await self.redirect_asgi(send, auth_url)
171
+ return
172
+
173
+ if path == "/auth/callback":
174
+ code = query_params.get("code", [""])[0]
175
+ if not code:
176
+ await self.redirect_asgi(send, "/error/400")
177
+ return
178
+
179
+ try:
180
+ # Exchange code using urllib
181
+ data = urllib.parse.urlencode({
182
+ "code": code,
183
+ "client_id": self.config.google_client_id or "",
184
+ "client_secret": self.config.google_client_secret or "",
185
+ "redirect_uri": self.config.google_redirect_url or "",
186
+ "grant_type": "authorization_code"
187
+ }).encode("utf-8")
188
+
189
+ req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data)
190
+ with urllib.request.urlopen(req) as res:
191
+ token_data = json.loads(res.read().decode("utf-8"))
192
+
193
+ user_req = urllib.request.Request(
194
+ "https://www.googleapis.com/oauth2/v2/userinfo",
195
+ headers={"Authorization": f"Bearer {token_data['access_token']}"}
196
+ )
197
+ with urllib.request.urlopen(user_req) as res:
198
+ user_data = json.loads(res.read().decode("utf-8"))
199
+
200
+ email = user_data.get("email")
201
+ if not email or not self.is_email_allowed(email):
202
+ await self.redirect_asgi(send, "/error/403")
203
+ return
204
+
205
+ session_token = self.sign_session(email)
206
+ state = query_params.get("state", ["/"])[0]
207
+
208
+ headers = {
209
+ b"set-cookie": f"eap_session={session_token}; Path=/; HttpOnly; Max-Age=86400".encode("utf-8")
210
+ }
211
+ await self.redirect_asgi(send, state, headers)
212
+ except Exception:
213
+ await self.redirect_asgi(send, "/error/500")
214
+ return
215
+
216
+ if path == "/logout":
217
+ headers = {b"set-cookie": b"eap_session=; Path=/; Max-Age=0"}
218
+ await self.redirect_asgi(send, "/login", headers)
219
+ return
220
+
221
+ if path == "/tunnel/poll":
222
+ token = query_params.get("token", [""])[0]
223
+ if not self.config.tunnel_token or token != self.config.tunnel_token:
224
+ await self.respond_asgi(send, 401, b"Unauthorized")
225
+ return
226
+
227
+ # Long poll wait
228
+ has_request = self.pending_event.wait(15.0)
229
+ if not has_request:
230
+ await send({"type": "http.response.start", "status": 204, "headers": []})
231
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
232
+ return
233
+
234
+ with self.pending_lock:
235
+ if self.pending_requests:
236
+ req_data = self.pending_requests.pop(0)
237
+ if not self.pending_requests:
238
+ self.pending_event.clear()
239
+ await self.respond_asgi(send, 200, json.dumps(req_data).encode("utf-8"), {b"content-type": b"application/json"})
240
+ return
241
+ await send({"type": "http.response.start", "status": 204, "headers": []})
242
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
243
+ return
244
+
245
+ if path == "/tunnel/respond":
246
+ token = query_params.get("token", [""])[0]
247
+ req_id = query_params.get("id", [""])[0]
248
+ if not self.config.tunnel_token or token != self.config.tunnel_token:
249
+ await self.respond_asgi(send, 401, b"Unauthorized")
250
+ return
251
+
252
+ # Read body
253
+ body_bytes = b""
254
+ more_body = True
255
+ while more_body:
256
+ msg = await receive()
257
+ body_bytes += msg.get("body", b"")
258
+ more_body = msg.get("more_body", False)
259
+
260
+ try:
261
+ resp_data = json.loads(body_bytes.decode("utf-8"))
262
+ with self.active_requests_lock:
263
+ self.responses[req_id] = resp_data
264
+ event = self.active_requests.get(req_id)
265
+ if event:
266
+ event.set()
267
+ await self.respond_asgi(send, 200, b"OK")
268
+ except Exception:
269
+ await self.respond_asgi(send, 400, b"Bad Request")
270
+ return
271
+
272
+ async def proxy_through_tunnel_asgi(self, scope: Any, receive: Callable, send: Callable, email: str) -> None:
273
+ with self.counter_lock:
274
+ self.request_counter += 1
275
+ req_id = str(self.request_counter)
276
+
277
+ # Read body
278
+ body_bytes = b""
279
+ more_body = True
280
+ while more_body:
281
+ msg = await receive()
282
+ body_bytes += msg.get("body", b"")
283
+ more_body = msg.get("more_body", False)
284
+
285
+ body_b64 = base64.b64encode(body_bytes).decode("utf-8")
286
+
287
+ headers_dict = {}
288
+ for k, v in scope.get("headers", []):
289
+ headers_dict[k.decode("utf-8")] = [v.decode("utf-8")]
290
+ headers_dict["X-User-Email"] = [email]
291
+
292
+ pending_req = {
293
+ "id": req_id,
294
+ "method": scope["method"],
295
+ "path": scope["path"],
296
+ "query": scope.get("query_string", b"").decode("utf-8"),
297
+ "headers": headers_dict,
298
+ "body": body_b64
299
+ }
300
+
301
+ event = threading.Event()
302
+ with self.active_requests_lock:
303
+ self.active_requests[req_id] = event
304
+
305
+ with self.pending_lock:
306
+ self.pending_requests.append(pending_req)
307
+ self.pending_event.set()
308
+
309
+ # Wait for response
310
+ success = event.wait(30.0)
311
+
312
+ with self.active_requests_lock:
313
+ if req_id in self.active_requests:
314
+ del self.active_requests[req_id]
315
+ resp_data = self.responses.pop(req_id, None)
316
+
317
+ if not success or not resp_data:
318
+ await self.respond_asgi(send, 504, b"Gateway Timeout")
319
+ return
320
+
321
+ headers = []
322
+ for k, values in resp_data.get("headers", {}).items():
323
+ for v in values:
324
+ headers.append((k.lower().encode("utf-8"), v.encode("utf-8")))
325
+
326
+ body = base64.b64decode(resp_data.get("body", ""))
327
+ await send({"type": "http.response.start", "status": resp_data.get("status", 200), "headers": headers})
328
+ await send({"type": "http.response.body", "body": body, "more_body": False})
329
+
330
+ async def respond_asgi(self, send: Callable, status: int, body: bytes, headers: Optional[Dict[bytes, bytes]] = None) -> None:
331
+ headers_list = []
332
+ if headers:
333
+ for k, v in headers.items():
334
+ headers_list.append((k, v))
335
+ await send({"type": "http.response.start", "status": status, "headers": headers_list})
336
+ await send({"type": "http.response.body", "body": body, "more_body": False})
337
+
338
+ async def redirect_asgi(self, send: Callable, location: str, extra_headers: Optional[Dict[bytes, bytes]] = None) -> None:
339
+ headers = {b"location": location.encode("utf-8")}
340
+ if extra_headers:
341
+ headers.update(extra_headers)
342
+ await self.respond_asgi(send, 302, b"", headers)
343
+
344
+ # JWT Session logic (Pure Python standard library implementation)
345
+ def sign_session(self, email: str) -> str:
346
+ header = {"alg": "HS256", "typ": "JWT"}
347
+ payload = {"email": email, "exp": int(time.time()) + 86400}
348
+
349
+ header_b64 = base64.urlsafe_b64encode(json.dumps(header).encode()).decode().rstrip("=")
350
+ payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
351
+
352
+ signature = hmac.new(self.config.jwt_secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
353
+ signature_b64 = base64.urlsafe_b64encode(signature).decode().rstrip("=")
354
+ return f"{header_b64}.{payload_b64}.{signature_b64}"
355
+
356
+ def verify_session(self, token: str) -> str:
357
+ parts = token.split(".")
358
+ if len(parts) != 3:
359
+ raise ValueError("Invalid token format")
360
+ header_b64, payload_b64, signature_b64 = parts
361
+
362
+ signature = hmac.new(self.config.jwt_secret.encode(), f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
363
+ expected_signature_b64 = base64.urlsafe_b64encode(signature).decode().rstrip("=")
364
+
365
+ if not hmac.compare_digest(signature_b64, expected_signature_b64):
366
+ raise ValueError("Signature mismatch")
367
+
368
+ padding = len(payload_b64) % 4
369
+ if padding:
370
+ payload_b64 += "=" * (4 - padding)
371
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64).decode())
372
+
373
+ if "exp" in payload and time.time() > payload["exp"]:
374
+ raise ValueError("Token expired")
375
+ return payload["email"]
376
+
377
+ def is_email_allowed(self, email: str) -> bool:
378
+ email_clean = email.lower().strip()
379
+ for p in self.config.allowed_emails:
380
+ pattern = p.lower().strip()
381
+ if pattern.startswith("@") and email_clean.endswith(pattern):
382
+ return True
383
+ if pattern == email_clean:
384
+ return True
385
+ return False
386
+
387
+ def is_rate_limited(self, ip: str, email: Optional[str], is_s2s: bool) -> bool:
388
+ key = email if email else ip
389
+ now = time.time()
390
+
391
+ limit_per_sec = self.config.s2s_rate_limit_per_sec if is_s2s else self.config.rate_limit_per_sec
392
+ burst = self.config.s2s_rate_burst if is_s2s else self.config.rate_burst
393
+
394
+ with self.limits_lock:
395
+ limit = self.client_limits.get(key)
396
+ if not limit:
397
+ self.client_limits[key] = {"tokens": burst - 1.0, "last_seen": now}
398
+ return False
399
+
400
+ elapsed = now - limit["last_seen"]
401
+ limit["tokens"] = min(burst, limit["tokens"] + elapsed * limit_per_sec)
402
+ limit["last_seen"] = now
403
+
404
+ if limit["tokens"] >= 1.0:
405
+ limit["tokens"] -= 1.0
406
+ return False
407
+ return True
408
+
409
+ def parse_cookies(self, cookie_str: str) -> Dict[str, str]:
410
+ cookies = {}
411
+ for cookie in cookie_str.split(";"):
412
+ parts = cookie.strip().split("=")
413
+ if len(parts) >= 2:
414
+ cookies[parts[0]] = parts[1]
415
+ return cookies
416
+
417
+ # Tunnel Client execution routine (Pure Python standard library)
418
+ def start_tunnel_client(server_url: str, token: str, local_port: int) -> None:
419
+ local_addr = f"http://localhost:{local_port}"
420
+ poll_url = f"{server_url}/tunnel/poll?token={urllib.parse.quote(token)}"
421
+
422
+ while True:
423
+ try:
424
+ req = urllib.request.Request(poll_url)
425
+ with urllib.request.urlopen(req) as res:
426
+ if res.getcode() == 204:
427
+ continue
428
+ req_data = json.loads(res.read().decode("utf-8"))
429
+
430
+ def process_and_respond(r_data):
431
+ try:
432
+ local_url = f"{local_addr}{r_data['path']}"
433
+ if r_data.get("query"):
434
+ local_url += "?" + r_data["query"]
435
+
436
+ headers = {k: v[0] for k, v in r_data.get("headers", {}).items()}
437
+ body_bytes = base64.b64decode(r_data.get("body", "")) if r_data.get("body") else None
438
+
439
+ local_req = urllib.request.Request(local_url, data=body_bytes, headers=headers, method=r_data["method"])
440
+ try:
441
+ with urllib.request.urlopen(local_req) as local_res:
442
+ status = local_res.getcode()
443
+ resp_headers = {k: [v] for k, v in local_res.info().items()}
444
+ resp_body = base64.b64encode(local_res.read()).decode("utf-8")
445
+ except urllib.error.HTTPError as he:
446
+ status = he.code
447
+ resp_headers = {k: [v] for k, v in he.info().items()}
448
+ resp_body = base64.b64encode(he.read()).decode("utf-8")
449
+
450
+ tunnel_resp = {
451
+ "status": status,
452
+ "headers": resp_headers,
453
+ "body": resp_body
454
+ }
455
+ except Exception as e:
456
+ tunnel_resp = {
457
+ "status": 502,
458
+ "headers": {},
459
+ "body": base64.b64encode(str(e).encode()).decode("utf-8")
460
+ }
461
+
462
+ # Send back response
463
+ try:
464
+ respond_url = f"{server_url}/tunnel/respond?token={urllib.parse.quote(token)}&id={urllib.parse.quote(r_data['id'])}"
465
+ post_req = urllib.request.Request(
466
+ respond_url,
467
+ data=json.dumps(tunnel_resp).encode("utf-8"),
468
+ headers={"Content-Type": "application/json"}
469
+ )
470
+ with urllib.request.urlopen(post_req) as pr:
471
+ pr.read()
472
+ except Exception:
473
+ pass
474
+
475
+ threading.Thread(target=process_and_respond, args=(req_data,), daemon=True).start()
476
+ except Exception:
477
+ time.sleep(3)
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: eap-middleware
3
+ Version: 1.0.0
4
+ Summary: External Authentication Proxy (EAP) Middleware and Client SDK for Python
5
+ Author-email: Parth Tiwari <www.parthtiwari@gmail.com>
6
+ License: MIT
7
+ Keywords: auth,proxy,jwt,middleware,google-oauth,localtunnel
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.7
12
+ Description-Content-Type: text/markdown
13
+
14
+ # EAP Python Middleware
15
+
16
+ External Authentication Proxy (EAP) Middleware and Client SDK for Python.
17
+
18
+ ## Installation
19
+ ```bash
20
+ pip install eap-middleware
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```python
26
+ from eap import EapEngine
27
+
28
+ # Automatically loads configuration from environment variables
29
+ engine = EapEngine.from_env()
30
+
31
+ # In a FastAPI/Starlette app:
32
+ app.add_middleware(engine.asgi_middleware())
33
+ ```
34
+
35
+ ## Configuration (Environment Variables)
36
+
37
+ When initializing the engine with `EapEngine.from_env()`, EAP reads configuration directly from environment variables. Configure these variables in your host environment or `.env` file:
38
+
39
+ | Variable | Description | Default |
40
+ | :--- | :--- | :--- |
41
+ | `JWT_SECRET` | Cryptographic secret used to sign session cookies (`HS256`). | *Required* |
42
+ | `ALLOWED_EMAILS` | Comma-separated whitelist of allowed user emails or domains (e.g. `admin@gmail.com, @iiitkota.ac.in`). | *Required* |
43
+ | `GOOGLE_CLIENT_ID` | Client ID from your Google Cloud Console Credentials. | *Required for Web OAuth* |
44
+ | `GOOGLE_CLIENT_SECRET` | Client Secret from your Google Cloud Console Credentials. | *Required for Web OAuth* |
45
+ | `GOOGLE_REDIRECT_URL` | Redirect URI registered in your Google Console (e.g., `http://localhost:8080/auth/callback`). | *Required for Web OAuth* |
46
+ | `TARGET_URL` | Set to `"tunnel"` to use localtunnel gateway, or backend service URL. | `None` |
47
+ | `TUNNEL_TOKEN` | Secure token required to authenticate tunnel clients (if `TARGET_URL=tunnel`). | `None` |
48
+ | `RATE_LIMIT_PER_SEC` | Requests per second limit for web users. | `3.0` |
49
+ | `RATE_BURST` | Max burst capacity for web users. | `5.0` |
50
+ | `S2S_RATE_LIMIT_PER_SEC` | Requests per second limit for Server-to-Server API clients. | `30.0` |
51
+ | `S2S_RATE_BURST` | Max burst capacity for Server-to-Server API clients. | `100.0` |
@@ -0,0 +1,5 @@
1
+ eap.py,sha256=BYJwVGELwHeWFkZI7dXT1sCrbX5aJq7TmKH0v9M7pgM,20664
2
+ eap_middleware-1.0.0.dist-info/METADATA,sha256=0TgYHF-dOSa6BIfb4Wz2r0T4pvZvHALsvpNp57J3C5g,2276
3
+ eap_middleware-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
4
+ eap_middleware-1.0.0.dist-info/top_level.txt,sha256=-HZDrvt8BBk-gXZavLnSArPnCp1kpki4VEwqdZihh8M,4
5
+ eap_middleware-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ eap