google-adk-extras 0.2.7__py3-none-any.whl → 0.3.1__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.
@@ -28,4 +28,4 @@ __all__ = [
28
28
  "CustomAgentLoader",
29
29
  ]
30
30
 
31
- __version__ = "0.2.7"
31
+ __version__ = "0.3.1"
@@ -683,6 +683,7 @@ class AdkBuilder:
683
683
  eval_storage_uri=self._eval_storage_uri,
684
684
  allow_origins=self._allow_origins,
685
685
  web=self._web_ui,
686
+ # Expose future override via builder when needed
686
687
  a2a=self._a2a,
687
688
  programmatic_a2a=self._a2a_expose_programmatic,
688
689
  programmatic_a2a_mount_base=self._a2a_programmatic_mount_base,
@@ -0,0 +1,10 @@
1
+ from .config import AuthConfig, JwtIssuerConfig, JwtValidatorConfig
2
+ from .attach import attach_auth
3
+
4
+ __all__ = [
5
+ "AuthConfig",
6
+ "JwtIssuerConfig",
7
+ "JwtValidatorConfig",
8
+ "attach_auth",
9
+ ]
10
+
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
6
+ import base64
7
+ from fastapi.security import APIKeyHeader
8
+ from starlette.middleware.base import BaseHTTPMiddleware
9
+
10
+ from .config import AuthConfig, JwtIssuerConfig, JwtValidatorConfig
11
+ from .jwt_utils import decode_jwt, encode_jwt, now_ts
12
+ from typing import Any
13
+
14
+
15
+ def attach_auth(app: FastAPI, cfg: Optional[AuthConfig]) -> None:
16
+ """Attach optional auth to the provided FastAPI app.
17
+
18
+ - Adds middleware that enforces auth on sensitive routes.
19
+ - Optionally registers token issuance endpoints if configured.
20
+ """
21
+ if not cfg or not cfg.enabled or cfg.allow_no_auth:
22
+ return
23
+
24
+ validator = cfg.jwt_validator
25
+ issuer_cfg = cfg.jwt_issuer
26
+ api_keys = set(cfg.api_keys or [])
27
+ basic_users = cfg.basic_users or {}
28
+ auth_store: Optional[Any] = None
29
+ if issuer_cfg and issuer_cfg.database_url:
30
+ try:
31
+ from .sql_store import AuthStore # type: ignore
32
+ auth_store = AuthStore(issuer_cfg.database_url)
33
+ except Exception:
34
+ # SQL store not available; API key issuance and password grants will be unavailable.
35
+ auth_store = None
36
+
37
+ # Security helpers
38
+ api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
39
+
40
+ async def _authenticate(request: Request) -> dict:
41
+ # API Key
42
+ api_key = request.query_params.get("api_key") or request.headers.get("x-api-key") or request.headers.get("X-API-Key")
43
+ if not api_key:
44
+ api_key = await api_key_header.__call__(request)
45
+ if api_key and api_key in api_keys:
46
+ return {"method": "api_key", "sub": "api_key_client"}
47
+ if api_key and auth_store and auth_store.verify_api_key(api_key):
48
+ return {"method": "api_key", "sub": "api_key_client"}
49
+
50
+ # Basic
51
+ authz = request.headers.get("authorization") or request.headers.get("Authorization")
52
+ if authz and authz.lower().startswith("basic "):
53
+ try:
54
+ b64 = authz.split(" ", 1)[1]
55
+ raw = base64.b64decode(b64).decode("utf-8")
56
+ username, _, password = raw.partition(":")
57
+ except Exception:
58
+ username, password = "", ""
59
+ # If SQL store present, try it first; else fall back to configured map
60
+ if auth_store:
61
+ uid = auth_store.authenticate_basic(username, password)
62
+ if uid:
63
+ return {"method": "basic", "sub": uid, "username": username}
64
+ stored = basic_users.get(username)
65
+ if stored and (stored == password):
66
+ return {"method": "basic", "sub": username, "username": username}
67
+
68
+ # Bearer JWT
69
+ if authz and authz.lower().startswith("bearer "):
70
+ token = authz.split(" ", 1)[1]
71
+ if validator and (validator.jwks_url or validator.hs256_secret):
72
+ try:
73
+ claims = decode_jwt(
74
+ token,
75
+ issuer=validator.issuer,
76
+ audience=validator.audience,
77
+ jwks_url=validator.jwks_url,
78
+ hs256_secret=validator.hs256_secret,
79
+ )
80
+ sub = str(claims.get("sub"))
81
+ if not sub:
82
+ raise HTTPException(status_code=401, detail="Invalid token: no subject")
83
+ return {"method": "jwt", "sub": sub, "claims": claims}
84
+ except Exception as e:
85
+ raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
86
+
87
+ raise HTTPException(status_code=401, detail="Unauthorized")
88
+
89
+ def _path_requires_auth(path: str, method: str) -> bool:
90
+ method = method.upper()
91
+ # Always protect core run endpoints
92
+ if path == "/run" and method == "POST":
93
+ return True
94
+ if path == "/run_sse" and method == "POST":
95
+ return True
96
+ # Sessions and artifacts under /apps
97
+ if path.startswith("/apps/"):
98
+ # Allow metrics to be toggled
99
+ if path.endswith("/metrics-info") and method == "GET":
100
+ return cfg.protect_metrics
101
+ return True
102
+ # Debug and builder are privileged
103
+ if path.startswith("/debug/") or path.startswith("/builder/"):
104
+ return True
105
+ # API key management endpoints
106
+ if path.startswith("/auth/api-keys"):
107
+ return True
108
+ # Optionally protect list-apps
109
+ if path == "/list-apps" and method == "GET":
110
+ return cfg.protect_list_apps
111
+ return False
112
+
113
+ class _AuthMiddleware(BaseHTTPMiddleware):
114
+ async def dispatch(self, request: Request, call_next):
115
+ path = request.url.path
116
+ if not _path_requires_auth(path, request.method):
117
+ return await call_next(request)
118
+ # Authenticate
119
+ try:
120
+ request.state.identity = await _authenticate(request)
121
+ except HTTPException as e:
122
+ from fastapi.responses import JSONResponse
123
+ return JSONResponse({"detail": e.detail}, status_code=e.status_code)
124
+ # Optional: Enforce user ownership when path has /users/{user_id}/
125
+ try:
126
+ parts = path.strip("/").split("/")
127
+ if "users" in parts:
128
+ idx = parts.index("users")
129
+ claimed = parts[idx + 1]
130
+ sub = str(request.state.identity.get("sub"))
131
+ # Allow api_key method to bypass ownership
132
+ if request.state.identity.get("method") != "api_key" and sub != claimed:
133
+ from fastapi.responses import JSONResponse
134
+ return JSONResponse({"detail": "Forbidden: user mismatch"}, status_code=403)
135
+ except HTTPException:
136
+ raise
137
+ except Exception:
138
+ pass
139
+ return await call_next(request)
140
+
141
+ app.add_middleware(_AuthMiddleware)
142
+
143
+ # Token issuance endpoints (optional)
144
+ if issuer_cfg and issuer_cfg.enabled:
145
+ if issuer_cfg.algorithm == "HS256" and not issuer_cfg.hs256_secret:
146
+ raise RuntimeError("HS256 issuer requires hs256_secret")
147
+ router = APIRouter()
148
+
149
+ @router.post("/auth/register")
150
+ async def register(username: str, password: str):
151
+ if not auth_store:
152
+ raise HTTPException(status_code=400, detail="SQL store not configured")
153
+ uid = auth_store.create_user(username, password)
154
+ return {"user_id": uid}
155
+
156
+ @router.post("/auth/token")
157
+ async def token_grant(grant_type: str = "password", username: Optional[str] = None, password: Optional[str] = None,
158
+ user_id: Optional[str] = None, fingerprint: Optional[str] = None):
159
+ sub: Optional[str] = None
160
+ if grant_type == "password":
161
+ if not auth_store or not username or password is None:
162
+ raise HTTPException(status_code=400, detail="invalid_request")
163
+ uid = auth_store.authenticate_basic(username, password)
164
+ if not uid:
165
+ raise HTTPException(status_code=401, detail="invalid_grant")
166
+ sub = uid
167
+ elif grant_type == "client_credentials":
168
+ # For simplicity map to provided user_id
169
+ if not user_id:
170
+ raise HTTPException(status_code=400, detail="invalid_request")
171
+ sub = user_id
172
+ else:
173
+ raise HTTPException(status_code=400, detail="unsupported_grant_type")
174
+
175
+ now = now_ts()
176
+ access = {
177
+ "iss": issuer_cfg.issuer,
178
+ "aud": issuer_cfg.audience,
179
+ "sub": sub,
180
+ "iat": now,
181
+ "nbf": now,
182
+ "exp": now + issuer_cfg.access_ttl_seconds,
183
+ }
184
+ key = issuer_cfg.hs256_secret if issuer_cfg.algorithm == "HS256" else ""
185
+ access_token = encode_jwt(access, algorithm=issuer_cfg.algorithm, key=key)
186
+
187
+ refresh_token = None
188
+ if auth_store:
189
+ jti = auth_store.issue_refresh(sub, issuer_cfg.refresh_ttl_seconds, fingerprint=fingerprint)
190
+ refresh_token = jti
191
+ return {"access_token": access_token, "token_type": "bearer", "refresh_token": refresh_token}
192
+
193
+ @router.post("/auth/refresh")
194
+ async def refresh(user_id: str, refresh_token: str, fingerprint: Optional[str] = None):
195
+ if not auth_store:
196
+ raise HTTPException(status_code=400, detail="invalid_request")
197
+ if not auth_store.verify_refresh(refresh_token, user_id, fingerprint=fingerprint):
198
+ raise HTTPException(status_code=401, detail="invalid_grant")
199
+ now = now_ts()
200
+ access = {
201
+ "iss": issuer_cfg.issuer,
202
+ "aud": issuer_cfg.audience,
203
+ "sub": user_id,
204
+ "iat": now,
205
+ "nbf": now,
206
+ "exp": now + issuer_cfg.access_ttl_seconds,
207
+ }
208
+ key = issuer_cfg.hs256_secret if issuer_cfg.algorithm == "HS256" else ""
209
+ access_token = encode_jwt(access, algorithm=issuer_cfg.algorithm, key=key)
210
+ return {"access_token": access_token, "token_type": "bearer"}
211
+
212
+ app.include_router(router)
213
+
214
+ # API key management endpoints (require SQL store)
215
+ if auth_store:
216
+ api_router = APIRouter()
217
+
218
+ @api_router.post("/auth/api-keys")
219
+ async def create_api_key(user_id: Optional[str] = None, name: Optional[str] = None):
220
+ key_id, key_plain = auth_store.create_api_key(user_id=user_id, name=name)
221
+ return {"id": key_id, "api_key": key_plain}
222
+
223
+ @api_router.get("/auth/api-keys")
224
+ async def list_api_keys():
225
+ return auth_store.list_api_keys()
226
+
227
+ @api_router.delete("/auth/api-keys/{key_id}")
228
+ async def delete_api_key(key_id: str):
229
+ auth_store.revoke_api_key(key_id)
230
+ return {"ok": True}
231
+
232
+ app.include_router(api_router)
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional
5
+
6
+
7
+ @dataclass
8
+ class JwtValidatorConfig:
9
+ # Accept JWTs from external issuers (e.g., Google/Auth0/Okta) or our own issuer
10
+ jwks_url: Optional[str] = None
11
+ issuer: Optional[str] = None
12
+ audience: Optional[str] = None
13
+ # If you want to validate with an HS256 shared secret (tests/dev)
14
+ hs256_secret: Optional[str] = None
15
+
16
+
17
+ @dataclass
18
+ class JwtIssuerConfig:
19
+ # Configure our own issuer if we issue tokens
20
+ enabled: bool = False
21
+ issuer: str = "https://example-issuer"
22
+ audience: str = "adk-api"
23
+ algorithm: str = "HS256" # HS256 or RS256/ES256 later
24
+ hs256_secret: Optional[str] = None
25
+ access_ttl_seconds: int = 3600
26
+ refresh_ttl_seconds: int = 60 * 60 * 24 * 14
27
+ # SQL store for users/refresh tokens
28
+ database_url: Optional[str] = None # e.g. sqlite:///auth.db
29
+
30
+
31
+ @dataclass
32
+ class AuthConfig:
33
+ # Global toggle
34
+ enabled: bool = False
35
+ # Modes
36
+ allow_no_auth: bool = False # if True, bypass checks entirely
37
+ api_keys: List[str] = field(default_factory=list) # accepted API keys
38
+ basic_users: dict[str, str] = field(default_factory=dict) # username -> password (PBKDF2 hash or plaintext for tests)
39
+ jwt_validator: Optional[JwtValidatorConfig] = None
40
+ jwt_issuer: Optional[JwtIssuerConfig] = None
41
+ # Route policy toggles
42
+ protect_list_apps: bool = True
43
+ protect_metrics: bool = True
44
+ # Scopes are advisory; we currently validate presence of a token and subject. Extend as needed.
45
+
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import time
6
+ from typing import Any, Dict, Optional
7
+
8
+ # Lazy import PyJWT to keep this optional when auth is not used.
9
+ def _import_pyjwt():
10
+ try:
11
+ import jwt # type: ignore
12
+ from jwt import PyJWKClient # type: ignore
13
+ return jwt, PyJWKClient
14
+ except Exception as e:
15
+ raise ImportError(
16
+ "PyJWT is required for JWT encode/decode. Install with: pip install PyJWT"
17
+ ) from e
18
+
19
+
20
+ def _b64url(data: bytes) -> str:
21
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
22
+
23
+
24
+ def encode_jwt(payload: Dict[str, Any], *, algorithm: str, key: str, headers: Optional[Dict[str, Any]] = None) -> str:
25
+ jwt, _PyJWKClient = _import_pyjwt()
26
+ return jwt.encode(payload, key, algorithm=algorithm, headers=headers)
27
+
28
+
29
+ def decode_jwt(token: str, *, issuer: Optional[str] = None, audience: Optional[str] = None,
30
+ jwks_url: Optional[str] = None, hs256_secret: Optional[str] = None) -> Dict[str, Any]:
31
+ jwt, PyJWKClient = _import_pyjwt()
32
+ options = {"verify_signature": True, "verify_exp": True, "verify_nbf": True}
33
+ if jwks_url:
34
+ jwk_client = PyJWKClient(jwks_url)
35
+ signing_key = jwk_client.get_signing_key_from_jwt(token).key
36
+ return jwt.decode(token, signing_key, algorithms=["RS256", "ES256"], audience=audience, issuer=issuer, options=options)
37
+ elif hs256_secret:
38
+ return jwt.decode(token, hs256_secret, algorithms=["HS256"], audience=audience, issuer=issuer, options=options)
39
+ else:
40
+ raise ValueError("No validation method configured (jwks_url or hs256_secret required)")
41
+
42
+
43
+ def now_ts() -> int:
44
+ return int(time.time())
45
+
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import secrets
6
+ from datetime import datetime, timedelta, timezone
7
+ from typing import Optional
8
+
9
+ try:
10
+ from sqlalchemy import Column, String, DateTime, create_engine, Text, Boolean
11
+ from sqlalchemy.orm import declarative_base, sessionmaker
12
+ except ImportError as e:
13
+ raise ImportError(
14
+ "SQLAlchemy is required for the auth SQL store. Install with: pip install sqlalchemy"
15
+ ) from e
16
+
17
+
18
+ Base = declarative_base()
19
+
20
+
21
+ def _pbkdf2(password: str, salt: str) -> str:
22
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 200_000)
23
+ return dk.hex()
24
+
25
+
26
+ def hash_password(password: str) -> str:
27
+ salt = secrets.token_hex(16)
28
+ return f"pbkdf2_sha256${salt}${_pbkdf2(password, salt)}"
29
+
30
+
31
+ def verify_password(password: str, stored: str) -> bool:
32
+ try:
33
+ algo, salt, digest = stored.split("$", 2)
34
+ if algo != "pbkdf2_sha256":
35
+ # fallback for plaintext in tests
36
+ return secrets.compare_digest(password, stored)
37
+ return secrets.compare_digest(_pbkdf2(password, salt), digest)
38
+ except Exception:
39
+ return secrets.compare_digest(password, stored)
40
+
41
+
42
+ class User(Base):
43
+ __tablename__ = "auth_users"
44
+ id = Column(String, primary_key=True)
45
+ username = Column(String, unique=True, index=True, nullable=False)
46
+ password_hash = Column(String, nullable=False)
47
+ roles = Column(String, default="") # comma-separated
48
+ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
49
+ disabled = Column(Boolean, default=False)
50
+
51
+
52
+ class RefreshToken(Base):
53
+ __tablename__ = "auth_refresh_tokens"
54
+ jti = Column(String, primary_key=True)
55
+ user_id = Column(String, index=True, nullable=False)
56
+ expires_at = Column(DateTime(timezone=True), nullable=False)
57
+ revoked_at = Column(DateTime(timezone=True), nullable=True)
58
+ fingerprint = Column(String, nullable=True)
59
+
60
+
61
+ class ApiKey(Base):
62
+ __tablename__ = "auth_api_keys"
63
+ id = Column(String, primary_key=True)
64
+ user_id = Column(String, index=True, nullable=True)
65
+ key_hash = Column(String, nullable=False)
66
+ name = Column(String, nullable=True)
67
+ created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
68
+ revoked_at = Column(DateTime(timezone=True), nullable=True)
69
+
70
+
71
+ class AuthStore:
72
+ def __init__(self, database_url: str):
73
+ self.engine = create_engine(database_url)
74
+ Base.metadata.create_all(self.engine)
75
+ self.Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
76
+
77
+ def create_user(self, username: str, password: str, user_id: Optional[str] = None) -> str:
78
+ import uuid
79
+ uid = user_id or str(uuid.uuid4())
80
+ with self.Session() as s:
81
+ u = User(id=uid, username=username, password_hash=hash_password(password))
82
+ s.add(u)
83
+ s.commit()
84
+ return uid
85
+
86
+ def authenticate_basic(self, username: str, password: str) -> Optional[str]:
87
+ with self.Session() as s:
88
+ u: Optional[User] = s.query(User).filter_by(username=username).first()
89
+ if not u or u.disabled:
90
+ return None
91
+ if verify_password(password, u.password_hash):
92
+ return u.id
93
+ return None
94
+
95
+ def issue_refresh(self, user_id: str, ttl_seconds: int, fingerprint: Optional[str] = None) -> str:
96
+ import uuid
97
+ jti = str(uuid.uuid4())
98
+ with self.Session() as s:
99
+ rt = RefreshToken(
100
+ jti=jti,
101
+ user_id=user_id,
102
+ expires_at=datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds),
103
+ fingerprint=fingerprint,
104
+ )
105
+ s.add(rt)
106
+ s.commit()
107
+ return jti
108
+
109
+ def verify_refresh(self, jti: str, user_id: str, fingerprint: Optional[str] = None) -> bool:
110
+ with self.Session() as s:
111
+ rt: Optional[RefreshToken] = s.query(RefreshToken).filter_by(jti=jti, user_id=user_id).first()
112
+ if not rt or rt.revoked_at is not None:
113
+ return False
114
+ if rt.expires_at <= datetime.now(timezone.utc):
115
+ return False
116
+ if fingerprint and rt.fingerprint and rt.fingerprint != fingerprint:
117
+ return False
118
+ return True
119
+
120
+ def revoke_refresh(self, jti: str) -> None:
121
+ with self.Session() as s:
122
+ rt: Optional[RefreshToken] = s.query(RefreshToken).filter_by(jti=jti).first()
123
+ if not rt:
124
+ return
125
+ rt.revoked_at = datetime.now(timezone.utc)
126
+ s.add(rt)
127
+ s.commit()
128
+
129
+ # API Keys
130
+ def _hash_api_key(self, key: str) -> str:
131
+ # Reuse PBKDF2; different prefix
132
+ salt = secrets.token_hex(16)
133
+ return f"api_pbkdf2_sha256${salt}${_pbkdf2(key, salt)}"
134
+
135
+ def _verify_api_key(self, key: str, stored: str) -> bool:
136
+ try:
137
+ algo, salt, digest = stored.split("$", 3)
138
+ if algo != "api_pbkdf2_sha256":
139
+ return secrets.compare_digest(key, stored)
140
+ return secrets.compare_digest(_pbkdf2(key, salt), digest)
141
+ except Exception:
142
+ return False
143
+
144
+ def create_api_key(self, user_id: Optional[str] = None, name: Optional[str] = None) -> tuple[str, str]:
145
+ import uuid
146
+ key_plain = secrets.token_urlsafe(32)
147
+ key_id = str(uuid.uuid4())
148
+ with self.Session() as s:
149
+ rec = ApiKey(id=key_id, user_id=user_id, key_hash=self._hash_api_key(key_plain), name=name)
150
+ s.add(rec)
151
+ s.commit()
152
+ return key_id, key_plain
153
+
154
+ def list_api_keys(self):
155
+ with self.Session() as s:
156
+ rows = s.query(ApiKey).all()
157
+ return [
158
+ {
159
+ "id": r.id,
160
+ "user_id": r.user_id,
161
+ "name": r.name,
162
+ "created_at": r.created_at.isoformat() if r.created_at else None,
163
+ "revoked": r.revoked_at is not None,
164
+ }
165
+ for r in rows
166
+ ]
167
+
168
+ def revoke_api_key(self, key_id: str) -> None:
169
+ with self.Session() as s:
170
+ rec = s.query(ApiKey).filter_by(id=key_id).first()
171
+ if not rec:
172
+ return
173
+ rec.revoked_at = datetime.now(timezone.utc)
174
+ s.add(rec)
175
+ s.commit()
176
+
177
+ def verify_api_key(self, key: str) -> bool:
178
+ with self.Session() as s:
179
+ rows = s.query(ApiKey).filter(ApiKey.revoked_at.is_(None)).all()
180
+ for r in rows:
181
+ if self._verify_api_key(key, r.key_hash):
182
+ return True
183
+ return False
@@ -10,7 +10,7 @@ import logging
10
10
  import os
11
11
  from pathlib import Path
12
12
  import shutil
13
- from typing import Any, Mapping, Optional, List, Callable, Dict
13
+ from typing import Any, Mapping, Optional, List, Callable, Dict, Union
14
14
 
15
15
  import click
16
16
  from fastapi import FastAPI
@@ -34,6 +34,7 @@ from google.adk.sessions.database_session_service import DatabaseSessionService
34
34
  from google.adk.utils.feature_decorator import working_in_progress
35
35
  from google.adk.cli.adk_web_server import AdkWebServer
36
36
  from .enhanced_adk_web_server import EnhancedAdkWebServer
37
+ from .auth import attach_auth, AuthConfig, JwtIssuerConfig, JwtValidatorConfig
37
38
  from .streaming import StreamingController, StreamingConfig
38
39
  from google.adk.cli.utils import envs
39
40
  from google.adk.cli.utils import evals
@@ -56,6 +57,7 @@ def get_enhanced_fast_api_app(
56
57
  eval_storage_uri: Optional[str] = None,
57
58
  allow_origins: Optional[List[str]] = None,
58
59
  web: bool = True,
60
+ web_assets_dir: Optional[Union[str, Path]] = None,
59
61
  a2a: bool = False,
60
62
  programmatic_a2a: bool = False,
61
63
  programmatic_a2a_mount_base: str = "/a2a",
@@ -68,6 +70,8 @@ def get_enhanced_fast_api_app(
68
70
  # Streaming layer (optional)
69
71
  enable_streaming: bool = False,
70
72
  streaming_config: Optional[StreamingConfig] = None,
73
+ # Auth layer (optional)
74
+ auth_config: Optional[AuthConfig] = None,
71
75
  ) -> FastAPI:
72
76
  """Enhanced version of Google ADK's get_fast_api_app with EnhancedRunner integration.
73
77
 
@@ -302,20 +306,53 @@ def get_enhanced_fast_api_app(
302
306
  tear_down_observer=tear_down_observer,
303
307
  )
304
308
 
305
- if web:
309
+ def _auto_find_web_assets() -> Optional[Path]:
306
310
  try:
307
- # Try to find ADK's web assets
308
- from google.adk.cli.fast_api import BASE_DIR
309
- ANGULAR_DIST_PATH = BASE_DIR / "browser"
310
- except (ImportError, AttributeError):
311
- # Fallback if ADK structure changes
312
- BASE_DIR = Path(__file__).parent.resolve()
313
- ANGULAR_DIST_PATH = BASE_DIR / "browser"
314
-
315
- if ANGULAR_DIST_PATH.exists():
316
- extra_fast_api_args.update(web_assets_dir=ANGULAR_DIST_PATH)
311
+ # Prefer importlib.resources so this works across ADK versions
312
+ import importlib.resources as r
313
+ try:
314
+ import google.adk.cli.fast_api as fast_api_pkg # type: ignore
315
+ base = r.files(fast_api_pkg)
316
+ candidates = [
317
+ base / "browser",
318
+ base / "static" / "browser",
319
+ ]
320
+ except Exception:
321
+ import google.adk.cli as cli_pkg # type: ignore
322
+ base = r.files(cli_pkg) / "fast_api"
323
+ candidates = [
324
+ base / "browser",
325
+ base / "static" / "browser",
326
+ ]
327
+ for p in candidates:
328
+ if p.exists() and (p / "index.html").exists():
329
+ # Convert to real filesystem Path if possible
330
+ try:
331
+ return Path(str(p))
332
+ except Exception:
333
+ continue
334
+ except Exception:
335
+ pass
336
+ # Fallback to local relative path (for dev builds of this package)
337
+ local = Path(__file__).parent / "browser"
338
+ if local.exists() and (local / "index.html").exists():
339
+ return local
340
+ return None
341
+
342
+ if web:
343
+ chosen: Optional[Path] = None
344
+ if web_assets_dir is not None:
345
+ p = Path(web_assets_dir)
346
+ if p.exists():
347
+ chosen = p
348
+ if chosen is None:
349
+ chosen = _auto_find_web_assets()
350
+ if chosen is not None:
351
+ extra_fast_api_args.update(web_assets_dir=chosen)
317
352
  else:
318
- logger.warning("Web UI assets not found, web interface will not be available")
353
+ logger.warning(
354
+ "Web UI assets not found; set web_assets_dir or install an ADK build that ships fast_api/browser"
355
+ )
319
356
 
320
357
  # Create FastAPI app
321
358
  app = adk_web_server.get_fast_api_app(
@@ -600,4 +637,7 @@ def get_enhanced_fast_api_app(
600
637
 
601
638
  app.include_router(router)
602
639
 
640
+ # Attach optional auth layer last so all routes are covered
641
+ attach_auth(app, auth_config)
642
+
603
643
  return app
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: google-adk-extras
3
- Version: 0.2.7
3
+ Version: 0.3.1
4
4
  Summary: Production-ready services and FastAPI wiring for Google ADK
5
5
  Home-page: https://github.com/DeadMeme5441/google-adk-extras
6
6
  Author: DeadMeme5441
@@ -94,7 +94,7 @@ If you plan to use specific backends, also install their clients (examples):
94
94
  - Redis: `uv pip install redis`
95
95
  - S3: `uv pip install boto3`
96
96
 
97
- Note on credentials (0.2.7): This release removes custom credential services and URI helpers from this package. For outbound credentials used by tools, rely on ADK’s experimental BaseCredentialService (e.g., InMemory/SessionState) or your own ADK-compatible implementation. Inbound API authentication (protecting /run and streaming routes) will be provided as an optional FastAPI layer separately.
97
+ Note on credentials (0.3.0): Outbound credentials for tools remain ADK’s concern (use ADK’s BaseCredentialService). Inbound API authentication is now available as an optional FastAPI layer in this package (see Auth below). You can run fully open (no auth) or enable API Key, Basic, or JWT (including first‑party issuance backed by SQL).
98
98
 
99
99
 
100
100
  ## Quickstart (FastAPI)
@@ -104,6 +104,7 @@ Use the fluent builder to wire services. Then run with uvicorn.
104
104
  ```python
105
105
  # app.py
106
106
  from google_adk_extras import AdkBuilder
107
+ from google_adk_extras.auth import AuthConfig, JwtIssuerConfig, JwtValidatorConfig
107
108
 
108
109
  app = (
109
110
  AdkBuilder()
@@ -127,6 +128,77 @@ uvicorn app:app --reload
127
128
  If you don’t keep agents on disk, register them programmatically and use a custom loader (see below).
128
129
 
129
130
 
131
+ ## Auth (optional)
132
+
133
+ Auth is entirely optional. By default, all endpoints are open (no auth). To enable protection, pass `auth_config` into `get_enhanced_fast_api_app` via the builder or directly.
134
+
135
+ Supported inbound methods:
136
+ - API Key: `X-API-Key: <key>` header (or `?api_key=` query). Keys can be static via config, or issued/rotated via SQL‑backed endpoints.
137
+ - HTTP Basic: `Authorization: Basic base64(user:pass)` for quick human/internal testing. Can validate against in‑memory map or the SQL users table.
138
+ - Bearer JWT (validate): Accept JWTs from Google/Auth0/Okta/etc. via JWKS, or HS256 secret in dev. Enforces iss/aud/exp/nbf.
139
+ - Bearer JWT (issue): First‑party issuer with HS256, tokens minted from `/auth/token`, users stored in SQL (SQLite/Postgres/MySQL).
140
+
141
+ Minimal enablement (JWT validate only):
142
+
143
+ ```python
144
+ from google_adk_extras.auth import AuthConfig, JwtValidatorConfig
145
+
146
+ auth = AuthConfig(
147
+ enabled=True,
148
+ jwt_validator=JwtValidatorConfig(
149
+ jwks_url="https://accounts.google.com/.well-known/openid-configuration", # example
150
+ issuer="https://accounts.google.com",
151
+ audience="your-api-audience",
152
+ ),
153
+ )
154
+
155
+ app = (
156
+ AdkBuilder()
157
+ .with_agents_dir("./agents")
158
+ .build_fastapi_app()
159
+ )
160
+ ```
161
+
162
+ First‑party issuer + validate (single shared HS256 secret) with SQL connector:
163
+
164
+ ```python
165
+ from google_adk_extras.auth import AuthConfig, JwtIssuerConfig, JwtValidatorConfig
166
+
167
+ issuer = JwtIssuerConfig(
168
+ enabled=True,
169
+ issuer="https://local-issuer",
170
+ audience="adk-api",
171
+ algorithm="HS256",
172
+ hs256_secret="topsecret",
173
+ database_url="sqlite:///./auth.db", # also supports Postgres/MySQL
174
+ )
175
+ validator = JwtValidatorConfig(
176
+ issuer=issuer.issuer,
177
+ audience=issuer.audience,
178
+ hs256_secret=issuer.hs256_secret,
179
+ )
180
+
181
+ auth = AuthConfig(enabled=True, jwt_issuer=issuer, jwt_validator=validator)
182
+
183
+ app = (
184
+ AdkBuilder()
185
+ .with_agents_dir("./agents")
186
+ .build_fastapi_app()
187
+ )
188
+ ```
189
+
190
+ Issuing and using tokens/keys at runtime:
191
+ - Register user: `POST /auth/register?username=alice&password=wonder`
192
+ - Token (password): `POST /auth/token?grant_type=password&username=alice&password=wonder`
193
+ - Refresh: `POST /auth/refresh?user_id=<uid>&refresh_token=<jti>`
194
+ - Create API key: `POST /auth/api-keys` (auth required) → returns `{ id, api_key }` (plaintext shown once)
195
+ - List keys: `GET /auth/api-keys` (auth required)
196
+ - Revoke key: `DELETE /auth/api-keys/{id}` (auth required)
197
+ - Use API key: add `X-API-Key: <api_key>` to any protected route (keys currently allow full access)
198
+
199
+ Protected routes include `/run`, `/run_sse`, all `/apps/...` session/artifact/eval endpoints, `/debug/*`, `/builder/*`, and optionally `/list-apps` and `/apps/{app}/metrics-info`.
200
+
201
+
130
202
  ## Quickstart (Runner)
131
203
 
132
204
  Create a Runner wired with your chosen backends. Use agent name (filesystem loader) or pass an agent instance.
@@ -1,8 +1,8 @@
1
- google_adk_extras/__init__.py,sha256=NnpXFV1q3Y50qmHhbvVp-7wxpd6esrk_gooU7LaHiFM,851
2
- google_adk_extras/adk_builder.py,sha256=Ax5e_NegGZcdb_xm4t4e18gpJjcR5ICn0Zefy6VuLh4,30068
1
+ google_adk_extras/__init__.py,sha256=GbBnzocuEjf2RphvKaL9-DoDtsq0FVz2QO0uVTe66sQ,851
2
+ google_adk_extras/adk_builder.py,sha256=OR8ZSgaZJwjVpbfoTHOjgIisO_2sPUxegS0_ngCAco4,30129
3
3
  google_adk_extras/custom_agent_loader.py,sha256=e_sgA58RmDzCUHCySAi3Hruxumtozw3UScZV2vxlCbw,5991
4
4
  google_adk_extras/enhanced_adk_web_server.py,sha256=4QxTADlQv6oXaAEVMQc7-bpf84jCrTkN6pJC6R2jmww,5348
5
- google_adk_extras/enhanced_fastapi.py,sha256=U7g24_c5-uQidCSs4Z158FWqR7qflW8O5EY9kqfuLqE,28202
5
+ google_adk_extras/enhanced_fastapi.py,sha256=8JCkGOtzUaQM68BanhPaii1xBziPelXAYIbyUeUMEO0,29748
6
6
  google_adk_extras/enhanced_runner.py,sha256=b7O1a9-4S49LduILOEDs6IxjCI4w_E39sc-Hs4y3Rys,1410
7
7
  google_adk_extras/artifacts/__init__.py,sha256=_IsKDgf6wanWR0HXvSpK9SiLa3n5URKLtazkKyH1P-o,931
8
8
  google_adk_extras/artifacts/base_custom_artifact_service.py,sha256=O9rkc250B3yDRYbyDI0EvTrCKvnih5_DQas5OF-hRMY,9721
@@ -10,6 +10,11 @@ google_adk_extras/artifacts/local_folder_artifact_service.py,sha256=7oepQIHYimXx
10
10
  google_adk_extras/artifacts/mongo_artifact_service.py,sha256=K46Ycl7gkzCbCweVL0GrWsFxCcJ3T7JnE9gpIamfd6Y,7099
11
11
  google_adk_extras/artifacts/s3_artifact_service.py,sha256=inIc2KL3OdIQGkCA_HYJE0ZfGFQ3YcX_SFZEFVUb0T8,15655
12
12
  google_adk_extras/artifacts/sql_artifact_service.py,sha256=OovKSzM0nib2c-pzkv7NYyiHi8kFK-k3SFOMi92U4Mk,11980
13
+ google_adk_extras/auth/__init__.py,sha256=PQQmFAjOPrt9tAGHOtQC-_U7fpRd8Dt_vh973b2pSnc,202
14
+ google_adk_extras/auth/attach.py,sha256=wSb2jKziGXqhRzu8XCBVuoHXyJa7T6CrGGbRhsfp_50,10235
15
+ google_adk_extras/auth/config.py,sha256=JGJefyExVG3QFjmAkgTT-yaMivCvkOs4E7HwMlqVES0,1573
16
+ google_adk_extras/auth/jwt_utils.py,sha256=B3Iyu0GRqDU0EstGxfMUKm_w58z4l6WQGRAQvlcwvYk,1701
17
+ google_adk_extras/auth/sql_store.py,sha256=-LouLrcT4gBBGrwG4kqbMyjVo1W1BbugPCKZUUFklWs,6693
13
18
  google_adk_extras/credentials/base_custom_credential_service.py,sha256=iYHacJAsZmDfpxLOPYx4tQpbtWTbwC75tRp6hlZFoSg,4014
14
19
  google_adk_extras/memory/__init__.py,sha256=2FFJXw9CZHctKXmCuc-lrdETeQ5xqdivy3oarHJz5gs,994
15
20
  google_adk_extras/memory/base_custom_memory_service.py,sha256=TRQMaXiRg2LXFwYZnFHoL-yBVtecuX1ownyPBJf6Xww,3613
@@ -25,8 +30,8 @@ google_adk_extras/sessions/sql_session_service.py,sha256=TaOeEVWnwQ_8nvDZBW7e3qh
25
30
  google_adk_extras/sessions/yaml_file_session_service.py,sha256=g65ptJWAMVN4XQmCxQ0UwnSC2GU1NJ6QRvrwfzSK_xo,11797
26
31
  google_adk_extras/streaming/__init__.py,sha256=rcjmlCJHTlvUiCrx6qNGw5ObCnEtfENkGTvzfEiGL0M,461
27
32
  google_adk_extras/streaming/streaming_controller.py,sha256=Z72k5QgvWBIU2YP8iXlc3D3oWxDYWJo9eygj_KzALYA,10489
28
- google_adk_extras-0.2.7.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
29
- google_adk_extras-0.2.7.dist-info/METADATA,sha256=ff9M_h1T_1bvq9RPCrd7z0eI_a9CWX_vnXmLH1qNTMA,10121
30
- google_adk_extras-0.2.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- google_adk_extras-0.2.7.dist-info/top_level.txt,sha256=DDWgVkz8G8ihPzznxAWyKa2jgJW3F6Fwy__qMddoKTs,18
32
- google_adk_extras-0.2.7.dist-info/RECORD,,
33
+ google_adk_extras-0.3.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
34
+ google_adk_extras-0.3.1.dist-info/METADATA,sha256=jN02PjbEwa6Lm-2rEpLV-bCBhRmNM9U3bd7u9RpxsS8,12867
35
+ google_adk_extras-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
36
+ google_adk_extras-0.3.1.dist-info/top_level.txt,sha256=DDWgVkz8G8ihPzznxAWyKa2jgJW3F6Fwy__qMddoKTs,18
37
+ google_adk_extras-0.3.1.dist-info/RECORD,,