mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-21 08:11:24 +02:00
Support for OAuth 2.0 (#5)
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"plugins/*"
|
"plugins/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yaakapp/api": "^0.3.4"
|
"@yaakapp/api": "^0.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.4",
|
||||||
@@ -930,9 +930,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/api": {
|
"node_modules/@yaakapp/api": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/api/-/api-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/api/-/api-0.4.0.tgz",
|
||||||
"integrity": "sha512-Yx0wigj+Re8cnYM1PJijTisRIL8jFeK50nJ66Hiv/OxPhX2QjqDWmOXGZ3TsRU9NB41JM74O1OfafRMhfeSUMw==",
|
"integrity": "sha512-F5yw9lxqomNtcYw0HtQvtWuKrOsXF7sD843zChaLMIY28gGkBpRJW9yO9D9h8AmG15oB87od3QHyW+fIFAsYxQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^22.5.4"
|
"@types/node": "^22.5.4"
|
||||||
}
|
}
|
||||||
@@ -949,6 +949,10 @@
|
|||||||
"resolved": "plugins/auth-jwt",
|
"resolved": "plugins/auth-jwt",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@yaakapp/auth-oauth2": {
|
||||||
|
"resolved": "plugins/auth-oauth2",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@yaakapp/exporter-curl": {
|
"node_modules/@yaakapp/exporter-curl": {
|
||||||
"resolved": "plugins/exporter-curl",
|
"resolved": "plugins/exporter-curl",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -7131,6 +7135,10 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.7"
|
"@types/jsonwebtoken": "^9.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"plugins/auth-oauth2": {
|
||||||
|
"name": "@yaakapp/auth-oauth2",
|
||||||
|
"version": "0.0.1"
|
||||||
|
},
|
||||||
"plugins/exporter-curl": {
|
"plugins/exporter-curl": {
|
||||||
"name": "@yaakapp/exporter-curl",
|
"name": "@yaakapp/exporter-curl",
|
||||||
"version": "0.0.1"
|
"version": "0.0.1"
|
||||||
|
|||||||
@@ -19,6 +19,6 @@
|
|||||||
"workspaces-run": "^1.0.2"
|
"workspaces-run": "^1.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@yaakapp/api": "^0.3.4"
|
"@yaakapp/api": "^0.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const plugin: PluginDefinition = {
|
|||||||
name: 'basic',
|
name: 'basic',
|
||||||
label: 'Basic Auth',
|
label: 'Basic Auth',
|
||||||
shortLabel: 'Basic',
|
shortLabel: 'Basic',
|
||||||
config: [{
|
args: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'username',
|
name: 'username',
|
||||||
label: 'Username',
|
label: 'Username',
|
||||||
@@ -17,8 +17,8 @@ export const plugin: PluginDefinition = {
|
|||||||
optional: true,
|
optional: true,
|
||||||
password: true,
|
password: true,
|
||||||
}],
|
}],
|
||||||
async onApply(_ctx, args) {
|
async onApply(_ctx, { values }) {
|
||||||
const { username, password } = args.config;
|
const { username, password } = values;
|
||||||
const value = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
const value = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
||||||
return { setHeaders: [{ name: 'Authorization', value }] };
|
return { setHeaders: [{ name: 'Authorization', value }] };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ export const plugin: PluginDefinition = {
|
|||||||
name: 'bearer',
|
name: 'bearer',
|
||||||
label: 'Bearer Token',
|
label: 'Bearer Token',
|
||||||
shortLabel: 'Bearer',
|
shortLabel: 'Bearer',
|
||||||
config: [{
|
args: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'token',
|
name: 'token',
|
||||||
label: 'Token',
|
label: 'Token',
|
||||||
optional: true,
|
optional: true,
|
||||||
password: true,
|
password: true,
|
||||||
}],
|
}],
|
||||||
async onApply(_ctx, args) {
|
async onApply(_ctx, { values }) {
|
||||||
const { token } = args.config;
|
const { token } = values;
|
||||||
const value = `Bearer ${token}`.trim();
|
const value = `Bearer ${token}`.trim();
|
||||||
return { setHeaders: [{ name: 'Authorization', value }] };
|
return { setHeaders: [{ name: 'Authorization', value }] };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,21 +24,22 @@ export const plugin: PluginDefinition = {
|
|||||||
name: 'jwt',
|
name: 'jwt',
|
||||||
label: 'JWT Bearer',
|
label: 'JWT Bearer',
|
||||||
shortLabel: 'JWT',
|
shortLabel: 'JWT',
|
||||||
config: [
|
args: [
|
||||||
{
|
{
|
||||||
type: 'select',
|
type: 'select',
|
||||||
name: 'algorithm',
|
name: 'algorithm',
|
||||||
label: 'Algorithm',
|
label: 'Algorithm',
|
||||||
hideLabel: true,
|
hideLabel: true,
|
||||||
defaultValue: defaultAlgorithm,
|
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',
|
name: 'secret',
|
||||||
label: 'Secret or Private Key',
|
label: 'Secret or Private Key',
|
||||||
|
password: true,
|
||||||
optional: true,
|
optional: true,
|
||||||
hideGutter: true,
|
multiLine: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
@@ -54,8 +55,8 @@ export const plugin: PluginDefinition = {
|
|||||||
placeholder: '{ }',
|
placeholder: '{ }',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
async onApply(_ctx, args) {
|
async onApply(_ctx, { values }) {
|
||||||
const { algorithm, secret: _secret, secretBase64, payload } = args.config;
|
const { algorithm, secret: _secret, secretBase64, payload } = values;
|
||||||
const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`;
|
const secret = secretBase64 ? Buffer.from(`${_secret}`, 'base64') : `${_secret}`;
|
||||||
const token = jwt.sign(`${payload}`, secret, { algorithm: algorithm as any });
|
const token = jwt.sign(`${payload}`, secret, { algorithm: algorithm as any });
|
||||||
const value = `Bearer ${token}`;
|
const value = `Bearer ${token}`;
|
||||||
|
|||||||
9
plugins/auth-oauth2/package.json
Normal file
9
plugins/auth-oauth2/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
plugins/auth-oauth2/src/getAccessToken.ts
Normal file
71
plugins/auth-oauth2/src/getAccessToken.ts
Normal file
@@ -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<AccessTokenRawResponse> {
|
||||||
|
console.log('Getting access token', accessTokenUrl);
|
||||||
|
const httpRequest: Partial<HttpRequest> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
99
plugins/auth-oauth2/src/getOrRefreshAccessToken.ts
Normal file
99
plugins/auth-oauth2/src/getOrRefreshAccessToken.ts
Normal file
@@ -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<AccessToken | null> {
|
||||||
|
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<HttpRequest> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
126
plugins/auth-oauth2/src/grants/authorizationCode.ts
Normal file
126
plugins/auth-oauth2/src/grants/authorizationCode.ts
Normal file
@@ -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<AccessToken> {
|
||||||
|
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 '_'
|
||||||
|
}
|
||||||
40
plugins/auth-oauth2/src/grants/clientCredentials.ts
Normal file
40
plugins/auth-oauth2/src/grants/clientCredentials.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
70
plugins/auth-oauth2/src/grants/implicit.ts
Normal file
70
plugins/auth-oauth2/src/grants/implicit.ts
Normal file
@@ -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<AccessToken> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
52
plugins/auth-oauth2/src/grants/password.ts
Normal file
52
plugins/auth-oauth2/src/grants/password.ts
Normal file
@@ -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<AccessToken> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
311
plugins/auth-oauth2/src/index.ts
Normal file
311
plugins/auth-oauth2/src/index.ts
Normal file
@@ -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<string, JsonPrimitive | undefined>, name: string): string | null {
|
||||||
|
const arg = values[name];
|
||||||
|
if (arg == null || arg == '') return null;
|
||||||
|
return `${arg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredString(values: Record<string, JsonPrimitive | undefined>, name: string): string {
|
||||||
|
const arg = optionalString(values, name);
|
||||||
|
if (!arg) throw new Error(`Missing required argument ${name}`);
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
42
plugins/auth-oauth2/src/store.ts
Normal file
42
plugins/auth-oauth2/src/store.ts
Normal file
@@ -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<AccessToken>(tokenStoreKey(contextId), token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getToken(ctx: Context, contextId: string) {
|
||||||
|
return ctx.store.get<AccessToken>(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;
|
||||||
|
}
|
||||||
@@ -4,14 +4,13 @@ const NEWLINE = '\\\n ';
|
|||||||
|
|
||||||
export const plugin: PluginDefinition = {
|
export const plugin: PluginDefinition = {
|
||||||
httpRequestActions: [{
|
httpRequestActions: [{
|
||||||
key: 'export-curl',
|
|
||||||
label: 'Copy as Curl',
|
label: 'Copy as Curl',
|
||||||
icon: 'copy',
|
icon: 'copy',
|
||||||
async onSelect(ctx, args) {
|
async onSelect(ctx, args) {
|
||||||
const rendered_request = await ctx.httpRequest.render({ httpRequest: args.httpRequest, purpose: 'preview' });
|
const rendered_request = await ctx.httpRequest.render({ httpRequest: args.httpRequest, purpose: 'preview' });
|
||||||
const data = await convertToCurl(rendered_request);
|
const data = await convertToCurl(rendered_request);
|
||||||
ctx.clipboard.copyText(data);
|
await ctx.clipboard.copyText(data);
|
||||||
ctx.toast.show({ message: 'Curl copied to clipboard', icon: 'copy' });
|
await ctx.toast.show({ message: 'Curl copied to clipboard', icon: 'copy', color: 'success' });
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,17 +17,16 @@ const behaviorArg: FormInput = {
|
|||||||
label: 'Sending Behavior',
|
label: 'Sending Behavior',
|
||||||
defaultValue: 'smart',
|
defaultValue: 'smart',
|
||||||
options: [
|
options: [
|
||||||
{ name: 'When no responses', value: 'smart' },
|
{ label: 'When no responses', value: 'smart' },
|
||||||
{ name: 'Always', value: 'always' },
|
{ label: 'Always', value: 'always' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestArg: FormInput =
|
const requestArg: FormInput = {
|
||||||
{
|
type: 'http_request',
|
||||||
type: 'http_request',
|
name: 'request',
|
||||||
name: 'request',
|
label: 'Request',
|
||||||
label: 'Request',
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export const plugin: PluginDefinition = {
|
export const plugin: PluginDefinition = {
|
||||||
templateFunctions: [
|
templateFunctions: [
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user