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 @@
|
|
|
1
|
+
eap
|