feat(oauth): add Codex OAuth authorize/callback/disconnect routes and admin AI settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:42:46 +02:00
1 parent 401e78e01c
commit fde3a0941a
5 files changed
+266

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin, HttpError } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { encrypt } from "@/lib/crypto";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.ai-settings.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
const hasKey = Boolean(settings?.adminAiKeyEncrypted);
const model = settings?.adminAiModel ?? "gpt-4o-mini";
const baseUrl = settings?.adminAiBaseUrl ?? "https://api.openai.com/v1";
return apiSuccess({
hasKey,
keyPreview: hasKey && settings?.adminAiKeyEncrypted
? `${settings.adminAiKeyEncrypted.slice(0, 7)}...`
: null,
model,
baseUrl,
});
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.ai-settings.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const { apiKey, model, baseUrl } = body as {
apiKey?: string;
model?: string;
baseUrl?: string;
};
const updates: Record<string, unknown> = {};
const auditDetails: Record<string, unknown> = {};
if (apiKey !== undefined) {
const trimmed = apiKey.trim();
if (trimmed) {
updates.adminAiKeyEncrypted = encrypt(trimmed);
auditDetails.apiKeyUpdated = true;
} else {
updates.adminAiKeyEncrypted = null;
auditDetails.apiKeyUpdated = false;
}
}
if (model !== undefined) {
const trimmed = model.trim();
if (!trimmed) throw new HttpError(422, "Model cannot be empty");
updates.adminAiModel = trimmed;
auditDetails.model = trimmed;
}
if (baseUrl !== undefined) {
const trimmed = baseUrl.trim();
if (!trimmed) throw new HttpError(422, "Base URL cannot be empty");
if (!trimmed.startsWith("https://")) {
throw new HttpError(422, "Base URL must start with https://");
}
updates.adminAiBaseUrl = trimmed;
auditDetails.baseUrl = trimmed;
}
if (Object.keys(updates).length === 0) {
throw new HttpError(422, "No valid fields");
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
});
await auditLog("admin.ai-settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: auditDetails,
});
const hasKey = Boolean(settings.adminAiKeyEncrypted);
return apiSuccess({
hasKey,
keyPreview: hasKey && settings.adminAiKeyEncrypted
? `${settings.adminAiKeyEncrypted.slice(0, 7)}...`
: null,
model: settings.adminAiModel,
baseUrl: settings.adminAiBaseUrl,
});
});
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
generatePKCE,
generateState,
buildAuthorizationUrl,
} from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { verifier, challenge } = generatePKCE();
const state = generateState();
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) throw new Error("APP_URL not configured");
const redirectUri = `${appUrl}/api/auth/codex/callback`;
const cookieStore = await cookies();
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: 300,
};
cookieStore.set("codex_verifier", verifier, cookieOptions);
cookieStore.set("codex_state", state, cookieOptions);
const authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
annotate({ action: { name: "codex.oauth.authorize" } });
return NextResponse.redirect(authUrl);
});
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp } from "@/lib/api-response";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) throw new Error("APP_URL not configured");
if (error) {
annotate({ meta: { oauth_error: error } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=${encodeURIComponent(error)}`,
);
}
if (!code || !state) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=missing_params`,
);
}
const cookieStore = await cookies();
const storedState = cookieStore.get("codex_state")?.value;
const storedVerifier = cookieStore.get("codex_verifier")?.value;
cookieStore.delete("codex_state");
cookieStore.delete("codex_verifier");
if (!storedState || !storedVerifier || state !== storedState) {
return NextResponse.redirect(
`${appUrl}/settings?codex_error=invalid_state`,
);
}
const redirectUri = `${appUrl}/api/auth/codex/callback`;
try {
const tokens = await exchangeCodeForTokens({
code,
codeVerifier: storedVerifier,
redirectUri,
});
const encrypted = encryptTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
});
await prisma.user.update({
where: { id: user.id },
data: {
codexAccessTokenEncrypted: encrypted.accessEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(
Date.now() + tokens.expires_in * 1000,
),
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
insightsCachedText: null,
},
});
await auditLog("codex.oauth.connected", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "codex.oauth.callback.success" } });
return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`);
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_token_error: message } });
return NextResponse.redirect(
`${appUrl}/settings?codex_error=token_exchange_failed`,
);
}
});
@@ -0,0 +1,31 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
await prisma.user.update({
where: { id: user.id },
data: {
codexAccessTokenEncrypted: null,
codexRefreshTokenEncrypted: null,
codexTokenExpiresAt: null,
codexConnectedAt: null,
codexConnectionStatus: "disconnected",
insightsCachedAt: null,
insightsCachedText: null,
},
});
await auditLog("codex.oauth.disconnected", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "codex.oauth.disconnect" } });
return apiSuccess({ disconnected: true });
});
+1
View File
@@ -17,6 +17,7 @@ const PUBLIC_PATHS = [
"/api/withings/webhook",
"/api/telegram/webhook",
"/api/integrations/moodlog/webhook",
"/api/auth/codex/callback",
"/api/ingest/",
"/onboarding",
"/robots.txt",