Files
yaak/plugins/auth-oauth2/src/index.ts
2026-03-05 06:14:11 -08:00

624 lines
22 KiB
TypeScript

import type {
Context,
FormInputSelectOption,
GetHttpAuthenticationConfigRequest,
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import type { Algorithm } from 'jsonwebtoken';
import {
buildHostedCallbackRedirectUri,
DEFAULT_LOCALHOST_PORT,
stopActiveServer,
} from './callbackServer';
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
genPkceCodeVerifier,
getAuthorizationCode,
PKCE_PLAIN,
PKCE_SHA256,
} from './grants/authorizationCode';
import {
defaultJwtAlgorithm,
getClientCredentials,
jwtAlgorithms,
} from './grants/clientCredentials';
import { getImplicit } from './grants/implicit';
import { getPassword } from './grants/password';
import type { AccessToken, TokenStoreArgs } from './store';
import { deleteToken, getToken, resetDataDirKey } 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',
'https://appcenter.intuit.com/app/connect/oauth2/authorize',
];
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',
'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer',
];
export const plugin: PluginDefinition = {
dispose() {
stopActiveServer();
},
authentication: {
name: 'oauth2',
label: 'OAuth 2.0',
shortLabel: 'OAuth 2',
actions: [
{
label: 'Copy Current Token',
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
await ctx.toast.show({
message: 'No token to copy',
color: 'warning',
});
} else {
await ctx.clipboard.copyText(token.response.access_token);
await ctx.toast.show({
message: 'Token copied to clipboard',
icon: 'copy',
color: 'success',
});
}
},
},
{
label: 'Delete Token',
async onSelect(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
if (await deleteToken(ctx, tokenArgs)) {
await ctx.toast.show({
message: 'Token deleted',
color: 'success',
});
} else {
await ctx.toast.show({
message: 'No token to delete',
color: 'warning',
});
}
},
},
{
label: 'Clear Window Session',
async onSelect(ctx, { contextId }) {
await resetDataDirKey(ctx, contextId);
},
},
],
args: [
{
type: 'select',
name: 'grantType',
label: 'Grant Type',
defaultValue: defaultGrantType,
options: grantTypes,
},
{
type: 'select',
name: 'clientCredentialsMethod',
label: 'Authentication Method',
description:
'"Client Secret" sends client_secret. \n' + '"Client Assertion" sends a signed JWT.',
defaultValue: 'client_secret',
options: [
{ label: 'Client Secret', value: 'client_secret' },
{ label: 'Client Assertion', value: 'client_assertion' },
],
dynamic: hiddenIfNot(['client_credentials']),
},
{
type: 'text',
name: 'clientId',
label: 'Client ID',
optional: true,
},
{
type: 'text',
name: 'clientSecret',
label: 'Client Secret',
optional: true,
password: true,
dynamic: hiddenIfNot(
['authorization_code', 'password', 'client_credentials'],
(values) => values.clientCredentialsMethod === 'client_secret',
),
},
{
type: 'select',
name: 'clientAssertionAlgorithm',
label: 'JWT Algorithm',
defaultValue: defaultJwtAlgorithm,
options: jwtAlgorithms.map((value) => ({
label: value === 'none' ? 'None' : value,
value,
})),
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
),
},
{
type: 'text',
name: 'clientAssertionSecret',
label: 'JWT Secret',
description:
'Can be HMAC, PEM or JWK. Make sure you pick the correct algorithm type above.',
password: true,
optional: true,
multiLine: true,
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
),
},
{
type: 'checkbox',
name: 'clientAssertionSecretBase64',
label: 'JWT secret is base64 encoded',
dynamic: hiddenIfNot(
['client_credentials'],
({ clientCredentialsMethod }) => clientCredentialsMethod === 'client_assertion',
),
},
{
type: 'text',
name: 'authorizationUrl',
optional: true,
label: 'Authorization URL',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
placeholder: authorizationUrls[0],
completionOptions: authorizationUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: 'text',
name: 'accessTokenUrl',
optional: true,
label: 'Access Token URL',
placeholder: accessTokenUrls[0],
dynamic: hiddenIfNot(['authorization_code', 'password', 'client_credentials']),
completionOptions: accessTokenUrls.map((url) => ({
label: url,
value: url,
})),
},
{
type: 'banner',
inputs: [
{
type: 'checkbox',
name: 'useExternalBrowser',
label: 'Use External Browser',
description:
'Open authorization URL in your system browser instead of the embedded browser. ' +
'Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'text',
name: 'redirectUri',
label: 'Redirect URI (can be any valid URL)',
placeholder: 'https://mysite.example.com/oauth/callback',
description:
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !useExternalBrowser,
),
},
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'callbackType',
label: 'Callback Type',
description:
'"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.',
defaultValue: 'hosted',
options: [
{ label: 'Hosted Redirect', value: 'hosted' },
{ label: 'Localhost', value: 'localhost' },
],
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
{
type: 'text',
name: 'callbackPort',
label: 'Callback Port',
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
description:
'Port for the local callback server. Defaults to ' +
DEFAULT_LOCALHOST_PORT +
' if empty.',
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
],
},
{
type: 'banner',
color: 'info',
inputs: [
{
type: 'markdown',
content: 'Redirect URI to Register',
async dynamic(_ctx, { values }) {
const grantType = String(values.grantType ?? defaultGrantType);
const useExternalBrowser = !!values.useExternalBrowser;
const callbackType = (stringArg(values, 'callbackType') ||
'localhost') as CallbackType;
// Only show for authorization_code and implicit with external browser enabled
if (
!['authorization_code', 'implicit'].includes(grantType) ||
!useExternalBrowser
) {
return { hidden: true };
}
// Compute the redirect URI based on callback type
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
let redirectUri: string;
if (callbackType === 'hosted') {
redirectUri = buildHostedCallbackRedirectUri(port);
} else {
redirectUri = `http://127.0.0.1:${port}/callback`;
}
return {
hidden: false,
content: `Register \`${redirectUri}\` as a redirect URI with your OAuth provider.`,
};
},
},
],
},
],
},
{
type: 'text',
name: 'state',
label: 'State',
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{ type: 'text', name: 'audience', 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: 'banner',
inputs: [
{
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: 'pkceCodeChallenge',
label: 'Code Verifier',
placeholder: 'Automatically generated when not set',
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
],
},
{
type: 'h_stack',
inputs: [
{
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: 'headerName',
label: 'Header Name',
defaultValue: 'Authorization',
},
{
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' },
],
dynamic: (_ctx: Context, { values }: GetHttpAuthenticationConfigRequest) => ({
hidden:
values.grantType === 'client_credentials' &&
values.clientCredentialsMethod === 'client_assertion',
}),
},
],
},
{
type: 'accordion',
label: 'Access Token Response',
inputs: [],
async dynamic(ctx, { contextId, values }) {
const tokenArgs: TokenStoreArgs = {
contextId,
authorizationUrl: stringArg(values, 'authorizationUrl'),
accessTokenUrl: stringArg(values, 'accessTokenUrl'),
clientId: stringArg(values, 'clientId'),
};
const token = await getToken(ctx, tokenArgs);
if (token == null) {
return { hidden: true };
}
return {
label: 'Access Token Response',
inputs: [
{
type: 'editor',
name: 'response',
defaultValue: JSON.stringify(token.response, null, 2),
hideLabel: true,
readOnly: true,
language: 'json',
},
],
};
},
},
],
async onApply(ctx, { values, contextId }) {
const headerPrefix = stringArg(values, 'headerPrefix');
const grantType = stringArg(values, 'grantType') as GrantType;
const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
// Build external browser options if enabled
const useExternalBrowser = !!values.useExternalBrowser;
const externalBrowserOptions = useExternalBrowser
? {
useExternalBrowser: true,
callbackType: (stringArg(values, 'callbackType') || 'localhost') as CallbackType,
callbackPort: intArg(values, 'callbackPort') ?? undefined,
}
: undefined;
let token: AccessToken;
if (grantType === 'authorization_code') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getAuthorizationCode(ctx, contextId, {
accessTokenUrl:
accessTokenUrl === '' || accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
authorizationUrl:
authorizationUrl === '' || authorizationUrl.match(/^https?:\/\//)
? authorizationUrl
: `https://${authorizationUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
redirectUri: stringArgOrNull(values, 'redirectUri'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
credentialsInBody,
pkce: values.usePkce
? {
challengeMethod: stringArg(values, 'pkceChallengeMethod') || DEFAULT_PKCE_METHOD,
codeVerifier: stringArg(values, 'pkceCodeVerifier') || genPkceCodeVerifier(),
}
: null,
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
token = await getImplicit(ctx, contextId, {
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: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getClientCredentials(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientAssertionAlgorithm: stringArg(values, 'clientAssertionAlgorithm') as Algorithm,
clientSecret: stringArg(values, 'clientSecret'),
clientCredentialsMethod: stringArg(values, 'clientCredentialsMethod'),
clientAssertionSecret: stringArg(values, 'clientAssertionSecret'),
clientAssertionSecretBase64: !!values.clientAssertionSecretBase64,
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
credentialsInBody,
});
} else if (grantType === 'password') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
token = await getPassword(ctx, contextId, {
accessTokenUrl: accessTokenUrl.match(/^https?:\/\//)
? accessTokenUrl
: `https://${accessTokenUrl}`,
clientId: stringArg(values, 'clientId'),
clientSecret: stringArg(values, 'clientSecret'),
username: stringArg(values, 'username'),
password: stringArg(values, 'password'),
scope: stringArgOrNull(values, 'scope'),
audience: stringArgOrNull(values, 'audience'),
credentialsInBody,
});
} else {
throw new Error(`Invalid grant type ${grantType}`);
}
const headerName = stringArg(values, 'headerName') || 'Authorization';
const headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
},
},
};
function stringArgOrNull(
values: Record<string, JsonPrimitive | undefined>,
name: string,
): string | null {
const arg = values[name];
if (arg == null || arg === '') return null;
return `${arg}`;
}
function stringArg(values: Record<string, JsonPrimitive | undefined>, name: string): string {
const arg = stringArgOrNull(values, name);
if (!arg) return '';
return arg;
}
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
const arg = values[name];
if (arg == null || arg === '') return null;
const num = parseInt(`${arg}`, 10);
return Number.isNaN(num) ? null : num;
}