Files
GearBox/src/server/services/oauth.service.ts
Jean-Luc Makiola b6d562f082 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
2026-04-05 10:34:19 +02:00

184 lines
5.0 KiB
TypeScript

import { createHash, randomBytes, randomUUID } from "node:crypto";
import { and, eq, lt } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { oauthClients, oauthCodes, oauthTokens } from "../../db/schema.ts";
type Db = typeof prodDb;
// ── Client Registration ──────────────────────────────────────────────
export async function registerClient(
db: Db = prodDb,
clientName: string,
redirectUris: string[],
): Promise<{ clientId: string }> {
const clientId = randomUUID();
const redirectUrisJson = JSON.stringify(redirectUris);
await db
.insert(oauthClients)
.values({ clientId, clientName, redirectUris: redirectUrisJson });
return { clientId };
}
export async function getClient(db: Db = prodDb, clientId: string) {
const [record] = await db
.select()
.from(oauthClients)
.where(eq(oauthClients.clientId, clientId));
return record ?? null;
}
// ── Authorization Code ───────────────────────────────────────────────
export async function createAuthorizationCode(
db: Db = prodDb,
clientId: string,
codeChallenge: string,
codeChallengeMethod: string,
redirectUri: string,
): Promise<{ code: string }> {
const code = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await db.insert(oauthCodes).values({
code,
clientId,
codeChallenge,
codeChallengeMethod,
redirectUri,
expiresAt,
});
return { code };
}
export async function exchangeCode(
db: Db = prodDb,
code: string,
codeVerifier: string,
clientId: string,
redirectUri: string,
userId: number,
): Promise<{
accessToken: string;
refreshToken: string;
expiresIn: number;
} | null> {
const [record] = await db
.select()
.from(oauthCodes)
.where(eq(oauthCodes.code, code));
if (!record) return null;
if (record.used !== 0) return null;
if (record.clientId !== clientId) return null;
if (record.redirectUri !== redirectUri) return null;
if (record.expiresAt < new Date()) return null;
// Verify PKCE: SHA-256(verifier) encoded as base64url must match stored challenge
const computedChallenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
if (computedChallenge !== record.codeChallenge) return null;
// Mark code as used
await db
.update(oauthCodes)
.set({ used: 1 })
.where(eq(oauthCodes.code, code));
return generateTokens(db, clientId, userId);
}
// ── Token Management ─────────────────────────────────────────────────
async function generateTokens(
db: Db,
clientId: string,
userId: number,
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = randomBytes(32).toString("hex");
const refreshToken = randomBytes(32).toString("hex");
const accessTokenHash = createHash("sha256")
.update(accessToken)
.digest("hex");
const refreshTokenHash = createHash("sha256")
.update(refreshToken)
.digest("hex");
const expiresAt = new Date(Date.now() + 3600 * 1000); // 1 hour
const refreshExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db.insert(oauthTokens).values({
accessTokenHash,
refreshTokenHash,
clientId,
userId,
expiresAt,
refreshExpiresAt,
});
return { accessToken, refreshToken, expiresIn: 3600 };
}
export async function verifyAccessToken(
db: Db = prodDb,
token: string,
): Promise<{ userId: number } | null> {
const tokenHash = createHash("sha256").update(token).digest("hex");
const [record] = await db
.select()
.from(oauthTokens)
.where(eq(oauthTokens.accessTokenHash, tokenHash));
if (!record) return null;
if (record.expiresAt < new Date()) return null;
return { userId: record.userId };
}
export async function refreshAccessToken(
db: Db = prodDb,
refreshToken: string,
clientId: string,
userId: number,
): Promise<{
accessToken: string;
refreshToken: string;
expiresIn: number;
} | null> {
const tokenHash = createHash("sha256").update(refreshToken).digest("hex");
const [record] = await db
.select()
.from(oauthTokens)
.where(
and(
eq(oauthTokens.refreshTokenHash, tokenHash),
eq(oauthTokens.clientId, clientId),
),
);
if (!record) return null;
if (record.refreshExpiresAt < new Date()) return null;
// Delete old token pair
await db.delete(oauthTokens).where(eq(oauthTokens.id, record.id));
return generateTokens(db, clientId, userId);
}
// ── Cleanup ──────────────────────────────────────────────────────────
export async function cleanExpiredOAuthData(db: Db = prodDb): Promise<void> {
const now = new Date();
await db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now));
await db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now));
}