diff --git a/integrations/slack-gateway/browser_auth.go b/integrations/slack-gateway/browser_auth.go index 90e2c13..ea6d0ab 100644 --- a/integrations/slack-gateway/browser_auth.go +++ b/integrations/slack-gateway/browser_auth.go @@ -5,24 +5,19 @@ import ( "strings" ) -const ( - slackGatewayPrincipalIDHeader = "X-Spritz-User-Id" - slackGatewayPrincipalEmailHeader = "X-Spritz-User-Email" -) - type browserPrincipal struct { ID string Email string } -func requireBrowserPrincipal(w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) { - id := strings.TrimSpace(r.Header.Get(slackGatewayPrincipalIDHeader)) +func requireBrowserPrincipal(cfg config, w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) { + id := strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderID)) if id == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return browserPrincipal{}, false } return browserPrincipal{ ID: id, - Email: strings.TrimSpace(r.Header.Get(slackGatewayPrincipalEmailHeader)), + Email: strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderEmail)), }, true } diff --git a/integrations/slack-gateway/config.go b/integrations/slack-gateway/config.go index 5b4e138..e63aedd 100644 --- a/integrations/slack-gateway/config.go +++ b/integrations/slack-gateway/config.go @@ -9,58 +9,62 @@ import ( ) type config struct { - Addr string - PublicURL string - SlackClientID string - SlackClientSecret string - SlackSigningSecret string - OAuthStateSecret string - SlackAPIBaseURL string - SlackBotScopes []string - PresetID string - BackendBaseURL string - BackendFastAPIBaseURL string - BackendInternalToken string - SpritzBaseURL string - SpritzServiceToken string - PrincipalID string - HTTPTimeout time.Duration - DedupeTTL time.Duration - ProcessingTimeout time.Duration - SessionRetryInterval time.Duration - StatusMessageDelay time.Duration - RecoveryTimeout time.Duration - PromptRetryInitial time.Duration - PromptRetryMax time.Duration - PromptRetryTimeout time.Duration + Addr string + PublicURL string + BrowserAuthHeaderID string + BrowserAuthHeaderEmail string + SlackClientID string + SlackClientSecret string + SlackSigningSecret string + OAuthStateSecret string + SlackAPIBaseURL string + SlackBotScopes []string + PresetID string + BackendBaseURL string + BackendFastAPIBaseURL string + BackendInternalToken string + SpritzBaseURL string + SpritzServiceToken string + PrincipalID string + HTTPTimeout time.Duration + DedupeTTL time.Duration + ProcessingTimeout time.Duration + SessionRetryInterval time.Duration + StatusMessageDelay time.Duration + RecoveryTimeout time.Duration + PromptRetryInitial time.Duration + PromptRetryMax time.Duration + PromptRetryTimeout time.Duration } func loadConfig() (config, error) { cfg := config{ - Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"), - PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL")), "/"), - SlackClientID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_ID")), - SlackClientSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_SECRET")), - SlackSigningSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SIGNING_SECRET")), - OAuthStateSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_OAUTH_STATE_SECRET")), - SlackAPIBaseURL: strings.TrimRight(envOrDefault("SPRITZ_SLACK_API_BASE_URL", "https://slack.com/api"), "/"), - SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history")), - PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)), - BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"), - BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"), - BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")), - SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"), - SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")), - PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")), - HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second), - DedupeTTL: parseDurationEnv("SPRITZ_SLACK_DEDUPE_TTL", 10*time.Minute), - ProcessingTimeout: parseDurationEnv("SPRITZ_SLACK_PROCESSING_TIMEOUT", 120*time.Second), - SessionRetryInterval: parseDurationEnv("SPRITZ_SLACK_SESSION_RETRY_INTERVAL", time.Second), - StatusMessageDelay: parseDurationEnv("SPRITZ_SLACK_STATUS_MESSAGE_DELAY", 5*time.Second), - RecoveryTimeout: parseDurationEnv("SPRITZ_SLACK_RECOVERY_TIMEOUT", 120*time.Second), - PromptRetryInitial: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_INITIAL", 250*time.Millisecond), - PromptRetryMax: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_MAX", 2*time.Second), - PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second), + Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"), + PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL")), "/"), + BrowserAuthHeaderID: envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id"), + BrowserAuthHeaderEmail: envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email"), + SlackClientID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_ID")), + SlackClientSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_SECRET")), + SlackSigningSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SIGNING_SECRET")), + OAuthStateSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_OAUTH_STATE_SECRET")), + SlackAPIBaseURL: strings.TrimRight(envOrDefault("SPRITZ_SLACK_API_BASE_URL", "https://slack.com/api"), "/"), + SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history")), + PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)), + BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"), + BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"), + BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")), + SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"), + SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")), + PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")), + HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second), + DedupeTTL: parseDurationEnv("SPRITZ_SLACK_DEDUPE_TTL", 10*time.Minute), + ProcessingTimeout: parseDurationEnv("SPRITZ_SLACK_PROCESSING_TIMEOUT", 120*time.Second), + SessionRetryInterval: parseDurationEnv("SPRITZ_SLACK_SESSION_RETRY_INTERVAL", time.Second), + StatusMessageDelay: parseDurationEnv("SPRITZ_SLACK_STATUS_MESSAGE_DELAY", 5*time.Second), + RecoveryTimeout: parseDurationEnv("SPRITZ_SLACK_RECOVERY_TIMEOUT", 120*time.Second), + PromptRetryInitial: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_INITIAL", 250*time.Millisecond), + PromptRetryMax: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_MAX", 2*time.Second), + PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second), } if cfg.PublicURL == "" { diff --git a/integrations/slack-gateway/gateway.go b/integrations/slack-gateway/gateway.go index 6cc5546..0a2a7ee 100644 --- a/integrations/slack-gateway/gateway.go +++ b/integrations/slack-gateway/gateway.go @@ -33,6 +33,12 @@ func newSlackGateway(cfg config, logger *slog.Logger) *slackGateway { if cfg.HTTPTimeout <= 0 { cfg.HTTPTimeout = 15 * time.Second } + if strings.TrimSpace(cfg.BrowserAuthHeaderID) == "" { + cfg.BrowserAuthHeaderID = envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id") + } + if strings.TrimSpace(cfg.BrowserAuthHeaderEmail) == "" { + cfg.BrowserAuthHeaderEmail = envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email") + } if cfg.DedupeTTL <= 0 { cfg.DedupeTTL = 10 * time.Minute } diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go index 70ba21c..31b76a5 100644 --- a/integrations/slack-gateway/gateway_test.go +++ b/integrations/slack-gateway/gateway_test.go @@ -366,6 +366,57 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) { } } +func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) { + t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Forwarded-User") + t.Setenv("SPRITZ_AUTH_HEADER_EMAIL", "X-Forwarded-Email") + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v2/spritz/channel-installations/list" { + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": []map[string]any{ + { + "route": map[string]any{ + "principalId": "shared-slack-gateway", + "provider": "slack", + "externalScopeType": "workspace", + "externalTenantId": "T_workspace_1", + }, + "state": "ready", + "currentTarget": map[string]any{ + "id": "ag_workspace", + "profile": map[string]any{ + "name": "Workspace Helper", + }, + "ownerLabel": "Personal", + }, + "allowedActions": []string{"changeTarget", "disconnect"}, + }, + }, + }) + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodGet, "/slack/workspaces", nil) + req.Header.Set("X-Forwarded-User", "user-1") + req.Header.Set("X-Forwarded-Email", "user@example.com") + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } +} + func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) { var listPayload map[string]any backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/integrations/slack-gateway/workspace_handlers.go b/integrations/slack-gateway/workspace_handlers.go index 33555eb..54a510a 100644 --- a/integrations/slack-gateway/workspace_handlers.go +++ b/integrations/slack-gateway/workspace_handlers.go @@ -6,7 +6,7 @@ import ( ) func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.Request) { - principal, ok := requireBrowserPrincipal(w, r) + principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return } @@ -38,7 +38,7 @@ func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Requ } func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) { - principal, ok := requireBrowserPrincipal(w, r) + principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return } @@ -89,7 +89,7 @@ func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *htt } func (g *slackGateway) handleWorkspaceTargetUpdate(w http.ResponseWriter, r *http.Request) { - principal, ok := requireBrowserPrincipal(w, r) + principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return } @@ -151,7 +151,7 @@ func (g *slackGateway) handleWorkspaceDisconnect(w http.ResponseWriter, r *http. http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - principal, ok := requireBrowserPrincipal(w, r) + principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return } diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index c4fcd02..0546efd 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import { ConfigProvider, config } from '@/lib/config'; import { NoticeProvider } from '@/components/notice-banner'; +import * as AppModule from '@/App'; +import { buildLegacySlackGatewayRedirectURL } from '@/App'; // Mock the page components to keep tests simple vi.mock('@/pages/chat', () => ({ @@ -76,4 +78,26 @@ describe('App routing', () => { expect(screen.getByTestId('chat-page')).toBeDefined(); }); + + it('maps legacy Slack gateway SPA paths to the real gateway URL', () => { + expect( + buildLegacySlackGatewayRedirectURL( + '/spritz/slack-gateway/slack/workspaces', + '?teamId=T123', + '#details', + ), + ).toBe('/slack-gateway/slack/workspaces?teamId=T123#details'); + }); + + it('redirects legacy Slack gateway routes instead of rendering a blank page', () => { + const replaceSpy = vi + .spyOn(AppModule.browserLocation, 'replace') + .mockImplementation(() => undefined); + window.history.pushState({}, '', '/spritz/slack-gateway/slack/workspaces'); + + render(); + + expect(replaceSpy).toHaveBeenCalledWith('/slack-gateway/slack/workspaces'); + replaceSpy.mockRestore(); + }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e7bf49a..a617d7b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; import { ConfigProvider, config } from '@/lib/config'; import { BrandingEffects } from '@/components/branding-effects'; import { NoticeProvider } from '@/components/notice-banner'; @@ -8,6 +8,40 @@ import { CreatePage } from '@/pages/create'; import { TerminalPage } from '@/pages/terminal'; import { chatCatchAllRoutePath } from '@/lib/urls'; +/** + * Maps legacy SPA-mounted Slack gateway paths to the real server-rendered + * gateway surface while preserving query and hash fragments. + */ +export function buildLegacySlackGatewayRedirectURL( + pathname: string, + search: string, + hash: string, +): string { + const nextPath = pathname.startsWith('/spritz/') ? pathname.slice('/spritz'.length) : pathname; + return `${nextPath}${search}${hash}`; +} + +/** + * Performs a full-page navigation to a non-SPA route. + */ +export const browserLocation = { + replace(url: string): void { + window.location.replace(url); + }, +}; + +function LegacySlackGatewayRedirectPage() { + const location = useLocation(); + + if (typeof window !== 'undefined') { + browserLocation.replace( + buildLegacySlackGatewayRedirectURL(location.pathname, location.search, location.hash), + ); + } + + return null; +} + export function App() { return ( @@ -15,6 +49,10 @@ export function App() { + } + /> }> } /> } />