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 { const now = new Date(); await db.delete(oauthCodes).where(lt(oauthCodes.expiresAt, now)); await db.delete(oauthTokens).where(lt(oauthTokens.expiresAt, now)); }