diff --git a/package-lock.json b/package-lock.json index 84eb4190..0c07682f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "plugins/*" ], "dependencies": { - "@yaakapp/api": "^0.3.4" + "@yaakapp/api": "^0.4.0" }, "devDependencies": { "@types/node": "^22.7.4", @@ -930,9 +930,9 @@ } }, "node_modules/@yaakapp/api": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@yaakapp/api/-/api-0.3.4.tgz", - "integrity": "sha512-Yx0wigj+Re8cnYM1PJijTisRIL8jFeK50nJ66Hiv/OxPhX2QjqDWmOXGZ3TsRU9NB41JM74O1OfafRMhfeSUMw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@yaakapp/api/-/api-0.4.0.tgz", + "integrity": "sha512-F5yw9lxqomNtcYw0HtQvtWuKrOsXF7sD843zChaLMIY28gGkBpRJW9yO9D9h8AmG15oB87od3QHyW+fIFAsYxQ==", "dependencies": { "@types/node": "^22.5.4" } @@ -949,6 +949,10 @@ "resolved": "plugins/auth-jwt", "link": true }, + "node_modules/@yaakapp/auth-oauth2": { + "resolved": "plugins/auth-oauth2", + "link": true + }, "node_modules/@yaakapp/exporter-curl": { "resolved": "plugins/exporter-curl", "link": true @@ -7131,6 +7135,10 @@ "@types/jsonwebtoken": "^9.0.7" } }, + "plugins/auth-oauth2": { + "name": "@yaakapp/auth-oauth2", + "version": "0.0.1" + }, "plugins/exporter-curl": { "name": "@yaakapp/exporter-curl", "version": "0.0.1" diff --git a/package.json b/package.json index cf419065..bf717486 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,6 @@ "workspaces-run": "^1.0.2" }, "dependencies": { - "@yaakapp/api": "^0.3.4" + "@yaakapp/api": "^0.4.0" } } diff --git a/plugins/auth-basic/src/index.ts b/plugins/auth-basic/src/index.ts index 342b3cf1..12f99a36 100644 --- a/plugins/auth-basic/src/index.ts +++ b/plugins/auth-basic/src/index.ts @@ -5,7 +5,7 @@ export const plugin: PluginDefinition = { name: 'basic', label: 'Basic Auth', shortLabel: 'Basic', - config: [{ + args: [{ type: 'text', name: 'username', label: 'Username', @@ -17,8 +17,8 @@ export const plugin: PluginDefinition = { optional: true, password: true, }], - async onApply(_ctx, args) { - const { username, password } = args.config; + async onApply(_ctx, { values }) { + const { username, password } = values; const value = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); return { setHeaders: [{ name: 'Authorization', value }] }; }, diff --git a/plugins/auth-bearer/src/index.ts b/plugins/auth-bearer/src/index.ts index a08a5dd3..6c6ec6b4 100644 --- a/plugins/auth-bearer/src/index.ts +++ b/plugins/auth-bearer/src/index.ts @@ -5,15 +5,15 @@ export const plugin: PluginDefinition = { name: 'bearer', label: 'Bearer Token', shortLabel: 'Bearer', - config: [{ + args: [{ type: 'text', name: 'token', label: 'Token', optional: true, password: true, }], - async onApply(_ctx, args) { - const { token } = args.config; + async onApply(_ctx, { values }) { + const { token } = values; const value = `Bearer ${token}`.trim(); return { setHeaders: [{ name: 'Authorization', value }] }; }, diff --git a/plugins/auth-jwt/src/index.ts b/plugins/auth-jwt/src/index.ts index 9edc2521..45b20a53 100644 --- a/plugins/auth-jwt/src/index.ts +++ b/plugins/auth-jwt/src/index.ts @@ -24,21 +24,22 @@ export const plugin: PluginDefinition = { name: 'jwt', label: 'JWT Bearer', shortLabel: 'JWT', - config: [ + args: [ { type: 'select', name: 'algorithm', label: 'Algorithm', hideLabel: true, defaultValue: defaultAlgorithm, - options: algorithms.map(value => ({ name: value === 'none' ? 'None' : value, value })), + options: algorithms.map(value => ({ label: value === 'none' ? 'None' : value, value })), }, { - type: 'editor', + type: 'text', name: 'secret', label: 'Secret or Private Key', + password: true, optional: true, - hideGutter: true, + multiLine: true, }, { type: 'checkbox', @@ -54,8 +55,8 @@ export const plugin: PluginDefinition = { placeholder: '{ }', }, ], - async onApply(_ctx, args) { - const { algorithm, secret: _secret, secretBase64, payload } = args.config; + async onApply(_ctx, { values }) { + const { algorithm, secret: _secret, secretBase64, payload } = values; const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`; const token = jwt.sign(`${payload}`, secret, { algorithm: algorithm as any }); const value = `Bearer ${token}`; diff --git a/plugins/auth-oauth2/package.json b/plugins/auth-oauth2/package.json new file mode 100644 index 00000000..696aa318 --- /dev/null +++ b/plugins/auth-oauth2/package.json @@ -0,0 +1,9 @@ +{ + "name": "@yaakapp/auth-oauth2", + "private": true, + "version": "0.0.1", + "scripts": { + "build": "yaakcli build ./src/index.ts", + "dev": "yaakcli dev ./src/index.js" + } +} diff --git a/plugins/auth-oauth2/src/getAccessToken.ts b/plugins/auth-oauth2/src/getAccessToken.ts new file mode 100644 index 00000000..6d04a379 --- /dev/null +++ b/plugins/auth-oauth2/src/getAccessToken.ts @@ -0,0 +1,71 @@ +import { Context, HttpRequest, HttpUrlParameter } from '@yaakapp/api'; +import { readFileSync } from 'node:fs'; +import { AccessTokenRawResponse } from './store'; + +export async function getAccessToken( + ctx: Context, { + accessTokenUrl, + scope, + params, + grantType, + credentialsInBody, + clientId, + clientSecret, + }: { + clientId: string; + clientSecret: string; + grantType: string; + accessTokenUrl: string; + scope: string | null; + credentialsInBody: boolean; + params: HttpUrlParameter[]; + }): 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, + ], + }, + 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 }); + } + + const resp = await ctx.httpRequest.send({ httpRequest }); + + if (resp.status < 200 || resp.status >= 300) { + throw new Error('Failed to fetch access token with status=' + resp.status); + } + + const body = readFileSync(resp.bodyPath ?? '', 'utf8'); + + let response; + 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; +} diff --git a/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts new file mode 100644 index 00000000..02b20203 --- /dev/null +++ b/plugins/auth-oauth2/src/getOrRefreshAccessToken.ts @@ -0,0 +1,99 @@ +import { Context, HttpRequest } from '@yaakapp/api'; +import { readFileSync } from 'node:fs'; +import { AccessToken, AccessTokenRawResponse, deleteToken, getToken, storeToken } from './store'; + +export async function getOrRefreshAccessToken(ctx: Context, contextId: string, { + scope, + accessTokenUrl, + credentialsInBody, + clientId, + clientSecret, + forceRefresh, +}: { + scope: string | null; + accessTokenUrl: string; + credentialsInBody: boolean; + clientId: string; + clientSecret: string; + forceRefresh?: boolean; +}): Promise { + const token = await getToken(ctx, contextId); + if (token == null) { + return null; + } + + const now = (Date.now() / 1000); + const isExpired = token.expiresAt && now > token.expiresAt; + + // 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 = { + 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 }); + } + + const resp = await ctx.httpRequest.send({ httpRequest }); + + if (resp.status === 401) { + // Bad refresh token, so we'll force it to fetch a fresh access token by deleting + // and returning null; + console.log('Unauthorized refresh_token request'); + await deleteToken(ctx, contextId); + return null; + } + + if (resp.status < 200 || resp.status >= 300) { + throw new Error('Failed to fetch access token with status=' + resp.status); + } + + const body = readFileSync(resp.bodyPath ?? '', 'utf8'); + + let response; + 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, contextId, newResponse); +} diff --git a/plugins/auth-oauth2/src/grants/authorizationCode.ts b/plugins/auth-oauth2/src/grants/authorizationCode.ts new file mode 100644 index 00000000..8d06dd7a --- /dev/null +++ b/plugins/auth-oauth2/src/grants/authorizationCode.ts @@ -0,0 +1,126 @@ +import { Context } from '@yaakapp/api'; +import { createHash, randomBytes } from 'node:crypto'; +import { getAccessToken } from '../getAccessToken'; +import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken'; +import { AccessToken, storeToken } from '../store'; + +export const PKCE_SHA256 = 'S256'; +export const PKCE_PLAIN = 'plain'; +export const DEFAULT_PKCE_METHOD = PKCE_SHA256; + +export async function getAuthorizationCode( + ctx: Context, + contextId: string, + { + authorizationUrl: authorizationUrlRaw, + accessTokenUrl, + clientId, + clientSecret, + redirectUri, + scope, + state, + credentialsInBody, + pkce, + }: { + authorizationUrl: string; + accessTokenUrl: string; + clientId: string; + clientSecret: string; + redirectUri: string | null; + scope: string | null; + state: string | null; + credentialsInBody: boolean; + pkce: { + challengeMethod: string | null; + codeVerifier: string | null; + } | null; + }, +): Promise { + const token = await getOrRefreshAccessToken(ctx, contextId, { + accessTokenUrl, + scope, + clientId, + clientSecret, + credentialsInBody, + }); + if (token != null) { + return token; + } + + const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', clientId); + if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri); + if (scope) authorizationUrl.searchParams.set('scope', scope); + if (state) authorizationUrl.searchParams.set('state', state); + 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_method', challengeMethod); + } + + return new Promise(async (resolve, reject) => { + const authorizationUrlStr = authorizationUrl.toString(); + console.log('Authorizing', authorizationUrlStr); + let { close } = await ctx.window.openUrl({ + url: authorizationUrlStr, + label: 'oauth-authorization-url', + async onNavigate({ url: urlStr }) { + const url = new URL(urlStr); + if (url.searchParams.has('error')) { + return reject(new Error(`Failed to authorize: ${url.searchParams.get('error')}`)); + } + const code = url.searchParams.get('code'); + if (!code) { + return; // Could be one of many redirects in a chain, so skip it + } + + // Close the window here, because we don't need it anymore! + close(); + + const response = await getAccessToken(ctx, { + grantType: 'authorization_code', + accessTokenUrl, + clientId, + clientSecret, + scope, + credentialsInBody, + params: [ + { name: 'code', value: code }, + ...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []), + ], + }); + + try { + resolve(await storeToken(ctx, contextId, response)); + } catch (err) { + reject(err); + } + }, + }); + }); +} + +function createPkceCodeVerifier() { + return encodeForPkce(randomBytes(32)); +} + +function createPkceCodeChallenge(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 '_' +} diff --git a/plugins/auth-oauth2/src/grants/clientCredentials.ts b/plugins/auth-oauth2/src/grants/clientCredentials.ts new file mode 100644 index 00000000..6fb8e6e4 --- /dev/null +++ b/plugins/auth-oauth2/src/grants/clientCredentials.ts @@ -0,0 +1,40 @@ +import { Context } from '@yaakapp/api'; +import { getAccessToken } from '../getAccessToken'; +import { getToken, storeToken } from '../store'; + +export async function getClientCredentials( + ctx: Context, + contextId: string, + { + accessTokenUrl, + clientId, + clientSecret, + scope, + credentialsInBody, + }: { + accessTokenUrl: string; + clientId: string; + clientSecret: string; + scope: string | null; + credentialsInBody: boolean; + }, +) { + const token = await getToken(ctx, contextId); + if (token) { + // resolve(token.response.access_token); + // TODO: Refresh token if expired + // return; + } + + const response = await getAccessToken(ctx, { + grantType: 'client_credentials', + accessTokenUrl, + clientId, + clientSecret, + scope, + credentialsInBody, + params: [], + }); + + return storeToken(ctx, contextId, response); +} diff --git a/plugins/auth-oauth2/src/grants/implicit.ts b/plugins/auth-oauth2/src/grants/implicit.ts new file mode 100644 index 00000000..15105b0f --- /dev/null +++ b/plugins/auth-oauth2/src/grants/implicit.ts @@ -0,0 +1,70 @@ +import { Context } from '@yaakapp/api'; +import { AccessToken, AccessTokenRawResponse, getToken, storeToken } from '../store'; + +export function getImplicit( + ctx: Context, + contextId: string, + { + authorizationUrl: authorizationUrlRaw, + responseType, + clientId, + redirectUri, + scope, + state, + }: { + authorizationUrl: string; + responseType: string; + clientId: string; + redirectUri: string | null; + scope: string | null; + state: string | null; + }, +) :Promise { + return new Promise(async (resolve, reject) => { + const token = await getToken(ctx, contextId); + if (token) { + // resolve(token.response.access_token); + // TODO: Refresh token if expired + // return; + } + + const authorizationUrl = new URL(`${authorizationUrlRaw ?? ''}`); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('client_id', clientId); + if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri); + if (scope) authorizationUrl.searchParams.set('scope', scope); + if (state) authorizationUrl.searchParams.set('state', state); + if (responseType.includes('id_token')) { + authorizationUrl.searchParams.set('nonce', String(Math.floor(Math.random() * 9999999999999) + 1)); + } + + const authorizationUrlStr = authorizationUrl.toString(); + let { close } = await ctx.window.openUrl({ + url: authorizationUrlStr, + label: 'oauth-authorization-url', + async onNavigate({ url: urlStr }) { + const url = new URL(urlStr); + if (url.searchParams.has('error')) { + return reject(Error(`Failed to authorize: ${url.searchParams.get('error')}`)); + } + + // Close the window here, because we don't need it anymore + close(); + + const hash = url.hash.slice(1); + const params = new URLSearchParams(hash); + const idToken = params.get('id_token'); + if (idToken) { + params.set('access_token', idToken); + params.delete('id_token'); + } + const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse; + try { + resolve(await storeToken(ctx, contextId, response)); + } catch (err) { + reject(err); + } + }, + }); + }); +} diff --git a/plugins/auth-oauth2/src/grants/password.ts b/plugins/auth-oauth2/src/grants/password.ts new file mode 100644 index 00000000..f1a685da --- /dev/null +++ b/plugins/auth-oauth2/src/grants/password.ts @@ -0,0 +1,52 @@ +import { Context } from '@yaakapp/api'; +import { getAccessToken } from '../getAccessToken'; +import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken'; +import { AccessToken, storeToken } from '../store'; + +export async function getPassword( + ctx: Context, + contextId: string, + { + accessTokenUrl, + clientId, + clientSecret, + username, + password, + credentialsInBody, + scope, + }: { + accessTokenUrl: string; + clientId: string; + clientSecret: string; + username: string; + password: string; + scope: string | null; + credentialsInBody: boolean; + }, +): Promise { + const token = await getOrRefreshAccessToken(ctx, contextId, { + accessTokenUrl, + scope, + clientId, + clientSecret, + credentialsInBody, + }); + if (token != null) { + return token; + } + + const response = await getAccessToken(ctx, { + accessTokenUrl, + clientId, + clientSecret, + scope, + grantType: 'password', + credentialsInBody, + params: [ + { name: 'username', value: username }, + { name: 'password', value: password }, + ], + }); + + return storeToken(ctx, contextId, response); +} diff --git a/plugins/auth-oauth2/src/index.ts b/plugins/auth-oauth2/src/index.ts new file mode 100644 index 00000000..27779a69 --- /dev/null +++ b/plugins/auth-oauth2/src/index.ts @@ -0,0 +1,311 @@ +import { + Context, + FormInputSelectOption, + GetHttpAuthenticationConfigRequest, + JsonPrimitive, + PluginDefinition, +} from '@yaakapp/api'; +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'; +import { AccessToken, deleteToken, getToken } 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', +]; + +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', +]; + +export const plugin: PluginDefinition = { + authentication: { + name: 'oauth2', + label: 'OAuth 2.0', + shortLabel: 'OAuth 2', + actions: [ + { + label: 'Copy Current Token', + icon: 'copy', + async onSelect(ctx, { contextId }) { + const token = await getToken(ctx, contextId); + 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', + icon: 'trash', + async onSelect(ctx, { contextId }) { + if (await deleteToken(ctx, contextId)) { + await ctx.toast.show({ message: 'Token deleted', color: 'success' }); + } else { + await ctx.toast.show({ message: 'No token to delete', color: 'warning' }); + } + }, + }, + ], + args: [ + { + type: 'select', + name: 'grantType', + label: 'Grant Type', + hideLabel: true, + defaultValue: defaultGrantType, + options: grantTypes, + }, + // Always-present fields + { type: 'text', name: 'clientId', label: 'Client ID' }, + + { + type: 'text', + name: 'clientSecret', + label: 'Client Secret', + password: true, + dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']), + }, + { + type: 'text', + name: 'authorizationUrl', + label: 'Authorization URL', + dynamic: hiddenIfNot(['authorization_code', 'implicit']), + placeholder: authorizationUrls[0], + completionOptions: authorizationUrls.map(url => ({ label: url, value: url })), + }, + { + type: 'text', + name: 'accessTokenUrl', + label: 'Access Token URL', + placeholder: accessTokenUrls[0], + dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']), + completionOptions: accessTokenUrls.map(url => ({ label: url, value: url })), + }, + { + type: 'text', + name: 'redirectUri', + label: 'Redirect URI', + optional: true, + dynamic: hiddenIfNot(['authorization_code', 'implicit']), + }, + { + type: 'text', + name: 'state', + label: 'State', + optional: true, + dynamic: hiddenIfNot(['authorization_code', 'implicit']), + }, + { + 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: 'pkceCodeVerifier', + label: 'Code Verifier', + placeholder: 'Automatically generated if not provided', + optional: true, + dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce), + }, + { + 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: '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: [ + { label: 'In Request Body', value: 'body' }, + { label: 'As Basic Authentication', value: 'basic' }, + ], + }, + ], + }, + { + type: 'accordion', + label: 'Access Token Response', + async dynamic(ctx, { contextId }) { + const token = await getToken(ctx, contextId); + if (token == null) { + return { hidden: true }; + } + return { + label: 'Access Token Response', + inputs: [ + { + type: 'editor', + defaultValue: JSON.stringify(token.response, null, 2), + hideLabel: true, + readOnly: true, + language: 'json', + }, + ], + }; + }, + }, + ], + async onApply(ctx, { values, contextId }) { + const headerPrefix = optionalString(values, 'headerPrefix') ?? ''; + const grantType = requiredString(values, 'grantType') as GrantType; + const credentialsInBody = values.credentials === 'body'; + + console.log('Performing OAuth', values); + let token: AccessToken; + if (grantType === 'authorization_code') { + const authorizationUrl = requiredString(values, 'authorizationUrl'); + const accessTokenUrl = requiredString(values, 'accessTokenUrl'); + token = await getAuthorizationCode(ctx, contextId, { + accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, + authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`, + clientId: requiredString(values, 'clientId'), + clientSecret: requiredString(values, 'clientSecret'), + redirectUri: optionalString(values, 'redirectUri'), + scope: optionalString(values, 'scope'), + state: optionalString(values, 'state'), + credentialsInBody, + pkce: values.usePkce ? { + challengeMethod: requiredString(values, 'pkceChallengeMethod'), + codeVerifier: optionalString(values, 'pkceCodeVerifier'), + } : null, + }); + } else if (grantType === 'implicit') { + const authorizationUrl = requiredString(values, 'authorizationUrl'); + token = await getImplicit(ctx, contextId, { + authorizationUrl: authorizationUrl.match(/^https?:\/\//) ? authorizationUrl : `https://${authorizationUrl}`, + clientId: requiredString(values, 'clientId'), + redirectUri: optionalString(values, 'redirectUri'), + responseType: requiredString(values, 'responseType'), + scope: optionalString(values, 'scope'), + state: optionalString(values, 'state'), + }); + } else if (grantType === 'client_credentials') { + const accessTokenUrl = requiredString(values, 'accessTokenUrl'); + token = await getClientCredentials(ctx, contextId, { + accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, + clientId: requiredString(values, 'clientId'), + clientSecret: requiredString(values, 'clientSecret'), + scope: optionalString(values, 'scope'), + credentialsInBody, + }); + } else if (grantType === 'password') { + const accessTokenUrl = requiredString(values, 'accessTokenUrl'); + token = await getPassword(ctx, contextId, { + accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, + clientId: requiredString(values, 'clientId'), + clientSecret: requiredString(values, 'clientSecret'), + username: requiredString(values, 'username'), + password: requiredString(values, 'password'), + scope: optionalString(values, 'scope'), + credentialsInBody, + }); + } else { + throw new Error('Invalid grant type ' + grantType); + } + + const headerValue = `${headerPrefix} ${token.response.access_token}`.trim(); + return { + setHeaders: [{ + name: 'Authorization', + value: headerValue, + }], + }; + }, + }, +}; + +function optionalString(values: Record, name: string): string | null { + const arg = values[name]; + if (arg == null || arg == '') return null; + return `${arg}`; +} + +function requiredString(values: Record, name: string): string { + const arg = optionalString(values, name); + if (!arg) throw new Error(`Missing required argument ${name}`); + return arg; +} diff --git a/plugins/auth-oauth2/src/store.ts b/plugins/auth-oauth2/src/store.ts new file mode 100644 index 00000000..bc1675ed --- /dev/null +++ b/plugins/auth-oauth2/src/store.ts @@ -0,0 +1,42 @@ +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`); + } + + const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null; + const token: AccessToken = { + response, + expiresAt, + }; + await ctx.store.set(tokenStoreKey(contextId), token); + return token; +} + +export async function getToken(ctx: Context, contextId: string) { + return ctx.store.get(tokenStoreKey(contextId)); +} + +export async function deleteToken(ctx: Context, contextId: string) { + return ctx.store.delete(tokenStoreKey(contextId)); +} + +function tokenStoreKey(context_id: string) { + return ['token', context_id].join('::'); +} + +export interface AccessToken { + response: AccessTokenRawResponse, + expiresAt: number | null; +} + +export interface AccessTokenRawResponse { + access_token: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + error?: string; + error_description?: string; + scope?: string; +} diff --git a/plugins/exporter-curl/src/index.ts b/plugins/exporter-curl/src/index.ts index fd90e757..e8fa3415 100644 --- a/plugins/exporter-curl/src/index.ts +++ b/plugins/exporter-curl/src/index.ts @@ -4,14 +4,13 @@ const NEWLINE = '\\\n '; export const plugin: PluginDefinition = { httpRequestActions: [{ - key: 'export-curl', label: 'Copy as Curl', icon: 'copy', async onSelect(ctx, args) { const rendered_request = await ctx.httpRequest.render({ httpRequest: args.httpRequest, purpose: 'preview' }); const data = await convertToCurl(rendered_request); - ctx.clipboard.copyText(data); - ctx.toast.show({ message: 'Curl copied to clipboard', icon: 'copy' }); + await ctx.clipboard.copyText(data); + await ctx.toast.show({ message: 'Curl copied to clipboard', icon: 'copy', color: 'success' }); }, }], }; diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index 15a67a00..0e8c8909 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -17,17 +17,16 @@ const behaviorArg: FormInput = { label: 'Sending Behavior', defaultValue: 'smart', options: [ - { name: 'When no responses', value: 'smart' }, - { name: 'Always', value: 'always' }, + { label: 'When no responses', value: 'smart' }, + { label: 'Always', value: 'always' }, ], }; -const requestArg: FormInput = - { - type: 'http_request', - name: 'request', - label: 'Request', - }; +const requestArg: FormInput = { + type: 'http_request', + name: 'request', + label: 'Request', +}; export const plugin: PluginDefinition = { templateFunctions: [ diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs deleted file mode 100644 index 687fc73c..00000000 --- a/scripts/build-plugins.cjs +++ /dev/null @@ -1,24 +0,0 @@ -const { readdirSync, readFileSync } = require('node:fs'); -const { execSync } = require('node:child_process'); -const path = require('node:path'); - -async function main() { - console.log('Building plugins'); - - const pluginsDir = path.join(__dirname, '../plugins'); - const pluginNames = readdirSync(pluginsDir); - - for (const dir of pluginNames) { - const pluginDir = path.join(pluginsDir, dir); - const pkg = JSON.parse(readFileSync(path.join(pluginDir, 'package.json'), 'utf8')); - - console.log('Building plugin', pkg.name, pluginDir); - execSync(`npm install`, { cwd: pluginDir }); - execSync(`npm run build`, { cwd: pluginDir }); - } -} - -main().catch(err => { - console.log('Failed', err); - process.exit(1); -});