Přeskočit obsah

Autentizace

3 schémata authentizace v systému: JWT Bearer (user-facing), Admin Hash (legacy ops gate), HMAC (Nette integration).

JWT Bearer (Supabase Auth)

Sign-in flow

  1. User otevře /login ve frontu
  2. Frontend volá supabase.auth.signInWithOtp({ email }) (anon key)
  3. Supabase pošle magic link přes Resend SMTP
  4. User klikne → browser navigace na https://nemoreport-ai-frontend-v2.algaweb.workers.dev/auth/confirm?token_hash=XYZ&type=magiclink
  5. Frontend route /auth/confirm volá supabase.auth.verifyOtp({ token_hash, type }) server-side
  6. Supabase vrátí session cookies (sb-*)
  7. Custom JWT hook (private.custom_access_token_hook) injektuje claims do JWT:
  8. sub: user UUID
  9. aud: "authenticated"
  10. personal_tenant_id: UUID
  11. active_tenant_id: UUID
  12. role: "authenticated"
  13. email, iat, exp, iss

Verifikace JWT v Backend

app/auth.py:require_user:

async def require_user(creds: HTTPAuthorizationCredentials = Depends(_bearer)) -> AuthUser:
    token = creds.credentials
    # JWKS cache (1h TTL)
    public_key = await _fetch_jwks(jwt.get_unverified_header(token)["kid"])
    claims = jwt.decode(
        token,
        public_key,
        algorithms=settings.jwt_algorithms_list,  # ["RS256", "ES256"]
        audience=settings.jwt_audience,           # "authenticated"
        options={"require": ["exp", "iat", "sub"]},
    )
    return AuthUser(
        id=UUID(claims["sub"]),
        email=claims.get("email"),
        access_token=token,
        tenant_id=UUID(claims.get("active_tenant_id") or claims["personal_tenant_id"]),
        personal_tenant_id=UUID(claims["personal_tenant_id"]),
        active_tenant_id=UUID(claims.get("active_tenant_id")) if claims.get("active_tenant_id") else None,
        role=claims.get("role", "authenticated"),
        claims=claims,
    )

AuthUser dataclass

@dataclass(frozen=True, slots=True)
class AuthUser:
    id: UUID                              # auth.users.id
    email: str | None
    access_token: str                     # raw JWT (pro propagaci do user Supabase clientu)
    tenant_id: UUID                       # effective: active → personal fallback
    personal_tenant_id: UUID | None
    active_tenant_id: UUID | None
    role: str
    claims: dict[str, Any]

JWT v API requestech

Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

Frontend: apiFetch() wrapper v src/lib/api.ts automaticky injektuje JWT z Supabase session cookies (přes server-side fetch v Server Components, nebo client-side fetch přes Supabase browser client).

Token lifetime

  • Default Supabase: access_token = 1h, refresh_token = ~30 dní
  • Refresh probíhá pasivně v @supabase/ssr na server-side render request
  • 401 fallback: frontend redirectne na /login

JWKS rotation

Supabase rotuje JWKS klíče občas. Backend používá cache s 1h TTL — po rotation přibližně 1h staré tokens fail-ují, FE klient získá nový JWT přes refresh.

Admin Hash (legacy ops gate)

Použito pro /admin/* endpointy. Gate na hash v env, NE OAuth/SSO.

Verify

app/auth.py:require_admin:

def require_admin(request: Request) -> None:
    import hmac
    provided = request.headers.get("x-admin-hash") or request.query_params.get("hash", "")
    expected = settings.admin_hash
    if not expected:
        raise HTTPException(403, "admin disabled")
    if not hmac.compare_digest(provided, expected):
        raise HTTPException(403, "forbidden")

Použití

GET /admin/cost/global?hash=<ADMIN_HASH>

NEBO header:

X-Admin-Hash: <ADMIN_HASH>

hmac.compare_digest = timing-safe compare.

Známé limity

  • Single shared secret — všichni adminové sdílí stejný hash. Pro produkci by bylo lepší per-admin user account s is_admin flag.
  • Žádný revocation — pokud hash leakne, musí se rotnout v Sliplane env + všem komunikovat.
  • Žádný audit log kdo co kdy admin operations dělal.

Pro Phase D / produkci doporučeno: redesign admin auth na Supabase user table s is_admin boolean + audit logging do user_events.

HMAC (Nette integration, Phase B.11)

Použito pro /reports/{id}/attachments/system endpoint. Žádný JWT — Nette systém autentizuje signaturou nad canonical string.

Canonical string

<report_id>|<nette_id>|<attachment_type>|<sha256(file_bytes)>

Důvod canonical string (ne raw multipart body): boundary se liší napříč klienty (různé HTTP libraries), takže HMAC nad raw bytes by nereprodukovatelný. Canonical fix tento problem.

Verify

import hmac, hashlib

canonical = f"{report_id}|{nette_id}|{attachment_type}|{file_sha256}"
expected_signature = hmac.new(
    settings.nette_hmac_secret.encode(),
    canonical.encode(),
    hashlib.sha256,
).hexdigest()

provided = request.headers.get("X-Nette-Signature", "")
if not hmac.compare_digest(provided, expected_signature):
    raise HTTPException(401, "invalid HMAC")

Nette-side example (PHP)

$report_id = "uuid-...";
$nette_id = "12345";
$attachment_type = "vyjadreni_cetin";
$file_bytes = file_get_contents("vyjadreni.pdf");
$file_sha256 = hash("sha256", $file_bytes);

$canonical = "$report_id|$nette_id|$attachment_type|$file_sha256";
$signature = hash_hmac("sha256", $canonical, $nette_hmac_secret);

$ch = curl_init("https://nemoreport-ai-backend-v2.sliplane.app/reports/$report_id/attachments/system");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "file" => new CURLFile("vyjadreni.pdf"),
    "nette_id" => $nette_id,
    "attachment_type" => $attachment_type,
]);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "X-Nette-Signature: $signature",
]);
$response = curl_exec($ch);

Idempotence

UNIQUE partial index (report_id, nette_id) WHERE source='nette' AND nette_id IS NOT NULL zajistí že duplicate retry s stejným nette_id vrátí 200 s původním ID místo duplicate insert.

Phase E: Nette JWT bridge (plánované)

V Phase E přidáme verify_nette_jwt() flow:

  1. Nette podepisuje RS256 JWT s aud="nemoreport-ai", iss="nemoreport.cz", exp short-lived (15min)
  2. Frontend (chat iframe) postMessage Nette parent → dostane JWT
  3. Frontend volá POST /auth/nette-exchange { jwt }
  4. Backend ověří RS256 přes NETTE_JWT_PUBLIC_KEY (PEM)
  5. Lookup nebo create Supabase user via auth.identities(provider='nette', provider_id=nette_user_id)
  6. Generate Supabase session přes admin API → return { access_token, refresh_token }
  7. Frontend uloží Supabase session, dál chatuje normálně

app/auth.py:verify_nette_jwt() je již ready hook (neaktivní v Phase A — fail-safe pokud NETTE_JWT_PUBLIC_KEY není set):

def verify_nette_jwt(token: str, *, audience: str = "nemoreport-ai") -> dict:
    if not settings.nette_jwt_public_key:
        raise HTTPException(503, "nette_jwt_disabled")
    return jwt.decode(
        token,
        settings.nette_jwt_public_key,
        algorithms=["RS256"],
        audience=audience,
        issuer="nemoreport.cz",
        options={"require": ["exp", "iat", "sub", "aud", "iss"]},
    )

Bezpečnost

  • HTTPS only — všechny endpointy přes TLS
  • CORS — explicit origin allowlist (workers.dev, localhost:3000), žádný *
  • Rate limiting — slowapi per IP (60/min default, 20/hour pro upload, 5/min pro migrate claim)
  • Defense-in-depth — RLS policies + table grants + sequence grants (Phase A.1 lessons)
  • Sensitive secrets — Sliplane env (secret=true masked v API responses)
  • JWT timing-safe comparehmac.compare_digest všude
  • HMAC canonical string — eliminuje boundary fragility

Detail viz Bezpečnost.