diff --git a/crates/yaak-templates/pkg/yaak_templates_bg.wasm b/crates/yaak-templates/pkg/yaak_templates_bg.wasm index e02abbca..ef7c337b 100644 Binary files a/crates/yaak-templates/pkg/yaak_templates_bg.wasm and b/crates/yaak-templates/pkg/yaak_templates_bg.wasm differ diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.js b/crates/yaak-wasm/pkg/yaak_wasm_bg.js index 8450af8d..60789744 100644 --- a/crates/yaak-wasm/pkg/yaak_wasm_bg.js +++ b/crates/yaak-wasm/pkg/yaak_wasm_bg.js @@ -697,22 +697,22 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) { console.warn(arg0, arg1); } export function __wbindgen_cast_0000000000000001(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1123, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3); return ret; } export function __wbindgen_cast_0000000000000002(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 214, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4); return ret; } export function __wbindgen_cast_0000000000000003(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 164, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf); return ret; } export function __wbindgen_cast_0000000000000004(arg0, arg1) { - // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f); return ret; } diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm index 4828e9e8..778ee83a 100644 Binary files a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm and b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm differ diff --git a/plugins/auth-oauth2/README.md b/plugins/auth-oauth2/README.md index 773a949c..8f0e30be 100644 --- a/plugins/auth-oauth2/README.md +++ b/plugins/auth-oauth2/README.md @@ -51,6 +51,8 @@ Direct username/password authentication. - **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 diff --git a/plugins/auth-oauth2/src/customParams.ts b/plugins/auth-oauth2/src/customParams.ts new file mode 100644 index 00000000..e1a9c3ba --- /dev/null +++ b/plugins/auth-oauth2/src/customParams.ts @@ -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): 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( + 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(generated: T[], custom: NameValue[]) { + return mergeByName(generated, custom, { caseInsensitive: true }); +} + +export function mergeFormParams(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); + } +} diff --git a/plugins/auth-oauth2/src/fetchAccessToken.ts b/plugins/auth-oauth2/src/fetchAccessToken.ts index 32e43b9c..9b7372dc 100644 --- a/plugins/auth-oauth2/src/fetchAccessToken.ts +++ b/plugins/auth-oauth2/src/fetchAccessToken.ts @@ -1,4 +1,6 @@ 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( @@ -10,9 +12,10 @@ export async function fetchAccessToken( scope: string | null; audience: string | null; params: HttpUrlParameter[]; + custom?: CustomRequestParams; } & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }), ): Promise { - const { clientId, grantType, accessTokenUrl, scope, audience, params } = args; + const { clientId, grantType, accessTokenUrl, scope, audience, params, custom } = args; console.log("[oauth2] Getting access token", accessTokenUrl); const httpRequest: Partial = { method: "POST", @@ -61,6 +64,12 @@ export async function fetchAccessToken( 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 }); diff --git a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts index 86aee3e9..abc38f24 100644 --- a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts +++ b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts @@ -1,4 +1,6 @@ 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"; @@ -14,6 +16,7 @@ export async function getOrRefreshAccessToken( clientSecret, tokenName, forceRefresh, + custom, }: { scope: string | null; accessTokenUrl: string; @@ -22,6 +25,7 @@ export async function getOrRefreshAccessToken( clientSecret: string; tokenName?: "access_token" | "id_token"; forceRefresh?: boolean; + custom?: CustomRequestParams; }, ): Promise { const token = await getToken(ctx, tokenArgs); @@ -69,6 +73,12 @@ export async function getOrRefreshAccessToken( 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 }); diff --git a/plugins/auth-oauth2/src/grants/authorizationCode.ts b/plugins/auth-oauth2/src/grants/authorizationCode.ts index a015b01b..54f8ace7 100644 --- a/plugins/auth-oauth2/src/grants/authorizationCode.ts +++ b/plugins/auth-oauth2/src/grants/authorizationCode.ts @@ -1,6 +1,8 @@ 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"; @@ -36,6 +38,7 @@ export async function getAuthorizationCode( pkce, tokenName, externalBrowser, + customParams = NO_CUSTOM_PARAMS, }: { authorizationUrl: string; accessTokenUrl: string; @@ -52,6 +55,7 @@ export async function getAuthorizationCode( } | null; tokenName: "access_token" | "id_token"; externalBrowser?: ExternalBrowserOptions; + customParams?: CustomParams; }, ): Promise { const tokenArgs: TokenStoreArgs = { @@ -68,6 +72,7 @@ export async function getAuthorizationCode( clientSecret, credentialsInBody, tokenName, + custom: customParams.refresh, }); if (token != null) { return token; @@ -92,6 +97,10 @@ export async function getAuthorizationCode( 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; @@ -130,6 +139,7 @@ export async function getAuthorizationCode( ...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []), ...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []), ], + custom: customParams.token, }); return storeToken(ctx, tokenArgs, response, tokenName); diff --git a/plugins/auth-oauth2/src/grants/clientCredentials.ts b/plugins/auth-oauth2/src/grants/clientCredentials.ts index d8b34fa8..222e479b 100644 --- a/plugins/auth-oauth2/src/grants/clientCredentials.ts +++ b/plugins/auth-oauth2/src/grants/clientCredentials.ts @@ -1,6 +1,8 @@ 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"; @@ -109,6 +111,7 @@ export async function getClientCredentials( clientAssertionSecretBase64, clientCredentialsMethod, clientAssertionAlgorithm, + customParams = NO_CUSTOM_PARAMS, }: { accessTokenUrl: string; clientId: string; @@ -120,6 +123,7 @@ export async function getClientCredentials( clientAssertionSecretBase64: boolean; clientCredentialsMethod: string; clientAssertionAlgorithm: string; + customParams?: CustomParams; }, ) { const tokenArgs: TokenStoreArgs = { @@ -143,6 +147,7 @@ export async function getClientCredentials( clientId, scope, params: [], + custom: customParams.token, }; const fetchParams: Parameters[1] = diff --git a/plugins/auth-oauth2/src/grants/implicit.ts b/plugins/auth-oauth2/src/grants/implicit.ts index d9b69766..ebbf2106 100644 --- a/plugins/auth-oauth2/src/grants/implicit.ts +++ b/plugins/auth-oauth2/src/grants/implicit.ts @@ -1,5 +1,7 @@ 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"; @@ -18,6 +20,7 @@ export async function getImplicit( audience, tokenName, externalBrowser, + customParams = NO_CUSTOM_PARAMS, }: { authorizationUrl: string; responseType: string; @@ -28,6 +31,7 @@ export async function getImplicit( audience: string | null; tokenName: "access_token" | "id_token"; externalBrowser?: ExternalBrowserOptions; + customParams?: CustomParams; }, ): Promise { const tokenArgs = { @@ -59,6 +63,10 @@ export async function getImplicit( ); } + // 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 diff --git a/plugins/auth-oauth2/src/grants/password.ts b/plugins/auth-oauth2/src/grants/password.ts index dbf55b31..0be38dc9 100644 --- a/plugins/auth-oauth2/src/grants/password.ts +++ b/plugins/auth-oauth2/src/grants/password.ts @@ -1,4 +1,6 @@ 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"; @@ -16,6 +18,7 @@ export async function getPassword( credentialsInBody, audience, scope, + customParams = NO_CUSTOM_PARAMS, }: { accessTokenUrl: string; clientId: string; @@ -25,6 +28,7 @@ export async function getPassword( scope: string | null; audience: string | null; credentialsInBody: boolean; + customParams?: CustomParams; }, ): Promise { const tokenArgs: TokenStoreArgs = { @@ -40,6 +44,7 @@ export async function getPassword( clientId, clientSecret, credentialsInBody, + custom: customParams.refresh, }); if (token != null) { return token; @@ -57,6 +62,7 @@ export async function getPassword( { name: "username", value: username }, { name: "password", value: password }, ], + custom: customParams.token, }); return storeToken(ctx, tokenArgs, response); diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index 02113a18..5531e1fa 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -11,6 +11,7 @@ import { DEFAULT_LOCALHOST_PORT, stopActiveServer, } from "./callbackServer"; +import { readCustomParams } from "./customParams"; import { type CallbackType, DEFAULT_PKCE_METHOD, @@ -468,6 +469,48 @@ export const plugin: PluginDefinition = { 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"]), + }, ], }, { @@ -506,6 +549,7 @@ export const plugin: PluginDefinition = { 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 @@ -546,6 +590,7 @@ export const plugin: PluginDefinition = { : null, tokenName: tokenName, externalBrowser: externalBrowserOptions, + customParams, }); } else if (grantType === "implicit") { const authorizationUrl = stringArg(values, "authorizationUrl"); @@ -561,6 +606,7 @@ export const plugin: PluginDefinition = { state: stringArgOrNull(values, "state"), tokenName: tokenName, externalBrowser: externalBrowserOptions, + customParams, }); } else if (grantType === "client_credentials") { const accessTokenUrl = stringArg(values, "accessTokenUrl"); @@ -577,6 +623,7 @@ export const plugin: PluginDefinition = { scope: stringArgOrNull(values, "scope"), audience: stringArgOrNull(values, "audience"), credentialsInBody, + customParams, }); } else if (grantType === "password") { const accessTokenUrl = stringArg(values, "accessTokenUrl"); @@ -591,6 +638,7 @@ export const plugin: PluginDefinition = { scope: stringArgOrNull(values, "scope"), audience: stringArgOrNull(values, "audience"), credentialsInBody, + customParams, }); } else { throw new Error(`Invalid grant type ${String(grantType)}`); diff --git a/plugins/auth-oauth2/tests/authorizationCode.test.ts b/plugins/auth-oauth2/tests/authorizationCode.test.ts new file mode 100644 index 00000000..f167066b --- /dev/null +++ b/plugins/auth-oauth2/tests/authorizationCode.test.ts @@ -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[] = []; + const values = new Map(); + + const ctx = { + store: { + async set(key: string, value: T) { + values.set(key, value); + }, + async get(key: string) { + return values.get(key) as T | undefined; + }, + }, + window: { + async openUrl({ + url, + onNavigate, + }: { + url: string; + onNavigate: (e: { url: string }) => Promise; + }) { + 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 }) { + 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" }); + }); +}); diff --git a/plugins/auth-oauth2/tests/customParams.test.ts b/plugins/auth-oauth2/tests/customParams.test.ts new file mode 100644 index 00000000..ff0fbf36 --- /dev/null +++ b/plugins/auth-oauth2/tests/customParams.test.ts @@ -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: [] }, + }); + }); +}); diff --git a/plugins/auth-oauth2/tests/fetchAccessToken.test.ts b/plugins/auth-oauth2/tests/fetchAccessToken.test.ts index 5ee0206d..8f02d2b1 100644 --- a/plugins/auth-oauth2/tests/fetchAccessToken.test.ts +++ b/plugins/auth-oauth2/tests/fetchAccessToken.test.ts @@ -36,6 +36,12 @@ function formValue(httpRequest: Partial, name: string) { return (httpRequest.body?.form ?? []).find((p: { name: string }) => p.name === name)?.value; } +function headerValue(httpRequest: Partial, 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"); + }); +}); diff --git a/plugins/auth-oauth2/tests/getOrRefreshAccessToken.test.ts b/plugins/auth-oauth2/tests/getOrRefreshAccessToken.test.ts new file mode 100644 index 00000000..4ab82515 --- /dev/null +++ b/plugins/auth-oauth2/tests/getOrRefreshAccessToken.test.ts @@ -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[] = []; + const values = new Map(); + + const ctx = { + store: { + async set(key: string, value: T) { + values.set(key, value); + }, + async get(key: string) { + return values.get(key) as T | undefined; + }, + async delete(key: string) { + return values.delete(key); + }, + }, + httpRequest: { + async send({ httpRequest }: { httpRequest: Partial }) { + 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, name: string) { + return (httpRequest.headers ?? []).find( + (h: { name: string }) => h.name.toLowerCase() === name.toLowerCase(), + )?.value; +} + +function formValue(httpRequest: Partial, 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", + ]); + }); +});