Skip to content

Commit 9f2fc4c

Browse files
committed
fix(openapi): share OAuth client credentials
1 parent 36fec8b commit 9f2fc4c

2 files changed

Lines changed: 170 additions & 13 deletions

File tree

plugins/importer-openapi/src/index.ts

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type ImportResources = {
2020
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
2121
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
2222
};
23+
type OAuthVariableNames = { clientId: string; clientSecret: string };
2324

2425
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
2526
const BODY_CONTENT_TYPE_PREFERENCE = [
@@ -62,6 +63,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
6263
folders: [],
6364
httpRequests: [],
6465
};
66+
const oauthVariablesByScheme = buildOAuthVariablesByScheme(importState, spec);
6567
const baseUrl = importBaseUrl(spec);
6668
const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
6769

@@ -119,6 +121,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
119121
importState,
120122
method,
121123
operation,
124+
oauthVariablesByScheme,
122125
path: rawPath,
123126
pathItem,
124127
pathParameters,
@@ -132,6 +135,31 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
132135
}
133136
}
134137

138+
if (resources.httpRequests.some((request) => request.authenticationType === "oauth2")) {
139+
let globalEnvironment = resources.environments[0];
140+
if (globalEnvironment == null) {
141+
globalEnvironment = {
142+
model: "environment",
143+
id: importState.generateId("environment"),
144+
workspaceId: workspace.id,
145+
name: "Global Variables",
146+
variables: [],
147+
parentModel: "workspace",
148+
parentId: null,
149+
sortPriority: importState.nextSortPriority(),
150+
};
151+
resources.environments.push(globalEnvironment);
152+
}
153+
154+
const variableNames = new Set(
155+
[...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [
156+
clientId,
157+
clientSecret,
158+
]),
159+
);
160+
globalEnvironment.variables.push(...[...variableNames].map((name) => ({ name, value: "" })));
161+
}
162+
135163
if (resources.httpRequests.length === 0) return undefined;
136164

137165
disambiguateNames(resources.httpRequests, routeLabels);
@@ -178,6 +206,7 @@ function importOperation({
178206
importState,
179207
method,
180208
operation,
209+
oauthVariablesByScheme,
181210
path,
182211
pathItem,
183212
pathParameters,
@@ -189,6 +218,7 @@ function importOperation({
189218
importState: ImportState;
190219
method: string;
191220
operation: UnknownRecord;
221+
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
192222
path: string;
193223
pathItem: UnknownRecord;
194224
pathParameters: unknown[];
@@ -210,7 +240,12 @@ function importOperation({
210240
body.headers,
211241
importAcceptHeader({ importState, operation, spec }),
212242
);
213-
const authentication = importAuthentication({ importState, operation, spec });
243+
const authentication = importAuthentication({
244+
importState,
245+
oauthVariablesByScheme,
246+
operation,
247+
spec,
248+
});
214249

215250
// Built after everything else, so it can report the refs they left unresolved
216251
const description = importOperationDescription({
@@ -828,10 +863,12 @@ function inferSchemaType(schema: UnknownRecord): string {
828863

829864
function importAuthentication({
830865
importState,
866+
oauthVariablesByScheme,
831867
operation,
832868
spec,
833869
}: {
834870
importState: ImportState;
871+
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
835872
operation: UnknownRecord;
836873
spec: UnknownRecord;
837874
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
@@ -849,7 +886,15 @@ function importAuthentication({
849886
const scheme = toRecord(importState.resolve(schemes[schemeName]));
850887
const type = stringAt(scheme, "type");
851888
if (type === "oauth2") {
852-
const oauth2 = importOAuth2(scheme, rawScopes);
889+
const oauth2 = importOAuth2(
890+
scheme,
891+
rawScopes,
892+
importBaseUrl(spec),
893+
oauthVariablesByScheme.get(schemeName) ?? {
894+
clientId: "oauth_client_id",
895+
clientSecret: "oauth_client_secret",
896+
},
897+
);
853898
if (oauth2 != null) return oauth2;
854899
continue;
855900
}
@@ -902,6 +947,8 @@ function importApiKey(scheme: UnknownRecord, schemeName: string): Record<string,
902947
function importOAuth2(
903948
scheme: UnknownRecord,
904949
rawScopes: unknown,
950+
baseUrl: string,
951+
variableNames: OAuthVariableNames,
905952
): Pick<HttpRequest, "authentication" | "authenticationType"> | null {
906953
const scope = toArray(rawScopes)
907954
.filter((s): s is string => typeof s === "string")
@@ -929,24 +976,36 @@ function importOAuth2(
929976
}
930977

931978
for (const { grantType, flow } of candidates) {
932-
const authorizationUrl = stringAt(flow, "authorizationUrl");
933-
const accessTokenUrl = stringAt(flow, "tokenUrl");
979+
const authorizationUrl = resolveOAuthUrl(stringAt(flow, "authorizationUrl"), baseUrl);
980+
const accessTokenUrl = resolveOAuthUrl(stringAt(flow, "tokenUrl"), baseUrl);
934981
if (authorizationUrl == null && accessTokenUrl == null) continue;
935982

936983
const grantPatch =
937984
grantType === "authorization_code"
938-
? { authorizationUrl, accessTokenUrl, clientSecret: "" }
985+
? {
986+
authorizationUrl,
987+
accessTokenUrl,
988+
clientSecret: templateVariable(variableNames.clientSecret),
989+
}
939990
: grantType === "implicit"
940991
? { authorizationUrl }
941992
: grantType === "password"
942-
? { accessTokenUrl, clientSecret: "", username: "", password: "" }
943-
: { accessTokenUrl, clientSecret: "" };
993+
? {
994+
accessTokenUrl,
995+
clientSecret: templateVariable(variableNames.clientSecret),
996+
username: "",
997+
password: "",
998+
}
999+
: {
1000+
accessTokenUrl,
1001+
clientSecret: templateVariable(variableNames.clientSecret),
1002+
};
9441003

9451004
return {
9461005
authenticationType: "oauth2",
9471006
authentication: {
9481007
grantType,
949-
clientId: "",
1008+
clientId: templateVariable(variableNames.clientId),
9501009
headerPrefix: "Bearer",
9511010
...(scope.length > 0 ? { scope } : {}),
9521011
...grantPatch,
@@ -957,6 +1016,50 @@ function importOAuth2(
9571016
return null;
9581017
}
9591018

1019+
function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | undefined {
1020+
if (value == null || baseUrl.length === 0) return value;
1021+
try {
1022+
return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString();
1023+
} catch {
1024+
return value;
1025+
}
1026+
}
1027+
1028+
function buildOAuthVariablesByScheme(
1029+
importState: ImportState,
1030+
spec: UnknownRecord,
1031+
): Map<string, OAuthVariableNames> {
1032+
const schemes = {
1033+
...toRecord(toRecord(spec.components).securitySchemes),
1034+
...toRecord(spec.securityDefinitions),
1035+
};
1036+
const oauthSchemeNames = Object.entries(schemes)
1037+
.filter(([, scheme]) => stringAt(importState.resolve(scheme), "type") === "oauth2")
1038+
.map(([name]) => name);
1039+
const usedPrefixes = new Set<string>();
1040+
1041+
return new Map(
1042+
oauthSchemeNames.map((schemeName) => {
1043+
const basePrefix =
1044+
oauthSchemeNames.length === 1
1045+
? "oauth"
1046+
: `oauth_${schemeName.replaceAll(/[^a-zA-Z0-9_]+/g, "_").replaceAll(/^_+|_+$/g, "") || "auth"}`;
1047+
let prefix = basePrefix;
1048+
let suffix = 2;
1049+
while (usedPrefixes.has(prefix)) prefix = `${basePrefix}_${suffix++}`;
1050+
usedPrefixes.add(prefix);
1051+
return [
1052+
schemeName,
1053+
{ clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
1054+
];
1055+
}),
1056+
);
1057+
}
1058+
1059+
function templateVariable(name: string): string {
1060+
return `\${[${name}]}`;
1061+
}
1062+
9601063
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
9611064
const headers: HttpRequestHeader[] = [];
9621065
for (const header of headerGroups.flat()) {

plugins/importer-openapi/tests/index.test.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,8 @@ describe("importer-openapi", () => {
289289
authenticationType: "oauth2",
290290
authentication: {
291291
grantType: "client_credentials",
292-
clientId: "",
293-
clientSecret: "",
292+
clientId: "${[oauth_oauth_client_id]}",
293+
clientSecret: "${[oauth_oauth_client_secret]}",
294294
headerPrefix: "Bearer",
295295
scope: "read write",
296296
accessTokenUrl: "https://example.com/token",
@@ -302,12 +302,66 @@ describe("importer-openapi", () => {
302302
authenticationType: "oauth2",
303303
authentication: {
304304
grantType: "implicit",
305-
clientId: "",
305+
clientId: "${[oauth_implicitOauth_client_id]}",
306306
headerPrefix: "Bearer",
307307
authorizationUrl: "https://example.com/authorize",
308308
},
309309
}),
310310
);
311+
expect(imported?.resources.environments[0]?.variables).toEqual([
312+
{ name: "oauth_oauth_client_id", value: "" },
313+
{ name: "oauth_oauth_client_secret", value: "" },
314+
{ name: "oauth_implicitOauth_client_id", value: "" },
315+
{ name: "oauth_implicitOauth_client_secret", value: "" },
316+
]);
317+
});
318+
319+
test("Uses shared environment variables for OAuth2 client credentials", async () => {
320+
const imported = await convertOpenApi(
321+
JSON.stringify({
322+
openapi: "3.0.4",
323+
info: { title: "OAuth Environment Test", version: "1.0.0" },
324+
servers: [{ url: "https://api.example.com" }],
325+
paths: {
326+
"/users": {
327+
get: { security: [{ oauth: ["read"] }], responses: {} },
328+
post: { security: [{ oauth: ["write"] }], responses: {} },
329+
},
330+
},
331+
components: {
332+
securitySchemes: {
333+
oauth: {
334+
type: "oauth2",
335+
flows: {
336+
authorizationCode: {
337+
authorizationUrl: "/oauth/authorize",
338+
tokenUrl: "/oauth/token",
339+
scopes: { read: "Read users", write: "Write users" },
340+
},
341+
},
342+
},
343+
},
344+
},
345+
}),
346+
);
347+
348+
expect(imported?.resources.environments[0]?.variables).toEqual([
349+
{ name: "baseUrl", value: "https://api.example.com" },
350+
{ name: "oauth_client_id", value: "" },
351+
{ name: "oauth_client_secret", value: "" },
352+
]);
353+
expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([
354+
expect.objectContaining({
355+
clientId: "${[oauth_client_id]}",
356+
clientSecret: "${[oauth_client_secret]}",
357+
authorizationUrl: "https://api.example.com/oauth/authorize",
358+
accessTokenUrl: "https://api.example.com/oauth/token",
359+
}),
360+
expect.objectContaining({
361+
clientId: "${[oauth_client_id]}",
362+
clientSecret: "${[oauth_client_secret]}",
363+
}),
364+
]);
311365
});
312366

313367
test("Imports Swagger 2 OAuth2 flows and produces", async () => {
@@ -335,8 +389,8 @@ describe("importer-openapi", () => {
335389
authenticationType: "oauth2",
336390
authentication: {
337391
grantType: "authorization_code",
338-
clientId: "",
339-
clientSecret: "",
392+
clientId: "${[oauth_client_id]}",
393+
clientSecret: "${[oauth_client_secret]}",
340394
headerPrefix: "Bearer",
341395
scope: "admin",
342396
authorizationUrl: "https://example.com/authorize",

0 commit comments

Comments
 (0)