Files
yaak/plugins/auth-oauth2/src/index.ts
Gregory Schier 7314aedc71 Merge main into proxy branch (formatting and docs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:09:59 -07: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 ${String(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;
}