diff --git a/src/app/api/providers/[id]/claude-auth/apply-local/route.ts b/src/app/api/providers/[id]/claude-auth/apply-local/route.ts new file mode 100644 index 0000000000..64d27e74e2 --- /dev/null +++ b/src/app/api/providers/[id]/claude-auth/apply-local/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; +import { + ClaudeAuthFileError, + writeClaudeAuthFileToLocalCli, +} from "@/lib/oauth/utils/claudeAuthFile"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +function toErrorResponse(error: unknown) { + if (error instanceof ClaudeAuthFileError) { + return NextResponse.json( + { + error: error.message, + code: error.code, + }, + { status: error.status } + ); + } + + const message = sanitizeErrorMessage(error) || "Failed to apply Claude auth file"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + try { + const writeGuard = ensureCliConfigWriteAllowed(); + if (writeGuard) { + return NextResponse.json({ error: writeGuard, code: "writes_disabled" }, { status: 403 }); + } + + const { id } = await params; + const result = await writeClaudeAuthFileToLocalCli(id); + + logAuditEvent({ + action: "provider.credentials.applied", + actor: "admin", + target: id, + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "claude", + authPath: result.authPath, + savedBakPath: result.savedBakPath, + centralizedBackupPath: result.centralizedBackupPath, + mcpOAuthPreserved: result.mcpOAuthPreserved, + }, + }); + + return NextResponse.json({ + success: true, + connectionId: id, + connectionLabel: result.connectionLabel, + authPath: result.authPath, + savedBakPath: result.savedBakPath, + centralizedBackupPath: result.centralizedBackupPath, + mcpOAuthPreserved: result.mcpOAuthPreserved, + writtenAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[Claude Auth Apply] Failed:", error); + return toErrorResponse(error); + } +} diff --git a/src/app/api/providers/[id]/claude-auth/export/route.ts b/src/app/api/providers/[id]/claude-auth/export/route.ts new file mode 100644 index 0000000000..96d145ab1c --- /dev/null +++ b/src/app/api/providers/[id]/claude-auth/export/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { buildClaudeAuthFile, ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +function toErrorResponse(error: unknown) { + if (error instanceof ClaudeAuthFileError) { + return NextResponse.json( + { + error: error.message, + code: error.code, + }, + { status: error.status } + ); + } + + const message = error instanceof Error ? error.message : "Failed to export Claude auth file"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(_request); + if (authError) return authError; + + try { + const { id } = await params; + const built = await buildClaudeAuthFile(id); + + return new Response(built.content, { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${built.fileName}"`, + "Cache-Control": "no-store, max-age=0", + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + console.error("[Claude Auth Export] Failed:", error); + return toErrorResponse(error); + } +} diff --git a/src/app/api/providers/claude-auth/import-bulk/route.ts b/src/app/api/providers/claude-auth/import-bulk/route.ts new file mode 100644 index 0000000000..578879f65b --- /dev/null +++ b/src/app/api/providers/claude-auth/import-bulk/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile"; +import { + parseAndValidateClaudeAuth, + enrichWithBootstrap, + createConnectionFromAuthFile, +} from "@/lib/oauth/utils/claudeAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importClaudeAuthBulkSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importClaudeAuthBulkSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { entries, overwriteExisting } = parsedBody.data; + + const created: Record[] = []; + const errors: { index: number; name: string; message: string }[] = []; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const label = e.name || `entry ${i + 1}`; + try { + const parsedAuth = parseAndValidateClaudeAuth(e.json); + const enriched = await enrichWithBootstrap(parsedAuth); + const { connection } = await createConnectionFromAuthFile(enriched, { + name: e.name, + email: e.email, + overwriteExisting, + }); + + const safe = sanitizeConnectionForResponse(connection as Record); + created.push(safe); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "claude", + email: enriched.email || e.email, + bulkIndex: i, + }, + }); + } catch (err) { + let message: string; + if (err instanceof ClaudeAuthFileError) { + message = err.message; + } else { + message = sanitizeErrorMessage(err) || "Failed to import"; + } + errors.push({ index: i, name: label, message }); + } + } + + logAuditEvent({ + action: "provider.credentials.bulk_imported", + actor: "admin", + target: "claude", + resourceType: "provider_credentials", + status: errors.length === entries.length ? "failure" : "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "claude", + total: entries.length, + success: created.length, + failed: errors.length, + }, + }); + + return NextResponse.json({ + success: created.length, + failed: errors.length, + total: entries.length, + created, + errors, + }); +} diff --git a/src/app/api/providers/claude-auth/import/route.ts b/src/app/api/providers/claude-auth/import/route.ts new file mode 100644 index 0000000000..6ea62c9d33 --- /dev/null +++ b/src/app/api/providers/claude-auth/import/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { ClaudeAuthFileError } from "@/lib/oauth/utils/claudeAuthFile"; +import { + parseAndValidateClaudeAuth, + enrichWithBootstrap, + createConnectionFromAuthFile, +} from "@/lib/oauth/utils/claudeAuthImport"; +import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index"; +import { getProviderAuditTarget } from "@/lib/compliance/providerAudit"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { importClaudeAuthSchema } from "@/shared/validation/schemas"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; +import { sanitizeProviderSpecificDataForResponse } from "@/lib/providers/requestDefaults"; + +function sanitizeConnectionForResponse(connection: Record) { + const safe = { ...connection }; + delete safe.accessToken; + delete safe.refreshToken; + delete safe.idToken; + delete safe.apiKey; + if (safe.providerSpecificData) { + safe.providerSpecificData = sanitizeProviderSpecificDataForResponse(safe.providerSpecificData); + } + return safe; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const auditContext = getAuditRequestContext(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsedBody = validateBody(importClaudeAuthSchema, body); + if (isValidationFailure(parsedBody)) { + return NextResponse.json({ error: parsedBody.error }, { status: 400 }); + } + + const { source, name, email, overwriteExisting } = parsedBody.data; + + let rawJson: unknown; + try { + rawJson = source.kind === "json" ? source.json : JSON.parse(source.text); + } catch { + return NextResponse.json( + { error: "Could not parse the content as JSON", code: "invalid_json" }, + { status: 400 } + ); + } + + try { + const parsed = parseAndValidateClaudeAuth(rawJson); + const enriched = await enrichWithBootstrap(parsed); + const { connection, created } = await createConnectionFromAuthFile(enriched, { + name, + email, + overwriteExisting, + }); + + logAuditEvent({ + action: "provider.credentials.imported", + actor: "admin", + target: getProviderAuditTarget(connection), + resourceType: "provider_credentials", + status: "success", + ipAddress: auditContext.ipAddress || undefined, + requestId: auditContext.requestId, + metadata: { + provider: "claude", + created, + email: enriched.email || email, + hasAccountUUID: !!enriched.accountUUID, + }, + }); + + return NextResponse.json({ + connection: sanitizeConnectionForResponse(connection as Record), + created, + }); + } catch (error) { + if (error instanceof ClaudeAuthFileError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status } + ); + } + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to import Claude auth" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/claude-auth/zip-extract/route.ts b/src/app/api/providers/claude-auth/zip-extract/route.ts new file mode 100644 index 0000000000..9e3c9f68f4 --- /dev/null +++ b/src/app/api/providers/claude-auth/zip-extract/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { extractClaudeAuthZip } from "@/lib/oauth/utils/claudeAuthZipExtract"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +const ZIP_BODY_LIMIT = 11 * 1024 * 1024; // 11 MB — slightly above the 10 MB extracted limit + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const contentLength = Number(request.headers.get("content-length") || "0"); + if (contentLength > ZIP_BODY_LIMIT) { + return NextResponse.json( + { error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" }, + { status: 413 } + ); + } + + let buffer: Buffer; + try { + const arrayBuffer = await request.arrayBuffer(); + if (arrayBuffer.byteLength > ZIP_BODY_LIMIT) { + return NextResponse.json( + { error: "ZIP file exceeds the 10 MB size limit", code: "file_too_large" }, + { status: 413 } + ); + } + buffer = Buffer.from(arrayBuffer); + } catch { + return NextResponse.json({ error: "Failed to read request body" }, { status: 400 }); + } + + try { + const files = extractClaudeAuthZip(buffer); + + const entries = files.map((f) => { + try { + return { name: f.name, json: JSON.parse(f.content), parseError: null }; + } catch { + return { name: f.name, json: null, parseError: "Not valid JSON" }; + } + }); + + return NextResponse.json({ entries }); + } catch (error) { + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to extract ZIP", code: "extract_failed" }, + { status: 400 } + ); + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index a8525aeabc..d47a37b7fa 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -353,6 +353,37 @@ export const importCodexAuthBulkSchema = z.object({ overwriteExisting: z.boolean().optional(), }); +// ──── Claude Auth Import Schema ──── + +export const importClaudeAuthSchema = z.object({ + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("json"), json: z.unknown() }), + z.object({ + kind: z.literal("text"), + text: z.string().max(256 * 1024, "Paste content must be under 256 KB"), + }), + ]), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + overwriteExisting: z.boolean().optional(), +}); + +// ──── Claude Auth Import Bulk Schema ──── + +export const importClaudeAuthBulkSchema = z.object({ + entries: z + .array( + z.object({ + json: z.unknown(), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + }) + ) + .min(1, "At least one entry is required") + .max(50, "At most 50 entries per bulk import"), + overwriteExisting: z.boolean().optional(), +}); + // ──── API Key Schemas ──── export const createKeySchema = z.object({ diff --git a/tests/unit/claude-import-route.test.ts b/tests/unit/claude-import-route.test.ts new file mode 100644 index 0000000000..518eb6f85c --- /dev/null +++ b/tests/unit/claude-import-route.test.ts @@ -0,0 +1,305 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { z } from "zod"; + +// Local copies of the schemas — avoids importing Next.js deps from schemas.ts. + +const importClaudeAuthSchema = z.object({ + source: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("json"), json: z.unknown() }), + z.object({ + kind: z.literal("text"), + text: z.string().max(256 * 1024, "Paste content must be under 256 KB"), + }), + ]), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + overwriteExisting: z.boolean().optional(), +}); + +const importClaudeAuthBulkSchema = z.object({ + entries: z + .array( + z.object({ + json: z.unknown(), + name: z.string().min(1).max(200).optional(), + email: z.string().email("Must be a valid email").optional(), + }) + ) + .min(1, "At least one entry is required") + .max(50, "At most 50 entries per bulk import"), + overwriteExisting: z.boolean().optional(), +}); + +// ──── Single schema ─────────────────────────────────────────────────────────── + +test("schema: valid json source", () => { + const result = importClaudeAuthSchema.safeParse({ + source: { kind: "json", json: { claudeAiOauth: {} } }, + }); + assert.ok(result.success); + assert.equal(result.data.source.kind, "json"); +}); + +test("schema: valid text source", () => { + const result = importClaudeAuthSchema.safeParse({ + source: { kind: "text", text: JSON.stringify({ claudeAiOauth: {} }) }, + }); + assert.ok(result.success); + assert.equal(result.data.source.kind, "text"); +}); + +test("schema: optional fields are optional", () => { + const result = importClaudeAuthSchema.safeParse({ source: { kind: "json", json: {} } }); + assert.ok(result.success); + assert.equal(result.data.name, undefined); + assert.equal(result.data.email, undefined); + assert.equal(result.data.overwriteExisting, undefined); +}); + +test("schema: kind 'file' (invalid) fails", () => { + const result = importClaudeAuthSchema.safeParse({ source: { kind: "file" } }); + assert.ok(!result.success); +}); + +test("schema: invalid email fails", () => { + const result = importClaudeAuthSchema.safeParse({ + source: { kind: "json", json: {} }, + email: "not-an-email", + }); + assert.ok(!result.success); + const emailIssue = result.error.issues.find((i) => i.path.includes("email")); + assert.ok(emailIssue, "expected email validation issue"); +}); + +test("schema: empty name fails", () => { + const result = importClaudeAuthSchema.safeParse({ + source: { kind: "json", json: {} }, + name: "", + }); + assert.ok(!result.success); +}); + +test("schema: text above 256KB fails", () => { + const bigText = "x".repeat(256 * 1024 + 1); + const result = importClaudeAuthSchema.safeParse({ source: { kind: "text", text: bigText } }); + assert.ok(!result.success); +}); + +test("schema: text exactly at 256KB passes", () => { + const maxText = "x".repeat(256 * 1024); + const result = importClaudeAuthSchema.safeParse({ source: { kind: "text", text: maxText } }); + assert.ok(result.success); +}); + +// ──── Bulk schema ───────────────────────────────────────────────────────────── + +test("bulk schema: valid entries (1 item)", () => { + const result = importClaudeAuthBulkSchema.safeParse({ + entries: [{ json: { claudeAiOauth: {} } }], + }); + assert.ok(result.success); + assert.equal(result.data.entries.length, 1); +}); + +test("bulk schema: 50 entries passes", () => { + const entries = Array.from({ length: 50 }, (_, i) => ({ + json: { claudeAiOauth: {} }, + name: `Account ${i + 1}`, + })); + const result = importClaudeAuthBulkSchema.safeParse({ entries }); + assert.ok(result.success); +}); + +test("bulk schema: empty entries fails", () => { + const result = importClaudeAuthBulkSchema.safeParse({ entries: [] }); + assert.ok(!result.success); +}); + +test("bulk schema: 51 entries fails", () => { + const entries = Array.from({ length: 51 }, () => ({ json: {} })); + const result = importClaudeAuthBulkSchema.safeParse({ entries }); + assert.ok(!result.success); +}); + +test("bulk schema: invalid email in entry fails", () => { + const result = importClaudeAuthBulkSchema.safeParse({ + entries: [{ json: {}, email: "bad-email" }], + }); + assert.ok(!result.success); +}); + +// ──── Parse logic (mirror of parseAndValidateClaudeAuth) ───────────────────── + +function parseAndValidateClaudeAuth(raw: unknown) { + function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + } + + function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + + class ParseError extends Error { + status: number; + code: string; + constructor(message: string, status = 400, code = "invalid_request") { + super(message); + this.status = status; + this.code = code; + } + } + + const doc = toRecord(raw); + const oauthBlock = toRecord(doc.claudeAiOauth); + + const accessToken = toNonEmptyString(oauthBlock.accessToken); + const refreshToken = toNonEmptyString(oauthBlock.refreshToken); + + if (!accessToken) { + throw new ParseError("accessToken is missing or empty in claudeAiOauth", 400, "missing_access_token"); + } + + if (!refreshToken) { + throw new ParseError("refreshToken is missing or empty in claudeAiOauth", 400, "missing_refresh_token"); + } + + let expiresAt: string | null = null; + const rawExpiresAt = oauthBlock.expiresAt; + if (typeof rawExpiresAt === "number" && Number.isFinite(rawExpiresAt)) { + expiresAt = new Date(rawExpiresAt).toISOString(); + } else if (typeof rawExpiresAt === "string" && rawExpiresAt.trim()) { + expiresAt = rawExpiresAt.trim(); + } + + const rawScopes = oauthBlock.scopes; + const scopes: string[] = Array.isArray(rawScopes) + ? rawScopes.filter((s): s is string => typeof s === "string") + : []; + + return { accessToken, refreshToken, expiresAt, scopes }; +} + +test("parse: accepts valid payload with claudeAiOauth block", () => { + const result = parseAndValidateClaudeAuth({ + claudeAiOauth: { + accessToken: "tok_access", + refreshToken: "tok_refresh", + expiresAt: Date.now() + 3600_000, + scopes: ["read", "write"], + }, + }); + assert.equal(result.accessToken, "tok_access"); + assert.equal(result.refreshToken, "tok_refresh"); + assert.ok(result.expiresAt !== null); + assert.deepEqual(result.scopes, ["read", "write"]); +}); + +test("parse: rejects payload without claudeAiOauth (400)", () => { + assert.throws( + () => parseAndValidateClaudeAuth({ something: "else" }), + (err: Error & { status?: number }) => { + assert.ok(err.status === 400); + return true; + } + ); +}); + +test("parse: rejects empty accessToken (400)", () => { + assert.throws( + () => + parseAndValidateClaudeAuth({ + claudeAiOauth: { accessToken: "", refreshToken: "tok", expiresAt: 0, scopes: [] }, + }), + (err: Error & { status?: number; code?: string }) => { + assert.equal(err.status, 400); + assert.equal(err.code, "missing_access_token"); + return true; + } + ); +}); + +test("parse: rejects empty refreshToken (400)", () => { + assert.throws( + () => + parseAndValidateClaudeAuth({ + claudeAiOauth: { accessToken: "tok", refreshToken: "", expiresAt: 0, scopes: [] }, + }), + (err: Error & { status?: number; code?: string }) => { + assert.equal(err.status, 400); + assert.equal(err.code, "missing_refresh_token"); + return true; + } + ); +}); + +test("parse: converts expiresAt ms to ISO", () => { + const ms = 1_700_000_000_000; + const result = parseAndValidateClaudeAuth({ + claudeAiOauth: { + accessToken: "tok_a", + refreshToken: "tok_r", + expiresAt: ms, + scopes: [], + }, + }); + assert.equal(result.expiresAt, new Date(ms).toISOString()); +}); + +test("parse: absent scopes produces empty array", () => { + const result = parseAndValidateClaudeAuth({ + claudeAiOauth: { + accessToken: "tok_a", + refreshToken: "tok_r", + expiresAt: 0, + }, + }); + assert.deepEqual(result.scopes, []); +}); + +test("parse: text source kind triggers JSON.parse before parse", () => { + const payload = { + claudeAiOauth: { + accessToken: "tok_a", + refreshToken: "tok_r", + expiresAt: 0, + scopes: [], + }, + }; + const text = JSON.stringify(payload); + const parsed = JSON.parse(text); + const result = parseAndValidateClaudeAuth(parsed); + assert.equal(result.accessToken, "tok_a"); +}); + +// ──── apply-local response contract ────────────────────────────────────────── + +test("apply-local: response includes mcpOAuthPreserved boolean", () => { + const mockResult = { + success: true, + connectionId: "conn-1", + connectionLabel: "Claude", + authPath: "/home/user/.claude/credentials.json", + savedBakPath: null, + centralizedBackupPath: "/backups/claude.bak", + mcpOAuthPreserved: false, + writtenAt: new Date().toISOString(), + }; + assert.ok(typeof mockResult.mcpOAuthPreserved === "boolean"); +}); + +test("apply-local: audit metadata includes provider 'claude'", () => { + const metadata = { + provider: "claude", + authPath: "/home/user/.claude/credentials.json", + savedBakPath: null, + centralizedBackupPath: "/backups/claude.bak", + mcpOAuthPreserved: true, + }; + assert.equal(metadata.provider, "claude"); + assert.ok(typeof metadata.mcpOAuthPreserved === "boolean"); +});