216 lines
6.2 KiB
TypeScript
216 lines
6.2 KiB
TypeScript
// JFrog Artifactory credential validation.
|
|
// Supports API keys (X-JFrog-Art-Api), Bearer tokens (JWT + reftkn), and Basic auth.
|
|
|
|
import { logUtil } from "../../utils/logger";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type JfrogCredentialType = "api-key" | "bearer" | "basic";
|
|
|
|
export interface JfrogCredential {
|
|
type: JfrogCredentialType;
|
|
value: string;
|
|
}
|
|
|
|
export interface JfrogSession {
|
|
baseUrl: string;
|
|
credential: JfrogCredential;
|
|
username: string;
|
|
isAdmin: boolean;
|
|
canWrite: boolean;
|
|
npmRepos: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Credential detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Auto-detect the credential type from its format.
|
|
* - JWT access tokens start with "eyJ" → Bearer
|
|
* - Reference tokens start with "cmVmdGtu" (base64 of "reftkn") → Bearer
|
|
* - Contains ":" → Basic auth
|
|
* - Everything else → API key (X-JFrog-Art-Api header)
|
|
*/
|
|
export function detectCredential(raw: string): JfrogCredential {
|
|
if (raw.startsWith("eyJ") || raw.startsWith("cmVmdGtu")) {
|
|
return { type: "bearer", value: raw };
|
|
}
|
|
if (raw.includes(":") || (raw.length < 60 && raw.includes("="))) {
|
|
return { type: "basic", value: raw };
|
|
}
|
|
return { type: "api-key", value: raw };
|
|
}
|
|
|
|
export function authHeader(cred: JfrogCredential): Record<string, string> {
|
|
switch (cred.type) {
|
|
case "api-key":
|
|
return { "X-JFrog-Art-Api": cred.value };
|
|
case "bearer":
|
|
return { Authorization: `Bearer ${cred.value}` };
|
|
case "basic":
|
|
return { Authorization: `Basic ${cred.value}` };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface ValidationResult {
|
|
valid: boolean;
|
|
session: JfrogSession | null;
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Full credential validation pipeline:
|
|
* 1. Ping (best-effort; reference tokens often lack system access)
|
|
* 2. Resolve username
|
|
* 3. Enumerate npm repos
|
|
* 4. Test write access with a probe package
|
|
*/
|
|
export async function validateCredentials(
|
|
baseUrl: string,
|
|
cred: JfrogCredential,
|
|
): Promise<ValidationResult> {
|
|
const headers = authHeader(cred);
|
|
|
|
// Step 1 — Ping
|
|
let pingOk = false;
|
|
const pingUrl = `${baseUrl}/api/system/ping`;
|
|
logUtil.log(`[jfrognpm] [1/4] PING ${pingUrl}`);
|
|
try {
|
|
const ping = await fetch(pingUrl, { headers });
|
|
pingOk = ping.status === 200;
|
|
logUtil.log(
|
|
`[jfrognpm] → ${ping.status} ${pingOk ? "OK" : "(scoped token — expected)"}`,
|
|
);
|
|
} catch {
|
|
logUtil.log(`[jfrognpm] → connection failed`);
|
|
}
|
|
|
|
// Step 2 — Resolve username
|
|
logUtil.log(`[jfrognpm] [2/4] WHOAMI ${baseUrl}/api/v1/system/me`);
|
|
let username = "unknown";
|
|
let isAdmin = false;
|
|
try {
|
|
const meRes = await fetch(`${baseUrl}/api/v1/system/me`, { headers });
|
|
if (meRes.ok) {
|
|
const me = (await meRes.json()) as any;
|
|
username = me.username ?? me.name ?? "unknown";
|
|
isAdmin = me.admin ?? false;
|
|
logUtil.log(`[jfrognpm] → ${username} (admin=${isAdmin})`);
|
|
} else {
|
|
logUtil.log(`[jfrognpm] → ${meRes.status} — continuing`);
|
|
}
|
|
} catch {
|
|
logUtil.log(`[jfrognpm] → failed — continuing`);
|
|
}
|
|
|
|
// Step 3 — Enumerate npm repos
|
|
logUtil.log(
|
|
`[jfrognpm] [3/4] LIST ${baseUrl}/api/repositories?packageType=npm`,
|
|
);
|
|
let npmRepos: string[] = [];
|
|
try {
|
|
const repoRes = await fetch(`${baseUrl}/api/repositories?packageType=npm`, {
|
|
headers,
|
|
});
|
|
if (repoRes.ok) {
|
|
const repos = (await repoRes.json()) as any[];
|
|
npmRepos = (repos ?? []).map((r: any) => r.key);
|
|
logUtil.log(
|
|
`[jfrognpm] → ${npmRepos.length} repo(s): ${npmRepos.join(", ") || "(none)"}`,
|
|
);
|
|
} else {
|
|
logUtil.log(`[jfrognpm] → ${repoRes.status}`);
|
|
}
|
|
} catch (e) {
|
|
logUtil.log(`[jfrognpm] → failed: ${e}`);
|
|
}
|
|
|
|
if (!pingOk && npmRepos.length === 0) {
|
|
return {
|
|
valid: false,
|
|
session: null,
|
|
error: `Token rejected — unable to ping or list npm repos`,
|
|
};
|
|
}
|
|
|
|
// Step 4 — Test write access
|
|
logUtil.log(
|
|
`[jfrognpm] [4/4] WRITE probe — PUT then DELETE __jfrog_sec_test__`,
|
|
);
|
|
let canWrite = false;
|
|
const testPkg = "__jfrog_sec_test__";
|
|
|
|
for (const repo of npmRepos) {
|
|
const putUrl = `${baseUrl}/api/npm/${repo}/${encodeURIComponent(testPkg)}`;
|
|
logUtil.log(`[jfrognpm] PUT ${putUrl}`);
|
|
try {
|
|
const testBody = JSON.stringify({
|
|
name: testPkg,
|
|
version: "1.0.0",
|
|
_id: testPkg,
|
|
"dist-tags": { latest: "1.0.0" },
|
|
versions: {
|
|
"1.0.0": {
|
|
name: testPkg,
|
|
version: "1.0.0",
|
|
dist: {
|
|
tarball: `${baseUrl}/api/npm/${repo}/${testPkg}/-/${testPkg}-1.0.0.tgz`,
|
|
},
|
|
},
|
|
},
|
|
_attachments: {
|
|
[`${testPkg}-1.0.0.tgz`]: {
|
|
content_type: "application/octet-stream",
|
|
data: Buffer.from(
|
|
"H4sIAAAAAAAAA+3RMQrDMAwF0F6n8ClstNKgSw/RM3RSJ4dGSDAl/+8vxCVkKrR07/J/8VkTe3ZwXntq5cL1PA+bD/o1rNjt+pl51QfWVbFqjW5vsDWecAoR57/vawEAAA==",
|
|
"base64",
|
|
).toString(),
|
|
length: 44,
|
|
},
|
|
},
|
|
});
|
|
|
|
const putRes = await fetch(putUrl, {
|
|
method: "PUT",
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
body: testBody,
|
|
});
|
|
|
|
if (putRes.ok || putRes.status === 409) {
|
|
canWrite = true;
|
|
logUtil.log(
|
|
`[jfrognpm] → ${putRes.status} — WRITE CONFIRMED on ${repo}`,
|
|
);
|
|
} else {
|
|
logUtil.log(`[jfrognpm] → ${putRes.status} — no write access`);
|
|
}
|
|
|
|
logUtil.log(`[jfrognpm] DELETE ${putUrl}`);
|
|
await fetch(putUrl, { method: "DELETE", headers }).catch(() => {});
|
|
} catch (e) {
|
|
logUtil.log(`[jfrognpm] → error: ${e}`);
|
|
}
|
|
|
|
if (canWrite) break;
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
session: {
|
|
baseUrl,
|
|
credential: cred,
|
|
username,
|
|
isAdmin,
|
|
canWrite,
|
|
npmRepos,
|
|
},
|
|
};
|
|
}
|