mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-16 23:01:31 +02:00
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:
co-authored by
Claude Opus 5
parent
7d0926c961
commit
7a6d275fdb
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<AccessTokenRawResponse> {
|
||||
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<HttpRequest> = {
|
||||
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 });
|
||||
|
||||
|
||||
@@ -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<AccessToken | null> {
|
||||
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 });
|
||||
|
||||
|
||||
@@ -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<AccessToken> {
|
||||
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);
|
||||
|
||||
@@ -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<typeof fetchAccessToken>[1] =
|
||||
|
||||
@@ -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<AccessToken> {
|
||||
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
|
||||
|
||||
@@ -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<AccessToken> {
|
||||
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);
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user