Skip to main content
Module

x/slack_oauth/src/index.ts

Setup the OAuth flow for Slack apps easily. Deno port of @slack/oauth
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
// deno-lint-ignore-file camelcaseimport { createJwt, verifyJwt, WebAPICallResult, WebClient, WebClientOptions} from '../deps.ts'
import { CodedError, InstallerInitializationError, GenerateInstallUrlError, AuthorizationError, HandleInstallCodeStateError} from './errors.ts'import { Logger, LogLevel, getLogger } from './logger.ts'
/** * InstallProvider Class. * @param clientId - Your apps client ID * @param clientSecret - Your apps client Secret * @param stateSecret - Used to sign and verify the generated state when using the built-in `stateStore` * @param stateStore - Replacement function for the built-in `stateStore` * @param installationStore - Interface to store and retrieve installation data from the database * @param authVersion - Can be either `v1` or `v2`. Determines which slack Oauth URL and method to use * @param logger - Pass in your own Logger if you don't want to use the built-in one * @param logLevel - Pass in the log level you want (ERROR, WARN, INFO, DEBUG). Default is INFO */export class InstallProvider { public stateStore: StateStore public installationStore: InstallationStore private clientId: string private clientSecret: string private authVersion: string private logger: Logger private clientOptions: WebClientOptions private authorizationUrl: string
constructor({ clientId, clientSecret, stateSecret = undefined, stateStore = undefined, installationStore = new MemoryInstallationStore(), authVersion = 'v2', logger = undefined, logLevel = LogLevel.INFO, clientOptions = {}, authorizationUrl = 'https://slack.com/oauth/v2/authorize', }: InstallProviderOptions) {
if (clientId === undefined || clientSecret === undefined) { throw new InstallerInitializationError('You must provide a valid clientId and clientSecret') }
// Setup the logger if (typeof logger !== 'undefined') { this.logger = logger if (typeof logLevel !== 'undefined') { this.logger.debug('The logLevel given to OAuth was ignored as you also gave logger') } } else { this.logger = getLogger('OAuth:InstallProvider', logLevel, logger) }
// Setup stateStore if (stateStore !== undefined) { this.stateStore = stateStore } else if (stateSecret === undefined) { throw new InstallerInitializationError('You must provide a State Secret to use the built-in state store') } else { this.stateStore = new ClearStateStore(stateSecret) }
this.installationStore = installationStore this.clientId = clientId this.clientSecret = clientSecret this.handle = this.handle.bind(this) this.authorize = this.authorize.bind(this) this.authVersion = authVersion
this.authorizationUrl = authorizationUrl if (authorizationUrl !== 'https://slack.com/oauth/v2/authorize' && authVersion === 'v1') { this.logger.info('You provided both an authorizationUrl and an authVersion! The authVersion will be ignored in favor of the authorizationUrl.') } else if (authVersion === 'v1') { this.authorizationUrl = 'https://slack.com/oauth/authorize' }
this.clientOptions = { logLevel: this.logger.getLevel(), ...clientOptions, } }
/** * Fetches data from the installationStore for non Org Installations. */ public async authorize(source: InstallationQuery<boolean>): Promise<AuthorizeResult> { try { let queryResult if (source.isEnterpriseInstall) { queryResult = await this.installationStore.fetchInstallation(source as InstallationQuery<true>, this.logger) } else { queryResult = await this.installationStore.fetchInstallation(source as InstallationQuery<false>, this.logger) }
if (queryResult === undefined) { throw new Error('Failed fetching data from the Installation Store') }
const authResult: AuthorizeResult = {} authResult.userToken = queryResult.user.token
if (queryResult.team !== undefined) { authResult.teamId = queryResult.team.id } else if (source.teamId !== undefined) { /** * since queryResult is a org installation, it won't have team.id. If one was passed in via source, * we should add it to the authResult */ authResult.teamId = source.teamId }
if (queryResult.enterprise !== undefined) { authResult.enterpriseId = queryResult.enterprise.id } else if (source.enterpriseId !== undefined) { authResult.enterpriseId = source.enterpriseId }
if (queryResult.bot !== undefined) { authResult.botToken = queryResult.bot.token authResult.botId = queryResult.bot.id authResult.botUserId = queryResult.bot.userId }
return authResult } catch (error) { throw new AuthorizationError(error.message) } }
/** * Returns a URL that is suitable for including in an Add to Slack button * Uses stateStore to generate a value for the state query param. */ public async generateInstallUrl(options: InstallURLOptions): Promise<string> {
const slackURL = new URL(this.authorizationUrl)
if (options.scopes === undefined) { throw new GenerateInstallUrlError('You must provide a scope parameter when calling generateInstallUrl') }
// scope let scopes: string if (options.scopes instanceof Array) { scopes = options.scopes.join(',') } else { scopes = options.scopes } const params = new URLSearchParams(`scope=${scopes}`)
// generate state const state = await this.stateStore.generateStateParam(options, new Date()) params.append('state', state)
// client id params.append('client_id', this.clientId)
// redirect uri if (options.redirectUri !== undefined) { params.append('redirect_uri', options.redirectUri) }
// team id if (options.teamId !== undefined) { params.append('team', options.teamId) }
// user scope, only available for OAuth v2 if (options.userScopes !== undefined && this.authVersion === 'v2') { let userScopes: string if (options.userScopes instanceof Array) { userScopes = options.userScopes.join(',') } else { userScopes = options.userScopes } params.append('user_scope', userScopes) }
slackURL.search = params.toString() return slackURL.toString() }
/** * This method handles the incoming request to the callback URL. * It can be used as a RequestListener in almost any HTTP server * framework. * * Verifies the state using the stateStore, exchanges the grant in the * query params for an access token, and stores token and associated data * in the installationStore. */ public async handle( code: string, state: string, ): Promise<string> { let installOptions: InstallURLOptions
try { installOptions = await this.stateStore.verifyStateParam(new Date(), state) const client = new WebClient(undefined, this.clientOptions)
// Start: Build the installation object let installation: Installation let resp: OAuthV1Response | OAuthV2Response if (this.authVersion === 'v1') { // convert response type from WebApiCallResult to OAuthResponse const v1Resp = await client.oauth.access({ code, client_id: this.clientId, client_secret: this.clientSecret, redirect_uri: installOptions.redirectUri, }) as OAuthV1Response
// resp obj for v1 - https://api.slack.com/methods/oauth.access#response const v1Installation: Installation<'v1', false> = { team: { id: v1Resp.team_id, name: v1Resp.team_name }, enterprise: v1Resp.enterprise_id === null ? undefined : { id: v1Resp.enterprise_id }, user: { token: v1Resp.access_token, scopes: v1Resp.scope.split(','), id: v1Resp.user_id, },
// synthesized properties: enterprise installation is unsupported in v1 auth isEnterpriseInstall: false, authVersion: 'v1', }
// only can get botId if bot access token exists // need to create a botUser + request bot scope to have this be part of resp if (v1Resp.bot !== undefined) { const authResult = await runAuthTest(v1Resp.bot.bot_access_token, this.clientOptions) // We already tested that a bot user was in the response, so we know the following bot_id will be defined const botId = authResult.bot_id as string
v1Installation.bot = { id: botId, scopes: ['bot'], token: v1Resp.bot.bot_access_token, userId: v1Resp.bot.bot_user_id, } }
resp = v1Resp installation = v1Installation } else { // convert response type from WebApiCallResult to OAuthResponse const v2Resp = await client.oauth.v2.access({ code, client_id: this.clientId, client_secret: this.clientSecret, redirect_uri: installOptions.redirectUri, }) as OAuthV2Response
// resp obj for v2 - https://api.slack.com/methods/oauth.v2.access#response const v2Installation: Installation<'v2', boolean> = { team: v2Resp.team === null ? undefined : v2Resp.team, enterprise: v2Resp.enterprise == null ? undefined : v2Resp.enterprise, user: { token: v2Resp.authed_user.access_token, scopes: v2Resp.authed_user.scope?.split(','), id: v2Resp.authed_user.id, }, tokenType: v2Resp.token_type, isEnterpriseInstall: v2Resp.is_enterprise_install, appId: v2Resp.app_id,
// synthesized properties authVersion: 'v2', }
if (v2Resp.access_token !== undefined && v2Resp.scope !== undefined && v2Resp.bot_user_id !== undefined) { // A bot user/scope was requested const authResult = await runAuthTest(v2Resp.access_token, this.clientOptions) v2Installation.bot = { scopes: v2Resp.scope.split(','), token: v2Resp.access_token, userId: v2Resp.bot_user_id, id: authResult.bot_id as string, }
if (v2Resp.is_enterprise_install) { // if it is an org enterprise install, add the enterprise url v2Installation.enterpriseUrl = authResult.url }
} else if (v2Resp.authed_user.access_token !== undefined) { // Only user scopes were requested // TODO: confirm if it is possible to do an org enterprise install without a bot user const authResult = await runAuthTest(v2Resp.authed_user.access_token, this.clientOptions) if (v2Resp.is_enterprise_install) { v2Installation.enterpriseUrl = authResult.url } } else { // TODO: make this a coded error throw new Error('The response from the authorization URL contained inconsistent information. Please file a bug.') }
resp = v2Resp installation = v2Installation }
if (resp.incoming_webhook !== undefined) { installation.incomingWebhook = { url: resp.incoming_webhook.url, channel: resp.incoming_webhook.channel, channelId: resp.incoming_webhook.channel_id, configurationUrl: resp.incoming_webhook.configuration_url, } } // End: Build the installation object
// Save installation object to installation store if (installation.isEnterpriseInstall) { this.installationStore.storeInstallation(installation as OrgInstallation, this.logger) } else { this.installationStore.storeInstallation(installation as Installation<'v1' | 'v2', false>, this.logger) }
// Return the success HTML return getSuccessBody(installation, installOptions) } catch (error) { this.logger.error(error)
// Return the failure HTML throw new HandleInstallCodeStateError(getFailureBody(error, installOptions!)) } }}
export interface InstallProviderOptions { clientId: string clientSecret: string stateStore?: StateStore // default ClearStateStore stateSecret?: string // ClearStateStoreOptions['secret'] // required when using default stateStore installationStore?: InstallationStore // default MemoryInstallationStore authVersion?: 'v1' | 'v2' // default 'v2' logger?: Logger logLevel?: LogLevel clientOptions?: Omit<WebClientOptions, 'logLevel' | 'logger'> authorizationUrl?: string}
export interface InstallURLOptions { scopes: string | string[] teamId?: string redirectUri?: string userScopes?: string | string[] // cannot be used with authVersion=v1 metadata?: string // Arbitrary data can be stored here, potentially to save app state or use for custom redirect}
export interface StateStore { // Returned Promise resolves for a string which can be used as an // OAuth state param. generateStateParam: (installOptions: InstallURLOptions, now: Date) => Promise<string>
// Returned Promise resolves for InstallURLOptions that were stored in the state // param. The Promise rejects with a CodedError when the state is invalid. verifyStateParam: (now: Date, state: string) => Promise<InstallURLOptions>}
// State object structureinterface StateObj { now: Date installOptions: InstallURLOptions}
// default implementation of StateStoreclass ClearStateStore implements StateStore { private stateSecret: string public constructor(stateSecret: string) { this.stateSecret = stateSecret }
public async generateStateParam(installOptions: InstallURLOptions, now: Date): Promise<string> { return await createJwt({ alg: "HS256" }, { installOptions, now: now.toJSON() }, this.stateSecret) }
public async verifyStateParam(_now: Date, state: string): Promise<InstallURLOptions> { // decode the state using the secret const decoded = await verifyJwt(state, this.stateSecret, "HS256") as unknown as StateObj
// return installOptions return decoded.installOptions }}
export interface InstallationStore { storeInstallation<AuthVersion extends 'v1' | 'v2'>( installation: Installation<AuthVersion, boolean>, logger?: Logger): void fetchInstallation: (query: InstallationQuery<boolean>, logger?: Logger) => Promise<Installation<'v1' | 'v2', boolean>>}
// using a javascript object as a makeshift database for developmentinterface DevDatabase { [teamIdOrEnterpriseId: string]: Installation}
// Default Install Store. Should only be used for developmentclass MemoryInstallationStore implements InstallationStore { public devDB: DevDatabase = {}
public storeInstallation(installation: Installation, logger?: Logger): void { // NOTE: installations on a single workspace that happen to be within an enterprise organization are stored by // the team ID as the key // TODO: what about installations on an enterprise (acting as a single workspace) with `admin` scope, which is not // an org install? if (logger !== undefined) { logger.warn('Storing Access Token. Please use a real Installation Store for production!') }
if (isOrgInstall(installation)) { if (logger !== undefined) { logger.debug('storing org installation') } this.devDB[installation.enterprise.id] = installation } else if (isNotOrgInstall(installation)) { if (logger !== undefined) { logger.debug('storing single team installation') } this.devDB[installation.team.id] = installation } else { throw new Error('Failed saving installation data to installationStore') } }
// The following ignore is present since most custom fetchInstallation methods will // query from a database, which will require them to be async // deno-lint-ignore require-await public async fetchInstallation( query: InstallationQuery<boolean>, logger?: Logger): Promise<Installation<'v1' | 'v2'>> { if (logger !== undefined) { logger.warn('Retrieving Access Token from DB. Please use a real Installation Store for production!') } if (query.isEnterpriseInstall) { if (query.enterpriseId !== undefined) { if (logger !== undefined) { logger.debug('fetching org installation') } return this.devDB[query.enterpriseId] as OrgInstallation } } if (query.teamId !== undefined) { if (logger !== undefined) { logger.debug('fetching single team installation') } return this.devDB[query.teamId] as Installation<'v1' | 'v2', false> } throw new Error('Failed fetching installation') }}
/** * An individual installation of the Slack app. * * This interface creates a representation for installations that normalizes the responses from OAuth grant exchanges * across auth versions (responses from the Web API methods `oauth.v2.access` and `oauth.access`). It describes some of * these differences using the `AuthVersion` generic placeholder type. * * This interface also represents both installations which occur on individual Slack workspaces and on Slack enterprise * organizations. The `IsEnterpriseInstall` generic placeholder type is used to describe some of those differences. * * This representation is designed to be used both when producing data that should be stored by an InstallationStore, * and when consuming data that is fetched from an InstallationStore. Most often, InstallationStore implementations * are a database. If you are going to implement an InstallationStore, it's advised that you **store as much of the * data in these objects as possible so that you can return as much as possible inside `fetchInstallation()`**. * * A few properties are synthesized with a default value if they are not present when returned from * `fetchInstallation()`. These properties are optional in the interface so that stored installations from previous * versions of this library (from before those properties were introduced) continue to work without requiring a breaking * change. However the synthesized default values are not always perfect and are based on some assumptions, so this is * why it's recommended to store as much of that data as possible in any InstallationStore. * * Some of the properties (e.g. `team.name`) can change between when the installation occurred and when it is fetched * from the InstallationStore. This can be seen as a reason not to store those properties. In most workspaces these * properties rarely change, and for most Slack apps having a slightly out of date value has no impact. However if your * app uses these values in a way where it must be up to date, it's recommended to implement a caching strategy in the * InstallationStore to fetch the latest data from the Web API (using methods such as `auth.test`, `teams.info`, etc.) * as often as it makes sense for your Slack app. * * TODO: IsEnterpriseInstall is always false when AuthVersion is v1 */export interface Installation<AuthVersion extends ('v1' | 'v2') = ('v1' | 'v2'), IsEnterpriseInstall extends boolean = boolean> { /** * TODO: when performing a “single workspace” install with the admin scope on the enterprise, * is the team property returned from oauth.access? */ team: IsEnterpriseInstall extends true ? undefined : { id: string /** Left as undefined when not returned from fetch. */ name?: string }
/** * When the installation is an enterprise install or when the installation occurs on the org to acquire `admin` scope, * the name and ID of the enterprise org. */ enterprise: IsEnterpriseInstall extends true ? EnterpriseInfo : (EnterpriseInfo | undefined)
user: { token: AuthVersion extends 'v1' ? string : (string | undefined) scopes: AuthVersion extends 'v1' ? string[] : (string[] | undefined) id: string }
bot?: { token: string scopes: string[] id: string // retrieved from auth.test userId: string } incomingWebhook?: { url: string /** Left as undefined when not returned from fetch. */ channel?: string /** Left as undefined when not returned from fetch. */ channelId?: string /** Left as undefined when not returned from fetch. */ configurationUrl?: string }
/** The App ID, which does not vary per installation. Left as undefined when not returned from fetch. */ appId?: AuthVersion extends 'v2' ? string : undefined
/** When the installation contains a bot user, the token type. Left as undefined when not returned from fetch. */ tokenType?: 'bot'
/** * When the installation is an enterprise org install, the URL of the landing page for all workspaces in the org. * Left as undefined when not returned from fetch. */ enterpriseUrl?: AuthVersion extends 'v2' ? string : undefined
/** Whether the installation was performed on an enterprise org. Synthesized as `false` when not present. */ isEnterpriseInstall?: IsEnterpriseInstall
/** The version of Slack's auth flow that produced this installation. Synthesized as `v2` when not present. */ authVersion?: AuthVersion}
/** * A type to describe enterprise organization installations. */export type OrgInstallation = Installation<'v2', true>
interface EnterpriseInfo { id: string /* Not defined in v1 auth version. Left as undefined when not returned from fetch. */ name?: string}
// This is intentionally structurally identical to AuthorizeSourceData// from App. It is redefined so that this class remains loosely coupled to// the rest of Bolt.export interface InstallationQuery<isEnterpriseInstall extends boolean> { teamId: isEnterpriseInstall extends false ? string : undefined enterpriseId: isEnterpriseInstall extends true ? string : (string | undefined) userId?: string conversationId?: string isEnterpriseInstall: isEnterpriseInstall}
export type OrgInstallationQuery = InstallationQuery<true>
// This is intentionally structurally identical to AuthorizeResult from App// It is redefined so that this class remains loosely coupled to the rest// of Bolt.export interface AuthorizeResult { botToken?: string userToken?: string botId?: string botUserId?: string teamId?: string enterpriseId?: string}
// Default function to call when OAuth flow is successfulfunction getSuccessBody( installation: Installation, _options: InstallURLOptions | undefined,): string { let redirectUrl: string
if (isNotOrgInstall(installation) && installation.appId !== undefined) { // redirect back to Slack native app // Changes to the workspace app was installed to, to the app home redirectUrl = `slack://app?team=${installation.team.id}&id=${installation.appId}` } else if (isOrgInstall(installation)) { // redirect to Slack app management dashboard redirectUrl = `${installation.enterpriseUrl}manage/organization/apps/profile/${installation.appId}/workspaces/add` } else { // redirect back to Slack native app // does not change the workspace the slack client was last in redirectUrl = 'slack://open' } const htmlResponse = `<html> <meta http-equiv="refresh" content="0 URL=${redirectUrl}"> <body> <h1>Success! Redirecting to the Slack App...</h1> <button onClick="window.location = '${redirectUrl}'">Click here to redirect</button> </body></html>`
return htmlResponse}
// Default function to call when OAuth flow is unsuccessfulfunction getFailureBody( _error: CodedError, _options: InstallURLOptions,): string { const htmlResponse = '<html><body><h1>Oops, Something Went Wrong! Please Try Again or Contact the App Owner</h1></body></html>' return htmlResponse}
// Gets the bot_id using the `auth.test` method.async function runAuthTest(token: string, clientOptions: WebClientOptions): Promise<AuthTestResult> { const client = new WebClient(token, clientOptions) const authResult = await client.auth.test() return authResult as AuthTestResult}
// Type guard to narrow Installation type to OrgInstallationfunction isOrgInstall(installation: Installation): installation is OrgInstallation { return installation.isEnterpriseInstall || false}
function isNotOrgInstall(installation: Installation): installation is Installation<'v1' | 'v2', false> { return !(isOrgInstall(installation))}
// Response shape from oauth.v2.access - https://api.slack.com/methods/oauth.v2.access#responseinterface OAuthV2Response extends WebAPICallResult { app_id: string authed_user: { id: string, scope?: string, access_token?: string, token_type?: string, } scope?: string token_type?: 'bot' access_token?: string bot_user_id?: string team: { id: string, name: string } | null enterprise: { name: string, id: string } | null is_enterprise_install: boolean incoming_webhook?: { url: string, channel: string, channel_id: string, configuration_url: string, }}
// Response shape from oauth.access - https://api.slack.com/methods/oauth.access#responseinterface OAuthV1Response extends WebAPICallResult { access_token: string // scope parameter isn't returned in workspace apps scope: string team_name: string team_id: string enterprise_id: string | null // if they request bot user token bot?: { bot_user_id: string, bot_access_token: string } incoming_webhook?: { url: string, channel: string, channel_id: string, configuration_url: string, } // app_id is currently undefined but leaving it in here incase the v1 method adds it app_id: string | undefined // TODO: removed optional because logically there's no case where a user_id cannot be provided, but needs verification user_id: string // Not documented but showing up on responses}
interface AuthTestResult extends WebAPICallResult { bot_id?: string url?: string}
export { LogLevel } from './logger.ts'export type { Logger } from './logger.ts'