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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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