import type { Context, FormInputSelectOption, GetHttpAuthenticationConfigRequest, JsonPrimitive, PluginDefinition, } from '@yaakapp/api'; import { genPkceCodeVerifier, 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 type { AccessToken } 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 = { authentication: { name: 'oauth2', label: 'OAuth 2.0', shortLabel: 'OAuth 2', actions: [ { label: 'Copy Current Token', 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', 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' }); } }, }, { label: 'Clear Window Session', async onSelect(ctx, { contextId }) { await resetDataDirKey(ctx, contextId); }, }, { label: 'Toggle Debug Logs', async onSelect(ctx) { const enableLogs = !(await ctx.store.get('enable_logs')); await ctx.store.set('enable_logs', enableLogs); await ctx.toast.show({ message: `Debug logs ${enableLogs ? 'enabled' : 'disabled'}`, color: 'info', }); }, }, ], args: [ { type: 'select', name: 'grantType', label: 'Grant Type', hideLabel: true, defaultValue: defaultGrantType, options: grantTypes, }, // Always-present fields { 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']), }, { 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: '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: '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: '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: '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 = 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'; let token: AccessToken; if (grantType === 'authorization_code') { const authorizationUrl = stringArg(values, 'authorizationUrl'); const accessTokenUrl = stringArg(values, 'accessTokenUrl'); token = await getAuthorizationCode(ctx, contextId, { accessTokenUrl: accessTokenUrl.match(/^https?:\/\//) ? accessTokenUrl : `https://${accessTokenUrl}`, 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, }); } 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, }); } 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'), clientSecret: stringArg(values, 'clientSecret'), 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 headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim(); return { setHeaders: [ { name: 'Authorization', 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; }