Files
zesdex/apps/interfaces/api/src/handlers/auth.ts
T

134 lines
4.2 KiB
TypeScript

/**
* 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);
}