Skip to content

Commit cc377bb

Browse files
committed
add spec-driven security
1 parent 979e2a3 commit cc377bb

9 files changed

Lines changed: 495 additions & 6 deletions

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ In practice this improves compatibility with APIs that define inputs outside sim
8989

9090
In practice this reduces the amount of manual profile setup and improves compatibility with APIs that rely on non-trivial parameter encoding or per-operation server definitions.
9191

92+
### Spec-driven security
93+
94+
`ocli` now understands more security metadata from OpenAPI and Swagger documents:
95+
96+
- declared `apiKey` security schemes in header, query, and cookie
97+
- operation-level and root-level `security` requirements
98+
- support for alternative security requirements when the spec offers multiple options
99+
- profile-level `auth-values` mapping for named security schemes
100+
101+
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.
102+
92103
### Multi-file specs and richer help
93104

94105
`ocli` now works better with larger, more structured API descriptions:

src/cli.ts

Lines changed: 208 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ interface AddProfileArgs {
3838
"openapi-spec": string;
3939
"api-basic-auth"?: string;
4040
"api-bearer-token"?: string;
41+
"auth-values"?: string;
4142
"include-endpoints"?: string;
4243
"exclude-endpoints"?: string;
4344
"command-prefix"?: string;
@@ -164,8 +165,9 @@ async function runApiCommand(
164165
throw new Error(`Missing required options: ${missingRequired.map((n) => `--${n}`).join(", ")}`);
165166
}
166167

167-
const url = buildRequestUrl(profile, command, flags);
168-
const headers = buildHeaders(profile, command, flags);
168+
const securityArtifacts = buildSecurityArtifacts(profile, spec, command);
169+
const url = buildRequestUrl(profile, command, flags, securityArtifacts.queryParts);
170+
const headers = buildHeaders(profile, command, flags, securityArtifacts);
169171
const payload = buildRequestPayload(command, flags);
170172

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

237-
function buildRequestUrl(profile: Profile, command: CliCommand, flags: Record<string, string>): string {
239+
function buildRequestUrl(
240+
profile: Profile,
241+
command: CliCommand,
242+
flags: Record<string, string>,
243+
securityQueryParts: string[] = []
244+
): string {
238245
let pathValue = command.path;
239246

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

263-
if (queryParts.length > 0) {
264-
url += url.includes("?") ? `&${queryParts.join("&")}` : `?${queryParts.join("&")}`;
270+
const allQueryParts = [...queryParts, ...securityQueryParts];
271+
if (allQueryParts.length > 0) {
272+
url += url.includes("?") ? `&${allQueryParts.join("&")}` : `?${allQueryParts.join("&")}`;
265273
}
266274

267275
return url;
268276
}
269277

270-
function buildHeaders(profile: Profile, command: CliCommand, flags: Record<string, string>): Record<string, string> {
278+
function buildHeaders(
279+
profile: Profile,
280+
command: CliCommand,
281+
flags: Record<string, string>,
282+
securityArtifacts?: { headers: Record<string, string>; cookies: string[] }
283+
): Record<string, string> {
271284
const headers: Record<string, string> = {};
272285

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

297+
if (securityArtifacts?.headers) {
298+
Object.assign(headers, securityArtifacts.headers);
299+
}
300+
284301
const cookiePairs: string[] = [];
285302
command.options
286303
.filter((opt) => opt.location === "header" || opt.location === "cookie")
@@ -302,9 +319,176 @@ function buildHeaders(profile: Profile, command: CliCommand, flags: Record<strin
302319
headers.Cookie = cookiePairs.join("; ");
303320
}
304321

322+
if (securityArtifacts?.cookies && securityArtifacts.cookies.length > 0) {
323+
headers.Cookie = headers.Cookie
324+
? `${headers.Cookie}; ${securityArtifacts.cookies.join("; ")}`
325+
: securityArtifacts.cookies.join("; ");
326+
}
327+
305328
return headers;
306329
}
307330

331+
interface SecuritySchemeLike {
332+
type?: string;
333+
scheme?: string;
334+
in?: string;
335+
name?: string;
336+
}
337+
338+
function buildSecurityArtifacts(
339+
profile: Profile,
340+
spec: unknown,
341+
command: CliCommand
342+
): {
343+
headers: Record<string, string>;
344+
queryParts: string[];
345+
cookies: string[];
346+
} {
347+
const operation = getOperationFromSpec(spec, command);
348+
const securityRequirements = getSecurityRequirements(spec, operation);
349+
const securitySchemes = getSecuritySchemes(spec);
350+
351+
if (securityRequirements.length === 0) {
352+
return { headers: {}, queryParts: [], cookies: [] };
353+
}
354+
355+
for (const requirement of securityRequirements) {
356+
const headers: Record<string, string> = {};
357+
const queryParts: string[] = [];
358+
const cookies: string[] = [];
359+
let satisfied = true;
360+
361+
for (const schemeName of Object.keys(requirement)) {
362+
const scheme = securitySchemes[schemeName];
363+
const artifacts = applySecurityScheme(profile, schemeName, scheme);
364+
if (!artifacts) {
365+
satisfied = false;
366+
break;
367+
}
368+
369+
Object.assign(headers, artifacts.headers);
370+
queryParts.push(...artifacts.queryParts);
371+
cookies.push(...artifacts.cookies);
372+
}
373+
374+
if (satisfied) {
375+
return { headers, queryParts, cookies };
376+
}
377+
}
378+
379+
throw new Error("Missing credentials for the security requirements defined by this operation");
380+
}
381+
382+
function getOperationFromSpec(spec: unknown, command: CliCommand): Record<string, unknown> | undefined {
383+
if (!spec || typeof spec !== "object") {
384+
return undefined;
385+
}
386+
387+
const pathItem = (spec as Record<string, unknown>).paths as Record<string, unknown> | undefined;
388+
const operationGroup = pathItem?.[command.path] as Record<string, unknown> | undefined;
389+
return operationGroup?.[command.method] as Record<string, unknown> | undefined;
390+
}
391+
392+
function getSecurityRequirements(spec: unknown, operation?: Record<string, unknown>): Array<Record<string, string[]>> {
393+
if (operation && Array.isArray(operation.security)) {
394+
return operation.security as Array<Record<string, string[]>>;
395+
}
396+
397+
if (spec && typeof spec === "object" && Array.isArray((spec as Record<string, unknown>).security)) {
398+
return (spec as Record<string, unknown>).security as Array<Record<string, string[]>>;
399+
}
400+
401+
return [];
402+
}
403+
404+
function getSecuritySchemes(spec: unknown): Record<string, SecuritySchemeLike> {
405+
if (!spec || typeof spec !== "object") {
406+
return {};
407+
}
408+
409+
const record = spec as Record<string, unknown>;
410+
const components = record.components as Record<string, unknown> | undefined;
411+
const oasSchemes = components?.securitySchemes as Record<string, SecuritySchemeLike> | undefined;
412+
const swaggerSchemes = record.securityDefinitions as Record<string, SecuritySchemeLike> | undefined;
413+
414+
return {
415+
...(oasSchemes ?? {}),
416+
...(swaggerSchemes ?? {}),
417+
};
418+
}
419+
420+
function applySecurityScheme(
421+
profile: Profile,
422+
schemeName: string,
423+
scheme?: SecuritySchemeLike
424+
): {
425+
headers: Record<string, string>;
426+
queryParts: string[];
427+
cookies: string[];
428+
} | null {
429+
if (!scheme?.type) {
430+
return null;
431+
}
432+
433+
if (scheme.type === "apiKey") {
434+
const value = profile.authValues[schemeName];
435+
if (!value || !scheme.name || !scheme.in) {
436+
return null;
437+
}
438+
439+
if (scheme.in === "header") {
440+
return {
441+
headers: { [scheme.name]: value },
442+
queryParts: [],
443+
cookies: [],
444+
};
445+
}
446+
447+
if (scheme.in === "query") {
448+
return {
449+
headers: {},
450+
queryParts: [`${encodeURIComponent(scheme.name)}=${encodeURIComponent(value)}`],
451+
cookies: [],
452+
};
453+
}
454+
455+
if (scheme.in === "cookie") {
456+
return {
457+
headers: {},
458+
queryParts: [],
459+
cookies: [`${encodeURIComponent(scheme.name)}=${encodeURIComponent(value)}`],
460+
};
461+
}
462+
463+
return null;
464+
}
465+
466+
if (scheme.type === "http") {
467+
const normalized = (scheme.scheme ?? "").toLowerCase();
468+
if (normalized === "basic" && profile.apiBasicAuth) {
469+
const encoded = Buffer.from(profile.apiBasicAuth).toString("base64");
470+
return { headers: { Authorization: `Basic ${encoded}` }, queryParts: [], cookies: [] };
471+
}
472+
473+
if (normalized === "bearer" && profile.apiBearerToken) {
474+
return { headers: { Authorization: `Bearer ${profile.apiBearerToken}` }, queryParts: [], cookies: [] };
475+
}
476+
477+
return null;
478+
}
479+
480+
if (scheme.type === "basic" && profile.apiBasicAuth) {
481+
const encoded = Buffer.from(profile.apiBasicAuth).toString("base64");
482+
return { headers: { Authorization: `Basic ${encoded}` }, queryParts: [], cookies: [] };
483+
}
484+
485+
if ((scheme.type === "oauth2" || scheme.type === "openIdConnect") && profile.apiBearerToken) {
486+
return { headers: { Authorization: `Bearer ${profile.apiBearerToken}` }, queryParts: [], cookies: [] };
487+
}
488+
489+
return null;
490+
}
491+
308492
function buildRequestPayload(
309493
command: CliCommand,
310494
flags: Record<string, string>
@@ -542,6 +726,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
542726
: [];
543727

544728
const customHeaders: Record<string, string> = {};
729+
const authValues: Record<string, string> = {};
545730
if (args["custom-headers"]) {
546731
const raw = args["custom-headers"].trim();
547732
if (raw.startsWith("{")) {
@@ -562,12 +747,24 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
562747
});
563748
}
564749
}
750+
if (args["auth-values"]) {
751+
const raw = args["auth-values"].trim();
752+
if (!raw.startsWith("{")) {
753+
throw new Error("Invalid --auth-values JSON. Expected format: '{\"SchemeName\":\"value\"}'");
754+
}
755+
try {
756+
Object.assign(authValues, JSON.parse(raw));
757+
} catch {
758+
throw new Error("Invalid --auth-values JSON. Expected format: '{\"SchemeName\":\"value\"}'");
759+
}
760+
}
565761

566762
const profile: Profile = {
567763
name: profileName,
568764
apiBaseUrl: args["api-base-url"] ?? "",
569765
apiBasicAuth: args["api-basic-auth"] ?? "",
570766
apiBearerToken: args["api-bearer-token"] ?? "",
767+
authValues,
571768
openapiSpecSource: args["openapi-spec"],
572769
openapiSpecCache: cachePath,
573770
includeEndpoints,
@@ -594,6 +791,11 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
594791
.option("openapi-spec", { type: "string", demandOption: true })
595792
.option("api-basic-auth", { type: "string", default: "" })
596793
.option("api-bearer-token", { type: "string", default: "" })
794+
.option("auth-values", {
795+
type: "string",
796+
default: "",
797+
description: "Security scheme values as JSON, e.g. '{\"ApiKeyAuth\":\"secret\"}'",
798+
})
597799
.option("include-endpoints", { type: "string", default: "" })
598800
.option("exclude-endpoints", { type: "string", default: "" })
599801
.option("command-prefix", { type: "string", default: "", description: "Prefix for command names (e.g. api_ -> api_messages)" })

src/profile-store.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface Profile {
99
apiBaseUrl: string;
1010
apiBasicAuth: string;
1111
apiBearerToken: string;
12+
authValues: Record<string, string>;
1213
openapiSpecSource: string;
1314
openapiSpecCache: string;
1415
includeEndpoints: string[];
@@ -94,11 +95,23 @@ export class ProfileStore {
9495
}
9596
}
9697

98+
const authValuesRaw = section.auth_values ?? "";
99+
const authValues: Record<string, string> = {};
100+
if (authValuesRaw) {
101+
const trimmed = authValuesRaw.trim();
102+
if (trimmed.startsWith("{")) {
103+
try {
104+
Object.assign(authValues, JSON.parse(trimmed));
105+
} catch { /* ignore malformed JSON */ }
106+
}
107+
}
108+
97109
return {
98110
name,
99111
apiBaseUrl: section.api_base_url ?? "",
100112
apiBasicAuth: section.api_basic_auth ?? "",
101113
apiBearerToken: section.api_bearer_token ?? "",
114+
authValues,
102115
openapiSpecSource: section.openapi_spec_source ?? "",
103116
openapiSpecCache: section.openapi_spec_cache ?? "",
104117
includeEndpoints,
@@ -150,11 +163,15 @@ export class ProfileStore {
150163
const customHeadersStr = Object.keys(profile.customHeaders).length > 0
151164
? JSON.stringify(profile.customHeaders)
152165
: "";
166+
const authValuesStr = Object.keys(profile.authValues).length > 0
167+
? JSON.stringify(profile.authValues)
168+
: "";
153169

154170
iniData[sectionName] = {
155171
api_base_url: profile.apiBaseUrl,
156172
api_basic_auth: profile.apiBasicAuth,
157173
api_bearer_token: profile.apiBearerToken,
174+
auth_values: authValuesStr,
158175
openapi_spec_source: profile.openapiSpecSource,
159176
openapi_spec_cache: profile.openapiSpecCache,
160177
include_endpoints: profile.includeEndpoints.join(","),

tests/box-api-yaml.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const profile: Profile = {
1717
apiBaseUrl: "https://api.box.com/2.0",
1818
apiBasicAuth: "",
1919
apiBearerToken: "",
20+
authValues: {},
2021
openapiSpecSource: "",
2122
openapiSpecCache: "",
2223
includeEndpoints: [],

0 commit comments

Comments
 (0)