Gregory Schier
2025-05-30 08:02:29 -07:00
parent 1e27e1d8cb
commit b52570bf58
8 changed files with 126 additions and 54 deletions

View File

@@ -282,15 +282,9 @@ export class PluginInstance {
this.#importModule(); this.#importModule();
} }
} catch (err) { } catch (err) {
console.log('Plugin call threw exception', payload.type, err); const error = `${err}`.replace(/^Error:\s*/g, '');
this.#sendPayload( console.log('Plugin call threw exception', payload.type, '→', error);
windowContext, this.#sendPayload(windowContext, { type: 'error_response', error }, replyId);
{
type: 'error_response',
error: `${err}`,
},
replyId,
);
return; return;
} }

View File

@@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs';
import { AccessTokenRawResponse } from './store'; import { AccessTokenRawResponse } from './store';
export async function getAccessToken( export async function getAccessToken(
ctx: Context, { ctx: Context,
{
accessTokenUrl, accessTokenUrl,
scope, scope,
audience, audience,
@@ -21,17 +22,15 @@ export async function getAccessToken(
audience: string | null; audience: string | null;
credentialsInBody: boolean; credentialsInBody: boolean;
params: HttpUrlParameter[]; params: HttpUrlParameter[];
}): Promise<AccessTokenRawResponse> { },
): Promise<AccessTokenRawResponse> {
console.log('Getting access token', accessTokenUrl); console.log('Getting access token', accessTokenUrl);
const httpRequest: Partial<HttpRequest> = { const httpRequest: Partial<HttpRequest> = {
method: 'POST', method: 'POST',
url: accessTokenUrl, url: accessTokenUrl,
bodyType: 'application/x-www-form-urlencoded', bodyType: 'application/x-www-form-urlencoded',
body: { body: {
form: [ form: [{ name: 'grant_type', value: grantType }, ...params],
{ name: 'grant_type', value: grantType },
...params,
],
}, },
headers: [ headers: [
{ name: 'User-Agent', value: 'yaak' }, { name: 'User-Agent', value: 'yaak' },
@@ -56,7 +55,9 @@ export async function getAccessToken(
const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : ''; const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : '';
if (resp.status < 200 || resp.status >= 300) { if (resp.status < 200 || resp.status >= 300) {
throw new Error('Failed to fetch access token with status=' + resp.status + ' and body=' + body); throw new Error(
'Failed to fetch access token with status=' + resp.status + ' and body=' + body,
);
} }
let response; let response;

View File

@@ -22,6 +22,7 @@ export async function getAuthorizationCode(
audience, audience,
credentialsInBody, credentialsInBody,
pkce, pkce,
tokenName,
}: { }: {
authorizationUrl: string; authorizationUrl: string;
accessTokenUrl: string; accessTokenUrl: string;
@@ -36,6 +37,7 @@ export async function getAuthorizationCode(
challengeMethod: string | null; challengeMethod: string | null;
codeVerifier: string | null; codeVerifier: string | null;
} | null; } | null;
tokenName: 'access_token' | 'id_token';
}, },
): Promise<AccessToken> { ): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, { const token = await getOrRefreshAccessToken(ctx, contextId, {
@@ -59,7 +61,10 @@ export async function getAuthorizationCode(
if (pkce) { if (pkce) {
const verifier = pkce.codeVerifier || createPkceCodeVerifier(); const verifier = pkce.codeVerifier || createPkceCodeVerifier();
const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD; const challengeMethod = pkce.challengeMethod || DEFAULT_PKCE_METHOD;
authorizationUrl.searchParams.set('code_challenge', createPkceCodeChallenge(verifier, challengeMethod)); authorizationUrl.searchParams.set(
'code_challenge',
createPkceCodeChallenge(verifier, challengeMethod),
);
authorizationUrl.searchParams.set('code_challenge_method', challengeMethod); authorizationUrl.searchParams.set('code_challenge_method', challengeMethod);
} }
@@ -107,7 +112,7 @@ export async function getAuthorizationCode(
}); });
try { try {
resolve(await storeToken(ctx, contextId, response)); resolve(await storeToken(ctx, contextId, response, tokenName));
} catch (err) { } catch (err) {
reject(err); reject(err);
} }
@@ -127,14 +132,15 @@ function createPkceCodeChallenge(verifier: string, method: string) {
const hash = encodeForPkce(createHash('sha256').update(verifier).digest()); const hash = encodeForPkce(createHash('sha256').update(verifier).digest());
return hash return hash
.replace(/=/g, '') // Remove padding '=' .replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-' .replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_' .replace(/\//g, '_'); // Replace '/' with '_'
} }
function encodeForPkce(bytes: Buffer) { function encodeForPkce(bytes: Buffer) {
return bytes.toString('base64') return bytes
.replace(/=/g, '') // Remove padding '=' .toString('base64')
.replace(/=/g, '') // Remove padding '='
.replace(/\+/g, '-') // Replace '+' with '-' .replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_'); // Replace '/' with '_' .replace(/\//g, '_'); // Replace '/' with '_'
} }

View File

@@ -12,6 +12,7 @@ export function getImplicit(
scope, scope,
state, state,
audience, audience,
tokenName,
}: { }: {
authorizationUrl: string; authorizationUrl: string;
responseType: string; responseType: string;
@@ -20,8 +21,9 @@ export function getImplicit(
scope: string | null; scope: string | null;
state: string | null; state: string | null;
audience: string | null; audience: string | null;
tokenName: 'access_token' | 'id_token';
}, },
) :Promise<AccessToken> { ): Promise<AccessToken> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const token = await getToken(ctx, contextId); const token = await getToken(ctx, contextId);
if (token) { if (token) {
@@ -38,7 +40,10 @@ export function getImplicit(
if (state) authorizationUrl.searchParams.set('state', state); if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience); if (audience) authorizationUrl.searchParams.set('audience', audience);
if (responseType.includes('id_token')) { if (responseType.includes('id_token')) {
authorizationUrl.searchParams.set('nonce', String(Math.floor(Math.random() * 9999999999999) + 1)); authorizationUrl.searchParams.set(
'nonce',
String(Math.floor(Math.random() * 9999999999999) + 1),
);
} }
const authorizationUrlStr = authorizationUrl.toString(); const authorizationUrlStr = authorizationUrl.toString();
@@ -60,7 +65,7 @@ export function getImplicit(
const hash = url.hash.slice(1); const hash = url.hash.slice(1);
const params = new URLSearchParams(hash); const params = new URLSearchParams(hash);
const accessToken = params.get('access_token'); const accessToken = params.get(tokenName);
if (!accessToken) { if (!accessToken) {
return; return;
} }

View File

@@ -5,7 +5,12 @@ import {
JsonPrimitive, JsonPrimitive,
PluginDefinition, PluginDefinition,
} from '@yaakapp/api'; } from '@yaakapp/api';
import { DEFAULT_PKCE_METHOD, getAuthorizationCode, PKCE_PLAIN, PKCE_SHA256 } from './grants/authorizationCode'; import {
DEFAULT_PKCE_METHOD,
getAuthorizationCode,
PKCE_PLAIN,
PKCE_SHA256,
} from './grants/authorizationCode';
import { getClientCredentials } from './grants/clientCredentials'; import { getClientCredentials } from './grants/clientCredentials';
import { getImplicit } from './grants/implicit'; import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password'; import { getPassword } from './grants/password';
@@ -22,10 +27,13 @@ const grantTypes: FormInputSelectOption[] = [
const defaultGrantType = grantTypes[0]!.value; const defaultGrantType = grantTypes[0]!.value;
function hiddenIfNot(grantTypes: GrantType[], ...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]) { function hiddenIfNot(
grantTypes: GrantType[],
...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[]
) {
return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => { return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => {
const hasGrantType = grantTypes.find(t => t === String(values.grantType ?? defaultGrantType)); const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType));
const hasOtherBools = other.every(t => t(values)); const hasOtherBools = other.every((t) => t(values));
const show = hasGrantType && hasOtherBools; const show = hasGrantType && hasOtherBools;
return { hidden: !show }; return { hidden: !show };
}; };
@@ -77,7 +85,11 @@ export const plugin: PluginDefinition = {
await ctx.toast.show({ message: 'No token to copy', color: 'warning' }); await ctx.toast.show({ message: 'No token to copy', color: 'warning' });
} else { } else {
await ctx.clipboard.copyText(token.response.access_token); await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({ message: 'Token copied to clipboard', icon: 'copy', color: 'success' }); await ctx.toast.show({
message: 'Token copied to clipboard',
icon: 'copy',
color: 'success',
});
} }
}, },
}, },
@@ -130,7 +142,7 @@ export const plugin: PluginDefinition = {
label: 'Authorization URL', label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']), dynamic: hiddenIfNot(['authorization_code', 'implicit']),
placeholder: authorizationUrls[0], placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map(url => ({ label: url, value: url })), completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })),
}, },
{ {
type: 'text', type: 'text',
@@ -139,7 +151,7 @@ export const plugin: PluginDefinition = {
label: 'Access Token URL', label: 'Access Token URL',
placeholder: accessTokenUrls[0], placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']), dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
completionOptions: accessTokenUrls.map(url => ({ label: url, value: url })), completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
}, },
{ {
type: 'text', type: 'text',
@@ -161,6 +173,20 @@ export const plugin: PluginDefinition = {
label: 'Audience', label: 'Audience',
optional: true, 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: 'checkbox', type: 'checkbox',
name: 'usePkce', name: 'usePkce',
@@ -171,7 +197,10 @@ export const plugin: PluginDefinition = {
type: 'select', type: 'select',
name: 'pkceChallengeMethod', name: 'pkceChallengeMethod',
label: 'Code Challenge Method', label: 'Code Challenge Method',
options: [{ label: 'SHA-256', value: PKCE_SHA256 }, { label: 'Plain', value: PKCE_PLAIN }], options: [
{ label: 'SHA-256', value: PKCE_SHA256 },
{ label: 'Plain', value: PKCE_PLAIN },
],
defaultValue: DEFAULT_PKCE_METHOD, defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
}, },
@@ -215,9 +244,19 @@ export const plugin: PluginDefinition = {
label: 'Advanced', label: 'Advanced',
inputs: [ inputs: [
{ type: 'text', name: 'scope', label: 'Scope', optional: true }, { type: 'text', name: 'scope', label: 'Scope', optional: true },
{ type: 'text', name: 'headerPrefix', label: 'Header Prefix', optional: true, defaultValue: 'Bearer' },
{ {
type: 'select', name: 'credentials', label: 'Send Credentials', defaultValue: 'body', options: [ 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: 'In Request Body', value: 'body' },
{ label: 'As Basic Authentication', value: 'basic' }, { label: 'As Basic Authentication', value: 'basic' },
], ],
@@ -257,8 +296,12 @@ export const plugin: PluginDefinition = {
const authorizationUrl = stringArg(values, 'authorizationUrl'); const authorizationUrl = stringArg(values, 'authorizationUrl');
const accessTokenUrl = stringArg(values, 'accessTokenUrl'); const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getAuthorizationCode(ctx, contextId, { token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`, ? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'), clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'), clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'), redirectUri: stringArgOrNull(values, 'redirectUri'),
@@ -266,26 +309,34 @@ export const plugin: PluginDefinition = {
audience: stringArgOrNull(values, 'audience'), audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'), state: stringArgOrNull(values, 'state'),
credentialsInBody, credentialsInBody,
pkce: values.usePkce ? { pkce: values.usePkce
challengeMethod: stringArg(values, 'pkceChallengeMethod'), ? {
codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'), challengeMethod: stringArg(values, 'pkceChallengeMethod'),
} : null, codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'),
}
: null,
tokenName: values.tokenName === 'id_token' ? 'id_token' : 'access_token',
}); });
} else if (grantType === 'implicit') { } else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl'); const authorizationUrl = stringArg(values, 'authorizationUrl');
token = await getImplicit(ctx, contextId, { token = await getImplicit(ctx, contextId, {
authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`, authorizationUrl: authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'), clientId: stringArg(values, 'clientId'),
redirectUri: stringArgOrNull(values, 'redirectUri'), redirectUri: stringArgOrNull(values, 'redirectUri'),
responseType: stringArg(values, 'responseType'), responseType: stringArg(values, 'responseType'),
scope: stringArgOrNull(values, 'scope'), scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'), audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'), state: stringArgOrNull(values, 'state'),
tokenName: values.tokenName === 'id_token' ? 'id_token' : 'access_token',
}); });
} else if (grantType === 'client_credentials') { } else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl'); const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getClientCredentials(ctx, contextId, { token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'), clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'), clientSecret: stringArg(values, 'clientSecret'),
scope: stringArgOrNull(values, 'scope'), scope: stringArgOrNull(values, 'scope'),
@@ -295,7 +346,9 @@ export const plugin: PluginDefinition = {
} else if (grantType === 'password') { } else if (grantType === 'password') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl'); const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getPassword(ctx, contextId, { token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'), clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'), clientSecret: stringArg(values, 'clientSecret'),
username: stringArg(values, 'username'), username: stringArg(values, 'username'),
@@ -310,16 +363,21 @@ export const plugin: PluginDefinition = {
const headerValue = `${headerPrefix} ${token.response.access_token}`.trim(); const headerValue = `${headerPrefix} ${token.response.access_token}`.trim();
return { return {
setHeaders: [{ setHeaders: [
name: 'Authorization', {
value: headerValue, name: 'Authorization',
}], value: headerValue,
},
],
}; };
}, },
}, },
}; };
function stringArgOrNull(values: Record<string, JsonPrimitive | undefined>, name: string): string | null { function stringArgOrNull(
values: Record<string, JsonPrimitive | undefined>,
name: string,
): string | null {
const arg = values[name]; const arg = values[name];
if (arg == null || arg == '') return null; if (arg == null || arg == '') return null;
return `${arg}`; return `${arg}`;

View File

@@ -1,8 +1,13 @@
import { Context } from '@yaakapp/api'; import { Context } from '@yaakapp/api';
export async function storeToken(ctx: Context, contextId: string, response: AccessTokenRawResponse) { export async function storeToken(
if (!response.access_token) { ctx: Context,
throw new Error(`Token not found in response`); contextId: string,
response: AccessTokenRawResponse,
tokenName: 'access_token' | 'id_token' = 'access_token',
) {
if (!response[tokenName]) {
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(', ')}`);
} }
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null; const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
@@ -41,12 +46,13 @@ function dataDirStoreKey(context_id: string) {
} }
export interface AccessToken { export interface AccessToken {
response: AccessTokenRawResponse, response: AccessTokenRawResponse;
expiresAt: number | null; expiresAt: number | null;
} }
export interface AccessTokenRawResponse { export interface AccessTokenRawResponse {
access_token: string; access_token: string;
id_token?: string;
token_type?: string; token_type?: string;
expires_in?: number; expires_in?: number;
refresh_token?: string; refresh_token?: string;

View File

@@ -530,6 +530,7 @@ impl PluginManager {
InternalEventPayload::EmptyResponse(_) => { InternalEventPayload::EmptyResponse(_) => {
Err(PluginErr("Auth plugin returned empty".to_string())) Err(PluginErr("Auth plugin returned empty".to_string()))
} }
InternalEventPayload::ErrorResponse(e) => Err(PluginErr(e.error)),
e => Err(PluginErr(format!("Auth plugin returned invalid event {:?}", e))), e => Err(PluginErr(format!("Auth plugin returned invalid event {:?}", e))),
} }
} }
@@ -601,6 +602,7 @@ impl PluginManager {
InternalEventPayload::EmptyResponse(_) => { InternalEventPayload::EmptyResponse(_) => {
Err(PluginErr("Auth plugin returned empty".to_string())) Err(PluginErr("Auth plugin returned empty".to_string()))
} }
InternalEventPayload::ErrorResponse(e) => Err(PluginErr(e.error)),
e => Err(PluginErr(format!("Auth plugin returned invalid event {:?}", e))), e => Err(PluginErr(format!("Auth plugin returned invalid event {:?}", e))),
} }
} }

View File

@@ -87,9 +87,9 @@ impl PluginRuntimeServerWebsocket {
// Parse everything but the payload so we can catch errors on that, specifically // Parse everything but the payload so we can catch errors on that, specifically
let payload = serde_json::from_value::<InternalEventPayload>(event.payload.clone()) let payload = serde_json::from_value::<InternalEventPayload>(event.payload.clone())
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
warn!("Plugin error from {}: {:?} {}", event.plugin_name, e, event.payload); warn!("Plugin event parse error from {}: {:?} {}", event.plugin_name, e, event.payload);
InternalEventPayload::ErrorResponse(ErrorResponse { InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Plugin error from {}: {e:?}", event.plugin_name), error: format!("Plugin event parse error from {}: {e:?}", event.plugin_name),
}) })
}); });