Skip to content

Commit 9b3ce00

Browse files
author
Onur
authored
fix(ui): redirect legacy slack workspace route (#225)
* fix(ui): redirect legacy slack workspace route * fix(slack-gateway): honor configured browser auth headers
1 parent 549f237 commit 9b3ce00

7 files changed

Lines changed: 179 additions & 61 deletions

File tree

integrations/slack-gateway/browser_auth.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,19 @@ import (
55
"strings"
66
)
77

8-
const (
9-
slackGatewayPrincipalIDHeader = "X-Spritz-User-Id"
10-
slackGatewayPrincipalEmailHeader = "X-Spritz-User-Email"
11-
)
12-
138
type browserPrincipal struct {
149
ID string
1510
Email string
1611
}
1712

18-
func requireBrowserPrincipal(w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) {
19-
id := strings.TrimSpace(r.Header.Get(slackGatewayPrincipalIDHeader))
13+
func requireBrowserPrincipal(cfg config, w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) {
14+
id := strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderID))
2015
if id == "" {
2116
http.Error(w, "unauthorized", http.StatusUnauthorized)
2217
return browserPrincipal{}, false
2318
}
2419
return browserPrincipal{
2520
ID: id,
26-
Email: strings.TrimSpace(r.Header.Get(slackGatewayPrincipalEmailHeader)),
21+
Email: strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderEmail)),
2722
}, true
2823
}

integrations/slack-gateway/config.go

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -9,58 +9,62 @@ import (
99
)
1010

1111
type config struct {
12-
Addr string
13-
PublicURL string
14-
SlackClientID string
15-
SlackClientSecret string
16-
SlackSigningSecret string
17-
OAuthStateSecret string
18-
SlackAPIBaseURL string
19-
SlackBotScopes []string
20-
PresetID string
21-
BackendBaseURL string
22-
BackendFastAPIBaseURL string
23-
BackendInternalToken string
24-
SpritzBaseURL string
25-
SpritzServiceToken string
26-
PrincipalID string
27-
HTTPTimeout time.Duration
28-
DedupeTTL time.Duration
29-
ProcessingTimeout time.Duration
30-
SessionRetryInterval time.Duration
31-
StatusMessageDelay time.Duration
32-
RecoveryTimeout time.Duration
33-
PromptRetryInitial time.Duration
34-
PromptRetryMax time.Duration
35-
PromptRetryTimeout time.Duration
12+
Addr string
13+
PublicURL string
14+
BrowserAuthHeaderID string
15+
BrowserAuthHeaderEmail string
16+
SlackClientID string
17+
SlackClientSecret string
18+
SlackSigningSecret string
19+
OAuthStateSecret string
20+
SlackAPIBaseURL string
21+
SlackBotScopes []string
22+
PresetID string
23+
BackendBaseURL string
24+
BackendFastAPIBaseURL string
25+
BackendInternalToken string
26+
SpritzBaseURL string
27+
SpritzServiceToken string
28+
PrincipalID string
29+
HTTPTimeout time.Duration
30+
DedupeTTL time.Duration
31+
ProcessingTimeout time.Duration
32+
SessionRetryInterval time.Duration
33+
StatusMessageDelay time.Duration
34+
RecoveryTimeout time.Duration
35+
PromptRetryInitial time.Duration
36+
PromptRetryMax time.Duration
37+
PromptRetryTimeout time.Duration
3638
}
3739

3840
func loadConfig() (config, error) {
3941
cfg := config{
40-
Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"),
41-
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL")), "/"),
42-
SlackClientID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_ID")),
43-
SlackClientSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_SECRET")),
44-
SlackSigningSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SIGNING_SECRET")),
45-
OAuthStateSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_OAUTH_STATE_SECRET")),
46-
SlackAPIBaseURL: strings.TrimRight(envOrDefault("SPRITZ_SLACK_API_BASE_URL", "https://slack.com/api"), "/"),
47-
SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history")),
48-
PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)),
49-
BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"),
50-
BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"),
51-
BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")),
52-
SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"),
53-
SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")),
54-
PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")),
55-
HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second),
56-
DedupeTTL: parseDurationEnv("SPRITZ_SLACK_DEDUPE_TTL", 10*time.Minute),
57-
ProcessingTimeout: parseDurationEnv("SPRITZ_SLACK_PROCESSING_TIMEOUT", 120*time.Second),
58-
SessionRetryInterval: parseDurationEnv("SPRITZ_SLACK_SESSION_RETRY_INTERVAL", time.Second),
59-
StatusMessageDelay: parseDurationEnv("SPRITZ_SLACK_STATUS_MESSAGE_DELAY", 5*time.Second),
60-
RecoveryTimeout: parseDurationEnv("SPRITZ_SLACK_RECOVERY_TIMEOUT", 120*time.Second),
61-
PromptRetryInitial: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_INITIAL", 250*time.Millisecond),
62-
PromptRetryMax: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_MAX", 2*time.Second),
63-
PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second),
42+
Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"),
43+
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL")), "/"),
44+
BrowserAuthHeaderID: envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id"),
45+
BrowserAuthHeaderEmail: envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email"),
46+
SlackClientID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_ID")),
47+
SlackClientSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_SECRET")),
48+
SlackSigningSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SIGNING_SECRET")),
49+
OAuthStateSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_OAUTH_STATE_SECRET")),
50+
SlackAPIBaseURL: strings.TrimRight(envOrDefault("SPRITZ_SLACK_API_BASE_URL", "https://slack.com/api"), "/"),
51+
SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history")),
52+
PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)),
53+
BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"),
54+
BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"),
55+
BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")),
56+
SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"),
57+
SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")),
58+
PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")),
59+
HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second),
60+
DedupeTTL: parseDurationEnv("SPRITZ_SLACK_DEDUPE_TTL", 10*time.Minute),
61+
ProcessingTimeout: parseDurationEnv("SPRITZ_SLACK_PROCESSING_TIMEOUT", 120*time.Second),
62+
SessionRetryInterval: parseDurationEnv("SPRITZ_SLACK_SESSION_RETRY_INTERVAL", time.Second),
63+
StatusMessageDelay: parseDurationEnv("SPRITZ_SLACK_STATUS_MESSAGE_DELAY", 5*time.Second),
64+
RecoveryTimeout: parseDurationEnv("SPRITZ_SLACK_RECOVERY_TIMEOUT", 120*time.Second),
65+
PromptRetryInitial: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_INITIAL", 250*time.Millisecond),
66+
PromptRetryMax: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_MAX", 2*time.Second),
67+
PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second),
6468
}
6569

6670
if cfg.PublicURL == "" {

integrations/slack-gateway/gateway.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ func newSlackGateway(cfg config, logger *slog.Logger) *slackGateway {
3333
if cfg.HTTPTimeout <= 0 {
3434
cfg.HTTPTimeout = 15 * time.Second
3535
}
36+
if strings.TrimSpace(cfg.BrowserAuthHeaderID) == "" {
37+
cfg.BrowserAuthHeaderID = envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id")
38+
}
39+
if strings.TrimSpace(cfg.BrowserAuthHeaderEmail) == "" {
40+
cfg.BrowserAuthHeaderEmail = envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email")
41+
}
3642
if cfg.DedupeTTL <= 0 {
3743
cfg.DedupeTTL = 10 * time.Minute
3844
}

integrations/slack-gateway/gateway_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,57 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) {
366366
}
367367
}
368368

369+
func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) {
370+
t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Forwarded-User")
371+
t.Setenv("SPRITZ_AUTH_HEADER_EMAIL", "X-Forwarded-Email")
372+
373+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
374+
if r.URL.Path != "/internal/v2/spritz/channel-installations/list" {
375+
t.Fatalf("unexpected backend path %s", r.URL.Path)
376+
}
377+
writeJSON(w, http.StatusOK, map[string]any{
378+
"status": "resolved",
379+
"installations": []map[string]any{
380+
{
381+
"route": map[string]any{
382+
"principalId": "shared-slack-gateway",
383+
"provider": "slack",
384+
"externalScopeType": "workspace",
385+
"externalTenantId": "T_workspace_1",
386+
},
387+
"state": "ready",
388+
"currentTarget": map[string]any{
389+
"id": "ag_workspace",
390+
"profile": map[string]any{
391+
"name": "Workspace Helper",
392+
},
393+
"ownerLabel": "Personal",
394+
},
395+
"allowedActions": []string{"changeTarget", "disconnect"},
396+
},
397+
},
398+
})
399+
}))
400+
defer backend.Close()
401+
402+
gateway := newSlackGateway(config{
403+
BackendFastAPIBaseURL: backend.URL,
404+
BackendInternalToken: "backend-internal-token",
405+
PrincipalID: "shared-slack-gateway",
406+
HTTPTimeout: 5 * time.Second,
407+
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
408+
409+
req := httptest.NewRequest(http.MethodGet, "/slack/workspaces", nil)
410+
req.Header.Set("X-Forwarded-User", "user-1")
411+
req.Header.Set("X-Forwarded-Email", "user@example.com")
412+
rec := httptest.NewRecorder()
413+
gateway.routes().ServeHTTP(rec, req)
414+
415+
if rec.Code != http.StatusOK {
416+
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
417+
}
418+
}
419+
369420
func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) {
370421
var listPayload map[string]any
371422
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

integrations/slack-gateway/workspace_handlers.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import (
66
)
77

88
func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.Request) {
9-
principal, ok := requireBrowserPrincipal(w, r)
9+
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
1010
if !ok {
1111
return
1212
}
@@ -38,7 +38,7 @@ func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Requ
3838
}
3939

4040
func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) {
41-
principal, ok := requireBrowserPrincipal(w, r)
41+
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
4242
if !ok {
4343
return
4444
}
@@ -89,7 +89,7 @@ func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *htt
8989
}
9090

9191
func (g *slackGateway) handleWorkspaceTargetUpdate(w http.ResponseWriter, r *http.Request) {
92-
principal, ok := requireBrowserPrincipal(w, r)
92+
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
9393
if !ok {
9494
return
9595
}
@@ -151,7 +151,7 @@ func (g *slackGateway) handleWorkspaceDisconnect(w http.ResponseWriter, r *http.
151151
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
152152
return
153153
}
154-
principal, ok := requireBrowserPrincipal(w, r)
154+
principal, ok := requireBrowserPrincipal(g.cfg, w, r)
155155
if !ok {
156156
return
157157
}

ui/src/App.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
33
import { MemoryRouter, Routes, Route } from 'react-router-dom';
44
import { ConfigProvider, config } from '@/lib/config';
55
import { NoticeProvider } from '@/components/notice-banner';
6+
import * as AppModule from '@/App';
7+
import { buildLegacySlackGatewayRedirectURL } from '@/App';
68

79
// Mock the page components to keep tests simple
810
vi.mock('@/pages/chat', () => ({
@@ -76,4 +78,26 @@ describe('App routing', () => {
7678

7779
expect(screen.getByTestId('chat-page')).toBeDefined();
7880
});
81+
82+
it('maps legacy Slack gateway SPA paths to the real gateway URL', () => {
83+
expect(
84+
buildLegacySlackGatewayRedirectURL(
85+
'/spritz/slack-gateway/slack/workspaces',
86+
'?teamId=T123',
87+
'#details',
88+
),
89+
).toBe('/slack-gateway/slack/workspaces?teamId=T123#details');
90+
});
91+
92+
it('redirects legacy Slack gateway routes instead of rendering a blank page', () => {
93+
const replaceSpy = vi
94+
.spyOn(AppModule.browserLocation, 'replace')
95+
.mockImplementation(() => undefined);
96+
window.history.pushState({}, '', '/spritz/slack-gateway/slack/workspaces');
97+
98+
render(<AppModule.App />);
99+
100+
expect(replaceSpy).toHaveBeenCalledWith('/slack-gateway/slack/workspaces');
101+
replaceSpy.mockRestore();
102+
});
79103
});

ui/src/App.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BrowserRouter, Routes, Route } from 'react-router-dom';
1+
import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
22
import { ConfigProvider, config } from '@/lib/config';
33
import { BrandingEffects } from '@/components/branding-effects';
44
import { NoticeProvider } from '@/components/notice-banner';
@@ -8,13 +8,51 @@ import { CreatePage } from '@/pages/create';
88
import { TerminalPage } from '@/pages/terminal';
99
import { chatCatchAllRoutePath } from '@/lib/urls';
1010

11+
/**
12+
* Maps legacy SPA-mounted Slack gateway paths to the real server-rendered
13+
* gateway surface while preserving query and hash fragments.
14+
*/
15+
export function buildLegacySlackGatewayRedirectURL(
16+
pathname: string,
17+
search: string,
18+
hash: string,
19+
): string {
20+
const nextPath = pathname.startsWith('/spritz/') ? pathname.slice('/spritz'.length) : pathname;
21+
return `${nextPath}${search}${hash}`;
22+
}
23+
24+
/**
25+
* Performs a full-page navigation to a non-SPA route.
26+
*/
27+
export const browserLocation = {
28+
replace(url: string): void {
29+
window.location.replace(url);
30+
},
31+
};
32+
33+
function LegacySlackGatewayRedirectPage() {
34+
const location = useLocation();
35+
36+
if (typeof window !== 'undefined') {
37+
browserLocation.replace(
38+
buildLegacySlackGatewayRedirectURL(location.pathname, location.search, location.hash),
39+
);
40+
}
41+
42+
return null;
43+
}
44+
1145
export function App() {
1246
return (
1347
<BrowserRouter>
1448
<ConfigProvider value={config}>
1549
<BrandingEffects />
1650
<NoticeProvider>
1751
<Routes>
52+
<Route
53+
path="spritz/slack-gateway/*"
54+
element={<LegacySlackGatewayRedirectPage />}
55+
/>
1856
<Route element={<Layout />}>
1957
<Route index element={<ChatPage />} />
2058
<Route path="create" element={<CreatePage />} />

0 commit comments

Comments
 (0)