mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 12:24:01 +02:00
Populate OAuth credential variables during OpenAPI import (#587)
This commit is contained in:
@@ -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()) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user