Files

172 lines
5.5 KiB
Python

from __future__ import annotations
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import json
import os
from typing import Callable
from fastapi import Depends, Header, HTTPException
from services.shared.core import Role
def _bool_env(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def legacy_header_auth_allowed() -> bool:
return _bool_env("ALLOW_LEGACY_HEADER_AUTH", True)
def _app_token_secret() -> str:
return os.getenv("APP_TOKEN_SECRET", "dev-secret-change-me")
def _app_token_ttl_seconds() -> int:
raw = os.getenv("APP_TOKEN_TTL_SECONDS", "3600").strip()
try:
value = int(raw)
except ValueError:
value = 3600
return max(value, 60)
def _b64url_encode(raw: bytes) -> str:
return urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
def _b64url_decode(raw: str) -> bytes:
padding = "=" * (-len(raw) % 4)
return urlsafe_b64decode((raw + padding).encode("utf-8"))
def _sign(message: str) -> str:
digest = hmac.new(
_app_token_secret().encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).digest()
return _b64url_encode(digest)
def issue_app_token(
*,
subject: str,
username: str,
role: str,
auth_source: str,
provider: str | None = None,
full_name: str | None = None,
email: str | None = None,
tenant_id: str | None = None,
ttl_seconds: int | None = None,
) -> str:
now = datetime.now(timezone.utc)
ttl = ttl_seconds if ttl_seconds is not None else _app_token_ttl_seconds()
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"sub": subject,
"username": username,
"role": role,
"auth_source": auth_source,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(seconds=ttl)).timestamp()),
}
if provider:
payload["provider"] = provider
if full_name:
payload["full_name"] = full_name
if email:
payload["email"] = email
if tenant_id:
payload["tenant_id"] = tenant_id
encoded_header = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8"))
encoded_payload = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
message = f"{encoded_header}.{encoded_payload}"
signature = _sign(message)
return f"{message}.{signature}"
def decode_app_token(token: str) -> dict:
try:
encoded_header, encoded_payload, encoded_signature = token.split(".")
except ValueError as exc:
raise HTTPException(status_code=401, detail="Invalid token format") from exc
message = f"{encoded_header}.{encoded_payload}"
expected_signature = _sign(message)
if not hmac.compare_digest(encoded_signature, expected_signature):
raise HTTPException(status_code=401, detail="Invalid token signature")
try:
payload = json.loads(_b64url_decode(encoded_payload).decode("utf-8"))
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=401, detail="Invalid token payload") from exc
exp = int(payload.get("exp", 0) or 0)
now_ts = int(datetime.now(timezone.utc).timestamp())
if exp and exp < now_ts:
raise HTTPException(status_code=401, detail="Token expired")
return payload
def get_actor(
authorization: str | None = Header(default=None, alias="Authorization"),
x_user: str | None = Header(default=None, alias="X-User"),
x_role: str | None = Header(default=None, alias="X-Role"),
x_tenant_id: str | None = Header(default=None, alias="X-Tenant-ID"),
) -> dict:
if authorization:
scheme, _, value = authorization.partition(" ")
if scheme.lower() != "bearer" or not value.strip():
raise HTTPException(status_code=401, detail="Invalid authorization header")
payload = decode_app_token(value.strip())
tenant_id = str(payload.get("tenant_id") or "").strip()
tenant_source = "token" if tenant_id else None
if not tenant_id:
tenant_id = str(x_tenant_id or "").strip()
tenant_source = "header" if tenant_id else None
return {
"sub": payload.get("sub", ""),
"user": payload.get("username", "anonymous"),
"role": str(payload.get("role", "anonymous")).strip().lower() or "anonymous",
"auth_source": payload.get("auth_source", "token"),
"provider": payload.get("provider"),
"full_name": payload.get("full_name"),
"email": payload.get("email"),
"tenant_id": tenant_id or None,
"tenant_source": tenant_source,
}
if legacy_header_auth_allowed():
role = (x_role or "").strip().lower()
tenant_id = str(x_tenant_id or "").strip()
return {
"user": (x_user or "anonymous").strip(),
"role": role or "anonymous",
"auth_source": "legacy",
"tenant_id": tenant_id or None,
"tenant_source": "header" if tenant_id else None,
}
return {"user": "anonymous", "role": "anonymous", "auth_source": "none", "tenant_id": None, "tenant_source": None}
def require_roles(*allowed: Role) -> Callable:
allowed_values = {a.value for a in allowed}
def dependency(actor: dict = Depends(get_actor)) -> dict:
if actor["role"] not in allowed_values:
raise HTTPException(status_code=403, detail="Insufficient role")
return actor
return dependency