mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 18:04:06 +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[];
|
||||
};
|
||||
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()) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user