feat(auth-oauth2): custom parameters for authorization, token, and refresh requests (#629)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-13 09:15:25 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 7d0926c961
commit 7a6d275fdb
16 changed files with 771 additions and 5 deletions
@@ -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" });
});
});
@@ -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: [] },
});
});
});
@@ -36,6 +36,12 @@ 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",
@@ -91,3 +97,102 @@ describe("fetchAccessToken scope handling", () => {
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");
});
});
@@ -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",
]);
});
});