pingql/apps/api/src/routes/auth.ts

172 lines
6.8 KiB
TypeScript

import { Elysia, t } from "elysia";
import { createHmac, randomBytes } from "crypto";
import sql from "../db";
import { createRateLimiter } from "../utils/rate-limit";
import { getPlanLimits } from "../utils/plans";
// ── Per-IP rate limiting for auth endpoints ───────────────────────────
const checkAuthRateLimit = createRateLimiter();
const EMAIL_HMAC_KEY = process.env.EMAIL_HMAC_KEY || "pingql-default-hmac-key";
function generateKey(): string {
return randomBytes(32).toString("base64url");
}
function hashEmail(email: string): string {
return createHmac("sha256", EMAIL_HMAC_KEY).update(email.toLowerCase().trim()).digest("hex");
}
async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null; plan: string } | null> {
const [account] = await sql`SELECT id, plan FROM accounts WHERE key = ${key}`;
if (account) return { accountId: account.id, keyId: null, plan: account.plan };
const [apiKey] = await sql`SELECT k.id, k.account_id, a.plan FROM api_keys k JOIN accounts a ON a.id = k.account_id WHERE k.key = ${key}`;
if (apiKey) {
sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {});
return { accountId: apiKey.account_id, keyId: apiKey.id, plan: apiKey.plan };
}
return null;
}
export { resolveKey };
export function requireAuth(app: Elysia) {
return app
.derive(async ({ headers, cookie, set }) => {
const authHeader = headers["authorization"] ?? "";
const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim();
const cookieKey = cookie?.pingql_key?.value;
const key = bearer || cookieKey;
if (!key) {
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string };
}
const resolved = await resolveKey(key);
if (resolved) return { accountId: resolved.accountId, keyId: resolved.keyId, plan: resolved.plan };
set.status = 401;
return { accountId: null as string | null, keyId: null as string | null, plan: "free" as string };
})
.onBeforeHandle(({ accountId, set }) => {
if (!accountId) {
set.status = 401;
return { error: "Invalid or missing account key" };
}
});
}
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== "false",
sameSite: "none" as const,
path: "/",
domain: process.env.COOKIE_DOMAIN ?? ".pingql.com",
maxAge: 60 * 60 * 24 * 30, // 30 days
};
export const account = new Elysia({ prefix: "/account" })
.post("/login", async ({ body, cookie, set, request }) => {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!checkAuthRateLimit(ip, 10)) { set.status = 429; return { error: "Too many login attempts. Try again later." }; }
const key = (body.key as string)?.trim();
if (!key) { set.status = 400; return { error: "Key required" }; }
const resolved = await resolveKey(key);
if (!resolved) {
set.status = 401;
if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; }
return { error: "Invalid account key" };
}
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
if ((body as any)._form) { set.redirect = "/dashboard/home"; return; }
return { ok: true };
}, { detail: { hide: true } })
.get("/logout", ({ cookie, set }) => {
cookie.pingql_key.set({ value: "", ...COOKIE_OPTS, maxAge: 0 });
set.redirect = "/dashboard";
}, { detail: { hide: true } })
.post("/register", async ({ body, cookie, request, set }) => {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!checkAuthRateLimit(ip, 5)) { set.status = 429; return { error: "Too many registrations. Try again later." }; }
const key = generateKey();
const emailHash = body.email ? hashEmail(body.email) : null;
await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`;
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
return {
key,
...(body.email ? { email_registered: true } : { email_registered: false }),
};
}, {
body: t.Object({
email: t.Optional(t.String({ format: "email", description: "Optional. Used for account recovery only." })),
}),
})
.use(requireAuth)
.get("/settings", async ({ accountId }) => {
const [acc] = await sql`SELECT id, email_hash, plan, plan_expires_at, created_at FROM accounts WHERE id = ${accountId}`;
const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`;
const [{ count: monitorCount }] = await sql`SELECT COUNT(*)::int as count FROM monitors WHERE account_id = ${accountId}`;
const limits = getPlanLimits(acc.plan);
return {
account_id: acc.id,
has_email: !!acc.email_hash,
plan: acc.plan,
plan_expires_at: acc.plan_expires_at,
monitor_count: monitorCount,
limits,
created_at: acc.created_at,
api_keys: keys,
};
})
.post("/email", async ({ accountId, keyId, body, set }) => {
if (keyId) { set.status = 403; return { error: "Sub-keys cannot modify account email" }; }
const emailHash = body.email ? hashEmail(body.email) : null;
await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`;
return { ok: true };
}, {
body: t.Object({
email: t.Optional(t.Nullable(t.String({ description: "Email for account recovery only." }))),
}),
})
.post("/reset-key", async ({ accountId, keyId, cookie, set }) => {
if (keyId) { set.status = 403; return { error: "Sub-keys cannot rotate the account key" }; }
const key = generateKey();
await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`;
cookie.pingql_key.set({ value: key, ...COOKIE_OPTS });
return { key, message: "Primary key rotated. Your old key is now invalid." };
})
.post("/keys", async ({ accountId, keyId, body, set }) => {
if (keyId) { set.status = 403; return { error: "Sub-keys cannot create other sub-keys" }; }
const key = generateKey();
const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`;
return { key, id: created.id, label: body.label };
}, {
body: t.Object({
label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }),
}),
})
.delete("/keys/:id", async ({ accountId, keyId, params, set }) => {
if (keyId) { set.status = 403; return { error: "Sub-keys cannot revoke other sub-keys" }; }
const [deleted] = await sql`
DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id
`;
if (!deleted) { set.status = 404; return { error: "Key not found" }; }
return { deleted: true };
});