snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

1
plugins/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*/build

View File

@@ -0,0 +1,68 @@
# Copy as cUrl
A request action plugin for Yaak that converts HTTP requests into [curl](https://curl.se)
commands, making it easy to share, debug, and execute requests outside Yaak.
![Screenshot of context menu](screenshot.png)
## Overview
This plugin adds a 'Copy as Curl' action to HTTP requests, converting any request into its
equivalent curl command. This is useful for debugging, sharing requests with team members,
and executing requests in terminal environments where `curl` is available.
## How It Works
The plugin analyzes the given HTTP request and generates a properly formatted curl command
that includes:
- HTTP method (GET, POST, PUT, DELETE, etc.)
- Request URL with query parameters
- Headers (including authentication headers)
- Request body (for POST, PUT, PATCH requests)
- Authentication credentials
## Usage
1. Configure an HTTP request as usual in Yaak
2. Right-click on the request in the sidebar
3. Select 'Copy as Curl'
4. The command is copied to your clipboard
5. Share or execute the command
## Generated Curl Examples
### Simple GET Request
```bash
curl -X GET 'https://api.example.com/users' \
--header 'Accept: application/json'
```
### POST Request with JSON Data
```bash
curl -X POST 'https://api.example.com/users' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"name": "John Doe",
"email": "john@example.com"
}'
```
### Request with Multi-part Form Data
```bash
curl -X POST 'yaak.app' \
--header 'Content-Type: multipart/form-data' \
--form 'hello=world' \
--form file=@/path/to/file.json
```
### Request with Authentication
```bash
curl -X GET 'https://api.example.com/protected' \
--user 'username:password'
```

View File

@@ -0,0 +1,17 @@
{
"name": "@yaak/action-copy-curl",
"displayName": "Copy as Curl",
"version": "0.1.0",
"private": true,
"description": "Copy request as a curl command",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-copy-curl"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}

View File

@@ -0,0 +1,179 @@
import type { HttpRequest, PluginDefinition } from "@yaakapp/api";
const NEWLINE = "\\\n ";
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: "Copy as Curl",
icon: "copy",
async onSelect(ctx, args) {
const rendered_request = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: "send",
});
const data = await convertToCurl(rendered_request);
await ctx.clipboard.copyText(data);
await ctx.toast.show({
message: "Command copied to clipboard",
icon: "copy",
color: "success",
});
},
},
],
};
export async function convertToCurl(request: Partial<HttpRequest>) {
const xs = ["curl"];
// Add method and URL all on first line
if (request.method) xs.push("-X", request.method);
// Build final URL with parameters (compatible with old curl)
let finalUrl = request.url || "";
const urlParams = (request.urlParameters ?? []).filter(onlyEnabled);
if (urlParams.length > 0) {
// Build url
const [base, hash] = finalUrl.split("#");
const separator = base?.includes("?") ? "&" : "?";
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join("&");
finalUrl = base + separator + queryString + (hash ? `#${hash}` : "");
}
// Add API key authentication
if (request.authenticationType === "apikey") {
if (request.authentication?.location === "query") {
const sep = finalUrl.includes("?") ? "&" : "?";
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? "token"),
"=",
encodeURIComponent(request.authentication?.value ?? ""),
].join("");
} else {
request.headers = request.headers ?? [];
request.headers.push({
name: request.authentication?.key ?? "X-Api-Key",
value: request.authentication?.value ?? "",
});
}
}
xs.push(quote(finalUrl));
xs.push(NEWLINE);
// Add headers
for (const h of (request.headers ?? []).filter(onlyEnabled)) {
xs.push("--header", quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add form params
const type = request.bodyType ?? "none";
if (
(type === "multipart/form-data" || type === "application/x-www-form-urlencoded") &&
Array.isArray(request.body?.form)
) {
const flag = request.bodyType === "multipart/form-data" ? "--form" : "--data";
for (const p of (request.body?.form ?? []).filter(onlyEnabled)) {
if (p.file) {
let v = `${p.name}=@${p.file}`;
v += p.contentType ? `;type=${p.contentType}` : "";
// A bare `;` separates commands and a path can hold spaces, so this
// argument needs quoting like every other one.
xs.push(flag, quote(v));
} else {
xs.push(flag, quote(`${p.name}=${p.value}`));
}
xs.push(NEWLINE);
}
} else if (type === "graphql" && typeof request.body?.query === "string") {
const body = {
query: request.body.query || "",
variables: maybeParseJSON(request.body.variables, undefined),
operationName: request.body.operationName || undefined,
};
xs.push("--data", quote(JSON.stringify(body)));
xs.push(NEWLINE);
} else if (type !== "none" && typeof request.body?.text === "string") {
xs.push("--data", quote(request.body.text));
xs.push(NEWLINE);
}
// Add basic/digest authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === "basic" || request.authenticationType === "digest") {
if (request.authenticationType === "digest") xs.push("--digest");
xs.push(
"--user",
quote(
`${request.authentication?.username ?? ""}:${request.authentication?.password ?? ""}`,
),
);
xs.push(NEWLINE);
}
// Add bearer authentication
if (request.authenticationType === "bearer") {
const value =
`${request.authentication?.prefix ?? "Bearer"} ${request.authentication?.token ?? ""}`.trim();
xs.push("--header", quote(`Authorization: ${value}`));
xs.push(NEWLINE);
}
if (request.authenticationType === "auth-aws-sig-v4") {
xs.push(
"--aws-sigv4",
[
"aws",
"amz",
request.authentication?.region ?? "",
request.authentication?.service ?? "",
].join(":"),
);
xs.push(NEWLINE);
xs.push(
"--user",
quote(
`${request.authentication?.accessKeyId ?? ""}:${request.authentication?.secretAccessKey ?? ""}`,
),
);
if (request.authentication?.sessionToken) {
xs.push(NEWLINE);
xs.push("--header", quote(`X-Amz-Security-Token: ${request.authentication.sessionToken}`));
}
xs.push(NEWLINE);
}
}
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);
}
return xs.join(" ");
}
function quote(arg: string): string {
// A single-quoted POSIX string takes no escapes, so `\'` does not close it:
// the string ends one character early and the rest of the command is left
// dangling. Step out of the quotes, emit an escaped quote, step back in.
const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
function onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {
return v.enabled !== false && !!v.name;
}
function maybeParseJSON<T>(v: string, fallback: T) {
try {
return JSON.parse(v);
} catch {
return fallback;
}
}

View File

@@ -0,0 +1,470 @@
import { describe, expect, test } from "vite-plus/test";
import { convertToCurl } from "../src";
describe("exporter-curl", () => {
test("Exports GET with params", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
urlParameters: [
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
}),
).toEqual([`curl 'https://yaak.app?a=aaa&b=bbb'`].join(" \\n "));
});
test("Exports GET with params and hash", async () => {
expect(
await convertToCurl({
url: "https://yaak.app/path#section",
urlParameters: [
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
}),
).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(" \\n "));
});
test("Exports POST with url form data", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "application/x-www-form-urlencoded",
body: {
form: [
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data 'a=aaa'`, `--data 'b=bbb'`].join(" \\\n "),
);
});
test("Exports POST with GraphQL data", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: "{foo,bar}",
variables: '{"a": "aaa", "b": "bbb"}',
},
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--data '{"query":"{foo,bar}","variables":{"a":"aaa","b":"bbb"}}'`,
].join(" \\\n "),
);
});
test("Exports POST with GraphQL operation name", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: "query Foo { foo } query Bar { bar }",
operationName: "Foo",
},
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--data '{"query":"query Foo { foo } query Bar { bar }","operationName":"Foo"}'`,
].join(" \\\n "),
);
});
test("Exports POST with GraphQL data no variables", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "graphql",
body: {
query: "{foo,bar}",
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data '{"query":"{foo,bar}"}'`].join(" \\\n "),
);
});
test("Exports PUT with multipart form", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "PUT",
bodyType: "multipart/form-data",
body: {
form: [
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
{ name: "f", file: "/foo/bar.png", contentType: "image/png" },
],
},
}),
).toEqual(
[
`curl -X PUT 'https://yaak.app'`,
`--form 'a=aaa'`,
`--form 'b=bbb'`,
"--form 'f=@/foo/bar.png;type=image/png'",
].join(" \\\n "),
);
});
test("Exports JSON body", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "application/json",
body: {
text: `{"foo":"bar's"}`,
},
headers: [{ name: "Content-Type", value: "application/json" }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar'\\''s"}'`,
].join(" \\\n "),
);
});
test("Quotes an apostrophe so the command still parses", async () => {
// POSIX single quotes take no escapes: `\'` ends the string one
// character early and everything after it is left dangling, so the copied
// command is a syntax error rather than a request.
const command = await convertToCurl({
url: "https://yaak.app/it's",
method: "POST",
bodyType: "application/json",
body: { text: `{"note":"don't stop"}` },
headers: [{ name: "X-Note", value: "it's fine" }],
});
expect(command).toEqual(
[
`curl -X POST 'https://yaak.app/it'\\''s'`,
`--header 'X-Note: it'\\''s fine'`,
`--data '{"note":"don'\\''t stop"}'`,
].join(" \\\n "),
);
});
test("Quotes a file form field so its type suffix survives", async () => {
// A bare `;` separates commands, so an unquoted `f=@x.png;type=image/png`
// reaches curl as `f=@x.png` and the rest runs as its own command.
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "multipart/form-data",
body: { form: [{ name: "f", file: "/my files/a.png", contentType: "image/png" }] },
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--form 'f=@/my files/a.png;type=image/png'`].join(
" \\\n ",
),
);
});
test("Exports multi-line JSON body", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
method: "POST",
bodyType: "application/json",
body: {
text: `{"foo":"bar",\n"baz":"qux"}`,
},
headers: [{ name: "Content-Type", value: "application/json" }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar",\n"baz":"qux"}'`,
].join(" \\\n "),
);
});
test("Exports headers", async () => {
expect(
await convertToCurl({
headers: [
{ name: "a", value: "aaa" },
{ name: "b", value: "bbb", enabled: true },
{ name: "c", value: "ccc", enabled: false },
],
}),
).toEqual([`curl ''`, `--header 'a: aaa'`, `--header 'b: bbb'`].join(" \\\n "));
});
test("Basic auth", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(" \\\n "));
});
test("Basic auth disabled", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
disabled: true,
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`].join(" \\\n "));
});
test("Broken basic auth", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "basic",
authentication: {},
}),
).toEqual([`curl 'https://yaak.app'`, `--user ':'`].join(" \\\n "));
});
test("Digest auth", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "digest",
authentication: {
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--digest --user 'user:pass'`].join(" \\\n "));
});
test("Bearer auth", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
token: "tok",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer tok'`].join(" \\\n "));
});
test("Bearer auth with custom prefix", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
token: "abc123",
prefix: "Token",
},
}),
).toEqual(
[`curl 'https://yaak.app'`, `--header 'Authorization: Token abc123'`].join(" \\\n "),
);
});
test("Bearer auth with empty prefix", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
token: "xyz789",
prefix: "",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: xyz789'`].join(" \\\n "));
});
test("Broken bearer auth", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "bearer",
authentication: {
username: "user",
password: "pass",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer'`].join(" \\\n "));
});
test("AWS v4 auth", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "auth-aws-sig-v4",
authentication: {
accessKeyId: "ak",
secretAccessKey: "sk",
sessionToken: "",
region: "us-east-1",
service: "s3",
},
}),
).toEqual(
[`curl 'https://yaak.app'`, "--aws-sigv4 aws:amz:us-east-1:s3", `--user 'ak:sk'`].join(
" \\\n ",
),
);
});
test("AWS v4 auth with session", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "auth-aws-sig-v4",
authentication: {
accessKeyId: "ak",
secretAccessKey: "sk",
sessionToken: "st",
region: "us-east-1",
service: "s3",
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
"--aws-sigv4 aws:amz:us-east-1:s3",
`--user 'ak:sk'`,
`--header 'X-Amz-Security-Token: st'`,
].join(" \\\n "),
);
});
test("API key auth header", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: "header",
key: "X-Header",
value: "my-token",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Header: my-token'`].join(" \\\n "));
});
test("API key auth header query", async () => {
expect(
await convertToCurl({
url: "https://yaak.app?hi=there",
urlParameters: [{ name: "param", value: "hi" }],
authenticationType: "apikey",
authentication: {
location: "query",
key: "foo",
value: "bar",
},
}),
).toEqual([`curl 'https://yaak.app?hi=there&param=hi&foo=bar'`].join(" \\\n "));
});
test("API key auth header query with params", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
urlParameters: [{ name: "param", value: "hi" }],
authenticationType: "apikey",
authentication: {
location: "query",
key: "foo",
value: "bar",
},
}),
).toEqual([`curl 'https://yaak.app?param=hi&foo=bar'`].join(" \\\n "));
});
test("API key auth header default", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: "header",
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Api-Key: '`].join(" \\\n "));
});
test("API key auth query", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: "query",
key: "foo",
value: "bar-baz",
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar-baz'`].join(" \\\n "));
});
test("API key auth query with existing", async () => {
expect(
await convertToCurl({
url: "https://yaak.app?foo=bar&baz=qux",
authenticationType: "apikey",
authentication: {
location: "query",
key: "hi",
value: "there",
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`].join(" \\\n "));
});
test("API key auth query default", async () => {
expect(
await convertToCurl({
url: "https://yaak.app?foo=bar&baz=qux",
authenticationType: "apikey",
authentication: {
location: "query",
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&token='`].join(" \\\n "));
});
test("Stale body data", async () => {
expect(
await convertToCurl({
url: "https://yaak.app",
bodyType: "none",
body: {
text: "ignore me",
},
}),
).toEqual([`curl 'https://yaak.app'`].join(" \\\n "));
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,75 @@
# Copy as gRPCurl
An HTTP request action plugin that converts gRPC requests
into [gRPCurl](https://github.com/fullstorydev/grpcurl) commands, enabling easy sharing,
debugging, and execution of gRPC calls outside Yaak.
![Screenshot of context menu](screenshot.png)
## Overview
This plugin adds a "Copy as gRPCurl" action to gRPC requests, converting any gRPC request
into its equivalent executable command. This is useful for debugging gRPC services,
sharing requests with team members, or executing gRPC calls in terminal environments where
`grpcurl` is available.
## How It Works
The plugin analyzes your gRPC request configuration and generates a properly formatted
`grpcurl` command that includes:
- gRPC service and method names
- Server address and port
- Request message data (JSON format)
- Metadata (headers)
- Authentication credentials
- Protocol buffer definitions
## Usage
1. Configure a gRPC request as usual in Yaak
2. Right-click on the request sidebar item
3. Select "Copy as gRPCurl" from the available actions
4. The command is copied to your clipboard
5. Share or execute the command
## Generated gRPCurl Examples
### Simple Unary Call
```bash
grpcurl -plaintext \
-d '{"name": "John Doe"}' \
localhost:9090 \
user.UserService/GetUser
```
### Call with Metadata
```bash
grpcurl -plaintext \
-H "authorization: Bearer my-token" \
-H "x-api-version: v1" \
-d '{"user_id": "12345"}' \
api.example.com:443 \
user.UserService/GetUserProfile
```
### Call with TLS
```bash
grpcurl \
-d '{"query": "search term"}' \
secure-api.example.com:443 \
search.SearchService/Search
```
### Call with Proto Files
```bash
grpcurl -import-path /path/to/protos \
-proto /other/path/to/user.proto \
-d '{"email": "user@example.com"}' \
localhost:9090 \
user.UserService/CreateUser
```

View File

@@ -0,0 +1,17 @@
{
"name": "@yaak/action-copy-grpcurl",
"displayName": "Copy as gRPCurl",
"version": "0.1.0",
"private": true,
"description": "Copy gRPC request as a grpcurl command",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-copy-grpcurl"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}

View File

@@ -0,0 +1,158 @@
import path from "node:path";
import type { GrpcRequest, PluginDefinition } from "@yaakapp/api";
const NEWLINE = "\\\n ";
export const plugin: PluginDefinition = {
grpcRequestActions: [
{
label: "Copy as gRPCurl",
icon: "copy",
async onSelect(ctx, args) {
const rendered_request = await ctx.grpcRequest.render({
grpcRequest: args.grpcRequest,
purpose: "send",
});
const data = await convert(rendered_request, args.protoFiles);
await ctx.clipboard.copyText(data);
await ctx.toast.show({
message: "Command copied to clipboard",
icon: "copy",
color: "success",
});
},
},
],
};
export async function convert(request: Partial<GrpcRequest>, allProtoFiles: string[]) {
const xs = ["grpcurl"];
if (request.url?.startsWith("http://")) {
xs.push("-plaintext");
}
const protoIncludes = allProtoFiles.filter((f) => !f.endsWith(".proto"));
const protoFiles = allProtoFiles.filter((f) => f.endsWith(".proto"));
const inferredIncludes = new Set<string>();
for (const f of protoFiles) {
const protoDir = findParentProtoDir(f);
if (protoDir) {
inferredIncludes.add(protoDir);
} else {
inferredIncludes.add(path.posix.join(f, ".."));
inferredIncludes.add(path.posix.join(f, "..", ".."));
}
}
for (const f of protoIncludes) {
xs.push("-import-path", quote(f));
xs.push(NEWLINE);
}
for (const f of inferredIncludes.values()) {
xs.push("-import-path", quote(f));
xs.push(NEWLINE);
}
for (const f of protoFiles) {
xs.push("-proto", quote(f));
xs.push(NEWLINE);
}
// Add headers
for (const h of (request.metadata ?? []).filter(onlyEnabled)) {
xs.push("-H", quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add basic authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === "basic") {
const user = request.authentication?.username ?? "";
const pass = request.authentication?.password ?? "";
const encoded = btoa(`${user}:${pass}`);
xs.push("-H", quote(`Authorization: Basic ${encoded}`));
xs.push(NEWLINE);
} else if (request.authenticationType === "bearer") {
// Add bearer authentication
xs.push("-H", quote(`Authorization: Bearer ${request.authentication?.token ?? ""}`));
xs.push(NEWLINE);
} else if (request.authenticationType === "apikey") {
if (request.authentication?.location === "query") {
const sep = request.url?.includes("?") ? "&" : "?";
request.url = [
request.url,
sep,
encodeURIComponent(request.authentication?.key ?? "token"),
"=",
encodeURIComponent(request.authentication?.value ?? ""),
].join("");
} else {
xs.push(
"-H",
quote(
`${request.authentication?.key ?? "X-Api-Key"}: ${request.authentication?.value ?? ""}`,
),
);
}
xs.push(NEWLINE);
}
}
// Add form params
if (request.message) {
xs.push("-d", quote(request.message));
xs.push(NEWLINE);
}
// Add the server address
if (request.url) {
const server = request.url.replace(/^https?:\/\//, ""); // remove protocol
xs.push(server);
xs.push(NEWLINE);
}
// Add service + method
if (request.service && request.method) {
xs.push(`${request.service}/${request.method}`);
xs.push(NEWLINE);
}
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);
}
return xs.join(" ");
}
function quote(arg: string): string {
// A single-quoted POSIX string takes no escapes, so `\'` does not close it:
// the string ends one character early and the rest of the command is left
// dangling. Step out of the quotes, emit an escaped quote, step back in.
const escaped = arg.replace(/'/g, `'\\''`);
return `'${escaped}'`;
}
function onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {
return v.enabled !== false && !!v.name;
}
function findParentProtoDir(startPath: string): string | null {
let dir = path.resolve(startPath);
while (true) {
if (path.basename(dir) === "proto") {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
return null; // Reached root
}
dir = parent;
}
}

View File

@@ -0,0 +1,195 @@
import { describe, expect, test } from "vite-plus/test";
import { convert } from "../src";
describe("exporter-curl", () => {
test("Simple example", async () => {
expect(
await convert(
{
url: "https://yaak.app",
},
[],
),
).toEqual(["grpcurl yaak.app"].join(" \\\n "));
});
test("Basic metadata", async () => {
expect(
await convert(
{
url: "https://yaak.app",
metadata: [
{ name: "aaa", value: "AAA" },
{ enabled: true, name: "bbb", value: "BBB" },
{ enabled: false, name: "disabled", value: "ddd" },
],
},
[],
),
).toEqual([`grpcurl -H 'aaa: AAA'`, `-H 'bbb: BBB'`, "yaak.app"].join(" \\\n "));
});
test("Basic auth", async () => {
expect(
await convert(
{
url: "https://yaak.app",
authenticationType: "basic",
authentication: {
username: "user",
password: "pass",
},
},
[],
),
).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, "yaak.app"].join(" \\\n "));
});
test("API key auth", async () => {
expect(
await convert(
{
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
key: "X-Token",
value: "tok",
},
},
[],
),
).toEqual([`grpcurl -H 'X-Token: tok'`, "yaak.app"].join(" \\\n "));
});
test("API key auth", async () => {
expect(
await convert(
{
url: "https://yaak.app",
authenticationType: "apikey",
authentication: {
location: "query",
key: "token",
value: "tok 1",
},
},
[],
),
).toEqual(["grpcurl", "yaak.app?token=tok%201"].join(" \\\n "));
});
test("Single proto file", async () => {
expect(await convert({ url: "https://yaak.app" }, ["/foo/bar/baz.proto"])).toEqual(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/baz.proto'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Multiple proto files, same dir", async () => {
expect(
await convert({ url: "https://yaak.app" }, ["/foo/bar/aaa.proto", "/foo/bar/bbb.proto"]),
).toEqual(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/aaa.proto'`,
`-proto '/foo/bar/bbb.proto'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Multiple proto files, different dir", async () => {
expect(
await convert({ url: "https://yaak.app" }, ["/aaa/bbb/ccc.proto", "/xxx/yyy/zzz.proto"]),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
`-import-path '/aaa'`,
`-import-path '/xxx/yyy'`,
`-import-path '/xxx'`,
`-proto '/aaa/bbb/ccc.proto'`,
`-proto '/xxx/yyy/zzz.proto'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Single include dir", async () => {
expect(await convert({ url: "https://yaak.app" }, ["/aaa/bbb"])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, "yaak.app"].join(" \\\n "),
);
});
test("Multiple include dir", async () => {
expect(await convert({ url: "https://yaak.app" }, ["/aaa/bbb", "/xxx/yyy"])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, `-import-path '/xxx/yyy'`, "yaak.app"].join(" \\\n "),
);
});
test("Mixed proto and dirs", async () => {
expect(
await convert({ url: "https://yaak.app" }, ["/aaa/bbb", "/xxx/yyy", "/foo/bar.proto"]),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
`-import-path '/xxx/yyy'`,
`-import-path '/foo'`,
`-import-path '/'`,
`-proto '/foo/bar.proto'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Sends data", async () => {
expect(
await convert(
{
url: "https://yaak.app",
message: JSON.stringify({ foo: "bar", baz: 1.0 }, null, 2),
},
["/foo.proto"],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{\n "foo": "bar",\n "baz": 1\n}'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Sends data with unresolved template tags", async () => {
expect(
await convert(
{
url: "https://yaak.app",
message: '{"timestamp": ${[ faker "timestamp" ]}, "foo": "bar"}',
},
["/foo.proto"],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{"timestamp": \${[ faker "timestamp" ]}, "foo": "bar"}'`,
"yaak.app",
].join(" \\\n "),
);
});
test("Quotes an apostrophe so the command still parses", async () => {
// POSIX single quotes take no escapes: `\'` ends the string one
// character early and leaves the rest of the command dangling.
const command = await convert(
{
url: "https://yaak.app",
service: "Service",
method: "Method",
message: `{"note":"don't stop"}`,
metadata: [{ name: "x-note", value: "it's fine" }],
},
[],
);
expect(command).toContain(`'{"note":"don'\\''t stop"}'`);
expect(command).toContain(`'x-note: it'\\''s fine'`);
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,16 @@
{
"name": "@yaak/action-send-folder",
"displayName": "Send All",
"version": "0.1.0",
"private": true,
"description": "Send all HTTP requests in a folder sequentially in tree order",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-send-folder"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
}
}

View File

@@ -0,0 +1,99 @@
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
folderActions: [
{
label: "Send All",
icon: "send_horizontal",
async onSelect(ctx, args) {
const targetFolder = args.folder;
// Get all folders and HTTP requests
const [allFolders, allRequests] = await Promise.all([
ctx.folder.list(),
ctx.httpRequest.list(),
]);
// Build the send order to match tree ordering:
// sort siblings by sortPriority then updatedAt, and traverse folders depth-first.
const compareByOrder = (
a: Pick<(typeof allFolders)[number], "sortPriority" | "updatedAt">,
b: Pick<(typeof allFolders)[number], "sortPriority" | "updatedAt">,
) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
}
return a.sortPriority - b.sortPriority;
};
const childrenByFolderId = new Map<
string,
Array<(typeof allFolders)[number] | (typeof allRequests)[number]>
>();
for (const folder of allFolders) {
if (folder.folderId == null) continue;
const children = childrenByFolderId.get(folder.folderId) ?? [];
children.push(folder);
childrenByFolderId.set(folder.folderId, children);
}
for (const request of allRequests) {
if (request.folderId == null) continue;
const children = childrenByFolderId.get(request.folderId) ?? [];
children.push(request);
childrenByFolderId.set(request.folderId, children);
}
const requestsToSend: typeof allRequests = [];
const collectRequests = (folderId: string) => {
const children = (childrenByFolderId.get(folderId) ?? []).slice().sort(compareByOrder);
for (const child of children) {
if (child.model === "folder") {
collectRequests(child.id);
} else if (child.model === "http_request") {
requestsToSend.push(child);
}
}
};
collectRequests(targetFolder.id);
if (requestsToSend.length === 0) {
await ctx.toast.show({
message: "No requests in folder",
icon: "info",
color: "info",
});
return;
}
// Send requests sequentially in the calculated folder order.
let successCount = 0;
let errorCount = 0;
for (const request of requestsToSend) {
try {
await ctx.httpRequest.send({ httpRequest: request });
successCount++;
} catch (error) {
errorCount++;
console.error(`Failed to send request ${request.id}:`, error);
}
}
// Show summary toast
if (errorCount === 0) {
await ctx.toast.show({
message: `Sent ${successCount} request${successCount !== 1 ? "s" : ""}`,
icon: "send_horizontal",
color: "success",
});
} else {
await ctx.toast.show({
message: `Sent ${successCount}, failed ${errorCount}`,
icon: "alert_triangle",
color: "warning",
});
}
},
},
],
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,16 @@
{
"name": "@yaak/auth-apikey",
"displayName": "API Key Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using an API key",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-apikey"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
}
}

View File

@@ -0,0 +1,54 @@
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
authentication: {
name: "apikey",
label: "API Key",
shortLabel: "API Key",
args: [
{
type: "select",
name: "location",
label: "Behavior",
defaultValue: "header",
options: [
{ label: "Insert Header", value: "header" },
{ label: "Append Query Parameter", value: "query" },
],
},
{
type: "text",
name: "key",
label: "Key",
dynamic: (_ctx, { values }) => {
return values.location === "query"
? {
label: "Parameter Name",
description: "The name of the query parameter to add to the request",
}
: {
label: "Header Name",
description: "The name of the header to add to the request",
};
},
},
{
type: "text",
name: "value",
label: "API Key",
optional: true,
password: true,
},
],
async onApply(_ctx, { values }) {
const key = String(values.key ?? "");
const value = String(values.value ?? "");
const location = String(values.location);
if (location === "query") {
return { setQueryParameters: [{ name: key, value }] };
}
return { setHeaders: [{ name: key, value }] };
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,49 @@
# AWS Signature Version 4 Auth
A plugin for authenticating AWS-compatible requests using the
[AWS Signature Version 4 signing process](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
This enables secure, signed requests to AWS services (or any S3-compatible APIs like
Cloudflare R2).
![Screenshot of AWS SigV4 UI](screenshot.png)
## Overview
This plugin provides AWS Signature authentication for API requests in Yaak. SigV4 is used
by nearly all AWS APIs to verify the authenticity and integrity of requests using
cryptographic signatures.
With this plugin, you can securely sign requests to AWS services such as S3, STS, Lambda,
API Gateway, DynamoDB, and more. You can also authenticate against S3-compatible services
like **Cloudflare R2**, **MinIO**, or **Wasabi**.
## How AWS Signature Version 4 Works
SigV4 signs requests by creating a hash of key request components (method, URL, headers,
and optionally the payload) using your AWS credentials. The resulting HMAC signature is
added in the `Authorization` header along with credential scope metadata.
Example header:
```
Authorization: AWS4-HMAC-SHA256 Credential=AKIA…/20251011/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abcdef123456…
```
Each request must include a timestamp (`X-Amz-Date`) and may include a session token if
using temporary credentials.
## Configuration
The plugin presents the following fields:
- **Access Key ID** – Your AWS access key identifier
- **Secret Access Key** – The secret associated with the access key
- **Session Token** _(optional)_ – Used for temporary or assumed-role credentials (treated as secret)
- **Region** – AWS region (e.g., `us-east-1`)
- **Service** – AWS service identifier (e.g., `sts`, `s3`, `execute-api`)
## Usage
1. Configure a request, folder, or workspace to use **AWS SigV4 Authentication**
2. Enter your AWS credentials and target service/region
3. The plugin will automatically sign outgoing requests with valid SigV4 headers

View File

@@ -0,0 +1,22 @@
{
"name": "@yaak/auth-aws",
"displayName": "AWS SigV4",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using AWS SigV4 signing",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-aws"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"aws4": "^1.13.2"
},
"devDependencies": {
"@types/aws4": "^1.11.6"
}
}

View File

@@ -0,0 +1,89 @@
import { URL } from "node:url";
import type { PluginDefinition } from "@yaakapp/api";
import type { CallHttpAuthenticationResponse } from "@yaakapp-internal/plugins";
import type { Request } from "aws4";
import aws4 from "aws4";
export const plugin: PluginDefinition = {
authentication: {
name: "awsv4",
label: "AWS Signature",
shortLabel: "AWS v4",
args: [
{ name: "accessKeyId", label: "Access Key ID", type: "text", password: true },
{
name: "secretAccessKey",
label: "Secret Access Key",
type: "text",
password: true,
},
{
name: "service",
label: "Service Name",
type: "text",
defaultValue: "sts",
placeholder: "sts",
description: "The service that is receiving the request (sts, s3, sqs, ...)",
},
{
name: "region",
label: "Region",
type: "text",
placeholder: "us-east-1",
description: "The region that is receiving the request (defaults to us-east-1)",
optional: true,
},
{
name: "sessionToken",
label: "Session Token",
type: "text",
password: true,
optional: true,
description: "Only required if you are using temporary credentials",
},
],
onApply(_ctx, { values, ...args }): CallHttpAuthenticationResponse {
const accessKeyId = String(values.accessKeyId || "");
const secretAccessKey = String(values.secretAccessKey || "");
const sessionToken = String(values.sessionToken || "") || undefined;
const url = new URL(args.url);
const headers: NonNullable<Request["headers"]> = {};
for (const headerName of ["content-type", "host", "x-amz-date", "x-amz-security-token"]) {
const v = args.headers.find((h) => h.name.toLowerCase() === headerName);
if (v != null) {
headers[headerName] = v.value;
}
}
const signature = aws4.sign(
{
host: url.host,
method: args.method,
path: url.pathname + (url.search || ""),
service: String(values.service || "sts"),
region: values.region ? String(values.region) : undefined,
headers,
body: args.body ?? undefined,
doNotEncodePath: true,
},
{
accessKeyId,
secretAccessKey,
sessionToken,
},
);
if (signature.headers == null) {
return {};
}
return {
setHeaders: Object.entries(signature.headers)
.filter(([name]) => name !== "content-type") // Don't add this because we already have it
.map(([name, value]) => ({ name, value: String(value || "") })),
};
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,44 @@
# Basic Authentication
A simple Basic Authentication plugin that implements HTTP Basic Auth according
to [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617), enabling secure
authentication with username and password credentials.
![Screenshot of basic auth UI](screenshot.png)
## Overview
This plugin provides HTTP Basic Authentication support for API requests in Yaak. Basic
Auth is one of the most widely supported authentication methods, making it ideal for APIs
that require simple username/password authentication without the complexity of OAuth
flows.
## How Basic Authentication Works
Basic Authentication encodes your username and password credentials using Base64 encoding
and sends them in the `Authorization` header with each request. The format is:
```
Authorization: Basic <base64-encoded-credentials>
```
Where `<base64-encoded-credentials>` is the Base64 encoding of `username:password`.
## Configuration
The plugin presents two fields:
- **Username**: Username or user identifier
- **Password**: Password or authentication token
## Usage
1. Configure the request, folder, or workspace to use Basic Authentication
2. Enter your username and password in the authentication configuration
3. The plugin will automatically add the proper `Authorization` header to your requests
## Troubleshooting
- **401 Unauthorized**: Verify your username and password are correct
- **403 Forbidden**: Check if your account has the necessary permissions
- **Connection Issues**: Ensure you're using HTTPS for secure transmission

View File

@@ -0,0 +1,17 @@
{
"name": "@yaak/auth-basic",
"displayName": "Basic Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using Basic Auth",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-basic"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}

View File

@@ -0,0 +1,30 @@
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
authentication: {
name: "basic",
label: "Basic Auth",
shortLabel: "Basic",
args: [
{
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
],
async onApply(_ctx, { values }) {
const username = values.username ?? "";
const password = values.password ?? "";
const value = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
return { setHeaders: [{ name: "Authorization", value }] };
},
},
};

View File

@@ -0,0 +1,87 @@
import type { Context } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { plugin } from "../src";
const ctx = {} as Context;
describe("auth-basic", () => {
test("Both username and password", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: "user", password: "pass" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from("user:pass").toString("base64")}` },
],
});
});
test("Empty password", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: "apikey", password: "" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from("apikey:").toString("base64")}` },
],
});
});
test("Missing password (undefined)", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: "apikey" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from("apikey:").toString("base64")}` },
],
});
});
test("Missing username (undefined)", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { password: "secret" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from(":secret").toString("base64")}` },
],
});
});
test("No values (both undefined)", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({
setHeaders: [
{ name: "Authorization", value: `Basic ${Buffer.from(":").toString("base64")}` },
],
});
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,47 @@
# Bearer Token Authentication Plugin
A Bearer Token authentication plugin for Yaak that
implements [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750), enabling secure API
access using tokens, API keys, and other bearer credentials.
![Screenshot of bearer auth UI](screenshot.png)
## Overview
This plugin provides Bearer Token authentication support for your API requests in Yaak.
Bearer Token authentication is widely used in modern APIs, especially those following REST
principles and OAuth 2.0 standards. It's the preferred method for APIs that issue access
tokens, API keys, or other bearer credentials.
## How Bearer Token Authentication Works
Bearer Token authentication sends your token in the `Authorization` header with each
request using the Bearer scheme:
```
Authorization: Bearer <your-token>
```
The token is transmitted as-is without any additional encoding, making it simple and
efficient for API authentication.
## Configuration
The plugin requires only one field:
- **Token**: Your bearer token, access token, API key, or other credential
- **Prefix**: The prefix to use for the Authorization header, which will be of the
format "<PREFIX> <TOKEN>"
## Usage
1. Configure the request, folder, or workspace to use Bearer Authentication
2. Enter the token and optional prefix in the authentication configuration
3. The plugin will automatically add the proper `Authorization` header to your requests
## Troubleshooting
- **401 Unauthorized**: Verify your token is valid and not expired
- **403 Forbidden**: Check if your token has the necessary permissions/scopes
- **Invalid Token Format**: Ensure you're using the complete token without truncation
- **Token Expiration**: Refresh or regenerate expired tokens

View File

@@ -0,0 +1,17 @@
{
"name": "@yaak/auth-bearer",
"displayName": "Bearer Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using bearer authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-bearer"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}

View File

@@ -0,0 +1,39 @@
import type { PluginDefinition } from "@yaakapp/api";
import type { CallHttpAuthenticationRequest } from "@yaakapp-internal/plugins";
export const plugin: PluginDefinition = {
authentication: {
name: "bearer",
label: "Bearer Token",
shortLabel: "Bearer",
args: [
{
type: "text",
name: "token",
label: "Token",
optional: true,
password: true,
},
{
type: "text",
name: "prefix",
label: "Prefix",
optional: true,
placeholder: "",
defaultValue: "Bearer",
description:
'The prefix to use for the Authorization header, which will be of the format "<PREFIX> <TOKEN>".',
},
],
async onApply(_ctx, { values }) {
return { setHeaders: [generateAuthorizationHeader(values)] };
},
},
};
function generateAuthorizationHeader(values: CallHttpAuthenticationRequest["values"]) {
const token = String(values.token || "").trim();
const prefix = String(values.prefix || "").trim();
const value = `${prefix} ${token}`.trim();
return { name: "Authorization", value };
}

View File

@@ -0,0 +1,67 @@
import type { Context } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { plugin } from "../src";
const ctx = {} as Context;
describe("auth-bearer", () => {
test("No values", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: "Authorization", value: "" }] });
});
test("Only token", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { token: "my-token" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: "Authorization", value: "my-token" }] });
});
test("Only prefix", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { prefix: "Hello" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: "Authorization", value: "Hello" }] });
});
test("Prefix and token", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { prefix: "Hello", token: "my-token" },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: "Authorization", value: "Hello my-token" }] });
});
test("Extra spaces", async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { prefix: "\t Hello ", token: " \nmy-token " },
headers: [],
url: "https://yaak.app",
method: "POST",
contextId: "111",
}),
).toEqual({ setHeaders: [{ name: "Authorization", value: "Hello my-token" }] });
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,56 @@
# Digest Authentication
An HTTP Digest Authentication plugin that implements
[RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616), with fallback to the
older [RFC 2617](https://datatracker.ietf.org/doc/html/rfc2617) and
[RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) behaviour that many
servers still speak.
## Overview
Digest Authentication proves you know a password without ever putting it on the
wire. The server issues a one-time `nonce`, and the client answers with a hash
over the credentials, the nonce and the request itself.
## How it works
Because the digest is computed over a server-issued nonce, the challenge has to
be fetched before the real request can be signed. On each send, the plugin:
1. Sends an unauthenticated probe of the same method and URL, carrying no
headers of its own so it cannot authorize anything
2. Reads the `WWW-Authenticate: Digest …` challenge from the `401` response
3. Computes the response hash and returns the `Authorization` header
## Configuration
- **Username**: Username or user identifier
- **Password**: Password or authentication token
- **Realm** (advanced): Only needed when the server offers more than one realm.
Leave it empty and the first challenge the server prefers is used.
## Supported challenges
| Feature | Supported |
| ---------- | ------------------------------------------------------------- |
| Algorithm | `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess` |
| `qop` | `auth`, `auth-int`, and challenges that omit `qop` (RFC 2069) |
| `opaque` | Echoed back when the server sends one |
| `userhash` | Declined with `userhash=false` |
`auth-int` is used when the server offers it and Yaak has the request body in
hand. Bodies that are streamed from disk or above the size Yaak passes to auth
plugins aren't available to hash, so those requests use `auth` instead.
Credentials are normalized to Unicode NFC before hashing, per RFC 7616 §4.
Usernames outside ASCII are sent as an RFC 5987 extended value (`username*`).
## Troubleshooting
- **Server did not offer Digest authentication**: The endpoint answered the probe
with a different scheme, or with no `WWW-Authenticate` header at all. Check the
URL, and whether the endpoint requires auth in the first place.
- **Unsupported Digest algorithm**: The server asked for an algorithm this plugin
doesn't implement, such as `SHA-512-256`.
- **401 Unauthorized**: Verify the username and password. If the server offers
several realms, set the Realm field to the one your account belongs to.

View File

@@ -0,0 +1,17 @@
{
"name": "@yaak/auth-digest",
"displayName": "Digest Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using HTTP Digest authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-digest"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}

View File

@@ -0,0 +1,294 @@
import { createHash } from "node:crypto";
/** A single challenge from a `WWW-Authenticate` header. */
export interface AuthChallenge {
scheme: string;
params: Record<string, string>;
/** The `token68` form (`NTLM TlRMTVNT…`), which carries no parameters. */
token68?: string;
}
export interface DigestChallenge {
realm: string;
nonce: string;
opaque?: string;
qop?: string[];
/** Echoed back verbatim, so it must keep the server's own spelling. */
algorithm?: string;
stale: boolean;
userhash: boolean;
}
export interface DigestAuthorizationOptions {
username: string;
password: string;
method: string;
uri: string;
body: string | null;
challenge: DigestChallenge;
cnonce: string;
nc: number;
}
const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
const PARAM_RE = new RegExp(`^(${TOKEN})\\s*=\\s*([\\s\\S]*)$`);
const SCHEME_RE = new RegExp(`^(${TOKEN})(?:\\s+([\\s\\S]*))?$`);
const TOKEN68_RE = /^[A-Za-z0-9\-._~+/]+=*$/;
const SUPPORTED_ALGORITHMS = ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"];
const SUPPORTED_QOPS = ["auth", "auth-int"];
/**
* Split a header value on commas that aren't inside a quoted string. Both
* challenges and their parameters are comma-separated, so this yields a flat
* list that {@link parseChallenges} re-groups.
*/
function splitOnCommas(value: string): string[] {
const parts: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < value.length; i++) {
const char = value[i]!;
if (quoted && char === "\\" && i + 1 < value.length) {
current += char + value[++i]!;
} else if (char === '"') {
quoted = !quoted;
current += char;
} else if (char === "," && !quoted) {
parts.push(current);
current = "";
} else {
current += char;
}
}
parts.push(current);
return parts.map((p) => p.trim()).filter((p) => p !== "");
}
function unquote(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\([\s\S])/g, "$1");
}
return trimmed;
}
export function parseChallenges(headerValues: string[]): AuthChallenge[] {
const challenges: AuthChallenge[] = [];
for (const headerValue of headerValues) {
let current: AuthChallenge | null = null;
for (const part of splitOnCommas(headerValue)) {
const param = PARAM_RE.exec(part);
if (param != null && current != null) {
current.params[param[1]!.toLowerCase()] = unquote(param[2]!);
continue;
}
const scheme = SCHEME_RE.exec(part);
if (scheme == null) continue;
current = { scheme: scheme[1]!, params: {} };
challenges.push(current);
const rest = scheme[2]?.trim();
if (rest == null || rest === "") continue;
if (TOKEN68_RE.test(rest)) {
current.token68 = rest;
continue;
}
const firstParam = PARAM_RE.exec(rest);
if (firstParam != null) {
current.params[firstParam[1]!.toLowerCase()] = unquote(firstParam[2]!);
}
}
}
return challenges;
}
export function toDigestChallenge(params: Record<string, string>): DigestChallenge {
const qop = params.qop
?.split(",")
.map((v) => v.trim().toLowerCase())
.filter(Boolean);
return {
realm: params.realm ?? "",
nonce: params.nonce ?? "",
opaque: params.opaque,
qop: qop == null || qop.length === 0 ? undefined : qop,
algorithm: params.algorithm,
stale: params.stale?.toLowerCase() === "true",
userhash: params.userhash?.toLowerCase() === "true",
};
}
/**
* `MD5`, `MD5-sess`, `SHA-256` and `SHA-256-sess`, tolerating the `SHA256`
* spelling some servers use. Returns null for anything else.
*/
function resolveAlgorithm(algorithm: string | undefined): { hash: string; sess: boolean } | null {
const value = (algorithm ?? "MD5").trim().toLowerCase();
const sess = value.endsWith("-sess");
const base = (sess ? value.slice(0, -"-sess".length) : value).replace(/-/g, "");
if (base === "md5") return { hash: "md5", sess };
if (base === "sha256") return { hash: "sha256", sess };
return null;
}
function unsupportedAlgorithmError(algorithm: string | undefined): Error {
return new Error(
`Unsupported Digest algorithm: ${algorithm ?? "MD5"}. ` +
`Supported algorithms are ${SUPPORTED_ALGORITHMS.join(", ")}`,
);
}
function unsupportedQopError(qop: string[]): Error {
return new Error(
`Unsupported Digest qop: ${qop.join(", ")}. Supported values are ${SUPPORTED_QOPS.join(" and ")}`,
);
}
/**
* Everything that would stop this challenge from being answered, or null if it
* can be. Selection asks the whole question at once so a challenge that fails
* on any count is passed over for the next one the server offered, rather than
* chosen and then failed on later.
*/
function challengeProblem(challenge: DigestChallenge): Error | null {
if (resolveAlgorithm(challenge.algorithm) == null) {
return unsupportedAlgorithmError(challenge.algorithm);
}
if (challenge.nonce === "") {
return new Error('Digest challenge is missing the required "nonce" parameter');
}
if (challenge.qop != null && !challenge.qop.some((q) => SUPPORTED_QOPS.includes(q))) {
return unsupportedQopError(challenge.qop);
}
return null;
}
/**
* Pick the challenge to answer. Servers list challenges strongest-first
* (RFC 7616 §3.7), so the first one we can compute is the one to use.
*/
export function selectDigestChallenge(
challenges: AuthChallenge[],
realm?: string,
): DigestChallenge {
const digestChallenges = challenges.filter((c) => c.scheme.toLowerCase() === "digest");
if (digestChallenges.length === 0) {
const offered = challenges.map((c) => c.scheme).join(", ");
throw new Error(
offered === ""
? "Server did not offer Digest authentication (no WWW-Authenticate header in the response)"
: `Server did not offer Digest authentication. It offered: ${offered}`,
);
}
const inRealm =
realm == null || realm === ""
? digestChallenges
: digestChallenges.filter((c) => c.params.realm === realm);
if (inRealm.length === 0) {
const offered = digestChallenges.map((c) => JSON.stringify(c.params.realm ?? "")).join(", ");
throw new Error(`Server did not offer a Digest realm named "${realm}". It offered: ${offered}`);
}
const candidates = inRealm.map((c) => toDigestChallenge(c.params));
const answerable = candidates.find((c) => challengeProblem(c) == null);
if (answerable == null) throw challengeProblem(candidates[0]!);
return answerable;
}
/**
* Prefer `auth-int` only when the body is in hand, since its digest covers the
* exact bytes sent. A body offered as `null` is either an empty one or one Yaak
* didn't hand over (too large, or streamed from a file), and the two are
* indistinguishable from here. When `auth-int` is all the server offers it is
* still used, hashing the empty body: that is exactly right for the empty case
* and no worse than refusing outright for the other.
*/
function selectQop(qop: string[], body: string | null): "auth" | "auth-int" {
if (qop.includes("auth-int") && (body != null || !qop.includes("auth"))) return "auth-int";
if (qop.includes("auth")) return "auth";
throw unsupportedQopError(qop);
}
function quote(value: string): string {
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
}
/** RFC 5987 `ext-value`, used for usernames that a quoted-string can't carry. */
function encodeExtended(value: string): string {
const encoded = encodeURIComponent(value).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `UTF-8''${encoded}`;
}
export function buildDigestAuthorization(options: DigestAuthorizationOptions): string {
const { method, uri, body, challenge, cnonce, nc } = options;
// RFC 7616 §4 hashes credentials in Normalization Form C, so a name typed as
// a combining sequence digests the same as its precomposed spelling.
const username = options.username.normalize("NFC");
const password = options.password.normalize("NFC");
const algorithm = resolveAlgorithm(challenge.algorithm);
if (algorithm == null) throw unsupportedAlgorithmError(challenge.algorithm);
const hash = (value: string) => createHash(algorithm.hash).update(value, "utf8").digest("hex");
const qop = challenge.qop == null ? null : selectQop(challenge.qop, body);
const ncHex = nc.toString(16).padStart(8, "0");
const secret = hash(`${username}:${challenge.realm}:${password}`);
const ha1 = algorithm.sess ? hash(`${secret}:${challenge.nonce}:${cnonce}`) : secret;
const ha2 =
qop === "auth-int" ? hash(`${method}:${uri}:${hash(body ?? "")}`) : hash(`${method}:${uri}`);
// Without qop the server speaks RFC 2069, where the client contributes nothing
// to the digest and so must not send cnonce, nc or qop back.
const response =
qop == null
? hash(`${ha1}:${challenge.nonce}:${ha2}`)
: hash(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`);
const params: string[] = [];
params.push(
/^[\x20-\x7E]*$/.test(username)
? `username=${quote(username)}`
: `username*=${encodeExtended(username)}`,
);
params.push(`realm=${quote(challenge.realm)}`);
params.push(`uri=${quote(uri)}`);
if (challenge.algorithm != null) params.push(`algorithm=${challenge.algorithm}`);
params.push(`nonce=${quote(challenge.nonce)}`);
if (qop != null) {
params.push(`nc=${ncHex}`);
params.push(`cnonce=${quote(cnonce)}`);
params.push(`qop=${qop}`);
}
params.push(`response=${quote(response)}`);
if (challenge.opaque != null) params.push(`opaque=${quote(challenge.opaque)}`);
if (challenge.userhash) params.push("userhash=false");
return `Digest ${params.join(", ")}`;
}
/** The origin-form request-target the digest is computed over. */
export function requestTarget(url: string): string {
const absolute = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url) ? url : `http://${url}`;
const parsed = new URL(absolute);
return `${parsed.pathname}${parsed.search}`;
}

View File

@@ -0,0 +1,75 @@
import { randomBytes } from "node:crypto";
import type { PluginDefinition } from "@yaakapp/api";
import {
buildDigestAuthorization,
parseChallenges,
requestTarget,
selectDigestChallenge,
} from "./digest";
export const plugin: PluginDefinition = {
authentication: {
name: "digest",
label: "Digest Auth",
shortLabel: "Digest",
args: [
{
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
{
type: "accordion",
label: "Advanced",
inputs: [
{
type: "text",
name: "realm",
label: "Realm",
optional: true,
description: "Only needed when the server offers more than one realm",
},
],
},
],
async onApply(ctx, { values, method, url, body }) {
const username = values.username ? String(values.username) : "";
const password = values.password ? String(values.password) : "";
const realm = values.realm ? String(values.realm) : undefined;
// Digest needs a server-issued nonce, so the challenge has to be provoked
// before the real request can be signed. The probe carries nothing but the
// method and URL: a cookie or an API key header would let it authorize the
// very operation it is only meant to ask permission for, and there is no
// telling a routing header from a credential by looking at it.
const { httpResponse } = await ctx.httpRequest.send({ httpRequest: { method, url } });
const headerValues = httpResponse.headers
.filter((h) => h.name.toLowerCase() === "www-authenticate")
.map((h) => h.value);
const challenge = selectDigestChallenge(parseChallenges(headerValues), realm);
const value = buildDigestAuthorization({
username,
password,
method,
uri: requestTarget(url),
body,
challenge,
cnonce: randomBytes(16).toString("hex"),
nc: 1,
});
return { setHeaders: [{ name: "Authorization", value }] };
},
},
};

View File

@@ -0,0 +1,390 @@
import { describe, expect, test } from "vite-plus/test";
import {
buildDigestAuthorization,
parseChallenges,
requestTarget,
selectDigestChallenge,
toDigestChallenge,
} from "../src/digest";
function paramOf(header: string, name: string): string | undefined {
return parseChallenges([header])[0]?.params[name];
}
describe("parseChallenges", () => {
test("parses quoted and unquoted parameters", () => {
expect(parseChallenges(['Digest realm="test", algorithm=MD5, stale=TRUE'])).toEqual([
{ scheme: "Digest", params: { realm: "test", algorithm: "MD5", stale: "TRUE" } },
]);
});
test("keeps commas and escapes inside quoted values", () => {
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "qop")).toEqual(
"auth,auth-int",
);
expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "realm")).toEqual(
'a "quoted" realm',
);
});
test("tolerates whitespace around the equals sign", () => {
expect(paramOf('Digest realm = "test"', "realm")).toEqual("test");
});
test("splits multiple challenges in a single header", () => {
expect(parseChallenges(['Basic realm="a", Digest realm="b", nonce="n"'])).toEqual([
{ scheme: "Basic", params: { realm: "a" } },
{ scheme: "Digest", params: { realm: "b", nonce: "n" } },
]);
});
test("collects challenges across repeated headers", () => {
expect(parseChallenges(['Digest realm="a"', "Negotiate"]).map((c) => c.scheme)).toEqual([
"Digest",
"Negotiate",
]);
});
test("captures token68 credentials rather than reading them as parameters", () => {
expect(parseChallenges(["NTLM TlRMTVNTUAACAAAAAA=="])).toEqual([
{ scheme: "NTLM", params: {}, token68: "TlRMTVNTUAACAAAAAA==" },
]);
});
test("lower-cases parameter names", () => {
expect(paramOf('Digest Realm="test", NONCE="n"', "realm")).toEqual("test");
expect(paramOf('Digest Realm="test", NONCE="n"', "nonce")).toEqual("n");
});
});
describe("toDigestChallenge", () => {
test("splits qop and reads the boolean flags", () => {
const challenge = toDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=" auth , AUTH-INT ", stale=true'])[0]!
.params,
);
expect(challenge.qop).toEqual(["auth", "auth-int"]);
expect(challenge.stale).toBe(true);
expect(challenge.userhash).toBe(false);
});
test("treats a missing qop as absent rather than empty", () => {
expect(toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])[0]!.params).qop).toBe(
undefined,
);
});
test("reads userhash", () => {
expect(
toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n", userhash=TRUE'])[0]!.params)
.userhash,
).toBe(true);
});
});
describe("selectDigestChallenge", () => {
const md5 = 'Digest realm="a", nonce="n1", algorithm=MD5';
const sha = 'Digest realm="b", nonce="n2", algorithm=SHA-256';
test("takes the first Digest challenge the server prefers", () => {
expect(selectDigestChallenge(parseChallenges([sha, md5])).nonce).toEqual("n2");
});
test("skips challenges whose algorithm is not supported", () => {
const unsupported = 'Digest realm="c", nonce="n0", algorithm=SHA-512-256';
expect(selectDigestChallenge(parseChallenges([unsupported, md5])).nonce).toEqual("n1");
});
test("skips challenges whose qop cannot be answered", () => {
const unanswerable = 'Digest realm="c", nonce="n0", qop="auth-conf"';
expect(selectDigestChallenge(parseChallenges([unanswerable, md5])).nonce).toEqual("n1");
});
test("skips challenges that carry no nonce", () => {
expect(selectDigestChallenge(parseChallenges(['Digest realm="c"', md5])).nonce).toEqual("n1");
});
test("reports the first challenge's problem when none can be answered", () => {
expect(() =>
selectDigestChallenge(
parseChallenges(['Digest realm="c", nonce="n", qop="auth-conf"', 'Digest realm="d"']),
),
).toThrow("Unsupported Digest qop: auth-conf");
});
test("matches the case-insensitive scheme name", () => {
expect(selectDigestChallenge(parseChallenges(['digest realm="a", nonce="n1"'])).nonce).toEqual(
"n1",
);
});
test("selects by realm when one is given", () => {
expect(selectDigestChallenge(parseChallenges([sha, md5]), "a").nonce).toEqual("n1");
});
test("errors when the requested realm is not offered", () => {
expect(() => selectDigestChallenge(parseChallenges([sha, md5]), "nope")).toThrow(
'Server did not offer a Digest realm named "nope". It offered: "b", "a"',
);
});
test("errors when the server offers no Digest challenge", () => {
expect(() => selectDigestChallenge(parseChallenges(['Basic realm="a"', "Negotiate"]))).toThrow(
"Server did not offer Digest authentication. It offered: Basic, Negotiate",
);
});
test("errors when the response carries no challenge at all", () => {
expect(() => selectDigestChallenge(parseChallenges([]))).toThrow(
"no WWW-Authenticate header in the response",
);
});
test("errors when no offered algorithm is supported", () => {
expect(() =>
selectDigestChallenge(
parseChallenges(['Digest realm="c", nonce="n", algorithm=SHA-512-256']),
),
).toThrow("Unsupported Digest algorithm: SHA-512-256");
});
test("errors when the challenge has no nonce", () => {
expect(() => selectDigestChallenge(parseChallenges(['Digest realm="c"']))).toThrow(
'Digest challenge is missing the required "nonce" parameter',
);
});
});
// https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
describe("RFC 7616 §3.9.1 worked example", () => {
const headers = [
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=SHA-256, ' +
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=MD5, ' +
'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
];
const common = {
username: "Mufasa",
password: "Circle of Life",
method: "GET",
uri: "/dir/index.html",
body: null,
cnonce: "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
nc: 1,
};
test("SHA-256", () => {
expect(
buildDigestAuthorization({
...common,
challenge: selectDigestChallenge(parseChallenges(headers)),
}),
).toEqual(
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
'algorithm=SHA-256, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
);
});
test("MD5", () => {
expect(
buildDigestAuthorization({
...common,
challenge: selectDigestChallenge(parseChallenges([headers[1]!])),
}),
).toEqual(
'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
'algorithm=MD5, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
'response="8ca523f5e9506fed4657c9700eebdbec", ' +
'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
);
});
});
// https://datatracker.ietf.org/doc/html/rfc2617#section-3.5
describe("RFC 2617 §3.5 worked example", () => {
test("MD5 with qop=auth", () => {
const challenge = selectDigestChallenge(
parseChallenges([
'Digest realm="testrealm@host.com", qop="auth,auth-int", ' +
'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
]),
);
expect(
buildDigestAuthorization({
username: "Mufasa",
password: "Circle Of Life",
method: "GET",
uri: "/dir/index.html",
body: null,
challenge,
cnonce: "0a4f113b",
nc: 1,
}),
).toContain('response="6629fae49393a05397450978507c4ef1"');
});
});
describe("buildDigestAuthorization", () => {
const base = {
username: "user",
password: "pass",
method: "POST",
uri: "/api",
body: null as string | null,
cnonce: "abc123",
nc: 1,
};
test("omits the client's contribution when the server offers no qop", () => {
const header = buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
});
expect(header).not.toContain("qop=");
expect(header).not.toContain("cnonce=");
expect(header).not.toContain("nc=");
// MD5(HA1:nonce:HA2), per RFC 2069.
expect(header).toContain('response="24644771b8983deed818b83aeb3ac381"');
});
test("omits algorithm when the challenge did not name one", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
}),
).not.toContain("algorithm=");
});
test("uses auth-int over the body when the server offers it", () => {
const header = buildDigestAuthorization({
...base,
body: '{"a":1}',
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
),
});
expect(header).toContain("qop=auth-int");
});
test("falls back to auth when no body was handed over", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
),
}),
).toContain("qop=auth");
});
test("uses auth-int over an empty body when it is the only qop offered", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth-int"']),
),
}),
).toContain("qop=auth-int");
});
test("rejects a qop it cannot compute", () => {
expect(() =>
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop="auth-conf"']),
),
}),
).toThrow("Unsupported Digest qop: auth-conf");
});
test("mixes the cnonce into HA1 for -sess algorithms", () => {
const sess = buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5-sess']),
),
});
const plain = buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5']),
),
});
expect(sess).toContain("algorithm=MD5-sess");
expect(sess).not.toEqual(plain);
});
test("declines userhash when the server advertises it", () => {
expect(
buildDigestAuthorization({
...base,
challenge: selectDigestChallenge(
parseChallenges(['Digest realm="r", nonce="n", qop=auth, userhash=true']),
),
}),
).toContain("userhash=false");
});
test("escapes quotes in the credentials it echoes back", () => {
expect(
buildDigestAuthorization({
...base,
username: 'a"b',
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
}),
).toContain('username="a\\"b"');
});
test("normalizes credentials to NFC before hashing", () => {
const challenge = selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"']));
// "Jäsøn" spelled with a combining diaeresis rather than a precomposed "ä".
const decomposed = buildDigestAuthorization({
...base,
username: "Ja\u0308s\u00f8n",
password: "pa\u0308ss",
challenge,
});
const precomposed = buildDigestAuthorization({
...base,
username: "J\u00e4s\u00f8n",
password: "p\u00e4ss",
challenge,
});
expect(decomposed).toEqual(precomposed);
});
test("sends a non-ASCII username as an RFC 5987 extended value", () => {
expect(
buildDigestAuthorization({
...base,
username: "Jäsøn Doe",
challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
}),
).toContain("username*=UTF-8''J%C3%A4s%C3%B8n%20Doe");
});
});
describe("requestTarget", () => {
test("keeps the path and query", () => {
expect(requestTarget("https://example.org/dir/index.html?a=b&c=d")).toEqual(
"/dir/index.html?a=b&c=d",
);
});
test("uses a bare slash when there is no path", () => {
expect(requestTarget("https://example.org")).toEqual("/");
});
test("handles a URL with no scheme", () => {
expect(requestTarget("localhost:8080/thing")).toEqual("/thing");
});
});

View File

@@ -0,0 +1,375 @@
import { createHash } from "node:crypto";
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import type { Context } from "@yaakapp/api";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
import { plugin } from "../src";
function apply(ctx: Context, values: Record<string, string>, over: Partial<ApplyArgs> = {}) {
return plugin.authentication!.onApply(ctx, {
values,
headers: [],
url: "https://example.org/dir/index.html?a=b",
method: "GET",
body: null,
contextId: "ctx",
...over,
});
}
type ApplyArgs = Parameters<NonNullable<typeof plugin.authentication>["onApply"]>[1];
function ctxRespondingWith(headers: Array<{ name: string; value: string }>): {
ctx: Context;
send: ReturnType<typeof vi.fn>;
} {
const send = vi.fn().mockResolvedValue({ httpResponse: { headers } });
return { ctx: { httpRequest: { send } } as unknown as Context, send };
}
describe("auth-digest onApply", () => {
test("probes with the same method and URL, without credentials or body", async () => {
const { ctx, send } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
await apply(ctx, { username: "user", password: "pass" }, { method: "POST", body: "hello" });
expect(send).toHaveBeenCalledWith({
httpRequest: { method: "POST", url: "https://example.org/dir/index.html?a=b" },
});
});
test("keeps credential-bearing headers off the probe", async () => {
const { ctx, send } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
await apply(
ctx,
{ username: "user", password: "pass" },
{
method: "DELETE",
headers: [
{ name: "Cookie", value: "session=abc" },
{ name: "X-Api-Key", value: "secret" },
{ name: "Authorization", value: "Bearer stale" },
],
},
);
expect(send).toHaveBeenCalledWith({
httpRequest: { method: "DELETE", url: "https://example.org/dir/index.html?a=b" },
});
});
test("signs the request-target rather than the whole URL", async () => {
const { ctx } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
const result = await apply(ctx, { username: "user", password: "pass" });
expect(result.setHeaders?.[0]?.name).toEqual("Authorization");
expect(result.setHeaders?.[0]?.value).toContain('uri="/dir/index.html?a=b"');
});
test("uses a fresh cnonce on every apply", async () => {
const { ctx } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
]);
const first = await apply(ctx, { username: "user", password: "pass" });
const second = await apply(ctx, { username: "user", password: "pass" });
expect(first.setHeaders?.[0]?.value).not.toEqual(second.setHeaders?.[0]?.value);
});
test("treats missing credentials as empty strings", async () => {
const { ctx } = ctxRespondingWith([
{ name: "www-authenticate", value: 'Digest realm="r", nonce="n"' },
]);
expect((await apply(ctx, {})).setHeaders?.[0]?.value).toContain('username=""');
});
test("fails clearly when the server does not offer Digest", async () => {
const { ctx } = ctxRespondingWith([{ name: "WWW-Authenticate", value: 'Basic realm="r"' }]);
await expect(apply(ctx, { username: "user", password: "pass" })).rejects.toThrow(
"Server did not offer Digest authentication. It offered: Basic",
);
});
test("selects the realm the user configured", async () => {
const { ctx } = ctxRespondingWith([
{ name: "WWW-Authenticate", value: 'Digest realm="one", nonce="n1"' },
{ name: "WWW-Authenticate", value: 'Digest realm="two", nonce="n2"' },
]);
expect(
(await apply(ctx, { username: "user", password: "pass", realm: "two" })).setHeaders?.[0]
?.value,
).toContain('nonce="n2"');
});
});
function nextComma(value: string, from: number): number {
const index = value.indexOf(",", from);
return index < 0 ? value.length : index;
}
/**
* Read `Digest name=value, name="value"` credentials by scanning, rather than
* with a global regex: a repeated character class in front of the `=` backtracks
* quadratically over a long run of the characters it accepts.
*/
function parseCredentials(header: string): Record<string, string> {
const params: Record<string, string> = {};
let i = header.indexOf(" ") + 1;
while (i < header.length) {
const equals = header.indexOf("=", i);
if (equals < 0) break;
const name = header.slice(i, equals).trim().toLowerCase();
i = equals + 1;
let value = "";
if (header[i] === '"') {
for (i++; i < header.length && header[i] !== '"'; i++) {
if (header[i] === "\\") i++;
value += header[i];
}
i++;
} else {
const end = nextComma(header, i);
value = header.slice(i, end).trim();
i = end;
}
params[name] = value;
i = nextComma(header, i) + 1;
}
return params;
}
/**
* A minimally correct Digest server, hashing inline rather than through the
* plugin's own helpers so the round trip can't agree with itself on a mistake.
*/
function startDigestServer(config: {
username: string;
password: string;
realm: string;
nonce: string;
algorithm?: string;
qop?: string;
/** Offer a second, unrelated realm ahead of the real one. */
decoyRealm?: string;
}): Promise<{ url: string; close: () => Promise<void> }> {
const hashName = (config.algorithm ?? "MD5").toLowerCase().startsWith("sha-256")
? "sha256"
: "md5";
const sess = (config.algorithm ?? "").toLowerCase().endsWith("-sess");
const hash = (value: string) => createHash(hashName).update(value, "utf8").digest("hex");
const server: Server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const authorization = req.headers.authorization;
if (authorization == null || !authorization.startsWith("Digest ")) {
const challenge = [
`Digest realm="${config.realm}"`,
`nonce="${config.nonce}"`,
`algorithm=${config.algorithm ?? "MD5"}`,
config.qop == null ? null : `qop="${config.qop}"`,
'opaque="0p4qu3"',
]
.filter(Boolean)
.join(", ");
res.setHeader(
"WWW-Authenticate",
config.decoyRealm == null
? [challenge]
: [
`Digest realm="${config.decoyRealm}", nonce="wrong-nonce", algorithm=MD5`,
challenge,
],
);
res.writeHead(401).end("unauthorized");
return;
}
const params = parseCredentials(authorization);
const secret = hash(`${config.username}:${config.realm}:${config.password}`);
const ha1 = sess ? hash(`${secret}:${params.nonce}:${params.cnonce}`) : secret;
const ha2 =
params.qop === "auth-int"
? hash(`${req.method}:${params.uri}:${hash(Buffer.concat(chunks).toString("utf8"))}`)
: hash(`${req.method}:${params.uri}`);
const expected =
params.qop == null
? hash(`${ha1}:${params.nonce}:${ha2}`)
: hash(`${ha1}:${params.nonce}:${params.nc}:${params.cnonce}:${params.qop}:${ha2}`);
const ok =
params.username === config.username &&
params.realm === config.realm &&
params.nonce === config.nonce &&
params.uri === req.url &&
params.opaque === "0p4qu3" &&
params.response === expected;
res.writeHead(ok ? 200 : 401).end(ok ? "welcome" : "denied");
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as AddressInfo;
resolve({
url: `http://127.0.0.1:${port}`,
close: () => new Promise<void>((done) => server.close(() => done())),
});
});
});
}
/** Sends for real, so the plugin sees the headers a live server actually returns. */
function realContext(): Context {
return {
httpRequest: {
async send({ httpRequest }: { httpRequest: { method?: string; url?: string } }) {
const res = await fetch(httpRequest.url!, { method: httpRequest.method });
await res.text();
return {
httpResponse: {
headers: [...res.headers].map(([name, value]) => ({ name, value })),
},
};
},
},
} as unknown as Context;
}
describe("auth-digest against a live server", () => {
let close: (() => Promise<void>) | null = null;
afterEach(async () => {
await close?.();
close = null;
});
for (const algorithm of ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"]) {
test(`authenticates with algorithm=${algorithm}`, async () => {
const server = await startDigestServer({
username: "Mufasa",
password: "Circle of Life",
realm: "http-auth@example.org",
nonce: "7ypf/xlj9XXwfDPEoM4URrv",
algorithm,
qop: "auth",
});
close = server.close;
const url = `${server.url}/dir/index.html?a=b`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "Mufasa", password: "Circle of Life" },
headers: [],
url,
method: "GET",
body: null,
contextId: "ctx",
});
const res = await fetch(url, {
headers: { Authorization: result.setHeaders![0]!.value },
});
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
});
}
test("authenticates a POST body with qop=auth-int", async () => {
const body = '{"hello":"world"}';
const server = await startDigestServer({
username: "user",
password: "pass",
realm: "api@example.org",
nonce: "n0nc3",
algorithm: "SHA-256",
qop: "auth,auth-int",
});
close = server.close;
const url = `${server.url}/submit`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "user", password: "pass" },
headers: [],
url,
method: "POST",
body,
contextId: "ctx",
});
expect(result.setHeaders![0]!.value).toContain("qop=auth-int");
const res = await fetch(url, {
method: "POST",
body,
headers: { Authorization: result.setHeaders![0]!.value },
});
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
});
test("authenticates against an RFC 2069 server that offers no qop", async () => {
const server = await startDigestServer({
username: "user",
password: "pass",
realm: "legacy@example.org",
nonce: "old-nonce",
});
close = server.close;
const url = `${server.url}/legacy`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "user", password: "pass" },
headers: [],
url,
method: "GET",
body: null,
contextId: "ctx",
});
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
expect([res.status, await res.text()]).toEqual([200, "welcome"]);
});
test("picks the configured realm out of several the server offers", async () => {
const server = await startDigestServer({
username: "user",
password: "pass",
realm: "second@example.org",
nonce: "n0nc3",
qop: "auth",
decoyRealm: "first@example.org",
});
close = server.close;
const url = `${server.url}/multi`;
const result = await plugin.authentication!.onApply(realContext(), {
values: { username: "user", password: "pass", realm: "second@example.org" },
headers: [],
url,
method: "GET",
body: null,
contextId: "ctx",
});
const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
expect(res.status).toEqual(200);
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,54 @@
# JSON Web Token (JWT) Authentication
A [JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) (JWT) authentication
plugin that supports token generation, signing, and automatic header management.
![Screenshot of JWT auth UI](screenshot.png)
## Overview
This plugin provides JWT authentication support for API requests. JWT is a compact,
URL-safe means of representing claims between two parties, commonly used for
authentication and information exchange in modern web applications and APIs.
## How JWT Authentication Works
JWT authentication involves creating a signed token containing claims about the user or
application. The token is sent in the `Authorization` header:
```
Authorization: Bearer <jwt-token>
```
A JWT consists of three parts separated by dots:
- **Header**: Contains the token type and signing algorithm
- **Payload**: Contains the claims (user data, permissions, expiration, etc.)
- **Signature**: Ensures the token hasn't been tampered with
## Usage
1. Configure the request, folder, or workspace to use JWT Authentication
2. Set up your signing algorithm and secret/key
3. Add custom JWT header fields if needed
4. Configure the required claims for your JWT payload
5. The plugin will generate, sign, and include the JWT in your requests
## Common Use Cases
JWT authentication is commonly used for:
- **Microservices Authentication**: Service-to-service communication
- **API Gateway Integration**: Authenticating with API gateways
- **Single Sign-On (SSO)**: Sharing authentication across applications
- **Stateless Authentication**: No server-side session storage required
- **Mobile App APIs**: Secure authentication for mobile applications
- **Third-party Integrations**: Authenticating with external services
## Troubleshooting
- **Invalid Signature**: Check your secret/key and algorithm configuration
- **Token Expired**: Verify expiration time settings
- **Invalid Claims**: Ensure required claims are properly configured
- **Algorithm Mismatch**: Verify the algorithm matches what the API expects
- **Key Format Issues**: Ensure RSA keys are in the correct PEM format

View File

@@ -0,0 +1,22 @@
{
"name": "@yaak/auth-jwt",
"displayName": "JSON Web Tokens",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using JSON web tokens (JWT)",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-jwt"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.7"
}
}

View File

@@ -0,0 +1,148 @@
import type { PluginDefinition } from "@yaakapp/api";
import jwt from "jsonwebtoken";
const algorithms = [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"none",
] as const;
const defaultAlgorithm = algorithms[0];
export const plugin: PluginDefinition = {
authentication: {
name: "jwt",
label: "JWT Bearer",
shortLabel: "JWT",
args: [
{
type: "select",
name: "algorithm",
label: "Algorithm",
hideLabel: true,
defaultValue: defaultAlgorithm,
options: algorithms.map((value) => ({ label: value === "none" ? "None" : value, value })),
},
{
type: "text",
name: "secret",
label: "Secret or Private Key",
password: true,
optional: true,
multiLine: true,
},
{
type: "checkbox",
name: "secretBase64",
label: "Secret is base64 encoded",
},
{
type: "editor",
name: "payload",
label: "JWT Payload",
language: "json",
defaultValue: '{\n "foo": "bar"\n}',
placeholder: "{ }",
},
{
type: "accordion",
label: "Advanced",
inputs: [
{
type: "editor",
name: "headers",
label: "JWT Header",
description: "Merged with auto-generated header fields like alg (e.g., kid)",
language: "json",
defaultValue: "{}",
placeholder: "{ }",
optional: true,
},
{
type: "select",
name: "location",
label: "Behavior",
defaultValue: "header",
options: [
{ label: "Insert Header", value: "header" },
{ label: "Append Query Parameter", value: "query" },
],
},
{
type: "h_stack",
inputs: [
{
type: "text",
name: "name",
label: "Header Name",
defaultValue: "Authorization",
optional: true,
description: "The name of the header to add to the request",
},
{
type: "text",
name: "headerPrefix",
label: "Header Prefix",
optional: true,
defaultValue: "Bearer",
},
],
dynamic(_ctx, args) {
if (args.values.location === "query") {
return {
hidden: true,
};
}
},
},
{
type: "text",
name: "name",
label: "Parameter Name",
description: "The name of the query parameter to add to the request",
defaultValue: "token",
optional: true,
dynamic(_ctx, args) {
if (args.values.location !== "query") {
return {
hidden: true,
};
}
},
},
],
},
],
async onApply(_ctx, { values }) {
const { algorithm, secret: _secret, secretBase64, payload, headers } = values;
const secret = secretBase64 ? Buffer.from(`${_secret}`, "base64") : `${_secret}`;
const parsedHeaders = headers ? JSON.parse(`${headers}`) : undefined;
const token = jwt.sign(`${payload}`, secret, {
algorithm: algorithm as (typeof algorithms)[number],
// Extra header fields are merged with the auto-generated header (which includes alg)
header: parsedHeaders as jwt.JwtHeader | undefined,
});
if (values.location === "query") {
const paramName = String(values.name || "token");
return { setQueryParameters: [{ name: paramName, value: token }] };
}
const headerPrefix = values.headerPrefix != null ? values.headerPrefix : "Bearer";
const headerName = String(values.name || "Authorization");
const headerValue = `${headerPrefix} ${token}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,20 @@
{
"name": "@yaak/auth-ntlm",
"displayName": "NTLM Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using NTLM authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-ntlm"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"httpntlm": "^1.8.13"
}
}

View File

@@ -0,0 +1,94 @@
import type { PluginDefinition } from "@yaakapp/api";
import { ntlm } from "httpntlm";
function extractNtlmChallenge(headers: Array<{ name: string; value: string }>): string | null {
const authValues = headers
.filter((h) => h.name.toLowerCase() === "www-authenticate")
.flatMap((h) => h.value.split(","))
.map((v) => v.trim())
.filter(Boolean);
return authValues.find((v) => /^NTLM\s+\S+/i.test(v)) ?? null;
}
export const plugin: PluginDefinition = {
authentication: {
name: "windows",
label: "NTLM Auth",
shortLabel: "NTLM",
args: [
{
type: "banner",
color: "info",
inputs: [
{
type: "markdown",
content:
"NTLM is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).",
},
],
},
{
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
{
type: "accordion",
label: "Advanced",
inputs: [
{ name: "domain", label: "Domain", type: "text", optional: true },
{ name: "workstation", label: "Workstation", type: "text", optional: true },
],
},
],
async onApply(ctx, { values, method, url }) {
const username = values.username ? String(values.username) : undefined;
const password = values.password ? String(values.password) : undefined;
const domain = values.domain ? String(values.domain) : undefined;
const workstation = values.workstation ? String(values.workstation) : undefined;
const options = {
url,
username,
password,
workstation,
domain,
};
const type1 = ntlm.createType1Message(options);
const { httpResponse: negotiateResponse } = await ctx.httpRequest.send({
httpRequest: {
method,
url,
headers: [
{ name: "Authorization", value: type1 },
{ name: "Connection", value: "keep-alive" },
],
},
});
const ntlmChallenge = extractNtlmChallenge(negotiateResponse.headers);
if (ntlmChallenge == null) {
throw new Error("Unable to find NTLM challenge in WWW-Authenticate response headers");
}
const type2 = ntlm.parseType2Message(ntlmChallenge, (err: Error | null) => {
if (err != null) throw err;
});
const type3 = ntlm.createType3Message(type2, options);
return { setHeaders: [{ name: "Authorization", value: type3 }] };
},
},
};

1
plugins/auth-ntlm/src/modules.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "httpntlm";

View File

@@ -0,0 +1,89 @@
import type { Context } from "@yaakapp/api";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const ntlmMock = vi.hoisted(() => ({
createType1Message: vi.fn(),
parseType2Message: vi.fn(),
createType3Message: vi.fn(),
}));
vi.mock("httpntlm", () => ({ ntlm: ntlmMock }));
import { plugin } from "../src";
describe("auth-ntlm", () => {
beforeEach(() => {
ntlmMock.createType1Message.mockReset();
ntlmMock.parseType2Message.mockReset();
ntlmMock.createType3Message.mockReset();
ntlmMock.createType1Message.mockReturnValue("NTLM TYPE1");
// oxlint-disable-next-line no-explicit-any
ntlmMock.parseType2Message.mockReturnValue({} as any);
ntlmMock.createType3Message.mockReturnValue("NTLM TYPE3");
});
test("uses NTLM challenge when Negotiate and NTLM headers are separate", async () => {
const send = vi.fn().mockResolvedValue({
httpResponse: {
headers: [
{ name: "WWW-Authenticate", value: "Negotiate" },
{ name: "WWW-Authenticate", value: "NTLM TlRMTVNTUAACAAAAAA==" },
],
},
});
const ctx = { httpRequest: { send } } as unknown as Context;
const result = await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: "https://example.local/resource",
method: "GET",
contextId: "ctx",
});
expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(
"NTLM TlRMTVNTUAACAAAAAA==",
expect.any(Function),
);
expect(result).toEqual({ setHeaders: [{ name: "Authorization", value: "NTLM TYPE3" }] });
});
test("uses NTLM challenge when auth schemes are comma-separated in one header", async () => {
const send = vi.fn().mockResolvedValue({
httpResponse: {
headers: [{ name: "www-authenticate", value: "Negotiate, NTLM TlRMTVNTUAACAAAAAA==" }],
},
});
const ctx = { httpRequest: { send } } as unknown as Context;
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: "https://example.local/resource",
method: "GET",
contextId: "ctx",
});
expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(
"NTLM TlRMTVNTUAACAAAAAA==",
expect.any(Function),
);
});
test("throws a clear error when NTLM challenge is missing", async () => {
const send = vi.fn().mockResolvedValue({
httpResponse: { headers: [{ name: "WWW-Authenticate", value: "Negotiate" }] },
});
const ctx = { httpRequest: { send } } as unknown as Context;
await expect(
plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: "https://example.local/resource",
method: "GET",
contextId: "ctx",
}),
).rejects.toThrow("Unable to find NTLM challenge in WWW-Authenticate response headers");
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,20 @@
{
"name": "@yaak/auth-oauth1",
"displayName": "OAuth 1.0",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using OAuth 1.0a",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-oauth1"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"oauth-1.0a": "^2.2.6"
}
}

View File

@@ -0,0 +1,217 @@
import crypto from "node:crypto";
import type { Context, GetHttpAuthenticationConfigRequest, PluginDefinition } from "@yaakapp/api";
import OAuth from "oauth-1.0a";
const signatures = {
HMAC_SHA1: "HMAC-SHA1",
HMAC_SHA256: "HMAC-SHA256",
HMAC_SHA512: "HMAC-SHA512",
RSA_SHA1: "RSA-SHA1",
RSA_SHA256: "RSA-SHA256",
RSA_SHA512: "RSA-SHA512",
PLAINTEXT: "PLAINTEXT",
} as const;
const defaultSig = signatures.HMAC_SHA1;
const pkSigs = Object.values(signatures).filter((k) => k.startsWith("RSA-"));
const nonPkSigs = Object.values(signatures).filter((k) => !pkSigs.includes(k));
type SigMethod = (typeof signatures)[keyof typeof signatures];
function hiddenIfNot(
sigMethod: SigMethod[],
...other: ((values: GetHttpAuthenticationConfigRequest["values"]) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = sigMethod.find((t) => t === String(values.signatureMethod ?? defaultSig));
const hasOtherBools = other.every((t) => t(values));
const show = hasGrantType && hasOtherBools;
return { hidden: !show };
};
}
export const plugin: PluginDefinition = {
authentication: {
name: "oauth1",
label: "OAuth 1.0",
shortLabel: "OAuth 1",
args: [
{
type: "banner",
color: "info",
inputs: [
{
type: "markdown",
content:
"OAuth 1.0 is still in beta. Please submit any issues to [Feedback](https://yaak.app/feedback).",
},
],
},
{
name: "signatureMethod",
label: "Signature Method",
type: "select",
defaultValue: defaultSig,
options: Object.values(signatures).map((v) => ({ label: v, value: v })),
},
{ name: "consumerKey", label: "Consumer Key", type: "text", password: true, optional: true },
{
name: "consumerSecret",
label: "Consumer Secret",
type: "text",
password: true,
optional: true,
},
{
name: "tokenKey",
label: "Access Token",
type: "text",
password: true,
optional: true,
},
{
name: "tokenSecret",
label: "Token Secret",
type: "text",
password: true,
optional: true,
dynamic: hiddenIfNot(nonPkSigs),
},
{
name: "privateKey",
label: "Private Key (RSA-SHA1)",
type: "text",
multiLine: true,
optional: true,
password: true,
placeholder:
"-----BEGIN RSA PRIVATE KEY-----\nPrivate key in PEM format\n-----END RSA PRIVATE KEY-----",
dynamic: hiddenIfNot(pkSigs),
},
{
type: "accordion",
label: "Advanced",
inputs: [
{ name: "callback", label: "Callback Url", type: "text", optional: true },
{ name: "verifier", label: "Verifier", type: "text", optional: true, password: true },
{ name: "timestamp", label: "Timestamp", type: "text", optional: true },
{ name: "nonce", label: "Nonce", type: "text", optional: true },
{
name: "version",
label: "OAuth Version",
type: "text",
optional: true,
defaultValue: "1.0",
},
{ name: "realm", label: "Realm", type: "text", optional: true },
],
},
],
onApply(
_ctx,
{ values, method, url },
): {
setHeaders?: { name: string; value: string }[];
setQueryParameters?: { name: string; value: string }[];
} {
const consumerKey = String(values.consumerKey || "");
const consumerSecret = String(values.consumerSecret || "");
const signatureMethod = String(values.signatureMethod || signatures.HMAC_SHA1) as SigMethod;
const version = String(values.version || "1.0");
const realm = String(values.realm || "") || undefined;
const oauth = new OAuth({
consumer: { key: consumerKey, secret: consumerSecret },
signature_method: signatureMethod,
version,
hash_function: hashFunction(signatureMethod),
realm,
});
if (pkSigs.includes(signatureMethod)) {
oauth.getSigningKey = (tokenSecret?: string) => tokenSecret || "";
}
const requestUrl = new URL(url);
// Base request options passed to oauth-1.0a
const requestData: Omit<OAuth.RequestOptions, "data"> & {
data: Record<string, string | string[]>;
} = {
method,
url: requestUrl.toString(),
includeBodyHash: false,
data: {},
};
// (1) Include existing query params in signature base string
for (const key of requestUrl.searchParams.keys()) {
if (key.startsWith("oauth_")) continue;
const all = requestUrl.searchParams.getAll(key);
const first = all[0];
if (first == null) continue;
requestData.data[key] = all.length > 1 ? all : first;
}
// (2) Manual oauth_* overrides
if (values.callback) requestData.data.oauth_callback = String(values.callback);
if (values.nonce) requestData.data.oauth_nonce = String(values.nonce);
if (values.timestamp) requestData.data.oauth_timestamp = String(values.timestamp);
if (values.verifier) requestData.data.oauth_verifier = String(values.verifier);
let token: OAuth.Token | { key: string } | { secret: string } | undefined;
if (pkSigs.includes(signatureMethod)) {
token = {
key: String(values.tokenKey || ""),
secret: String(values.privateKey || ""),
};
} else if (values.tokenKey && values.tokenSecret) {
token = { key: String(values.tokenKey), secret: String(values.tokenSecret) };
} else if (values.tokenKey) {
token = { key: String(values.tokenKey) };
} else if (values.tokenSecret) {
// The secret still joins the signing key without an access token;
// leaving `key` out keeps oauth_token out of the header entirely
token = { secret: String(values.tokenSecret) };
}
const authParams = oauth.authorize(requestData, token as OAuth.Token | undefined);
const { Authorization } = oauth.toHeader(authParams);
return { setHeaders: [{ name: "Authorization", value: Authorization }] };
},
},
};
function hashFunction(signatureMethod: SigMethod) {
switch (signatureMethod) {
case signatures.HMAC_SHA1:
return (base: string, key: string) =>
crypto.createHmac("sha1", key).update(base).digest("base64");
case signatures.HMAC_SHA256:
return (base: string, key: string) =>
crypto.createHmac("sha256", key).update(base).digest("base64");
case signatures.HMAC_SHA512:
return (base: string, key: string) =>
crypto.createHmac("sha512", key).update(base).digest("base64");
case signatures.RSA_SHA1:
return (base: string, privateKey: string) =>
crypto.createSign("RSA-SHA1").update(base).sign(privateKey, "base64");
case signatures.RSA_SHA256:
return (base: string, privateKey: string) =>
crypto.createSign("RSA-SHA256").update(base).sign(privateKey, "base64");
case signatures.RSA_SHA512:
return (base: string, privateKey: string) =>
crypto.createSign("RSA-SHA512").update(base).sign(privateKey, "base64");
case signatures.PLAINTEXT:
// RFC 5849 3.4.4: the PLAINTEXT signature IS the signing key,
// `encoded(consumer secret)&encoded(token secret)`. Returning the base
// string put the whole percent-encoded request into oauth_signature.
return (_base: string, key: string) => key;
default:
return (base: string, key: string) =>
crypto.createHmac("sha1", key).update(base).digest("base64");
}
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "vite-plus/test";
import { plugin } from "../src";
function sign(values: Record<string, string>): string {
const result = plugin.authentication!.onApply!(
{} as never,
{
values,
method: "GET",
url: "https://api.example.com/resource",
} as never,
) as { setHeaders: { name: string; value: string }[] };
const header = result.setHeaders[0]!.value;
const match = header.match(/oauth_signature="([^"]*)"/);
return decodeURIComponent(match![1]!);
}
describe("PLAINTEXT signature", () => {
const base = {
signatureMethod: "PLAINTEXT",
consumerKey: "ck",
consumerSecret: "cs",
nonce: "abc123",
timestamp: "1700000000",
};
// RFC 5849 3.4.4: the PLAINTEXT signature is the signing key itself --
// encoded(consumer secret) "&" encoded(token secret) -- not the base string.
test("is the signing key, not the signature base string", () => {
expect(sign({ ...base, tokenKey: "tk", tokenSecret: "ts" })).toBe("cs&ts");
});
test("keeps the trailing separator when there is no token secret", () => {
expect(sign(base)).toBe("cs&");
});
test("includes the token secret without an access token, omitting oauth_token", () => {
const result = plugin.authentication!.onApply!(
{} as never,
{
values: { ...base, tokenSecret: "ts" },
method: "GET",
url: "https://api.example.com/resource",
} as never,
) as { setHeaders: { name: string; value: string }[] };
const header = result.setHeaders[0]!.value;
const match = header.match(/oauth_signature="([^"]*)"/);
expect(decodeURIComponent(match![1]!)).toBe("cs&ts");
expect(header).not.toContain("oauth_token=");
});
test("percent-encodes reserved characters in the secrets", () => {
expect(sign({ ...base, consumerSecret: "c s", tokenKey: "tk", tokenSecret: "t&s" })).toBe(
"c%20s&t%26s",
);
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,74 @@
# OAuth 2.0 Authentication
An [OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) authentication plugin that
supports multiple grant types and flows, enabling secure API authentication with OAuth 2.0
providers.
![Screenshot of OAuth 2.0 auth UI](screenshot.png)
## Overview
This plugin implements OAuth 2.0 authentication for requests, supporting the most common
OAuth 2.0 grant types used in modern API integrations. It handles token management,
automatic refresh, and [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) (Proof Key
for Code Exchange) for enhanced security.
## Supported Grant Types
### Authorization Code Flow
The most secure and commonly used OAuth 2.0 flow for web applications.
- Standard Authorization Code flow
- Optional PKCE (Proof Key for Code Exchange) for enhanced security
- Supports automatic token refresh
### Client Credentials Flow
Ideal for server-to-server authentication where no user interaction is required.
### Implicit Flow
Legacy flow for single-page applications (deprecated but still supported):
- Direct access token retrieval
- No refresh token support
- Suitable for legacy integrations
### Resource Owner Password Credentials Flow
Direct username/password authentication.
- User credentials are exchanged directly for tokens
- Should only be used with trusted applications
- Supports automatic token refresh
## Features
- **Automatic Token Management**: Handles token storage, expiration, and refresh
automatically
- **PKCE Support**: Enhanced security for Authorization Code flow
- **Token Persistence**: Stores tokens between sessions
- **Flexible Configuration**: Supports custom authorization and token endpoints
- **Scope Management**: Configure required OAuth scopes for your API
- **Custom Parameters**: Add headers and params to the authorization, token, and refresh
requests, replacing generated entries of the same name
- **Error Handling**: Comprehensive error handling and user feedback
## Usage
1. Configure the request, folder, or workspace to use OAuth 2.0 Authentication
2. Select the appropriate grant type for your use case
3. Fill in the required OAuth 2.0 parameters from your API provider
4. The plugin will handle the authentication flow and token management automatically
## Compatibility
This plugin is compatible with OAuth 2.0 providers including:
- Google APIs
- Microsoft Graph
- GitHub API
- Auth0
- Okta
- And many other OAuth 2.0 compliant services

View File

@@ -0,0 +1,23 @@
{
"name": "@yaak/auth-oauth2",
"displayName": "OAuth 2.0",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using OAuth 2.0",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-oauth2"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.7"
}
}

View File

@@ -0,0 +1,345 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import http from "node:http";
import type { Context } from "@yaakapp/api";
export const HOSTED_CALLBACK_URL_BASE = "https://oauth.yaak.app/redirect";
export const DEFAULT_LOCALHOST_PORT = 8765;
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Singleton: only one callback server runs at a time across all OAuth flows. */
let activeServer: CallbackServerResult | null = null;
export interface CallbackServerResult {
/** The port the server is listening on */
port: number;
/** The full redirect URI to register with the OAuth provider */
redirectUri: string;
/** Promise that resolves with the callback URL when received */
waitForCallback: () => Promise<string>;
/** Stop the server */
stop: () => void;
}
/**
* Start a local HTTP server to receive OAuth callbacks.
* Only one server runs at a time — if a previous server is still active,
* it is stopped before starting the new one.
* Returns the port, redirect URI, and a promise that resolves when the callback is received.
*/
export function startCallbackServer(options: {
/** Specific port to use, or 0 for random available port */
port?: number;
/** Path for the callback endpoint */
path?: string;
/** Timeout in milliseconds (default 5 minutes) */
timeoutMs?: number;
}): Promise<CallbackServerResult> {
// Stop any previously active server before starting a new one
if (activeServer) {
console.log("[oauth2] Stopping previous callback server before starting new one");
activeServer.stop();
activeServer = null;
}
const { port = 0, path = "/callback", timeoutMs = CALLBACK_TIMEOUT_MS } = options;
return new Promise((resolve, reject) => {
let callbackResolve: ((url: string) => void) | null = null;
let callbackReject: ((err: Error) => void) | null = null;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
const reqUrl = new URL(req.url ?? "/", `http://${req.headers.host}`);
// Only handle the callback path
if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
if (req.method === "POST") {
// POST: read JSON body with the final callback URL and resolve
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
try {
const { url: callbackUrl } = JSON.parse(body);
if (!callbackUrl || typeof callbackUrl !== "string") {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Missing url in request body");
return;
}
// Send success response
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
// Resolve the callback promise
if (callbackResolve) {
callbackResolve(callbackUrl);
callbackResolve = null;
callbackReject = null;
}
// Stop the server after a short delay to ensure response is sent
setTimeout(() => stopServer(), 100);
} catch {
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Invalid JSON");
}
});
return;
}
// GET: serve intermediate page that reads the fragment and POSTs back
res.writeHead(200, { "Content-Type": "text/html" });
res.end(getFragmentForwardingHtml());
});
server.on("error", (err: Error) => {
if (!stopped) {
reject(err);
}
});
const stopServer = () => {
if (stopped) return;
stopped = true;
// Clear the singleton reference
if (activeServer?.stop === stopServer) {
activeServer = null;
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
server.close();
if (callbackReject) {
callbackReject(new Error("Callback server stopped"));
callbackResolve = null;
callbackReject = null;
}
};
server.listen(port, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Failed to get server address"));
return;
}
const actualPort = address.port;
const redirectUri = `http://127.0.0.1:${actualPort}${path}`;
console.log(`[oauth2] Callback server listening on ${redirectUri}`);
const result: CallbackServerResult = {
port: actualPort,
redirectUri,
waitForCallback: () => {
return new Promise<string>((res, rej) => {
if (stopped) {
rej(new Error("Callback server already stopped"));
return;
}
callbackResolve = res;
callbackReject = rej;
// Set timeout
timeoutHandle = setTimeout(() => {
if (callbackReject) {
callbackReject(new Error("Authorization timed out"));
callbackResolve = null;
callbackReject = null;
}
stopServer();
}, timeoutMs);
});
},
stop: stopServer,
};
activeServer = result;
resolve(result);
});
});
}
/**
* Build the redirect URI for the hosted callback page.
* The port is encoded in the URL path so the hosted page can redirect
* to the local server without relying on query params (which some OAuth
* providers strip). The default port is omitted for a cleaner URL.
*/
export function buildHostedCallbackRedirectUri(localPort: number): string {
if (localPort === DEFAULT_LOCALHOST_PORT) {
return HOSTED_CALLBACK_URL_BASE;
}
return `${HOSTED_CALLBACK_URL_BASE}/${localPort}`;
}
/**
* Stop the active callback server if one is running.
* Called during plugin dispose to ensure the server is cleaned up before the process exits.
*/
export function stopActiveServer(): void {
if (activeServer) {
console.log("[oauth2] Stopping active callback server during dispose");
activeServer.stop();
activeServer = null;
}
}
/**
* Open an authorization URL in the system browser, start a local callback server,
* and wait for the OAuth provider to redirect back.
*
* Returns the raw callback URL and the redirect URI that was registered with the
* OAuth provider (needed for token exchange).
*/
export async function getRedirectUrlViaExternalBrowser(
ctx: Context,
authorizationUrl: URL,
options: {
callbackType: "localhost" | "hosted";
callbackPort?: number;
},
): Promise<{ callbackUrl: string; redirectUri: string }> {
const { callbackType, callbackPort } = options;
const port = callbackPort ?? DEFAULT_LOCALHOST_PORT;
console.log(`[oauth2] Starting callback server (type: ${callbackType}, port: ${port})`);
const server = await startCallbackServer({
port,
path: "/callback",
});
try {
// Determine the redirect URI to send to the OAuth provider
let oauthRedirectUri: string;
if (callbackType === "hosted") {
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
console.log("[oauth2] Using hosted callback redirect:", oauthRedirectUri);
} else {
oauthRedirectUri = server.redirectUri;
console.log("[oauth2] Using localhost callback redirect:", oauthRedirectUri);
}
// Set the redirect URI on the authorization URL
authorizationUrl.searchParams.set("redirect_uri", oauthRedirectUri);
const authorizationUrlStr = authorizationUrl.toString();
console.log("[oauth2] Opening external browser:", authorizationUrlStr);
// Show toast to inform user
await ctx.toast.show({
message: "Opening browser for authorization...",
icon: "info",
timeout: 3000,
});
// Open the system browser
await ctx.window.openExternalUrl(authorizationUrlStr);
// Wait for the callback
console.log("[oauth2] Waiting for callback on", server.redirectUri);
const callbackUrl = await server.waitForCallback();
console.log("[oauth2] Received callback:", callbackUrl);
return { callbackUrl, redirectUri: oauthRedirectUri };
} finally {
server.stop();
}
}
/**
* Intermediate HTML page that reads the URL fragment and _fragment query param,
* reconstructs a proper OAuth callback URL, and POSTs it back to the server.
*
* Handles three cases:
* - Localhost implicit: fragment is in location.hash (e.g. #access_token=...)
* - Hosted implicit: fragment was converted to ?_fragment=... by the hosted redirect page
* - Auth code: no fragment, code is already in query params
*/
function getFragmentForwardingHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Yaak</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: hsl(244,23%,14%);
color: hsl(245,23%,85%);
}
.container { text-align: center; }
.logo { display: block; width: 100px; height: 100px; margin: 0 auto 32px; border-radius: 50%; }
h1 { font-size: 28px; font-weight: 600; margin-bottom: 12px; }
p { font-size: 16px; color: hsl(245,18%,58%); }
</style>
</head>
<body>
<div class="container">
<svg class="logo" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(649.94,712.03,-712.03,649.94,179.25,220.59)"><stop offset="0" stop-color="#4cc48c"/><stop offset=".5" stop-color="#476cc9"/><stop offset="1" stop-color="#ba1ab7"/></linearGradient></defs><rect x="0" y="0" width="1024" height="1024" fill="url(#g)"/><g transform="matrix(0.822,0,0,0.822,91.26,91.26)"><path d="M766.775,105.176C902.046,190.129 992.031,340.639 992.031,512C992.031,706.357 876.274,873.892 710,949.361C684.748,838.221 632.417,791.074 538.602,758.96C536.859,790.593 545.561,854.983 522.327,856.611C477.951,859.719 321.557,782.368 310.75,710.135C300.443,641.237 302.536,535.834 294.475,482.283C86.974,483.114 245.65,303.256 245.65,303.256L261.925,368.357L294.475,368.357C294.475,368.357 298.094,296.03 310.75,286.981C326.511,275.713 366.457,254.592 473.502,254.431C519.506,190.629 692.164,133.645 766.775,105.176ZM603.703,352.082C598.577,358.301 614.243,384.787 623.39,401.682C639.967,432.299 672.34,459.32 760.231,456.739C780.796,456.135 808.649,456.743 831.555,448.316C919.689,369.191 665.548,260.941 652.528,270.706C629.157,288.235 677.433,340.481 685.079,352.082C663.595,350.818 630.521,352.121 603.703,352.082ZM515.817,516.822C491.026,516.822 470.898,536.949 470.898,561.741C470.898,586.532 491.026,606.66 515.817,606.66C540.609,606.66 560.736,586.532 560.736,561.741C560.736,536.949 540.609,516.822 515.817,516.822ZM656.608,969.83C610.979,984.25 562.391,992.031 512,992.031C247.063,992.031 31.969,776.937 31.969,512C31.969,247.063 247.063,31.969 512,31.969C581.652,31.969 647.859,46.835 707.634,73.574C674.574,86.913 627.224,104.986 620,103.081C343.573,30.201 98.64,283.528 98.64,511.993C98.64,761.842 376.244,989.043 627.831,910C637.21,907.053 645.743,936.753 656.608,969.83Z" fill="#fff"/></g></svg>
<h1 id="title">Authorizing...</h1>
<p id="message">Please wait</p>
</div>
<script>
(function() {
var title = document.getElementById('title');
var message = document.getElementById('message');
var url = new URL(window.location.href);
var fragment = window.location.hash;
var fragmentParam = url.searchParams.get('_fragment');
// Build the final callback URL:
// 1. If _fragment query param exists (from hosted redirect), convert it back to a real fragment
// 2. If location.hash exists (direct localhost implicit), use it as-is
// 3. Otherwise (auth code flow), use the URL as-is with query params
if (fragmentParam) {
url.searchParams.delete('_fragment');
url.hash = fragmentParam;
} else if (fragment && fragment.length > 1) {
url.hash = fragment;
}
// POST the final URL back to the callback server
fetch(url.pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.toString() })
}).then(function(res) {
if (res.ok) {
title.textContent = 'Authorization Complete';
message.textContent = 'You may close this tab and return to Yaak';
} else {
title.textContent = 'Authorization Failed';
message.textContent = 'Something went wrong. Please try again.';
}
}).catch(function() {
title.textContent = 'Authorization Failed';
message.textContent = 'Something went wrong. Please try again.';
});
})();
</script>
</body>
</html>`;
}

View File

@@ -0,0 +1,121 @@
import type { JsonPrimitive } from "@yaakapp/api";
export interface NameValue {
name: string;
value: string;
}
/** Custom entries for a single outgoing request */
export interface CustomRequestParams {
headers: NameValue[];
body: NameValue[];
}
export interface CustomParams {
/** Query parameters appended to the authorization URL */
authorizationQuery: NameValue[];
token: CustomRequestParams;
/** Already merged over `token`, so the refresh request only reads this */
refresh: CustomRequestParams;
}
export const NO_CUSTOM_PARAMS: CustomParams = {
authorizationQuery: [],
token: { headers: [], body: [] },
refresh: { headers: [], body: [] },
};
/**
* A key_value form input stores its rows as a JSON-encoded array of pairs. The
* whole string goes through the template engine before it reaches the plugin,
* so a row's value can be a template.
*/
export function parsePairs(value: JsonPrimitive | undefined): NameValue[] {
if (value == null || value === "") return [];
let parsed: unknown;
try {
parsed = JSON.parse(String(value));
} catch {
console.log("[oauth2] Ignoring custom parameters that failed to parse");
return [];
}
if (!Array.isArray(parsed)) return [];
const pairs: NameValue[] = [];
for (const row of parsed) {
if (row == null || typeof row !== "object") continue;
const { name, value, enabled } = row as { name?: unknown; value?: unknown; enabled?: unknown };
if (enabled === false) continue;
const trimmedName = typeof name === "string" ? name.trim() : "";
if (trimmedName === "") continue;
pairs.push({ name: trimmedName, value: rowValue(value) });
}
return pairs;
}
/** The pair editor writes strings, but a hand-edited file may hold any primitive */
function rowValue(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
/**
* Read every custom parameter list off the auth form.
*
* Token entries also apply to the refresh request, so the refresh lists are the
* token lists with the refresh-specific rows merged over them.
*/
export function readCustomParams(values: Record<string, JsonPrimitive | undefined>): CustomParams {
const tokenHeaders = parsePairs(values.tokenHeaders);
const tokenBody = parsePairs(values.tokenBodyParams);
return {
authorizationQuery: parsePairs(values.authorizationParams),
token: { headers: tokenHeaders, body: tokenBody },
refresh: {
headers: mergeHeaders(tokenHeaders, parsePairs(values.refreshHeaders)),
body: mergeFormParams(tokenBody, parsePairs(values.refreshBodyParams)),
},
};
}
/**
* Custom entries win over generated ones of the same name: every generated
* entry the custom list names is dropped, then the custom list is appended.
* Dropping by name rather than replacing in place means two custom rows sharing
* a name both survive, which repeatable headers and params rely on.
*/
function mergeByName<T extends NameValue>(
generated: T[],
custom: NameValue[],
{ caseInsensitive }: { caseInsensitive: boolean },
): (T | NameValue)[] {
if (custom.length === 0) return generated;
const normalize = (name: string) => (caseInsensitive ? name.toLowerCase() : name);
const overridden = new Set(custom.map((c) => normalize(c.name)));
return [...generated.filter((g) => !overridden.has(normalize(g.name))), ...custom];
}
export function mergeHeaders<T extends NameValue>(generated: T[], custom: NameValue[]) {
return mergeByName(generated, custom, { caseInsensitive: true });
}
export function mergeFormParams<T extends NameValue>(generated: T[], custom: NameValue[]) {
return mergeByName(generated, custom, { caseInsensitive: false });
}
/** Apply custom query parameters to an authorization URL, in place */
export function applyQueryParams(url: URL, custom: NameValue[]) {
for (const name of new Set(custom.map((c) => c.name))) {
url.searchParams.delete(name);
}
for (const { name, value } of custom) {
url.searchParams.append(name, value);
}
}

View File

@@ -0,0 +1,104 @@
import type { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
import type { CustomRequestParams } from "./customParams";
import { mergeFormParams, mergeHeaders } from "./customParams";
import type { AccessTokenRawResponse } from "./store";
export async function fetchAccessToken(
ctx: Context,
args: {
clientId: string;
grantType: string;
accessTokenUrl: string;
scope: string | null;
audience: string | null;
params: HttpUrlParameter[];
custom?: CustomRequestParams;
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
): Promise<AccessTokenRawResponse> {
const { clientId, grantType, accessTokenUrl, scope, audience, params, custom } = args;
console.log("[oauth2] Getting access token", accessTokenUrl);
const httpRequest: Partial<HttpRequest> = {
method: "POST",
url: accessTokenUrl,
bodyType: "application/x-www-form-urlencoded",
body: {
form: [{ name: "grant_type", value: grantType }, ...params],
},
headers: [
{ name: "User-Agent", value: "yaak" },
{
name: "Accept",
value: "application/x-www-form-urlencoded, application/json",
},
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
],
};
// RFC 6749 §4.1.3 doesn't define scope for the authorization code token
// request, so strict servers (OpenIddict) reject it outright. Scope belongs on
// the authorize request, which already sends it. Every other grant does define
// it: §4.3.2 password, §4.4.2 client credentials, §6 refresh.
if (scope && grantType !== "authorization_code") {
httpRequest.body?.form.push({ name: "scope", value: scope });
}
if (audience) httpRequest.body?.form.push({ name: "audience", value: audience });
if ("clientAssertion" in args) {
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({
name: "client_assertion_type",
value: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
});
httpRequest.body?.form.push({
name: "client_assertion",
value: args.clientAssertion,
});
} else if (args.credentialsInBody) {
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({
name: "client_secret",
value: args.clientSecret,
});
} else {
const value = `Basic ${Buffer.from(`${clientId}:${args.clientSecret}`).toString("base64")}`;
httpRequest.headers?.push({ name: "Authorization", value });
}
// Merged last so custom entries override the credential headers and params above
if (custom) {
httpRequest.headers = mergeHeaders(httpRequest.headers ?? [], custom.headers);
httpRequest.body = { form: mergeFormParams(httpRequest.body?.form ?? [], custom.body) };
}
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
console.log("[oauth2] Got access token response", resp.status);
if (resp.error) {
throw new Error(`Failed to fetch access token: ${resp.error}`);
}
// A token request is sent ad-hoc, with no id, so nothing saves the response
// and this body is the only copy of it. An empty one parses to {} below,
// which is what reading a missing file used to give.
const body = await responseBody.text();
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to fetch access token with status=${resp.status} and body=${body}`);
}
// oxlint-disable-next-line no-explicit-any
let response: any;
try {
response = JSON.parse(body);
} catch {
response = Object.fromEntries(new URLSearchParams(body));
}
if (response.error) {
throw new Error(`Failed to fetch access token with ${response.error}`);
}
return response;
}

View File

@@ -0,0 +1,128 @@
import type { Context, HttpRequest } from "@yaakapp/api";
import type { CustomRequestParams } from "./customParams";
import { mergeFormParams, mergeHeaders } from "./customParams";
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
import { deleteToken, getToken, storeToken } from "./store";
import { isTokenExpired } from "./util";
export async function getOrRefreshAccessToken(
ctx: Context,
tokenArgs: TokenStoreArgs,
{
scope,
accessTokenUrl,
credentialsInBody,
clientId,
clientSecret,
tokenName,
forceRefresh,
custom,
}: {
scope: string | null;
accessTokenUrl: string;
credentialsInBody: boolean;
clientId: string;
clientSecret: string;
tokenName?: "access_token" | "id_token";
forceRefresh?: boolean;
custom?: CustomRequestParams;
},
): Promise<AccessToken | null> {
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return null;
}
const isExpired = isTokenExpired(token, tokenName);
// Return the current access token if it's still valid
if (!isExpired && !forceRefresh) {
return token;
}
// Token is expired, but there's no refresh token :(
if (!token.response.refresh_token) {
return null;
}
// Access token is expired, so get a new one
const httpRequest: Partial<HttpRequest> = {
method: "POST",
url: accessTokenUrl,
bodyType: "application/x-www-form-urlencoded",
body: {
form: [
{ name: "grant_type", value: "refresh_token" },
{ name: "refresh_token", value: token.response.refresh_token },
],
},
headers: [
{ name: "User-Agent", value: "yaak" },
{ name: "Accept", value: "application/x-www-form-urlencoded, application/json" },
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
],
};
if (scope) httpRequest.body?.form.push({ name: "scope", value: scope });
if (credentialsInBody) {
httpRequest.body?.form.push({ name: "client_id", value: clientId });
httpRequest.body?.form.push({ name: "client_secret", value: clientSecret });
} else {
const value = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`;
httpRequest.headers?.push({ name: "Authorization", value });
}
// Merged last so custom entries override the credential headers and params above
if (custom) {
httpRequest.headers = mergeHeaders(httpRequest.headers ?? [], custom.headers);
httpRequest.body = { form: mergeFormParams(httpRequest.body?.form ?? [], custom.body) };
}
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
if (resp.error) {
throw new Error(`Failed to refresh access token: ${resp.error}`);
}
if (resp.status >= 400 && resp.status < 500) {
// Client errors (4xx) indicate the refresh token is invalid, expired, or revoked
// Delete the token and return null to trigger a fresh authorization flow
console.log("[oauth2] Refresh token request failed with client error, deleting token");
await deleteToken(ctx, tokenArgs);
return null;
}
// Sent ad-hoc, so this body came back with the response rather than being
// saved anywhere to read later.
const body = await responseBody.text();
console.log("[oauth2] Got refresh token response", resp.status);
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`Failed to refresh access token with status=${resp.status} and body=${body}`);
}
// oxlint-disable-next-line no-explicit-any
let response: any;
try {
response = JSON.parse(body);
} catch {
response = Object.fromEntries(new URLSearchParams(body));
}
if (response.error) {
throw new Error(
`Failed to fetch access token with ${response.error} -> ${response.error_description}`,
);
}
const newResponse: AccessTokenRawResponse = {
...response,
// Assign a new one or keep the old one,
refresh_token: response.refresh_token ?? token.response.refresh_token,
};
return storeToken(ctx, tokenArgs, newResponse, tokenName);
}

View File

@@ -0,0 +1,218 @@
import { createHash, randomBytes } from "node:crypto";
import type { Context } from "@yaakapp/api";
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
import type { CustomParams } from "../customParams";
import { applyQueryParams, NO_CUSTOM_PARAMS } from "../customParams";
import { fetchAccessToken } from "../fetchAccessToken";
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
import type { AccessToken, TokenStoreArgs } from "../store";
import { getDataDirKey, storeToken } from "../store";
import { extractCode } from "../util";
export const PKCE_SHA256 = "S256";
export const PKCE_PLAIN = "plain";
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
export type CallbackType = "localhost" | "hosted";
export interface ExternalBrowserOptions {
useExternalBrowser: boolean;
callbackType: CallbackType;
/** Port for localhost callback (only used when callbackType is 'localhost') */
callbackPort?: number;
}
export async function getAuthorizationCode(
ctx: Context,
contextId: string,
{
authorizationUrl: authorizationUrlRaw,
accessTokenUrl,
clientId,
clientSecret,
redirectUri,
scope,
state,
audience,
credentialsInBody,
pkce,
tokenName,
externalBrowser,
customParams = NO_CUSTOM_PARAMS,
}: {
authorizationUrl: string;
accessTokenUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string | null;
scope: string | null;
state: string | null;
audience: string | null;
credentialsInBody: boolean;
pkce: {
challengeMethod: string;
codeVerifier: string;
} | null;
tokenName: "access_token" | "id_token";
externalBrowser?: ExternalBrowserOptions;
customParams?: CustomParams;
},
): Promise<AccessToken> {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: authorizationUrlRaw,
};
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
accessTokenUrl,
scope,
clientId,
clientSecret,
credentialsInBody,
tokenName,
custom: customParams.refresh,
});
if (token != null) {
return token;
}
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set("response_type", "code");
authorizationUrl.searchParams.set("client_id", clientId);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (pkce) {
authorizationUrl.searchParams.set(
"code_challenge",
pkceCodeChallenge(pkce.codeVerifier, pkce.challengeMethod),
);
authorizationUrl.searchParams.set("code_challenge_method", pkce.challengeMethod);
}
// Applied before redirect_uri, which belongs to the callback flow rather than
// to the user: overriding it would send the code somewhere nothing listens
applyQueryParams(authorizationUrl, customParams.authorizationQuery);
let code: string;
let actualRedirectUri: string | null = redirectUri;
// Use external browser flow if enabled
if (externalBrowser?.useExternalBrowser) {
const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {
callbackType: externalBrowser.callbackType,
callbackPort: externalBrowser.callbackPort,
});
// Pass null to skip redirect URI matching — the callback came from our own local server
const extractedCode = extractCode(result.callbackUrl, null);
if (!extractedCode) {
throw new Error("No authorization code found in callback URL");
}
code = extractedCode;
actualRedirectUri = result.redirectUri;
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
}
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
}
console.log("[oauth2] Code found");
const response = await fetchAccessToken(ctx, {
grantType: "authorization_code",
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: "code", value: code },
...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []),
...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []),
],
custom: customParams.token,
});
return storeToken(ctx, tokenArgs, response, tokenName);
}
/**
* Get authorization code using the embedded browser window.
* This is the original flow that monitors navigation events.
*/
async function getCodeViaEmbeddedBrowser(
ctx: Context,
contextId: string,
authorizationUrl: URL,
redirectUri: string | null,
): Promise<string> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log("[oauth2] Authorizing via embedded browser", authorizationUrlStr);
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
return new Promise<string>(async (resolve, reject) => {
let foundCode = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: "oauth-authorization-url",
async onClose() {
if (!foundCode) {
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
let code: string | null;
try {
code = extractCode(urlStr, redirectUri);
} catch (err) {
reject(err);
close();
return;
}
if (!code) {
return;
}
foundCode = true;
close();
resolve(code);
},
});
});
}
export function genPkceCodeVerifier() {
return encodeForPkce(randomBytes(32));
}
function pkceCodeChallenge(verifier: string, method: string) {
if (method === "plain") {
return verifier;
}
const hash = encodeForPkce(createHash("sha256").update(verifier).digest());
return hash
.replace(/=/g, "") // Remove padding '='
.replace(/\+/g, "-") // Replace '+' with '-'
.replace(/\//g, "_"); // Replace '/' with '_'
}
function encodeForPkce(bytes: Buffer) {
return bytes
.toString("base64")
.replace(/=/g, "") // Remove padding '='
.replace(/\+/g, "-") // Replace '+' with '-'
.replace(/\//g, "_"); // Replace '/' with '_'
}

View File

@@ -0,0 +1,175 @@
import { createPrivateKey, randomUUID } from "node:crypto";
import type { Context } from "@yaakapp/api";
import jwt, { type Algorithm } from "jsonwebtoken";
import type { CustomParams } from "../customParams";
import { NO_CUSTOM_PARAMS } from "../customParams";
import { fetchAccessToken } from "../fetchAccessToken";
import type { TokenStoreArgs } from "../store";
import { getToken, storeToken } from "../store";
import { isTokenExpired } from "../util";
export const jwtAlgorithms = [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512",
"none",
] as const;
export const defaultJwtAlgorithm = jwtAlgorithms[0];
/**
* Build a signed JWT for the client_assertion parameter (RFC 7523).
*
* The `secret` value is auto-detected as one of:
* - **JWK** – a JSON string containing a private-key object (has a `kty` field).
* - **PEM** – a string whose trimmed form starts with `-----`.
* - **HMAC secret** – anything else, used as-is for HS* algorithms.
*/
function buildClientAssertionJwt(params: {
clientId: string;
accessTokenUrl: string;
secret: string;
algorithm: Algorithm;
}): string {
const { clientId, accessTokenUrl, secret, algorithm } = params;
const isHmac = algorithm.startsWith("HS") || algorithm === "none";
// Resolve the signing key depending on format
let signingKey: jwt.Secret;
let kid: string | undefined;
const trimmed = secret.trim();
if (isHmac) {
// HMAC algorithms use the raw secret (string or Buffer)
signingKey = secret;
} else if (trimmed.startsWith("{")) {
// Looks like JSON - treat as JWK. There is surely a better way to detect JWK vs a raw secret, but this should work in most cases.
// oxlint-disable-next-line no-explicit-any
let jwk: any;
try {
jwk = JSON.parse(trimmed);
} catch {
throw new Error("Client Assertion secret looks like JSON but is not valid");
}
kid = jwk?.kid;
signingKey = createPrivateKey({ key: jwk, format: "jwk" });
} else if (trimmed.startsWith("-----")) {
// PEM-encoded key
signingKey = createPrivateKey({ key: trimmed, format: "pem" });
} else {
throw new Error(
"Client Assertion secret must be a JWK JSON object, a PEM-encoded key " +
"(starting with -----), or a raw secret for HMAC algorithms.",
);
}
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: clientId,
sub: clientId,
aud: accessTokenUrl,
iat: now,
exp: now + 300, // 5 minutes
jti: randomUUID(),
};
// Build the JWT header; include "kid" when available
const header: jwt.JwtHeader = { alg: algorithm, typ: "JWT" };
if (kid) {
header.kid = kid;
}
return jwt.sign(JSON.stringify(payload), signingKey, {
algorithm: algorithm as jwt.Algorithm,
header,
});
}
export async function getClientCredentials(
ctx: Context,
contextId: string,
{
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
clientAssertionSecret,
clientAssertionSecretBase64,
clientCredentialsMethod,
clientAssertionAlgorithm,
customParams = NO_CUSTOM_PARAMS,
}: {
accessTokenUrl: string;
clientId: string;
clientSecret: string;
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
clientAssertionSecret: string;
clientAssertionSecretBase64: boolean;
clientCredentialsMethod: string;
clientAssertionAlgorithm: string;
customParams?: CustomParams;
},
) {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: null,
};
const token = await getToken(ctx, tokenArgs);
if (token && !isTokenExpired(token)) {
return token;
}
const common: Omit<
Parameters<typeof fetchAccessToken>[1],
"clientAssertion" | "clientSecret" | "credentialsInBody"
> = {
grantType: "client_credentials",
accessTokenUrl,
audience,
clientId,
scope,
params: [],
custom: customParams.token,
};
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
clientCredentialsMethod === "client_assertion"
? {
...common,
clientAssertion: buildClientAssertionJwt({
clientId,
algorithm: clientAssertionAlgorithm as Algorithm,
accessTokenUrl,
secret: clientAssertionSecretBase64
? Buffer.from(clientAssertionSecret, "base64").toString("utf-8")
: clientAssertionSecret,
}),
}
: {
...common,
clientSecret,
credentialsInBody,
};
const response = await fetchAccessToken(ctx, fetchParams);
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -0,0 +1,207 @@
import type { Context } from "@yaakapp/api";
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
import type { CustomParams } from "../customParams";
import { applyQueryParams, NO_CUSTOM_PARAMS } from "../customParams";
import type { AccessToken, AccessTokenRawResponse } from "../store";
import { getDataDirKey, getToken, storeToken } from "../store";
import { isTokenExpired } from "../util";
import type { ExternalBrowserOptions } from "./authorizationCode";
export async function getImplicit(
ctx: Context,
contextId: string,
{
authorizationUrl: authorizationUrlRaw,
responseType,
clientId,
redirectUri,
scope,
state,
audience,
tokenName,
externalBrowser,
customParams = NO_CUSTOM_PARAMS,
}: {
authorizationUrl: string;
responseType: string;
clientId: string;
redirectUri: string | null;
scope: string | null;
state: string | null;
audience: string | null;
tokenName: "access_token" | "id_token";
externalBrowser?: ExternalBrowserOptions;
customParams?: CustomParams;
},
): Promise<AccessToken> {
const tokenArgs = {
contextId,
clientId,
accessTokenUrl: null,
authorizationUrl: authorizationUrlRaw,
};
const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token, tokenName)) {
return token;
}
let authorizationUrl: URL;
try {
authorizationUrl = new URL(`${authorizationUrlRaw ?? ""}`);
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set("response_type", responseType);
authorizationUrl.searchParams.set("client_id", clientId);
if (scope) authorizationUrl.searchParams.set("scope", scope);
if (state) authorizationUrl.searchParams.set("state", state);
if (audience) authorizationUrl.searchParams.set("audience", audience);
if (responseType.includes("id_token")) {
authorizationUrl.searchParams.set(
"nonce",
String(Math.floor(Math.random() * 9999999999999) + 1),
);
}
// Applied before redirect_uri, which belongs to the callback flow rather than
// to the user: overriding it would send the token somewhere nothing listens
applyQueryParams(authorizationUrl, customParams.authorizationQuery);
let newToken: AccessToken;
// Use external browser flow if enabled
if (externalBrowser?.useExternalBrowser) {
const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {
callbackType: externalBrowser.callbackType,
callbackPort: externalBrowser.callbackPort,
});
newToken = await extractImplicitToken(ctx, result.callbackUrl, tokenArgs, tokenName);
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
}
newToken = await getTokenViaEmbeddedBrowser(
ctx,
contextId,
authorizationUrl,
tokenArgs,
tokenName,
);
}
return newToken;
}
/**
* Get token using the embedded browser window.
* This is the original flow that monitors navigation events.
*/
async function getTokenViaEmbeddedBrowser(
ctx: Context,
contextId: string,
authorizationUrl: URL,
tokenArgs: {
contextId: string;
clientId: string;
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: "access_token" | "id_token",
): Promise<AccessToken> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log("[oauth2] Authorizing via embedded browser (implicit)", authorizationUrlStr);
// oxlint-disable-next-line no-async-promise-executor -- Required for this pattern
return new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
label: "oauth-authorization-url",
async onClose() {
if (!foundAccessToken) {
reject(new Error("Authorization window closed"));
}
},
async onNavigate({ url: urlStr }) {
const url = new URL(urlStr);
if (url.searchParams.has("error")) {
return reject(Error(`Failed to authorize: ${url.searchParams.get("error")}`));
}
const hash = url.hash.slice(1);
const params = new URLSearchParams(hash);
const accessToken = params.get(tokenName);
if (!accessToken) {
return;
}
foundAccessToken = true;
// Close the window here, because we don't need it anymore
close();
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(storeToken(ctx, tokenArgs, response, tokenName));
} catch (err) {
reject(err);
}
},
});
});
}
/**
* Extract the implicit grant token from a callback URL and store it.
*/
async function extractImplicitToken(
ctx: Context,
callbackUrl: string,
tokenArgs: {
contextId: string;
clientId: string;
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: "access_token" | "id_token",
): Promise<AccessToken> {
const url = new URL(callbackUrl);
// Check for errors
if (url.searchParams.has("error")) {
throw new Error(`Failed to authorize: ${url.searchParams.get("error")}`);
}
// Extract token from fragment
const hash = url.hash.slice(1);
const params = new URLSearchParams(hash);
// Also check query params (in case fragment was converted)
const accessToken = params.get(tokenName) ?? url.searchParams.get(tokenName);
if (!accessToken) {
throw new Error(`No ${tokenName} found in callback URL`);
}
// Build response from params (prefer fragment, fall back to query)
const response: AccessTokenRawResponse = {
access_token: params.get("access_token") ?? url.searchParams.get("access_token") ?? "",
token_type: params.get("token_type") ?? url.searchParams.get("token_type") ?? undefined,
expires_in: params.has("expires_in")
? parseInt(params.get("expires_in") ?? "0", 10)
: url.searchParams.has("expires_in")
? parseInt(url.searchParams.get("expires_in") ?? "0", 10)
: undefined,
scope: params.get("scope") ?? url.searchParams.get("scope") ?? undefined,
};
// Include id_token if present
const idToken = params.get("id_token") ?? url.searchParams.get("id_token");
if (idToken) {
response.id_token = idToken;
}
return storeToken(ctx, tokenArgs, response, tokenName);
}

View File

@@ -0,0 +1,69 @@
import type { Context } from "@yaakapp/api";
import type { CustomParams } from "../customParams";
import { NO_CUSTOM_PARAMS } from "../customParams";
import { fetchAccessToken } from "../fetchAccessToken";
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
import type { AccessToken, TokenStoreArgs } from "../store";
import { storeToken } from "../store";
export async function getPassword(
ctx: Context,
contextId: string,
{
accessTokenUrl,
clientId,
clientSecret,
username,
password,
credentialsInBody,
audience,
scope,
customParams = NO_CUSTOM_PARAMS,
}: {
accessTokenUrl: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
scope: string | null;
audience: string | null;
credentialsInBody: boolean;
customParams?: CustomParams;
},
): Promise<AccessToken> {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: null,
username,
};
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
accessTokenUrl,
scope,
clientId,
clientSecret,
credentialsInBody,
custom: customParams.refresh,
});
if (token != null) {
return token;
}
const response = await fetchAccessToken(ctx, {
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
grantType: "password",
credentialsInBody,
params: [
{ name: "username", value: username },
{ name: "password", value: password },
],
custom: customParams.token,
});
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -0,0 +1,680 @@
import type {
Context,
FormInputSelectOption,
GetHttpAuthenticationConfigRequest,
JsonPrimitive,
PluginDefinition,
} from "@yaakapp/api";
import type { Algorithm } from "jsonwebtoken";
import {
buildHostedCallbackRedirectUri,
DEFAULT_LOCALHOST_PORT,
stopActiveServer,
} from "./callbackServer";
import { readCustomParams } from "./customParams";
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
genPkceCodeVerifier,
getAuthorizationCode,
PKCE_PLAIN,
PKCE_SHA256,
} from "./grants/authorizationCode";
import {
defaultJwtAlgorithm,
getClientCredentials,
jwtAlgorithms,
} from "./grants/clientCredentials";
import { getImplicit } from "./grants/implicit";
import { getPassword } from "./grants/password";
import type { AccessToken, TokenStoreArgs } from "./store";
import { deleteToken, getToken, resetDataDirKey } from "./store";
type GrantType = "authorization_code" | "implicit" | "password" | "client_credentials";
const grantTypes: FormInputSelectOption[] = [
{ label: "Authorization Code", value: "authorization_code" },
{ label: "Implicit", value: "implicit" },
{ label: "Resource Owner Password Credential", value: "password" },
{ label: "Client Credentials", value: "client_credentials" },
];
const defaultGrantType = grantTypes[0]?.value;
function hiddenIfNot(
grantTypes: GrantType[],
...other: ((values: GetHttpAuthenticationConfigRequest["values"]) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));
const hasOtherBools = other.every((t) => t(values));
const show = hasGrantType && hasOtherBools;
return { hidden: !show };
};
}
const authorizationUrls = [
"https://github.com/login/oauth/authorize",
"https://account.box.com/api/oauth2/authorize",
"https://accounts.google.com/o/oauth2/v2/auth",
"https://api.imgur.com/oauth2/authorize",
"https://bitly.com/oauth/authorize",
"https://gitlab.example.com/oauth/authorize",
"https://medium.com/m/oauth/authorize",
"https://public-api.wordpress.com/oauth2/authorize",
"https://slack.com/oauth/authorize",
"https://todoist.com/oauth/authorize",
"https://www.dropbox.com/oauth2/authorize",
"https://www.linkedin.com/oauth/v2/authorization",
"https://MY_SHOP.myshopify.com/admin/oauth/access_token",
"https://appcenter.intuit.com/app/connect/oauth2/authorize",
];
const accessTokenUrls = [
"https://github.com/login/oauth/access_token",
"https://api-ssl.bitly.com/oauth/access_token",
"https://api.box.com/oauth2/token",
"https://api.dropboxapi.com/oauth2/token",
"https://api.imgur.com/oauth2/token",
"https://api.medium.com/v1/tokens",
"https://gitlab.example.com/oauth/token",
"https://public-api.wordpress.com/oauth2/token",
"https://slack.com/api/oauth.access",
"https://todoist.com/oauth/access_token",
"https://www.googleapis.com/oauth2/v4/token",
"https://www.linkedin.com/oauth/v2/accessToken",
"https://MY_SHOP.myshopify.com/admin/oauth/authorize",
"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
];
export const plugin: PluginDefinition = {
dispose() {
stopActiveServer();
},
authentication: {
name: "oauth2",
label: "OAuth 2.0",
shortLabel: "OAuth 2",
actions: [
{
label: "Copy Current Token",
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
username: usernameArg(values),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
await ctx.toast.show({
message: "No token to copy",
color: "warning",
});
} else {
await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({
message: "Token copied to clipboard",
icon: "copy",
color: "success",
});
}
},
},
{
label: "Delete Token",
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
username: usernameArg(values),
};
if (await deleteToken(ctx, tokenArgs)) {
await ctx.toast.show({
message: "Token deleted",
color: "success",
});
} else {
await ctx.toast.show({
message: "No token to delete",
color: "warning",
});
}
},
},
{
label: "Clear Window Session",
async onSelect(ctx, { contextId }) {
await resetDataDirKey(ctx, contextId);
},
},
],
args: [
{
type: "select",
name: "grantType",
label: "Grant Type",
defaultValue: defaultGrantType,
options: grantTypes,
},
{
type: "select",
name: "clientCredentialsMethod",
label: "Authentication Method",
description:
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
defaultValue: "client_secret",
options: [
{ label: "Client Secret", value: "client_secret" },
{ label: "Client Assertion", value: "client_assertion" },
],
dynamic: hiddenIfNot(["client_credentials"]),
},
{
type: "text",
name: "clientId",
label: "Client ID",
optional: true,
},
{
type: "text",
name: "clientSecret",
label: "Client Secret",
optional: true,
password: true,
dynamic: hiddenIfNot(
["authorization_code", "password", "client_credentials"],
(values) => values.clientCredentialsMethod === "client_secret",
),
},
{
type: "select",
name: "clientAssertionAlgorithm",
label: "JWT Algorithm",
defaultValue: defaultJwtAlgorithm,
options: jwtAlgorithms.map((value) => ({
label: value === "none" ? "None" : value,
value,
})),
dynamic: hiddenIfNot(
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: "text",
name: "clientAssertionSecret",
label: "JWT Secret",
description:
"Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.",
password: true,
optional: true,
multiLine: true,
dynamic: hiddenIfNot(
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: "checkbox",
name: "clientAssertionSecretBase64",
label: "JWT secret is base64 encoded",
dynamic: hiddenIfNot(
["client_credentials"],
({ clientCredentialsMethod }) => clientCredentialsMethod === "client_assertion",
),
},
{
type: "text",
name: "authorizationUrl",
optional: true,
label: "Authorization URL",
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: "text",
name: "accessTokenUrl",
optional: true,
label: "Access Token URL",
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
completionOptions: accessTokenUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: "banner",
inputs: [
{
type: "checkbox",
name: "useExternalBrowser",
label: "Use External Browser",
description:
"Open authorization URL in your system browser instead of the embedded browser. " +
"Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.",
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{
type: "text",
name: "redirectUri",
label: "Redirect URI (can be any valid URL)",
placeholder: "https://mysite.example.com/oauth/callback",
description:
"URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.",
optional: true,
dynamic: hiddenIfNot(
["authorization_code", "implicit"],
({ useExternalBrowser }) => !useExternalBrowser,
),
},
{
type: "h_stack",
inputs: [
{
type: "select",
name: "callbackType",
label: "Callback Type",
description:
'"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.',
defaultValue: "hosted",
options: [
{ label: "Hosted Redirect", value: "hosted" },
{ label: "Localhost", value: "localhost" },
],
dynamic: hiddenIfNot(
["authorization_code", "implicit"],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
{
type: "text",
name: "callbackPort",
label: "Callback Port",
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
description:
"Port for the local callback server. Defaults to " +
DEFAULT_LOCALHOST_PORT +
" if empty.",
optional: true,
dynamic: hiddenIfNot(
["authorization_code", "implicit"],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
],
},
{
type: "banner",
color: "info",
inputs: [
{
type: "markdown",
content: "Redirect URI to Register",
async dynamic(_ctx, { values }) {
const grantType = String(values.grantType ?? defaultGrantType);
const useExternalBrowser = !!values.useExternalBrowser;
const callbackType = (stringArg(values, "callbackType") ||
"localhost") as CallbackType;
// Only show for authorization_code and implicit with external browser enabled
if (
!["authorization_code", "implicit"].includes(grantType) ||
!useExternalBrowser
) {
return { hidden: true };
}
// Compute the redirect URI based on callback type
const port = intArg(values, "callbackPort") || DEFAULT_LOCALHOST_PORT;
let redirectUri: string;
if (callbackType === "hosted") {
redirectUri = buildHostedCallbackRedirectUri(port);
} else {
redirectUri = `http://127.0.0.1:${port}/callback`;
}
return {
hidden: false,
content: `Register \`${redirectUri}\` as a redirect URI with your OAuth provider.`,
};
},
},
],
},
],
},
{
type: "text",
name: "state",
label: "State",
optional: true,
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{ type: "text", name: "scope", label: "Scope", optional: true },
{ type: "text", name: "audience", label: "Audience", optional: true },
{
type: "select",
name: "tokenName",
label: "Token for authorization",
description:
'Select which token to send in the "Authorization: Bearer" header. Most APIs expect ' +
"access_token, but some (like OpenID Connect) require id_token.",
defaultValue: "access_token",
options: [
{ label: "access_token", value: "access_token" },
{ label: "id_token", value: "id_token" },
],
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{
type: "banner",
inputs: [
{
type: "checkbox",
name: "usePkce",
label: "Use PKCE",
dynamic: hiddenIfNot(["authorization_code"]),
},
{
type: "select",
name: "pkceChallengeMethod",
label: "Code Challenge Method",
options: [
{ label: "SHA-256", value: PKCE_SHA256 },
{ label: "Plain", value: PKCE_PLAIN },
],
defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
},
{
type: "text",
name: "pkceCodeChallenge",
label: "Code Verifier",
placeholder: "Automatically generated when not set",
optional: true,
dynamic: hiddenIfNot(["authorization_code"], ({ usePkce }) => !!usePkce),
},
],
},
{
type: "h_stack",
inputs: [
{
type: "text",
name: "username",
label: "Username",
optional: true,
dynamic: hiddenIfNot(["password"]),
},
{
type: "text",
name: "password",
label: "Password",
password: true,
optional: true,
dynamic: hiddenIfNot(["password"]),
},
],
},
{
type: "select",
name: "responseType",
label: "Response Type",
defaultValue: "token",
options: [
{ label: "Access Token", value: "token" },
{ label: "ID Token", value: "id_token" },
{ label: "ID and Access Token", value: "id_token token" },
],
dynamic: hiddenIfNot(["implicit"]),
},
{
type: "accordion",
label: "Advanced",
inputs: [
{
type: "text",
name: "headerName",
label: "Header Name",
defaultValue: "Authorization",
},
{
type: "text",
name: "headerPrefix",
label: "Header Prefix",
optional: true,
defaultValue: "Bearer",
},
{
type: "select",
name: "credentials",
label: "Send Credentials",
defaultValue: "body",
options: [
{ label: "In Request Body", value: "body" },
{ label: "As Basic Authentication", value: "basic" },
],
dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({
hidden:
values.grantType === "client_credentials" &&
values.clientCredentialsMethod === "client_assertion",
}),
},
{
type: "key_value",
name: "authorizationParams",
label: "Authorization Params",
description: "Query params appended to the authorization URL.",
optional: true,
dynamic: hiddenIfNot(["authorization_code", "implicit"]),
},
{
type: "key_value",
name: "tokenHeaders",
label: "Token Headers",
description:
"Headers sent with the token request, and when refreshing unless overridden.",
optional: true,
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
},
{
type: "key_value",
name: "tokenBodyParams",
label: "Token Body Params",
description:
"Form params sent with the token request, and when refreshing unless overridden.",
optional: true,
dynamic: hiddenIfNot(["authorization_code", "password", "client_credentials"]),
},
{
type: "key_value",
name: "refreshHeaders",
label: "Refresh Headers",
description: "Headers sent only when refreshing the token.",
optional: true,
dynamic: hiddenIfNot(["authorization_code", "password"]),
},
{
type: "key_value",
name: "refreshBodyParams",
label: "Refresh Body Params",
description: "Form params sent only when refreshing the token.",
optional: true,
dynamic: hiddenIfNot(["authorization_code", "password"]),
},
],
},
{
type: "accordion",
label: "Access Token Response",
inputs: [],
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, "authorizationUrl"),
accessTokenUrl: stringArg(values, "accessTokenUrl"),
clientId: stringArg(values, "clientId"),
username: usernameArg(values),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return { hidden: true };
}
return {
label: "Access Token Response",
inputs: [
{
type: "editor",
name: "response",
defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true,
readOnly: true,
language: "json",
},
],
};
},
},
],
async onApply(ctx, { values, contextId }) {
const headerPrefix = stringArg(values, "headerPrefix");
const grantType = stringArg(values, "grantType") as GrantType;
const credentialsInBody = values.credentials === "body";
const customParams = readCustomParams(values);
const tokenName = values.tokenName === "id_token" ? "id_token" : "access_token";
// Build external browser options if enabled
const useExternalBrowser = !!values.useExternalBrowser;
const externalBrowserOptions = useExternalBrowser
? {
useExternalBrowser: true,
callbackType: (stringArg(values, "callbackType") || "localhost") as CallbackType,
callbackPort: intArg(values, "callbackPort") ?? undefined,
}
: undefined;
let token: AccessToken;
if (grantType === "authorization_code") {
const authorizationUrl = stringArg(values, "authorizationUrl");
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl:
accessTokenUrl === "" || accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl:
authorizationUrl === "" || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
redirectUri: stringArgOrNull(values, "redirectUri"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
credentialsInBody,
pkce: values.usePkce
? {
challengeMethod: stringArg(values, "pkceChallengeMethod") || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, "pkceCodeVerifier") || genPkceCodeVerifier(),
}
: null,
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
customParams,
});
} else if (grantType === "implicit") {
const authorizationUrl = stringArg(values, "authorizationUrl");
token = await getImplicit(ctx, contextId, {
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, "clientId"),
redirectUri: stringArgOrNull(values, "redirectUri"),
responseType: stringArg(values, "responseType"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
state: stringArgOrNull(values, "state"),
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
customParams,
});
} else if (grantType === "client_credentials") {
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, "clientId"),
clientAssertionAlgorithm: stringArg(values, "clientAssertionAlgorithm") as Algorithm,
clientSecret: stringArg(values, "clientSecret"),
clientCredentialsMethod: stringArg(values, "clientCredentialsMethod"),
clientAssertionSecret: stringArg(values, "clientAssertionSecret"),
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody,
customParams,
});
} else if (grantType === "password") {
const accessTokenUrl = stringArg(values, "accessTokenUrl");
token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, "clientId"),
clientSecret: stringArg(values, "clientSecret"),
username: stringArg(values, "username"),
password: stringArg(values, "password"),
scope: stringArgOrNull(values, "scope"),
audience: stringArgOrNull(values, "audience"),
credentialsInBody,
customParams,
});
} else {
throw new Error(`Invalid grant type ${String(grantType)}`);
}
const headerName = stringArg(values, "headerName") || "Authorization";
const headerValue = `${headerPrefix} ${token.response[tokenName] ?? ""}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
},
},
};
function stringArgOrNull(
values: Record<string, JsonPrimitive | undefined>,
name: string,
): string | null {
const arg = values[name];
if (arg == null || arg === "") return null;
return `${arg}`;
}
/** Only the password grant stores its token under a username, so the others must not key by one */
function usernameArg(values: Record<string, JsonPrimitive | undefined>): string | null {
const grantType = String(values.grantType ?? defaultGrantType);
return grantType === "password" ? stringArgOrNull(values, "username") : null;
}
function stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {
const arg = stringArgOrNull(values, name);
if (!arg) return "";
return arg;
}
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
const arg = values[name];
if (arg == null || arg === "") return null;
const num = parseInt(`${arg}`, 10);
return Number.isNaN(num) ? null : num;
}

View File

@@ -0,0 +1,86 @@
import { createHash } from "node:crypto";
import type { Context } from "@yaakapp/api";
import { jwtExpiresAt } from "./util";
export async function storeToken(
ctx: Context,
args: TokenStoreArgs,
response: AccessTokenRawResponse,
tokenName: "access_token" | "id_token" = "access_token",
) {
if (!response[tokenName]) {
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
}
// Prefer expires_in from the response, falling back to the JWT's own exp claim
const expiresAt = response.expires_in
? Date.now() + response.expires_in * 1000
: jwtExpiresAt(response[tokenName]);
const token: AccessToken = {
response,
expiresAt,
};
await ctx.store.set<AccessToken>(tokenStoreKey(args), token);
return token;
}
export async function getToken(ctx: Context, args: TokenStoreArgs) {
return ctx.store.get<AccessToken>(tokenStoreKey(args));
}
export async function deleteToken(ctx: Context, args: TokenStoreArgs) {
return ctx.store.delete(tokenStoreKey(args));
}
export async function resetDataDirKey(ctx: Context, contextId: string) {
const key = new Date().toISOString();
return ctx.store.set<string>(dataDirStoreKey(contextId), key);
}
export async function getDataDirKey(ctx: Context, contextId: string) {
const key = (await ctx.store.get<string>(dataDirStoreKey(contextId))) ?? "default";
return `${contextId}::${key}`;
}
export interface TokenStoreArgs {
contextId: string;
clientId: string;
accessTokenUrl: string | null;
authorizationUrl: string | null;
username?: string | null;
}
/**
* Generate a store key to use based on some arguments. The arguments will be normalized a bit to
* account for slight variations (like domains with and without a protocol scheme).
*/
function tokenStoreKey(args: TokenStoreArgs) {
const hash = createHash("md5");
if (args.contextId) hash.update(args.contextId.trim());
if (args.clientId) hash.update(args.clientId.trim());
if (args.accessTokenUrl) hash.update(args.accessTokenUrl.trim().replace(/^https?:\/\//, ""));
if (args.authorizationUrl) hash.update(args.authorizationUrl.trim().replace(/^https?:\/\//, ""));
if (args.username) hash.update(args.username);
const key = hash.digest("hex");
return ["token", key].join("::");
}
function dataDirStoreKey(contextId: string) {
return ["data_dir", contextId].join("::");
}
export interface AccessToken {
response: AccessTokenRawResponse;
expiresAt: number | null;
}
export interface AccessTokenRawResponse {
access_token: string;
id_token?: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
error?: string;
error_description?: string;
scope?: string;
}

View File

@@ -0,0 +1,112 @@
import jwt from "jsonwebtoken";
import type { AccessToken } from "./store";
export function isTokenExpired(
token: AccessToken,
tokenName: "access_token" | "id_token" = "access_token",
) {
// Fall back to the JWT's own exp claim for tokens stored without an expiry
// (eg. from a token response that had no expires_in). Decode the same token
// that gets sent as the credential.
const expiresAt = token.expiresAt ?? jwtExpiresAt(token.response[tokenName]);
return expiresAt != null && Date.now() > expiresAt;
}
/**
* Get the expiry timestamp (ms) from a JWT's `exp` claim, or null if the token
* is not a JWT or has no `exp`.
*/
export function jwtExpiresAt(token: string | undefined): number | null {
if (!token) return null;
try {
const payload = jwt.decode(token);
if (payload != null && typeof payload === "object" && typeof payload.exp === "number") {
return payload.exp * 1000;
}
} catch {
// Opaque (non-JWT) token
}
return null;
}
export function extractCode(urlStr: string, redirectUri: string | null): string | null {
const url = new URL(urlStr);
if (!urlMatchesRedirect(url, redirectUri)) {
console.log("[oauth2] URL does not match redirect origin/path; skipping.");
return null;
}
// Prefer query param; fall back to fragment if query lacks it
const query = url.searchParams;
const queryError = query.get("error");
const queryDesc = query.get("error_description");
const queryUri = query.get("error_uri");
let hashParams: URLSearchParams | null = null;
if (url.hash && url.hash.length > 1) {
hashParams = new URLSearchParams(url.hash.slice(1));
}
const hashError = hashParams?.get("error");
const hashDesc = hashParams?.get("error_description");
const hashUri = hashParams?.get("error_uri");
const error = queryError || hashError;
if (error) {
const desc = queryDesc || hashDesc;
const uri = queryUri || hashUri;
let message = `Failed to authorize: ${error}`;
if (desc) message += ` (${desc})`;
if (uri) message += ` [${uri}]`;
throw new Error(message);
}
const queryCode = query.get("code");
if (queryCode) return queryCode;
const hashCode = hashParams?.get("code");
if (hashCode) return hashCode;
console.log("[oauth2] Code not found");
return null;
}
export function urlMatchesRedirect(url: URL, redirectUrl: string | null): boolean {
if (!redirectUrl) return true;
let redirect: URL;
try {
redirect = new URL(redirectUrl);
} catch {
console.log("[oauth2] Invalid redirect URI; skipping.");
return false;
}
const sameProtocol = url.protocol === redirect.protocol;
const sameHost = url.hostname.toLowerCase() === redirect.hostname.toLowerCase();
const normalizePort = (u: URL) =>
(u.protocol === "https:" && (!u.port || u.port === "443")) ||
(u.protocol === "http:" && (!u.port || u.port === "80"))
? ""
: u.port;
const samePort = normalizePort(url) === normalizePort(redirect);
const normPath = (p: string) => {
const withLeading = p.startsWith("/") ? p : `/${p}`;
// strip trailing slashes, keep root as "/"
return withLeading.replace(/\/+$/g, "") || "/";
};
// Require redirect path to be a prefix of the navigated URL path
const urlPath = normPath(url.pathname);
const redirectPath = normPath(redirect.pathname);
const pathMatches = urlPath === redirectPath || urlPath.startsWith(`${redirectPath}/`);
return sameProtocol && sameHost && samePort && pathMatches;
}

View File

@@ -0,0 +1,137 @@
import type { HttpRequest } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { readCustomParams } from "../src/customParams";
import { getAuthorizationCode } from "../src/grants/authorizationCode";
const REDIRECT_URI = "https://app.example.com/callback";
/**
* Drives the embedded-browser flow: records the authorization URL the plugin
* opens, then navigates straight to the redirect with a code.
*/
function createMockContext() {
const opened: string[] = [];
const sent: Partial<HttpRequest>[] = [];
const values = new Map<string, unknown>();
const ctx = {
store: {
async set<T>(key: string, value: T) {
values.set(key, value);
},
async get<T>(key: string) {
return values.get(key) as T | undefined;
},
},
window: {
async openUrl({
url,
onNavigate,
}: {
url: string;
onNavigate: (e: { url: string }) => Promise<void>;
}) {
opened.push(url);
// Deferred because onNavigate closes the window it is handed back from
setTimeout(() => onNavigate({ url: `${REDIRECT_URI}?code=code-123` }), 0);
return { close() {} };
},
},
httpRequest: {
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
sent.push(httpRequest);
return {
httpResponse: { status: 200, error: null },
body: {
async text() {
return JSON.stringify({ access_token: "token-123" });
},
},
};
},
},
} as never;
return { ctx, opened, sent };
}
const baseArgs = {
authorizationUrl: "https://auth.example.com/authorize",
accessTokenUrl: "https://auth.example.com/token",
clientId: "client-123",
clientSecret: "secret",
redirectUri: REDIRECT_URI,
scope: "openid",
state: null,
audience: null,
credentialsInBody: true,
pkce: null,
tokenName: "access_token" as const,
};
function pairs(...rows: { name: string; value: string; enabled?: boolean }[]) {
return JSON.stringify(rows.map((r) => ({ enabled: true, ...r })));
}
describe("authorization code custom parameters", () => {
test("puts custom authorization params on the authorize URL", async () => {
const { ctx, opened } = createMockContext();
await getAuthorizationCode(ctx, "request-1", {
...baseArgs,
customParams: readCustomParams({
authorizationParams: pairs(
{ name: "prompt", value: "consent" },
{ name: "realm", value: "employees" },
{ name: "skipped", value: "nope", enabled: false },
),
}),
});
const url = new URL(opened[0]!);
expect(url.searchParams.get("prompt")).toBe("consent");
expect(url.searchParams.get("realm")).toBe("employees");
expect(url.searchParams.has("skipped")).toBe(false);
// Generated params are still there
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe("client-123");
expect(url.searchParams.get("scope")).toBe("openid");
expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT_URI);
});
test("keeps the flow's redirect_uri when a custom param tries to change it", async () => {
const { ctx, opened } = createMockContext();
await getAuthorizationCode(ctx, "request-1", {
...baseArgs,
customParams: readCustomParams({
authorizationParams: pairs({ name: "redirect_uri", value: "https://evil.example.com" }),
}),
});
expect(new URL(opened[0]!).searchParams.get("redirect_uri")).toBe(REDIRECT_URI);
});
test("sends custom token entries with the exchange that follows", async () => {
const { ctx, sent } = createMockContext();
await getAuthorizationCode(ctx, "request-1", {
...baseArgs,
customParams: readCustomParams({
tokenHeaders: pairs(
{ name: "Origin", value: "https://app.example.com" },
{ name: "X-Skipped", value: "nope", enabled: false },
),
tokenBodyParams: pairs({ name: "realm", value: "employees" }),
}),
});
const headers = sent[0]!.headers ?? [];
expect(headers).toContainEqual({ name: "Origin", value: "https://app.example.com" });
expect(headers.map((h: { name: string }) => h.name)).not.toContain("X-Skipped");
const form = sent[0]!.body?.form ?? [];
expect(form).toContainEqual({ name: "realm", value: "employees" });
expect(form).toContainEqual({ name: "code", value: "code-123" });
});
});

View File

@@ -0,0 +1,164 @@
import { describe, expect, test } from "vite-plus/test";
import {
applyQueryParams,
mergeFormParams,
mergeHeaders,
parsePairs,
readCustomParams,
} from "../src/customParams";
function pairs(...rows: { name: string; value: string; enabled?: boolean }[]) {
return JSON.stringify(rows.map((r) => ({ enabled: true, ...r })));
}
describe("parsePairs", () => {
test("reads enabled rows", () => {
expect(parsePairs(pairs({ name: "Origin", value: "https://app.example.com" }))).toEqual([
{ name: "Origin", value: "https://app.example.com" },
]);
});
test("skips disabled rows", () => {
const value = pairs(
{ name: "Origin", value: "https://app.example.com" },
{ name: "X-Debug", value: "1", enabled: false },
);
expect(parsePairs(value).map((p) => p.name)).toEqual(["Origin"]);
});
test("skips rows without a name", () => {
expect(parsePairs(pairs({ name: " ", value: "ignored" }))).toEqual([]);
});
test("trims names and keeps empty values", () => {
expect(parsePairs(pairs({ name: " realm ", value: "" }))).toEqual([
{ name: "realm", value: "" },
]);
});
test("returns nothing for missing or malformed input", () => {
expect(parsePairs(undefined)).toEqual([]);
expect(parsePairs("")).toEqual([]);
expect(parsePairs("not json")).toEqual([]);
expect(parsePairs('{"name":"Origin"}')).toEqual([]);
});
});
describe("merge precedence", () => {
const generated = [
{ name: "User-Agent", value: "yaak" },
{ name: "Content-Type", value: "application/x-www-form-urlencoded" },
];
test("keeps generated entries that are not overridden", () => {
const merged = mergeHeaders(generated, [{ name: "Origin", value: "https://app.example.com" }]);
expect(merged).toEqual([...generated, { name: "Origin", value: "https://app.example.com" }]);
});
test("replaces generated headers by name, ignoring case", () => {
const merged = mergeHeaders(generated, [{ name: "content-type", value: "application/json" }]);
expect(merged).toEqual([
{ name: "User-Agent", value: "yaak" },
{ name: "content-type", value: "application/json" },
]);
});
test("keeps repeated custom entries of the same name", () => {
const merged = mergeHeaders(generated, [
{ name: "X-Trace", value: "a" },
{ name: "X-Trace", value: "b" },
]);
expect(merged.filter((h) => h.name === "X-Trace")).toHaveLength(2);
});
test("matches form params case-sensitively", () => {
const merged = mergeFormParams(
[{ name: "scope", value: "openid" }],
[{ name: "Scope", value: "other" }],
);
expect(merged).toEqual([
{ name: "scope", value: "openid" },
{ name: "Scope", value: "other" },
]);
});
});
describe("applyQueryParams", () => {
test("appends custom params and overrides generated ones", () => {
const url = new URL("https://auth.example.com/authorize?client_id=abc&scope=openid");
applyQueryParams(url, [
{ name: "scope", value: "openid email" },
{ name: "realm", value: "employees" },
]);
expect(url.searchParams.get("client_id")).toBe("abc");
expect(url.searchParams.getAll("scope")).toEqual(["openid email"]);
expect(url.searchParams.get("realm")).toBe("employees");
});
test("keeps repeated custom params", () => {
const url = new URL("https://auth.example.com/authorize");
applyQueryParams(url, [
{ name: "resource", value: "one" },
{ name: "resource", value: "two" },
]);
expect(url.searchParams.getAll("resource")).toEqual(["one", "two"]);
});
});
describe("readCustomParams", () => {
test("sends token entries on the refresh request too", () => {
const custom = readCustomParams({
tokenHeaders: pairs({ name: "Origin", value: "https://app.example.com" }),
tokenBodyParams: pairs({ name: "realm", value: "employees" }),
});
expect(custom.refresh.headers).toEqual([{ name: "Origin", value: "https://app.example.com" }]);
expect(custom.refresh.body).toEqual([{ name: "realm", value: "employees" }]);
});
test("lets a refresh entry override the token entry of the same name", () => {
const custom = readCustomParams({
tokenHeaders: pairs({ name: "Origin", value: "https://app.example.com" }),
refreshHeaders: pairs({ name: "origin", value: "https://refresh.example.com" }),
tokenBodyParams: pairs({ name: "realm", value: "employees" }),
refreshBodyParams: pairs({ name: "realm", value: "service" }),
});
// The token request is unaffected by the refresh-only entries
expect(custom.token.headers).toEqual([{ name: "Origin", value: "https://app.example.com" }]);
expect(custom.token.body).toEqual([{ name: "realm", value: "employees" }]);
expect(custom.refresh.headers).toEqual([
{ name: "origin", value: "https://refresh.example.com" },
]);
expect(custom.refresh.body).toEqual([{ name: "realm", value: "service" }]);
});
test("reads authorization params separately", () => {
const custom = readCustomParams({
authorizationParams: pairs({ name: "prompt", value: "consent" }),
});
expect(custom.authorizationQuery).toEqual([{ name: "prompt", value: "consent" }]);
expect(custom.token.headers).toEqual([]);
});
test("is empty when nothing is configured", () => {
const custom = readCustomParams({ clientId: "abc" });
expect(custom).toEqual({
authorizationQuery: [],
token: { headers: [], body: [] },
refresh: { headers: [], body: [] },
});
});
});

View File

@@ -0,0 +1,198 @@
import type { HttpRequest } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { fetchAccessToken } from "../src/fetchAccessToken";
/**
* Captures the request handed to ctx.httpRequest.send so tests can assert on the
* form body, and replies with a minimal successful token response.
*/
function createMockContext() {
const sent: Partial<HttpRequest>[] = [];
const ctx = {
httpRequest: {
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
sent.push(httpRequest);
return {
httpResponse: { status: 200, error: null },
body: {
async text() {
return JSON.stringify({ access_token: "token-123" });
},
},
};
},
},
} as never;
return { ctx, sent };
}
function formNames(httpRequest: Partial<HttpRequest>) {
return (httpRequest.body?.form ?? []).map((p: { name: string }) => p.name);
}
function formValue(httpRequest: Partial<HttpRequest>, name: string) {
return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value;
}
function headerValue(httpRequest: Partial<HttpRequest>, name: string) {
return (httpRequest.headers ?? []).find(
(h: { name: string }) => h.name.toLowerCase() === name.toLowerCase(),
)?.value;
}
const baseArgs = {
clientId: "client-123",
accessTokenUrl: "https://auth.example.com/token",
scope: "openid profile",
audience: null,
clientSecret: "secret",
credentialsInBody: true,
params: [],
};
describe("fetchAccessToken scope handling", () => {
test("omits scope for the authorization code grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "authorization_code",
params: [{ name: "code", value: "abc" }],
});
expect(formNames(sent[0]!)).not.toContain("scope");
// The rest of the request is untouched
expect(formValue(sent[0]!, "grant_type")).toBe("authorization_code");
expect(formValue(sent[0]!, "code")).toBe("abc");
});
test("sends scope for the client credentials grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, { ...baseArgs, grantType: "client_credentials" });
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
});
test("sends scope for the password grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, { ...baseArgs, grantType: "password" });
expect(formValue(sent[0]!, "scope")).toBe("openid profile");
});
test("still sends audience for the authorization code grant", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "authorization_code",
audience: "https://api.example.com",
});
expect(formValue(sent[0]!, "audience")).toBe("https://api.example.com");
expect(formNames(sent[0]!)).not.toContain("scope");
});
});
describe("fetchAccessToken custom parameters", () => {
test("sends a custom header with the token request", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "authorization_code",
custom: {
headers: [{ name: "Origin", value: "https://app.example.com" }],
body: [],
},
});
expect(headerValue(sent[0]!, "Origin")).toBe("https://app.example.com");
});
test("keeps the generated headers that are not overridden", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "client_credentials",
credentialsInBody: false,
custom: {
headers: [{ name: "Origin", value: "https://app.example.com" }],
body: [],
},
});
expect(headerValue(sent[0]!, "User-Agent")).toBe("yaak");
expect(headerValue(sent[0]!, "Content-Type")).toBe("application/x-www-form-urlencoded");
expect(headerValue(sent[0]!, "Accept")).toBe(
"application/x-www-form-urlencoded, application/json",
);
// Basic credentials still go out untouched
expect(headerValue(sent[0]!, "Authorization")).toMatch(/^Basic /);
});
test("replaces a generated header of the same name", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "client_credentials",
credentialsInBody: false,
custom: {
headers: [
{ name: "content-type", value: "application/json" },
{ name: "Authorization", value: "Custom abc123" },
],
body: [],
},
});
const contentTypes = (sent[0]!.headers ?? []).filter(
(h: { name: string }) => h.name.toLowerCase() === "content-type",
);
expect(contentTypes).toEqual([{ name: "content-type", value: "application/json" }]);
expect(headerValue(sent[0]!, "Authorization")).toBe("Custom abc123");
});
test("sends custom body params in the form-encoded body", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "password",
params: [{ name: "username", value: "alice" }],
custom: {
headers: [],
body: [{ name: "realm", value: "employees" }],
},
});
expect(sent[0]!.bodyType).toBe("application/x-www-form-urlencoded");
expect(formValue(sent[0]!, "realm")).toBe("employees");
// Generated params survive alongside it
expect(formValue(sent[0]!, "grant_type")).toBe("password");
expect(formValue(sent[0]!, "username")).toBe("alice");
expect(formValue(sent[0]!, "client_id")).toBe("client-123");
});
test("replaces a generated body param of the same name", async () => {
const { ctx, sent } = createMockContext();
await fetchAccessToken(ctx, {
...baseArgs,
grantType: "client_credentials",
custom: {
headers: [],
body: [{ name: "scope", value: "custom-scope" }],
},
});
expect(formNames(sent[0]!).filter((n: string) => n === "scope")).toHaveLength(1);
expect(formValue(sent[0]!, "scope")).toBe("custom-scope");
});
});

View File

@@ -0,0 +1,141 @@
import type { HttpRequest } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { getOrRefreshAccessToken } from "../src/getOrRefreshAccessToken";
import type { TokenStoreArgs } from "../src/store";
import { storeToken } from "../src/store";
/**
* Captures the refresh request handed to ctx.httpRequest.send and replies with
* a minimal successful token response.
*/
function createMockContext() {
const sent: Partial<HttpRequest>[] = [];
const values = new Map<string, unknown>();
const ctx = {
store: {
async set<T>(key: string, value: T) {
values.set(key, value);
},
async get<T>(key: string) {
return values.get(key) as T | undefined;
},
async delete(key: string) {
return values.delete(key);
},
},
httpRequest: {
async send({ httpRequest }: { httpRequest: Partial<HttpRequest> }) {
sent.push(httpRequest);
return {
httpResponse: { status: 200, error: null },
body: {
async text() {
return JSON.stringify({ access_token: "refreshed-token" });
},
},
};
},
},
} as never;
return { ctx, sent };
}
const tokenArgs: TokenStoreArgs = {
contextId: "request-1",
clientId: "client-123",
accessTokenUrl: "https://auth.example.com/token",
authorizationUrl: null,
};
const refreshArgs = {
accessTokenUrl: "https://auth.example.com/token",
scope: "openid",
clientId: "client-123",
clientSecret: "secret",
credentialsInBody: true,
forceRefresh: true,
};
function headerValue(httpRequest: Partial<HttpRequest>, name: string) {
return (httpRequest.headers ?? []).find(
(h: { name: string }) => h.name.toLowerCase() === name.toLowerCase(),
)?.value;
}
function formValue(httpRequest: Partial<HttpRequest>, name: string) {
return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value;
}
async function seedToken(ctx: never) {
await storeToken(ctx, tokenArgs, {
access_token: "old-token",
refresh_token: "refresh-123",
});
}
describe("getOrRefreshAccessToken custom parameters", () => {
test("sends custom headers and body params with the refresh request", async () => {
const { ctx, sent } = createMockContext();
await seedToken(ctx);
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
...refreshArgs,
custom: {
headers: [{ name: "Origin", value: "https://app.example.com" }],
body: [{ name: "realm", value: "employees" }],
},
});
expect(token?.response.access_token).toBe("refreshed-token");
expect(headerValue(sent[0]!, "Origin")).toBe("https://app.example.com");
expect(formValue(sent[0]!, "realm")).toBe("employees");
// Generated entries survive
expect(headerValue(sent[0]!, "User-Agent")).toBe("yaak");
expect(formValue(sent[0]!, "grant_type")).toBe("refresh_token");
expect(formValue(sent[0]!, "refresh_token")).toBe("refresh-123");
expect(formValue(sent[0]!, "client_secret")).toBe("secret");
});
test("replaces generated entries of the same name", async () => {
const { ctx, sent } = createMockContext();
await seedToken(ctx);
await getOrRefreshAccessToken(ctx, tokenArgs, {
...refreshArgs,
custom: {
headers: [{ name: "user-agent", value: "custom-agent" }],
body: [{ name: "scope", value: "custom-scope" }],
},
});
const userAgents = (sent[0]!.headers ?? []).filter(
(h: { name: string }) => h.name.toLowerCase() === "user-agent",
);
expect(userAgents).toEqual([{ name: "user-agent", value: "custom-agent" }]);
const scopes = (sent[0]!.body?.form ?? []).filter((p: { name: string }) => p.name === "scope");
expect(scopes).toEqual([{ name: "scope", value: "custom-scope" }]);
});
test("sends the generated request untouched with no custom parameters", async () => {
const { ctx, sent } = createMockContext();
await seedToken(ctx);
await getOrRefreshAccessToken(ctx, tokenArgs, refreshArgs);
expect((sent[0]!.headers ?? []).map((h: { name: string }) => h.name)).toEqual([
"User-Agent",
"Accept",
"Content-Type",
]);
expect((sent[0]!.body?.form ?? []).map((p: { name: string }) => p.name)).toEqual([
"grant_type",
"refresh_token",
"scope",
"client_id",
"client_secret",
]);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, test } from "vite-plus/test";
import type { TokenStoreArgs } from "../src/store";
import { getToken, storeToken } from "../src/store";
function createMockContext() {
const values = new Map<string, unknown>();
return {
store: {
async set<T>(key: string, value: T) {
values.set(key, value);
},
async get<T>(key: string) {
return values.get(key) as T | undefined;
},
},
} as never;
}
describe("token store", () => {
test("separates password grant tokens when the username changes", async () => {
const ctx = createMockContext();
const aliceArgs: TokenStoreArgs = {
contextId: "request-1",
clientId: "client-123",
accessTokenUrl: "https://auth.example.com/token",
authorizationUrl: null,
username: "alice@example.com",
};
const bobArgs: TokenStoreArgs = { ...aliceArgs, username: "bob@example.com" };
await storeToken(ctx, aliceArgs, { access_token: "alice-token" });
expect((await getToken(ctx, aliceArgs))?.response.access_token).toBe("alice-token");
expect(await getToken(ctx, bobArgs)).toBeUndefined();
});
test("keeps the same key for grants without a username", async () => {
const ctx = createMockContext();
const args: TokenStoreArgs = {
contextId: "request-1",
clientId: "client-123",
accessTokenUrl: "https://auth.example.com/token",
authorizationUrl: null,
};
await storeToken(ctx, args, { access_token: "cc-token" });
expect((await getToken(ctx, { ...args, username: null }))?.response.access_token).toBe(
"cc-token",
);
expect((await getToken(ctx, { ...args, username: "" }))?.response.access_token).toBe("cc-token");
});
});

View File

@@ -0,0 +1,162 @@
import jwt from "jsonwebtoken";
import { describe, expect, test } from "vite-plus/test";
import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
describe("extractCode", () => {
test("extracts code from query when same origin + path", () => {
const url = "https://app.example.com/cb?code=abc123&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc123");
});
test("extracts code from query with weird path", () => {
const url = "https://app.example.com/cbwithextra?code=abc123&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBeNull();
});
test("allows trailing slash differences", () => {
expect(extractCode("https://app.example.com/cb/?code=abc", "https://app.example.com/cb")).toBe(
"abc",
);
expect(extractCode("https://app.example.com/cb?code=abc", "https://app.example.com/cb/")).toBe(
"abc",
);
});
test("treats default ports as equal (https:443, http:80)", () => {
expect(
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:443/cb"),
).toBe("abc");
expect(extractCode("http://app.example.com/cb?code=abc", "http://app.example.com:80/cb")).toBe(
"abc",
);
});
test("rejects different port", () => {
expect(
extractCode("https://app.example.com/cb?code=abc", "https://app.example.com:8443/cb"),
).toBeNull();
});
test("rejects different hostname (including subdomain changes)", () => {
expect(
extractCode("https://evil.example.com/cb?code=abc", "https://app.example.com/cb"),
).toBeNull();
});
test("requires path to start with redirect path (ignoring query/hash)", () => {
// same origin but wrong path -> null
expect(
extractCode("https://app.example.com/other?code=abc", "https://app.example.com/cb"),
).toBeNull();
// deeper subpath under the redirect path -> allowed (prefix match)
expect(
extractCode("https://app.example.com/cb/deep?code=abc", "https://app.example.com/cb"),
).toBe("abc");
});
test("works with custom schemes", () => {
expect(extractCode("myapp://cb?code=abc", "myapp://cb")).toBe("abc");
});
test("prefers query over fragment when both present", () => {
const url = "https://app.example.com/cb?code=queryCode#code=hashCode";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("queryCode");
});
test("extracts code from fragment when query lacks code", () => {
const url = "https://app.example.com/cb#code=fromHash&state=xyz";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("fromHash");
});
test("returns null if no code present (query or fragment)", () => {
const url = "https://app.example.com/cb?state=only";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBeNull();
});
test("returns null when provider reports an error", () => {
const url = "https://app.example.com/cb?error=access_denied&error_description=oopsy";
const redirect = "https://app.example.com/cb";
expect(() => extractCode(url, redirect)).toThrow("Failed to authorize: access_denied");
});
test("when redirectUri is null, extracts code from any URL", () => {
expect(extractCode("https://random.example.com/whatever?code=abc", null)).toBe("abc");
});
test("handles extra params gracefully", () => {
const url = "https://app.example.com/cb?foo=1&bar=2&code=abc&baz=3";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
test("ignores fragment noise when code is in query", () => {
const url = "https://app.example.com/cb?code=abc#some=thing";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
// If you decide NOT to support fragment-based codes, flip these to expect null or mark as .skip
test("supports fragment-only code for response_mode=fragment providers", () => {
const url = "https://app.example.com/cb#state=xyz&code=abc";
const redirect = "https://app.example.com/cb";
expect(extractCode(url, redirect)).toBe("abc");
});
});
describe("isTokenExpired", () => {
const jwtWithExp = (expSecondsFromNow: number) =>
jwt.sign({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, "test-secret");
test("uses stored expiresAt when present", () => {
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() - 1000 })).toBe(
true,
);
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() + 10000 })).toBe(
false,
);
});
test("falls back to JWT exp claim when expiresAt is null", () => {
expect(
isTokenExpired({ response: { access_token: jwtWithExp(-60) }, expiresAt: null }),
).toBe(true);
expect(
isTokenExpired({ response: { access_token: jwtWithExp(60) }, expiresAt: null }),
).toBe(false);
});
test("treats opaque tokens without expiresAt as non-expiring", () => {
expect(isTokenExpired({ response: { access_token: "opaque-token" }, expiresAt: null })).toBe(
false,
);
});
test("checks the token that is used as the credential", () => {
// Expired id_token credential is not masked by an opaque access_token
const token = {
response: { access_token: "opaque-token", id_token: jwtWithExp(-60) },
expiresAt: null,
};
expect(isTokenExpired(token, "id_token")).toBe(true);
expect(isTokenExpired(token, "access_token")).toBe(false);
});
});
describe("jwtExpiresAt", () => {
test("extracts exp claim in milliseconds", () => {
const exp = Math.floor(Date.now() / 1000) + 300;
expect(jwtExpiresAt(jwt.sign({ exp }, "test-secret"))).toBe(exp * 1000);
});
test("returns null for non-JWT or missing tokens", () => {
expect(jwtExpiresAt("not-a-jwt")).toBeNull();
expect(jwtExpiresAt(undefined)).toBeNull();
expect(jwtExpiresAt(jwt.sign({ foo: "bar" }, "test-secret"))).toBeNull();
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,59 @@
# JSONPath
A filter plugin that enables [JSONPath](https://en.wikipedia.org/wiki/JSONPath)
extraction and filtering for JSON responses, making it easy to extract specific values
from complex JSON structures.
![Screenshot of JSONPath filtering](screenshot.png)
## Overview
This plugin provides JSONPath filtering for responses in Yaak. JSONPath is a query
language for JSON, similar to XPath for XML, that provides the ability to extract data
from JSON documents using a simple, expressive syntax. This is useful for working with
complex API responses where you need to only view a small subset of response data.
## How JSONPath Works
JSONPath uses a dot-notation syntax to navigate JSON structures:
- `$` - Root element
- `.` - Child element
- `..` - Recursive descent
- `*` - Wildcard
- `[]` - Array index or filter
## JSONPath Syntax Examples
### Basic Navigation
```
$.store.book[0].title # First book title
$.store.book[*].author # All book authors
$.store.book[-1] # Last book
$.store.book[0,1] # First two books
$.store.book[0:2] # First two books (slice)
```
### Filtering
```
$.store.book[?(@.price < 10)] # Books under $10
$.store.book[?(@.author == 'Tolkien')] # Books by Tolkien
$.store.book[?(@.category == 'fiction')] # Fiction books
```
### Recursive Search
```
$..author # All authors anywhere in the document
$..book[2] # Third book anywhere
$..price # All prices in the document
```
## Usage
1. Make an API request that returns JSON data
2. Below the response body, click the filter icon
3. Enter a JSONPath expression
4. View the extracted data in the results panel

View File

@@ -0,0 +1,24 @@
{
"name": "@yaak/filter-jsonpath",
"displayName": "JSONPath Filter",
"version": "0.1.0",
"private": true,
"description": "Filter JSON response data using JSONPath expressions",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/filter-jsonpath"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"@yaakapp-internal/lib": "^1.0.0",
"jsonpath-plus": "^10.3.0"
},
"devDependencies": {
"@types/jsonpath": "^0.2.4"
}
}

View File

@@ -0,0 +1,39 @@
import type { PluginDefinition } from "@yaakapp/api";
import { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
import { JSONPath } from "jsonpath-plus";
export const plugin: PluginDefinition = {
filter: {
name: "JSONPath",
description: "Filter JSONPath",
onFilter(_ctx, args) {
const parsed = JSON.parse(args.payload);
try {
// jsonpath-plus doesn't decode JSON-quoted keys reliably and can treat
// their contents as operators. Only complete literal paths take this
// route; all other expressions retain the library's existing behavior.
const segments = args.filter.includes('["') ? jsonPathToSegments(args.filter) : null;
let filtered: unknown;
if (segments != null) {
let value: unknown = parsed;
for (const segment of segments) {
const key = segment.kind === "key" ? segment.key : segment.index;
if (value == null || !Object.prototype.hasOwnProperty.call(value, key)) {
return { content: "[]" };
}
value = (value as Record<string, unknown>)[key];
}
filtered = [value];
} else {
filtered = JSONPath({ path: args.filter, json: parsed });
}
return { content: JSON.stringify(filtered, null, 2) };
} catch (err) {
return {
content: "",
error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
},
};

View File

@@ -0,0 +1,130 @@
import { forceParsing } from "@codemirror/language";
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { JSONPath } from "jsonpath-plus";
import { describe, expect, test } from "vite-plus/test";
import {
jsonPathSegmentsAt,
jsonPathToSegments,
segmentsToJsonPath,
} from "../../../apps/yaak-client/components/core/Editor/json/jsonPath";
import { plugin } from "../src";
async function filter(json: unknown, path: string) {
const result = await plugin.filter!.onFilter({} as never, {
payload: JSON.stringify(json),
filter: path,
mimeType: "application/json",
});
expect(result.error).toBeUndefined();
return JSON.parse(result.content);
}
/** Exercise the caret path and expression the breadcrumb button actually sends. */
function breadcrumb(json: unknown) {
const doc = JSON.stringify(json, null, 2);
const state = EditorState.create({ doc, extensions: [jsonc()] });
forceParsing({ state } as never, doc.length, 5000);
const segments = jsonPathSegmentsAt(state, doc.indexOf('"needle-value"') + 4);
expect(segments).not.toBeNull();
const path = segmentsToJsonPath(segments ?? []);
expect(jsonPathToSegments(path)).toEqual(segments);
return path;
}
describe("breadcrumb paths through the real filter plugin", () => {
test.each([
"identifier",
"a.b",
"space key",
"",
'quote"key',
"single'key",
"back\\slash",
"bracket]key",
"combined\"\\]'key",
"*",
"..",
"$",
"^",
"~",
"a,b",
"0:2",
"?(true)",
"(@.length-1)",
"@number()",
"`key",
"x)]y",
"x)']y",
"semi;colon",
"percent%@%key",
"line\nbreak\t\u0000",
"emoji 🤔",
"lone surrogate \ud800",
"__proto__",
"constructor",
])("selects the literal key %j, not an operator or expression", async (key) => {
const json = Object.fromEntries([
[key, "needle-value"],
["decoy", "wrong-value"],
]);
expect(await filter(json, breadcrumb(json))).toEqual(["needle-value"]);
});
test("nested unusual keys and arrays round-trip and filter at every crumb", async () => {
const inner = Object.fromEntries([["inner'\\]", { "": "needle-value" }]]);
const json = { outer: Object.fromEntries([['quote"key', ["decoy", inner]]]) };
const path = breadcrumb(json);
const segments = jsonPathToSegments(path)!;
expect(await filter(json, path)).toEqual(["needle-value"]);
expect(await filter(json, segmentsToJsonPath(segments, 4))).toEqual([{ "": "needle-value" }]);
expect(await filter(json, segmentsToJsonPath(segments, 3))).toEqual([inner]);
expect(await filter(json, segmentsToJsonPath(segments, 2))).toEqual([["decoy", inner]]);
expect(await filter(json, segmentsToJsonPath(segments, 1))).toEqual([json.outer]);
expect(await filter(json, segmentsToJsonPath(segments, 0))).toEqual([json]);
});
test("root arrays and quoted numeric object keys remain distinct", async () => {
const json = ["decoy", { "01": { "1": "needle-value" } }];
expect(await filter(json, breadcrumb(json))).toEqual(["needle-value"]);
expect(await filter(json, '$[1]["01"]["missing"]')).toEqual([]);
});
test.each([null, false, 0, "", [1, 2], { value: 3 }])(
"retains the matched value %j",
async (value) => {
expect(await filter({ 'quote"key': value }, '$["quote\\\"key"]')).toEqual([value]);
},
);
test("only follows own properties, while allowing actual prototype-named JSON keys", async () => {
const json = JSON.parse('{"a.b":{"__proto__":{"constructor":"own-value"}}}');
expect(await filter(json, '$["a.b"].__proto__.constructor')).toEqual(["own-value"]);
expect(await filter(json, '$["a.b"].constructor')).toEqual([]);
expect(await filter(json, '$["a.b"].__proto__.toString')).toEqual([]);
expect(await filter(json, '$["a.b"].__proto__.constructor.__proto__')).toEqual([]);
});
});
describe("existing JSONPath expressions", () => {
const json = { items: [{ id: 1 }, { id: 2 }, { id: 3 }], "space key": 4 };
test.each([
["$", [json]],
["$.items[1].id", [2]],
['$["space key"]', [4]],
["$['space key']", [4]],
["$.items[*].id", [1, 2, 3]],
["$..id", [1, 2, 3]],
["$.items[0:2].id", [1, 2]],
["$.items[0,2].id", [1, 3]],
["$.items[?(@.id > 1)].id", [2, 3]],
['$.items[?(@["id"] > 1)].id', [2, 3]],
["$.items[(@.length-1)].id", [3]],
['$["items"][*].id', [1, 2, 3]],
['$["items"].$', [json.items]],
["$.missing", []],
])("preserves %s", async (path, expected) => {
expect(await filter(json, path as string)).toEqual(expected);
expect(await filter(json, path as string)).toEqual(JSONPath({ path: path as string, json }));
});
});

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,15 @@
{
"name": "@yaak/filter-xpath",
"displayName": "XPath Filter",
"version": "0.1.0",
"private": true,
"description": "Filter response XML data using XPath expressions",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.12",
"xpath": "^0.0.34"
}
}

View File

@@ -0,0 +1,28 @@
/* oxlint-disable no-base-to-string */
import { DOMParser } from "@xmldom/xmldom";
import type { PluginDefinition } from "@yaakapp/api";
import xpath from "xpath";
export const plugin: PluginDefinition = {
filter: {
name: "XPath",
description: "Filter XPath",
onFilter(_ctx, args) {
// oxlint-disable-next-line no-explicit-any
const doc: any = new DOMParser().parseFromString(args.payload, "text/xml");
try {
const result = xpath.select(args.filter, doc, false);
if (Array.isArray(result)) {
return { content: result.map((r) => String(r)).join("\n") };
}
// Not sure what cases this happens in (?)
return { content: String(result) };
} catch (err) {
return {
content: "",
error: `Invalid filter: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,15 @@
{
"name": "@yaak/importer-curl",
"displayName": "cURL Importer",
"version": "0.1.0",
"private": true,
"description": "Import requests from cURL commands",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"shlex": "^3.0.0"
}
}

View File

@@ -0,0 +1,117 @@
type GraphQLDetectionSignal = {
score: number;
requiresGraphQLDocument?: boolean;
};
export type GraphQLJsonBody = {
query: string;
variables?: string;
operationName?: string;
};
type GraphQLJsonBodyArgs = {
mimeType: string | null;
text: string;
url: string;
};
export function isGraphQLJsonBody(args: GraphQLJsonBodyArgs): boolean {
return parseGraphQLJsonBody(args) != null;
}
export function parseGraphQLJsonBody({
mimeType,
text,
url,
}: GraphQLJsonBodyArgs): GraphQLJsonBody | null {
if (mimeType !== "application/json") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const body = parsed as Record<string, unknown>;
if (typeof body.query !== "string") {
return null;
}
if (hasExtraGraphQLEnvelopeFields(body)) {
return null;
}
const signals = getGraphQLDetectionSignals(body, url);
const score = signals.reduce((total, signal) => total + signal.score, 0);
const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument);
if (!hasGraphQLDocument || score < 4) {
return null;
}
const result: GraphQLJsonBody = { query: body.query };
if (body.variables != null) {
result.variables =
typeof body.variables === "string" ? body.variables : JSON.stringify(body.variables, null, 2);
}
if (typeof body.operationName === "string") {
result.operationName = body.operationName;
}
return result;
}
function hasExtraGraphQLEnvelopeFields(body: Record<string, unknown>): boolean {
const allowedKeys = new Set(["query", "variables", "operationName"]);
return Object.keys(body).some((key) => !allowedKeys.has(key));
}
function getGraphQLDetectionSignals(
body: Record<string, unknown>,
url: string,
): GraphQLDetectionSignal[] {
const signals: GraphQLDetectionSignal[] = [];
const query = body.query as string;
const urlPath = getUrlPath(url).toLowerCase();
if (/\b(graphql|gql)\b/.test(urlPath)) {
signals.push({ score: 2 });
}
if (/^(query|mutation|subscription|fragment)\b/.test(query.trim())) {
signals.push({ score: 3 });
} else if (/^\{[\s\S]*\}$/.test(query.trim())) {
signals.push({ score: 3, requiresGraphQLDocument: true });
}
if (/\{[\s\S]*\}/.test(query)) {
signals.push({ score: 1, requiresGraphQLDocument: true });
}
if (typeof body.operationName === "string" && body.operationName.trim() !== "") {
signals.push({ score: 1 });
}
if (
body.variables != null &&
(typeof body.variables === "object" || typeof body.variables === "string")
) {
signals.push({ score: 1 });
}
return signals;
}
function getUrlPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url;
}
}

View File

@@ -0,0 +1,775 @@
import type {
Context,
Environment,
Folder,
HttpRequest,
HttpUrlParameter,
PluginDefinition,
Workspace,
} from "@yaakapp/api";
import { split } from "shlex";
import { parseGraphQLJsonBody } from "./graphql";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
interface ExportResources {
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
}
const DATA_FLAGS = ["d", "data", "data-raw", "data-urlencode", "data-binary", "data-ascii"];
const SUPPORTED_FLAGS = [
["cookie", "b"],
["d", "data"], // Add url encoded data
["data-ascii"],
["data-binary"],
["data-raw"],
["data-urlencode"],
["digest"], // Apply auth as digest
["form", "F"], // Add multipart data
["get", "G"], // Put the post data in the URL
["header", "H"],
["request", "X"], // Request method
["url"], // Specify the URL explicitly
["url-query"],
["user", "u"], // Authentication
DATA_FLAGS,
].flat();
const BOOLEAN_FLAGS = ["G", "get", "digest"];
// Short flags that consume a value, derived so this stays in step with the
// tables above.
const VALUE_SHORT_FLAGS = SUPPORTED_FLAGS.flat().filter(
(name) => name.length === 1 && !BOOLEAN_FLAGS.includes(name),
);
/**
* Expand a short-flag token into separate arguments.
*
* curl reads a short cluster left to right, one option per character, until an
* option that takes a value — that one swallows the rest of the cluster. So
* `-XPOST` is `-X POST`, but `-fsSL` is four boolean flags rather than `-f`
* plus a value. Splitting unconditionally after the first character left
* `sSL` as a positional argument, and the first positional is read as the URL:
* `curl -fsSL https://example.com` imported with a URL of `sSL`.
*/
function expandShortFlags(token: string): string[] {
if (!token.startsWith("-") || token.startsWith("--") || token.length <= 2) {
return [token];
}
const expanded: string[] = [];
for (let i = 1; i < token.length; i++) {
const name = token[i] ?? "";
expanded.push(`-${name}`);
if (VALUE_SHORT_FLAGS.includes(name)) {
const value = token.slice(i + 1);
if (value) {
expanded.push(value);
}
break;
}
}
return expanded;
}
type FlagValue = string | boolean;
type FlagsByName = Record<string, FlagValue[]>;
export const plugin: PluginDefinition = {
importer: {
name: "cURL",
description: "Import cURL commands",
onImport(_ctx: Context, args: { text: string }) {
// oxlint-disable-next-line no-explicit-any
return convertCurl(args.text) as any;
},
},
};
/**
* Splits raw input into individual shell command strings.
* Handles line continuations, semicolons, and newline-separated curl commands.
*/
function splitCommands(rawData: string): string[] {
// Join line continuations (backslash-newline, and backslash-CRLF for
// Windows). Trailing spaces or tabs after the backslash are common when a
// command is copied from a terminal or a doc, and the shell treats
// "\ <newline>" as an escaped space rather than a continuation, so accept
// them here to keep the pasted command in one piece.
const joined = rawData.replace(/\\[ \t]*\r?\n/g, " ");
// Count consecutive backslashes immediately before position i.
// An even count means the quote at i is NOT escaped; odd means it IS escaped.
function isEscaped(i: number): boolean {
let backslashes = 0;
let j = i - 1;
while (j >= 0 && joined[j] === "\\") {
backslashes++;
j--;
}
return backslashes % 2 !== 0;
}
// Split on semicolons and newlines to separate commands
const commands: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let inDollarQuote = false;
for (let i = 0; i < joined.length; i++) {
if (joined[i] === undefined) break; // Make TS happy
const ch = joined[i];
const next = joined[i + 1];
// Track quoting state to avoid splitting inside quoted strings
if (!inDoubleQuote && !inDollarQuote && ch === "'" && !inSingleQuote) {
inSingleQuote = true;
current += ch;
continue;
}
if (inSingleQuote && ch === "'") {
inSingleQuote = false;
current += ch;
continue;
}
if (!inSingleQuote && !inDollarQuote && ch === '"' && !inDoubleQuote) {
inDoubleQuote = true;
current += ch;
continue;
}
if (inDoubleQuote && ch === '"' && !isEscaped(i)) {
inDoubleQuote = false;
current += ch;
continue;
}
if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === "$" && next === "'") {
inDollarQuote = true;
current += ch + next;
i++; // Skip the opening quote
continue;
}
if (inDollarQuote && ch === "'" && !isEscaped(i)) {
inDollarQuote = false;
current += ch;
continue;
}
const inQuote = inSingleQuote || inDoubleQuote || inDollarQuote;
// Split on ;, newline, or CRLF when not inside quotes and not escaped
if (
!inQuote &&
!isEscaped(i) &&
(ch === ";" || ch === "\n" || (ch === "\r" && next === "\n"))
) {
if (ch === "\r") i++; // Skip the \n in \r\n
if (current.trim()) {
commands.push(current.trim());
}
current = "";
continue;
}
current += ch;
}
if (current.trim()) {
commands.push(current.trim());
}
return commands;
}
export function convertCurl(rawData: string) {
if (!rawData.match(/^\s*curl /)) {
return null;
}
const commands: string[][] = splitCommands(rawData).map((cmd) => {
const tokens = split(cmd);
return tokens.flatMap(expandShortFlags);
});
const workspace: ExportResources["workspaces"][0] = {
model: "workspace",
id: generateId("workspace"),
name: "Curl Import",
};
const requests: ExportResources["httpRequests"] = commands
.filter((command) => command[0] === "curl")
.map((v) => importCommand(v, workspace.id));
return {
resources: {
httpRequests: requests,
workspaces: [workspace],
},
};
}
interface ExtractedAuthentication {
authenticationType: string | null;
authentication: Record<string, string>;
filteredHeaders: HttpUrlParameter[]; // headers without authorization
}
function extractAuthenticationFromHeaders(headers: HttpUrlParameter[]): ExtractedAuthentication {
const authorizationHeaderIndex = headers.findIndex(
(h) => h.name.toLowerCase() === "authorization",
);
const authorizationHeader = headers[authorizationHeaderIndex];
if (authorizationHeader == null) {
return {
authenticationType: null,
authentication: {},
filteredHeaders: headers,
};
}
const value = authorizationHeader.value.trim();
const spaceIndex = value.indexOf(" ");
if (spaceIndex <= 0) {
return {
authenticationType: null,
authentication: {},
filteredHeaders: headers,
};
}
const scheme = value.slice(0, spaceIndex).toLowerCase();
const credentials = value.slice(spaceIndex + 1).trim();
// Bearer authentication (RFC 6750)
if (scheme === "bearer") {
const filteredHeaders = headers.filter((_, i) => i !== authorizationHeaderIndex);
return {
authenticationType: "bearer",
authentication: { token: credentials, prefix: "Bearer" },
filteredHeaders,
};
}
// Basic authentication (RFC 7617)
if (scheme === "basic") {
try {
const decoded = Buffer.from(credentials, "base64").toString();
const colonIndex = decoded.indexOf(":");
if (colonIndex > 0) {
const filteredHeaders = headers.filter((_, i) => i !== authorizationHeaderIndex);
return {
authenticationType: "basic",
authentication: {
username: decoded.slice(0, colonIndex),
password: decoded.slice(colonIndex + 1),
},
filteredHeaders,
};
}
} catch {
// Invalid base64, keep header as-is
}
}
return {
authenticationType: null,
authentication: {},
filteredHeaders: headers,
};
}
function importCommand(parseEntries: string[], workspaceId: string) {
// ~~~~~~~~~~~~~~~~~~~~~ //
// Collect all the flags //
// ~~~~~~~~~~~~~~~~~~~~~ //
const flagsByName: FlagsByName = {};
const singletons: string[] = [];
// Start at 1 so we can skip the ^curl part
for (let i = 1; i < parseEntries.length; i++) {
let parseEntry = parseEntries[i];
if (typeof parseEntry === "string") {
parseEntry = parseEntry.trim();
}
if (typeof parseEntry === "string" && parseEntry.match(/^-{1,2}[\w-]+/)) {
const isSingleDash = parseEntry[0] === "-" && parseEntry[1] !== "-";
let name = parseEntry.replace(/^-{1,2}/, "");
if (!SUPPORTED_FLAGS.includes(name)) {
continue;
}
let value: string | boolean;
const nextEntry = parseEntries[i + 1];
const hasValue = !BOOLEAN_FLAGS.includes(name);
// Check if nextEntry looks like a flag:
// - Single dash followed by a letter: -X, -H, -d
// - Double dash followed by a letter: --data-raw, --header
// This prevents mistaking data that starts with dashes (like multipart boundaries ------) as flags
const nextEntryIsFlag =
typeof nextEntry === "string" &&
(nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/));
if (isSingleDash && name.length > 1) {
// Handle squished arguments like -XPOST
value = name.slice(1);
name = name.slice(0, 1);
} else if (typeof nextEntry === "string" && hasValue && !nextEntryIsFlag) {
// Next arg is not a flag, so assign it as the value
value = nextEntry;
i++; // Skip next one
} else {
value = true;
}
flagsByName[name] = flagsByName[name] || [];
flagsByName[name]?.push(value);
} else if (parseEntry) {
singletons.push(parseEntry);
}
}
// ~~~~~~~~~~~~~~~~~ //
// Build the request //
// ~~~~~~~~~~~~~~~~~ //
const urlArg = getPairValue(flagsByName, (singletons[0] as string) || "", ["url"]);
const [baseUrl, search] = splitOnce(urlArg, "?");
const urlParameters: HttpUrlParameter[] =
search?.split("&").map((p) => {
const v = splitOnce(p, "=");
return {
name: decodeURIComponent(v[0] ?? ""),
value: decodeURIComponent(v[1] ?? ""),
enabled: true,
};
}) ?? [];
const url = baseUrl ?? urlArg;
// Query params
for (const p of flagsByName["url-query"] ?? []) {
if (typeof p !== "string") {
continue;
}
// splitOnce: a query value may itself contain "=" (a base64 payload, a
// nested filter), and only the first one separates name from value.
const [name, value] = splitOnce(p, "=");
urlParameters.push({
name: name ?? "",
value: value ?? "",
enabled: true,
});
}
// Authentication
const [username, password] = getPairValue(flagsByName, "", ["u", "user"]).split(/:(.*)$/);
const isDigest = getPairValue(flagsByName, false, ["digest"]);
const authenticationType = username ? (isDigest ? "digest" : "basic") : null;
const authentication = username
? {
username: username.trim(),
password: (password ?? "").trim(),
}
: {};
// Headers
const headers = [
...((flagsByName.header as string[] | undefined) || []),
...((flagsByName.H as string[] | undefined) || []),
].map((header) => {
const [name, value] = header.split(/:(.*)$/);
// remove final colon from header name if present
if (!value) {
return {
name: (name ?? "").trim().replace(/;$/, ""),
value: "",
enabled: true,
};
}
return {
name: (name ?? "").trim(),
value: value.trim(),
enabled: true,
};
});
// Cookies
const cookieHeaderValue = [
...((flagsByName.cookie as string[] | undefined) || []),
...((flagsByName.b as string[] | undefined) || []),
]
.map((str) => {
const name = str.split("=", 1)[0];
const value = str.replace(`${name}=`, "");
return `${name}=${value}`;
})
.join("; ");
// Convert cookie value to header
const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === "cookie");
if (cookieHeaderValue && existingCookieHeader) {
// Has existing cookie header, so let's update it
existingCookieHeader.value += `; ${cookieHeaderValue}`;
} else if (cookieHeaderValue) {
// No existing cookie header, so let's make a new one
headers.push({
name: "Cookie",
value: cookieHeaderValue,
enabled: true,
});
}
// Extract authentication from Authorization headers (Bearer/Basic)
const {
authenticationType: extractedAuthenticationType,
authentication: extractedAuthentication,
filteredHeaders,
} = extractAuthenticationFromHeaders(headers);
// Use extracted authentication from header if found, otherwise fall back to -u/--user parsing
const finalAuthenticationType = extractedAuthenticationType || authenticationType;
const finalAuthentication = extractedAuthenticationType
? extractedAuthentication
: authentication;
// Body (Text or Blob)
const contentTypeHeader = filteredHeaders.find(
(header) => header.name.toLowerCase() === "content-type",
);
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(";")[0]?.trim() : null;
// Extract boundary from Content-Type header for multipart parsing
const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\s;]+)/i);
const boundary = boundaryMatch?.[1];
// Get raw data from --data-raw flags (before splitting by &)
const rawDataValues = [
...((flagsByName["data-raw"] as string[] | undefined) || []),
...((flagsByName.d as string[] | undefined) || []),
...((flagsByName.data as string[] | undefined) || []),
...((flagsByName["data-binary"] as string[] | undefined) || []),
...((flagsByName["data-ascii"] as string[] | undefined) || []),
];
// Check if this is multipart form data in --data-raw (Chrome DevTools format)
let multipartFormDataFromRaw:
| { name: string; value?: string; file?: string; enabled: boolean }[]
| null = null;
if (mimeType === "multipart/form-data" && boundary && rawDataValues.length > 0) {
const rawBody = rawDataValues.join("");
multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary);
}
const dataParameters = pairsToDataParameters(flagsByName);
// Body (Multipart Form Data from -F flags)
const formDataParams = [
...((flagsByName.form as string[] | undefined) || []),
...((flagsByName.F as string[] | undefined) || []),
].map((str) => {
// splitOnce for the same reason as --url-query above: base64 padding
// ("...==") and any value containing "=" must survive intact.
const parts = splitOnce(str, "=");
const name = parts[0] ?? "";
const value = parts[1] ?? "";
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
name,
enabled: true,
};
if (value.indexOf("@") === 0) {
item.file = value.slice(1);
} else {
item.value = value;
}
return item;
});
// Body
let body = {};
let bodyType: string | null = null;
const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]);
const hasDataBody = dataParameters.length > 0 && !bodyAsGET;
const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0;
if (multipartFormDataFromRaw) {
// Handle multipart form data parsed from --data-raw (Chrome DevTools format)
bodyType = "multipart/form-data";
body = {
form: multipartFormDataFromRaw,
};
} else if (dataParameters.length > 0 && bodyAsGET) {
// `-G` moves the data into the query string, and Yaak encodes url
// parameters on send exactly as it encodes the form body below, so this
// needs the same decode -- otherwise a `--data-urlencode` value arrives
// here already encoded and goes out encoded twice.
urlParameters.push(
...dataParameters.map((parameter) => ({
...parameter,
name: decodePercentEncoding(parameter.name),
value: decodePercentEncoding(parameter.value),
})),
);
} else if (
dataParameters.length > 0 &&
(mimeType == null || mimeType === "application/x-www-form-urlencoded")
) {
bodyType = mimeType ?? "application/x-www-form-urlencoded";
body = {
form: dataParameters.map((parameter) => ({
...parameter,
name: decodePercentEncoding(parameter.name),
value: decodePercentEncoding(parameter.value),
})),
};
filteredHeaders.push({
name: "Content-Type",
value: "application/x-www-form-urlencoded",
enabled: true,
});
} else if (dataParameters.length > 0) {
const text = dataParameters
.map(({ name, value }) => (name && value ? `${name}=${value}` : name || value))
.join("&");
const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url });
if (graphqlBody != null) {
bodyType = "graphql";
body = graphqlBody;
} else if (
mimeType === "application/json" ||
mimeType === "text/xml" ||
mimeType === "text/plain"
) {
bodyType = mimeType;
body = { text };
} else {
bodyType = "other";
body = { text };
}
} else if (formDataParams.length) {
bodyType = mimeType ?? "multipart/form-data";
body = {
form: formDataParams,
};
if (mimeType == null) {
filteredHeaders.push({
name: "Content-Type",
value: "multipart/form-data",
enabled: true,
});
}
}
// Method
let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase();
if (method === "") {
method = hasDataBody || hasFormBody ? "POST" : "GET";
}
const request: ExportResources["httpRequests"][0] = {
id: generateId("http_request"),
model: "http_request",
workspaceId,
name: "",
urlParameters,
url,
method,
headers: filteredHeaders,
authentication: finalAuthentication,
authenticationType: finalAuthenticationType,
body,
bodyType,
folderId: null,
sortPriority: 0,
};
return request;
}
interface DataParameter {
name: string;
value: string;
contentType?: string;
filePath?: string;
enabled?: boolean;
}
/**
* Decode a percent-encoded form value, keeping it as-is when it is not one.
*
* Yaak's form editor holds decoded values and re-encodes them on send, so a
* `-d` value has to be decoded on the way in. But curl sends that value
* verbatim and does not require it to be valid percent-encoding: `a=100%` is
* an ordinary form value, and `decodeURIComponent` throws URIError on it,
* which failed the whole import rather than that one parameter.
*/
function decodePercentEncoding(value: string | undefined): string {
const text = value || "";
try {
return decodeURIComponent(text);
} catch {
// Mixed: some of it is percent-encoded and some of it is a stray `%`.
// Returning the whole string untouched would leave the encoded part to be
// encoded a second time on send, so decode each valid run on its own and
// leave the stray byte alone. A run rather than a single escape, because a
// non-ASCII character is several escapes that only decode together.
return text.replace(/(%[0-9A-Fa-f]{2})+/g, (run) => {
try {
return decodeURIComponent(run);
} catch {
return run;
}
});
}
}
function pairsToDataParameters(keyedPairs: FlagsByName): DataParameter[] {
const dataParameters: DataParameter[] = [];
for (const flagName of DATA_FLAGS) {
const pairs = keyedPairs[flagName];
if (!pairs || pairs.length === 0) {
continue;
}
for (const p of pairs) {
if (typeof p !== "string") continue;
// `-d` content really is `&`-separated, so splitting it is right. But
// `--data-urlencode` encodes its whole argument — an `&` inside it is
// data curl percent-encodes, not a separator, so splitting there turned
// one parameter into several and changed what the request sends.
const params = flagName === "data-urlencode" ? [p] : p.split("&");
for (const param of params) {
const [name, value] = splitOnce(param, "=");
if (param.startsWith("@")) {
// Yaak doesn't support files in url-encoded data, so
dataParameters.push({
name: name ?? "",
value: "",
filePath: param.slice(1),
enabled: true,
});
} else {
dataParameters.push({
name: name ?? "",
value: flagName === "data-urlencode" ? encodeURIComponent(value ?? "") : (value ?? ""),
enabled: true,
});
}
}
}
}
return dataParameters;
}
const getPairValue = <T extends string | boolean>(
pairsByName: FlagsByName,
defaultValue: T,
names: string[],
) => {
for (const name of names) {
if (pairsByName[name]?.length) {
return pairsByName[name]?.[0] as T;
}
}
return defaultValue;
};
function splitOnce(str: string, sep: string): string[] {
const index = str.indexOf(sep);
if (index > -1) {
return [str.slice(0, index), str.slice(index + 1)];
}
return [str];
}
/**
* Parses multipart form data from a raw body string
* Used when Chrome DevTools exports a cURL with --data-raw containing multipart data
*/
function parseMultipartFormData(
rawBody: string,
boundary: string,
): { name: string; value?: string; file?: string; enabled: boolean }[] | null {
const results: { name: string; value?: string; file?: string; enabled: boolean }[] = [];
// The boundary in the body typically has -- prefix
const boundaryMarker = `--${boundary}`;
const parts = rawBody.split(boundaryMarker);
for (const part of parts) {
// Skip empty parts and the closing boundary marker
if (!part || part.trim() === "--" || part.trim() === "--\r\n") {
continue;
}
// Each part has headers and content separated by \r\n\r\n
const headerContentSplit = part.indexOf("\r\n\r\n");
if (headerContentSplit === -1) {
continue;
}
const headerSection = part.slice(0, headerContentSplit);
let content = part.slice(headerContentSplit + 4); // Skip \r\n\r\n
// Remove trailing \r\n from content
if (content.endsWith("\r\n")) {
content = content.slice(0, -2);
}
// Parse Content-Disposition header to get name and filename
const contentDispositionMatch = headerSection.match(
/Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]+)")?/i,
);
if (!contentDispositionMatch) {
continue;
}
const name = contentDispositionMatch[1] ?? "";
const filename = contentDispositionMatch[2];
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
name,
enabled: true,
};
if (filename) {
// This is a file upload field
item.file = filename;
} else {
// This is a regular text field
item.value = content;
}
results.push(item);
}
return results.length > 0 ? results : null;
}
const idCount: Partial<Record<string, number>> = {};
function generateId(model: string): string {
idCount[model] = (idCount[model] ?? -1) + 1;
return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, test } from "vite-plus/test";
import { isGraphQLJsonBody, parseGraphQLJsonBody } from "../src/graphql";
describe("isGraphQLJsonBody", () => {
test("detects named query documents without a GraphQL URL", () => {
const args = {
mimeType: "application/json",
text: JSON.stringify({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: { id: "123" },
operationName: "Search",
}),
url: "https://api.example.com/search",
};
expect(isGraphQLJsonBody(args)).toBe(true);
expect(parseGraphQLJsonBody(args)).toEqual({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{\n "id": "123"\n}',
operationName: "Search",
});
});
test("detects mutation documents", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "mutation Save { saveThing { id } }" }),
url: "https://api.example.com",
}),
).toBe(true);
});
test("detects anonymous selection set documents", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "{ viewer { id email } }" }),
url: "https://api.example.com",
}),
).toBe(true);
});
test("detects document bodies on GraphQL-looking paths", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "query Search { viewer { id } }", operationName: "Search" }),
url: "https://api.example.com/v1/graphql",
}),
).toBe(true);
});
test("does not detect incomplete operation documents even on GraphQL-looking paths", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "query Search", operationName: "Search" }),
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
test("does not detect plain JSON query fields even on GraphQL-looking paths", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({ query: "SearchQueryInput!" }),
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
test("does not use variables and operationName alone as enough evidence", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: JSON.stringify({
query: "SearchQueryInput!",
variables: { id: "123" },
operationName: "Search",
}),
url: "https://api.example.com",
}),
).toBe(false);
});
test("detects bodies with string variables without parsing them", () => {
const args = {
mimeType: "application/json",
text: JSON.stringify({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{ "id": "123" }',
}),
url: "https://api.example.com",
};
expect(isGraphQLJsonBody(args)).toBe(true);
expect(parseGraphQLJsonBody(args)).toEqual({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: '{ "id": "123" }',
});
});
test("does not detect GraphQL envelopes with extra fields", () => {
const args = {
mimeType: "application/json",
text: JSON.stringify({
query: "query Search($id: ID!) { node(id: $id) { id } }",
variables: { id: "123" },
extensions: { persistedQuery: { version: 1, sha256Hash: "abc123" } },
}),
url: "https://api.example.com/graphql",
};
expect(isGraphQLJsonBody(args)).toBe(false);
expect(parseGraphQLJsonBody(args)).toBeNull();
});
test("ignores invalid JSON and non-object JSON", () => {
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: "not json",
url: "https://api.example.com/graphql",
}),
).toBe(false);
expect(
isGraphQLJsonBody({
mimeType: "application/json",
text: "[]",
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
test("ignores non-JSON MIME types", () => {
expect(
isGraphQLJsonBody({
mimeType: "text/plain",
text: JSON.stringify({ query: "query Search { viewer { id } }" }),
url: "https://api.example.com/graphql",
}),
).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,15 @@
{
"name": "@yaak/importer-insomnia",
"displayName": "Insomnia Importer",
"version": "0.1.0",
"private": true,
"description": "Import data from Insomnia",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"yaml": "^2.8.3"
}
}

View File

@@ -0,0 +1,147 @@
/* oxlint-disable no-explicit-any */
export function isJSObject(obj: unknown) {
return Object.prototype.toString.call(obj) === "[object Object]";
}
export function isJSString(obj: unknown) {
return Object.prototype.toString.call(obj) === "[object String]";
}
export function convertId(id: string): string {
if (id.startsWith("GENERATE_ID::")) {
return id;
}
return `GENERATE_ID::${id}`;
}
export function createSourceKeys() {
const keys: Record<string, string> = {};
return {
/** Convert a resource's own document ID, keeping it as that resource's source key. */
own(id: string): string {
const converted = convertId(id);
keys[converted] = id;
return converted;
},
all: (): Record<string, string> => keys,
};
}
export type SourceKeys = ReturnType<typeof createSourceKeys>;
export function importHttpBodyAndHeaders(obj: any) {
const { headers } = importHeaders(obj);
const { body, bodyType } = importHttpBody(obj.body);
const mimeType = typeof obj.body?.mimeType === "string" ? obj.body.mimeType.trim() : "";
if (
bodyType != null &&
mimeType !== "" &&
!headers.some((header: { name: string }) => header.name.toLowerCase() === "content-type")
) {
headers.push({ enabled: true, name: "Content-Type", value: mimeType });
}
return { body, bodyType, headers };
}
export function importHeaders(obj: any) {
const headers = (obj.headers ?? [])
.map((header: any) => ({
enabled: !header.disabled,
name: header.name ?? "",
value: header.value ?? "",
}))
.filter(({ name, value }: any) => name !== "" || value !== "");
return { headers } as const;
}
function importHttpBody(rawBody: any) {
const mimeType = typeof rawBody?.mimeType === "string" ? rawBody.mimeType.trim() : "";
const normalizedMimeType = mimeType.split(";", 1)[0]?.toLowerCase() ?? "";
if (normalizedMimeType === "application/octet-stream") {
return { bodyType: "binary", body: { filePath: rawBody.fileName ?? "" } };
}
if (normalizedMimeType === "application/x-www-form-urlencoded") {
return {
bodyType: "application/x-www-form-urlencoded",
body: {
form: (rawBody.params ?? []).map((parameter: any) => ({
enabled: !parameter.disabled,
name: parameter.name ?? "",
value: parameter.value ?? "",
})),
},
};
}
if (normalizedMimeType === "multipart/form-data") {
return {
bodyType: "multipart/form-data",
body: {
form: (rawBody.params ?? []).map((parameter: any) => ({
enabled: !parameter.disabled,
name: parameter.name ?? "",
value: parameter.value ?? "",
file: parameter.fileName ?? null,
})),
},
};
}
if (normalizedMimeType === "application/graphql") {
return { bodyType: "graphql", body: { text: rawBody.text ?? "" } };
}
if (normalizedMimeType === "application/json" || normalizedMimeType.endsWith("+json")) {
return { bodyType: "application/json", body: { text: rawBody.text ?? "" } };
}
if (
normalizedMimeType === "text/xml" ||
normalizedMimeType === "application/xml" ||
normalizedMimeType.endsWith("+xml")
) {
return { bodyType: "text/xml", body: { text: rawBody.text ?? "" } };
}
if (typeof rawBody?.text === "string") {
return { bodyType: "other", body: { text: rawBody.text } };
}
return { bodyType: null, body: {} };
}
export function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
}
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
) as T;
}
return obj;
}
/** Recursively render all nested object properties */
export function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === "string") {
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
}
if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
}
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
}
return obj;
}

View File

@@ -0,0 +1,37 @@
import type { Context, PluginDefinition } from "@yaakapp/api";
import YAML from "yaml";
import { deleteUndefinedAttrs, isJSObject } from "./common";
import { convertInsomniaV4 } from "./v4";
import { convertInsomniaV5 } from "./v5";
export const plugin: PluginDefinition = {
importer: {
name: "Insomnia",
description: "Import Insomnia workspaces",
async onImport(_ctx: Context, args: { text: string }) {
return convertInsomnia(args.text);
},
},
};
export function convertInsomnia(contents: string) {
let parsed: unknown;
try {
parsed = JSON.parse(contents);
} catch {
// Fall through
}
try {
parsed = parsed ?? YAML.parse(contents);
} catch {
// Fall through
}
if (!isJSObject(parsed)) return null;
const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed);
return deleteUndefinedAttrs(result);
}

View File

@@ -0,0 +1,190 @@
/* oxlint-disable no-explicit-any */
import type { PartialImportResources } from "@yaakapp/api";
import {
convertId,
convertTemplateSyntax,
createSourceKeys,
importHttpBodyAndHeaders,
isJSObject,
type SourceKeys,
} from "./common";
export function convertInsomniaV4(parsed: any) {
if (!Array.isArray(parsed.resources)) return null;
const keys = createSourceKeys();
const resources: PartialImportResources = {
environments: [],
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: [],
};
// Import workspaces
const workspacesToImport = parsed.resources.filter(
(r: any) => isJSObject(r) && r._type === "workspace",
);
for (const w of workspacesToImport) {
resources.workspaces.push({
id: keys.own(w._id),
createdAt: w.created ? new Date(w.created).toISOString().replace("Z", "") : undefined,
updatedAt: w.updated ? new Date(w.updated).toISOString().replace("Z", "") : undefined,
model: "workspace",
name: w.name,
description: w.description || undefined,
});
const environmentsToImport = parsed.resources.filter(
(r: any) => isJSObject(r) && r._type === "environment",
);
resources.environments.push(
...environmentsToImport.map((r: any) => importEnvironment(r, w._id, keys)),
);
const nextFolder = (parentId: string) => {
const children = parsed.resources.filter((r: any) => r.parentId === parentId);
for (const child of children) {
if (!isJSObject(child)) continue;
if (child._type === "request_group") {
resources.folders.push(importFolder(child, w._id, keys));
nextFolder(child._id);
} else if (child._type === "request") {
resources.httpRequests.push(importHttpRequest(child, w._id, keys));
} else if (child._type === "grpc_request") {
resources.grpcRequests.push(importGrpcRequest(child, w._id, keys));
}
}
};
// Import folders
nextFolder(w._id);
}
// Filter out any `null` values
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
}
function importHttpRequest(
r: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["httpRequests"][0] {
let authenticationType: string | null = null;
let authentication = {};
if (r.authentication.type === "bearer") {
authenticationType = "bearer";
authentication = {
token: r.authentication.token,
};
} else if (r.authentication.type === "basic") {
authenticationType = "basic";
authentication = {
username: r.authentication.username,
password: r.authentication.password,
};
}
return {
id: keys.own(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "http_request",
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
urlParameters: (r.parameters ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
})),
...importHttpBodyAndHeaders(r),
authentication,
authenticationType,
method: r.method,
};
}
function importGrpcRequest(
r: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["grpcRequests"][0] {
const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
id: keys.own(r.meta?.id ?? r._id),
createdAt: r.created ? new Date(r.created).toISOString().replace("Z", "") : undefined,
updatedAt: r.modified ? new Date(r.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
folderId: r.parentId === workspaceId ? null : convertId(r.parentId),
model: "grpc_request",
sortPriority: r.metaSortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== "" || value !== ""),
};
}
function importFolder(
f: any,
workspaceId: string,
keys: SourceKeys,
): PartialImportResources["folders"][0] {
return {
id: keys.own(f._id),
createdAt: f.created ? new Date(f.created).toISOString().replace("Z", "") : undefined,
updatedAt: f.modified ? new Date(f.modified).toISOString().replace("Z", "") : undefined,
folderId: f.parentId === workspaceId ? null : convertId(f.parentId),
workspaceId: convertId(workspaceId),
description: f.description || undefined,
model: "folder",
name: f.name,
};
}
function importEnvironment(
e: any,
workspaceId: string,
keys: SourceKeys,
isParentOg?: boolean,
): PartialImportResources["environments"][0] {
const isParent = isParentOg ?? e.parentId === workspaceId;
return {
id: keys.own(e._id),
createdAt: e.created ? new Date(e.created).toISOString().replace("Z", "") : undefined,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
sortPriority: e.metaSortKey,
parentModel: isParent ? "workspace" : "environment",
parentId: null,
model: "environment",
name: e.name,
variables: Object.entries(e.data).map(([name, value]) => ({
enabled: true,
name,
value: String(value),
})),
};
}

View File

@@ -0,0 +1,285 @@
/* oxlint-disable no-explicit-any */
import type { PartialImportResources } from "@yaakapp/api";
import {
convertId,
convertTemplateSyntax,
createSourceKeys,
importHeaders,
importHttpBodyAndHeaders,
isJSObject,
type SourceKeys,
} from "./common";
export function convertInsomniaV5(parsed: any) {
// Assert parsed is object
if (parsed == null || typeof parsed !== "object") {
return null;
}
if (!("collection" in parsed) || !Array.isArray(parsed.collection)) {
return null;
}
const keys = createSourceKeys();
const resources: PartialImportResources = {
environments: [],
folders: [],
grpcRequests: [],
httpRequests: [],
websocketRequests: [],
workspaces: [],
};
// Import workspaces
const meta = ("meta" in parsed ? parsed.meta : {}) as Record<string, any>;
resources.workspaces.push({
id: keys.own(meta.id ?? "collection"),
createdAt: meta.created ? new Date(meta.created).toISOString().replace("Z", "") : undefined,
updatedAt: meta.modified ? new Date(meta.modified).toISOString().replace("Z", "") : undefined,
model: "workspace",
name: parsed.name,
description: meta.description || undefined,
...importHeaders(parsed),
...importAuthentication(parsed),
});
// Import environments
resources.environments.push(
importEnvironment(parsed.environments, meta.id, keys, true),
...(parsed.environments.subEnvironments ?? []).map((r: any) =>
importEnvironment(r, meta.id, keys),
),
);
// Import folders
const nextFolder = (children: any[], parentId: string) => {
for (const child of children ?? []) {
if (!isJSObject(child)) continue;
if (Array.isArray(child.children)) {
const { folder, environment } = importFolder(child, meta.id, parentId, keys);
resources.folders.push(folder);
if (environment) resources.environments.push(environment);
nextFolder(child.children, child.meta.id);
} else if (child.method) {
resources.httpRequests.push(importHttpRequest(child, meta.id, parentId, keys));
} else if (child.protoFileId) {
resources.grpcRequests.push(importGrpcRequest(child, meta.id, parentId, keys));
} else if (child.url) {
resources.websocketRequests.push(importWebsocketRequest(child, meta.id, parentId, keys));
}
}
};
// Import folders
nextFolder(parsed.collection ?? [], meta.id);
// Filter out any `null` values
resources.httpRequests = resources.httpRequests.filter(Boolean);
resources.grpcRequests = resources.grpcRequests.filter(Boolean);
resources.environments = resources.environments.filter(Boolean);
resources.workspaces = resources.workspaces.filter(Boolean);
return { resources: convertTemplateSyntax(resources), sourceKeys: keys.all() };
}
function importHttpRequest(
r: any,
workspaceId: string,
parentId: string,
keys: SourceKeys,
): PartialImportResources["httpRequests"][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
return {
id: keys.own(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
model: "http_request",
name: r.name,
description: r.meta?.description || undefined,
url: r.url,
urlParameters: (r.parameters ?? []).map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? "",
value: p.value ?? "",
})),
...importHttpBodyAndHeaders(r),
method: r.method,
...importAuthentication(r),
};
}
function importGrpcRequest(
r: any,
workspaceId: string,
parentId: string,
keys: SourceKeys,
): PartialImportResources["grpcRequests"][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
const parts = r.protoMethodName.split("/").filter((p: any) => p !== "");
const service = parts[0] ?? null;
const method = parts[1] ?? null;
return {
model: "grpc_request",
id: keys.own(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
service,
method,
message: r.body?.text ?? "",
metadata: (r.metadata ?? [])
.map((h: any) => ({
enabled: !h.disabled,
name: h.name ?? "",
value: h.value ?? "",
}))
.filter(({ name, value }: any) => name !== "" || value !== ""),
};
}
function importWebsocketRequest(
r: any,
workspaceId: string,
parentId: string,
keys: SourceKeys,
): PartialImportResources["websocketRequests"][0] {
const id = r.meta?.id ?? r._id;
const created = r.meta?.created ?? r.created;
const updated = r.meta?.modified ?? r.updated;
const sortKey = r.meta?.sortKey ?? r.sortKey;
return {
model: "websocket_request",
id: keys.own(id),
workspaceId: convertId(workspaceId),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
name: r.name,
description: r.description || undefined,
url: r.url,
message: r.body?.text ?? "",
...importHeaders(r),
...importAuthentication(r),
};
}
function importAuthentication(obj: any) {
let authenticationType: string | null = null;
let authentication = {};
if (obj.authentication?.type === "bearer") {
authenticationType = "bearer";
authentication = {
token: obj.authentication.token,
};
} else if (obj.authentication?.type === "basic") {
authenticationType = "basic";
authentication = {
username: obj.authentication.username,
password: obj.authentication.password,
};
}
return { authenticationType, authentication } as const;
}
function importFolder(
f: any,
workspaceId: string,
parentId: string,
keys: SourceKeys,
): {
folder: PartialImportResources["folders"][0];
environment: PartialImportResources["environments"][0] | null;
} {
const id = f.meta?.id ?? f._id;
const created = f.meta?.created ?? f.created;
const updated = f.meta?.modified ?? f.updated;
const sortKey = f.meta?.sortKey ?? f.sortKey;
let environment: PartialImportResources["environments"][0] | null = null;
if (Object.keys(f.environment ?? {}).length > 0) {
environment = {
id: keys.own(`${id}folder`),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
public: true,
parentModel: "folder",
parentId: convertId(id),
model: "environment",
name: "Folder Environment",
variables: Object.entries(f.environment ?? {}).map(([name, value]) => ({
enabled: true,
name,
value: String(value),
})),
};
}
return {
folder: {
model: "folder",
id: keys.own(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
folderId: parentId === workspaceId ? null : convertId(parentId),
sortPriority: sortKey,
workspaceId: convertId(workspaceId),
description: f.description || undefined,
name: f.name,
...importAuthentication(f),
...importHeaders(f),
},
environment,
};
}
function importEnvironment(
e: any,
workspaceId: string,
keys: SourceKeys,
isParent?: boolean,
): PartialImportResources["environments"][0] {
const id = e.meta?.id ?? e._id;
const created = e.meta?.created ?? e.created;
const updated = e.meta?.modified ?? e.updated;
const sortKey = e.meta?.sortKey ?? e.sortKey;
return {
id: keys.own(id),
createdAt: created ? new Date(created).toISOString().replace("Z", "") : undefined,
updatedAt: updated ? new Date(updated).toISOString().replace("Z", "") : undefined,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
sortPriority: sortKey,
parentModel: isParent ? "workspace" : "environment",
parentId: null,
model: "environment",
name: e.name,
variables: Object.entries(e.data ?? {}).map(([name, value]) => ({
enabled: true,
name,
value: String(value),
})),
};
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, test } from "vite-plus/test";
import { importHttpBodyAndHeaders } from "../src/common";
describe("importHttpBodyAndHeaders", () => {
test("imports XML text using the native XML body type", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/soap+xml; charset=utf-8",
text: "<soap:Envelope />",
},
});
expect(result).toEqual({
bodyType: "text/xml",
body: { text: "<soap:Envelope />" },
headers: [
{
enabled: true,
name: "Content-Type",
value: "application/soap+xml; charset=utf-8",
},
],
});
});
test("imports vendor JSON using the native JSON body type", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/problem+json",
text: '{"message":"Nope"}',
},
});
expect(result.bodyType).toBe("application/json");
expect(result.body).toEqual({ text: '{"message":"Nope"}' });
});
test("imports unknown text using the other body type", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/yaml",
text: "message: hello",
},
});
expect(result).toEqual({
bodyType: "other",
body: { text: "message: hello" },
headers: [
{
enabled: true,
name: "Content-Type",
value: "application/yaml",
},
],
});
});
test("preserves an explicit content type instead of adding a duplicate", () => {
const result = importHttpBodyAndHeaders({
body: {
mimeType: "application/yaml",
text: "message: hello",
},
headers: [
{
name: "content-type",
value: "application/x-yaml",
},
],
});
expect(result.headers).toEqual([
{
enabled: true,
name: "content-type",
value: "application/x-yaml",
},
]);
});
test("imports text without inventing a content type", () => {
const result = importHttpBodyAndHeaders({ body: { text: "hello" } });
expect(result).toEqual({
bodyType: "other",
body: { text: "hello" },
headers: [],
});
});
});

View File

@@ -0,0 +1,187 @@
{
"_type": "export",
"__export_format": 4,
"__export_date": "2025-01-13T15:19:18.330Z",
"__export_source": "insomnia.desktop.app:v10.3.0",
"resources": [
{
"_id": "req_84cd9ae4bd034dd8bb730e856a665cbb",
"parentId": "fld_859d1df78261463480b6a3a1419517e3",
"modified": 1736781473176,
"created": 1736781406672,
"url": "{{ _.BASE_URL }}/foo/:id",
"name": "New Request",
"description": "My description of the request",
"method": "GET",
"body": {
"mimeType": "multipart/form-data",
"params": [
{
"id": "pair_7c86036ae8ef499dbbc0b43d0800c5a3",
"name": "form",
"value": "data",
"description": "",
"disabled": false
}
]
},
"parameters": [
{
"id": "pair_b22f6ff611cd4250a6e405ca7b713d09",
"name": "query",
"value": "qqq",
"description": "",
"disabled": false
}
],
"headers": [
{
"name": "Content-Type",
"value": "multipart/form-data",
"id": "pair_4af845963bd14256b98716617971eecd"
},
{
"name": "User-Agent",
"value": "insomnia/10.3.0",
"id": "pair_535ffd00ce48462cb1b7258832ade65a"
},
{
"id": "pair_ab4b870278e943cba6babf5a73e213e3",
"name": "X-Header",
"value": "xxxx",
"description": "",
"disabled": false
}
],
"authentication": {
"type": "basic",
"useISO88591": false,
"disabled": false,
"username": "user",
"password": "pass"
},
"metaSortKey": -1736781406672,
"isPrivate": false,
"pathParameters": [
{
"name": "id",
"value": "iii"
}
],
"settingStoreCookies": true,
"settingSendCookies": true,
"settingDisableRenderRequestBody": false,
"settingEncodeUrl": true,
"settingRebuildPath": true,
"settingFollowRedirects": "global",
"_type": "request"
},
{
"_id": "fld_859d1df78261463480b6a3a1419517e3",
"parentId": "wrk_d4d92f7c0ee947b89159243506687019",
"modified": 1736781404718,
"created": 1736781404718,
"name": "Top Level",
"description": "",
"environment": {},
"environmentPropertyOrder": null,
"metaSortKey": -1736781404718,
"environmentType": "kv",
"_type": "request_group"
},
{
"_id": "wrk_d4d92f7c0ee947b89159243506687019",
"parentId": null,
"modified": 1736781343765,
"created": 1736781343765,
"name": "Dummy",
"description": "",
"scope": "collection",
"_type": "workspace"
},
{
"_id": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"parentId": "wrk_d4d92f7c0ee947b89159243506687019",
"modified": 1736781355209,
"created": 1736781343767,
"name": "Base Environment",
"data": {
"BASE_VAR": "hello"
},
"dataPropertyOrder": null,
"color": null,
"isPrivate": false,
"metaSortKey": 1736781343767,
"environmentType": "kv",
"kvPairData": [
{
"id": "envPair_61c1be66d42241b5a28306d2cd92d3e3",
"name": "BASE_VAR",
"value": "hello",
"type": "str",
"enabled": true
}
],
"_type": "environment"
},
{
"_id": "jar_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"parentId": "wrk_d4d92f7c0ee947b89159243506687019",
"modified": 1736781343768,
"created": 1736781343768,
"name": "Default Jar",
"cookies": [],
"_type": "cookie_jar"
},
{
"_id": "env_799ae3d723ef44af91b4817e5d057e6d",
"parentId": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"modified": 1736781394705,
"created": 1736781358515,
"name": "Production",
"data": {
"BASE_URL": "https://api.yaak.app"
},
"dataPropertyOrder": null,
"color": "#f22c2c",
"isPrivate": false,
"metaSortKey": 1736781358515,
"environmentType": "kv",
"kvPairData": [
{
"id": "envPair_4d97b569b7e845ccbf488e1b26637cbc",
"name": "BASE_URL",
"value": "https://api.yaak.app",
"type": "str",
"enabled": true
}
],
"_type": "environment"
},
{
"_id": "env_030fbfdbb274426ebd78e2e6518f8553",
"parentId": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"modified": 1736781391078,
"created": 1736781374707,
"name": "Staging",
"data": {
"BASE_URL": "https://api.staging.yaak.app"
},
"dataPropertyOrder": null,
"color": "#206fac",
"isPrivate": false,
"metaSortKey": 1736781358565,
"environmentType": "kv",
"kvPairData": [
{
"id": "envPair_4d97b569b7e845ccbf488e1b26637cbc",
"name": "BASE_URL",
"value": "https://api.staging.yaak.app",
"type": "str",
"enabled": true
}
],
"_type": "environment"
}
]
}

View File

@@ -0,0 +1,144 @@
{
"resources": {
"environments": [
{
"createdAt": "2025-01-13T15:15:43.767",
"updatedAt": "2025-01-13T15:15:55.209",
"sortPriority": 1736781343767,
"parentId": null,
"parentModel": "workspace",
"id": "GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"model": "environment",
"name": "Base Environment",
"variables": [
{
"enabled": true,
"name": "BASE_VAR",
"value": "hello"
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
},
{
"createdAt": "2025-01-13T15:15:58.515",
"updatedAt": "2025-01-13T15:16:34.705",
"sortPriority": 1736781358515,
"parentId": null,
"parentModel": "environment",
"id": "GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d",
"model": "environment",
"name": "Production",
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
},
{
"createdAt": "2025-01-13T15:16:14.707",
"updatedAt": "2025-01-13T15:16:31.078",
"sortPriority": 1736781358565,
"parentId": null,
"parentModel": "environment",
"id": "GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553",
"model": "environment",
"name": "Staging",
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.staging.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],
"folders": [
{
"createdAt": "2025-01-13T15:16:44.718",
"updatedAt": "2025-01-13T15:16:44.718",
"folderId": null,
"id": "GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3",
"model": "folder",
"name": "Top Level",
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],
"grpcRequests": [],
"httpRequests": [
{
"authentication": {
"password": "pass",
"username": "user"
},
"authenticationType": "basic",
"body": {
"form": [
{
"enabled": true,
"file": null,
"name": "form",
"value": "data"
}
]
},
"bodyType": "multipart/form-data",
"createdAt": "2025-01-13T15:16:46.672",
"sortPriority": -1736781406672,
"updatedAt": "2025-01-13T15:17:53.176",
"description": "My description of the request",
"folderId": "GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3",
"headers": [
{
"enabled": true,
"name": "Content-Type",
"value": "multipart/form-data"
},
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/10.3.0"
},
{
"enabled": true,
"name": "X-Header",
"value": "xxxx"
}
],
"id": "GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb",
"method": "GET",
"model": "http_request",
"name": "New Request",
"url": "${[BASE_URL ]}/foo/:id",
"urlParameters": [
{
"name": "query",
"value": "qqq",
"enabled": true
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],
"websocketRequests": [],
"workspaces": [
{
"createdAt": "2025-01-13T15:15:43.765",
"id": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019",
"model": "workspace",
"name": "Dummy"
}
]
},
"sourceKeys": {
"GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d": "env_799ae3d723ef44af91b4817e5d057e6d",
"GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553": "env_030fbfdbb274426ebd78e2e6518f8553",
"GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3": "fld_859d1df78261463480b6a3a1419517e3",
"GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb": "req_84cd9ae4bd034dd8bb730e856a665cbb",
"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019": "wrk_d4d92f7c0ee947b89159243506687019"
}
}

View File

@@ -0,0 +1,74 @@
type: collection.insomnia.rest/5.0
name: Debugging
meta:
id: wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c
created: 1747197924902
modified: 1747197924902
collection:
- name: My Folder
meta:
id: fld_296933ea4ea84783a775d199997e9be7
created: 1747414092298
modified: 1747414142427
sortKey: -1747414092298
children:
- url: https://httpbin.org/post
name: New Request
meta:
id: req_9a80320365ac4509ade406359dbc6a71
created: 1747197928502
modified: 1747414129313
isPrivate: false
sortKey: -1747414129276
method: GET
headers:
- name: User-Agent
value: insomnia/11.1.0
id: pair_6ae87d1620a9494f8e5b29cd9f92d087
settings:
renderRequestBody: true
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
rebuildPath: true
headers:
- id: pair_f2b330e3914f4c11b209318aef94325c
name: foo
value: bar
disabled: false
environment:
folder_env_var: testing
- name: New Request
meta:
id: req_e3f8cdbd58784a539dd4c1e127d73451
created: 1747414160497
modified: 1747414160497
isPrivate: false
sortKey: -1747414160498
method: GET
headers:
- name: User-Agent
value: insomnia/11.1.0
settings:
renderRequestBody: true
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
rebuildPath: true
cookieJar:
name: Default Jar
meta:
id: jar_e46dc73e8ccda30ca132153e8f11183bd08119ce
created: 1747197924904
modified: 1747197924904
environments:
name: Base Environment
meta:
id: env_e46dc73e8ccda30ca132153e8f11183bd08119ce
created: 1747197924903
modified: 1747197924903
isPrivate: false

View File

@@ -0,0 +1,128 @@
{
"resources": {
"environments": [
{
"createdAt": "2025-05-14T04:45:24.903",
"id": "GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
"model": "environment",
"name": "Base Environment",
"public": true,
"updatedAt": "2025-05-14T04:45:24.903",
"variables": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"parentId": null,
"parentModel": "workspace"
},
{
"createdAt": "2025-05-16T16:48:12.298",
"id": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder",
"model": "environment",
"name": "Folder Environment",
"parentId": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7",
"parentModel": "folder",
"public": true,
"updatedAt": "2025-05-16T16:49:02.427",
"variables": [
{
"enabled": true,
"name": "folder_env_var",
"value": "testing"
}
],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],
"folders": [
{
"createdAt": "2025-05-16T16:48:12.298",
"folderId": null,
"id": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7",
"model": "folder",
"name": "My Folder",
"sortPriority": -1747414092298,
"updatedAt": "2025-05-16T16:49:02.427",
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"authentication": {},
"authenticationType": null,
"headers": [
{
"enabled": true,
"name": "foo",
"value": "bar"
}
]
}
],
"grpcRequests": [],
"httpRequests": [
{
"authentication": {},
"authenticationType": null,
"body": {},
"bodyType": null,
"createdAt": "2025-05-14T04:45:28.502",
"folderId": "GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7",
"headers": [
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/11.1.0"
}
],
"id": "GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71",
"method": "GET",
"model": "http_request",
"name": "New Request",
"sortPriority": -1747414129276,
"updatedAt": "2025-05-16T16:48:49.313",
"url": "https://httpbin.org/post",
"urlParameters": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
},
{
"authentication": {},
"authenticationType": null,
"body": {},
"bodyType": null,
"createdAt": "2025-05-16T16:49:20.497",
"folderId": null,
"headers": [
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/11.1.0"
}
],
"id": "GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451",
"method": "GET",
"model": "http_request",
"name": "New Request",
"sortPriority": -1747414160498,
"updatedAt": "2025-05-16T16:49:20.497",
"urlParameters": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],
"websocketRequests": [],
"workspaces": [
{
"createdAt": "2025-05-14T04:45:24.902",
"id": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c",
"model": "workspace",
"name": "Debugging",
"updatedAt": "2025-05-14T04:45:24.902",
"authentication": {},
"authenticationType": null,
"headers": []
}
]
},
"sourceKeys": {
"GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce": "env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder": "fld_296933ea4ea84783a775d199997e9be7folder",
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7": "fld_296933ea4ea84783a775d199997e9be7",
"GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71": "req_9a80320365ac4509ade406359dbc6a71",
"GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451": "req_e3f8cdbd58784a539dd4c1e127d73451",
"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c": "wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
}

View File

@@ -0,0 +1,146 @@
type: collection.insomnia.rest/5.0
name: Dummy
meta:
id: wrk_c1eacfa750a04f3ea9985ef28043fa53
created: 1746799305927
modified: 1746843054272
description: This is the description
collection:
- name: Top Level
meta:
id: fld_42eb2e2bb22b4cedacbd3d057634e80c
created: 1736781404718
modified: 1736781404718
sortKey: -1736781404718
children:
- url: "{{ _.BASE_URL }}/foo/:id"
name: New Request
meta:
id: req_d72fff2a6b104b91a2ebe9de9edd2785
created: 1736781406672
modified: 1736781473176
isPrivate: false
description: My description of the request
sortKey: -1736781406672
method: GET
body:
mimeType: multipart/form-data
params:
- id: pair_7c86036ae8ef499dbbc0b43d0800c5a3
name: form
value: data
disabled: false
parameters:
- id: pair_b22f6ff611cd4250a6e405ca7b713d09
name: query
value: qqq
disabled: false
headers:
- name: Content-Type
value: multipart/form-data
id: pair_4af845963bd14256b98716617971eecd
- name: User-Agent
value: insomnia/10.3.0
id: pair_535ffd00ce48462cb1b7258832ade65a
- id: pair_ab4b870278e943cba6babf5a73e213e3
name: X-Header
value: xxxx
disabled: false
- id: pair_ab4b870278e943cba6babf5a73e213e3
name: "{{ _.ApiHeaderName }}"
value: "{{ _.ApiKey }}"
disabled: false
authentication:
type: basic
useISO88591: false
disabled: false
username: user
password: pass
settings:
renderRequestBody: true
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
rebuildPath: true
pathParameters:
- name: id
value: iii
- url: grpcb.in:9000
name: New Request
meta:
id: greq_06d659324df94504a4d64632be7106b3
created: 1746799344864
modified: 1746799544082
isPrivate: false
sortKey: -1746799344864
body:
text: |-
{
"greeting": "Greg"
}
protoFileId: pf_9d45b0dfaccc4bcc9d930746716786c5
protoMethodName: /hello.HelloService/SayHello
reflectionApi:
enabled: false
url: https://buf.build
module: buf.build/connectrpc/eliza
- url: wss://echo.websocket.org
name: New WebSocket Request
meta:
id: ws-req_5d1a4c7c79494743962e5176f6add270
created: 1746799553909
modified: 1746887120958
sortKey: -1746799553909
settings:
encodeUrl: true
followRedirects: global
cookies:
send: true
store: true
authentication:
type: basic
useISO88591: false
disabled: false
username: user
password: password
headers:
- name: User-Agent
value: insomnia/11.1.0
cookieJar:
name: Default Jar
meta:
id: jar_663d5741b072441aa2709a6113371510
created: 1736781343768
modified: 1736781343768
environments:
name: Base Environment
meta:
id: env_20945044d3c8497ca8b717bef750987e
created: 1736781343767
modified: 1736781355209
isPrivate: false
data:
BASE_VAR: hello
subEnvironments:
- name: Production
meta:
id: env_6f7728bb7fc04d558d668e954d756ea2
created: 1736781358515
modified: 1736781394705
isPrivate: false
sortKey: 1736781358515
data:
BASE_URL: https://api.yaak.app
color: "#f22c2c"
- name: Staging
meta:
id: env_976a8b6eb5d44fb6a20150f65c32d243
created: 1736781374707
modified: 1736781391078
isPrivate: false
sortKey: 1736781358565
data:
BASE_URL: https://api.staging.yaak.app
color: "#206fac"

View File

@@ -0,0 +1,203 @@
{
"resources": {
"environments": [
{
"createdAt": "2025-01-13T15:15:43.767",
"updatedAt": "2025-01-13T15:15:55.209",
"public": true,
"id": "GENERATE_ID::env_20945044d3c8497ca8b717bef750987e",
"model": "environment",
"name": "Base Environment",
"parentId": null,
"parentModel": "workspace",
"variables": [
{
"enabled": true,
"name": "BASE_VAR",
"value": "hello"
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
},
{
"createdAt": "2025-01-13T15:15:58.515",
"updatedAt": "2025-01-13T15:16:34.705",
"public": true,
"id": "GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2",
"model": "environment",
"name": "Production",
"parentId": null,
"parentModel": "environment",
"sortPriority": 1736781358515,
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
},
{
"createdAt": "2025-01-13T15:16:14.707",
"updatedAt": "2025-01-13T15:16:31.078",
"public": true,
"parentId": null,
"parentModel": "environment",
"id": "GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243",
"model": "environment",
"name": "Staging",
"sortPriority": 1736781358565,
"variables": [
{
"enabled": true,
"name": "BASE_URL",
"value": "https://api.staging.yaak.app"
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"folders": [
{
"createdAt": "2025-01-13T15:16:44.718",
"updatedAt": "2025-01-13T15:16:44.718",
"folderId": null,
"id": "GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c",
"model": "folder",
"name": "Top Level",
"sortPriority": -1736781404718,
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53",
"authentication": {},
"authenticationType": null,
"headers": []
}
],
"grpcRequests": [
{
"model": "grpc_request",
"createdAt": "2025-05-09T14:02:24.864",
"folderId": null,
"id": "GENERATE_ID::greq_06d659324df94504a4d64632be7106b3",
"message": "{\n\t\"greeting\": \"Greg\"\n}",
"metadata": [],
"method": "SayHello",
"name": "New Request",
"service": "hello.HelloService",
"sortPriority": -1746799344864,
"updatedAt": "2025-05-09T14:05:44.082",
"url": "grpcb.in:9000",
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"httpRequests": [
{
"authentication": {
"password": "pass",
"username": "user"
},
"authenticationType": "basic",
"body": {
"form": [
{
"enabled": true,
"file": null,
"name": "form",
"value": "data"
}
]
},
"bodyType": "multipart/form-data",
"createdAt": "2025-01-13T15:16:46.672",
"updatedAt": "2025-01-13T15:17:53.176",
"description": "My description of the request",
"folderId": "GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c",
"headers": [
{
"enabled": true,
"name": "Content-Type",
"value": "multipart/form-data"
},
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/10.3.0"
},
{
"enabled": true,
"name": "X-Header",
"value": "xxxx"
},
{
"enabled": true,
"name": "${[ApiHeaderName ]}",
"value": "${[ApiKey ]}"
}
],
"id": "GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785",
"method": "GET",
"model": "http_request",
"name": "New Request",
"sortPriority": -1736781406672,
"url": "${[BASE_URL ]}/foo/:id",
"urlParameters": [
{
"name": "query",
"value": "qqq",
"enabled": true
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"websocketRequests": [
{
"id": "GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270",
"createdAt": "2025-05-09T14:05:53.909",
"updatedAt": "2025-05-10T14:25:20.958",
"message": "",
"model": "websocket_request",
"name": "New WebSocket Request",
"sortPriority": -1746799553909,
"authenticationType": "basic",
"authentication": {
"password": "password",
"username": "user"
},
"folderId": null,
"headers": [
{
"enabled": true,
"name": "User-Agent",
"value": "insomnia/11.1.0"
}
],
"url": "wss://echo.websocket.org",
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],
"workspaces": [
{
"createdAt": "2025-05-09T14:01:45.927",
"updatedAt": "2025-05-10T02:10:54.272",
"description": "This is the description",
"id": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53",
"model": "workspace",
"name": "Dummy",
"authentication": {},
"authenticationType": null,
"headers": []
}
]
},
"sourceKeys": {
"GENERATE_ID::env_20945044d3c8497ca8b717bef750987e": "env_20945044d3c8497ca8b717bef750987e",
"GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2": "env_6f7728bb7fc04d558d668e954d756ea2",
"GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243": "env_976a8b6eb5d44fb6a20150f65c32d243",
"GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c": "fld_42eb2e2bb22b4cedacbd3d057634e80c",
"GENERATE_ID::greq_06d659324df94504a4d64632be7106b3": "greq_06d659324df94504a4d64632be7106b3",
"GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785": "req_d72fff2a6b104b91a2ebe9de9edd2785",
"GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270": "ws-req_5d1a4c7c79494743962e5176f6add270",
"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53": "wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
}

View File

@@ -0,0 +1,67 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import YAML from "yaml";
import { convertInsomnia } from "../src";
describe("importer-yaak", () => {
const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p);
for (const fixture of fixtures) {
if (fixture.includes(".output")) {
continue;
}
test(`Imports ${fixture}`, () => {
const contents = fs.readFileSync(path.join(p, fixture), "utf-8");
const expected = fs.readFileSync(
path.join(p, fixture.replace(/.input\..*/, ".output.json")),
"utf-8",
);
const result = convertInsomnia(contents);
// console.log(JSON.stringify(result, null, 2))
expect(result).toEqual(parseJsonOrYaml(expected));
});
}
test("Keys resources by their Insomnia _id, unchanged by a rename", () => {
const collection = (requestName: string) =>
YAML.stringify({
type: "collection.insomnia.rest/5.0",
name: "Keys",
meta: { id: "wrk_1" },
environments: { meta: { id: "env_1" }, name: "Base", data: {} },
collection: [
{
meta: { id: "fld_1" },
name: "Folder",
children: [
{
meta: { id: "req_1" },
name: requestName,
method: "GET",
url: "https://yaak.app",
},
],
},
],
});
const before = convertInsomnia(collection("Original"));
const after = convertInsomnia(collection("Renamed"));
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("req_1");
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("req_1");
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("fld_1");
expect(before?.sourceKeys?.[before.resources.workspaces[0]!.id]).toBe("wrk_1");
});
});
function parseJsonOrYaml(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return YAML.parse(text);
}
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -0,0 +1,16 @@
{
"name": "@yaak/importer-openapi",
"displayName": "OpenAPI Importer",
"version": "0.1.0",
"private": true,
"description": "Import API specifications from OpenAPI/Swagger format",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests",
"test:roundtrip": "node tests/roundtrip.mjs"
},
"dependencies": {
"yaml": "^2.8.3"
}
}

Some files were not shown because too many files have changed in this diff Show More