verge-auth-sdk 0.1.14__py3-none-any.whl → 0.1.48__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.
@@ -2,7 +2,7 @@
2
2
  """
3
3
  verge_auth_sdk package public API.
4
4
  Exports:
5
- - add_central_auth(app) -> middleware to attach to FastAPI app
5
+ - add_central_auth(app) -> middleware to attach to Python app
6
6
  - get_secret(name) -> secret retrieval helper
7
7
  """
8
8
  from .middleware import add_central_auth
@@ -1,135 +1,879 @@
1
+ # from fastapi import FastAPI, Request
2
+ # from fastapi.responses import RedirectResponse, JSONResponse
3
+ # import httpx
4
+ # import os
5
+ # import asyncio
6
+ # import jwt
7
+ # from typing import List
8
+ # from .secret_provider import get_secret
9
+ # from .verge_routes import router as verge_routes_router
10
+
11
+
12
+ # def is_request_secure(request: Request) -> bool:
13
+ # """
14
+ # Returns True when the browser-facing connection is HTTPS.
15
+ # Works for:
16
+ # - Real production HTTPS
17
+ # - Ngrok (HTTPS → HTTP backend)
18
+ # - Cloud LB / Nginx / ALB setups
19
+ # """
20
+ # # Case 1: Native HTTPS
21
+ # if request.url.scheme == "https":
22
+ # return True
23
+
24
+ # # Case 2: Behind proxy / ngrok
25
+ # forwarded_proto = request.headers.get("x-forwarded-proto")
26
+ # if forwarded_proto and forwarded_proto.lower() == "https":
27
+ # return True
28
+
29
+ # return False
30
+
31
+
32
+ # REGISTERED_ROUTES: List = []
33
+
34
+ # # ============================================================
35
+ # # GLOBAL JWT CACHE
36
+ # # ============================================================
37
+ # JWT_PUBLIC_KEY: str | None = None
38
+ # JWT_KEY_ID: str | None = None
39
+ # JWT_ALGORITHMS = ["RS256"]
40
+
41
+
42
+ # # -----------------------------------------------------------
43
+ # # HTTP helper with retries
44
+ # # -----------------------------------------------------------
45
+ # async def _post_with_retries(
46
+ # client,
47
+ # url,
48
+ # json=None,
49
+ # headers=None,
50
+ # timeout=10,
51
+ # retries=8,
52
+ # backoff=1,
53
+ # ):
54
+ # last_exc = None
55
+ # for attempt in range(1, retries + 1):
56
+ # try:
57
+ # resp = await client.post(
58
+ # url,
59
+ # json=json,
60
+ # headers=headers,
61
+ # timeout=timeout,
62
+ # )
63
+ # return resp
64
+ # except Exception as e:
65
+ # last_exc = e
66
+ # print(
67
+ # f"❗ Retry {attempt}/{retries} failed for {url}: "
68
+ # f"{type(e).__name__}: {e}"
69
+ # )
70
+ # await asyncio.sleep(backoff * attempt)
71
+ # raise last_exc
72
+
73
+
74
+ # # -----------------------------------------------------------
75
+ # # PUBLIC KEY DISCOVERY
76
+ # # -----------------------------------------------------------
77
+ # async def load_public_key(force: bool = False):
78
+ # global JWT_PUBLIC_KEY, JWT_KEY_ID
79
+
80
+ # if JWT_PUBLIC_KEY and not force:
81
+ # return
82
+
83
+ # AUTH_PUBLIC_KEY_URL = os.getenv("AUTH_PUBLIC_KEY_URL")
84
+ # if not AUTH_PUBLIC_KEY_URL:
85
+ # print("❌ AUTH_PUBLIC_KEY_URL not set! Please Set it.")
86
+ # return
87
+
88
+ # try:
89
+ # async with httpx.AsyncClient(timeout=5) as client:
90
+ # resp = await client.get(AUTH_PUBLIC_KEY_URL)
91
+ # resp.raise_for_status()
92
+ # data = resp.json()
93
+
94
+ # JWT_PUBLIC_KEY = data.get("public_key")
95
+ # JWT_KEY_ID = data.get("kid")
96
+
97
+ # if JWT_PUBLIC_KEY:
98
+ # print("✅ Security Key Loaded Successfully")
99
+ # else:
100
+ # print("❌ Security Key Loading Failed")
101
+
102
+ # except Exception as e:
103
+ # print("❌ Failed to load Security Key:", str(e))
104
+
105
+
106
+ # # -----------------------------------------------------------
107
+ # # MAIN INTEGRATION
108
+ # # -----------------------------------------------------------
109
+ # def add_central_auth(app: FastAPI):
110
+ # AUTH_BASE_URL = os.getenv("AUTH_BASE_URL")
111
+ # SERVICE_NAME = os.getenv("SERVICE_NAME")
112
+ # SERVICE_BASE_URL = os.getenv("SERVICE_BASE_URL")
113
+ # CLIENT_ID = os.getenv("VERGE_CLIENT_ID")
114
+ # CLIENT_SECRET = os.getenv("VERGE_CLIENT_SECRET")
115
+ # VERGE_SERVICE_SECRET = get_secret("VERGE_SERVICE_SECRET")
116
+ # AUTH_REGISTER_URL = os.getenv("AUTH_REGISTER_URL")
117
+ # AUTH_ROUTE_SYNC_URL = os.getenv("AUTH_ROUTE_SYNC_URL")
118
+ # INTROSPECT_URL = os.getenv("AUTH_INTROSPECT_URL")
119
+
120
+ # # Include internal verge routes
121
+ # app.include_router(verge_routes_router)
122
+
123
+ # # -------------------------------------------------------
124
+ # # MICROSERVICE BOOTSTRAP ON STARTUP
125
+ # # -------------------------------------------------------
126
+ # @app.on_event("startup")
127
+ # async def verge_bootstrap():
128
+ # print("🔥 Verge Auth started")
129
+
130
+ # # Load JWT public key
131
+ # await load_public_key(force=True)
132
+ # await asyncio.sleep(2)
133
+
134
+ # REGISTERED_ROUTES.clear()
135
+ # print("📌 Collecting routes...")
136
+
137
+ # for route in app.routes:
138
+ # try:
139
+ # path = getattr(route, "path", None)
140
+ # methods = getattr(route, "methods", [])
141
+
142
+ # if not path:
143
+ # continue
144
+
145
+ # if path.startswith(("/docs", "/openapi", "/__verge__")):
146
+ # continue
147
+
148
+ # for m in methods:
149
+ # if m in ("GET", "POST", "PUT", "PATCH", "DELETE"):
150
+ # REGISTERED_ROUTES.append({"path": path, "method": m})
151
+
152
+ # except Exception as e:
153
+ # print("❌ Error collecting route:", e)
154
+
155
+ # print("\n📡 Registering service with Verge Auth...")
156
+
157
+ # async with httpx.AsyncClient() as client:
158
+ # if AUTH_REGISTER_URL:
159
+ # try:
160
+ # resp = await _post_with_retries(
161
+ # client,
162
+ # AUTH_REGISTER_URL,
163
+ # json={
164
+ # "service_name": SERVICE_NAME,
165
+ # "base_url": SERVICE_BASE_URL,
166
+ # },
167
+ # headers={
168
+ # "X-Client-Id": CLIENT_ID or "",
169
+ # "X-Client-Secret": CLIENT_SECRET or "",
170
+ # "X-Verge-Service-Secret": VERGE_SERVICE_SECRET or "",
171
+ # },
172
+ # )
173
+ # print(
174
+ # "📡 Registration response:",
175
+ # resp.status_code,
176
+ # resp.text,
177
+ # )
178
+ # except Exception as e:
179
+ # print("❌ Registration failed:", e)
180
+
181
+ # if AUTH_ROUTE_SYNC_URL:
182
+ # try:
183
+ # resp = await _post_with_retries(
184
+ # client,
185
+ # AUTH_ROUTE_SYNC_URL,
186
+ # json={
187
+ # "service_name": SERVICE_NAME,
188
+ # "base_url": SERVICE_BASE_URL,
189
+ # "routes": REGISTERED_ROUTES,
190
+ # },
191
+ # headers={
192
+ # "X-Client-Id": CLIENT_ID or "",
193
+ # "X-Client-Secret": CLIENT_SECRET or "",
194
+ # "X-Verge-Service-Secret": VERGE_SERVICE_SECRET or "",
195
+ # },
196
+ # timeout=20,
197
+ # )
198
+ # print(
199
+ # "📡 Route sync response:",
200
+ # resp.status_code,
201
+ # resp.text,
202
+ # )
203
+ # except Exception as e:
204
+ # print("❌ Route sync failed:", e)
205
+
206
+ # # -------------------------------------------------------
207
+ # # CENTRAL AUTHZ MIDDLEWARE (CORRECTED FLOW)
208
+ # # -------------------------------------------------------
209
+ # @app.middleware("http")
210
+ # async def central_auth(request: Request, call_next):
211
+ # path = request.url.path
212
+
213
+ # SKIP_PATHS = {
214
+ # "/health",
215
+ # "/openapi.json",
216
+ # "/favicon.ico",
217
+ # "/service-registry/register",
218
+ # "/route-sync",
219
+ # "/__verge__",
220
+ # "/post-login",
221
+ # }
222
+
223
+ # # safer matching (handles trailing slash)
224
+ # if path.rstrip("/") in SKIP_PATHS or path.startswith("/__verge__"):
225
+ # return await call_next(request)
226
+
227
+ # # ---------------------------------------------------
228
+ # # STEP 1 — HANDLE AUTH CODE FIRST
229
+ # # ---------------------------------------------------
230
+ # if "code" in request.query_params:
231
+ # code = request.query_params.get("code")
232
+
233
+ # # Prevent infinite redirect loop
234
+ # if request.cookies.get("verge_access"):
235
+ # return await call_next(request)
236
+
237
+ # try:
238
+ # async with httpx.AsyncClient(timeout=5) as client:
239
+ # resp = await client.post(
240
+ # f"{AUTH_BASE_URL}/auth/exchange",
241
+ # json={"code": code},
242
+ # headers={
243
+ # "X-Client-Id": os.getenv("VERGE_CLIENT_ID") or "",
244
+ # "X-Client-Secret": os.getenv("VERGE_CLIENT_SECRET") or "",
245
+ # },
246
+ # )
247
+ # resp.raise_for_status()
248
+ # token = resp.json().get("access_token")
249
+
250
+ # if not token:
251
+ # return JSONResponse(
252
+ # {"detail": "Authorization failed: no token returned"},
253
+ # status_code=401,
254
+ # )
255
+ # clean_url = str(request.url.remove_query_params("code"))
256
+ # response = RedirectResponse(clean_url)
257
+ # response.set_cookie(
258
+ # "verge_access",
259
+ # token,
260
+ # httponly=True,
261
+ # secure=is_request_secure(request),
262
+ # samesite="lax",
263
+ # path="/",
264
+ # )
265
+
266
+ # response.set_cookie(
267
+ # "verge_fresh_auth",
268
+ # "1",
269
+ # httponly=True,
270
+ # secure=is_request_secure(request),
271
+ # samesite="lax",
272
+ # path="/",
273
+ # max_age=5, # only valid for 5 seconds
274
+ # )
275
+ # return response
276
+
277
+ # except Exception as e:
278
+ # return JSONResponse(
279
+ # {"detail": "Authorization failed", "error": str(e)},
280
+ # status_code=401,
281
+ # )
282
+
283
+ # # ---------------------------------------------------
284
+ # # STEP 2 — COLLECT TOKEN (cookie → header → session)
285
+ # # ---------------------------------------------------
286
+ # token = request.cookies.get("verge_access")
287
+
288
+ # if not token:
289
+ # auth_header = request.headers.get("authorization")
290
+ # if auth_header and auth_header.lower().startswith("bearer "):
291
+ # token = auth_header.split(" ", 1)[1].strip()
292
+
293
+ # if not token and "session" in request.scope:
294
+ # token = request.scope["session"].get("access_token")
295
+
296
+ # # if getattr(request.state, "_just_authenticated", False):
297
+ # # return await call_next(request)
298
+
299
+ # if request.cookies.get("verge_fresh_auth") == "1":
300
+ # response = await call_next(request)
301
+
302
+ # # clean up the flag so normal auth resumes next time
303
+ # response.delete_cookie("verge_fresh_auth")
304
+ # return response
305
+
306
+ # # ---------------------------------------------------
307
+ # # STEP 3 — FAIL CLEANLY IF STILL NO TOKEN
308
+ # # ---------------------------------------------------
309
+ # if not token:
310
+ # login_url = f"{os.getenv('AUTH_LOGIN_URL')}?redirect={request.url}"
311
+ # return RedirectResponse(login_url)
312
+
313
+ # # ---------------------------------------------------
314
+ # # STEP 4 — LOCAL JWT VERIFICATION
315
+ # # ---------------------------------------------------
316
+ # try:
317
+ # if not JWT_PUBLIC_KEY:
318
+ # await load_public_key(force=True)
319
+
320
+ # if not JWT_PUBLIC_KEY:
321
+ # return JSONResponse(
322
+ # {"detail": "Auth key not ready"},
323
+ # status_code=503,
324
+ # )
325
+
326
+ # payload = jwt.decode(
327
+ # token,
328
+ # JWT_PUBLIC_KEY,
329
+ # algorithms=JWT_ALGORITHMS,
330
+ # options={"require": ["exp", "iat"]},
331
+ # )
332
+
333
+ # except jwt.ExpiredSignatureError:
334
+ # return JSONResponse({"detail": "Token expired"}, status_code=401)
335
+ # except jwt.InvalidTokenError:
336
+ # return JSONResponse({"detail": "Invalid token"}, status_code=401)
337
+ # except Exception as e:
338
+ # return JSONResponse(
339
+ # {"detail": "Auth verification failed", "error": str(e)},
340
+ # status_code=401,
341
+ # )
342
+
343
+ # # ---------------------------------------------------
344
+ # # STEP 5 — CENTRAL INTROSPECTION
345
+ # # ---------------------------------------------------
346
+ # try:
347
+ # async with httpx.AsyncClient(timeout=5) as client:
348
+ # resp = await client.post(
349
+ # INTROSPECT_URL,
350
+ # headers={
351
+ # "Authorization": f"Bearer {token}",
352
+ # "X-Client-Id": os.getenv("VERGE_CLIENT_ID") or "",
353
+ # "X-Client-Secret": os.getenv("VERGE_CLIENT_SECRET") or "",
354
+ # },
355
+ # )
356
+
357
+ # data = resp.json()
358
+ # if not data.get("active"):
359
+ # return JSONResponse(
360
+ # {
361
+ # "detail": "Session inactive",
362
+ # "reason": data.get("reason"),
363
+ # },
364
+ # status_code=401,
365
+ # )
366
+
367
+ # request.state.introspect = data
368
+
369
+ # except Exception as e:
370
+ # return JSONResponse(
371
+ # {"detail": "Auth introspection failed", "error": str(e)},
372
+ # status_code=401,
373
+ # )
374
+
375
+ # # ---------------------------------------------------
376
+ # # STEP 6 — PERMISSION CHECK
377
+ # # ---------------------------------------------------
378
+ # request.state.user = payload
379
+ # permissions = payload.get("roles") or []
380
+
381
+ # route_obj = request.scope.get("route")
382
+ # route_path = route_obj.path if route_obj else path
383
+ # method = request.method
384
+
385
+ # # 🔹 SPECIAL CASE: allow redoc AFTER auth
386
+ # if path.rstrip("/") in {"/redoc", "/docs"}:
387
+ # return await call_next(request)
388
+
389
+ # required_key = f"{SERVICE_NAME}:{route_path}:{method}".lower()
390
+ # normalized_permissions = [p.lower() for p in permissions]
391
+
392
+ # if required_key not in normalized_permissions:
393
+ # return JSONResponse(
394
+ # {"detail": "Contact admin for access"},
395
+ # status_code=403,
396
+ # )
397
+
398
+ # return await call_next(request)
399
+
400
+
1
401
  from fastapi import FastAPI, Request
2
402
  from fastapi.responses import RedirectResponse, JSONResponse
3
403
  import httpx
4
404
  import os
5
-
405
+ import asyncio
406
+ import jwt
407
+ from typing import List
6
408
  from .secret_provider import get_secret
7
409
  from .verge_routes import router as verge_routes_router
8
410
 
9
- REGISTERED_ROUTES = []
10
411
 
412
+ def is_request_secure(request: Request) -> bool:
413
+ """
414
+ Returns True when the browser-facing connection is HTTPS.
415
+ Works for:
416
+ - Real production HTTPS
417
+ - Ngrok (HTTPS → HTTP backend)
418
+ - Cloud LB / Nginx / ALB setups
419
+ """
420
+ # Case 1: Native HTTPS
421
+ if request.url.scheme == "https":
422
+ return True
11
423
 
12
- def add_central_auth(app: FastAPI):
424
+ # Case 2: Behind proxy / ngrok
425
+ forwarded_proto = request.headers.get("x-forwarded-proto")
426
+ if forwarded_proto and forwarded_proto.lower() == "https":
427
+ return True
13
428
 
14
- AUTH_INTROSPECT_URL = os.getenv("AUTH_INTROSPECT_URL")
15
- AUTH_LOGIN_URL = os.getenv("AUTH_LOGIN_URL")
429
+ return False
16
430
 
17
- CLIENT_ID = os.getenv("VERGE_CLIENT_ID")
18
- CLIENT_SECRET = os.getenv("VERGE_CLIENT_SECRET")
19
431
 
432
+ REGISTERED_ROUTES: List = []
433
+
434
+ # ============================================================
435
+ # GLOBAL JWT CACHE
436
+ # ============================================================
437
+ JWT_PUBLIC_KEY: str | None = None
438
+ JWT_KEY_ID: str | None = None
439
+ JWT_ALGORITHMS = ["RS256"]
440
+
441
+
442
+ # -----------------------------------------------------------
443
+ # HTTP helper with retries
444
+ # -----------------------------------------------------------
445
+ async def _post_with_retries(
446
+ client,
447
+ url,
448
+ json=None,
449
+ headers=None,
450
+ timeout=10,
451
+ retries=8,
452
+ backoff=1,
453
+ ):
454
+ last_exc = None
455
+ for attempt in range(1, retries + 1):
456
+ try:
457
+ resp = await client.post(
458
+ url,
459
+ json=json,
460
+ headers=headers,
461
+ timeout=timeout,
462
+ )
463
+ return resp
464
+ except Exception as e:
465
+ last_exc = e
466
+ print(
467
+ f"❗ Retry {attempt}/{retries} failed for {url}: "
468
+ f"{type(e).__name__}: {e}"
469
+ )
470
+ await asyncio.sleep(backoff * attempt)
471
+ raise last_exc
472
+
473
+
474
+ # -----------------------------------------------------------
475
+ # PUBLIC KEY DISCOVERY
476
+ # -----------------------------------------------------------
477
+ async def load_public_key(force: bool = False):
478
+ global JWT_PUBLIC_KEY, JWT_KEY_ID
479
+
480
+ if JWT_PUBLIC_KEY and not force:
481
+ return
482
+
483
+ AUTH_PUBLIC_KEY_URL = os.getenv("AUTH_PUBLIC_KEY_URL")
484
+ if not AUTH_PUBLIC_KEY_URL:
485
+ print("❌ AUTH_PUBLIC_KEY_URL not set! Please Set it.")
486
+ return
487
+
488
+ try:
489
+ async with httpx.AsyncClient(timeout=50) as client:
490
+ resp = await client.get(AUTH_PUBLIC_KEY_URL)
491
+ resp.raise_for_status()
492
+ data = resp.json()
493
+
494
+ JWT_PUBLIC_KEY = data.get("public_key")
495
+ JWT_KEY_ID = data.get("kid")
496
+
497
+ if JWT_PUBLIC_KEY:
498
+ print("✅ Security Key Loaded Successfully")
499
+ else:
500
+ print("❌ Security Key Loading Failed")
501
+
502
+ except Exception as e:
503
+ print("❌ Failed to load Security Key:", str(e))
504
+
505
+
506
+ # -----------------------------------------------------------
507
+ # MAIN INTEGRATION
508
+ # -----------------------------------------------------------
509
+ def add_central_auth(app: FastAPI):
510
+ AUTH_BASE_URL = os.getenv("AUTH_BASE_URL")
20
511
  SERVICE_NAME = os.getenv("SERVICE_NAME")
21
512
  SERVICE_BASE_URL = os.getenv("SERVICE_BASE_URL")
513
+ CLIENT_ID = os.getenv("VERGE_CLIENT_ID")
514
+ CLIENT_SECRET = os.getenv("VERGE_CLIENT_SECRET")
515
+ VERGE_SERVICE_SECRET = get_secret("VERGE_SERVICE_SECRET")
22
516
  AUTH_REGISTER_URL = os.getenv("AUTH_REGISTER_URL")
517
+ AUTH_ROUTE_SYNC_URL = os.getenv("AUTH_ROUTE_SYNC_URL")
518
+ INTROSPECT_URL = os.getenv("AUTH_INTROSPECT_URL")
23
519
 
24
- VERGE_SECRET = get_secret("VERGE_SERVICE_SECRET")
25
-
26
- # ---------------------------------------
27
- # INTERNAL ROUTES (MUST LOAD FIRST)
28
- # ---------------------------------------
520
+ # Include internal verge routes
29
521
  app.include_router(verge_routes_router)
30
522
 
31
- # ---------------------------------------
32
- # STARTUP — ONLY COLLECT ROUTES (NO REGISTER)
33
- # ---------------------------------------
523
+ # ================== ADD THIS BLOCK HERE ==================
524
+ @app.post("/__verge__/set-cookie")
525
+ def verge_set_cookie(request: Request):
526
+ auth = request.headers.get("authorization")
527
+ if not auth:
528
+ return JSONResponse({"detail": "Missing Authorization header"}, status_code=401)
529
+
530
+ token = auth.split(" ")[1]
531
+
532
+ response = JSONResponse({"ok": True})
533
+ response.set_cookie(
534
+ "verge_access",
535
+ token,
536
+ httponly=True,
537
+ secure=True,
538
+ # samesite="lax",
539
+ samesite="None",
540
+ path="/",
541
+ )
542
+ return response
543
+ # ================== END OF INSERT =======================
544
+
545
+ # -------------------------------------------------------
546
+ # MICROSERVICE BOOTSTRAP ON STARTUP
547
+ # -------------------------------------------------------
34
548
  @app.on_event("startup")
35
- async def collect_routes():
36
- print("🔥 Verge bootstrap started")
549
+ async def verge_bootstrap():
550
+ print("🔥 Verge Auth started")
551
+
552
+ # Load JWT public key
553
+ await load_public_key(force=True)
554
+ await asyncio.sleep(2)
555
+
37
556
  REGISTERED_ROUTES.clear()
557
+ print("📌 Collecting routes...")
38
558
 
39
559
  for route in app.routes:
40
- path = getattr(route, "path", None)
41
- methods = getattr(route, "methods", [])
560
+ try:
561
+ path = getattr(route, "path", None)
562
+ methods = getattr(route, "methods", [])
42
563
 
43
- if not path:
44
- continue
564
+ if not path:
565
+ continue
45
566
 
46
- if path.startswith(("/docs", "/openapi", "/__verge__")):
47
- continue
567
+ if path.startswith(("/docs", "/openapi", "/__verge__")):
568
+ continue
48
569
 
49
- for m in methods:
50
- if m in ("GET", "POST", "PUT", "PATCH", "DELETE"):
51
- REGISTERED_ROUTES.append({"path": path, "method": m})
570
+ for m in methods:
571
+ if m in ("GET", "POST", "PUT", "PATCH", "DELETE"):
572
+ REGISTERED_ROUTES.append({"path": path, "method": m})
52
573
 
53
- print("✅ Collected Routes:", REGISTERED_ROUTES)
574
+ except Exception as e:
575
+ print("❌ Error collecting route:", e)
54
576
 
55
- # ---------------------------------------
56
- # SUPER ADMIN TRIGGERED REGISTRATION
57
- # ---------------------------------------
58
- async def register_with_auth():
59
- try:
60
- async with httpx.AsyncClient(timeout=5) as client:
61
- resp = await client.post(
62
- AUTH_REGISTER_URL,
63
- params={
64
- "name": SERVICE_NAME,
65
- "base_url": SERVICE_BASE_URL,
66
- },
67
- headers={"X-Verge-Service-Secret": VERGE_SECRET}
68
- )
577
+ print("\n📡 Registering service with Verge Auth...")
69
578
 
70
- print(f"📡 Registration: {resp.status_code}")
71
- print(resp.text)
579
+ async with httpx.AsyncClient() as client:
580
+ if AUTH_REGISTER_URL:
581
+ try:
582
+ resp = await _post_with_retries(
583
+ client,
584
+ AUTH_REGISTER_URL,
585
+ json={
586
+ "service_name": SERVICE_NAME,
587
+ "base_url": SERVICE_BASE_URL,
588
+ },
589
+ headers={
590
+ "X-Client-Id": CLIENT_ID or "",
591
+ "X-Client-Secret": CLIENT_SECRET or "",
592
+ "X-Verge-Service-Secret": VERGE_SERVICE_SECRET or "",
593
+ },
594
+ )
595
+ print(
596
+ "📡 Registration response:",
597
+ resp.status_code,
598
+ resp.text,
599
+ )
600
+ except Exception as e:
601
+ print("❌ Registration failed:", e)
72
602
 
73
- except Exception as e:
74
- print("❌ Registration failed:", e)
603
+ if AUTH_ROUTE_SYNC_URL:
604
+ try:
605
+ resp = await _post_with_retries(
606
+ client,
607
+ AUTH_ROUTE_SYNC_URL,
608
+ json={
609
+ "service_name": SERVICE_NAME,
610
+ "base_url": SERVICE_BASE_URL,
611
+ "routes": REGISTERED_ROUTES,
612
+ },
613
+ headers={
614
+ "X-Client-Id": CLIENT_ID or "",
615
+ "X-Client-Secret": CLIENT_SECRET or "",
616
+ "X-Verge-Service-Secret": VERGE_SERVICE_SECRET or "",
617
+ },
618
+ timeout=20,
619
+ )
620
+ print(
621
+ "📡 Route sync response:",
622
+ resp.status_code,
623
+ resp.text,
624
+ )
625
+ except Exception as e:
626
+ print("❌ Route sync failed:", e)
75
627
 
76
- # ---------------------------------------
77
- # MIDDLEWARE AUTH
78
- # ---------------------------------------
628
+ # -------------------------------------------------------
629
+ # CENTRAL AUTHZ MIDDLEWARE (FIXED FLOW)
630
+ # -------------------------------------------------------
79
631
  @app.middleware("http")
80
632
  async def central_auth(request: Request, call_next):
81
633
  path = request.url.path
82
634
 
83
- # Whitelisted paths
84
- if path.startswith("/__verge__") or path in {"/docs", "/openapi.json", "/health"}:
635
+ SKIP_PATHS = {
636
+ "/service-registry/register",
637
+ "/route-sync",
638
+ "/__verge__",
639
+ }
640
+
641
+ # Safer matching (handles trailing slash)
642
+ normalized_path = path.rstrip("/")
643
+ if normalized_path in SKIP_PATHS or path.startswith("/__verge__"):
85
644
  return await call_next(request)
86
645
 
87
- # Extract token
88
- token = None
89
- auth_header = request.headers.get("authorization")
646
+ # ---------------------------------------------------
647
+ # STEP 1 — HANDLE AUTH CODE EXCHANGE (SERVICE-SIDE ONLY)
648
+ # ---------------------------------------------------
649
+ # NOTE: The auth frontend already exchanges codes via /auth/exchange
650
+ # This middleware only needs to handle codes coming from /services/launch
651
+ # which generates codes for microservice access
652
+
653
+ # code = request.query_params.get("code")
654
+ # print("code in request param", code)
655
+
656
+ # if code:
657
+ # # Check if we already have a valid token cookie
658
+ # existing_token = request.cookies.get("verge_access")
659
+ # print("existing token ", existing_token)
660
+ # if existing_token:
661
+ # # Already authenticated - just clean URL and proceed
662
+ # clean_url = str(request.url.remove_query_params("code"))
663
+ # print("clean_url RedirectResponse", clean_url)
664
+ # return RedirectResponse(clean_url, status_code=302)
665
+
666
+ # # Exchange code for token (from /services/launch flow)
667
+ # try:
668
+ # async with httpx.AsyncClient(timeout=30) as client:
669
+ # resp = await client.post(
670
+ # f"{AUTH_BASE_URL}/auth/exchange",
671
+ # json={"code": code},
672
+ # headers={
673
+ # "X-Client-Id": CLIENT_ID or "",
674
+ # "X-Client-Secret": CLIENT_SECRET or "",
675
+ # },
676
+ # )
677
+ # resp.raise_for_status()
678
+ # data = resp.json()
679
+ # token = data.get("access_token")
680
+
681
+ # if not token:
682
+ # return JSONResponse(
683
+ # {"detail": "Authorization failed: no token returned"},
684
+ # status_code=401,
685
+ # )
686
+
687
+ # # Set cookie with token and redirect to clean URL
688
+ # clean_url = str(request.url.remove_query_params("code"))
689
+ # response = RedirectResponse(clean_url, status_code=302)
690
+ # print("clean url when no existinig toke ", clean_url)
691
+ # response.set_cookie(
692
+ # key="verge_access",
693
+ # value=token,
694
+ # httponly=True,
695
+ # secure=is_request_secure(request),
696
+ # samesite="lax",
697
+ # path="/",
698
+ # max_age=28800, # 8 hours to match session
699
+ # )
700
+
701
+ # return response
702
+
703
+ # except httpx.HTTPStatusError as e:
704
+ # error_detail = e.response.text if hasattr(
705
+ # e.response, 'text') else str(e)
706
+ # print(f"❌ Code exchange failed: {error_detail}")
707
+ # return JSONResponse(
708
+ # {
709
+ # "detail": "Authorization code exchange failed",
710
+ # "error": error_detail,
711
+ # "status_code": e.response.status_code if hasattr(e.response, 'status_code') else 500
712
+ # },
713
+ # status_code=401,
714
+ # )
715
+ # except Exception as e:
716
+ # print(f"❌ Code exchange error: {str(e)}")
717
+ # return JSONResponse(
718
+ # {"detail": "Authorization failed", "error": str(e)},
719
+ # status_code=401,
720
+ # )
90
721
 
91
- if auth_header and auth_header.lower().startswith("bearer "):
92
- token = auth_header.split(" ")[1]
722
+ # ---------------------------------------------------
723
+ # STEP 2 — COLLECT TOKEN (cookie → header → session)
724
+ # ---------------------------------------------------
725
+ token = request.cookies.get("verge_access")
93
726
 
94
727
  if not token:
95
- token = request.cookies.get("access_token")
728
+ auth_header = request.headers.get("authorization")
729
+ if auth_header and auth_header.lower().startswith("bearer "):
730
+ token = auth_header.split(" ", 1)[1].strip()
96
731
 
97
- # redirect if HTML request
98
- if not token and "text/html" in request.headers.get("accept", ""):
99
- return RedirectResponse(
100
- f"{AUTH_LOGIN_URL}?redirect_url={request.url}"
101
- )
732
+ if not token and "session" in request.scope:
733
+ token = request.scope["session"].get("access_token")
734
+
735
+ # ---------------------------------------------------
736
+ # STEP 3 — REDIRECT TO LOGIN IF NO TOKEN
737
+ # ---------------------------------------------------
738
+ # add the paths that needs to be public paths ex "/redoc", "/docs"
739
+ _raw_public = os.getenv("PUBLIC_PATHS", "")
740
+ PUBLIC_PATHS = {
741
+ "/" + p.strip("/ ")
742
+ for p in _raw_public.split(",")
743
+ if p.strip()
744
+ }
102
745
 
103
746
  if not token:
104
- return JSONResponse({"detail": "Unauthorized"}, status_code=401)
747
+ if normalized_path in PUBLIC_PATHS:
748
+ return await call_next(request)
749
+
750
+ # login_url = f"{os.getenv('AUTH_LOGIN_URL')}?redirect={request.url}"
751
+ login_url = f"{os.getenv('AUTH_LOGIN_URL')}?redirect_url={request.url}"
752
+
753
+ print("Login_Url ***", login_url)
105
754
 
106
- # Validate token
755
+ return RedirectResponse(login_url, status_code=302)
756
+
757
+ # ---------------------------------------------------
758
+ # STEP 4 — LOCAL JWT VERIFICATION
759
+ # ---------------------------------------------------
107
760
  try:
108
- async with httpx.AsyncClient(timeout=4) as client:
109
- res = await client.post(
110
- AUTH_INTROSPECT_URL,
111
- headers={
112
- "Authorization": f"Bearer {token}",
113
- "X-Client-Id": CLIENT_ID,
114
- "X-Client-Secret": CLIENT_SECRET,
115
- },
761
+ if not JWT_PUBLIC_KEY:
762
+ await load_public_key(force=True)
763
+
764
+ if not JWT_PUBLIC_KEY:
765
+ return JSONResponse(
766
+ {"detail": "Auth key not ready, please try again"},
767
+ status_code=503,
116
768
  )
117
- data = res.json()
118
769
 
119
- except Exception:
120
- return JSONResponse({"detail": "Auth service unreachable"}, status_code=503)
770
+ payload = jwt.decode(
771
+ token,
772
+ JWT_PUBLIC_KEY,
773
+ algorithms=JWT_ALGORITHMS,
774
+ options={"require": ["exp", "iat"]},
775
+ )
121
776
 
122
- if not data.get("active"):
123
- return JSONResponse({"detail": "Session expired"}, status_code=401)
777
+ except jwt.ExpiredSignatureError:
778
+ # Clear expired cookie and redirect to login
779
+ response = RedirectResponse(
780
+ # f"{os.getenv('AUTH_LOGIN_URL')}?redirect={request.url}&reason=expired",
781
+ f"{os.getenv('AUTH_LOGIN_URL')}?redirect_url={request.url}&reason=expired",
782
+ status_code=302
783
+ )
784
+ response.delete_cookie("verge_access")
785
+ return response
124
786
 
125
- user = data.get("user", {})
126
- request.state.user = user
787
+ except jwt.InvalidTokenError as e:
788
+ # Clear invalid cookie and redirect to login
789
+ response = RedirectResponse(
790
+ # f"{os.getenv('AUTH_LOGIN_URL')}?redirect={request.url}&reason=invalid",
791
+ f"{os.getenv('AUTH_LOGIN_URL')}?redirect_url={request.url}&reason=invalid",
792
+ status_code=302
793
+ )
794
+ response.delete_cookie("verge_access")
795
+ return response
796
+
797
+ except Exception as e:
798
+ return JSONResponse(
799
+ {"detail": "Token verification failed", "error": str(e)},
800
+ status_code=401,
801
+ )
802
+
803
+ # ---------------------------------------------------
804
+ # STEP 5 — CENTRAL INTROSPECTION (Optional but recommended)
805
+ # ---------------------------------------------------
806
+ if INTROSPECT_URL:
807
+ try:
808
+ async with httpx.AsyncClient(timeout=5) as client:
809
+ resp = await client.post(
810
+ INTROSPECT_URL,
811
+ headers={
812
+ "Authorization": f"Bearer {token}",
813
+ "X-Client-Id": CLIENT_ID or "",
814
+ "X-Client-Secret": CLIENT_SECRET or "",
815
+ },
816
+ )
817
+
818
+ if resp.status_code == 200:
819
+ data = resp.json()
820
+ if not data.get("active"):
821
+ # Session revoked/inactive - clear cookie and redirect
822
+ response = RedirectResponse(
823
+ # f"{os.getenv('AUTH_LOGIN_URL')}?redirect={request.url}&reason=inactive",
824
+ f"{os.getenv('AUTH_LOGIN_URL')}?redirect_url={request.url}&reason=inactive",
825
+ status_code=302
826
+ )
827
+ response.delete_cookie("verge_access")
828
+ return response
829
+
830
+ request.state.introspect = data
831
+ else:
832
+ # Introspection failed - treat as unauthorized
833
+ response = RedirectResponse(
834
+ f"{os.getenv('AUTH_LOGIN_URL')}?redirect={request.url}",
835
+ status_code=302
836
+ )
837
+ response.delete_cookie("verge_access")
838
+ return response
839
+
840
+ except Exception as e:
841
+ # If introspection fails, log but continue with JWT validation
842
+ print(f"⚠️ Introspection failed: {e}")
843
+ # Could optionally fail open or closed here based on your security posture
127
844
 
128
- # ----------------------------------------------
129
- # SUPER ADMIN LOGGED IN → REGISTER SERVICE
130
- # ----------------------------------------------
131
- if user.get("is_super_admin", False):
132
- print("🔐 Super Admin logged in → triggering service registration")
133
- await register_with_auth()
845
+ # ---------------------------------------------------
846
+ # STEP 6 PERMISSION CHECK
847
+ # ---------------------------------------------------
848
+ request.state.user = payload
849
+ permissions = payload.get("roles") or []
850
+
851
+ route_obj = request.scope.get("route")
852
+ route_path = route_obj.path if route_obj else path
853
+ method = request.method
854
+
855
+ # # Allow docs/redoc after authentication
856
+ # if normalized_path in {"/redoc", "/docs"}:
857
+ # return await call_next(request)
858
+
859
+ if normalized_path in PUBLIC_PATHS:
860
+ return await call_next(request)
861
+
862
+ # Build permission key: service_name:route_path:method
863
+ required_key = f"{SERVICE_NAME}:{route_path}:{method}".lower()
864
+ normalized_permissions = [p.lower() for p in permissions]
865
+
866
+ if required_key not in normalized_permissions:
867
+ return JSONResponse(
868
+ {
869
+ "detail": "Insufficient permissions. Contact admin for access.",
870
+ "required": required_key,
871
+ "user_permissions": permissions
872
+ },
873
+ status_code=403,
874
+ )
134
875
 
876
+ # ---------------------------------------------------
877
+ # STEP 7 — PROCEED TO ROUTE HANDLER
878
+ # ---------------------------------------------------
135
879
  return await call_next(request)
@@ -10,8 +10,8 @@ async def verge_internal_routes(request: Request):
10
10
  expected_secret = get_secret("VERGE_SERVICE_SECRET")
11
11
  received_secret = request.headers.get("X-Verge-Service-Secret")
12
12
 
13
- # Enforce exact secret match
14
13
  if not expected_secret or expected_secret != received_secret:
14
+
15
15
  raise HTTPException(status_code=403, detail="Forbidden")
16
16
 
17
17
  collected = []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: verge-auth-sdk
3
- Version: 0.1.14
3
+ Version: 0.1.48
4
4
  Summary: Secure centralized authentication SDK for FastAPI microservices
5
5
  Author-email: Verge Infosoft <contactus@vergeinfosoft.com>
6
6
  License: MIT
@@ -14,7 +14,8 @@ Requires-Dist: python-dotenv
14
14
  Dynamic: license-file
15
15
 
16
16
  🔐 Verge Auth SDK
17
- Secure Identity & Access Management for FastAPI Microservices
17
+
18
+ Secure Identity & Access Management for FastAPI Microservices and all Python-based frameworks Monolithic and Microservice Architectures
18
19
 
19
20
  Verge Auth SDK is a lightweight integration library that connects your FastAPI microservices to the Verge Auth Platform — a centralized identity, role management, and access-control system built for modern SaaS applications.
20
21
 
@@ -53,8 +54,8 @@ Users sign up with their organization details, company domain, and email.
53
54
 
54
55
  2. Email Verification
55
56
 
56
- A verification link is sent from no-reply@vergeinfosoft.com
57
- .
57
+ A verification email is sent to the registered address.
58
+
58
59
  Once verified, the user is redirected to the Verge Auth platform.
59
60
 
60
61
  3. Login
@@ -155,6 +156,7 @@ Add users into the group
155
156
  This makes onboarding smoother and keeps role management scalable.
156
157
 
157
158
  🔌 Integrating the SDK Into a Microservice
159
+
158
160
  Install from PyPI
159
161
  pip install verge_auth_sdk
160
162
 
@@ -163,6 +165,8 @@ from fastapi import FastAPI
163
165
  from verge_auth_sdk import add_central_auth
164
166
 
165
167
  app = FastAPI()
168
+
169
+ # call this at the last line of your apps main
166
170
  add_central_auth(app)
167
171
 
168
172
  That’s it.
@@ -171,30 +175,55 @@ The service will now:
171
175
  ✓ Authenticate incoming requests
172
176
  ✓ Communicate securely with Verge Auth
173
177
  ✓ Provide user identity + roles
174
- Automatically register its routes for RBAC assignment
178
+ Secure synchronization of service access metadata for centralized permission governance.
175
179
 
176
180
  ⚙ Environment Configuration
177
181
 
178
182
  Each service requires a minimal set of environment variables:
183
+ Exact endpoint configurations and integration details may vary by deployment and are abstracted by the SDK.
184
+
185
+ ############## DO NOT CHANGE THIS #################################
179
186
 
180
- ######################################################################
187
+ AUTH_BASE_URL=https://auth.vergeinfosoft.com
188
+ AUTH_SESSION_URL=https://auth.vergeinfosoft.com/session
189
+ AUTH_INTROSPECT_URL=https://auth.vergeinfosoft.com/introspect
190
+ AUTH_REGISTER_URL=https://auth.vergeinfosoft.com/service-registry/register
191
+ AUTH_ROUTE_SYNC_URL=https://auth.vergeinfosoft.com/route-sync
192
+ AUTH_PUBLIC_KEY_URL=https://auth.vergeinfosoft.com/auth/keys/public
193
+ AUTH_LOGIN_URL=https://auth.vergeinfosoft.com/login
181
194
 
182
- AUTH_INTROSPECT_URL=<auth-server-introspection-endpoint>
183
- AUTH_LOGIN_URL=<auth-server-login-ui>
195
+ ############## DO NOT CHANGE THIS #################################
196
+
197
+
198
+ ################# CHANGE THESE AS PER DETAILS PROVIDED #############################################
184
199
 
185
200
  VERGE_CLIENT_ID=<client-id>
186
201
  VERGE_CLIENT_SECRET=<client-secret>
187
-
188
202
  VERGE_SERVICE_SECRET=<service-integration-secret>
189
-
190
203
  # These are provided by Verge Infosoft during onboarding.
191
204
 
192
- # Optional secret provider:
205
+ ####################################################################################################
193
206
 
194
- SECRETS_PROVIDER=env # azure | aws
207
+
208
+ # Select Optional secret provider:
209
+
210
+ SECRETS_PROVIDER=env | AZURE | AWS | GCP | ORACLE # Supported cloud providers for secret management
211
+
212
+ env=env # if you want to load from your local ENV
213
+ azure=<AZURE_URL>
214
+ aws=<AWS_URL>
215
+ gcp=<GCP_URL>
216
+ oracle=<ORACLE_URL>
217
+
218
+ ########################################################################
219
+
220
+ SERVICE_NAME=<SERVICE_NAME> # example billing service or hr service
221
+ SERVICE_BASE_URL=<SERVICE_BASE_URL> example https://hr.yourdomain.com
195
222
 
196
223
  ########################################################################
197
224
 
225
+
226
+
198
227
  🛡 Middleware Responsibilities
199
228
 
200
229
  The SDK transparently handles:
@@ -215,7 +244,7 @@ You do not need to implement any auth or RBAC logic manually.
215
244
 
216
245
  🔐 Security Highlights
217
246
 
218
- RSA-based JWT verification
247
+ Industry-standard asymmetric token verification with key rotation support
219
248
 
220
249
  Centralized session & token lifecycle management
221
250
 
@@ -227,7 +256,7 @@ HTTPS-only communication
227
256
 
228
257
  Support for cloud key vaults
229
258
 
230
- 💼 Ideal For
259
+ 💼 Ideal For (including but not limited to):
231
260
 
232
261
  HRMS, ERP, CRM, Billing platforms
233
262
 
@@ -0,0 +1,9 @@
1
+ verge_auth_sdk/__init__.py,sha256=fna_P4l-tc3ZLKwERFEdOpT4hqPQoJSikrbt_ze5yME,410
2
+ verge_auth_sdk/middleware.py,sha256=MJdukBVCMAwZbBcnHY-viccyc5TsUbX4Mbdp13at1MM,34019
3
+ verge_auth_sdk/secret_provider.py,sha256=j89rj9LZHl4qTI2fdf0qnn-mgDD3oTbdP3imsm0S9n8,1653
4
+ verge_auth_sdk/verge_routes.py,sha256=GAFS8HdSNznuh6two6DAHZZeq2t29l3IlzDJjBcOGKA,1413
5
+ verge_auth_sdk-0.1.48.dist-info/licenses/LICENSE,sha256=OLuFd1VUvl1QKIJJ_qJ8zR4ypcDhzeRkEywc7PX2ABQ,1090
6
+ verge_auth_sdk-0.1.48.dist-info/METADATA,sha256=H4XiQRVtQWbmsynBw4AT_dG7Zw3dwqyAIcXqE-VpmeE,7075
7
+ verge_auth_sdk-0.1.48.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ verge_auth_sdk-0.1.48.dist-info/top_level.txt,sha256=0ik2K2CJZrP11DvuR7USTYJkX_kv2DSJS5wyhP1yAfA,15
9
+ verge_auth_sdk-0.1.48.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- verge_auth_sdk/__init__.py,sha256=n76KSaK3CohCP7dzrUXWlTPNPGzWesLTlYl-C7rfW60,411
2
- verge_auth_sdk/middleware.py,sha256=2WKXMgW7mxaHbEyE1g8qpTM0SAcuXs4XVnB5xr5KEKs,4707
3
- verge_auth_sdk/secret_provider.py,sha256=j89rj9LZHl4qTI2fdf0qnn-mgDD3oTbdP3imsm0S9n8,1653
4
- verge_auth_sdk/verge_routes.py,sha256=R0WUb0OyIkapZ0dKhqfYzfhI6I_cnHSJsB6p-8lt9V4,1445
5
- verge_auth_sdk-0.1.14.dist-info/licenses/LICENSE,sha256=OLuFd1VUvl1QKIJJ_qJ8zR4ypcDhzeRkEywc7PX2ABQ,1090
6
- verge_auth_sdk-0.1.14.dist-info/METADATA,sha256=FqrFVVuW2xS5mSWo9xGRWR_nT3IFvVxNiG6pgcPuIwA,5702
7
- verge_auth_sdk-0.1.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- verge_auth_sdk-0.1.14.dist-info/top_level.txt,sha256=0ik2K2CJZrP11DvuR7USTYJkX_kv2DSJS5wyhP1yAfA,15
9
- verge_auth_sdk-0.1.14.dist-info/RECORD,,