From 20681e5be31d5cb69f5868dd7ef5409abb25e9eb Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 23 Jul 2025 22:03:03 -0700 Subject: [PATCH] Scoped OAuth 2 tokens --- .../src/getAccessTokenIfNotExpired.ts | 19 ------ .../src/getOrRefreshAccessToken.ts | 12 ++-- .../src/grants/authorizationCode.ts | 13 ++++- .../src/grants/clientCredentials.ts | 13 ++++- plugins/auth-oauth2/src/grants/implicit.ts | 14 +++-- plugins/auth-oauth2/src/grants/password.ts | 12 +++- plugins/auth-oauth2/src/index.ts | 39 +++++++++---- plugins/auth-oauth2/src/store.ts | 34 ++++++++--- plugins/auth-oauth2/src/util.ts | 5 ++ src-tauri/src/http_request.rs | 11 ++-- src-tauri/src/lib.rs | 58 +++++++++++++++++-- src-tauri/yaak-plugins/src/manager.rs | 37 +++++++++++- src-web/components/DynamicForm.tsx | 41 +++++++------ src-web/hooks/useHttpAuthenticationConfig.ts | 10 ++++ 14 files changed, 232 insertions(+), 86 deletions(-) delete mode 100644 plugins/auth-oauth2/src/getAccessTokenIfNotExpired.ts create mode 100644 plugins/auth-oauth2/src/util.ts diff --git a/plugins/auth-oauth2/src/getAccessTokenIfNotExpired.ts b/plugins/auth-oauth2/src/getAccessTokenIfNotExpired.ts deleted file mode 100644 index fed45b9e..00000000 --- a/plugins/auth-oauth2/src/getAccessTokenIfNotExpired.ts +++ /dev/null @@ -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 { - 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; -} diff --git a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts index 0b1b37e3..a21b20c0 100644 --- a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts +++ b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts @@ -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 { - 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); } diff --git a/plugins/auth-oauth2/src/grants/authorizationCode.ts b/plugins/auth-oauth2/src/grants/authorizationCode.ts index b1bdb2b3..fa0677f7 100644 --- a/plugins/auth-oauth2/src/grants/authorizationCode.ts +++ b/plugins/auth-oauth2/src/grants/authorizationCode.ts @@ -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 { - 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() { diff --git a/plugins/auth-oauth2/src/grants/clientCredentials.ts b/plugins/auth-oauth2/src/grants/clientCredentials.ts index d565bd91..290dbb80 100644 --- a/plugins/auth-oauth2/src/grants/clientCredentials.ts +++ b/plugins/auth-oauth2/src/grants/clientCredentials.ts @@ -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); } diff --git a/plugins/auth-oauth2/src/grants/implicit.ts b/plugins/auth-oauth2/src/grants/implicit.ts index 625f255a..d861b472 100644 --- a/plugins/auth-oauth2/src/grants/implicit.ts +++ b/plugins/auth-oauth2/src/grants/implicit.ts @@ -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 { - 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); } diff --git a/plugins/auth-oauth2/src/grants/password.ts b/plugins/auth-oauth2/src/grants/password.ts index f2a4d401..c96b9848 100644 --- a/plugins/auth-oauth2/src/grants/password.ts +++ b/plugins/auth-oauth2/src/grants/password.ts @@ -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 { - 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); } diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index c76e961a..90f28255 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -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'), diff --git a/plugins/auth-oauth2/src/store.ts b/plugins/auth-oauth2/src/store.ts index a49edb1a..2edff231 100644 --- a/plugins/auth-oauth2/src/store.ts +++ b/plugins/auth-oauth2/src/store.ts @@ -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(tokenStoreKey(contextId), token); + await ctx.store.set(tokenStoreKey(args), token); return token; } -export async function getToken(ctx: Context, contextId: string) { - return ctx.store.get(tokenStoreKey(contextId)); +export async function getToken(ctx: Context, args: TokenStoreArgs) { + return ctx.store.get(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) { diff --git a/plugins/auth-oauth2/src/util.ts b/plugins/auth-oauth2/src/util.ts new file mode 100644 index 00000000..42a5b05b --- /dev/null +++ b/plugins/auth-oauth2/src/util.ts @@ -0,0 +1,5 @@ +import type { AccessToken } from './store'; + +export function isTokenExpired(token: AccessToken) { + return token.expiresAt && Date.now() > token.expiresAt; +} diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index c6c595f1..98550f5f 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -55,11 +55,6 @@ pub async fn send_http_request( 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( } }; + 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b9f14e16..4ab42bbb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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( auth_name: &str, values: HashMap, request_id: &str, + environment_id: Option<&str>, + workspace_id: &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 - .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( action_index: i32, values: HashMap, 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, }, diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index 44293af4..1f9bd725 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -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( &self, window: &WebviewWindow, + base_environment: &Environment, + environment: Option<&Environment>, auth_name: &str, values: HashMap, 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( &self, window: &WebviewWindow, + base_environment: &Environment, + environment: Option<&Environment>, auth_name: &str, action_index: i32, values: HashMap, 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)?, + }, }, ), ) diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index fe46a23a..651ba7cc 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -182,23 +182,24 @@ function FormInputs>({ ); case 'accordion': return ( - -
- -
-
+
+ +
+ +
+
+
); 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 && (