Skip to content

Adapter hardening pass#441

Merged
dancer merged 2 commits into
mainfrom
env-var-tightening
May 2, 2026
Merged

Adapter hardening pass#441
dancer merged 2 commits into
mainfrom
env-var-tightening

Conversation

@cramforce

Copy link
Copy Markdown
Collaborator

A round of correctness and configuration tightening across the platform adapters and the example app's OAuth install flows. Each change is small in surface area but several shift defaults, so I've called those out explicitly.

Summary of changes

@chat-adapter/gchat — require explicit verification configuration

The Google Chat adapter previously accepted the construction call when neither googleChatProjectNumber nor pubsubAudience was set, logged a one-shot warning, and then processed every incoming webhook. That made the warning easy to miss in deployment logs and meant the safe configuration was the path of greatest effort.

The constructor now refuses to start unless one of the following is true:

  • googleChatProjectNumber (or GOOGLE_CHAT_PROJECT_NUMBER) is configured for direct webhook JWT verification, or
  • pubsubAudience (or GOOGLE_CHAT_PUBSUB_AUDIENCE) is configured for Pub/Sub push JWT verification, or
  • the new disableSignatureVerification: true flag (or GOOGLE_CHAT_DISABLE_SIGNATURE_VERIFICATION=true) is set.

The thrown ValidationError names all three escape hatches so the resolution is obvious from the message alone. This mirrors what the Slack adapter already does for signingSecret/webhookVerifier.

Two transports share one HTTP endpoint here, so the constructor check alone is not sufficient: a deployment that only sets googleChatProjectNumber would still happily process Pub/Sub-shaped requests, and vice versa. handleWebhook now treats each shape independently — if the verifier for the incoming shape is unconfigured and disableSignatureVerification is not set, the handler returns 401 instead of falling through to processing.

This is a breaking change for any deployment that was relying on the old warn-and-process default. The fix is one env var. We chose loud-at-startup over silent-at-runtime intentionally — the example app (examples/nextjs-chat/src/lib/adapters.ts) catches the throw and the gchat adapter will simply no-op until configured, so it doesn't take down the rest of the bot.

@chat-adapter/github — multi-tenant bot identity auto-detection

In multi-tenant App mode, this.octokit is null at construction time and only populated lazily in getOctokit() per installation. The previous initialize() only fetched _botUserId when a global Octokit existed — so multi-tenant bots ran with _botUserId === null, which meant parseAuthor()'s isMe: user.id === this._botUserId check was always false (real GitHub user IDs are positive integers).

For bots that respond to events that include their own replies, this caused self-message detection to silently fail and let the bot loop on its own messages.

The fix:

  • New private detectBotUserId(octokit) helper called eagerly the first time a per-installation Octokit is created in getOctokit(), and synchronously inside handleWebhook() once the installation ID is known so isMe works on the very first dispatch.
  • users.getAuthenticated is the primary path; if that fails (some installation tokens don't have access to /user), it falls back to apps.getAuthenticated + users.getByUsername for ${slug}[bot].
  • removeReaction()'s ad-hoc lazy detection was deduplicated into the same helper.

Single-tenant and PAT modes are unaffected — initialize() still does the same single fetch it always did, just routed through the new helper.

@chat-adapter/slack — constant-time compare on socket forwarding token

handleWebhook validated the x-slack-socket-token header with !==, while every other auth check in the same file routed through crypto.timingSafeEqual. Switched to a small timingSafeStringEqual helper that buffers both sides and length-checks before delegating to timingSafeEqual, matching verifySignature's pattern.

No behavior change for callers — same 401/200 outcomes for the same inputs.

@chat-adapter/linear — optional at-rest encryption for OAuth tokens

The Linear adapter persists per-organization accessToken and refreshToken values to whatever StateAdapter the chat instance is configured with. Previously these went in plaintext; the Slack adapter has had AES-256-GCM token encryption support since the Slack OAuth work landed.

This PR brings Linear to parity:

  • New optional encryptionKey config field (or LINEAR_ENCRYPTION_KEY env var). Accepts the same hex-64 / base64-44 formats as the Slack adapter.
  • setInstallation() encrypts accessToken and refreshToken before writing when a key is configured.
  • getInstallation() decrypts on read; it tolerates legacy plaintext records (encryption key set, but stored value isn't an EncryptedTokenData envelope), which lets operators rotate encryption in without flushing existing installs.
  • Without an encryptionKey, behavior is identical to before — plaintext storage, no changes to the on-disk format.

To avoid duplicating the AES-GCM primitives across Slack and Linear, the actual encryption helpers (encryptToken, decryptToken, decodeKey, isEncryptedTokenData, EncryptedTokenData) moved from packages/adapter-slack/src/crypto.ts into packages/adapter-shared/src/crypto.ts. Slack's crypto.ts re-exports from shared, so the public surface (including the decodeKey and EncryptedTokenData re-exports from @chat-adapter/slack) is unchanged.

@chat-adapter/shared — token encryption helpers

New module: packages/adapter-shared/src/crypto.ts. AES-256-GCM with a randomly generated 12-byte IV per encryption, exported from the package entry point so any adapter that needs to encrypt tokens can pull it in instead of re-implementing.

examples/nextjs-chat — OAuth state on Slack and Linear install flows

Both /api/slack/install and /api/linear/install now generate a 32-byte opaque state value, stash it in an HttpOnly + SameSite=Lax cookie scoped to the matching callback path with a 10-minute TTL, and redirect to the provider with the value in the authorize URL. The corresponding callback routes read the state cookie, consume it (delete on first read so it can't be replayed), and crypto.timingSafeEqual-compare against the ?state= query parameter. Mismatch returns 400 before handleOAuthCallback is called.

Logic is shared via examples/nextjs-chat/src/lib/oauth-state.ts (issueOAuthState / consumeAndVerifyOAuthState) so any future install flow can adopt the same pattern with two function calls. There is no library-side change here — this is example-app only — but the pattern is what we'd recommend for real deployments.

In-flight install flows started against the old code will fail with OAuth state mismatch after this lands; users just need to restart the flow.

Tests

  • @chat-adapter/slack: two new index.test.ts cases for the timing-safe compare — same-length / wrong-content and length-mismatch — both expect 401.
  • @chat-adapter/github: two new self-message-detection tests covering multi-tenant first-webhook detection and the apps.getAuthenticated fallback path. The existing "should handle auth failure gracefully" test was updated to reflect the new fallback chain (rejects both users.getAuthenticated and apps.getAuthenticated) and the new warning string.
  • @chat-adapter/linear: four new tests under a token encryption describe — ciphertext-on-disk verification, plaintext-when-no-key (legacy behavior preserved), legacy-plaintext-tolerance for zero-downtime key rotation in, and key-length validation.
  • @chat-adapter/gchat: replaced two old "fail-open with warning" tests with new ones covering the constructor throw, the disableSignatureVerification opt-in, and the two cross-transport rejection cases (Pub/Sub-shaped to a googleChatProjectNumber-only adapter, and the inverse). The existing test helpers and module-level env var have been updated so the rest of the file's 240+ tests continue to construct adapters without verification config.
  • @chat-adapter/integration-tests: the three replay/test contexts that construct gchat adapters with mock credentials now pass disableSignatureVerification: true, since these tests inject pre-built event payloads directly and don't exercise the JWT path.

pnpm validate (knip + check + typecheck + test + build) is green across all 16 tasks. A changeset is included.

@cramforce cramforce requested a review from a team as a code owner May 2, 2026 14:46
@vercel

vercel Bot commented May 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat Ready Ready Preview, Comment, Open in v0 May 2, 2026 3:22pm
chat-sdk-nextjs-chat Ready Ready Preview, Comment, Open in v0 May 2, 2026 3:22pm

@dancer dancer merged commit 9824d33 into main May 2, 2026
13 checks passed
@dancer dancer deleted the env-var-tightening branch May 2, 2026 15:37
dancer pushed a commit that referenced this pull request May 4, 2026
…o helpers (#445)

* docs: cover gchat verification, linear token encryption, shared crypto helpers

The adapter hardening pass in #441 made gchat JWT verification required,
added optional at-rest encryption for Linear OAuth tokens, and promoted
the AES-256-GCM helpers into @chat-adapter/shared. Update the affected
package READMEs and the adapter-authoring guide to match.

* docs(adapter-shared): correct EncryptedTokenData field names

---------

Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants