diff --git a/plugins/importer-openapi/src/index.ts b/plugins/importer-openapi/src/index.ts index 4a22b2af..a18969c8 100644 --- a/plugins/importer-openapi/src/index.ts +++ b/plugins/importer-openapi/src/index.ts @@ -25,6 +25,7 @@ type ImportedAuthentication = Pick; +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 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; 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; 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; 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 | 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 | 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 { + 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(); + + 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()) { diff --git a/plugins/importer-openapi/tests/index.test.ts b/plugins/importer-openapi/tests/index.test.ts index d4576377..4c11c582 100644 --- a/plugins/importer-openapi/tests/index.test.ts +++ b/plugins/importer-openapi/tests/index.test.ts @@ -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",