diff --git a/package-lock.json b/package-lock.json index b4d52f7e..9c47b723 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "plugins/auth-basic", "plugins/auth-bearer", "plugins/auth-jwt", + "plugins/auth-oauth1", "plugins/auth-oauth2", "plugins/filter-jsonpath", "plugins/filter-xpath", @@ -4163,6 +4164,10 @@ "resolved": "plugins/auth-jwt", "link": true }, + "node_modules/@yaak/auth-oauth1": { + "resolved": "plugins/auth-oauth1", + "link": true + }, "node_modules/@yaak/auth-oauth2": { "resolved": "plugins/auth-oauth2", "link": true @@ -13082,6 +13087,12 @@ "node": ">= 6" } }, + "node_modules/oauth-1.0a": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/oauth-1.0a/-/oauth-1.0a-2.2.6.tgz", + "integrity": "sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -18823,6 +18834,13 @@ "@types/jsonwebtoken": "^9.0.7" } }, + "plugins/auth-oauth1": { + "name": "@yaak/auth-oauth1", + "version": "0.1.0", + "dependencies": { + "oauth-1.0a": "^2.2.6" + } + }, "plugins/auth-oauth2": { "name": "@yaak/auth-oauth2", "version": "0.1.0" diff --git a/package.json b/package.json index d6bf1b22..5b07f086 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "plugins/auth-bearer", "plugins/auth-jwt", "plugins/auth-oauth2", + "plugins/auth-oauth1", "plugins/filter-jsonpath", "plugins/filter-xpath", "plugins/importer-curl", diff --git a/plugins/auth-oauth1/package.json b/plugins/auth-oauth1/package.json new file mode 100644 index 00000000..e9889b64 --- /dev/null +++ b/plugins/auth-oauth1/package.json @@ -0,0 +1,20 @@ +{ + "name": "@yaak/auth-oauth1", + "displayName": "OAuth 1.0", + "description": "Authenticate requests using OAuth 1.0a", + "repository": { + "type": "git", + "url": "https://github.com/mountain-loop/yaak.git", + "directory": "plugins/auth-oauth1" + }, + "private": true, + "version": "0.1.0", + "scripts": { + "build": "yaakcli build", + "dev": "yaakcli dev", + "lint": "tsc --noEmit && eslint . --ext .ts,.tsx" + }, + "dependencies": { + "oauth-1.0a": "^2.2.6" + } +} diff --git a/plugins/auth-oauth1/src/index.ts b/plugins/auth-oauth1/src/index.ts new file mode 100644 index 00000000..d7be083a --- /dev/null +++ b/plugins/auth-oauth1/src/index.ts @@ -0,0 +1,197 @@ +import type { Context, GetHttpAuthenticationConfigRequest, PluginDefinition } from '@yaakapp/api'; +import crypto from 'node:crypto'; +import OAuth from 'oauth-1.0a'; + +const signatures = { + HMAC_SHA1: 'HMAC-SHA1', + HMAC_SHA256: 'HMAC-SHA256', + HMAC_SHA512: 'HMAC-SHA512', + RSA_SHA1: 'RSA-SHA1', + RSA_SHA256: 'RSA-SHA256', + RSA_SHA512: 'RSA-SHA512', + PLAINTEXT: 'PLAINTEXT', +} as const; +const defaultSig = signatures.HMAC_SHA1; + +const pkSigs = Object.values(signatures).filter((k) => k.startsWith('RSA-')); +const nonPkSigs = Object.values(signatures).filter((k) => !pkSigs.includes(k)); + +type SigMethod = (typeof signatures)[keyof typeof signatures]; + +function hiddenIfNot( + sigMethod: SigMethod[], + ...other: ((values: GetHttpAuthenticationConfigRequest['values']) => boolean)[] +) { + return (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => { + const hasGrantType = sigMethod.find((t) => t === String(values.signatureMethod ?? defaultSig)); + const hasOtherBools = other.every((t) => t(values)); + const show = hasGrantType && hasOtherBools; + return { hidden: !show }; + }; +} + +export const plugin: PluginDefinition = { + authentication: { + name: 'oauth1', + label: 'OAuth 1.0', + shortLabel: 'OAuth 1', + args: [ + { + name: 'signatureMethod', + label: 'Signature Method', + type: 'select', + defaultValue: defaultSig, + options: Object.values(signatures).map((v) => ({ label: v, value: v })), + }, + { name: 'consumerKey', label: 'Consumer Key', type: 'text', password: true, optional: true }, + { + name: 'consumerSecret', + label: 'Consumer Secret', + type: 'text', + password: true, + optional: true, + }, + { + name: 'tokenKey', + label: 'Access Token', + type: 'text', + password: true, + optional: true, + }, + { + name: 'tokenSecret', + label: 'Token Secret', + type: 'text', + password: true, + optional: true, + dynamic: hiddenIfNot(nonPkSigs), + }, + { + name: 'privateKey', + label: 'Private Key (RSA-SHA1)', + type: 'text', + multiLine: true, + optional: true, + password: true, + placeholder: + '-----BEGIN RSA PRIVATE KEY-----\nPrivate key in PEM format\n-----END RSA PRIVATE KEY-----', + dynamic: hiddenIfNot(pkSigs), + }, + { + type: 'accordion', + label: 'Advanced', + inputs: [ + { name: 'callback', label: 'Callback Url', type: 'text', optional: true }, + { name: 'verifier', label: 'Verifier', type: 'text', optional: true, password: true }, + { name: 'timestamp', label: 'Timestamp', type: 'text', optional: true }, + { name: 'nonce', label: 'Nonce', type: 'text', optional: true }, + { + name: 'version', + label: 'OAuth Version', + type: 'text', + optional: true, + defaultValue: '1.0', + }, + { name: 'realm', label: 'Realm', type: 'text', optional: true }, + ], + }, + ], + + onApply( + _ctx, + { values, method, url }, + ): { + setHeaders?: { name: string; value: string }[]; + setQueryParameters?: { name: string; value: string }[]; + } { + const consumerKey = String(values.consumerKey || ''); + const consumerSecret = String(values.consumerSecret || ''); + + const signatureMethod = String(values.signatureMethod || signatures.HMAC_SHA1) as SigMethod; + const version = String(values.version || '1.0'); + const realm = String(values.realm || '') || undefined; + + const oauth = new OAuth({ + consumer: { key: consumerKey, secret: consumerSecret }, + signature_method: signatureMethod, + version, + hash_function: hashFunction(signatureMethod), + realm, + }); + + if (pkSigs.includes(signatureMethod)) { + oauth.getSigningKey = (tokenSecret?: string) => tokenSecret || ''; + } + + const requestUrl = new URL(url); + + // Base request options passed to oauth-1.0a + const requestData: Omit & { + data: Record; + } = { + method, + url: requestUrl.toString(), + includeBodyHash: false, + data: {}, + }; + + // (1) Include existing query params in signature base string + for (const key of requestUrl.searchParams.keys()) { + if (key.startsWith('oauth_')) continue; + const all = requestUrl.searchParams.getAll(key); + requestData.data[key] = all.length > 1 ? all : all[0]!; + } + + // (2) Manual oauth_* overrides + if (values.callback) requestData.data.oauth_callback = String(values.callback); + if (values.nonce) requestData.data.oauth_nonce = String(values.nonce); + if (values.timestamp) requestData.data.oauth_timestamp = String(values.timestamp); + if (values.verifier) requestData.data.oauth_verifier = String(values.verifier); + + let token: OAuth.Token | { key: string } | undefined; + + if (pkSigs.includes(signatureMethod)) { + token = { + key: String(values.tokenKey || ''), + secret: String(values.privateKey || ''), + }; + } else if (values.tokenKey && values.tokenSecret) { + token = { key: String(values.tokenKey), secret: String(values.tokenSecret) }; + } else if (values.tokenKey) { + token = { key: String(values.tokenKey) }; + } + + const authParams = oauth.authorize(requestData, token as OAuth.Token | undefined); + const { Authorization } = oauth.toHeader(authParams); + return { setHeaders: [{ name: 'Authorization', value: Authorization }] }; + }, + }, +}; + +function hashFunction(signatureMethod: SigMethod) { + switch (signatureMethod) { + case signatures.HMAC_SHA1: + return (base: string, key: string) => + crypto.createHmac('sha1', key).update(base).digest('base64'); + case signatures.HMAC_SHA256: + return (base: string, key: string) => + crypto.createHmac('sha256', key).update(base).digest('base64'); + case signatures.HMAC_SHA512: + return (base: string, key: string) => + crypto.createHmac('sha512', key).update(base).digest('base64'); + case signatures.RSA_SHA1: + return (base: string, privateKey: string) => + crypto.createSign('RSA-SHA1').update(base).sign(privateKey, 'base64'); + case signatures.RSA_SHA256: + return (base: string, privateKey: string) => + crypto.createSign('RSA-SHA256').update(base).sign(privateKey, 'base64'); + case signatures.RSA_SHA512: + return (base: string, privateKey: string) => + crypto.createSign('RSA-SHA512').update(base).sign(privateKey, 'base64'); + case signatures.PLAINTEXT: + return (base: string) => base; + default: + return (base: string, key: string) => + crypto.createHmac('sha1', key).update(base).digest('base64'); + } +} diff --git a/plugins/auth-oauth1/tsconfig.json b/plugins/auth-oauth1/tsconfig.json new file mode 100644 index 00000000..4082f16a --- /dev/null +++ b/plugins/auth-oauth1/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/src-web/components/DynamicForm.tsx b/src-web/components/DynamicForm.tsx index 73e98862..1ff88501 100644 --- a/src-web/components/DynamicForm.tsx +++ b/src-web/components/DynamicForm.tsx @@ -247,6 +247,7 @@ function TextArg({ name={arg.name} multiLine={arg.multiLine} onChange={onChange} + className={arg.multiLine ? 'min-h-[4rem]' : undefined} defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value} required={!arg.optional} disabled={arg.disabled} diff --git a/src-web/components/core/Editor/Editor.tsx b/src-web/components/core/Editor/Editor.tsx index b71018e1..cd1192ef 100644 --- a/src-web/components/core/Editor/Editor.tsx +++ b/src-web/components/core/Editor/Editor.tsx @@ -91,6 +91,7 @@ export interface EditorProps { placeholder?: string; readOnly?: boolean; singleLine?: boolean; + containerOnly?: boolean; stateKey: string | null; tooltipContainer?: HTMLElement; type?: 'text' | 'password'; @@ -131,6 +132,7 @@ export function Editor({ placeholder, readOnly, singleLine, + containerOnly, stateKey, type, wrapLines, @@ -540,7 +542,7 @@ export function Editor({ /> ); - if (singleLine) { + if (singleLine || containerOnly) { return cmContainer; } diff --git a/src-web/components/core/Input.tsx b/src-web/components/core/Input.tsx index 50dc1a4f..d9085919 100644 --- a/src-web/components/core/Input.tsx +++ b/src-web/components/core/Input.tsx @@ -302,6 +302,7 @@ function BaseInput({ id={id.current} hideGutter singleLine={!multiLine} + containerOnly stateKey={stateKey} wrapLines={wrapLines} heightMode="auto"