feat(api): port Fase 5 REST API — Bun.serve router + JWT middleware + auth/session/conversation/chat handlers + composition root
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Authentication handlers — login, register, and token refresh.
|
||||
* Mirrors `apps/interfaces/api/src/handlers/auth.rs`.
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /auth/login — authenticate, return JWT pair
|
||||
* - POST /auth/register — create account, return JWT pair
|
||||
* - POST /auth/refresh — exchange refresh token for a new pair
|
||||
*
|
||||
* All three are rate-limited (20 attempts / 10 min per client).
|
||||
*/
|
||||
import type { ApiState, RateLimiter } from "../state.ts";
|
||||
import { ApiError } from "../error.ts";
|
||||
import { loadUsers, saveUsers } from "../state.ts";
|
||||
import type { AuthResponse, LoginRequest, RegisterRequest, RefreshRequest } from "../dto.ts";
|
||||
|
||||
/** Login/register brute-force protection: 20 attempts per 10-minute window. */
|
||||
const AUTH_RATE_LIMIT_MAX = 20;
|
||||
const AUTH_RATE_LIMIT_WINDOW_SECS = 600;
|
||||
|
||||
/** Extract a coarse client identity (X-Forwarded-For first hop or "unknown"). */
|
||||
function clientId(headers: Headers): string {
|
||||
const xff = headers.get("x-forwarded-for");
|
||||
if (xff) {
|
||||
const first = xff.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Enforce the auth rate limit; throws TooManyRequests when exceeded. */
|
||||
function enforceRateLimit(limiter: RateLimiter, headers: Headers): void {
|
||||
if (!limiter.check(clientId(headers), AUTH_RATE_LIMIT_MAX, AUTH_RATE_LIMIT_WINDOW_SECS)) {
|
||||
throw ApiError.tooManyRequests("Too many requests, try again later");
|
||||
}
|
||||
}
|
||||
|
||||
function requireFields(req: LoginRequest & RegisterRequest, reg: boolean): void {
|
||||
if (req.username === "") {
|
||||
throw ApiError.badRequest("Username is required");
|
||||
}
|
||||
if (req.password === "") {
|
||||
throw ApiError.badRequest("Password is required");
|
||||
}
|
||||
if (reg && req.password.length < 6) {
|
||||
throw ApiError.badRequest("Password must be at least 6 characters");
|
||||
}
|
||||
}
|
||||
|
||||
/** Issue an AuthResponse for the given subject. */
|
||||
function authResponse(state: ApiState, sub: string): AuthResponse {
|
||||
const [access, refresh] = state.token_service.generateTokens(sub);
|
||||
return {
|
||||
access_token: access,
|
||||
refresh_token: refresh,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600, // access token expiry in seconds
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /auth/login — authenticate and issue a JWT pair. */
|
||||
export async function loginHandler(
|
||||
state: ApiState,
|
||||
headers: Headers,
|
||||
body: LoginRequest,
|
||||
): Promise<AuthResponse> {
|
||||
enforceRateLimit(state.auth_rate_limiter, headers);
|
||||
requireFields(body, false);
|
||||
|
||||
const users = loadUsers(state.store_base_dir);
|
||||
if (!users) {
|
||||
throw ApiError.unauthorized("Invalid username or password");
|
||||
}
|
||||
const storedHash = users[body.username];
|
||||
if (!storedHash) {
|
||||
throw ApiError.unauthorized("Invalid username or password");
|
||||
}
|
||||
|
||||
const valid = await state.password_service.verify(body.password, storedHash);
|
||||
if (!valid) {
|
||||
throw ApiError.unauthorized("Invalid username or password");
|
||||
}
|
||||
|
||||
return authResponse(state, body.username);
|
||||
}
|
||||
|
||||
/** POST /auth/register — create a new user account and issue a JWT pair. */
|
||||
export async function registerHandler(
|
||||
state: ApiState,
|
||||
headers: Headers,
|
||||
body: RegisterRequest,
|
||||
): Promise<AuthResponse> {
|
||||
enforceRateLimit(state.auth_rate_limiter, headers);
|
||||
requireFields(body, true);
|
||||
|
||||
// Hash first (async), then serialize the read-modify-write of users.json.
|
||||
const hash = await state.password_service.hash(body.password);
|
||||
|
||||
const unlock = state.users_lock.lock();
|
||||
try {
|
||||
const users = loadUsers(state.store_base_dir) ?? {};
|
||||
if (body.username in users) {
|
||||
throw ApiError.conflict("Username already exists");
|
||||
}
|
||||
users[body.username] = hash;
|
||||
saveUsers(state.store_base_dir, users);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
|
||||
return authResponse(state, body.username);
|
||||
}
|
||||
|
||||
/** POST /auth/refresh — exchange a refresh token for a fresh pair. */
|
||||
export async function refreshHandler(
|
||||
state: ApiState,
|
||||
headers: Headers,
|
||||
body: RefreshRequest,
|
||||
): Promise<AuthResponse> {
|
||||
enforceRateLimit(state.auth_rate_limiter, headers);
|
||||
if (body.refresh_token === "") {
|
||||
throw ApiError.badRequest("Refresh token is required");
|
||||
}
|
||||
|
||||
let sub: string;
|
||||
try {
|
||||
sub = state.token_service.verifyRefreshToken(body.refresh_token);
|
||||
} catch {
|
||||
throw ApiError.unauthorized("Invalid or expired refresh token");
|
||||
}
|
||||
|
||||
return authResponse(state, sub);
|
||||
}
|
||||
Reference in New Issue
Block a user