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[];
};
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 BODY_CONTENT_TYPE_PREFERENCE = [
@@ -68,6 +69,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
httpRequests: [],
};
const authenticationVariables: AuthenticationVariableRegistry = new Map();
const oauthVariablesByScheme = buildOAuthVariablesByScheme(importState, spec);
const baseUrl = importBaseUrl(spec);
// A local spec has no document URL against which OpenAPI's implicit "/"
// 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,
method,
operation,
oauthVariablesByScheme,
path: rawPath,
pathItem,
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 (authenticationVariables.size > 0) {
@@ -218,6 +258,7 @@ function importOperation({
importState,
method,
operation,
oauthVariablesByScheme,
path,
pathItem,
pathParameters,
@@ -230,6 +271,7 @@ function importOperation({
importState: ImportState;
method: string;
operation: UnknownRecord;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
path: string;
pathItem: UnknownRecord;
pathParameters: unknown[];
@@ -249,6 +291,7 @@ function importOperation({
const authentication = importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
operation,
spec,
});
@@ -885,11 +928,13 @@ function inferSchemaType(schema: UnknownRecord): string {
function importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
operation,
spec,
}: {
authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
operation: UnknownRecord;
spec: UnknownRecord;
}): ImportedAuthentication {
@@ -919,8 +964,10 @@ function importAuthentication({
const imported = importSecurityRequirement({
authenticationVariables,
importState,
oauthVariablesByScheme,
requirement: rawRequirement,
schemes,
spec,
});
if (imported != null) return imported;
}
@@ -931,13 +978,17 @@ function importAuthentication({
function importSecurityRequirement({
authenticationVariables,
importState,
oauthVariablesByScheme,
requirement,
schemes,
spec,
}: {
authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
requirement: UnknownRecord;
schemes: UnknownRecord;
spec: UnknownRecord;
}): ImportedAuthentication | null {
const entries = Object.entries(requirement);
const headers: HttpRequestHeader[] = [];
@@ -963,7 +1014,15 @@ function importSecurityRequirement({
let candidate: Pick<HttpRequest, "authentication" | "authenticationType"> | null = null;
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") {
const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
candidate = {
@@ -1101,6 +1160,8 @@ function templateVariable(name: string): string {
function importOAuth2(
scheme: UnknownRecord,
rawScopes: unknown,
baseUrl: string,
variableNames: OAuthVariableNames,
): Pick<HttpRequest, "authentication" | "authenticationType"> | null {
const scope = toArray(rawScopes)
.filter((s): s is string => typeof s === "string")
@@ -1128,24 +1189,36 @@ function importOAuth2(
}
for (const { grantType, flow } of candidates) {
const authorizationUrl = stringAt(flow, "authorizationUrl");
const accessTokenUrl = stringAt(flow, "tokenUrl");
const authorizationUrl = resolveOAuthUrl(stringAt(flow, "authorizationUrl"), baseUrl);
const accessTokenUrl = resolveOAuthUrl(stringAt(flow, "tokenUrl"), baseUrl);
if (authorizationUrl == null && accessTokenUrl == null) continue;
const grantPatch =
grantType === "authorization_code"
? { authorizationUrl, accessTokenUrl, clientSecret: "" }
? {
authorizationUrl,
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
}
: grantType === "implicit"
? { authorizationUrl }
: grantType === "password"
? { accessTokenUrl, clientSecret: "", username: "", password: "" }
: { accessTokenUrl, clientSecret: "" };
? {
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
username: "",
password: "",
}
: {
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
};
return {
authenticationType: "oauth2",
authentication: {
grantType,
clientId: "",
clientId: templateVariable(variableNames.clientId),
headerPrefix: "Bearer",
...(scope.length > 0 ? { scope } : {}),
...grantPatch,
@@ -1156,6 +1229,65 @@ function importOAuth2(
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[] {
const headers: HttpRequestHeader[] = [];
for (const header of headerGroups.flat()) {
+100 -5
View File
@@ -342,8 +342,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "client_credentials",
clientId: "",
clientSecret: "",
clientId: "${[oauth_oauth_client_id]}",
clientSecret: "${[oauth_oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "read write",
accessTokenUrl: "https://example.com/token",
@@ -355,12 +355,107 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "implicit",
clientId: "",
clientId: "${[oauth_implicitOauth_client_id]}",
headerPrefix: "Bearer",
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 () => {
@@ -388,8 +483,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "authorization_code",
clientId: "",
clientSecret: "",
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "admin",
authorizationUrl: "https://example.com/authorize",