第一次提交

This commit is contained in:
2026-03-13 16:40:08 +08:00
commit 0a49bcfb2d
277 changed files with 61890 additions and 0 deletions

View File

@ -0,0 +1 @@
"""AuthZ service package."""

View File

@ -0,0 +1,268 @@
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from threading import Lock
from typing import Any
from app.models import ChannelSettings, BackendCredential, BackendRecord, OutlookSettings, UserRecord, utcnow_iso
class JsonStore:
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.data_dir.mkdir(parents=True, exist_ok=True)
self._lock = Lock()
@property
def backends_path(self) -> Path:
return self.data_dir / "backends.json"
@property
def credentials_path(self) -> Path:
return self.data_dir / "backend_credentials.json"
@property
def permissions_path(self) -> Path:
return self.data_dir / "permissions.json"
@property
def users_path(self) -> Path:
return self.data_dir / "users.json"
@property
def settings_path(self) -> Path:
return self.data_dir / "settings.json"
def _read_json(self, path: Path, default: dict[str, Any]) -> dict[str, Any]:
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError, json.JSONDecodeError):
return default
def _write_json(self, path: Path, payload: dict[str, Any]) -> None:
with self._lock:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=path.name, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
def list_backends(self) -> list[BackendRecord]:
raw = self._read_json(self.backends_path, {"backends": []})
items = raw.get("backends", [])
if not isinstance(items, list):
return []
return [BackendRecord.model_validate(item) for item in items if isinstance(item, dict)]
def get_backend(self, backend_id: str) -> BackendRecord | None:
for backend in self.list_backends():
if backend.backend_id == backend_id:
return backend
return None
def save_backend(self, backend: BackendRecord) -> None:
backends = [item for item in self.list_backends() if item.backend_id != backend.backend_id]
backends.append(backend)
backends.sort(key=lambda item: item.backend_id)
self._write_json(
self.backends_path,
{"backends": [item.model_dump(mode="json") for item in backends]},
)
def list_credentials(self) -> list[BackendCredential]:
raw = self._read_json(self.credentials_path, {"credentials": []})
items = raw.get("credentials", [])
if not isinstance(items, list):
return []
return [BackendCredential.model_validate(item) for item in items if isinstance(item, dict)]
def get_credential_by_client_id(self, client_id: str) -> BackendCredential | None:
for item in self.list_credentials():
if item.client_id == client_id:
return item
return None
def save_credential(self, credential: BackendCredential) -> None:
creds = [item for item in self.list_credentials() if item.backend_id != credential.backend_id]
creds.append(credential)
creds.sort(key=lambda item: item.backend_id)
self._write_json(
self.credentials_path,
{"credentials": [item.model_dump(mode="json") for item in creds]},
)
def list_users(self) -> list[UserRecord]:
raw = self._read_json(self.users_path, {"users": []})
items = raw.get("users", [])
if not isinstance(items, list):
return []
return [UserRecord.model_validate(item) for item in items if isinstance(item, dict)]
def get_user(self, username: str) -> UserRecord | None:
for item in self.list_users():
if item.username == username:
return item
return None
def save_user(self, user: UserRecord) -> None:
users = [item for item in self.list_users() if item.username != user.username]
users.append(user)
users.sort(key=lambda item: item.username)
self._write_json(
self.users_path,
{"users": [item.model_dump(mode="json") for item in users]},
)
def get_permissions(self, backend_id: str) -> dict[str, Any]:
raw = self._read_json(self.permissions_path, {"permissions": {}})
permissions = raw.get("permissions", {})
if not isinstance(permissions, dict):
return {}
value = permissions.get(backend_id, {})
return value if isinstance(value, dict) else {}
def save_permissions(self, backend_id: str, permissions: dict[str, Any]) -> dict[str, Any]:
raw = self._read_json(self.permissions_path, {"permissions": {}})
root = raw.get("permissions", {})
if not isinstance(root, dict):
root = {}
root[backend_id] = permissions
self._write_json(self.permissions_path, {"permissions": root})
return permissions
def get_outlook_settings(self, backend_id: str) -> OutlookSettings | None:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return None
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
return None
outlook = backend_settings.get("outlook")
if not isinstance(outlook, dict):
return None
return OutlookSettings.model_validate(outlook)
def list_channel_settings(self, backend_id: str) -> dict[str, ChannelSettings]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return {}
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
return {}
channels = backend_settings.get("channels", {})
if not isinstance(channels, dict):
return {}
return {
str(channel_id): ChannelSettings.model_validate(settings)
for channel_id, settings in channels.items()
if isinstance(channel_id, str) and channel_id.strip() and isinstance(settings, dict)
}
def get_channel_settings(self, backend_id: str, channel_id: str) -> ChannelSettings | None:
return self.list_channel_settings(backend_id).get(channel_id)
def save_outlook_settings(self, backend_id: str, settings: OutlookSettings) -> dict[str, Any]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
root = {}
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
backend_settings = {}
backend_settings["outlook"] = settings.model_dump(mode="json")
root[backend_id] = backend_settings
self._write_json(self.settings_path, {"settings": root})
return backend_settings
def save_channel_settings(self, backend_id: str, channel_id: str, settings: ChannelSettings) -> dict[str, Any]:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
root = {}
backend_settings = root.get(backend_id, {})
if not isinstance(backend_settings, dict):
backend_settings = {}
channels = backend_settings.get("channels", {})
if not isinstance(channels, dict):
channels = {}
channels[channel_id] = settings.model_dump(mode="json")
backend_settings["channels"] = channels
root[backend_id] = backend_settings
self._write_json(self.settings_path, {"settings": root})
return channels[channel_id]
def delete_outlook_settings(self, backend_id: str) -> bool:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return False
backend_settings = root.get(backend_id)
if not isinstance(backend_settings, dict) or "outlook" not in backend_settings:
return False
backend_settings.pop("outlook", None)
if backend_settings:
root[backend_id] = backend_settings
else:
root.pop(backend_id, None)
self._write_json(self.settings_path, {"settings": root})
return True
def delete_channel_settings(self, backend_id: str, channel_id: str) -> bool:
raw = self._read_json(self.settings_path, {"settings": {}})
root = raw.get("settings", {})
if not isinstance(root, dict):
return False
backend_settings = root.get(backend_id)
if not isinstance(backend_settings, dict):
return False
channels = backend_settings.get("channels")
if not isinstance(channels, dict) or channel_id not in channels:
return False
channels.pop(channel_id, None)
if channels:
backend_settings["channels"] = channels
else:
backend_settings.pop("channels", None)
if backend_settings:
root[backend_id] = backend_settings
else:
root.pop(backend_id, None)
self._write_json(self.settings_path, {"settings": root})
return True
def disable_backend(self, backend_id: str) -> BackendRecord | None:
backend = self.get_backend(backend_id)
if backend is None:
return None
backend.status = "disabled"
backend.updated_at = utcnow_iso()
self.save_backend(backend)
return backend
def enable_backend(self, backend_id: str) -> BackendRecord | None:
backend = self.get_backend(backend_id)
if backend is None:
return None
backend.status = "active"
backend.updated_at = utcnow_iso()
self.save_backend(backend)
return backend
def touch_backend(self, backend_id: str) -> BackendRecord | None:
backend = self.get_backend(backend_id)
if backend is None:
return None
backend.updated_at = utcnow_iso()
self.save_backend(backend)
return backend

View File

@ -0,0 +1,554 @@
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from app.json_store import JsonStore
from app.models import (
ChannelSettings,
BackendCredential,
BackendRecord,
IntrospectRequest,
IntrospectResponse,
OAuthTokenRequest,
OAuthTokenResponse,
OutlookSettings,
RegisterBackendRequest,
RegisterBackendResponse,
RegisterUserRequest,
RegisterUserResponse,
RegisterUserBackendResult,
RotateSecretResponse,
UpdateBackendRequest,
UserRecord,
utcnow_iso,
)
from app.security import JwtSigner, generate_client_secret, hash_secret, verify_secret
DATA_DIR = Path(os.getenv("AUTHZ_DATA_DIR", Path(__file__).resolve().parents[1] / "data"))
ISSUER = os.getenv("AUTHZ_ISSUER", "http://127.0.0.1:19090").rstrip("/")
INTERNAL_TOKEN = os.getenv("AUTHZ_INTERNAL_TOKEN", "dev-internal-token")
ACCESS_TOKEN_TTL_SECONDS = int(os.getenv("AUTHZ_ACCESS_TOKEN_TTL_SECONDS", "3600"))
PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem"))
store = JsonStore(DATA_DIR)
signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS)
app = FastAPI(title="AuthZ Service", version="0.1.0")
def _slugify(value: str) -> str:
lowered = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return lowered or "backend"
def _next_backend_id(name: str) -> str:
base = _slugify(name)
existing = {item.backend_id for item in store.list_backends()}
candidate = base
index = 1
while candidate in existing:
index += 1
candidate = f"{base}-{index:03d}"
return candidate
def _ensure_backend(backend_id: str) -> BackendRecord:
backend = store.get_backend(backend_id)
if backend is None:
raise HTTPException(status_code=404, detail="Backend not found")
return backend
def _clean_optional(value: str | None) -> str | None:
cleaned = (value or "").strip()
return cleaned or None
def _require_internal(authorization: str | None = Header(default=None)) -> None:
token = ""
if authorization and authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
if token != INTERNAL_TOKEN:
raise HTTPException(status_code=401, detail="Invalid internal token")
def _allowed_scopes_for_audience(backend_id: str, audience: str) -> set[str]:
permissions = store.get_permissions(backend_id)
allowed: set[str] = set()
if audience.startswith("mcp:"):
server_id = audience.split(":", 1)[1]
mcp_config = permissions.get("mcp", {}).get(server_id, {})
if not isinstance(mcp_config, dict) or not mcp_config.get("enabled"):
return set()
allowed.add("list_tools")
for tool_name in mcp_config.get("tools", []) or []:
if isinstance(tool_name, str) and tool_name.strip():
allowed.add(f"tool:{tool_name.strip()}")
return allowed
if audience.startswith("a2a:"):
agent_id = audience.split(":", 1)[1]
a2a_config = permissions.get("a2a", {})
if not isinstance(a2a_config, dict) or not a2a_config.get("enabled"):
return set()
agents = a2a_config.get("agents", []) or []
if agent_id in agents:
allowed.add("run_task")
return allowed
if audience.startswith("channel:"):
channel_id = audience.split(":", 1)[1]
channel_config = permissions.get("channels", {}).get(channel_id, {})
if not isinstance(channel_config, dict) or not channel_config.get("enabled"):
return set()
# `invoke` is the baseline permission for a protected channel connector.
allowed.add("invoke")
for scope_name in channel_config.get("scopes", []) or []:
if isinstance(scope_name, str) and scope_name.strip():
allowed.add(scope_name.strip())
return allowed
return set()
def _issue_token(payload: OAuthTokenRequest) -> OAuthTokenResponse:
if payload.grant_type != "client_credentials":
raise HTTPException(status_code=400, detail="Unsupported grant_type")
credential = store.get_credential_by_client_id(payload.client_id)
if credential is None or not verify_secret(payload.client_secret, credential.client_secret_hash):
raise HTTPException(status_code=401, detail="Invalid client credentials")
backend = _ensure_backend(credential.backend_id)
if backend.status != "active":
raise HTTPException(status_code=403, detail="Backend is disabled")
allowed = _allowed_scopes_for_audience(credential.backend_id, payload.aud)
if not allowed:
raise HTTPException(status_code=403, detail="Audience is not enabled for this backend")
requested = set(payload.scopes or [])
if requested:
if not requested.issubset(allowed):
raise HTTPException(status_code=403, detail="Requested scopes exceed backend permissions")
scopes = sorted(requested)
else:
scopes = sorted(allowed)
token, expires_in = signer.issue_access_token(
credential=credential,
audience=payload.aud,
scopes=scopes,
)
return OAuthTokenResponse(access_token=token, expires_in=expires_in)
def _parse_token_request_from_form(form: dict[str, Any]) -> OAuthTokenRequest:
raw_scopes = form.get("scope", "")
scopes = [item for item in str(raw_scopes).split() if item]
return OAuthTokenRequest(
grant_type=str(form.get("grant_type") or "client_credentials"),
client_id=str(form.get("client_id") or ""),
client_secret=str(form.get("client_secret") or ""),
aud=str(form.get("aud") or form.get("resource") or ""),
scopes=scopes,
)
def _create_backend(
*,
backend_id: str,
name: str,
base_url: str,
frontend_base_url: str | None,
) -> tuple[BackendRecord, str]:
client_secret = generate_client_secret()
backend = BackendRecord(
backend_id=backend_id,
name=name,
base_url=base_url,
frontend_base_url=frontend_base_url,
)
credential = BackendCredential(
backend_id=backend_id,
client_id=backend_id,
client_secret_hash=hash_secret(client_secret),
)
store.save_backend(backend)
store.save_credential(credential)
return backend, client_secret
def _update_backend_record(
backend: BackendRecord,
*,
name: str | None = None,
base_url: str | None = None,
frontend_base_url: str | None = None,
) -> BackendRecord:
updated = backend.model_copy(deep=True)
if name is not None:
updated.name = name
if base_url is not None:
updated.base_url = base_url
if frontend_base_url is not None:
updated.frontend_base_url = frontend_base_url
updated.updated_at = utcnow_iso()
store.save_backend(updated)
return updated
def _upsert_user(
*,
username: str,
email: str | None,
default_backend_id: str | None,
) -> UserRecord:
existing = store.get_user(username)
if existing is None:
user = UserRecord(
username=username,
email=email,
default_backend_id=default_backend_id,
)
else:
user = existing.model_copy(deep=True)
if email is not None:
user.email = email
if default_backend_id is not None:
user.default_backend_id = default_backend_id
user.updated_at = utcnow_iso()
store.save_user(user)
return user
def _resolve_register_backend_payload(req: RegisterBackendRequest) -> tuple[str, str, str, str | None]:
backend_name = req.name.strip() or "backend"
backend_id = _clean_optional(req.backend_id)
base_url = req.base_url.strip()
if not base_url:
raise HTTPException(status_code=400, detail="base_url is required")
frontend_base_url = _clean_optional(req.frontend_base_url)
return backend_name, backend_id or _next_backend_id(backend_name), base_url, frontend_base_url
def _resolve_register_user_backend_payload(req: RegisterUserRequest) -> tuple[str, str | None, str, str | None]:
nested = req.backend
backend_name = _clean_optional(
(nested.name if nested else None)
or req.backend_name
or req.name
) or req.username.strip()
backend_id = _clean_optional((nested.backend_id if nested else None) or req.backend_id)
base_url = _clean_optional(
(nested.base_url if nested else None)
or req.public_base_url
or req.base_url
)
if not base_url:
raise HTTPException(status_code=400, detail="base_url is required")
frontend_base_url = _clean_optional(
(nested.frontend_base_url if nested else None)
or req.frontend_base_url
)
return backend_name, backend_id, base_url, frontend_base_url
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/.well-known/oauth-authorization-server")
async def oauth_metadata() -> dict[str, Any]:
return {
"issuer": ISSUER,
"token_endpoint": f"{ISSUER}/oauth/token",
"introspection_endpoint": f"{ISSUER}/oauth/introspect",
"jwks_uri": f"{ISSUER}/.well-known/jwks.json",
"grant_types_supported": ["client_credentials"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
}
@app.get("/.well-known/jwks.json")
async def jwks() -> dict[str, Any]:
return signer.build_jwks()
@app.post("/backends/register", response_model=RegisterBackendResponse)
async def register_backend(req: RegisterBackendRequest) -> RegisterBackendResponse:
backend_name, backend_id, base_url, frontend_base_url = _resolve_register_backend_payload(req)
if store.get_backend(backend_id) is not None:
raise HTTPException(status_code=409, detail="Backend already exists")
backend, client_secret = _create_backend(
backend_id=backend_id,
name=backend_name,
base_url=base_url,
frontend_base_url=frontend_base_url,
)
return RegisterBackendResponse(
backend_id=backend.backend_id,
client_id=backend.backend_id,
client_secret=client_secret,
created_at=backend.created_at,
frontend_base_url=backend.frontend_base_url,
)
@app.get("/backends")
async def list_backends() -> list[dict[str, Any]]:
return [item.model_dump(mode="json") for item in store.list_backends()]
@app.get("/backends/{backend_id}")
async def get_backend(backend_id: str) -> dict[str, Any]:
return _ensure_backend(backend_id).model_dump(mode="json")
@app.put("/backends/{backend_id}")
async def update_backend(backend_id: str, req: UpdateBackendRequest) -> dict[str, Any]:
backend = _ensure_backend(backend_id)
updated = _update_backend_record(
backend,
name=_clean_optional(req.name),
base_url=_clean_optional(req.base_url),
frontend_base_url=_clean_optional(req.frontend_base_url),
)
return updated.model_dump(mode="json")
@app.post("/backends/{backend_id}/disable")
async def disable_backend(backend_id: str) -> dict[str, Any]:
backend = store.disable_backend(backend_id)
if backend is None:
raise HTTPException(status_code=404, detail="Backend not found")
return backend.model_dump(mode="json")
@app.post("/backends/{backend_id}/enable")
async def enable_backend(backend_id: str) -> dict[str, Any]:
backend = store.enable_backend(backend_id)
if backend is None:
raise HTTPException(status_code=404, detail="Backend not found")
return backend.model_dump(mode="json")
@app.post("/backends/{backend_id}/rotate-secret", response_model=RotateSecretResponse)
async def rotate_secret(backend_id: str) -> RotateSecretResponse:
backend = _ensure_backend(backend_id)
client_secret = generate_client_secret()
credential = BackendCredential(
backend_id=backend_id,
client_id=backend_id,
client_secret_hash=hash_secret(client_secret),
created_at=utcnow_iso(),
rotated_at=utcnow_iso(),
)
store.save_credential(credential)
return RotateSecretResponse(
backend_id=backend_id,
client_id=backend_id,
client_secret=client_secret,
rotated_at=credential.rotated_at or credential.created_at,
)
@app.get("/backends/{backend_id}/permissions")
async def get_permissions(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
return store.get_permissions(backend_id)
@app.post("/backends/{backend_id}/permissions")
async def save_permissions(backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
_ensure_backend(backend_id)
saved = store.save_permissions(backend_id, payload)
store.touch_backend(backend_id)
return saved
@app.get("/backends/{backend_id}/settings/outlook")
async def get_outlook_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_outlook_settings(backend_id)
if settings is None:
return {"configured": False}
return settings.masked_dict()
@app.post("/backends/{backend_id}/settings/outlook")
async def save_outlook_settings(backend_id: str, payload: OutlookSettings) -> dict[str, Any]:
_ensure_backend(backend_id)
existing = store.get_outlook_settings(backend_id)
if not payload.password:
if existing is None or not existing.password:
raise HTTPException(status_code=400, detail="Password is required for initial Outlook setup")
payload = payload.model_copy(update={"password": existing.password, "updated_at": utcnow_iso()})
else:
payload = payload.model_copy(update={"updated_at": utcnow_iso()})
store.save_outlook_settings(backend_id, payload)
store.touch_backend(backend_id)
return payload.masked_dict()
@app.delete("/backends/{backend_id}/settings/outlook")
async def delete_outlook_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
removed = store.delete_outlook_settings(backend_id)
if removed:
store.touch_backend(backend_id)
return {"ok": removed}
@app.get("/backends/{backend_id}/settings/channels")
async def list_channel_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
return {
channel_id: settings.masked_dict()
for channel_id, settings in store.list_channel_settings(backend_id).items()
}
@app.get("/backends/{backend_id}/settings/channels/{channel_id}")
async def get_channel_settings(backend_id: str, channel_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_channel_settings(backend_id, channel_id)
if settings is None:
return {"configured": False}
return settings.masked_dict()
@app.post("/backends/{backend_id}/settings/channels/{channel_id}")
async def save_channel_settings(backend_id: str, channel_id: str, payload: ChannelSettings) -> dict[str, Any]:
_ensure_backend(backend_id)
existing = store.get_channel_settings(backend_id, channel_id)
if not payload.secrets and existing is not None and existing.secrets:
payload = payload.model_copy(update={"secrets": existing.secrets, "updated_at": utcnow_iso()})
else:
payload = payload.model_copy(update={"updated_at": utcnow_iso()})
store.save_channel_settings(backend_id, channel_id, payload)
store.touch_backend(backend_id)
return payload.masked_dict()
@app.delete("/backends/{backend_id}/settings/channels/{channel_id}")
async def delete_channel_settings(backend_id: str, channel_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
removed = store.delete_channel_settings(backend_id, channel_id)
if removed:
store.touch_backend(backend_id)
return {"ok": removed}
@app.post("/oauth/register", response_model=RegisterUserResponse)
async def oauth_register(req: RegisterUserRequest) -> RegisterUserResponse:
username = req.username.strip()
if not username:
raise HTTPException(status_code=400, detail="username is required")
if not req.password:
raise HTTPException(status_code=400, detail="password is required")
backend_name, requested_backend_id, base_url, frontend_base_url = _resolve_register_user_backend_payload(req)
backend_id = requested_backend_id or _next_backend_id(backend_name)
existing_backend = store.get_backend(backend_id)
client_secret: str | None = None
if existing_backend is None:
backend, client_secret = _create_backend(
backend_id=backend_id,
name=backend_name,
base_url=base_url,
frontend_base_url=frontend_base_url,
)
else:
backend = _update_backend_record(
existing_backend,
name=backend_name,
base_url=base_url,
frontend_base_url=frontend_base_url,
)
user = _upsert_user(
username=username,
email=_clean_optional(req.email),
default_backend_id=backend.backend_id,
)
return RegisterUserResponse(
user=user,
backend=RegisterUserBackendResult(
backend_id=backend.backend_id,
client_id=backend.backend_id,
client_secret=client_secret,
name=backend.name,
base_url=backend.base_url,
frontend_base_url=backend.frontend_base_url,
status=backend.status,
created_at=backend.created_at,
),
)
@app.get("/internal/backends/{backend_id}/settings/outlook", dependencies=[Depends(_require_internal)])
async def get_internal_outlook_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_outlook_settings(backend_id)
if settings is None:
raise HTTPException(status_code=404, detail="Outlook settings not configured")
return settings.model_dump(mode="json")
@app.get("/internal/backends/{backend_id}/settings/channels", dependencies=[Depends(_require_internal)])
async def list_internal_channel_settings(backend_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
return {
channel_id: settings.model_dump(mode="json")
for channel_id, settings in store.list_channel_settings(backend_id).items()
}
@app.get("/internal/backends/{backend_id}/settings/channels/{channel_id}", dependencies=[Depends(_require_internal)])
async def get_internal_channel_settings(backend_id: str, channel_id: str) -> dict[str, Any]:
_ensure_backend(backend_id)
settings = store.get_channel_settings(backend_id, channel_id)
if settings is None:
raise HTTPException(status_code=404, detail="Channel settings not configured")
return settings.model_dump(mode="json")
@app.post("/oauth/token", response_model=OAuthTokenResponse)
async def oauth_token(request: Request) -> OAuthTokenResponse:
content_type = request.headers.get("content-type", "")
if "application/json" in content_type:
payload = OAuthTokenRequest.model_validate(await request.json())
else:
form = await request.form()
payload = _parse_token_request_from_form(dict(form))
return _issue_token(payload)
@app.post("/oauth/introspect", response_model=IntrospectResponse, dependencies=[Depends(_require_internal)])
async def oauth_introspect(req: IntrospectRequest) -> IntrospectResponse:
try:
payload = signer.decode(req.token)
except Exception:
return IntrospectResponse(active=False)
return IntrospectResponse(
active=True,
client_id=str(payload.get("client_id") or ""),
backend_id=str(payload.get("backend_id") or ""),
aud=str(payload.get("aud") or ""),
scp=[str(item) for item in payload.get("scp", []) if str(item).strip()],
exp=int(payload.get("exp")) if payload.get("exp") is not None else None,
)
@app.exception_handler(HTTPException)
async def http_error_handler(_: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})

View File

@ -0,0 +1,168 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from pydantic import BaseModel, Field
def utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
class BackendRecord(BaseModel):
backend_id: str
name: str
base_url: str
frontend_base_url: str | None = None
status: str = "active"
created_at: str = Field(default_factory=utcnow_iso)
updated_at: str = Field(default_factory=utcnow_iso)
class BackendCredential(BaseModel):
backend_id: str
client_id: str
client_secret_hash: str
created_at: str = Field(default_factory=utcnow_iso)
rotated_at: str | None = None
class UserRecord(BaseModel):
username: str
email: str | None = None
default_backend_id: str | None = None
created_at: str = Field(default_factory=utcnow_iso)
updated_at: str = Field(default_factory=utcnow_iso)
class PermissionsModel(BaseModel):
permissions: dict[str, Any] = Field(default_factory=dict)
class ChannelSettings(BaseModel):
configured: bool = True
config: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, Any] = Field(default_factory=dict)
updated_at: str = Field(default_factory=utcnow_iso)
def masked_dict(self) -> dict[str, Any]:
data = self.model_dump(mode="json")
secrets = data.pop("secrets", {})
data["secrets_masked"] = bool(secrets)
data["secret_keys"] = sorted(
str(key).strip()
for key in (secrets.keys() if isinstance(secrets, dict) else [])
if str(key).strip()
)
return data
class OutlookSettings(BaseModel):
configured: bool = True
email: str
username: str
domain: str | None = None
service_endpoint: str | None = None
server: str | None = None
autodiscover: bool = False
default_timezone: str = "Asia/Shanghai"
password: str
updated_at: str = Field(default_factory=utcnow_iso)
def masked_dict(self) -> dict[str, Any]:
data = self.model_dump()
data.pop("password", None)
data["password_masked"] = True
return data
class BackendRoutingPayload(BaseModel):
name: str | None = None
backend_id: str | None = None
base_url: str | None = None
frontend_base_url: str | None = None
class RegisterBackendRequest(BaseModel):
name: str
base_url: str
backend_id: str | None = None
frontend_base_url: str | None = None
class UpdateBackendRequest(BaseModel):
name: str | None = None
base_url: str | None = None
frontend_base_url: str | None = None
class RegisterBackendResponse(BaseModel):
backend_id: str
client_id: str
client_secret: str
created_at: str
frontend_base_url: str | None = None
class RegisterUserRequest(BaseModel):
username: str
password: str
email: str | None = None
name: str | None = None
backend_name: str | None = None
backend_id: str | None = None
base_url: str | None = None
public_base_url: str | None = None
frontend_base_url: str | None = None
backend: BackendRoutingPayload | None = None
class RegisterUserBackendResult(BaseModel):
backend_id: str
client_id: str
client_secret: str | None = None
name: str
base_url: str
frontend_base_url: str | None = None
status: str = "active"
created_at: str
class RegisterUserResponse(BaseModel):
user: UserRecord
backend: RegisterUserBackendResult
class RotateSecretResponse(BaseModel):
backend_id: str
client_id: str
client_secret: str
rotated_at: str
class OAuthTokenRequest(BaseModel):
grant_type: str = "client_credentials"
client_id: str
client_secret: str
aud: str
scopes: list[str] = Field(default_factory=list)
class OAuthTokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
class IntrospectRequest(BaseModel):
token: str
class IntrospectResponse(BaseModel):
active: bool
client_id: str | None = None
backend_id: str | None = None
aud: str | None = None
scp: list[str] = Field(default_factory=list)
exp: int | None = None

View File

@ -0,0 +1,121 @@
from __future__ import annotations
import base64
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from uuid import uuid4
import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from app.models import BackendCredential
def hash_secret(secret: str) -> str:
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
def verify_secret(secret: str, digest: str) -> bool:
return secrets.compare_digest(hash_secret(secret), digest)
def generate_client_secret() -> str:
return secrets.token_urlsafe(32)
class JwtSigner:
def __init__(self, key_path: Path, issuer: str, ttl_seconds: int):
self.key_path = key_path
self.issuer = issuer.rstrip("/")
self.ttl_seconds = ttl_seconds
self._private_key = self._load_or_create_private_key()
self._public_key = self._private_key.public_key()
self._kid = self._compute_kid()
def _load_or_create_private_key(self):
self.key_path.parent.mkdir(parents=True, exist_ok=True)
if self.key_path.exists():
return serialization.load_pem_private_key(
self.key_path.read_bytes(),
password=None,
)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
self.key_path.write_bytes(pem)
return private_key
def _compute_kid(self) -> str:
der = self._public_key.public_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return hashlib.sha256(der).hexdigest()[:16]
def build_jwks(self) -> dict[str, Any]:
public_numbers = self._public_key.public_numbers()
def _b64url(value: int) -> str:
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
return {
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": self._kid,
"n": _b64url(public_numbers.n),
"e": _b64url(public_numbers.e),
}
]
}
def issue_access_token(
self,
*,
credential: BackendCredential,
audience: str,
scopes: list[str],
) -> tuple[str, int]:
now = datetime.now(timezone.utc)
exp = now + timedelta(seconds=self.ttl_seconds)
payload = {
"iss": self.issuer,
"sub": f"backend:{credential.backend_id}",
"client_id": credential.client_id,
"backend_id": credential.backend_id,
"aud": audience,
"scp": scopes,
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int(exp.timestamp()),
"jti": str(uuid4()),
}
token = jwt.encode(
payload,
self._private_key,
algorithm="RS256",
headers={"kid": self._kid},
)
return token, self.ttl_seconds
def decode(self, token: str) -> dict[str, Any]:
return jwt.decode(
token,
self._public_key,
algorithms=["RS256"],
issuer=self.issuer,
options={
"require": ["iss", "sub", "aud", "exp", "iat", "jti"],
"verify_aud": False,
},
)