From faef24e2fdade0e6e2213c87081a9691ffdb180d Mon Sep 17 00:00:00 2001 From: malewis5 <29663600+malewis5@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:54:43 -0400 Subject: [PATCH] feat(oauth): add RedisInstallationStore Add an installation store that saves installations in Redis with the client an app already uses, such as node-redis, ioredis, iovalkey or @upstash/redis, without adding a dependency. The store only needs get, set, del and append, and every command uses one key, so it also works with Redis Cluster. Each workspace's installation and each user's token are separate records. An authorization with only user scopes never replaces the bot token, and when InstallProvider#authorize() saves refreshed tokens, the store keeps the user who installed the app and writes only the records that changed, so a request whose refresh failed can't overwrite a token another request just refreshed. Co-Authored-By: Claude Opus 5.5 --- .changeset/oauth-redis-installation-store.md | 20 + docs/english/oauth.md | 40 ++ .../oauth/src/installation-stores/index.ts | 5 + .../installation-stores/redis-store.test.ts | 551 ++++++++++++++++++ .../src/installation-stores/redis-store.ts | 278 +++++++++ 5 files changed, 894 insertions(+) create mode 100644 .changeset/oauth-redis-installation-store.md create mode 100644 packages/oauth/src/installation-stores/redis-store.test.ts create mode 100644 packages/oauth/src/installation-stores/redis-store.ts 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; +}