mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(api): add Claude Code auth import/export API routes + schemas (PR2)
This commit is contained in:
72
src/app/api/providers/[id]/claude-auth/apply-local/route.ts
Normal file
72
src/app/api/providers/[id]/claude-auth/apply-local/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
41
src/app/api/providers/[id]/claude-auth/export/route.ts
Normal file
41
src/app/api/providers/[id]/claude-auth/export/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
114
src/app/api/providers/claude-auth/import-bulk/route.ts
Normal file
114
src/app/api/providers/claude-auth/import-bulk/route.ts
Normal file
@@ -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<string, unknown>) {
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown>);
|
||||
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,
|
||||
});
|
||||
}
|
||||
99
src/app/api/providers/claude-auth/import/route.ts
Normal file
99
src/app/api/providers/claude-auth/import/route.ts
Normal file
@@ -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<string, unknown>) {
|
||||
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<string, unknown>),
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/app/api/providers/claude-auth/zip-extract/route.ts
Normal file
52
src/app/api/providers/claude-auth/zip-extract/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
305
tests/unit/claude-import-route.test.ts
Normal file
305
tests/unit/claude-import-route.test.ts
Normal file
@@ -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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
Reference in New Issue
Block a user