Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ In practice this improves compatibility with APIs that define inputs outside sim

In practice this improves compatibility with APIs that rely on non-trivial parameter encoding or per-operation server definitions.

### Spec-driven security

`ocli` now understands more security metadata from OpenAPI and Swagger documents:

- declared `apiKey` security schemes in header, query, and cookie
- operation-level and root-level `security` requirements
- support for alternative security requirements when the spec offers multiple options
- profile-level `auth-values` mapping for named security schemes

In practice this means APIs that define authentication in the spec can now inject API keys into the correct place in the request without manually rewriting URLs or headers.

### Multi-file specs and richer help

Expand Down
214 changes: 208 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface AddProfileArgs {
"openapi-spec": string;
"api-basic-auth"?: string;
"api-bearer-token"?: string;
"auth-values"?: string;
"include-endpoints"?: string;
"exclude-endpoints"?: string;
"command-prefix"?: string;
Expand Down Expand Up @@ -164,8 +165,9 @@ async function runApiCommand(
throw new Error(`Missing required options: ${missingRequired.map((n) => `--${n}`).join(", ")}`);
}

const url = buildRequestUrl(profile, command, flags);
const headers = buildHeaders(profile, command, flags);
const securityArtifacts = buildSecurityArtifacts(profile, spec, command);
const url = buildRequestUrl(profile, command, flags, securityArtifacts.queryParts);
const headers = buildHeaders(profile, command, flags, securityArtifacts);
const payload = buildRequestPayload(command, flags);

const method = command.method.toUpperCase();
Expand Down Expand Up @@ -234,7 +236,12 @@ function parseArgs(args: string[]): { flags: Record<string, string>; positional:
return { flags, positional };
}

function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record<string, string>): string {
function buildRequestUrl(
profile: Profile,
command: CliCommand,
flags: Record<string, string>,
securityQueryParts: string[] = []
): string {
let pathValue = command.path;

command.options
Expand All @@ -260,14 +267,20 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record<st
}
});

if (queryParts.length > 0) {
url += url.includes("?") ? `&${queryParts.join("&")}` : `?${queryParts.join("&")}`;
const allQueryParts = [...queryParts, ...securityQueryParts];
if (allQueryParts.length > 0) {
url += url.includes("?") ? `&${allQueryParts.join("&")}` : `?${allQueryParts.join("&")}`;
}

return url;
}

function buildHeaders(profile: Profile, command: CliCommand, flags: Record<string, string>): Record<string, string> {
function buildHeaders(
profile: Profile,
command: CliCommand,
flags: Record<string, string>,
securityArtifacts?: { headers: Record<string, string>; cookies: string[] }
): Record<string, string> {
const headers: Record<string, string> = {};

if (profile.customHeaders) {
Expand All @@ -281,6 +294,10 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record<strin
headers.Authorization = `Bearer ${profile.apiBearerToken}`;
}

if (securityArtifacts?.headers) {
Object.assign(headers, securityArtifacts.headers);
}

const cookiePairs: string[] = [];
command.options
.filter((opt) => opt.location === "header" || opt.location === "cookie")
Expand All @@ -302,9 +319,176 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record<strin
headers.Cookie = cookiePairs.join("; ");
}

if (securityArtifacts?.cookies && securityArtifacts.cookies.length > 0) {
headers.Cookie = headers.Cookie
? `${headers.Cookie}; ${securityArtifacts.cookies.join("; ")}`
: securityArtifacts.cookies.join("; ");
}

return headers;
}

interface SecuritySchemeLike {
type?: string;
scheme?: string;
in?: string;
name?: string;
}

function buildSecurityArtifacts(
profile: Profile,
spec: unknown,
command: CliCommand
): {
headers: Record<string, string>;
queryParts: string[];
cookies: string[];
} {
const operation = getOperationFromSpec(spec, command);
const securityRequirements = getSecurityRequirements(spec, operation);
const securitySchemes = getSecuritySchemes(spec);

if (securityRequirements.length === 0) {
return { headers: {}, queryParts: [], cookies: [] };
}

for (const requirement of securityRequirements) {
const headers: Record<string, string> = {};
const queryParts: string[] = [];
const cookies: string[] = [];
let satisfied = true;

for (const schemeName of Object.keys(requirement)) {
const scheme = securitySchemes[schemeName];
const artifacts = applySecurityScheme(profile, schemeName, scheme);
if (!artifacts) {
satisfied = false;
break;
}

Object.assign(headers, artifacts.headers);
queryParts.push(...artifacts.queryParts);
cookies.push(...artifacts.cookies);
}

if (satisfied) {
return { headers, queryParts, cookies };
}
}

throw new Error("Missing credentials for the security requirements defined by this operation");
}

function getOperationFromSpec(spec: unknown, command: CliCommand): Record<string, unknown> | undefined {
if (!spec || typeof spec !== "object") {
return undefined;
}

const pathItem = (spec as Record<string, unknown>).paths as Record<string, unknown> | undefined;
const operationGroup = pathItem?.[command.path] as Record<string, unknown> | undefined;
return operationGroup?.[command.method] as Record<string, unknown> | undefined;
}

function getSecurityRequirements(spec: unknown, operation?: Record<string, unknown>): Array<Record<string, string[]>> {
if (operation && Array.isArray(operation.security)) {
return operation.security as Array<Record<string, string[]>>;
}

if (spec && typeof spec === "object" && Array.isArray((spec as Record<string, unknown>).security)) {
return (spec as Record<string, unknown>).security as Array<Record<string, string[]>>;
}

return [];
}

function getSecuritySchemes(spec: unknown): Record<string, SecuritySchemeLike> {
if (!spec || typeof spec !== "object") {
return {};
}

const record = spec as Record<string, unknown>;
const components = record.components as Record<string, unknown> | undefined;
const oasSchemes = components?.securitySchemes as Record<string, SecuritySchemeLike> | undefined;
const swaggerSchemes = record.securityDefinitions as Record<string, SecuritySchemeLike> | undefined;

return {
...(oasSchemes ?? {}),
...(swaggerSchemes ?? {}),
};
}

function applySecurityScheme(
profile: Profile,
schemeName: string,
scheme?: SecuritySchemeLike
): {
headers: Record<string, string>;
queryParts: string[];
cookies: string[];
} | null {
if (!scheme?.type) {
return null;
}

if (scheme.type === "apiKey") {
const value = profile.authValues[schemeName];
if (!value || !scheme.name || !scheme.in) {
return null;
}

if (scheme.in === "header") {
return {
headers: { [scheme.name]: value },
queryParts: [],
cookies: [],
};
}

if (scheme.in === "query") {
return {
headers: {},
queryParts: [`${encodeURIComponent(scheme.name)}=${encodeURIComponent(value)}`],
cookies: [],
};
}

if (scheme.in === "cookie") {
return {
headers: {},
queryParts: [],
cookies: [`${encodeURIComponent(scheme.name)}=${encodeURIComponent(value)}`],
};
}

return null;
}

if (scheme.type === "http") {
const normalized = (scheme.scheme ?? "").toLowerCase();
if (normalized === "basic" && profile.apiBasicAuth) {
const encoded = Buffer.from(profile.apiBasicAuth).toString("base64");
return { headers: { Authorization: `Basic ${encoded}` }, queryParts: [], cookies: [] };
}

if (normalized === "bearer" && profile.apiBearerToken) {
return { headers: { Authorization: `Bearer ${profile.apiBearerToken}` }, queryParts: [], cookies: [] };
}

return null;
}

if (scheme.type === "basic" && profile.apiBasicAuth) {
const encoded = Buffer.from(profile.apiBasicAuth).toString("base64");
return { headers: { Authorization: `Basic ${encoded}` }, queryParts: [], cookies: [] };
}

if ((scheme.type === "oauth2" || scheme.type === "openIdConnect") && profile.apiBearerToken) {
return { headers: { Authorization: `Bearer ${profile.apiBearerToken}` }, queryParts: [], cookies: [] };
}

return null;
}

function buildRequestPayload(
command: CliCommand,
flags: Record<string, string>
Expand Down Expand Up @@ -507,6 +691,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
: [];

const customHeaders: Record<string, string> = {};
const authValues: Record<string, string> = {};
if (args["custom-headers"]) {
const raw = args["custom-headers"].trim();
if (raw.startsWith("{")) {
Expand All @@ -527,12 +712,24 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
});
}
}
if (args["auth-values"]) {
const raw = args["auth-values"].trim();
if (!raw.startsWith("{")) {
throw new Error("Invalid --auth-values JSON. Expected format: '{\"SchemeName\":\"value\"}'");
}
try {
Object.assign(authValues, JSON.parse(raw));
} catch {
throw new Error("Invalid --auth-values JSON. Expected format: '{\"SchemeName\":\"value\"}'");
}
}

const profile: Profile = {
name: profileName,
apiBaseUrl: args["api-base-url"],
apiBasicAuth: args["api-basic-auth"] ?? "",
apiBearerToken: args["api-bearer-token"] ?? "",
authValues,
openapiSpecSource: args["openapi-spec"],
openapiSpecCache: cachePath,
includeEndpoints,
Expand All @@ -555,6 +752,11 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
.option("openapi-spec", { type: "string", demandOption: true })
.option("api-basic-auth", { type: "string", default: "" })
.option("api-bearer-token", { type: "string", default: "" })
.option("auth-values", {
type: "string",
default: "",
description: "Security scheme values as JSON, e.g. '{\"ApiKeyAuth\":\"secret\"}'",
})
.option("include-endpoints", { type: "string", default: "" })
.option("exclude-endpoints", { type: "string", default: "" })
.option("command-prefix", { type: "string", default: "", description: "Prefix for command names (e.g. api_ -> api_messages)" })
Expand Down
17 changes: 17 additions & 0 deletions src/profile-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Profile {
apiBaseUrl: string;
apiBasicAuth: string;
apiBearerToken: string;
authValues: Record<string, string>;
openapiSpecSource: string;
openapiSpecCache: string;
includeEndpoints: string[];
Expand Down Expand Up @@ -94,11 +95,23 @@ export class ProfileStore {
}
}

const authValuesRaw = section.auth_values ?? "";
const authValues: Record<string, string> = {};
if (authValuesRaw) {
const trimmed = authValuesRaw.trim();
if (trimmed.startsWith("{")) {
try {
Object.assign(authValues, JSON.parse(trimmed));
} catch { /* ignore malformed JSON */ }
}
}

return {
name,
apiBaseUrl: section.api_base_url ?? "",
apiBasicAuth: section.api_basic_auth ?? "",
apiBearerToken: section.api_bearer_token ?? "",
authValues,
openapiSpecSource: section.openapi_spec_source ?? "",
openapiSpecCache: section.openapi_spec_cache ?? "",
includeEndpoints,
Expand Down Expand Up @@ -150,11 +163,15 @@ export class ProfileStore {
const customHeadersStr = Object.keys(profile.customHeaders).length > 0
? JSON.stringify(profile.customHeaders)
: "";
const authValuesStr = Object.keys(profile.authValues).length > 0
? JSON.stringify(profile.authValues)
: "";

iniData[sectionName] = {
api_base_url: profile.apiBaseUrl,
api_basic_auth: profile.apiBasicAuth,
api_bearer_token: profile.apiBearerToken,
auth_values: authValuesStr,
openapi_spec_source: profile.openapiSpecSource,
openapi_spec_cache: profile.openapiSpecCache,
include_endpoints: profile.includeEndpoints.join(","),
Expand Down
1 change: 1 addition & 0 deletions tests/box-api-yaml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const profile: Profile = {
apiBaseUrl: "https://api.box.com/2.0",
apiBasicAuth: "",
apiBearerToken: "",
authValues: {},
openapiSpecSource: "",
openapiSpecCache: "",
includeEndpoints: [],
Expand Down
Loading
Loading