Skip to content

fix(slack-oauth-backend): deliver refresh token on re-auth (DM + page fallback) - #545

Merged
wkoutre merged 2 commits into
nextfrom
nickkoutrelakos/fix-slack-oauth-refresh-token-delivery
Jun 30, 2026
Merged

fix(slack-oauth-backend): deliver refresh token on re-auth (DM + page fallback)#545
wkoutre merged 2 commits into
nextfrom
nickkoutrelakos/fix-slack-oauth-refresh-token-delivery

Conversation

@wkoutre

@wkoutre wkoutre commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Re-authing against the Slack OAuth backend returns an access token but no refresh token, which breaks the downstream token-rotation keeper (it can no longer refresh, surfacing as opaque 500s).

Two linked causes, both from the Jun 25 2026 security hardening:

  1. routes/oauth.ts hardcoded the success-page refresh-token arg to undefined, so the HTML fallback (shown when the DM fails) no longer carries the refresh token.
  2. DM delivery was gated on the users.info enrichment result (result.user?.id). When enrichment fails, the DM is silently skipped even though authed_user.id is available — so neither channel delivers the refresh token.

Fix

  • Restore the refresh token on the no-store success-page fallback. Only rendered when the DM fails (dmSent ? undefined : result.refreshToken), under the same Cache-Control: no-store, no-cache, must-revalidate, private headers that already protect the access token (also a secret) on that page. Both are HTML-escaped.
  • Decouple DM delivery from users.info. Surface authed_user.id as OAuthResult.userId and send the DM to it, so an enrichment failure no longer skips the DM. When the precondition genuinely can't be met (bot-only install, no authed_user), log a warn (booleans only) instead of skipping silently.
  • Surface the underlying Slack error on DM failure (the error code, never the token) for diagnosability.

Security note

Rendering the refresh token in the success-page fallback is consistent with the access token already rendered there under no-store headers; the "page can be cached" concern the prior commit cited is mitigated by those headers. The prior change broke a real delivery path for a marginal, inconsistent gain.

Test plan

  • nx test slack-oauth-backend: 106 passed (+2 new regression guards: refresh-token-on-page when DM fails; DM sent via authed_user.id when users.info fails; plus a negative test that the refresh token is NOT rendered when the DM succeeds, and a SlackApiError-shaped DM-failure log assertion).
  • nx typecheck slack-oauth-backend: clean.
  • nx lint slack-oauth-backend: 0 errors (38 pre-existing no-explicit-any warnings, out of scope).
  • nx build slack-oauth-backend: compiles.
  • Reviewed via /review-fix-loop (specialist reviewers, two rounds → zero findings); the regression-guard tests were verified to fail against the pre-fix code.

https://claude.ai/code/session_012LLEFGoEDgLpQXRubeMK7w

wkoutre added 2 commits June 29, 2026 17:28
… fallback)

A Jun 2026 security commit hardcoded the success-page refresh token arg to
undefined, and DM delivery was gated on the users.info enrichment result. So a
user re-authing received only an access token and had no way to obtain their
refresh token whenever the DM didn't go out, which in turn breaks the token
keeper's ability to rotate.

- routes/oauth.ts: render the refresh token on the no-store success-page
  fallback again (consistent with the access token already shown there under
  the same no-store/no-cache headers); send the DM using authed_user.id
  (OAuthResult.userId) rather than the enrichment result, so a users.info
  failure no longer silently skips delivery; log the underlying Slack error on
  DM failure for diagnosability.
- oauth/handler.ts + oauth/types.ts: surface authed_user.id as OAuthResult.userId.
- tests: guard the refresh-token-on-page regression and the DM-without-
  enrichment path; update the integration test that asserted the old (gated)
  skip-the-DM behavior.

Claude-Session: https://claude.ai/code/session_012LLEFGoEDgLpQXRubeMK7w
…livery

Round 1+2 review-fix-loop findings on the refresh-token-delivery fix:

- types.ts: correct the userId docstring. It claimed "always present on
  success", but a bot-only install (no user scopes) returns success with
  no authed_user, so userId is undefined. Document the bot-only caveat.
- handler.ts: align the inline userId comment with the same caveat (was
  also claiming "always present here").
- oauth.ts: add an else branch logging a warn when a successful exchange
  has no userId/accessToken, so the bot-only DM-skip is diagnosable
  instead of inferable only from an info breadcrumb. Logs booleans only,
  never a token.
- oauth.spec.ts: add a negative test (no token rendered when the DM
  succeeds, pinning the !dmSent gate) and a test asserting the DM-failure
  log surfaces the Slack error code, not the token. Refactor the logger
  mock to a stable singleton so its spies survive jest.isolateModules.
- oauth-flow.spec.ts: fix a stale comment that contradicted the now-
  asserted "DM is sent" behavior.

Tests 106 pass, typecheck clean, lint 0 errors.

Claude-Session: https://claude.ai/code/session_012LLEFGoEDgLpQXRubeMK7w
@wkoutre
wkoutre requested a review from a team as a code owner June 30, 2026 00:42
@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
ai-toolkit-slack-oauth-backend Ready Ready Preview, Comment Jun 30, 2026 12:43am

Request Review

@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Review complete

Review: DM delivery via authed_user.id + refresh-token fallback

This is a well-constructed, focused fix. Verified across routes/oauth.ts, oauth/handler.ts, oauth/types.ts, messages/formatter.ts, and slack/client.ts (error shapes), plus both test files.

What it fixes (correct)

  1. DM was wrongly gated on enrichment. Delivery previously depended on result.user?.id (from the users.info enrichment), which fails independently of the token exchange — silently dropping the DM. The PR routes delivery through result.userId (authed_user.id from the exchange, handler.ts:140 / oauth.ts:137), decoupling it from enrichment. The integration test at oauth-flow.spec.ts:480 is correctly updated to assert the DM is now sent when enrichment fails.

  2. Refresh token now reaches the user when the DM fails. oauth.ts:197 passes the refresh token to the fallback page. It only renders nested under the access token (formatter.ts:396), under no-store, no-cache, must-revalidate, private headers (oauth.ts:200). Since the access token — an equally sensitive secret — was already shown there under the same headers, this is a consistent, well-justified reversal of the prior "never render the refresh token" stance.

  3. Better diagnostics: the skipped-DM warn (oauth.ts:164) and slackError extraction (oauth.ts:173) surface the underlying Slack error code without leaking the token. I confirmed the extraction matches the real SlackApiError shape: an SDK-thrown platform error is wrapped as DM_ERROR with details.originalError, so details.originalError.data.error resolves to the Slack code as the test pins.

Tests

Comprehensive and pinning the right invariants: refresh-token regression guard, DM-via-authed_user.id-on-enrichment-failure, no-secrets-on-happy-path, and error-code-not-token logging. The stable-singleton logger mock for jest.isolateModules is a sound approach.

Minor (non-blocking)

One small diagnostic gap in the slackError extraction noted inline — for the OPEN_DM_FAILED / SEND_MESSAGE_FAILED branches (where conversations.open/chat.postMessage return ok: false rather than throwing), the granular Slack code lives at details.error, not details.originalError.data.error, so it surfaces the wrapper code instead. This path is rare with the modern SDK (which throws), so it's a nice-to-have, not a blocker.

No bugs, security vulnerabilities, or data-loss risks found. Safe to merge.


💡 Want a fresh review? Add a comment containing @request-claude-review to trigger a new review at any time.

@github-actions

Copy link
Copy Markdown
Contributor

📚 Documentation Check ✅

Verdict: Passed

No plugin files modified (all changes are in apps/slack-oauth-backend/), so the mandatory version-bump rule does not apply. fail_on_missing_docs is false, so the documentation gaps below are advisory warnings only.


PR #545 — Documentation Validation

Changed scope: apps/slack-oauth-backend/ only — no files under packages/plugins/ were touched.

Plugin version bump

Not required. Zero plugin files were modified.

Key behavioral changes in this PR

  1. DM targeting decoupled from users.info enrichmentOAuthResult.userId (new field, sourced directly from authed_user.id in the token exchange) replaces result.user?.id as the DM target. This fixes a silent regression where DMs were skipped whenever users.info failed.
  2. Refresh token now delivered on page fallback — when DM delivery fails, formatSuccessPage now receives the refresh token alongside the access token. Previously the refresh token was always omitted from the fallback page.
  3. Improved DM-failure diagnostics — the error logger now extracts the underlying Slack API error code (channel_not_found, etc.) from the nested error structure instead of logging a generic message.

Documentation gaps found

File Severity Issue
apps/slack-oauth-backend/src/oauth/CLAUDE.md ⚠️ warning OAuthResult example and behavior notes don't document the new userId field or the critical distinction between userId (reliable, from token exchange) and user.id (enrichment, can fail)
apps/slack-oauth-backend/README.md ℹ️ info Security section states "Token delivery only via secure DM" — now inaccurate; there's an explicit page-based fallback with no-store cache headers

Neither gap is blocking given fail_on_missing_docs: false.

Missing Updates

Type File Severity Reason
📘 claude_md apps/slack-oauth-backend/src/oauth/CLAUDE.md ⚠️ warning OAuthResult interface example is outdated: missing refreshToken, botAccessToken, and the new userId field. More importantly it doesn't document the reliability distinction — userId comes from the token exchange (always present on user-token installs) while user comes from users.info enrichment (can fail independently). This distinction is now load-bearing for DM delivery logic.
📄 readme apps/slack-oauth-backend/README.md ℹ️ info Security section bullet 'Token delivery only via secure DM' is now inaccurate. This PR adds an explicit page-based fallback that delivers both the access token and refresh token when DM delivery fails, guarded by no-store cache headers.

Suggestions (2)

💡 Inline suggestions have been posted as review comments. Click "Commit suggestion" to apply each fix directly.

  • ⚠️ apps/slack-oauth-backend/src/oauth/CLAUDE.md: The PR introduces userId as a first-class field on OAuthResult (sourced from authed_user.id in the token exchange) and changes DM delivery to target it instead of user?.id. The CLAUDE.md example still shows an outdated simplified interface that omits refreshToken, botAccessToken, and user, and doesn't convey the reliability distinction between userId and user.id. Updating this prevents future contributors from assuming the two are interchangeable.
  • ℹ️ apps/slack-oauth-backend/README.md: This PR intentionally enables page-based fallback delivery of the refresh token (in addition to the access token) when the DM fails. The README security section previously stated delivery was DM-only, which is now inaccurate and could mislead operators reviewing the security posture.

🤖 Generated by Claude Documentation Validator | Mode: suggest

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Review verdict: APPROVE

👆 The main review comment above is the source of truth for this PR review. It is automatically updated on each review cycle, so always refer to it for the most current feedback.

This formal review submission is for the verdict only. 1 inline comment(s) are attached below.

// instead of vanishing into a generic message.
const slackError =
(dmError as { details?: { originalError?: { data?: { error?: string } } } })?.details
?.originalError?.data?.error ??

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first extraction (details.originalError.data.error) covers the common case where the Slack SDK throws a platform error (wrapped as DM_ERROR), which the test pins. But when conversations.open / chat.postMessage return ok: false, sendDirectMessage throws SlackApiError(..., 'OPEN_DM_FAILED'|'SEND_MESSAGE_FAILED', response) — here the granular Slack code is at details.error (the response's error), and details.originalError is undefined, so this falls through to code and logs the generic wrapper code instead of e.g. user_not_found. Adding a details?.error step before the code fallback would capture both shapes. Non-blocking — these ok: false branches are rarely hit with the modern throwing SDK.

Suggested change
?.originalError?.data?.error ??
const slackError =
(dmError as { details?: { originalError?: { data?: { error?: string } } } })?.details
?.originalError?.data?.error ??
(dmError as { details?: { error?: string } })?.details?.error ??
(dmError as { code?: string })?.code ??
(dmError instanceof Error ? dmError.message : 'unknown');

@wkoutre
wkoutre changed the base branch from main to next June 30, 2026 16:04
@wkoutre
wkoutre merged commit 23e6fc1 into next Jun 30, 2026
21 checks passed
@wkoutre
wkoutre deleted the nickkoutrelakos/fix-slack-oauth-refresh-token-delivery branch June 30, 2026 16:50
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.

1 participant