mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
2435da232b
* fix(api-tokens): scope per-token rate limits to live tokens Forged or token-shaped Authorization headers no longer mint their own rate-limit budget. The key generator now grants a per-token budget only to a real, active token and falls back to per-IP keying for anything else, so a single source cannot evade the global limiter by rotating fake tokens. The validated token is memoized on the request, so authentication reuses it without a second database lookup. Token validation (format, checksum, lookup, revocation, expiry) is now a single shared helper used by the HTTP auth middleware, the WebSocket upgrade handler, and the rate-limit key generator, replacing two near-identical inline copies that could drift apart. The last-used timestamp write is throttled so a busy token no longer writes to the database on every request. * fix(api-tokens): surface token list-load failures with a retry A failed load of the API tokens list was swallowed: a server error rendered the empty "no tokens yet" state with no sign that anything went wrong. The list now shows an error card with a Retry action and raises a toast on any non-ok response or network error, matching the create and revoke flows. Adds a troubleshooting entry for the error. * test(api-tokens): seed tokens via the shared test helper The new hardening and WS-scope suites computed sha256 of a raw token directly, which CodeQL flags as js/insufficient-password-hash (a false positive: these are 256-bit CSPRNG opaque tokens, not passwords). Route token creation through the existing apiTokenTestHelper and read the stored token_hash back from the row, so the suites no longer hash anything themselves. Also removes the duplicated createToken helpers. * fix(api-tokens): key the rate limiter by the same credential auth uses The rate-limit key generator checked the session cookie before the Authorization bearer, while authMiddleware authenticates bearer-over-cookie (bearerToken || cookieToken). A request could send a Bearer API token plus a forged cookie and be keyed by the cookie's (forgeable, rotatable) username, sidestepping the per-token / per-IP keying the limiter applies to API tokens: a valid token would lose its own bucket, and a forged token-shaped bearer would no longer collapse to per-IP. Reorder the generator to mirror auth: process the bearer first (validate the API token and key per-token or fall back to per-IP; otherwise decode the JWT by username/sub), and consult the cookie only when there is no bearer. Regression tests cover a valid and a forged sen_sk_ bearer, each sent with a forged cookie.
49 lines
2.4 KiB
TypeScript
49 lines
2.4 KiB
TypeScript
import crypto from 'crypto';
|
|
import { DatabaseService, type ApiToken } from '../services/DatabaseService';
|
|
import { looksLikeApiToken, verifyApiTokenChecksum } from './apiTokenFormat';
|
|
|
|
/**
|
|
* Result of validating an opaque `sen_sk_` API token. The failure `reason` is
|
|
* for diagnostic logging only; callers MUST surface a single uniform 401 so the
|
|
* response body never becomes a token-existence oracle.
|
|
*/
|
|
export type ApiTokenValidation =
|
|
| { ok: true; token: ApiToken }
|
|
| { ok: false; reason: 'not-api-token' | 'checksum' | 'not-found' | 'revoked' | 'expired' };
|
|
|
|
/**
|
|
* Validate an API token with no side effects: format, checksum (timing-safe),
|
|
* hash lookup, revocation, and expiry. The format and checksum checks
|
|
* short-circuit before the SQLite lookup so malformed or bad-checksum keys never
|
|
* touch the database. Shared by the HTTP auth middleware, the WebSocket upgrade
|
|
* handler, and the rate limiter's key generator so all three agree on what
|
|
* counts as a live token.
|
|
*/
|
|
export function validateApiToken(token: string): ApiTokenValidation {
|
|
if (!looksLikeApiToken(token)) return { ok: false, reason: 'not-api-token' };
|
|
if (!verifyApiTokenChecksum(token)) return { ok: false, reason: 'checksum' };
|
|
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
|
const apiToken = DatabaseService.getInstance().getApiTokenByHash(tokenHash);
|
|
if (!apiToken) return { ok: false, reason: 'not-found' };
|
|
if (apiToken.revoked_at) return { ok: false, reason: 'revoked' };
|
|
if (apiToken.expires_at && apiToken.expires_at < Date.now()) return { ok: false, reason: 'expired' };
|
|
return { ok: true, token: apiToken };
|
|
}
|
|
|
|
/** Minimum interval between `last_used_at` writes for a single token. */
|
|
const LAST_USED_THROTTLE_MS = 60_000;
|
|
|
|
/**
|
|
* Record that a token was used, skipping the write when last_used_at is still
|
|
* within the throttle window. Authenticated API requests fire on every
|
|
* CI/script call, so an unconditional bump was a synchronous SQLite write per
|
|
* request; the throttle keeps "last used" accurate to the minute while removing
|
|
* that write amplification from the hot path. Best-effort: the check reads the
|
|
* row fetched at request start, so concurrent requests for one token may each
|
|
* write once.
|
|
*/
|
|
export function touchApiTokenLastUsed(token: ApiToken): void {
|
|
if (token.last_used_at && Date.now() - token.last_used_at < LAST_USED_THROTTLE_MS) return;
|
|
DatabaseService.getInstance().updateApiTokenLastUsed(token.id);
|
|
}
|