feat(16-01): update auth middleware and services to resolve userId

- verifyApiKey returns { userId } | null instead of boolean
- verifyAccessToken returns { userId } | null instead of boolean
- Add getOrCreateUser upsert function in auth.service
- Add getOrCreateUncategorized helper in category.service
- requireAuth sets userId on Hono context for all 3 auth methods
- Remove GET bypass: all API routes require auth for userId resolution
- Keep bypass for /api/auth and /api/health paths
This commit is contained in:
2026-04-05 10:34:19 +02:00
parent 91e93a31a5
commit b6d562f082
5 changed files with 144 additions and 196 deletions

View File

@@ -54,13 +54,13 @@ app.use("/api/*", async (c, next) => {
return next(); return next();
}); });
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes // Auth middleware for all data routes (userId must be available for per-user scoping)
app.use("/api/*", async (c, next) => { app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth // Skip auth routes — they handle their own auth
if (c.req.path.startsWith("/api/auth")) return next(); if (c.req.path.startsWith("/api/auth")) return next();
// Skip GET requests — read is public // Skip health check
if (c.req.method === "GET") return next(); if (c.req.path === "/api/health") return next();
// All other methods require auth // All methods require auth for userId resolution
return requireAuth(c, next); return requireAuth(c, next);
}); });

View File

@@ -1,37 +1,46 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie"; import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
import { import { getOrCreateUncategorized } from "../services/category.service";
getSession, import { verifyAccessToken } from "../services/oauth.service";
getUserCount,
refreshSession,
verifyApiKey,
} from "../services/auth.service";
export async function requireAuth(c: Context, next: Next) { export async function requireAuth(c: Context, next: Next) {
const db = c.get("db"); const db = c.get("db");
// Check if any users exist at all
if (getUserCount(db) === 0) {
return c.json({ error: "setup_required" }, 403);
}
// Check API key first // Check API key first
const apiKey = c.req.header("X-API-Key"); const apiKey = c.req.header("X-API-Key");
if (apiKey) { if (apiKey) {
const valid = await verifyApiKey(db, apiKey); const result = await verifyApiKey(db, apiKey);
if (valid) return next(); if (result) {
c.set("userId", result.userId);
return next();
}
return c.json({ error: "Invalid API key" }, 401); return c.json({ error: "Invalid API key" }, 401);
} }
// Check session cookie // Check OAuth Bearer token
const sessionId = getCookie(c, "gearbox_session"); const authHeader = c.req.header("Authorization");
if (sessionId) { if (authHeader?.startsWith("Bearer ")) {
const session = getSession(db, sessionId); const token = authHeader.slice(7);
if (session) { const result = await verifyAccessToken(db, token);
// Refresh session expiry on use if (result) {
refreshSession(db, sessionId); c.set("userId", result.userId);
return next(); return next();
} }
return c.json({ error: "Invalid or expired token" }, 401);
}
// Check OIDC session (browser users via Logto)
try {
const { getAuth } = await import("@hono/oidc-auth");
const auth = await getAuth(c);
if (auth?.sub) {
const user = await getOrCreateUser(db, auth.sub);
await getOrCreateUncategorized(db, user.id);
c.set("userId", user.id);
return next();
}
} catch {
// OIDC not configured or session invalid — fall through
} }
return c.json({ error: "Authentication required" }, 401); return c.json({ error: "Authentication required" }, 401);

View File

@@ -1,123 +1,42 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { count, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { apiKeys, sessions, users } from "../../db/schema.ts"; import { apiKeys, users } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
// ── User Management ────────────────────────────────────────────────── // ── User Management ──────────────────────────────────────────────────
export async function createUser( export async function getOrCreateUser(
db: Db = prodDb, db: Db,
username: string, logtoSub: string,
password: string, ): Promise<{ id: number }> {
) { const [user] = await db
const passwordHash = await Bun.password.hash(password); .insert(users)
return db.insert(users).values({ username, passwordHash }).returning().get(); .values({ logtoSub })
} .onConflictDoUpdate({
target: users.logtoSub,
export async function verifyPassword( set: { logtoSub },
db: Db = prodDb, })
username: string, .returning({ id: users.id });
password: string, return user;
) {
const user = db
.select()
.from(users)
.where(eq(users.username, username))
.get();
if (!user) return null;
const valid = await Bun.password.verify(password, user.passwordHash);
return valid ? user : null;
}
export function getUserCount(db: Db = prodDb): number {
const result = db.select({ value: count() }).from(users).get();
return result?.value ?? 0;
}
export async function changePassword(
db: Db = prodDb,
username: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const user = await verifyPassword(db, username, currentPassword);
if (!user) return false;
const newHash = await Bun.password.hash(newPassword);
db.update(users)
.set({ passwordHash: newHash })
.where(eq(users.id, user.id))
.run();
return true;
}
// ── Session Management ───────────────────────────────────────────────
export function createSession(
db: Db = prodDb,
userId: number,
expiryDays = 30,
) {
const id = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
return db
.insert(sessions)
.values({ id, userId, expiresAt })
.returning()
.get();
}
export function getSession(db: Db = prodDb, sessionId: string) {
const session = db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.get();
if (!session) return null;
if (session.expiresAt < new Date()) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return null;
}
return session;
}
export function deleteSession(db: Db = prodDb, sessionId: string) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}
export function refreshSession(
db: Db = prodDb,
sessionId: string,
expiryDays = 30,
) {
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
db.update(sessions)
.set({ expiresAt })
.where(eq(sessions.id, sessionId))
.run();
} }
// ── API Key Management ─────────────────────────────────────────────── // ── API Key Management ───────────────────────────────────────────────
export async function createApiKey(db: Db = prodDb, name: string) { export async function createApiKey(
db: Db = prodDb,
name: string,
userId: number,
) {
const rawKey = randomBytes(32).toString("hex"); const rawKey = randomBytes(32).toString("hex");
const keyHash = await Bun.password.hash(rawKey); const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8); const keyPrefix = rawKey.slice(0, 8);
const record = db const [record] = await db
.insert(apiKeys) .insert(apiKeys)
.values({ name, keyHash, keyPrefix }) .values({ name, keyHash, keyPrefix, userId })
.returning() .returning();
.get();
return { ...record, rawKey }; return { ...record, rawKey };
} }
@@ -125,23 +44,22 @@ export async function createApiKey(db: Db = prodDb, name: string) {
export async function verifyApiKey( export async function verifyApiKey(
db: Db = prodDb, db: Db = prodDb,
rawKey: string, rawKey: string,
): Promise<boolean> { ): Promise<{ userId: number } | null> {
const prefix = rawKey.slice(0, 8); const prefix = rawKey.slice(0, 8);
const candidates = db const candidates = await db
.select() .select()
.from(apiKeys) .from(apiKeys)
.where(eq(apiKeys.keyPrefix, prefix)) .where(eq(apiKeys.keyPrefix, prefix));
.all();
for (const candidate of candidates) { for (const candidate of candidates) {
const valid = await Bun.password.verify(rawKey, candidate.keyHash); const valid = await Bun.password.verify(rawKey, candidate.keyHash);
if (valid) return true; if (valid) return { userId: candidate.userId };
} }
return false; return null;
} }
export function listApiKeys(db: Db = prodDb) { export async function listApiKeys(db: Db = prodDb, userId: number) {
return db return db
.select({ .select({
id: apiKeys.id, id: apiKeys.id,
@@ -150,9 +68,15 @@ export function listApiKeys(db: Db = prodDb) {
createdAt: apiKeys.createdAt, createdAt: apiKeys.createdAt,
}) })
.from(apiKeys) .from(apiKeys)
.all(); .where(eq(apiKeys.userId, userId));
} }
export function deleteApiKey(db: Db = prodDb, id: number) { export async function deleteApiKey(
db.delete(apiKeys).where(eq(apiKeys.id, id)).run(); db: Db = prodDb,
id: number,
userId: number,
) {
await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
} }

View File

@@ -1,9 +1,25 @@
import { asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts"; import { db as prodDb } from "../../db/index.ts";
import { categories, items } from "../../db/schema.ts"; import { categories, items } from "../../db/schema.ts";
type Db = typeof prodDb; type Db = typeof prodDb;
export async function getOrCreateUncategorized(
db: Db,
userId: number,
): Promise<number> {
const [existing] = await db
.select({ id: categories.id })
.from(categories)
.where(and(eq(categories.userId, userId), eq(categories.name, "Uncategorized")));
if (existing) return existing.id;
const [created] = await db
.insert(categories)
.values({ name: "Uncategorized", icon: "package", userId })
.returning({ id: categories.id });
return created.id;
}
export function getAllCategories(db: Db = prodDb) { export function getAllCategories(db: Db = prodDb) {
return db.select().from(categories).orderBy(asc(categories.name)).all(); return db.select().from(categories).orderBy(asc(categories.name)).all();
} }

View File

@@ -7,53 +7,50 @@ type Db = typeof prodDb;
// ── Client Registration ────────────────────────────────────────────── // ── Client Registration ──────────────────────────────────────────────
export function registerClient( export async function registerClient(
db: Db = prodDb, db: Db = prodDb,
clientName: string, clientName: string,
redirectUris: string[], redirectUris: string[],
): { clientId: string } { ): Promise<{ clientId: string }> {
const clientId = randomUUID(); const clientId = randomUUID();
const redirectUrisJson = JSON.stringify(redirectUris); const redirectUrisJson = JSON.stringify(redirectUris);
db.insert(oauthClients) await db
.values({ clientId, clientName, redirectUris: redirectUrisJson }) .insert(oauthClients)
.run(); .values({ clientId, clientName, redirectUris: redirectUrisJson });
return { clientId }; return { clientId };
} }
export function getClient(db: Db = prodDb, clientId: string) { export async function getClient(db: Db = prodDb, clientId: string) {
return ( const [record] = await db
db
.select() .select()
.from(oauthClients) .from(oauthClients)
.where(eq(oauthClients.clientId, clientId)) .where(eq(oauthClients.clientId, clientId));
.get() ?? null
); return record ?? null;
} }
// ── Authorization Code ─────────────────────────────────────────────── // ── Authorization Code ───────────────────────────────────────────────
export function createAuthorizationCode( export async function createAuthorizationCode(
db: Db = prodDb, db: Db = prodDb,
clientId: string, clientId: string,
codeChallenge: string, codeChallenge: string,
codeChallengeMethod: string, codeChallengeMethod: string,
redirectUri: string, redirectUri: string,
): { code: string } { ): Promise<{ code: string }> {
const code = randomBytes(32).toString("hex"); const code = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
db.insert(oauthCodes) await db.insert(oauthCodes).values({
.values({
code, code,
clientId, clientId,
codeChallenge, codeChallenge,
codeChallengeMethod, codeChallengeMethod,
redirectUri, redirectUri,
expiresAt, expiresAt,
}) });
.run();
return { code }; return { code };
} }
@@ -64,16 +61,16 @@ export async function exchangeCode(
codeVerifier: string, codeVerifier: string,
clientId: string, clientId: string,
redirectUri: string, redirectUri: string,
userId: number,
): Promise<{ ): Promise<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
expiresIn: number; expiresIn: number;
} | null> { } | null> {
const record = db const [record] = await db
.select() .select()
.from(oauthCodes) .from(oauthCodes)
.where(eq(oauthCodes.code, code)) .where(eq(oauthCodes.code, code));
.get();
if (!record) return null; if (!record) return null;
if (record.used !== 0) return null; if (record.used !== 0) return null;
@@ -89,17 +86,21 @@ export async function exchangeCode(
if (computedChallenge !== record.codeChallenge) return null; if (computedChallenge !== record.codeChallenge) return null;
// Mark code as used // Mark code as used
db.update(oauthCodes).set({ used: 1 }).where(eq(oauthCodes.code, code)).run(); await db
.update(oauthCodes)
.set({ used: 1 })
.where(eq(oauthCodes.code, code));
return generateTokens(db, clientId); return generateTokens(db, clientId, userId);
} }
// ── Token Management ───────────────────────────────────────────────── // ── Token Management ─────────────────────────────────────────────────
function generateTokens( async function generateTokens(
db: Db, db: Db,
clientId: string, clientId: string,
): { accessToken: string; refreshToken: string; expiresIn: number } { userId: number,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = randomBytes(32).toString("hex"); const accessToken = randomBytes(32).toString("hex");
const refreshToken = randomBytes(32).toString("hex"); const refreshToken = randomBytes(32).toString("hex");
@@ -113,15 +114,14 @@ function generateTokens(
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour
const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
db.insert(oauthTokens) await db.insert(oauthTokens).values({
.values({
accessTokenHash, accessTokenHash,
refreshTokenHash, refreshTokenHash,
clientId, clientId,
userId,
expiresAt, expiresAt,
refreshExpiresAt, refreshExpiresAt,
}) });
.run();
return { accessToken, refreshToken, expiresIn: 3600 }; return { accessToken, refreshToken, expiresIn: 3600 };
} }
@@ -129,25 +129,25 @@ function generateTokens(
export async function verifyAccessToken( export async function verifyAccessToken(
db: Db = prodDb, db: Db = prodDb,
token: string, token: string,
): Promise<boolean> { ): Promise<{ userId: number } | null> {
const tokenHash = createHash("sha256").update(token).digest("hex"); const tokenHash = createHash("sha256").update(token).digest("hex");
const record = db const [record] = await db
.select() .select()
.from(oauthTokens) .from(oauthTokens)
.where(eq(oauthTokens.accessTokenHash, tokenHash)) .where(eq(oauthTokens.accessTokenHash, tokenHash));
.get();
if (!record) return false; if (!record) return null;
if (record.expiresAt < new Date()) return false; if (record.expiresAt < new Date()) return null;
return true; return { userId: record.userId };
} }
export async function refreshAccessToken( export async function refreshAccessToken(
db: Db = prodDb, db: Db = prodDb,
refreshToken: string, refreshToken: string,
clientId: string, clientId: string,
userId: number,
): Promise<{ ): Promise<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
@@ -155,7 +155,7 @@ export async function refreshAccessToken(
} | null> { } | null> {
const tokenHash = createHash("sha256").update(refreshToken).digest("hex"); const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
const record = db const [record] = await db
.select() .select()
.from(oauthTokens) .from(oauthTokens)
.where( .where(
@@ -163,22 +163,21 @@ export async function refreshAccessToken(
eq(oauthTokens.refreshTokenHash, tokenHash), eq(oauthTokens.refreshTokenHash, tokenHash),
eq(oauthTokens.clientId, clientId), eq(oauthTokens.clientId, clientId),
), ),
) );
.get();
if (!record) return null; if (!record) return null;
if (record.refreshExpiresAt < new Date()) return null; if (record.refreshExpiresAt < new Date()) return null;
// Delete old token pair // Delete old token pair
db.delete(oauthTokens).where(eq(oauthTokens.id, record.id)).run(); await db.delete(oauthTokens).where(eq(oauthTokens.id, record.id));
return generateTokens(db, clientId); return generateTokens(db, clientId, userId);
} }
// ── Cleanup ────────────────────────────────────────────────────────── // ── Cleanup ──────────────────────────────────────────────────────────
export function cleanExpiredOAuthData(db: Db = prodDb): void { export async function cleanExpiredOAuthData(db: Db = prodDb): Promise<void> {
const now = new Date(); const now = new Date();
db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now)).run(); await db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now));
db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now)).run(); await db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now));
} }