Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions integrations/slack-gateway/browser_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
100 changes: 52 additions & 48 deletions integrations/slack-gateway/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
6 changes: 6 additions & 0 deletions integrations/slack-gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
51 changes: 51 additions & 0 deletions integrations/slack-gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions integrations/slack-gateway/workspace_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
24 changes: 24 additions & 0 deletions ui/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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(<AppModule.App />);

expect(replaceSpy).toHaveBeenCalledWith('/slack-gateway/slack/workspaces');
replaceSpy.mockRestore();
});
});
40 changes: 39 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -8,13 +8,51 @@ 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 (
<BrowserRouter>
<ConfigProvider value={config}>
<BrandingEffects />
<NoticeProvider>
<Routes>
<Route
path="spritz/slack-gateway/*"
element={<LegacySlackGatewayRedirectPage />}
/>
<Route element={<Layout />}>
<Route index element={<ChatPage />} />
<Route path="create" element={<CreatePage />} />
Expand Down
Loading