diff --git a/.changeset/oauth-redis-installation-store.md b/.changeset/oauth-redis-installation-store.md new file mode 100644 index 000000000..1515ef94d --- /dev/null +++ b/.changeset/oauth-redis-installation-store.md @@ -0,0 +1,20 @@ +--- +"@slack/oauth": minor +--- + +feat(oauth): add `RedisInstallationStore`, which saves installations in Redis with the client your app already uses, such as `ioredis`, `redis` (node-redis) or `@upstash/redis`. Each user's token is saved on its own, so an authorization with only user scopes never replaces the bot token, and a token refresh saves only the token that changed. + +```js +import { InstallProvider, RedisInstallationStore } from '@slack/oauth'; +import Redis from 'ioredis'; + +const installer = new InstallProvider({ + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + stateSecret: process.env.SLACK_STATE_SECRET, + installationStore: new RedisInstallationStore({ + client: new Redis(process.env.REDIS_URL), + clientId: process.env.SLACK_CLIENT_ID, + }), +}); +``` diff --git a/docs/english/oauth.md b/docs/english/oauth.md index 042b23861..082c35d79 100644 --- a/docs/english/oauth.md +++ b/docs/english/oauth.md @@ -255,6 +255,8 @@ For more persistent storage during development, `FileInstallationStore` is a pro :::warning[`FileInstallationStore` and `FileStateStore` are for development only and you should supply your own `installationStore` for production deployments.] ::: +For production, `RedisInstallationStore` saves installations in Redis. Read [using `RedisInstallationStore`](#using-redisinstallationstore) to set it up, or keep reading to write an installation store for your own database. + An installation store is an object that provides three methods: `storeInstallation`, `fetchInstallation`, and `deleteInstallation`. `storeInstallation` takes an `installation` as an argument, which is an object that contains all installation related data (like tokens, teamIds, enterpriseIds, etc). `fetchInstallation` and `deleteInstallation` both take in an `installQuery`, which is used to query the database. The `installQuery` can contain `teamId`, `enterpriseId`, `userId`, `conversationId` and `isEnterpriseInstall`. In the following example, the `installationStore` option is used and the object is defined in line. The methods are implemented by calling an example database library with simple get and set operations. @@ -311,6 +313,44 @@ const installer = new InstallProvider({ }, }); ``` + +### Using `RedisInstallationStore` + +`RedisInstallationStore` saves installations in Redis, or in a server that works like it, such as Valkey. It doesn't add a Redis client to your app. Pass it the client you already use, such as one from `ioredis`, `redis` (node-redis v4 or later), `iovalkey`, or `@upstash/redis`: + +```javascript +const { InstallProvider, RedisInstallationStore } = require('@slack/oauth'); +const Redis = require('ioredis'); + +const installer = new InstallProvider({ + clientId: process.env.SLACK_CLIENT_ID, + clientSecret: process.env.SLACK_CLIENT_SECRET, + stateSecret: process.env.SLACK_STATE_SECRET, + installationStore: new RedisInstallationStore({ + client: new Redis(process.env.REDIS_URL), + clientId: process.env.SLACK_CLIENT_ID, + }), +}); +``` + +With node-redis, create the client with `createClient({ url: process.env.REDIS_URL })`, then pass it once `await client.connect()` resolves. With Upstash, pass `Redis.fromEnv()` from `@upstash/redis`. Your app owns the client, so close it when your app shuts down. Bolt apps pass the same store as the `installationStore` option of `App`, after adding `@slack/oauth` to their dependencies. + +The store takes these options: + +| Option | Description | +|---|---| +| `client` | A connected Redis client. Any client with promise-based `get`, `set`, `del` and `append` methods works. | +| `keyPrefix` | The start of every key the store uses. Defaults to `slack:installation`. | +| `clientId` | Your app's client ID. It keeps the installations of apps that share one Redis apart. | + +Each workspace (or organization, for org-wide installs) has its own installation, and each user who authorized your app has their own token: + +* An authorization with only user scopes never replaces the bot token, and a token refresh saves only the token that changed. +* `fetchInstallation()` with a `userId` returns that user's token, or no user token when that user never authorized your app. Without a `userId`, it returns the token of the user who installed your app, like `FileInstallationStore`. +* `deleteInstallation()` with a `userId` deletes that user's token. Without one, it deletes the installation and every user's token, for example when your app is uninstalled. + +Tokens are saved as plain JSON, so limit who can reach your Redis, and turn on persistence. Use a `maxmemory-policy` of `noeviction` or one of the `volatile-*` policies, since an `allkeys-*` policy can evict installations. + --- ## Reading tokens and other installation data diff --git a/packages/oauth/src/installation-stores/index.ts b/packages/oauth/src/installation-stores/index.ts index 071bd835f..f17ab690b 100644 --- a/packages/oauth/src/installation-stores/index.ts +++ b/packages/oauth/src/installation-stores/index.ts @@ -1,3 +1,8 @@ export { default as FileInstallationStore } from './file-store'; export { InstallationStore } from './interface'; export { default as MemoryInstallationStore } from './memory-store'; +export { + default as RedisInstallationStore, + RedisInstallationStoreClient, + RedisInstallationStoreOptions, +} from './redis-store'; diff --git a/packages/oauth/src/installation-stores/redis-store.test.ts b/packages/oauth/src/installation-stores/redis-store.test.ts new file mode 100644 index 000000000..c1a41fcdb --- /dev/null +++ b/packages/oauth/src/installation-stores/redis-store.test.ts @@ -0,0 +1,551 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import sinon from 'sinon'; + +import type { Installation, InstallationQuery, InstallationStore, Logger } from '../index'; +import { InstallProvider, LogLevel } from '../index'; +import { RedisInstallationStore, type RedisInstallationStoreClient } from './index'; + +// Keeps strings like Redis. The methods use a private field, so calling one unbound fails like it does on real clients. +class FakeRedis implements RedisInstallationStoreClient { + #data = new Map(); + + async get(key: string): Promise { + return this.#data.get(key) ?? null; + } + + async set(key: string, value: string): Promise { + this.#data.set(key, value); + return 'OK'; + } + + async del(key: string): Promise { + return this.#data.delete(key) ? 1 : 0; + } + + async append(key: string, value: string): Promise { + const appended = (this.#data.get(key) ?? '') + value; + this.#data.set(key, appended); + return appended.length; + } + + raw(key: string): string | undefined { + return this.#data.get(key); + } + + keys(): string[] { + return [...this.#data.keys()].sort(); + } +} + +// Parses JSON values on get(), like @upstash/redis does by default. +class UpstashLikeRedis extends FakeRedis { + async get(key: string): Promise { + const value = await super.get(key); + try { + return typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return value; + } + } +} + +// Returns bytes, like clients set up to return buffers. +class BufferRedis extends FakeRedis { + async get(key: string): Promise { + const value = await super.get(key); + return typeof value === 'string' ? Buffer.from(value) : value; + } +} + +const now = () => Math.floor(Date.now() / 1000); +const expiring = () => now() + 60; // inside the 2 hours before expiry when InstallProvider refreshes a token +const fresh = () => now() + 43200; + +function bot(token = 'xoxb-1', expiresAt = fresh()): NonNullable { + return { id: 'B1', userId: 'UB1', scopes: ['chat:write'], token, refreshToken: `xoxe-${token}`, expiresAt }; +} + +function user(id: string, token = `xoxp-${id}`, expiresAt = fresh()): Installation['user'] { + return { id, token, refreshToken: `xoxe-${token}`, expiresAt, scopes: ['search:read'] }; +} + +function installation(installer: Installation['user'], withBot?: Installation['bot']): Installation { + return { + team: { id: 'T1', name: 'Team' }, + enterprise: undefined, + user: installer, + ...(withBot !== undefined && { bot: withBot, tokenType: 'bot' as const }), + appId: 'A1', + isEnterpriseInstall: false, + authVersion: 'v2', + }; +} + +const orgInstallation: Installation<'v2', true> = { + team: undefined, + enterprise: { id: 'E1', name: 'Org' }, + user: user('U1'), + bot: bot(), + tokenType: 'bot', + appId: 'A1', + enterpriseUrl: 'https://org.enterprise.slack.com/', + isEnterpriseInstall: true, + authVersion: 'v2', +}; + +function team(userId?: string): InstallationQuery { + return { teamId: 'T1', enterpriseId: undefined, userId, isEnterpriseInstall: false }; +} + +// Bolt also passes the ID of the workspace in the org that an event came from. +function org(teamId?: string, userId?: string): InstallationQuery { + return { teamId, enterpriseId: 'E1', userId, isEnterpriseInstall: true }; +} + +// Drops what JSON doesn't keep, like undefined values. +function plain(value: T): T { + return JSON.parse(JSON.stringify(value)); +} + +function spyLogger(): Logger & Record<'debug' | 'info' | 'warn' | 'error', sinon.SinonSpy> { + return { + debug: sinon.spy(), + info: sinon.spy(), + warn: sinon.spy(), + error: sinon.spy(), + setLevel: sinon.spy(), + getLevel: () => LogLevel.DEBUG, + setName: sinon.spy(), + }; +} + +// An InstallProvider whose token refreshes are answered by `refresh`, with the refresh token it was given. +function installer(installationStore: InstallationStore, refresh: (refreshToken: string) => Promise) { + return new InstallProvider({ + clientId: 'C1', + clientSecret: 'secret', + stateSecret: 'state', + installationStore, + logger: spyLogger(), + clientOptions: { + retryConfig: { retries: 0 }, + fetch: async (_url, init) => { + const body = await refresh(new URLSearchParams(String(init?.body)).get('refresh_token') ?? ''); + return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); + }, + }, + }); +} + +function refreshed(tokenType: 'bot' | 'user', token: string) { + return { ok: true, token_type: tokenType, access_token: token, refresh_token: `xoxe-${token}`, expires_in: 43200 }; +} + +const invalidRefreshToken = { ok: false, error: 'invalid_refresh_token' }; + +const W = 'slack:installation:team:T1'; + +describe('RedisInstallationStore', async () => { + it('should require a client with get(), set(), del() and append()', async () => { + for (const client of [undefined, {}, { get: () => {}, set: () => {}, del: () => {} }]) { + assert.throws(() => new RedisInstallationStore({ client: client as unknown as RedisInstallationStoreClient }), { + name: 'TypeError', + message: 'RedisInstallationStore needs a Redis client with get(), set(), del() and append() methods', + }); + } + }); + + it('should use the documented keys', async () => { + const client = new FakeRedis(); + await new RedisInstallationStore({ client }).storeInstallation(installation(user('U1'), bot())); + await new RedisInstallationStore({ client, clientId: '123.456' }).storeInstallation(installation(user('U1'))); + await new RedisInstallationStore({ client, keyPrefix: 'app' }).storeInstallation(orgInstallation); + + assert.deepStrictEqual(client.keys(), [ + 'app:enterprise:E1', + 'app:enterprise:E1:user:U1', + 'app:enterprise:E1:users', + 'slack:installation:123.456:team:T1:nobot', + 'slack:installation:123.456:team:T1:user:U1', + 'slack:installation:123.456:team:T1:users', + W, + `${W}:user:U1`, + `${W}:users`, + ]); + assert.strictEqual(client.raw(`${W}:users`), ',U1'); + }); + + for (const Client of [FakeRedis, UpstashLikeRedis, BufferRedis]) { + describe(`with a client like ${Client.name}`, async () => { + it('should store and fetch an installation, keeping user tokens out of the installation record', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + const saved = installation(user('U1'), bot()); + + await store.storeInstallation(saved); + + assert.deepStrictEqual(plain(await store.fetchInstallation(team())), plain(saved)); + assert.deepStrictEqual(plain(await store.fetchInstallation(team('U1'))), plain(saved)); + assert.doesNotMatch(client.raw(W) ?? '', /xoxp/); + assert.strictEqual(JSON.parse(client.raw(`${W}:user:U1`) ?? '').token, 'xoxp-U1'); + }); + + it('should fetch an org-wide installation for any workspace in the org', async () => { + const store = new RedisInstallationStore({ client: new Client() }); + await store.storeInstallation(orgInstallation); + + const fetched = await store.fetchInstallation(org('T2', 'U1')); + assert.deepStrictEqual(plain(fetched), plain(orgInstallation)); + }); + + it('should fetch a workspace in an Enterprise Grid org with or without its enterprise ID', async () => { + const store = new RedisInstallationStore({ client: new Client() }); + await store.storeInstallation({ ...installation(user('U1'), bot()), enterprise: { id: 'E1', name: 'Org' } }); + + assert.strictEqual((await store.fetchInstallation(team())).enterprise?.id, 'E1'); + assert.strictEqual((await store.fetchInstallation({ ...team(), enterpriseId: 'E1' })).enterprise?.id, 'E1'); + }); + + it("should not replace the bot with a later authorization that has only user scopes, and fetch each user's token", async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'), bot())); + const record = client.raw(W); + + await store.storeInstallation(installation(user('U2'))); + + assert.strictEqual(client.raw(W), record); + const forU2 = await store.fetchInstallation(team('U2')); + assert.strictEqual(forU2.bot?.token, 'xoxb-1'); + assert.deepStrictEqual(plain(forU2.user), plain(user('U2'))); + assert.strictEqual((await store.fetchInstallation(team())).user.token, 'xoxp-U1'); + }); + + it('should fetch no user token for a user who never authorized the app', async () => { + const store = new RedisInstallationStore({ client: new Client() }); + await store.storeInstallation(installation(user('U1'), bot())); + + const fetched = await store.fetchInstallation(team('U3')); + assert.deepStrictEqual(plain(fetched.user), { id: 'U1' }); + assert.strictEqual(fetched.bot?.token, 'xoxb-1'); + }); + + it('should fetch the latest installation of an app without a bot', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'))); + await store.storeInstallation(installation(user('U2'))); + + assert.strictEqual(client.raw(W), undefined); + assert.strictEqual((await store.fetchInstallation(team())).user.token, 'xoxp-U2'); + assert.strictEqual((await store.fetchInstallation(team('U1'))).user.token, 'xoxp-U1'); + }); + + it('should not change the installation it stores or share objects between fetches', async () => { + const store = new RedisInstallationStore({ client: new Client() }); + const saved = installation(user('U1'), bot()); + const copy = structuredClone(saved); + + await store.storeInstallation(saved); + assert.deepStrictEqual(saved, copy); + + const first = await store.fetchInstallation(team()); + if (first.bot) first.bot.token = 'changed'; + assert.strictEqual((await store.fetchInstallation(team())).bot?.token, 'xoxb-1'); + }); + + it('should handle null values in installations', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + const withNulls = { + ...installation(user('U1'), bot()), + enterprise: null, + user: { id: 'U1', token: 'xoxp-U1', scopes: null }, + } as unknown as Installation; + + await store.storeInstallation(withNulls); + assert.deepStrictEqual(plain(await store.fetchInstallation(team('U1'))), plain(withNulls)); + assert.strictEqual( + (await installer(store, async () => invalidRefreshToken).authorize(team())).botToken, + 'xoxb-1', + ); + + await store.storeInstallation({ ...installation(user('U9'), bot()), user: null } as unknown as Installation); + assert.strictEqual(client.raw(`${W}:user:U9`), undefined); + }); + + it("should delete only a user's token when given a user ID", async () => { + const store = new RedisInstallationStore({ client: new Client() }); + await store.storeInstallation(installation(user('U1'), bot())); + await store.storeInstallation(installation(user('U2'))); + + await store.deleteInstallation(team('U2')); + assert.strictEqual((await store.fetchInstallation(team('U2'))).user.token, undefined); + assert.strictEqual((await store.fetchInstallation(team('U1'))).user.token, 'xoxp-U1'); + + await store.deleteInstallation(team('U1')); + const fetched = await store.fetchInstallation(team()); + assert.strictEqual(fetched.user.token, undefined); + assert.strictEqual(fetched.bot?.token, 'xoxb-1'); + }); + + it('should delete everything of a workspace, more than once, and only for that workspace or org', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(orgInstallation); + const orgKeys = client.keys(); + await store.storeInstallation(installation(user('U1'), bot())); + await store.storeInstallation(installation(user('U2'))); + await Promise.all(['U7', 'U8'].map((id) => store.storeInstallation(installation(user(id))))); + + await store.deleteInstallation(team()); + assert.deepStrictEqual(client.keys(), orgKeys); + await assert.rejects(store.fetchInstallation(team()), /No installation data found/); + + await store.deleteInstallation(team()); + await store.deleteInstallation({ ...team(), teamId: 'T404' }); + await store.deleteInstallation(org('T1')); + assert.deepStrictEqual(client.keys(), []); + }); + + it('should finish deleting a workspace when a failed delete runs again', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'), bot())); + await store.storeInstallation(installation(user('U2'))); + const del = sinon.stub(client, 'del').callThrough(); + del.withArgs(`${W}:user:U2`).rejects(new Error('Connection lost')); + + await assert.rejects(store.deleteInstallation(team()), /Failed to delete installation/); + del.restore(); + await store.deleteInstallation(team()); + + assert.deepStrictEqual(client.keys(), []); + }); + + describe('when InstallProvider refreshes tokens', async () => { + it("should save a refreshed bot token without dropping the installer's user token", async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'), bot('xoxb-1', expiring()))); + const installerRecord = client.raw(`${W}:user:U1`); + const provider = installer(store, async (refreshToken) => + refreshToken === 'xoxe-xoxb-1' ? refreshed('bot', 'xoxb-2') : invalidRefreshToken, + ); + + // U3 never authorized the app, so the lookup has no user token for them. + const result = await provider.authorize(team('U3')); + + assert.strictEqual(result.botToken, 'xoxb-2'); + assert.strictEqual(result.userToken, undefined); + assert.strictEqual(JSON.parse(client.raw(W) ?? '').user.id, 'U1'); + assert.strictEqual(client.raw(`${W}:user:U1`), installerRecord); + assert.strictEqual((await provider.authorize(team())).userToken, 'xoxp-U1'); + }); + + it('should keep the installer when another user triggers the refresh', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'), bot('xoxb-1', expiring()))); + await store.storeInstallation(installation(user('U2'))); + const provider = installer(store, async () => refreshed('bot', 'xoxb-2')); + + assert.strictEqual((await provider.authorize(team('U2'))).userToken, 'xoxp-U2'); + + assert.strictEqual(JSON.parse(client.raw(W) ?? '').user.id, 'U1'); + const result = await provider.authorize(team()); + assert.strictEqual(result.botToken, 'xoxb-2'); + assert.strictEqual(result.userToken, 'xoxp-U1'); + }); + + it('should save only the user token when only that one is refreshed', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1', 'xoxp-1', expiring()), bot())); + const [record, users] = [client.raw(W), client.raw(`${W}:users`)]; + const provider = installer(store, async () => refreshed('user', 'xoxp-2')); + + assert.strictEqual((await provider.authorize(team('U1'))).userToken, 'xoxp-2'); + + assert.strictEqual(client.raw(W), record); + assert.strictEqual(client.raw(`${W}:users`), users); + assert.strictEqual(JSON.parse(client.raw(`${W}:user:U1`) ?? '').refreshToken, 'xoxe-xoxp-2'); + }); + + it('should not overwrite a token that another request refreshed when its own refresh fails', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1', 'xoxp-1', expiring()), bot('xoxb-1', expiring()))); + let redeemed = false; + // Slack accepts a refresh token once, so the second request's bot refresh fails, and its user refresh + // finishes last, writing back the installation it fetched. + const provider = installer(store, async (refreshToken) => { + if (refreshToken === 'xoxe-xoxb-1') { + if (redeemed) return invalidRefreshToken; + redeemed = true; + return refreshed('bot', 'xoxb-2'); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + return refreshed('user', 'xoxp-2'); + }); + + await Promise.all([provider.authorize(team('U3')), provider.authorize(team('U1'))]); + + const record = JSON.parse(client.raw(W) ?? ''); + assert.strictEqual(record.bot.token, 'xoxb-2'); + assert.strictEqual(record.bot.refreshToken, 'xoxe-xoxb-2'); + assert.strictEqual(JSON.parse(client.raw(`${W}:user:U1`) ?? '').token, 'xoxp-2'); + }); + }); + + it('should save only what a copy of a fetched installation changed', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'), bot())); + const first = await store.fetchInstallation(team('U1')); + const second = await store.fetchInstallation(team('U1')); + + // Each copy changes one token. Saving both, in either order, keeps both changes. + await store.storeInstallation({ ...first, user: { ...first.user, token: 'xoxp-2' } }); + await store.storeInstallation({ ...second, bot: { ...bot(), token: 'xoxb-2' } }); + const saved = await store.fetchInstallation(team('U1')); + assert.strictEqual(saved.bot?.token, 'xoxb-2'); + assert.strictEqual(saved.user.token, 'xoxp-2'); + const third = await store.fetchInstallation(team('U1')); + const fourth = await store.fetchInstallation(team('U1')); + await store.storeInstallation({ ...third, bot: { ...bot(), token: 'xoxb-3' } }); + await store.storeInstallation({ ...fourth, user: { ...fourth.user, token: 'xoxp-3' } }); + + const fetched = await store.fetchInstallation(team('U1')); + assert.strictEqual(fetched.bot?.token, 'xoxb-3'); + assert.strictEqual(fetched.user.token, 'xoxp-3'); + }); + + it('should save changes to a fetched installation and copy it into another store', async () => { + const client = new Client(); + const store = new RedisInstallationStore({ client }); + await store.storeInstallation(installation(user('U1'), bot())); + await store.storeInstallation(installation(user('U2'))); + const userRecord = client.raw(`${W}:user:U2`); + const fetched = await store.fetchInstallation(team('U2')); + + await store.storeInstallation({ ...fetched, metadata: 'note' }); + + const record = JSON.parse(client.raw(W) ?? ''); + assert.strictEqual(record.metadata, 'note'); + assert.strictEqual(record.user.id, 'U1'); + assert.strictEqual(client.raw(`${W}:user:U2`), userRecord); + + await store.storeInstallation(fetched); + assert.strictEqual(JSON.parse(client.raw(W) ?? '').metadata, undefined); + + // Saved for another workspace, the copy is a new installation there. + await store.storeInstallation({ ...(await store.fetchInstallation(team('U2'))), team: { id: 'T2' } }); + assert.strictEqual((await store.fetchInstallation({ ...team('U2'), teamId: 'T2' })).user.token, 'xoxp-U2'); + await store.deleteInstallation({ ...team(), teamId: 'T2' }); + assert.ok(!client.keys().some((key) => key.includes('team:T2'))); + + const copy = new RedisInstallationStore({ client: new Client(), keyPrefix: 'copy' }); + await copy.storeInstallation(await store.fetchInstallation(team())); + assert.deepStrictEqual( + plain(await copy.fetchInstallation(team())), + plain(await store.fetchInstallation(team())), + ); + }); + }); + } + + it('should require the IDs of the installation or query', async () => { + const client = new FakeRedis(); + const store = new RedisInstallationStore({ client }); + const workspace = /^teamId is required to (save|fetch|delete) data of a workspace installation$/; + const enterprise = /^enterpriseId is required to (save|fetch|delete) data of an enterprise installation$/; + + await assert.rejects(store.storeInstallation({ ...installation(user('U1')), team: undefined }), { + message: 'teamId is required to save data of a workspace installation', + }); + await assert.rejects(store.storeInstallation({ ...orgInstallation, enterprise: undefined } as Installation), { + message: 'enterpriseId is required to save data of an enterprise installation', + }); + for (const query of [ + { ...team(), teamId: undefined }, + { ...org(), enterpriseId: undefined }, + ]) { + const expected = query.isEnterpriseInstall ? enterprise : workspace; + await assert.rejects(store.fetchInstallation(query as InstallationQuery), { message: expected }); + await assert.rejects(store.deleteInstallation(query as InstallationQuery), { message: expected }); + } + assert.deepStrictEqual(client.keys(), []); + }); + + it('should say what it looked for when no installation is found', async () => { + const store = new RedisInstallationStore({ client: new FakeRedis() }); + + await assert.rejects(store.fetchInstallation({ ...team('U1'), teamId: 'T404' }), { + message: 'No installation data found (enterprise_id: undefined, team_id: T404, user_id: U1)', + }); + }); + + it('should wrap errors from the client, without tokens', async () => { + const client = new FakeRedis(); + const store = new RedisInstallationStore({ client }); + const error = new Error('READONLY You cannot write against a read only replica.'); + + // Like @upstash/redis, which puts the command it sent in its errors. + const append = sinon + .stub(client, 'append') + .rejects( + new Error('ERR max requests limit exceeded, command was: ["set","k","{\\"token\\":\\"xoxe.xoxp-1-abc\\"}"]'), + ); + await assert.rejects(store.storeInstallation(installation(user('U1'), bot())), (e: Error) => { + assert.strictEqual( + e.message, + 'Failed to save installation to RedisInstallationStore (error: Error: ERR max requests limit exceeded, command was: ["set","k","{\\"token\\":\\"[redacted]\\"}"])', + ); + assert.strictEqual(e.cause, undefined); + return true; + }); + // The installation was still saved, so the bot keeps working, and the user's token wasn't saved unindexed. + assert.strictEqual((await store.fetchInstallation(team())).bot?.token, 'xoxb-1'); + assert.strictEqual(client.raw(`${W}:user:U1`), undefined); + append.restore(); + + sinon.stub(client, 'get').rejects(error); + await assert.rejects(store.fetchInstallation(team()), (e: Error) => { + assert.strictEqual(e.message, `Failed to fetch installation from RedisInstallationStore (error: ${error})`); + assert.strictEqual(e.cause, error); + return true; + }); + await assert.rejects(store.deleteInstallation(team()), (e: Error) => { + assert.strictEqual(e.message, `Failed to delete installation from RedisInstallationStore (error: ${error})`); + assert.strictEqual(e.cause, error); + return true; + }); + }); + + it("should reject values that aren't installations", async () => { + const client = new FakeRedis(); + const store = new RedisInstallationStore({ client }); + + await client.set(W, 'not JSON'); + await assert.rejects(store.fetchInstallation(team()), /slack:installation:team:T1 doesn't hold JSON/); + await client.set(W, '[]'); + await assert.rejects(store.fetchInstallation(team()), /slack:installation:team:T1 doesn't hold an installation/); + sinon.stub(client, 'get').resolves(undefined); + await assert.rejects(store.fetchInstallation(team()), /resolved undefined from get\(\)/); + }); + + it('should log only at the debug level, without tokens', async () => { + const logger = spyLogger(); + const store = new RedisInstallationStore({ client: new FakeRedis() }); + + await store.storeInstallation(installation(user('U1'), bot()), logger); + await store.fetchInstallation(team('U1'), logger); + await store.deleteInstallation(team(), logger); + + assert.ok(logger.debug.called); + assert.ok(logger.info.notCalled && logger.warn.notCalled && logger.error.notCalled); + assert.doesNotMatch(JSON.stringify(logger.debug.args), /xox/); + }); +}); diff --git a/packages/oauth/src/installation-stores/redis-store.ts b/packages/oauth/src/installation-stores/redis-store.ts new file mode 100644 index 000000000..fb62db1b7 --- /dev/null +++ b/packages/oauth/src/installation-stores/redis-store.ts @@ -0,0 +1,278 @@ +import type { Installation, InstallationQuery, InstallationStore } from '../index'; +import type { Logger } from '../logger'; + +/** + * @description The Redis commands that `RedisInstallationStore` uses. The clients from `redis` (node-redis v4 and + * later), `ioredis`, `iovalkey` and `@upstash/redis` match this interface as they are, so you can pass one directly. + * Every call uses a single key, so the store also works with Redis Cluster. + * @see {@link https://docs.slack.dev/tools/node-slack-sdk/oauth/#using-redisinstallationstore Using RedisInstallationStore} + */ +export interface RedisInstallationStoreClient { + /** + * @description Resolves the value at `key`, or `null` when there is none. A string, bytes, or a value the client + * already parsed as JSON (like `@upstash/redis` does) all work. + */ + get(key: string): Promise; + /** @description Sets the value at `key`. */ + set(key: string, value: string): Promise; + /** @description Deletes `key`. */ + del(key: string): Promise; + /** @description Appends `value` to the value at `key`, creating it when there is none. */ + append(key: string, value: string): Promise; +} + +/** + * @description Options for `RedisInstallationStore`. + * @see {@link https://docs.slack.dev/tools/node-slack-sdk/oauth/#using-redisinstallationstore Using RedisInstallationStore} + */ +export interface RedisInstallationStoreOptions { + /** @description A connected Redis client. You create, connect and close it. */ + client: RedisInstallationStoreClient; + /** @description The start of every key the store uses. Defaults to `slack:installation`. */ + keyPrefix?: string; + /** @description Your app's client ID, to keep the installations of apps that share one Redis apart. */ + clientId?: string; +} + +// What the store last read or wrote for an installation, kept by the installation's user and bot objects. +// InstallProvider#authorize() saves refreshed tokens on a copy of what fetchInstallation() returned that keeps those +// objects, so the store can tell who installed the app and which records changed. +interface Saved { + key: string; + installerId: string | undefined; + workspace: string; + userId: string | undefined; + user: string | undefined; +} + +type Action = 'save' | 'fetch' | 'delete'; + +/** + * @description An installation store that saves installations in Redis, with any client that matches + * `RedisInstallationStoreClient`. For each workspace, or for each organization with org-wide installs, it keeps: + * + * - `[:]:team:` (or `:enterprise:`): the latest installation with a bot, + * without user tokens + * - `<…>:nobot`: the latest installation without a bot, for apps that only request user scopes + * - `<…>:user:`: the user token of each user who authorized the app + * - `<…>:users`: the IDs of those users, so deleting a workspace deletes their tokens too + * + * A lookup with a user ID gets that user's token, or no user token when that user never authorized the app. A lookup + * without one gets the token of the user who installed the app. + * @example + * import { InstallProvider, RedisInstallationStore } from '@slack/oauth'; + * import { createClient } from 'redis'; + * + * const client = createClient({ url: process.env.REDIS_URL }); + * await client.connect(); + * const installer = new InstallProvider({ + * clientId: process.env.SLACK_CLIENT_ID, + * clientSecret: process.env.SLACK_CLIENT_SECRET, + * stateSecret: process.env.SLACK_STATE_SECRET, + * installationStore: new RedisInstallationStore({ client, clientId: process.env.SLACK_CLIENT_ID }), + * }); + * @see {@link https://docs.slack.dev/tools/node-slack-sdk/oauth/#using-redisinstallationstore Using RedisInstallationStore} + */ +export default class RedisInstallationStore implements InstallationStore { + private readonly client: RedisInstallationStoreClient; + + private readonly keyPrefix: string; + + private readonly saved = new WeakMap(); + + public constructor(options: RedisInstallationStoreOptions) { + const { client, keyPrefix = 'slack:installation', clientId } = options; + if (!(['get', 'set', 'del', 'append'] as const).every((method) => typeof client?.[method] === 'function')) { + throw new TypeError('RedisInstallationStore needs a Redis client with get(), set(), del() and append() methods'); + } + this.client = client; + this.keyPrefix = clientId !== undefined ? `${keyPrefix}:${clientId}` : keyPrefix; + } + + public async storeInstallation(installation: Installation, logger?: Logger): Promise { + const { isEnterpriseInstall, enterprise, team, bot, user } = installation; + const keys = this.keysFor(isEnterpriseInstall, enterprise?.id, team?.id, 'save'); + const saved = this.savedFor(installation, keys.workspace); + + // Saving what fetchInstallation() returned, like a token refresh does, keeps the user who installed the app. + const installerId = saved ? saved.installerId : user?.id; + const workspace = JSON.stringify({ ...installation, user: { id: installerId } }); + const record = user?.token ? JSON.stringify(user) : undefined; + const writes: (() => Promise)[] = []; + + // Records that didn't change aren't written, so a request whose refresh of one token failed doesn't overwrite + // the token that another request just refreshed. + if (workspace !== saved?.workspace) { + // An installation without a bot, like an authorization with only user scopes, never replaces one with a bot. + const key = bot ? keys.workspace : keys.noBot; + writes.push(() => this.client.set(key, workspace)); + } + if (user && record !== undefined) { + const key = keys.user(user.id); + if (saved === undefined || saved.userId !== user.id) { + // Index the user before saving their token, so deleting the workspace can't miss it. The index only grows, + // and deleting the workspace skips the IDs it lists twice. + writes.push(async () => { + await this.client.append(keys.users, `,${user.id}`); + await this.client.set(key, record); + }); + } else if (record !== saved.user) { + writes.push(() => this.client.set(key, record)); + } + } + + logger?.debug(`Saving ${writes.length} record(s) of the installation at ${keys.workspace}`); + const results = await Promise.allSettled(writes.map(async (write) => write())); + const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failure !== undefined) { + // The client's error isn't kept as the cause, since some clients keep the command they sent, tokens included, + // in their errors. + throw new Error(`Failed to save installation to RedisInstallationStore (error: ${redact(failure.reason)})`); + } + this.remember(installation, { + key: keys.workspace, + installerId, + workspace, + userId: record !== undefined ? user?.id : saved?.userId, + user: record ?? saved?.user, + }); + } + + public async fetchInstallation(query: InstallationQuery, logger?: Logger): Promise { + const { enterpriseId, teamId, userId, isEnterpriseInstall } = query; + const keys = this.keysFor(isEnterpriseInstall, enterpriseId, teamId, 'fetch'); + logger?.debug(`Fetching installation from ${keys.workspace} with the following query: ${JSON.stringify(query)}`); + + let installation: Installation | undefined; + try { + const [withBot, requester] = await Promise.all([ + this.read(keys.workspace), + userId ? this.read(keys.user(userId)) : undefined, + ]); + const workspace = (withBot ?? (await this.read(keys.noBot))) as Installation | undefined; + if (workspace !== undefined) { + const installerId = workspace.user?.id; + // Only a user who authorized the app gets a user token. A lookup without a user gets the installer's, like + // with FileInstallationStore. + let user = requester; + if (!userId && installerId) { + user = await this.read(keys.user(installerId)); + } + const fetched = { ...workspace, user: user ?? { id: installerId } } as Installation; + this.remember(fetched, { + key: keys.workspace, + installerId, + workspace: JSON.stringify({ ...workspace, user: { id: installerId } }), + userId: user?.id as string | undefined, + user: user && JSON.stringify(user), + }); + installation = fetched; + } + } catch (error) { + throw new Error(`Failed to fetch installation from RedisInstallationStore (error: ${error})`, { cause: error }); + } + + if (installation === undefined) { + throw new Error( + `No installation data found (enterprise_id: ${enterpriseId}, team_id: ${teamId}, user_id: ${userId})`, + ); + } + return installation; + } + + public async deleteInstallation(query: InstallationQuery, logger?: Logger): Promise { + const { enterpriseId, teamId, userId, isEnterpriseInstall } = query; + const keys = this.keysFor(isEnterpriseInstall, enterpriseId, teamId, 'delete'); + logger?.debug(`Deleting installation at ${keys.workspace} with the following query: ${JSON.stringify(query)}`); + + try { + if (userId !== undefined) { + await this.client.del(keys.user(userId)); + return; + } + const users = decode(await this.client.get(keys.users)); + const userIds = new Set(users === null ? [] : String(users).split(',').filter(Boolean)); + await Promise.all([...userIds].map((id) => this.client.del(keys.user(id)))); + // The installation goes last, so a delete that fails partway can run again. + await this.client.del(keys.users); + await this.client.del(keys.noBot); + await this.client.del(keys.workspace); + } catch (error) { + throw new Error(`Failed to delete installation from RedisInstallationStore (error: ${error})`, { cause: error }); + } + } + + private savedFor({ user, bot }: Installation, key: string): Saved | undefined { + const saved = (user && this.saved.get(user)) || (bot && this.saved.get(bot)) || undefined; + return saved?.key === key ? saved : undefined; + } + + private remember({ user, bot }: Installation, saved: Saved): void { + for (const part of [user, bot]) { + if (typeof part === 'object' && part !== null) { + this.saved.set(part, saved); + } + } + } + + private keysFor( + isEnterpriseInstall: boolean | undefined, + enterpriseId: string | undefined, + teamId: string | undefined, + action: Action, + ) { + let id: string; + if (isEnterpriseInstall) { + if (!enterpriseId) { + throw new Error(`enterpriseId is required to ${action} data of an enterprise installation`); + } + id = `enterprise:${enterpriseId}`; + } else { + if (!teamId) { + throw new Error(`teamId is required to ${action} data of a workspace installation`); + } + id = `team:${teamId}`; + } + const workspace = `${this.keyPrefix}:${id}`; + return { + workspace, + noBot: `${workspace}:nobot`, + users: `${workspace}:users`, + user: (userId: string) => `${workspace}:user:${userId}`, + }; + } + + private async read(key: string): Promise | undefined> { + const value = decode(await this.client.get(key)); + if (value === null) { + return undefined; + } + if (value === undefined) { + throw new TypeError( + 'The Redis client resolved undefined from get(). RedisInstallationStore needs a client whose commands return promises, which resolve null for a missing key.', + ); + } + let record = value; + if (typeof value === 'string') { + try { + record = JSON.parse(value); + } catch { + throw new Error(`${key} doesn't hold JSON`); + } + } + if (typeof record !== 'object' || record === null || Array.isArray(record)) { + throw new TypeError(`${key} doesn't hold an installation`); + } + return record as Record; + } +} + +// Some clients, like @upstash/redis, put the command they sent in their error messages. +function redact(error: unknown): string { + return String(error).replace(/xox[a-z.]*-[\w.-]+/gi, '[redacted]'); +} + +// Some clients return bytes instead of strings. +function decode(value: unknown): unknown { + return value instanceof Uint8Array ? new TextDecoder().decode(value) : value; +}