diff --git a/README.md b/README.md index 47da421..34d9234 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/cli.ts b/src/cli.ts index e3675a5..7d3af76 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; @@ -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(); @@ -234,7 +236,12 @@ function parseArgs(args: string[]): { flags: Record; positional: return { flags, positional }; } -function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record): string { +function buildRequestUrl( + profile: Profile, + command: CliCommand, + flags: Record, + securityQueryParts: string[] = [] +): string { let pathValue = command.path; command.options @@ -260,14 +267,20 @@ function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record 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): Record { +function buildHeaders( + profile: Profile, + command: CliCommand, + flags: Record, + securityArtifacts?: { headers: Record; cookies: string[] } +): Record { const headers: Record = {}; if (profile.customHeaders) { @@ -281,6 +294,10 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record opt.location === "header" || opt.location === "cookie") @@ -302,9 +319,176 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record 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; + 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 = {}; + 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 | undefined { + if (!spec || typeof spec !== "object") { + return undefined; + } + + const pathItem = (spec as Record).paths as Record | undefined; + const operationGroup = pathItem?.[command.path] as Record | undefined; + return operationGroup?.[command.method] as Record | undefined; +} + +function getSecurityRequirements(spec: unknown, operation?: Record): Array> { + if (operation && Array.isArray(operation.security)) { + return operation.security as Array>; + } + + if (spec && typeof spec === "object" && Array.isArray((spec as Record).security)) { + return (spec as Record).security as Array>; + } + + return []; +} + +function getSecuritySchemes(spec: unknown): Record { + if (!spec || typeof spec !== "object") { + return {}; + } + + const record = spec as Record; + const components = record.components as Record | undefined; + const oasSchemes = components?.securitySchemes as Record | undefined; + const swaggerSchemes = record.securityDefinitions as Record | undefined; + + return { + ...(oasSchemes ?? {}), + ...(swaggerSchemes ?? {}), + }; +} + +function applySecurityScheme( + profile: Profile, + schemeName: string, + scheme?: SecuritySchemeLike +): { + headers: Record; + 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 @@ -507,6 +691,7 @@ export async function run(argv: string[], options?: RunOptions): Promise { : []; const customHeaders: Record = {}; + const authValues: Record = {}; if (args["custom-headers"]) { const raw = args["custom-headers"].trim(); if (raw.startsWith("{")) { @@ -527,12 +712,24 @@ export async function run(argv: string[], options?: RunOptions): Promise { }); } } + 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, @@ -555,6 +752,11 @@ export async function run(argv: string[], options?: RunOptions): Promise { .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)" }) diff --git a/src/profile-store.ts b/src/profile-store.ts index dfab449..2c98560 100644 --- a/src/profile-store.ts +++ b/src/profile-store.ts @@ -9,6 +9,7 @@ export interface Profile { apiBaseUrl: string; apiBasicAuth: string; apiBearerToken: string; + authValues: Record; openapiSpecSource: string; openapiSpecCache: string; includeEndpoints: string[]; @@ -94,11 +95,23 @@ export class ProfileStore { } } + const authValuesRaw = section.auth_values ?? ""; + const authValues: Record = {}; + 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, @@ -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(","), diff --git a/tests/box-api-yaml.test.ts b/tests/box-api-yaml.test.ts index b660ce9..5de40fa 100644 --- a/tests/box-api-yaml.test.ts +++ b/tests/box-api-yaml.test.ts @@ -17,6 +17,7 @@ const profile: Profile = { apiBaseUrl: "https://api.box.com/2.0", apiBasicAuth: "", apiBearerToken: "", + authValues: {}, openapiSpecSource: "", openapiSpecCache: "", includeEndpoints: [], diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 8b14c8c..ebda9a4 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -948,6 +948,82 @@ describe("cli", () => { expect(config.headers.Cookie).toBe("session_id=cookie-abc"); }); + it("shows schema hints in command help output", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/help-hints-api.json`; + + const spec = { + openapi: "3.1.0", + paths: { + "/reports": { + post: { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + format: { + type: "string", + enum: ["csv", "json"], + default: "json", + }, + note: { + type: "string", + nullable: true, + }, + filter: { + oneOf: [ + { type: "string" }, + { type: "integer" }, + ], + }, + }, + required: ["format"], + }, + }, + }, + }, + }, + }, + }, + }; + + const iniContent = [ + "[help-hints-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = tok", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const log: string[] = []; + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "help-hints-api", + }); + + await run(["reports", "--help"], { + cwd, + profileStore, + openapiLoader, + stdout: (msg: string) => log.push(msg), + }); + + const out = log.join(""); + expect(out).toContain('enum: "csv", "json"'); + expect(out).toContain('default: "json"'); + expect(out).toContain("nullable"); + expect(out).toContain("oneOf: string | integer"); + }); + it("serializes query arrays and deepObject parameters from spec metadata", async () => { const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`; @@ -1318,6 +1394,229 @@ describe("cli", () => { expect(config.url).toBe("https://ops.example.com/custom/messages"); }); + it("applies apiKey security scheme in header from profile auth-values", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/security-header-api.json`; + + const spec = { + openapi: "3.0.0", + components: { + securitySchemes: { + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "X-API-Key", + }, + }, + }, + security: [{ ApiKeyAuth: [] }], + paths: { + "/secure": { + get: { + summary: "Secure endpoint", + }, + }, + }, + }; + + const iniContent = [ + "[security-header-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = ", + 'auth_values = {"ApiKeyAuth":"secret-123"}', + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "security-header-api", + }); + + await run(["secure"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }); + + const config = capturedConfigs[0] as { headers: Record }; + expect(config.headers["X-API-Key"]).toBe("secret-123"); + }); + + it("applies apiKey security schemes in query and cookie and picks a satisfiable alternative", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/security-query-cookie-api.json`; + + const spec = { + openapi: "3.0.0", + components: { + securitySchemes: { + MissingHeader: { + type: "apiKey", + in: "header", + name: "X-Missing", + }, + QueryKey: { + type: "apiKey", + in: "query", + name: "api_key", + }, + SessionCookie: { + type: "apiKey", + in: "cookie", + name: "session_id", + }, + }, + }, + paths: { + "/reports": { + get: { + security: [{ MissingHeader: [] }, { QueryKey: [], SessionCookie: [] }], + summary: "Reports", + }, + }, + }, + }; + + const iniContent = [ + "[security-query-cookie-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = ", + 'auth_values = {"QueryKey":"q-123","SessionCookie":"c-456"}', + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const capturedConfigs: unknown[] = []; + const fakeHttpClient: HttpClient = { + request: async (config: any) => { + capturedConfigs.push(config); + return { status: 200, statusText: "OK", headers: {}, config, data: { ok: true } }; + }, + }; + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "security-query-cookie-api", + }); + + await run(["reports"], { + cwd, + profileStore, + openapiLoader, + httpClient: fakeHttpClient, + stdout: () => {}, + }); + + const config = capturedConfigs[0] as { url: string; headers: Record }; + expect(config.url).toBe("https://api.example.com/reports?api_key=q-123"); + expect(config.headers.Cookie).toBe("session_id=c-456"); + expect(config.headers["X-Missing"]).toBeUndefined(); + }); + + it("throws when required declared security credentials are missing", async () => { + const localDir = `${cwd}/.ocli`; + const profilesPath = `${localDir}/profiles.ini`; + const cachePath = `${localDir}/specs/security-missing-api.json`; + + const spec = { + openapi: "3.0.0", + components: { + securitySchemes: { + ApiKeyAuth: { + type: "apiKey", + in: "query", + name: "api_key", + }, + }, + }, + security: [{ ApiKeyAuth: [] }], + paths: { + "/secure": { + get: { + summary: "Secure endpoint", + }, + }, + }, + }; + + const iniContent = [ + "[security-missing-api]", + "api_base_url = https://api.example.com", + "api_basic_auth = ", + "api_bearer_token = ", + "openapi_spec_source = /spec.json", + `openapi_spec_cache = ${cachePath}`, + "include_endpoints = ", + "exclude_endpoints = ", + "", + ].join("\n"); + + const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, { + [profilesPath]: iniContent, + [cachePath]: JSON.stringify(spec), + [`${localDir}/current`]: "security-missing-api", + }); + + await expect( + run(["secure"], { + cwd, + profileStore, + openapiLoader, + stdout: () => {}, + }) + ).rejects.toThrow("Missing credentials for the security requirements defined by this operation"); + }); + + it("accepts auth-values during onboarding", async () => { + const localDir = `${cwd}/.ocli`; + const fs = new MemoryFs({ + "/project/openapi.json": JSON.stringify({ openapi: "3.0.0", paths: {} }), + }); + const locator = new ConfigLocator({ fs, homeDir }); + const profileStore = new ProfileStore({ fs, locator }); + const openapiLoader = new OpenapiLoader({ fs }); + + await run( + [ + "onboard", + "--api-base-url", + "https://api.example.com", + "--openapi-spec", + "/project/openapi.json", + "--auth-values", + '{"ApiKeyAuth":"secret-123"}', + ], + { cwd, profileStore, openapiLoader } + ); + + const profile = profileStore.getCurrentProfile(cwd); + expect(profile?.authValues).toEqual({ ApiKeyAuth: "secret-123" }); + expect(fs.existsSync(`${localDir}/profiles.ini`)).toBe(true); + }); + it("sends custom headers from profile in API requests", async () => { const localDir = `${cwd}/.ocli`; const profilesPath = `${localDir}/profiles.ini`; diff --git a/tests/github-api.test.ts b/tests/github-api.test.ts index e31e047..f9014a0 100644 --- a/tests/github-api.test.ts +++ b/tests/github-api.test.ts @@ -13,6 +13,7 @@ const profile: Profile = { apiBaseUrl: "https://api.github.com", apiBasicAuth: "", apiBearerToken: "", + authValues: {}, openapiSpecSource: "", openapiSpecCache: "", includeEndpoints: [], diff --git a/tests/openapi-loader.test.ts b/tests/openapi-loader.test.ts index f236948..8eaf3ba 100644 --- a/tests/openapi-loader.test.ts +++ b/tests/openapi-loader.test.ts @@ -80,6 +80,7 @@ describe("OpenapiLoader", () => { apiBaseUrl: "http://127.0.0.1:3000", apiBasicAuth: "", apiBearerToken: "", + authValues: {}, openapiSpecSource: "", openapiSpecCache: "/home/user/.ocli/specs/myapi.json", includeEndpoints: [], diff --git a/tests/openapi-to-commands.test.ts b/tests/openapi-to-commands.test.ts index 5648d69..b173e65 100644 --- a/tests/openapi-to-commands.test.ts +++ b/tests/openapi-to-commands.test.ts @@ -6,6 +6,7 @@ const baseProfile: Profile = { apiBaseUrl: "http://127.0.0.1:3000", apiBasicAuth: "", apiBearerToken: "", + authValues: {}, openapiSpecSource: "", openapiSpecCache: "/home/user/.ocli/specs/myapi.json", includeEndpoints: [], diff --git a/tests/profile-store.test.ts b/tests/profile-store.test.ts index 911b534..e4d5295 100644 --- a/tests/profile-store.test.ts +++ b/tests/profile-store.test.ts @@ -148,6 +148,7 @@ describe("ProfileStore", () => { apiBaseUrl: "http://example.com", apiBasicAuth: "", apiBearerToken: "X", + authValues: {}, openapiSpecSource: "http://example.com/openapi.json", openapiSpecCache: "/home/user/.ocli/specs/savedapi.json", includeEndpoints: ["get:/messages"], @@ -163,11 +164,42 @@ describe("ProfileStore", () => { const savedProfile = store.getCurrentProfile(cwd); expect(savedProfile?.name).toBe("savedapi"); + expect(savedProfile?.authValues).toEqual({}); expect(fs.existsSync(localDir)).toBe(true); expect(fs.existsSync(profilesPath)).toBe(true); }); + it("persists auth_values JSON in profiles.ini", () => { + const cwd = "/project"; + const { store } = createStoreWithFs(cwd, homeDir, {}); + + const profile: Profile = { + name: "secured", + apiBaseUrl: "https://api.example.com", + apiBasicAuth: "", + apiBearerToken: "", + authValues: { + ApiKeyAuth: "secret-123", + SessionCookie: "cookie-456", + }, + openapiSpecSource: "https://api.example.com/openapi.json", + openapiSpecCache: "/home/user/.ocli/specs/secured.json", + includeEndpoints: [], + excludeEndpoints: [], + commandPrefix: "", + customHeaders: {}, + }; + + store.saveProfile(cwd, profile); + + const loaded = store.getProfileByName(cwd, "secured"); + expect(loaded?.authValues).toEqual({ + ApiKeyAuth: "secret-123", + SessionCookie: "cookie-456", + }); + }); + it("listProfileNames returns all profile section names", () => { const cwd = "/project"; const localDir = `${cwd}/.ocli`;