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¶
- User otevře
/loginve frontu - Frontend volá
supabase.auth.signInWithOtp({ email })(anon key) - Supabase pošle magic link přes Resend SMTP
- User klikne → browser navigace na
https://nemoreport-ai-frontend-v2.algaweb.workers.dev/auth/confirm?token_hash=XYZ&type=magiclink - Frontend route
/auth/confirmvolásupabase.auth.verifyOtp({ token_hash, type })server-side - Supabase vrátí session cookies (
sb-*) - Custom JWT hook (
private.custom_access_token_hook) injektuje claims do JWT: sub: user UUIDaud: "authenticated"personal_tenant_id: UUIDactive_tenant_id: UUIDrole: "authenticated"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¶
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/ssrna 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í¶
NEBO header:
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_adminflag. - Žá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¶
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:
- Nette podepisuje RS256 JWT s
aud="nemoreport-ai",iss="nemoreport.cz",expshort-lived (15min) - Frontend (chat iframe) postMessage Nette parent → dostane JWT
- Frontend volá
POST /auth/nette-exchange { jwt } - Backend ověří RS256 přes
NETTE_JWT_PUBLIC_KEY(PEM) - Lookup nebo create Supabase user via
auth.identities(provider='nette', provider_id=nette_user_id) - Generate Supabase session přes admin API → return
{ access_token, refresh_token } - 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 compare —
hmac.compare_digestvšude - HMAC canonical string — eliminuje boundary fragility
Detail viz Bezpečnost.