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, name: string, ): string | null { const arg = values[name]; if (arg == null || arg === "") return null; return `${arg}`; } function stringArg(values: Record, name: string): string { const arg = stringArgOrNull(values, name); if (!arg) return ""; return arg; } function intArg(values: Record, 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; }