- 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
184 lines
5.0 KiB
TypeScript
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));
|
|
}
|