Compare commits

..

2 Commits

Author SHA1 Message Date
Gregory Schier
20681e5be3 Scoped OAuth 2 tokens 2025-07-23 22:03:03 -07:00
Gregory Schier
a258a80fbd Prevent auth from adding lone ? to URL
https://feedback.yaak.app/p/using-inherited-api-key-causes-a-question-mark-to-be
2025-07-23 17:20:17 -07:00
15 changed files with 242 additions and 93 deletions

View File

@@ -1,19 +0,0 @@
import type { Context } from '@yaakapp/api';
import type { AccessToken } from './store';
import { getToken } from './store';
export async function getAccessTokenIfNotExpired(
ctx: Context,
contextId: string,
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
if (token == null || isTokenExpired(token)) {
return null;
}
return token;
}
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
}

View File

@@ -1,12 +1,12 @@
import type { Context, HttpRequest } from '@yaakapp/api';
import { readFileSync } from 'node:fs';
import { isTokenExpired } from './getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse } from './store';
import type { AccessToken, AccessTokenRawResponse, TokenStoreArgs } from './store';
import { deleteToken, getToken, storeToken } from './store';
import { isTokenExpired } from './util';
export async function getOrRefreshAccessToken(
ctx: Context,
contextId: string,
tokenArgs: TokenStoreArgs,
{
scope,
accessTokenUrl,
@@ -23,7 +23,7 @@ export async function getOrRefreshAccessToken(
forceRefresh?: boolean;
},
): Promise<AccessToken | null> {
const token = await getToken(ctx, contextId);
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return null;
}
@@ -75,7 +75,7 @@ export async function getOrRefreshAccessToken(
// Bad refresh token, so we'll force it to fetch a fresh access token by deleting
// and returning null;
console.log('[oauth2] Unauthorized refresh_token request');
await deleteToken(ctx, contextId);
await deleteToken(ctx, tokenArgs);
return null;
}
@@ -108,5 +108,5 @@ export async function getOrRefreshAccessToken(
refresh_token: response.refresh_token ?? token.response.refresh_token,
};
return storeToken(ctx, contextId, newResponse);
return storeToken(ctx, tokenArgs, newResponse);
}

View File

@@ -2,7 +2,7 @@ import type { Context } from '@yaakapp/api';
import { createHash, randomBytes } from 'node:crypto';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken } from '../store';
import type { AccessToken, TokenStoreArgs } from '../store';
import { getDataDirKey, storeToken } from '../store';
export const PKCE_SHA256 = 'S256';
@@ -41,7 +41,14 @@ export async function getAuthorizationCode(
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: authorizationUrlRaw,
};
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
accessTokenUrl,
scope,
clientId,
@@ -128,7 +135,7 @@ export async function getAuthorizationCode(
],
});
return storeToken(ctx, contextId, response, tokenName);
return storeToken(ctx, tokenArgs, response, tokenName);
}
export function genPkceCodeVerifier() {

View File

@@ -1,7 +1,8 @@
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import type { TokenStoreArgs } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
export async function getClientCredentials(
ctx: Context,
@@ -22,7 +23,13 @@ export async function getClientCredentials(
credentialsInBody: boolean;
},
) {
const token = await getToken(ctx, contextId);
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: null,
};
const token = await getToken(ctx, tokenArgs);
if (token && !isTokenExpired(token)) {
return token;
}
@@ -38,5 +45,5 @@ export async function getClientCredentials(
params: [],
});
return storeToken(ctx, contextId, response);
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -1,7 +1,7 @@
import type { Context } from '@yaakapp/api';
import { isTokenExpired } from '../getAccessTokenIfNotExpired';
import type { AccessToken, AccessTokenRawResponse} from '../store';
import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
export async function getImplicit(
ctx: Context,
@@ -26,7 +26,13 @@ export async function getImplicit(
tokenName: 'access_token' | 'id_token';
},
): Promise<AccessToken> {
const token = await getToken(ctx, contextId);
const tokenArgs = {
contextId,
clientId,
accessTokenUrl: null,
authorizationUrl: authorizationUrlRaw,
};
const token = await getToken(ctx, tokenArgs);
if (token != null && !isTokenExpired(token)) {
return token;
}
@@ -82,7 +88,7 @@ export async function getImplicit(
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
try {
resolve(storeToken(ctx, contextId, response));
resolve(storeToken(ctx, tokenArgs, response));
} catch (err) {
reject(err);
}

View File

@@ -1,7 +1,7 @@
import type { Context } from '@yaakapp/api';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken} from '../store';
import type { AccessToken, TokenStoreArgs } from '../store';
import { storeToken } from '../store';
export async function getPassword(
@@ -27,7 +27,13 @@ export async function getPassword(
credentialsInBody: boolean;
},
): Promise<AccessToken> {
const token = await getOrRefreshAccessToken(ctx, contextId, {
const tokenArgs: TokenStoreArgs = {
contextId,
clientId,
accessTokenUrl,
authorizationUrl: null,
};
const token = await getOrRefreshAccessToken(ctx, tokenArgs, {
accessTokenUrl,
scope,
clientId,
@@ -52,5 +58,5 @@ export async function getPassword(
],
});
return storeToken(ctx, contextId, response);
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -15,7 +15,7 @@ import {
import { getClientCredentials } from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import type { AccessToken } from './store';
import type { AccessToken, TokenStoreArgs } from './store';
import { deleteToken, getToken, resetDataDirKey } from './store';
type GrantType = 'authorization_code' | 'implicit' | 'password' | 'client_credentials';
@@ -83,8 +83,14 @@ export const plugin: PluginDefinition = {
actions: [
{
label: 'Copy Current Token',
async onSelect(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
await ctx.toast.show({ message: 'No token to copy', color: 'warning' });
} else {
@@ -99,8 +105,14 @@ export const plugin: PluginDefinition = {
},
{
label: 'Delete Token',
async onSelect(ctx, { contextId }) {
if (await deleteToken(ctx, contextId)) {
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
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' });
@@ -281,8 +293,14 @@ export const plugin: PluginDefinition = {
{
type: 'accordion',
label: 'Access Token Response',
async dynamic(ctx, { contextId }) {
const token = await getToken(ctx, contextId);
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return { hidden: true };
}
@@ -316,9 +334,10 @@ export const plugin: PluginDefinition = {
accessTokenUrl === '' || accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl: authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
authorizationUrl:
authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'),

View File

@@ -1,8 +1,9 @@
import type { Context } from '@yaakapp/api';
import { createHash } from 'node:crypto';
export async function storeToken(
ctx: Context,
contextId: string,
args: TokenStoreArgs,
response: AccessTokenRawResponse,
tokenName: 'access_token' | 'id_token' = 'access_token',
) {
@@ -15,16 +16,16 @@ export async function storeToken(
response,
expiresAt,
};
await ctx.store.set<AccessToken>(tokenStoreKey(contextId), token);
await ctx.store.set<AccessToken>(tokenStoreKey(args), token);
return token;
}
export async function getToken(ctx: Context, contextId: string) {
return ctx.store.get<AccessToken>(tokenStoreKey(contextId));
export async function getToken(ctx: Context, args: TokenStoreArgs) {
return ctx.store.get<AccessToken>(tokenStoreKey(args));
}
export async function deleteToken(ctx: Context, contextId: string) {
return ctx.store.delete(tokenStoreKey(contextId));
export async function deleteToken(ctx: Context, args: TokenStoreArgs) {
return ctx.store.delete(tokenStoreKey(args));
}
export async function resetDataDirKey(ctx: Context, contextId: string) {
@@ -37,8 +38,25 @@ export async function getDataDirKey(ctx: Context, contextId: string) {
return `${contextId}::${key}`;
}
function tokenStoreKey(contextId: string) {
return ['token', contextId].join('::');
export interface TokenStoreArgs {
contextId: string;
clientId: string;
accessTokenUrl: string | null;
authorizationUrl: 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?:\/\//, ''));
const key = hash.digest('hex');
return ['token', key].join('::');
}
function dataDirStoreKey(contextId: string) {

View File

@@ -0,0 +1,5 @@
import type { AccessToken } from './store';
export function isTokenExpired(token: AccessToken) {
return token.expiresAt && Date.now() > token.expiresAt;
}

View File

@@ -55,11 +55,6 @@ pub async fn send_http_request<R: Runtime>(
let response_id = og_response.id.clone();
let response = Arc::new(Mutex::new(og_response.clone()));
let cb = PluginTemplateCallback::new(
window.app_handle(),
&PluginWindowContext::new(window),
RenderPurpose::Send,
);
let update_source = UpdateSource::from_window(window);
let (resolved_request, auth_context_id) = match resolve_http_request(window, unrendered_request)
@@ -75,6 +70,12 @@ pub async fn send_http_request<R: Runtime>(
}
};
let cb = PluginTemplateCallback::new(
window.app_handle(),
&PluginWindowContext::new(window),
RenderPurpose::Send,
);
let request =
match render_http_request(&resolved_request, &base_environment, environment.as_ref(), &cb)
.await
@@ -488,10 +489,11 @@ pub async fn send_http_request<R: Runtime>(
};
}
let mut query_pairs = sendable_req.url_mut().query_pairs_mut();
for p in plugin_result.set_query_parameters.unwrap_or_default() {
println!("Adding query parameter: {:?}", p);
query_pairs.append_pair(&p.name, &p.value);
if let Some(params) = plugin_result.set_query_parameters {
let mut query_pairs = sendable_req.url_mut().query_pairs_mut();
for p in params {
query_pairs.append_pair(&p.name, &p.value);
}
}
}
}

View File

@@ -35,7 +35,13 @@ use yaak_models::models::{
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose, ShowToastRequest};
use yaak_plugins::events::{
CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs,
CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionsResponse, InternalEvent,
InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::PluginMetadata;
use yaak_plugins::template_callback::PluginTemplateCallback;
@@ -818,9 +824,29 @@ async fn cmd_get_http_authentication_config<R: Runtime>(
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
request_id: &str,
environment_id: Option<&str>,
workspace_id: &str,
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
let base_environment = window.db().get_base_environment(&workspace_id)?;
let environment = match environment_id {
Some(id) => match window.db().get_environment(id) {
Ok(env) => Some(env),
Err(e) => {
warn!("Failed to find environment by id {id} {}", e);
None
}
},
None => None,
};
Ok(plugin_manager
.get_http_authentication_config(&window, auth_name, values, request_id)
.get_http_authentication_config(
&window,
&base_environment,
environment.as_ref(),
auth_name,
values,
request_id,
)
.await?)
}
@@ -872,9 +898,30 @@ async fn cmd_call_http_authentication_action<R: Runtime>(
action_index: i32,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
workspace_id: &str,
environment_id: Option<&str>,
) -> YaakResult<()> {
let base_environment = window.db().get_base_environment(&workspace_id)?;
let environment = match environment_id {
Some(id) => match window.db().get_environment(id) {
Ok(env) => Some(env),
Err(e) => {
warn!("Failed to find environment by id {id} {}", e);
None
}
},
None => None,
};
Ok(plugin_manager
.call_http_authentication_action(&window, auth_name, action_index, values, model_id)
.call_http_authentication_action(
&window,
&base_environment,
environment.as_ref(),
auth_name,
action_index,
values,
model_id,
)
.await?)
}
@@ -1238,7 +1285,10 @@ pub fn run() {
let _ = app_handle.emit(
"show_toast",
ShowToastRequest {
message: format!("Error handling deep link: {}", e.to_string()),
message: format!(
"Error handling deep link: {}",
e.to_string()
),
color: Some(Color::Danger),
icon: None,
},

View File

@@ -18,7 +18,9 @@ use crate::native_template_functions::template_function_secure;
use crate::nodejs::start_nodejs_plugin_runtime;
use crate::plugin_handle::PluginHandle;
use crate::server_ws::PluginRuntimeServerWebsocket;
use crate::template_callback::PluginTemplateCallback;
use log::{error, info, warn};
use serde_json::json;
use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};
@@ -30,10 +32,13 @@ use tokio::fs::read_dir;
use tokio::net::TcpListener;
use tokio::sync::{Mutex, mpsc};
use tokio::time::{Instant, timeout};
use yaak_models::models::Environment;
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::render::make_vars_hashmap;
use yaak_models::util::generate_id;
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result as TemplateResult;
use yaak_templates::render_json_value_raw;
#[derive(Clone)]
pub struct PluginManager {
@@ -569,6 +574,8 @@ impl PluginManager {
pub async fn get_http_authentication_config<R: Runtime>(
&self,
window: &WebviewWindow<R>,
base_environment: &Environment,
environment: Option<&Environment>,
auth_name: &str,
values: HashMap<String, JsonPrimitive>,
request_id: &str,
@@ -579,13 +586,23 @@ impl PluginManager {
.find_map(|(p, r)| if r.name == auth_name { Some(p) } else { None })
.ok_or(PluginNotFoundErr(auth_name.into()))?;
let vars = &make_vars_hashmap(&base_environment, environment);
let cb = PluginTemplateCallback::new(
window.app_handle(),
&PluginWindowContext::new(&window),
RenderPurpose::Preview,
);
let rendered_values = render_json_value_raw(json!(values), vars, &cb).await?;
let context_id = format!("{:x}", md5::compute(request_id.to_string()));
let event = self
.send_to_plugin_and_wait(
&PluginWindowContext::new(window),
&plugin,
&InternalEventPayload::GetHttpAuthenticationConfigRequest(
GetHttpAuthenticationConfigRequest { values, context_id },
GetHttpAuthenticationConfigRequest {
values: serde_json::from_value(rendered_values)?,
context_id,
},
),
)
.await?;
@@ -602,11 +619,24 @@ impl PluginManager {
pub async fn call_http_authentication_action<R: Runtime>(
&self,
window: &WebviewWindow<R>,
base_environment: &Environment,
environment: Option<&Environment>,
auth_name: &str,
action_index: i32,
values: HashMap<String, JsonPrimitive>,
model_id: &str,
) -> Result<()> {
let vars = &make_vars_hashmap(&base_environment, environment);
let rendered_values = render_json_value_raw(
json!(values),
vars,
&PluginTemplateCallback::new(
window.app_handle(),
&PluginWindowContext::new(&window),
RenderPurpose::Preview,
),
)
.await?;
let results = self.get_http_authentication_summaries(window).await?;
let plugin = results
.iter()
@@ -621,7 +651,10 @@ impl PluginManager {
CallHttpAuthenticationActionRequest {
index: action_index,
plugin_ref_id: plugin.clone().ref_id,
args: CallHttpAuthenticationActionArgs { context_id, values },
args: CallHttpAuthenticationActionArgs {
context_id,
values: serde_json::from_value(rendered_values)?,
},
},
),
)

View File

@@ -294,9 +294,11 @@ pub(crate) async fn connect<R: Runtime>(
_ => continue,
};
}
let mut query_pairs = url.query_pairs_mut();
for p in plugin_result.set_query_parameters.unwrap_or_default() {
query_pairs.append_pair(&p.name, &p.value);
if let Some(params) = plugin_result.set_query_parameters {
let mut query_pairs = url.query_pairs_mut();
for p in params {
query_pairs.append_pair(&p.name, &p.value);
}
}
}
}

View File

@@ -182,23 +182,24 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
);
case 'accordion':
return (
<DetailsBanner
key={i}
summary={input.label}
className={classNames(disabled && 'opacity-disabled')}
>
<div className="mb-3 px-3">
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</div>
</DetailsBanner>
<div key={i}>
<DetailsBanner
summary={input.label}
className={classNames('!mb-auto', disabled && 'opacity-disabled')}
>
<div className="mb-3 px-3">
<FormInputs
data={data}
disabled={disabled}
inputs={input.inputs}
setDataAttr={setDataAttr}
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables}
/>
</div>
</DetailsBanner>
</div>
);
case 'banner':
return (
@@ -309,6 +310,7 @@ function EditorArg({
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
disabled={arg.disabled}
language={arg.language}
readOnly={arg.readOnly}
onChange={onChange}
heightMode="auto"
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
@@ -329,9 +331,9 @@ function EditorArg({
showDialog({
id: 'id',
size: 'full',
title: 'Edit Value',
title: arg.readOnly ? 'View Value' : 'Edit Value',
className: '!max-w-[50rem] !max-h-[60rem]',
description: (
description: arg.label && (
<Label
htmlFor={id}
required={!arg.optional}
@@ -355,6 +357,7 @@ function EditorArg({
}
disabled={arg.disabled}
language={arg.language}
readOnly={arg.readOnly}
onChange={onChange}
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
placeholder={arg.placeholder ?? undefined}

View File

@@ -12,12 +12,16 @@ import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import { useState } from 'react';
import { invokeCmd } from '../lib/tauri';
import { activeEnvironmentIdAtom } from './useActiveEnvironment';
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
export function useHttpAuthenticationConfig(
authName: string | null,
values: Record<string, JsonPrimitive>,
requestId: string,
) {
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const environmentId = useAtomValue(activeEnvironmentIdAtom);
const responses = useAtomValue(httpResponsesAtom);
const [forceRefreshCounter, setForceRefreshCounter] = useState<number>(0);
@@ -38,6 +42,8 @@ export function useHttpAuthenticationConfig(
values,
responseKey,
forceRefreshCounter,
workspaceId,
environmentId,
],
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
@@ -48,6 +54,8 @@ export function useHttpAuthenticationConfig(
authName,
values,
requestId,
workspaceId,
environmentId,
},
);
@@ -64,6 +72,8 @@ export function useHttpAuthenticationConfig(
authName,
values,
modelId,
environmentId,
workspaceId,
});
// Ensure the config is refreshed after the action is done