Populate OAuth credential variables during OpenAPI import (#587)

This commit is contained in:
Gregory Schier
2026-08-19 08:43:17 -07:00
committed by GitHub
parent d89831a84d
commit 71c217d3f0
2 changed files with 239 additions and 12 deletions
+139 -7
View File
@@ -25,6 +25,7 @@ type ImportedAuthentication = Pick<HttpRequest, "authentication" | "authenticati
urlParameters: HttpUrlParameter[]; urlParameters: HttpUrlParameter[];
}; };
type AuthenticationVariableRegistry = Map<string, { name: string; value: string }>; type AuthenticationVariableRegistry = Map<string, { name: string; value: string }>;
type OAuthVariableNames = { clientId: string; clientSecret: string };
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"]; const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"];
const BODY_CONTENT_TYPE_PREFERENCE = [ const BODY_CONTENT_TYPE_PREFERENCE = [
@@ -68,6 +69,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
httpRequests: [], httpRequests: [],
}; };
const authenticationVariables: AuthenticationVariableRegistry = new Map(); const authenticationVariables: AuthenticationVariableRegistry = new Map();
const oauthVariablesByScheme = buildOAuthVariablesByScheme(importState, spec);
const baseUrl = importBaseUrl(spec); const baseUrl = importBaseUrl(spec);
// A local spec has no document URL against which OpenAPI's implicit "/" // A local spec has no document URL against which OpenAPI's implicit "/"
// server can resolve. Keep the shared variable even when its initial value // server can resolve. Keep the shared variable even when its initial value
@@ -122,6 +124,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
importState, importState,
method, method,
operation, operation,
oauthVariablesByScheme,
path: rawPath, path: rawPath,
pathItem, pathItem,
pathParameters, pathParameters,
@@ -136,6 +139,43 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
} }
} }
if (resources.httpRequests.some((request) => request.authenticationType === "oauth2")) {
let globalEnvironment = resources.environments[0];
if (globalEnvironment == null) {
globalEnvironment = {
model: "environment",
id: importState.generateId("environment"),
workspaceId: workspace.id,
name: "Global Variables",
variables: [],
parentModel: "workspace",
parentId: null,
sortPriority: importState.nextSortPriority(),
};
resources.environments.push(globalEnvironment);
}
const variableNames = new Set(
[...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [
clientId,
clientSecret,
]),
);
if (
resources.httpRequests.some(
(request) =>
request.authenticationType === "oauth2" &&
Object.values(toRecord(request.authentication)).some(
(value) =>
typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")),
),
)
) {
variableNames.add("baseUrlOrigin");
}
globalEnvironment.variables.push(...[...variableNames].map((name) => ({ name, value: "" })));
}
if (resources.httpRequests.length === 0) return undefined; if (resources.httpRequests.length === 0) return undefined;
if (authenticationVariables.size > 0) { if (authenticationVariables.size > 0) {
@@ -218,6 +258,7 @@ function importOperation({
importState, importState,
method, method,
operation, operation,
oauthVariablesByScheme,
path, path,
pathItem, pathItem,
pathParameters, pathParameters,
@@ -230,6 +271,7 @@ function importOperation({
importState: ImportState; importState: ImportState;
method: string; method: string;
operation: UnknownRecord; operation: UnknownRecord;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
path: string; path: string;
pathItem: UnknownRecord; pathItem: UnknownRecord;
pathParameters: unknown[]; pathParameters: unknown[];
@@ -249,6 +291,7 @@ function importOperation({
const authentication = importAuthentication({ const authentication = importAuthentication({
authenticationVariables, authenticationVariables,
importState, importState,
oauthVariablesByScheme,
operation, operation,
spec, spec,
}); });
@@ -885,11 +928,13 @@ function inferSchemaType(schema: UnknownRecord): string {
function importAuthentication({ function importAuthentication({
authenticationVariables, authenticationVariables,
importState, importState,
oauthVariablesByScheme,
operation, operation,
spec, spec,
}: { }: {
authenticationVariables: AuthenticationVariableRegistry; authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState; importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
operation: UnknownRecord; operation: UnknownRecord;
spec: UnknownRecord; spec: UnknownRecord;
}): ImportedAuthentication { }): ImportedAuthentication {
@@ -919,8 +964,10 @@ function importAuthentication({
const imported = importSecurityRequirement({ const imported = importSecurityRequirement({
authenticationVariables, authenticationVariables,
importState, importState,
oauthVariablesByScheme,
requirement: rawRequirement, requirement: rawRequirement,
schemes, schemes,
spec,
}); });
if (imported != null) return imported; if (imported != null) return imported;
} }
@@ -931,13 +978,17 @@ function importAuthentication({
function importSecurityRequirement({ function importSecurityRequirement({
authenticationVariables, authenticationVariables,
importState, importState,
oauthVariablesByScheme,
requirement, requirement,
schemes, schemes,
spec,
}: { }: {
authenticationVariables: AuthenticationVariableRegistry; authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState; importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
requirement: UnknownRecord; requirement: UnknownRecord;
schemes: UnknownRecord; schemes: UnknownRecord;
spec: UnknownRecord;
}): ImportedAuthentication | null { }): ImportedAuthentication | null {
const entries = Object.entries(requirement); const entries = Object.entries(requirement);
const headers: HttpRequestHeader[] = []; const headers: HttpRequestHeader[] = [];
@@ -963,7 +1014,15 @@ function importSecurityRequirement({
let candidate: Pick<HttpRequest, "authentication" | "authenticationType"> | null = null; let candidate: Pick<HttpRequest, "authentication" | "authenticationType"> | null = null;
if (type === "oauth2") { if (type === "oauth2") {
candidate = importOAuth2(scheme, rawScopes); candidate = importOAuth2(
scheme,
rawScopes,
importBaseUrl(spec),
oauthVariablesByScheme.get(schemeName) ?? {
clientId: "oauth_client_id",
clientSecret: "oauth_client_secret",
},
);
} else if (type === "openIdConnect") { } else if (type === "openIdConnect") {
const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token"); const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
candidate = { candidate = {
@@ -1101,6 +1160,8 @@ function templateVariable(name: string): string {
function importOAuth2( function importOAuth2(
scheme: UnknownRecord, scheme: UnknownRecord,
rawScopes: unknown, rawScopes: unknown,
baseUrl: string,
variableNames: OAuthVariableNames,
): Pick<HttpRequest, "authentication" | "authenticationType"> | null { ): Pick<HttpRequest, "authentication" | "authenticationType"> | null {
const scope = toArray(rawScopes) const scope = toArray(rawScopes)
.filter((s): s is string => typeof s === "string") .filter((s): s is string => typeof s === "string")
@@ -1128,24 +1189,36 @@ function importOAuth2(
} }
for (const { grantType, flow } of candidates) { for (const { grantType, flow } of candidates) {
const authorizationUrl = stringAt(flow, "authorizationUrl"); const authorizationUrl = resolveOAuthUrl(stringAt(flow, "authorizationUrl"), baseUrl);
const accessTokenUrl = stringAt(flow, "tokenUrl"); const accessTokenUrl = resolveOAuthUrl(stringAt(flow, "tokenUrl"), baseUrl);
if (authorizationUrl == null && accessTokenUrl == null) continue; if (authorizationUrl == null && accessTokenUrl == null) continue;
const grantPatch = const grantPatch =
grantType === "authorization_code" grantType === "authorization_code"
? { authorizationUrl, accessTokenUrl, clientSecret: "" } ? {
authorizationUrl,
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
}
: grantType === "implicit" : grantType === "implicit"
? { authorizationUrl } ? { authorizationUrl }
: grantType === "password" : grantType === "password"
? { accessTokenUrl, clientSecret: "", username: "", password: "" } ? {
: { accessTokenUrl, clientSecret: "" }; accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
username: "",
password: "",
}
: {
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
};
return { return {
authenticationType: "oauth2", authenticationType: "oauth2",
authentication: { authentication: {
grantType, grantType,
clientId: "", clientId: templateVariable(variableNames.clientId),
headerPrefix: "Bearer", headerPrefix: "Bearer",
...(scope.length > 0 ? { scope } : {}), ...(scope.length > 0 ? { scope } : {}),
...grantPatch, ...grantPatch,
@@ -1156,6 +1229,65 @@ function importOAuth2(
return null; return null;
} }
function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | undefined {
if (value == null) return undefined;
try {
return new URL(value).toString();
} catch {
// Relative endpoint; resolve it against the API base below.
}
if (baseUrl.length > 0) {
try {
return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString();
} catch {
// A path-only server has no origin to resolve against. Preserve whether
// the OAuth endpoint is relative to that path or to the eventual origin.
}
}
if (value.startsWith("//")) return value;
try {
const placeholderOrigin = "https://openapi-import.invalid";
const relativeBase = new URL(`${trimTrailingSlashes(baseUrl)}/`, placeholderOrigin);
const resolved = new URL(value, relativeBase);
return `${templateVariable("baseUrlOrigin")}${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return joinUrlParts(templateVariable("baseUrlOrigin"), value);
}
}
function buildOAuthVariablesByScheme(
importState: ImportState,
spec: UnknownRecord,
): Map<string, OAuthVariableNames> {
const schemes = {
...toRecord(toRecord(spec.components).securitySchemes),
...toRecord(spec.securityDefinitions),
};
const oauthSchemeNames = Object.entries(schemes)
.filter(([, scheme]) => stringAt(importState.resolve(scheme), "type") === "oauth2")
.map(([name]) => name);
const usedPrefixes = new Set<string>();
return new Map(
oauthSchemeNames.map((schemeName) => {
const basePrefix =
oauthSchemeNames.length === 1
? "oauth"
: `oauth_${schemeName.replaceAll(/[^a-zA-Z0-9_]+/g, "_").replaceAll(/^_+|_+$/g, "") || "auth"}`;
let prefix = basePrefix;
let suffix = 2;
while (usedPrefixes.has(prefix)) prefix = `${basePrefix}_${suffix++}`;
usedPrefixes.add(prefix);
return [
schemeName,
{ clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
];
}),
);
}
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] { function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
const headers: HttpRequestHeader[] = []; const headers: HttpRequestHeader[] = [];
for (const header of headerGroups.flat()) { for (const header of headerGroups.flat()) {
+100 -5
View File
@@ -342,8 +342,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2", authenticationType: "oauth2",
authentication: { authentication: {
grantType: "client_credentials", grantType: "client_credentials",
clientId: "", clientId: "${[oauth_oauth_client_id]}",
clientSecret: "", clientSecret: "${[oauth_oauth_client_secret]}",
headerPrefix: "Bearer", headerPrefix: "Bearer",
scope: "read write", scope: "read write",
accessTokenUrl: "https://example.com/token", accessTokenUrl: "https://example.com/token",
@@ -355,12 +355,107 @@ describe("importer-openapi", () => {
authenticationType: "oauth2", authenticationType: "oauth2",
authentication: { authentication: {
grantType: "implicit", grantType: "implicit",
clientId: "", clientId: "${[oauth_implicitOauth_client_id]}",
headerPrefix: "Bearer", headerPrefix: "Bearer",
authorizationUrl: "https://example.com/authorize", authorizationUrl: "https://example.com/authorize",
}, },
}), }),
); );
expect(imported?.resources.environments[0]?.variables).toEqual([
{ name: "baseUrl", value: "" },
{ name: "oauth_oauth_client_id", value: "" },
{ name: "oauth_oauth_client_secret", value: "" },
{ name: "oauth_implicitOauth_client_id", value: "" },
{ name: "oauth_implicitOauth_client_secret", value: "" },
]);
});
test("Uses shared environment variables for OAuth2 client credentials", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "OAuth Environment Test", version: "1.0.0" },
servers: [{ url: "https://api.example.com" }],
paths: {
"/users": {
get: { security: [{ oauth: ["read"] }], responses: {} },
post: { security: [{ oauth: ["write"] }], responses: {} },
},
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "/oauth/authorize",
tokenUrl: "/oauth/token",
scopes: { read: "Read users", write: "Write users" },
},
},
},
},
},
}),
);
expect(imported?.resources.environments[0]?.variables).toEqual([
{ name: "baseUrl", value: "https://api.example.com" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
]);
expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([
expect.objectContaining({
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
authorizationUrl: "https://api.example.com/oauth/authorize",
accessTokenUrl: "https://api.example.com/oauth/token",
}),
expect.objectContaining({
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
}),
]);
});
test("Uses the shared base variable for OAuth endpoints with a path-only API base", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Path-only OAuth Test", version: "1.0.0" },
servers: [{ url: "/api/v1" }],
paths: {
"/users": { get: { security: [{ oauth: [] }], responses: {} } },
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "oauth/authorize",
tokenUrl: "/oauth/token",
scopes: {},
},
},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
expect.objectContaining({
authorizationUrl: "${[baseUrlOrigin]}/api/v1/oauth/authorize",
accessTokenUrl: "${[baseUrlOrigin]}/oauth/token",
}),
);
expect(imported?.resources.environments[0]?.variables).toEqual([
{ name: "baseUrl", value: "/api/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "" },
]);
}); });
test("Imports Swagger 2 OAuth2 flows and produces", async () => { test("Imports Swagger 2 OAuth2 flows and produces", async () => {
@@ -388,8 +483,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2", authenticationType: "oauth2",
authentication: { authentication: {
grantType: "authorization_code", grantType: "authorization_code",
clientId: "", clientId: "${[oauth_client_id]}",
clientSecret: "", clientSecret: "${[oauth_client_secret]}",
headerPrefix: "Bearer", headerPrefix: "Bearer",
scope: "admin", scope: "admin",
authorizationUrl: "https://example.com/authorize", authorizationUrl: "https://example.com/authorize",