light-infer-chat commited on
Commit
8604693
·
1 Parent(s): 870ca8d
app/api/server.py CHANGED
@@ -89,8 +89,33 @@ def create_application() -> FastAPI:
89
  app.include_router(api_v1_router, prefix="/api/v1")
90
 
91
  @app.get("/", include_in_schema=False)
92
- async def root():
93
- return {"message": f"{_settings.app_name} v{_settings.app_version} is running"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  @app.get("/health", include_in_schema=False)
96
  async def root_health():
 
89
  app.include_router(api_v1_router, prefix="/api/v1")
90
 
91
  @app.get("/", include_in_schema=False)
92
+ async def root(request: Request):
93
+ from collections import defaultdict
94
+ routes_by_tag: dict[str, list[dict]] = defaultdict(list)
95
+ for route in app.routes:
96
+ if not hasattr(route, "methods") or not hasattr(route, "path"):
97
+ continue
98
+ if route.path in ("/", "/health", "/ping", "/openapi.json", "/docs", "/redoc", "/docs/oauth2-redirect"):
99
+ continue
100
+ tags = getattr(route, "tags", None) or ["default"]
101
+ for tag in tags:
102
+ routes_by_tag[tag].append({
103
+ "method": list(route.methods - {"HEAD", "OPTIONS"}),
104
+ "path": route.path,
105
+ "summary": getattr(route, "summary", ""),
106
+ })
107
+ return {
108
+ "name": _settings.app_name,
109
+ "version": _settings.app_version,
110
+ "docs": {
111
+ "swagger": str(request.base_url) + "docs",
112
+ "redoc": str(request.base_url) + "redoc",
113
+ },
114
+ "endpoints": [
115
+ {"tag": tag, "routes": sorted(routes, key=lambda r: r["path"])}
116
+ for tag, routes in sorted(routes_by_tag.items())
117
+ ],
118
+ }
119
 
120
  @app.get("/health", include_in_schema=False)
121
  async def root_health():
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -19,4 +19,5 @@ api_v1_router.include_router(web_search.router, tags=["Web Search"])
19
  api_v1_router.include_router(sql_validator.router, tags=["SQL Validator"])
20
  api_v1_router.include_router(semantic_router.router, tags=["Semantic Router"])
21
  api_v1_router.include_router(token_counter.router, tags=["Token Counter"])
 
22
  api_v1_router.include_router(chat.router, tags=["Chat"])
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
19
  api_v1_router.include_router(sql_validator.router, tags=["SQL Validator"])
20
  api_v1_router.include_router(semantic_router.router, tags=["Semantic Router"])
21
  api_v1_router.include_router(token_counter.router, tags=["Token Counter"])
22
+ api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
23
  api_v1_router.include_router(chat.router, tags=["Chat"])
app/api/v1/token_generator.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from fastapi import APIRouter, Depends
6
+
7
+ from app.api.deps import require_auth
8
+ from app.models.schemas import (
9
+ TokenGenerateRequest,
10
+ TokenGenerateResponse,
11
+ TokenValidateRequest,
12
+ TokenValidateResponse,
13
+ )
14
+ from app.services.jwt_service import JWTService
15
+
16
+ router = APIRouter()
17
+ _service = JWTService()
18
+
19
+
20
+ @router.post(
21
+ "/token/generate",
22
+ response_model=TokenGenerateResponse,
23
+ summary="Generate a signed JWT token with custom claims",
24
+ )
25
+ async def generate_token(
26
+ body: TokenGenerateRequest,
27
+ auth: str = Depends(require_auth),
28
+ ) -> TokenGenerateResponse:
29
+ start = time.perf_counter()
30
+ try:
31
+ result = _service.generate(
32
+ subject=body.subject,
33
+ role=body.role,
34
+ permissions=body.permissions,
35
+ issuer=body.issuer,
36
+ audience=body.audience,
37
+ expiry_minutes=body.expiry_minutes,
38
+ not_before_minutes=body.not_before_minutes,
39
+ extra_claims=body.extra_claims,
40
+ secret=body.secret,
41
+ algorithm=body.algorithm,
42
+ )
43
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
44
+ return TokenGenerateResponse(
45
+ success=True,
46
+ time_ms=elapsed_ms,
47
+ token=result["token"],
48
+ claims=result["claims"],
49
+ expires_at=result["expires_at"],
50
+ valid_for=result.get("valid_for"),
51
+ secret=result.get("secret"),
52
+ algorithm=result["algorithm"],
53
+ )
54
+ except Exception as exc:
55
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
56
+ return TokenGenerateResponse(
57
+ success=False,
58
+ time_ms=elapsed_ms,
59
+ error=str(exc),
60
+ )
61
+
62
+
63
+ @router.post(
64
+ "/token/validate",
65
+ response_model=TokenValidateResponse,
66
+ summary="Validate a JWT token and return its claims",
67
+ )
68
+ async def validate_token(
69
+ body: TokenValidateRequest,
70
+ auth: str = Depends(require_auth),
71
+ ) -> TokenValidateResponse:
72
+ start = time.perf_counter()
73
+ try:
74
+ result = _service.validate(body.token, body.audience, body.secret, body.algorithm)
75
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
76
+ return TokenValidateResponse(
77
+ success=True,
78
+ time_ms=elapsed_ms,
79
+ valid=result["valid"],
80
+ claims=result["claims"],
81
+ error=result["error"],
82
+ )
83
+ except Exception as exc:
84
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
85
+ return TokenValidateResponse(
86
+ success=False,
87
+ time_ms=elapsed_ms,
88
+ valid=False,
89
+ error=str(exc),
90
+ )
app/config.py CHANGED
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
61
  key_lock_ttl: int = 600
62
  rounds_per_model: int = 50
63
 
 
 
 
 
 
64
  @property
65
  def max_upload_mb(self) -> int:
66
  return self.max_upload_bytes // (1024 * 1024)
 
61
  key_lock_ttl: int = 600
62
  rounds_per_model: int = 50
63
 
64
+ jwt_secret_key: str = "changeme-jwt-secret"
65
+ jwt_algorithm: str = "HS256"
66
+ jwt_default_expiry_minutes: int = 30
67
+ jwt_issuer: str = "all-api-collection"
68
+
69
  @property
70
  def max_upload_mb(self) -> int:
71
  return self.max_upload_bytes // (1024 * 1024)
app/models/schemas.py CHANGED
@@ -468,6 +468,46 @@ class WebSearchEngineDescriptionsResponse(BaseModel):
468
  error: Optional[str] = None
469
 
470
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  class SqlValidationRequest(BaseModel):
472
  query: str = Field(..., min_length=1, max_length=100000, description="SQL query string to validate")
473
  dialect: Optional[str] = Field(None, description="SQL dialect (mysql, postgres, bigquery, snowflake, sqlite, etc.)")
 
468
  error: Optional[str] = None
469
 
470
 
471
+ class TokenGenerateRequest(BaseModel):
472
+ subject: str = Field(..., min_length=1, max_length=256, description="Token subject (user ID, client ID, etc.)")
473
+ role: Optional[str] = Field(None, max_length=64, description="User role for RBAC")
474
+ permissions: Optional[List[str]] = Field(None, description="List of permission strings")
475
+ issuer: Optional[str] = Field(None, max_length=128, description="Token issuer (overrides default)")
476
+ audience: Optional[str] = Field(None, max_length=128, description="Token audience")
477
+ expiry_minutes: Optional[int] = Field(None, ge=1, le=525600, description="Token lifetime in minutes")
478
+ not_before_minutes: int = Field(default=0, ge=0, le=525600, description="Delay token validity by N minutes")
479
+ extra_claims: Optional[Dict[str, Any]] = Field(None, description="Additional custom claims")
480
+ secret: Optional[str] = Field(None, description="JWT signing secret (auto-generated if not provided)")
481
+ algorithm: Optional[str] = Field(None, pattern="^(HS256|HS384|HS512)$", description="JWT signing algorithm (defaults to HS256)")
482
+
483
+
484
+ class TokenGenerateResponse(BaseModel):
485
+ success: bool
486
+ time_ms: float
487
+ token: Optional[str] = None
488
+ claims: Optional[Dict[str, Any]] = None
489
+ expires_at: Optional[str] = None
490
+ valid_for: Optional[str] = None
491
+ secret: Optional[str] = None
492
+ algorithm: Optional[str] = None
493
+ error: Optional[str] = None
494
+
495
+
496
+ class TokenValidateRequest(BaseModel):
497
+ token: str = Field(..., min_length=1, description="JWT token string to validate")
498
+ audience: Optional[str] = Field(None, description="Expected audience to verify against")
499
+ secret: Optional[str] = Field(None, description="Signing secret used during generation (uses server default if not provided)")
500
+ algorithm: Optional[str] = Field(None, pattern="^(HS256|HS384|HS512)$", description="Algorithm used during generation (uses server default if not provided)")
501
+
502
+
503
+ class TokenValidateResponse(BaseModel):
504
+ success: bool
505
+ time_ms: float
506
+ valid: bool
507
+ claims: Optional[Dict[str, Any]] = None
508
+ error: Optional[str] = None
509
+
510
+
511
  class SqlValidationRequest(BaseModel):
512
  query: str = Field(..., min_length=1, max_length=100000, description="SQL query string to validate")
513
  dialect: Optional[str] = Field(None, description="SQL dialect (mysql, postgres, bigquery, snowflake, sqlite, etc.)")
app/services/jwt_service.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import uuid
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Any, Optional
7
+
8
+ import jwt
9
+ from jwt import PyJWTError
10
+
11
+ from app.config import get_settings
12
+
13
+ _SUPPORTED_ALGORITHMS = frozenset({"HS256", "HS384", "HS512"})
14
+
15
+
16
+ class JWTService:
17
+ def __init__(self) -> None:
18
+ self._settings = get_settings()
19
+
20
+ @staticmethod
21
+ def _generate_secret() -> str:
22
+ return secrets.token_hex(32)
23
+
24
+ def generate(
25
+ self,
26
+ *,
27
+ subject: str,
28
+ role: Optional[str] = None,
29
+ permissions: Optional[list[str]] = None,
30
+ issuer: Optional[str] = None,
31
+ audience: Optional[str] = None,
32
+ expiry_minutes: Optional[int] = None,
33
+ not_before_minutes: int = 0,
34
+ extra_claims: Optional[dict[str, Any]] = None,
35
+ secret: Optional[str] = None,
36
+ algorithm: Optional[str] = None,
37
+ ) -> dict[str, Any]:
38
+ now = datetime.now(timezone.utc)
39
+ expire_min = expiry_minutes if expiry_minutes is not None else self._settings.jwt_default_expiry_minutes
40
+
41
+ payload: dict[str, Any] = {
42
+ "sub": subject,
43
+ "iat": now,
44
+ "exp": now + timedelta(minutes=expire_min),
45
+ "jti": uuid.uuid4().hex,
46
+ }
47
+
48
+ if issuer:
49
+ payload["iss"] = issuer
50
+ else:
51
+ payload["iss"] = self._settings.jwt_issuer
52
+
53
+ if audience:
54
+ payload["aud"] = audience
55
+
56
+ if role:
57
+ payload["role"] = role
58
+
59
+ if permissions:
60
+ payload["permissions"] = permissions
61
+
62
+ if not_before_minutes > 0:
63
+ payload["nbf"] = now + timedelta(minutes=not_before_minutes)
64
+
65
+ if extra_claims:
66
+ payload.update(extra_claims)
67
+
68
+ used_secret = secret if secret else self._generate_secret()
69
+ used_algorithm = algorithm if algorithm else self._settings.jwt_algorithm
70
+
71
+ if used_algorithm not in _SUPPORTED_ALGORITHMS:
72
+ raise ValueError(f"Unsupported algorithm '{used_algorithm}'. Supported: {sorted(_SUPPORTED_ALGORITHMS)}")
73
+
74
+ token = jwt.encode(payload, used_secret, algorithm=used_algorithm)
75
+
76
+ valid_minutes = expire_min
77
+ if valid_minutes < 60:
78
+ valid_for = f"{valid_minutes} minute{'s' if valid_minutes != 1 else ''}"
79
+ elif valid_minutes < 1440:
80
+ hours = valid_minutes // 60
81
+ mins = valid_minutes % 60
82
+ valid_for = f"{hours} hour{'s' if hours != 1 else ''}"
83
+ if mins:
84
+ valid_for += f" {mins} minute{'s' if mins != 1 else ''}"
85
+ else:
86
+ days = valid_minutes // 1440
87
+ hrs = (valid_minutes % 1440) // 60
88
+ valid_for = f"{days} day{'s' if days != 1 else ''}"
89
+ if hrs:
90
+ valid_for += f" {hrs} hour{'s' if hrs != 1 else ''}"
91
+
92
+ return {
93
+ "token": token,
94
+ "claims": {
95
+ "sub": subject,
96
+ "iss": payload["iss"],
97
+ "aud": audience,
98
+ "role": role,
99
+ "permissions": permissions,
100
+ "jti": payload["jti"],
101
+ "iat": payload["iat"].isoformat(),
102
+ "exp": payload["exp"].isoformat(),
103
+ "nbf": payload.get("nbf").isoformat() if payload.get("nbf") else None,
104
+ },
105
+ "expires_at": payload["exp"].isoformat(),
106
+ "valid_for": valid_for,
107
+ "secret": used_secret,
108
+ "algorithm": used_algorithm,
109
+ }
110
+
111
+ def validate(
112
+ self,
113
+ token: str,
114
+ audience: Optional[str] = None,
115
+ secret: Optional[str] = None,
116
+ algorithm: Optional[str] = None,
117
+ ) -> dict[str, Any]:
118
+ used_secret = secret if secret else self._settings.jwt_secret_key
119
+ used_algorithms = [algorithm] if algorithm else [self._settings.jwt_algorithm]
120
+
121
+ try:
122
+ payload = jwt.decode(
123
+ token,
124
+ used_secret,
125
+ algorithms=used_algorithms,
126
+ audience=audience,
127
+ issuer=self._settings.jwt_issuer,
128
+ options={
129
+ "require": ["sub", "exp", "iat", "jti"],
130
+ "verify_signature": True,
131
+ "verify_exp": True,
132
+ "verify_iat": True,
133
+ "verify_aud": audience is not None,
134
+ "verify_iss": True,
135
+ },
136
+ )
137
+ return {
138
+ "valid": True,
139
+ "claims": {
140
+ "sub": payload.get("sub"),
141
+ "iss": payload.get("iss"),
142
+ "aud": payload.get("aud"),
143
+ "role": payload.get("role"),
144
+ "permissions": payload.get("permissions"),
145
+ "jti": payload.get("jti"),
146
+ "iat": datetime.fromtimestamp(payload["iat"], tz=timezone.utc).isoformat() if payload.get("iat") else None,
147
+ "exp": datetime.fromtimestamp(payload["exp"], tz=timezone.utc).isoformat() if payload.get("exp") else None,
148
+ "nbf": datetime.fromtimestamp(payload["nbf"], tz=timezone.utc).isoformat() if payload.get("nbf") else None,
149
+ },
150
+ "error": None,
151
+ }
152
+ except jwt.ExpiredSignatureError:
153
+ return {"valid": False, "claims": None, "error": "Token has expired."}
154
+ except jwt.InvalidAudienceError:
155
+ return {"valid": False, "claims": None, "error": "Token audience does not match."}
156
+ except jwt.InvalidIssuerError:
157
+ return {"valid": False, "claims": None, "error": "Token issuer does not match."}
158
+ except jwt.InvalidTokenError as exc:
159
+ return {"valid": False, "claims": None, "error": f"Invalid token: {exc}"}
160
+ except PyJWTError as exc:
161
+ return {"valid": False, "claims": None, "error": f"JWT validation error: {exc}"}
requirements.txt CHANGED
@@ -25,6 +25,7 @@ spacy>=3.7.0
25
  phonenumbers>=8.13.0
26
  sqlglot>=20.0.0
27
  tiktoken>=0.9.0
 
28
 
29
  # Async database drivers
30
  aiomysql>=0.3.2
 
25
  phonenumbers>=8.13.0
26
  sqlglot>=20.0.0
27
  tiktoken>=0.9.0
28
+ PyJWT>=2.9.0
29
 
30
  # Async database drivers
31
  aiomysql>=0.3.2