diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index a7b72177..228cadef 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -282,15 +282,9 @@ export class PluginInstance { this.#importModule(); } } catch (err) { - console.log('Plugin call threw exception', payload.type, err); - this.#sendPayload( - windowContext, - { - type: 'error_response', - error: `${err}`, - }, - replyId, - ); + const error = `${err}`.replace(/^Error:\s*/g, ''); + console.log('Plugin call threw exception', payload.type, '→', error); + this.#sendPayload(windowContext, { type: 'error_response', error }, replyId); return; } diff --git a/plugins/auth-oauth2/src/getAccessToken.ts b/plugins/auth-oauth2/src/getAccessToken.ts index 5651c8cb..82f98e4f 100644 --- a/plugins/auth-oauth2/src/getAccessToken.ts +++ b/plugins/auth-oauth2/src/getAccessToken.ts @@ -3,7 +3,8 @@ import { readFileSync } from 'node:fs'; import { AccessTokenRawResponse } from './store'; export async function getAccessToken( - ctx: Context, { + ctx: Context, + { accessTokenUrl, scope, audience, @@ -21,17 +22,15 @@ export async function getAccessToken( audience: string | null; credentialsInBody: boolean; params: HttpUrlParameter[]; - }): Promise { + }, +): Promise { console.log('Getting access token', accessTokenUrl); const httpRequest: Partial = { method: 'POST', url: accessTokenUrl, bodyType: 'application/x-www-form-urlencoded', body: { - form: [ - { name: 'grant_type', value: grantType }, - ...params, - ], + form: [{ name: 'grant_type', value: grantType }, ...params], }, headers: [ { name: 'User-Agent', value: 'yaak' }, @@ -56,7 +55,9 @@ export async function getAccessToken( const body = resp.bodyPath ? readFileSync(resp.bodyPath, 'utf8') : ''; 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; diff --git a/plugins/auth-oauth2/src/grants/authorizationCode.ts b/plugins/auth-oauth2/src/grants/authorizationCode.ts index c9400d6b..cd729764 100644 --- a/plugins/auth-oauth2/src/grants/authorizationCode.ts +++ b/plugins/auth-oauth2/src/grants/authorizationCode.ts @@ -22,6 +22,7 @@ export async function getAuthorizationCode( audience, credentialsInBody, pkce, + tokenName, }: { authorizationUrl: string; accessTokenUrl: string; @@ -36,6 +37,7 @@ export async function getAuthorizationCode( challengeMethod: string | null; codeVerifier: string | null; } | null; + tokenName: 'access_token' | 'id_token'; }, ): Promise { const token = await getOrRefreshAccessToken(ctx, contextId, { @@ -59,7 +61,10 @@ export async function getAuthorizationCode( if (pkce) { const verifier = pkce.codeVerifier || createPkceCodeVerifier(); 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); } @@ -107,7 +112,7 @@ export async function getAuthorizationCode( }); try { - resolve(await storeToken(ctx, contextId, response)); + resolve(await storeToken(ctx, contextId, response, tokenName)); } catch (err) { reject(err); } @@ -127,14 +132,15 @@ function createPkceCodeChallenge(verifier: string, method: string) { const hash = encodeForPkce(createHash('sha256').update(verifier).digest()); return hash - .replace(/=/g, '') // Remove padding '=' + .replace(/=/g, '') // Remove padding '=' .replace(/\+/g, '-') // Replace '+' with '-' .replace(/\//g, '_'); // Replace '/' with '_' } function encodeForPkce(bytes: Buffer) { - return bytes.toString('base64') - .replace(/=/g, '') // Remove padding '=' + return bytes + .toString('base64') + .replace(/=/g, '') // Remove padding '=' .replace(/\+/g, '-') // Replace '+' with '-' .replace(/\//g, '_'); // Replace '/' with '_' } diff --git a/plugins/auth-oauth2/src/grants/implicit.ts b/plugins/auth-oauth2/src/grants/implicit.ts index 592f875f..347fbfcd 100644 --- a/plugins/auth-oauth2/src/grants/implicit.ts +++ b/plugins/auth-oauth2/src/grants/implicit.ts @@ -12,6 +12,7 @@ export function getImplicit( scope, state, audience, + tokenName, }: { authorizationUrl: string; responseType: string; @@ -20,8 +21,9 @@ export function getImplicit( scope: string | null; state: string | null; audience: string | null; + tokenName: 'access_token' | 'id_token'; }, -) :Promise { +): Promise { return new Promise(async (resolve, reject) => { const token = await getToken(ctx, contextId); if (token) { @@ -38,7 +40,10 @@ export function getImplicit( 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)); + authorizationUrl.searchParams.set( + 'nonce', + String(Math.floor(Math.random() * 9999999999999) + 1), + ); } const authorizationUrlStr = authorizationUrl.toString(); @@ -60,7 +65,7 @@ export function getImplicit( const hash = url.hash.slice(1); const params = new URLSearchParams(hash); - const accessToken = params.get('access_token'); + const accessToken = params.get(tokenName); if (!accessToken) { return; } diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts index 9026a305..f14238a6 100644 --- a/plugins/auth-oauth2/src/index.ts +++ b/plugins/auth-oauth2/src/index.ts @@ -5,7 +5,12 @@ import { JsonPrimitive, PluginDefinition, } 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 { getImplicit } from './grants/implicit'; import { getPassword } from './grants/password'; @@ -22,10 +27,13 @@ const grantTypes: FormInputSelectOption[] = [ 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) => { - const hasGrantType = grantTypes.find(t => t === String(values.grantType ?? defaultGrantType)); - const hasOtherBools = other.every(t => t(values)); + const hasGrantType = grantTypes.find((t) => t === String(values.grantType ?? defaultGrantType)); + const hasOtherBools = other.every((t) => t(values)); const show = hasGrantType && hasOtherBools; return { hidden: !show }; }; @@ -77,7 +85,11 @@ export const plugin: PluginDefinition = { 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' }); + await ctx.toast.show({ + message: 'Token copied to clipboard', + icon: 'copy', + color: 'success', + }); } }, }, @@ -130,7 +142,7 @@ export const plugin: PluginDefinition = { label: 'Authorization URL', dynamic: hiddenIfNot(['authorization_code', 'implicit']), placeholder: authorizationUrls[0], - completionOptions: authorizationUrls.map(url => ({ label: url, value: url })), + completionOptions: authorizationUrls.map((url) => ({ label: url, value: url })), }, { type: 'text', @@ -139,7 +151,7 @@ export const plugin: PluginDefinition = { label: 'Access Token URL', placeholder: accessTokenUrls[0], 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', @@ -161,6 +173,20 @@ export const plugin: PluginDefinition = { 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: 'checkbox', name: 'usePkce', @@ -171,7 +197,10 @@ export const plugin: PluginDefinition = { type: 'select', name: 'pkceChallengeMethod', 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, dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), }, @@ -215,9 +244,19 @@ export const plugin: PluginDefinition = { label: 'Advanced', inputs: [ { 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: 'As Basic Authentication', value: 'basic' }, ], @@ -257,8 +296,12 @@ export const plugin: PluginDefinition = { const authorizationUrl = stringArg(values, 'authorizationUrl'); const accessTokenUrl = stringArg(values, 'accessTokenUrl'); token = await getAuthorizationCode(ctx, contextId, { - accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, - authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`, + accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) + ? accessTokenUrl + : `https://${accessTokenUrl}`, + authorizationUrl: authorizationUrl.match(/^https?:\/\//) + ? authorizationUrl + : `https://${authorizationUrl}`, clientId: stringArg(values, 'clientId'), clientSecret: stringArg(values, 'clientSecret'), redirectUri: stringArgOrNull(values, 'redirectUri'), @@ -266,26 +309,34 @@ export const plugin: PluginDefinition = { audience: stringArgOrNull(values, 'audience'), state: stringArgOrNull(values, 'state'), credentialsInBody, - pkce: values.usePkce ? { - challengeMethod: stringArg(values, 'pkceChallengeMethod'), - codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'), - } : null, + pkce: values.usePkce + ? { + challengeMethod: stringArg(values, 'pkceChallengeMethod'), + codeVerifier: stringArgOrNull(values, 'pkceCodeVerifier'), + } + : null, + tokenName: values.tokenName === 'id_token' ? 'id_token' : 'access_token', }); } else if (grantType === 'implicit') { const authorizationUrl = stringArg(values, 'authorizationUrl'); token = await getImplicit(ctx, contextId, { - authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`, + 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: values.tokenName === 'id_token' ? 'id_token' : 'access_token', }); } else if (grantType === 'client_credentials') { const accessTokenUrl = stringArg(values, 'accessTokenUrl'); token = await getClientCredentials(ctx, contextId, { - accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, + accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) + ? accessTokenUrl + : `https://${accessTokenUrl}`, clientId: stringArg(values, 'clientId'), clientSecret: stringArg(values, 'clientSecret'), scope: stringArgOrNull(values, 'scope'), @@ -295,7 +346,9 @@ export const plugin: PluginDefinition = { } else if (grantType === 'password') { const accessTokenUrl = stringArg(values, 'accessTokenUrl'); token = await getPassword(ctx, contextId, { - accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, + accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) + ? accessTokenUrl + : `https://${accessTokenUrl}`, clientId: stringArg(values, 'clientId'), clientSecret: stringArg(values, 'clientSecret'), username: stringArg(values, 'username'), @@ -310,16 +363,21 @@ export const plugin: PluginDefinition = { const headerValue = `${headerPrefix} ${token.response.access_token}`.trim(); return { - setHeaders: [{ - name: 'Authorization', - value: headerValue, - }], + setHeaders: [ + { + name: 'Authorization', + value: headerValue, + }, + ], }; }, }, }; -function stringArgOrNull(values: Record, name: string): string | null { +function stringArgOrNull( + values: Record, + name: string, +): string | null { const arg = values[name]; if (arg == null || arg == '') return null; return `${arg}`; diff --git a/plugins/auth-oauth2/src/store.ts b/plugins/auth-oauth2/src/store.ts index 89bf43e6..ba193741 100644 --- a/plugins/auth-oauth2/src/store.ts +++ b/plugins/auth-oauth2/src/store.ts @@ -1,8 +1,13 @@ import { Context } from '@yaakapp/api'; -export async function storeToken(ctx: Context, contextId: string, response: AccessTokenRawResponse) { - if (!response.access_token) { - throw new Error(`Token not found in response`); +export async function storeToken( + ctx: Context, + 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; @@ -41,12 +46,13 @@ function dataDirStoreKey(context_id: string) { } export interface AccessToken { - response: AccessTokenRawResponse, + response: AccessTokenRawResponse; expiresAt: number | null; } export interface AccessTokenRawResponse { access_token: string; + id_token?: string; token_type?: string; expires_in?: number; refresh_token?: string; diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index c47ac28e..0f431a09 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -530,6 +530,7 @@ impl PluginManager { InternalEventPayload::EmptyResponse(_) => { 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))), } } @@ -601,6 +602,7 @@ impl PluginManager { InternalEventPayload::EmptyResponse(_) => { 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))), } } diff --git a/src-tauri/yaak-plugins/src/server_ws.rs b/src-tauri/yaak-plugins/src/server_ws.rs index 3e2ddda5..4088fd86 100644 --- a/src-tauri/yaak-plugins/src/server_ws.rs +++ b/src-tauri/yaak-plugins/src/server_ws.rs @@ -87,9 +87,9 @@ impl PluginRuntimeServerWebsocket { // Parse everything but the payload so we can catch errors on that, specifically let payload = serde_json::from_value::(event.payload.clone()) .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 { - error: format!("Plugin error from {}: {e:?}", event.plugin_name), + error: format!("Plugin event parse error from {}: {e:?}", event.plugin_name), }) });