Skip to content

Commit 80be26c

Browse files
committed
add spec-driven security
1 parent 9f31549 commit 80be26c

9 files changed

Lines changed: 494 additions & 21 deletions

README.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,16 @@ In practice this improves compatibility with APIs that define inputs outside sim
8787

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

90+
### Spec-driven security
9091

91-
### Multi-file specs and richer help
92-
93-
`ocli` now works better with larger, more structured API descriptions:
92+
`ocli` now understands more security metadata from OpenAPI and Swagger documents:
9493

95-
- external `$ref` resolution across multiple local or remote OpenAPI / Swagger documents
96-
- support for multi-document specs that split paths, parameters, and request bodies into separate files
97-
- richer `--help` output with schema hints such as `enum`, `default`, `nullable`, and `oneOf`
98-
- better handling of composed schemas that use `allOf` for shared request object structure
94+
- declared `apiKey` security schemes in header, query, and cookie
95+
- operation-level and root-level `security` requirements
96+
- support for alternative security requirements when the spec offers multiple options
97+
- profile-level `auth-values` mapping for named security schemes
9998

100-
In practice this improves compatibility with modular specs and makes generated commands easier to use without opening the original OpenAPI document.
99+
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.
101100

102101
### Multi-file specs and richer help
103102

src/cli.ts

Lines changed: 211 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ const defaultHttpClient: HttpClient = {
3434
};
3535

3636
interface AddProfileArgs {
37-
"api-base-url"?: string;
37+
"api-base-url": string;
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>
@@ -507,6 +691,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
507691
: [];
508692

509693
const customHeaders: Record<string, string> = {};
694+
const authValues: Record<string, string> = {};
510695
if (args["custom-headers"]) {
511696
const raw = args["custom-headers"].trim();
512697
if (raw.startsWith("{")) {
@@ -527,12 +712,24 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
527712
});
528713
}
529714
}
715+
if (args["auth-values"]) {
716+
const raw = args["auth-values"].trim();
717+
if (!raw.startsWith("{")) {
718+
throw new Error("Invalid --auth-values JSON. Expected format: '{\"SchemeName\":\"value\"}'");
719+
}
720+
try {
721+
Object.assign(authValues, JSON.parse(raw));
722+
} catch {
723+
throw new Error("Invalid --auth-values JSON. Expected format: '{\"SchemeName\":\"value\"}'");
724+
}
725+
}
530726

531727
const profile: Profile = {
532728
name: profileName,
533-
apiBaseUrl: args["api-base-url"] ?? "",
729+
apiBaseUrl: args["api-base-url"],
534730
apiBasicAuth: args["api-basic-auth"] ?? "",
535731
apiBearerToken: args["api-bearer-token"] ?? "",
732+
authValues,
536733
openapiSpecSource: args["openapi-spec"],
537734
openapiSpecCache: cachePath,
538735
includeEndpoints,
@@ -541,11 +738,7 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
541738
customHeaders,
542739
};
543740

544-
const spec = await openapiLoader.loadSpec(profile, { refresh: true });
545-
profile.apiBaseUrl = profile.apiBaseUrl || deriveApiBaseUrlFromSpec(spec);
546-
if (!profile.apiBaseUrl) {
547-
throw new Error("Unable to determine API base URL. Provide --api-base-url explicitly.");
548-
}
741+
await openapiLoader.loadSpec(profile, { refresh: true });
549742
profileStore.saveProfile(cwd, profile, { makeCurrent: true });
550743
};
551744

@@ -559,6 +752,11 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
559752
.option("openapi-spec", { type: "string", demandOption: true })
560753
.option("api-basic-auth", { type: "string", default: "" })
561754
.option("api-bearer-token", { type: "string", default: "" })
755+
.option("auth-values", {
756+
type: "string",
757+
default: "",
758+
description: "Security scheme values as JSON, e.g. '{\"ApiKeyAuth\":\"secret\"}'",
759+
})
562760
.option("include-endpoints", { type: "string", default: "" })
563761
.option("exclude-endpoints", { type: "string", default: "" })
564762
.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)