Skip to content

feat(oauth): add RedisInstallationStore - #2786

Open
malewis5 wants to merge 1 commit into
slackapi:mainfrom
malewis5:feat-oauth-redis-installation-store
Open

malewis5 wants to merge 1 commit into
slackapi:mainfrom
malewis5:feat-oauth-redis-installation-store

Conversation

@malewis5

Copy link
Copy Markdown

Summary

This adds RedisInstallationStore to @slack/oauth. It's an installation store for production that saves installations in Redis, using the Redis client the app already has. @slack/oauth gains no dependency.

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,
  }),
});

Any client works. The store needs a client with promise-based get, set, del and append methods. Those four commands have the same names in node-redis, ioredis, iovalkey and @upstash/redis, so you can pass any of those clients directly.

The store also accepts values that come back already parsed as JSON (what @upstash/redis does by default) or as bytes. Every command uses a single key, so Redis Cluster works too.

Keys. For each workspace (or org, for org-wide installs), the store writes:

Key Holds
<keyPrefix>[:<clientId>]:team:<teamId> (or :enterprise:<enterpriseId>) The latest installation with a bot, without user tokens
<…>:nobot The latest installation without a bot
<…>:user:<userId> Each user's token
<…>:users The user IDs, as an append-only list. deleteInstallation() uses it to delete every user's token.

Why user tokens get their own keys. FileInstallationStore saves every installation as app-latest, and token refreshes save the whole installation back. That loses tokens in two cases. I reproduced both on main:

  1. A user authorizes only user scopes after the bot was installed. app-latest then has no bot, so authorize() returns no bot token.
  2. With token rotation on, an event from a user who never authorized the app triggers a bot token refresh. fetchInstallation() clears the installer's token fields for that user. authorize() then saves the installation back over user-<installer>-latest, which deletes the installer's user token.

RedisInstallationStore handles these cases differently:

  • An authorization with only user scopes writes :nobot and never replaces the installation that has a bot.

  • fetchInstallation() remembers what it returned, in a WeakMap keyed by the returned user and bot objects. authorize() saves a spread copy that keeps those objects, so the store can recognize it. It keeps the user who installed the app and writes only the records that changed.

    This also means a request whose refresh failed can't overwrite a token that another request just refreshed. authorize() drops failed refreshes but still saves the whole installation back (install-provider.ts refreshExpiringTokens), which is the path this covers.

  • Lookups match FileInstallationStore:

    • With a userId, you get that user's token, or no user token when they never authorized.
    • Without one, you get the installer's token.

Errors and logs.

  • No installation found: the store throws the same "No installation data found" message as FileInstallationStore.
  • Redis errors on fetch and delete: these are wrapped, with cause set.
  • Save errors: these leave out the client's error as cause and redact anything shaped like a token. @upstash/redis puts the command it sent, including the tokens, in its error messages, and ioredis attaches the command's arguments to its errors.
  • Logging: debug level only, with no token values.

Docs and release. The docs add a "Using RedisInstallationStore" section to docs/english/oauth.md, and there's a minor changeset for @slack/oauth.

Testing

  • Unit tests: redis-store.test.ts has 58 tests. The main suite runs once with each of three fake clients: one that stores strings, one that parses JSON like Upstash, and one that returns Buffers.
    • The token refresh tests run through InstallProvider#authorize() with a fake fetch.
    • One of them races two requests where one request's refresh fails.
  • Mutation checks: I broke the store on purpose 17 ways, and the tests caught every one. Examples:
    • ignoring the installer
    • always rewriting records
    • writing tokens before indexing them
    • deleting the index first
    • not decoding bytes
    • not redacting errors
  • Real clients: I ran the same flows against a local Redis 8.6 with node-redis 4.7 and 5.12 (also with RESP3 and with Buffer type mapping), ioredis 5 and 6, and iovalkey. I ran them against Upstash with @upstash/redis 1.39, with automatic deserialization on and off. Every key was cleaned up afterwards.
  • Node versions: the tests pass on Node 20.9, 20.19, 22 and 24. npm test, lint and the build pass.

Limitations

  • Concurrent writes to the same key are last-writer-wins. For example, two successful refreshes of the same token.
  • :users keeps the IDs of users whose tokens were deleted until the workspace is deleted.

Unrelated thing I noticed: the oauth test script runs bash -c '… src/**/*.test.ts' without globstar. As a result it only runs the test files one folder deep. src/install-provider.test.ts and the other top-level src/*.test.ts files don't run under npm test. Happy to send that fix separately.

Requirements

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@malewis5
malewis5 requested review from a team as code owners October 20, 2026 21:55
@changeset-bot

changeset-bot Bot commented Oct 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: faef24e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@slack/oauth Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@hello-ashleyintech

Copy link
Copy Markdown
Contributor

Hi @malewis5, thanks so much for your submission!

I will need to discuss with the team whether the node-slack-sdk repo is the right location for this PR since it adds a bit of code and will increase maintenance needs. Hang tight, and I'll get back to you soon!

@hello-ashleyintech hello-ashleyintech self-assigned this Oct 21, 2026
@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.64664% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 90.44%. Comparing base (8730b5e) to head (faef24e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2786      +/-   ##
==========================================
+ Coverage   89.34%   90.44%   +1.10%     
==========================================
  Files          65       66       +1     
  Lines       10548    10831     +283     
  Branches      480      527      +47     
==========================================
+ Hits         9424     9796     +372     
+ Misses       1092     1004      -88     
+ Partials       32       31       -1     
Flag Coverage Δ
cli-hooks 90.27% <99.64%> (+0.99%) ⬆️
cli-test 90.27% <99.64%> (+0.99%) ⬆️
logger 90.27% <99.64%> (+0.99%) ⬆️
oauth 90.27% <99.64%> (+0.99%) ⬆️
socket-mode 90.27% <99.64%> (+0.99%) ⬆️
web-api 90.27% <99.64%> (+0.99%) ⬆️
webhook 90.27% <99.64%> (+0.99%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants