mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-15 14:21:53 +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
BIN
Binary file not shown.
@@ -697,22 +697,22 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
|
|||||||
console.warn(arg0, arg1);
|
console.warn(arg0, arg1);
|
||||||
}
|
}
|
||||||
export function __wbindgen_cast_0000000000000001(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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
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);
|
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -51,6 +51,8 @@ Direct username/password authentication.
|
|||||||
- **Token Persistence**: Stores tokens between sessions
|
- **Token Persistence**: Stores tokens between sessions
|
||||||
- **Flexible Configuration**: Supports custom authorization and token endpoints
|
- **Flexible Configuration**: Supports custom authorization and token endpoints
|
||||||
- **Scope Management**: Configure required OAuth scopes for your API
|
- **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
|
- **Error Handling**: Comprehensive error handling and user feedback
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
@@ -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 { Context, HttpRequest, HttpUrlParameter } from "@yaakapp/api";
|
||||||
|
import type { CustomRequestParams } from "./customParams";
|
||||||
|
import { mergeFormParams, mergeHeaders } from "./customParams";
|
||||||
import type { AccessTokenRawResponse } from "./store";
|
import type { AccessTokenRawResponse } from "./store";
|
||||||
|
|
||||||
export async function fetchAccessToken(
|
export async function fetchAccessToken(
|
||||||
@@ -10,9 +12,10 @@ export async function fetchAccessToken(
|
|||||||
scope: string | null;
|
scope: string | null;
|
||||||
audience: string | null;
|
audience: string | null;
|
||||||
params: HttpUrlParameter[];
|
params: HttpUrlParameter[];
|
||||||
|
custom?: CustomRequestParams;
|
||||||
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
|
} & ({ clientAssertion: string } | { clientSecret: string; credentialsInBody: boolean }),
|
||||||
): Promise<AccessTokenRawResponse> {
|
): 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);
|
console.log("[oauth2] Getting access token", accessTokenUrl);
|
||||||
const httpRequest: Partial<HttpRequest> = {
|
const httpRequest: Partial<HttpRequest> = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -61,6 +64,12 @@ export async function fetchAccessToken(
|
|||||||
httpRequest.headers?.push({ name: "Authorization", value });
|
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
|
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
|
||||||
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
|
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Context, HttpRequest } from "@yaakapp/api";
|
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 type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from "./store";
|
||||||
import { deleteToken, getToken, storeToken } from "./store";
|
import { deleteToken, getToken, storeToken } from "./store";
|
||||||
import { isTokenExpired } from "./util";
|
import { isTokenExpired } from "./util";
|
||||||
@@ -14,6 +16,7 @@ export async function getOrRefreshAccessToken(
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
tokenName,
|
tokenName,
|
||||||
forceRefresh,
|
forceRefresh,
|
||||||
|
custom,
|
||||||
}: {
|
}: {
|
||||||
scope: string | null;
|
scope: string | null;
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
@@ -22,6 +25,7 @@ export async function getOrRefreshAccessToken(
|
|||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
tokenName?: "access_token" | "id_token";
|
tokenName?: "access_token" | "id_token";
|
||||||
forceRefresh?: boolean;
|
forceRefresh?: boolean;
|
||||||
|
custom?: CustomRequestParams;
|
||||||
},
|
},
|
||||||
): Promise<AccessToken | null> {
|
): Promise<AccessToken | null> {
|
||||||
const token = await getToken(ctx, tokenArgs);
|
const token = await getToken(ctx, tokenArgs);
|
||||||
@@ -69,6 +73,12 @@ export async function getOrRefreshAccessToken(
|
|||||||
httpRequest.headers?.push({ name: "Authorization", value });
|
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
|
httpRequest.authenticationType = "none"; // Don't inherit workspace auth
|
||||||
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
|
const { httpResponse: resp, body: responseBody } = await ctx.httpRequest.send({ httpRequest });
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import type { Context } from "@yaakapp/api";
|
import type { Context } from "@yaakapp/api";
|
||||||
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
|
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
|
||||||
|
import type { CustomParams } from "../customParams";
|
||||||
|
import { applyQueryParams, NO_CUSTOM_PARAMS } from "../customParams";
|
||||||
import { fetchAccessToken } from "../fetchAccessToken";
|
import { fetchAccessToken } from "../fetchAccessToken";
|
||||||
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
|
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
|
||||||
import type { AccessToken, TokenStoreArgs } from "../store";
|
import type { AccessToken, TokenStoreArgs } from "../store";
|
||||||
@@ -36,6 +38,7 @@ export async function getAuthorizationCode(
|
|||||||
pkce,
|
pkce,
|
||||||
tokenName,
|
tokenName,
|
||||||
externalBrowser,
|
externalBrowser,
|
||||||
|
customParams = NO_CUSTOM_PARAMS,
|
||||||
}: {
|
}: {
|
||||||
authorizationUrl: string;
|
authorizationUrl: string;
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
@@ -52,6 +55,7 @@ export async function getAuthorizationCode(
|
|||||||
} | null;
|
} | null;
|
||||||
tokenName: "access_token" | "id_token";
|
tokenName: "access_token" | "id_token";
|
||||||
externalBrowser?: ExternalBrowserOptions;
|
externalBrowser?: ExternalBrowserOptions;
|
||||||
|
customParams?: CustomParams;
|
||||||
},
|
},
|
||||||
): Promise<AccessToken> {
|
): Promise<AccessToken> {
|
||||||
const tokenArgs: TokenStoreArgs = {
|
const tokenArgs: TokenStoreArgs = {
|
||||||
@@ -68,6 +72,7 @@ export async function getAuthorizationCode(
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
tokenName,
|
tokenName,
|
||||||
|
custom: customParams.refresh,
|
||||||
});
|
});
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
return token;
|
return token;
|
||||||
@@ -92,6 +97,10 @@ export async function getAuthorizationCode(
|
|||||||
authorizationUrl.searchParams.set("code_challenge_method", 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 code: string;
|
||||||
let actualRedirectUri: string | null = redirectUri;
|
let actualRedirectUri: string | null = redirectUri;
|
||||||
|
|
||||||
@@ -130,6 +139,7 @@ export async function getAuthorizationCode(
|
|||||||
...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []),
|
...(pkce ? [{ name: "code_verifier", value: pkce.codeVerifier }] : []),
|
||||||
...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []),
|
...(actualRedirectUri ? [{ name: "redirect_uri", value: actualRedirectUri }] : []),
|
||||||
],
|
],
|
||||||
|
custom: customParams.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
return storeToken(ctx, tokenArgs, response, tokenName);
|
return storeToken(ctx, tokenArgs, response, tokenName);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createPrivateKey, randomUUID } from "node:crypto";
|
import { createPrivateKey, randomUUID } from "node:crypto";
|
||||||
import type { Context } from "@yaakapp/api";
|
import type { Context } from "@yaakapp/api";
|
||||||
import jwt, { type Algorithm } from "jsonwebtoken";
|
import jwt, { type Algorithm } from "jsonwebtoken";
|
||||||
|
import type { CustomParams } from "../customParams";
|
||||||
|
import { NO_CUSTOM_PARAMS } from "../customParams";
|
||||||
import { fetchAccessToken } from "../fetchAccessToken";
|
import { fetchAccessToken } from "../fetchAccessToken";
|
||||||
import type { TokenStoreArgs } from "../store";
|
import type { TokenStoreArgs } from "../store";
|
||||||
import { getToken, storeToken } from "../store";
|
import { getToken, storeToken } from "../store";
|
||||||
@@ -109,6 +111,7 @@ export async function getClientCredentials(
|
|||||||
clientAssertionSecretBase64,
|
clientAssertionSecretBase64,
|
||||||
clientCredentialsMethod,
|
clientCredentialsMethod,
|
||||||
clientAssertionAlgorithm,
|
clientAssertionAlgorithm,
|
||||||
|
customParams = NO_CUSTOM_PARAMS,
|
||||||
}: {
|
}: {
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -120,6 +123,7 @@ export async function getClientCredentials(
|
|||||||
clientAssertionSecretBase64: boolean;
|
clientAssertionSecretBase64: boolean;
|
||||||
clientCredentialsMethod: string;
|
clientCredentialsMethod: string;
|
||||||
clientAssertionAlgorithm: string;
|
clientAssertionAlgorithm: string;
|
||||||
|
customParams?: CustomParams;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const tokenArgs: TokenStoreArgs = {
|
const tokenArgs: TokenStoreArgs = {
|
||||||
@@ -143,6 +147,7 @@ export async function getClientCredentials(
|
|||||||
clientId,
|
clientId,
|
||||||
scope,
|
scope,
|
||||||
params: [],
|
params: [],
|
||||||
|
custom: customParams.token,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
|
const fetchParams: Parameters<typeof fetchAccessToken>[1] =
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Context } from "@yaakapp/api";
|
import type { Context } from "@yaakapp/api";
|
||||||
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
|
import { getRedirectUrlViaExternalBrowser } from "../callbackServer";
|
||||||
|
import type { CustomParams } from "../customParams";
|
||||||
|
import { applyQueryParams, NO_CUSTOM_PARAMS } from "../customParams";
|
||||||
import type { AccessToken, AccessTokenRawResponse } from "../store";
|
import type { AccessToken, AccessTokenRawResponse } from "../store";
|
||||||
import { getDataDirKey, getToken, storeToken } from "../store";
|
import { getDataDirKey, getToken, storeToken } from "../store";
|
||||||
import { isTokenExpired } from "../util";
|
import { isTokenExpired } from "../util";
|
||||||
@@ -18,6 +20,7 @@ export async function getImplicit(
|
|||||||
audience,
|
audience,
|
||||||
tokenName,
|
tokenName,
|
||||||
externalBrowser,
|
externalBrowser,
|
||||||
|
customParams = NO_CUSTOM_PARAMS,
|
||||||
}: {
|
}: {
|
||||||
authorizationUrl: string;
|
authorizationUrl: string;
|
||||||
responseType: string;
|
responseType: string;
|
||||||
@@ -28,6 +31,7 @@ export async function getImplicit(
|
|||||||
audience: string | null;
|
audience: string | null;
|
||||||
tokenName: "access_token" | "id_token";
|
tokenName: "access_token" | "id_token";
|
||||||
externalBrowser?: ExternalBrowserOptions;
|
externalBrowser?: ExternalBrowserOptions;
|
||||||
|
customParams?: CustomParams;
|
||||||
},
|
},
|
||||||
): Promise<AccessToken> {
|
): Promise<AccessToken> {
|
||||||
const tokenArgs = {
|
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;
|
let newToken: AccessToken;
|
||||||
|
|
||||||
// Use external browser flow if enabled
|
// Use external browser flow if enabled
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Context } from "@yaakapp/api";
|
import type { Context } from "@yaakapp/api";
|
||||||
|
import type { CustomParams } from "../customParams";
|
||||||
|
import { NO_CUSTOM_PARAMS } from "../customParams";
|
||||||
import { fetchAccessToken } from "../fetchAccessToken";
|
import { fetchAccessToken } from "../fetchAccessToken";
|
||||||
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
|
import { getOrRefreshAccessToken } from "../getOrRefreshAccessToken";
|
||||||
import type { AccessToken, TokenStoreArgs } from "../store";
|
import type { AccessToken, TokenStoreArgs } from "../store";
|
||||||
@@ -16,6 +18,7 @@ export async function getPassword(
|
|||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
audience,
|
audience,
|
||||||
scope,
|
scope,
|
||||||
|
customParams = NO_CUSTOM_PARAMS,
|
||||||
}: {
|
}: {
|
||||||
accessTokenUrl: string;
|
accessTokenUrl: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -25,6 +28,7 @@ export async function getPassword(
|
|||||||
scope: string | null;
|
scope: string | null;
|
||||||
audience: string | null;
|
audience: string | null;
|
||||||
credentialsInBody: boolean;
|
credentialsInBody: boolean;
|
||||||
|
customParams?: CustomParams;
|
||||||
},
|
},
|
||||||
): Promise<AccessToken> {
|
): Promise<AccessToken> {
|
||||||
const tokenArgs: TokenStoreArgs = {
|
const tokenArgs: TokenStoreArgs = {
|
||||||
@@ -40,6 +44,7 @@ export async function getPassword(
|
|||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
|
custom: customParams.refresh,
|
||||||
});
|
});
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
return token;
|
return token;
|
||||||
@@ -57,6 +62,7 @@ export async function getPassword(
|
|||||||
{ name: "username", value: username },
|
{ name: "username", value: username },
|
||||||
{ name: "password", value: password },
|
{ name: "password", value: password },
|
||||||
],
|
],
|
||||||
|
custom: customParams.token,
|
||||||
});
|
});
|
||||||
|
|
||||||
return storeToken(ctx, tokenArgs, response);
|
return storeToken(ctx, tokenArgs, response);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DEFAULT_LOCALHOST_PORT,
|
DEFAULT_LOCALHOST_PORT,
|
||||||
stopActiveServer,
|
stopActiveServer,
|
||||||
} from "./callbackServer";
|
} from "./callbackServer";
|
||||||
|
import { readCustomParams } from "./customParams";
|
||||||
import {
|
import {
|
||||||
type CallbackType,
|
type CallbackType,
|
||||||
DEFAULT_PKCE_METHOD,
|
DEFAULT_PKCE_METHOD,
|
||||||
@@ -468,6 +469,48 @@ export const plugin: PluginDefinition = {
|
|||||||
values.clientCredentialsMethod === "client_assertion",
|
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 headerPrefix = stringArg(values, "headerPrefix");
|
||||||
const grantType = stringArg(values, "grantType") as GrantType;
|
const grantType = stringArg(values, "grantType") as GrantType;
|
||||||
const credentialsInBody = values.credentials === "body";
|
const credentialsInBody = values.credentials === "body";
|
||||||
|
const customParams = readCustomParams(values);
|
||||||
const tokenName = values.tokenName === "id_token" ? "id_token" : "access_token";
|
const tokenName = values.tokenName === "id_token" ? "id_token" : "access_token";
|
||||||
|
|
||||||
// Build external browser options if enabled
|
// Build external browser options if enabled
|
||||||
@@ -546,6 +590,7 @@ export const plugin: PluginDefinition = {
|
|||||||
: null,
|
: null,
|
||||||
tokenName: tokenName,
|
tokenName: tokenName,
|
||||||
externalBrowser: externalBrowserOptions,
|
externalBrowser: externalBrowserOptions,
|
||||||
|
customParams,
|
||||||
});
|
});
|
||||||
} else if (grantType === "implicit") {
|
} else if (grantType === "implicit") {
|
||||||
const authorizationUrl = stringArg(values, "authorizationUrl");
|
const authorizationUrl = stringArg(values, "authorizationUrl");
|
||||||
@@ -561,6 +606,7 @@ export const plugin: PluginDefinition = {
|
|||||||
state: stringArgOrNull(values, "state"),
|
state: stringArgOrNull(values, "state"),
|
||||||
tokenName: tokenName,
|
tokenName: tokenName,
|
||||||
externalBrowser: externalBrowserOptions,
|
externalBrowser: externalBrowserOptions,
|
||||||
|
customParams,
|
||||||
});
|
});
|
||||||
} else if (grantType === "client_credentials") {
|
} else if (grantType === "client_credentials") {
|
||||||
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
||||||
@@ -577,6 +623,7 @@ export const plugin: PluginDefinition = {
|
|||||||
scope: stringArgOrNull(values, "scope"),
|
scope: stringArgOrNull(values, "scope"),
|
||||||
audience: stringArgOrNull(values, "audience"),
|
audience: stringArgOrNull(values, "audience"),
|
||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
|
customParams,
|
||||||
});
|
});
|
||||||
} else if (grantType === "password") {
|
} else if (grantType === "password") {
|
||||||
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
const accessTokenUrl = stringArg(values, "accessTokenUrl");
|
||||||
@@ -591,6 +638,7 @@ export const plugin: PluginDefinition = {
|
|||||||
scope: stringArgOrNull(values, "scope"),
|
scope: stringArgOrNull(values, "scope"),
|
||||||
audience: stringArgOrNull(values, "audience"),
|
audience: stringArgOrNull(values, "audience"),
|
||||||
credentialsInBody,
|
credentialsInBody,
|
||||||
|
customParams,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Invalid grant type ${String(grantType)}`);
|
throw new Error(`Invalid grant type ${String(grantType)}`);
|
||||||
|
|||||||
@@ -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;
|
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 = {
|
const baseArgs = {
|
||||||
clientId: "client-123",
|
clientId: "client-123",
|
||||||
accessTokenUrl: "https://auth.example.com/token",
|
accessTokenUrl: "https://auth.example.com/token",
|
||||||
@@ -91,3 +97,102 @@ describe("fetchAccessToken scope handling", () => {
|
|||||||
expect(formNames(sent[0]!)).not.toContain("scope");
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user