Skip to content

Commit 067cdea

Browse files
committed
fix: keep oauth picker on gateway origin
1 parent 41336d2 commit 067cdea

6 files changed

Lines changed: 171 additions & 9 deletions

File tree

docs/2026-04-25-react-management-ui-migration-plan.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,19 +148,28 @@ Target flow:
148148
2. React links to gateway: `/slack-gateway/slack/install`.
149149
3. Gateway redirects to Slack OAuth.
150150
4. Slack calls gateway callback: `/slack-gateway/slack/oauth/callback`.
151-
5. If target selection is needed, gateway redirects to React:
152-
`/settings/slack/install/select`.
151+
5. If target selection is needed and gateway APIs are same-origin/proxied with
152+
React, gateway redirects to React: `/settings/slack/install/select`.
153153
6. React fetches selection data from gateway JSON and submits the selected
154154
opaque target payload back to gateway JSON.
155-
7. Gateway upserts the install and redirects to React result:
155+
7. If target selection is needed but the gateway and React are different
156+
origins, gateway renders the minimal target picker itself so its HTTP-only
157+
pending-state cookie remains readable by the gateway.
158+
8. Gateway upserts the install and redirects to React result:
156159
`/settings/slack/install/result?...`.
157-
8. React renders the result page.
160+
9. React renders the result page.
158161

159162
The opaque OAuth state stays gateway-owned. The callback stores pending
160163
selection state in a short-lived, HTTP-only gateway cookie scoped to the
161164
selection API. React must not receive, parse, or forward Slack token material
162165
or encrypted OAuth state in the URL.
163166

167+
Exception: when the gateway public URL and the Spritz React URL are different
168+
origins and there is no same-origin `/slack-gateway` proxy, that HTTP-only
169+
gateway cookie cannot be sent by browser calls from the React origin. In that
170+
case the gateway must keep the target picker on the gateway origin as a minimal
171+
protocol fallback. Same-origin/proxied deployments should use the React picker.
172+
164173
## Migration Phases
165174

166175
### Phase 1: JSON API parity

integrations/slack-gateway/gateway_test.go

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ func TestOAuthCallbackAutoSelectsSingleInstallTargetAndUpsertsRegistry(t *testin
202202
}
203203
}
204204

205-
func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t *testing.T) {
205+
func TestOAuthCallbackRedirectsToReactInstallTargetPickerWhenSameOrigin(t *testing.T) {
206206
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207207
switch r.URL.Path {
208208
case "/internal/v2/spritz/channel-install-targets/list":
@@ -262,7 +262,7 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t *
262262
SlackBotScopes: []string{"chat:write"},
263263
BackendBaseURL: backend.URL,
264264
BackendInternalToken: "backend-internal-token",
265-
SpritzBaseURL: "https://spritz.example.test",
265+
SpritzBaseURL: "https://gateway.example.test",
266266
SpritzServiceToken: "spritz-service-token",
267267
PrincipalID: "shared-slack-gateway",
268268
HTTPTimeout: 5 * time.Second,
@@ -284,7 +284,7 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t *
284284
if err != nil {
285285
t.Fatalf("parse picker redirect: %v", err)
286286
}
287-
if redirectURL.Scheme != "https" || redirectURL.Host != "spritz.example.test" {
287+
if redirectURL.Scheme != "https" || redirectURL.Host != "gateway.example.test" {
288288
t.Fatalf("expected picker redirect to use Spritz host, got %s", redirectURL.String())
289289
}
290290
if redirectURL.Path != "/settings/slack/install/select" {
@@ -341,6 +341,104 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t *
341341
}
342342
}
343343

344+
func TestOAuthCallbackRendersGatewayInstallTargetPickerWhenCrossOrigin(t *testing.T) {
345+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346+
switch r.URL.Path {
347+
case "/internal/v2/spritz/channel-install-targets/list":
348+
writeJSON(w, http.StatusOK, map[string]any{
349+
"status": "resolved",
350+
"targets": []map[string]any{
351+
{
352+
"id": "ag_123",
353+
"profile": map[string]any{
354+
"name": "Personal Helper",
355+
},
356+
"ownerLabel": "Personal",
357+
"presetInputs": map[string]any{
358+
"agentId": "ag_123",
359+
},
360+
},
361+
{
362+
"id": "ag_456",
363+
"profile": map[string]any{
364+
"name": "Workspace Helper",
365+
},
366+
"ownerLabel": "Workspace",
367+
"presetInputs": map[string]any{
368+
"agentId": "ag_456",
369+
},
370+
},
371+
},
372+
})
373+
case "/internal/v1/spritz/channel-installations/upsert":
374+
t.Fatal("upsert should not happen before the picker selection")
375+
default:
376+
t.Fatalf("unexpected backend path %s", r.URL.Path)
377+
}
378+
}))
379+
defer backend.Close()
380+
381+
slackAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
382+
writeJSON(w, http.StatusOK, map[string]any{
383+
"ok": true,
384+
"app_id": "A_app_1",
385+
"scope": "chat:write",
386+
"access_token": "xoxb-installed",
387+
"bot_user_id": "U_bot",
388+
"team": map[string]any{"id": "T_workspace_1"},
389+
"authed_user": map[string]any{"id": "U_installer"},
390+
})
391+
}))
392+
defer slackAPI.Close()
393+
394+
gateway := newSlackGateway(config{
395+
PublicURL: "https://gateway.example.test",
396+
SlackClientID: "client-id",
397+
SlackClientSecret: "client-secret",
398+
SlackSigningSecret: "signing-secret",
399+
OAuthStateSecret: "oauth-state-secret",
400+
SlackAPIBaseURL: slackAPI.URL,
401+
SlackBotScopes: []string{"chat:write"},
402+
BackendBaseURL: backend.URL,
403+
BackendInternalToken: "backend-internal-token",
404+
SpritzBaseURL: "https://spritz.example.test",
405+
SpritzServiceToken: "spritz-service-token",
406+
PrincipalID: "shared-slack-gateway",
407+
HTTPTimeout: 5 * time.Second,
408+
DedupeTTL: time.Minute,
409+
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
410+
state, err := gateway.state.generate()
411+
if err != nil {
412+
t.Fatalf("state generate failed: %v", err)
413+
}
414+
415+
req := httptest.NewRequest(http.MethodGet, "/slack/oauth/callback?code=test-code&state="+url.QueryEscape(state), nil)
416+
rec := httptest.NewRecorder()
417+
gateway.handleOAuthCallback(rec, req)
418+
419+
if rec.Code != http.StatusOK {
420+
t.Fatalf("expected gateway picker page, got %d: %s", rec.Code, rec.Body.String())
421+
}
422+
body := rec.Body.String()
423+
if !strings.Contains(body, "Choose an install target") {
424+
t.Fatalf("expected picker title, got %q", body)
425+
}
426+
if !strings.Contains(body, "Personal Helper") || !strings.Contains(body, "Workspace Helper") {
427+
t.Fatalf("expected picker targets, got %q", body)
428+
}
429+
if !strings.Contains(body, `/slack/install/select`) {
430+
t.Fatalf("expected picker form action, got %q", body)
431+
}
432+
if strings.Contains(body, "xoxb-installed") {
433+
t.Fatalf("expected picker state to keep bot token encrypted, got %q", body)
434+
}
435+
for _, cookie := range rec.Result().Cookies() {
436+
if strings.HasPrefix(cookie.Name, pendingInstallCookieName) {
437+
t.Fatalf("expected cross-origin picker to avoid pending install cookie, got %#v", cookie)
438+
}
439+
}
440+
}
441+
344442
func TestInstallTargetSelectionAPIUsesRequestScopedPendingCookie(t *testing.T) {
345443
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346444
if r.URL.Path != "/internal/v2/spritz/channel-install-targets/list" {
@@ -7628,6 +7726,30 @@ func TestReactRouteURLUsesSpritzBaseURL(t *testing.T) {
76287726
}
76297727
}
76307728

7729+
func TestReactRoutesShareGatewayOrigin(t *testing.T) {
7730+
sameOrigin := newSlackGateway(
7731+
config{
7732+
PublicURL: "https://spritz.example.test/slack-gateway",
7733+
SpritzBaseURL: "https://spritz.example.test",
7734+
},
7735+
slog.New(slog.NewTextHandler(io.Discard, nil)),
7736+
)
7737+
if !sameOrigin.reactRoutesShareGatewayOrigin() {
7738+
t.Fatal("expected same host and scheme to share gateway origin")
7739+
}
7740+
7741+
crossOrigin := newSlackGateway(
7742+
config{
7743+
PublicURL: "https://gateway.example.test",
7744+
SpritzBaseURL: "https://spritz.example.test",
7745+
},
7746+
slog.New(slog.NewTextHandler(io.Discard, nil)),
7747+
)
7748+
if crossOrigin.reactRoutesShareGatewayOrigin() {
7749+
t.Fatal("expected different hosts not to share gateway origin")
7750+
}
7751+
}
7752+
76317753
func signSlackRequest(header http.Header, signingSecret string, body []byte, now time.Time) {
76327754
timestamp := fmt.Sprintf("%d", now.Unix())
76337755
base := "v0:" + timestamp + ":" + string(body)

integrations/slack-gateway/install_result.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,20 @@ func (g *slackGateway) installRedirectPath() string {
173173
return g.publicPathPrefix() + "/slack/install"
174174
}
175175

176+
func (g *slackGateway) installRedirectURL() string {
177+
base, err := url.Parse(strings.TrimRight(strings.TrimSpace(g.cfg.PublicURL), "/"))
178+
if err != nil || base.Scheme == "" || base.Host == "" {
179+
return g.installRedirectPath()
180+
}
181+
basePath := strings.TrimRight(base.Path, "/")
182+
routePath := "/slack/install"
183+
base.RawPath = ""
184+
base.Path = basePath + routePath
185+
base.RawQuery = ""
186+
base.Fragment = ""
187+
return base.String()
188+
}
189+
176190
func (g *slackGateway) redirectToInstallResult(w http.ResponseWriter, r *http.Request, result installResult) {
177191
target := url.URL{Path: g.installResultPath()}
178192
query := target.Query()

integrations/slack-gateway/management_api.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,9 @@ func (g *slackGateway) handleInstallResultAPI(w http.ResponseWriter, r *http.Req
179179
}
180180

181181
func (g *slackGateway) writeInstallResultAPI(w http.ResponseWriter, status int, result installResult) {
182-
descriptor := installResultDescriptorFor(result.Code, g.installRedirectPath())
182+
descriptor := installResultDescriptorFor(result.Code, g.installRedirectURL())
183183
if result.Status == installResultStatusSuccess && result.Code == installResultCodeInternalError {
184-
descriptor = installResultDescriptorFor(installResultCodeInstalled, g.installRedirectPath())
184+
descriptor = installResultDescriptorFor(installResultCodeInstalled, g.installRedirectURL())
185185
}
186186
writeAPIJSON(w, status, map[string]any{
187187
"status": result.Status,

integrations/slack-gateway/react_routes.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ func (g *slackGateway) reactRouteURL(target string) string {
6767
return base.String()
6868
}
6969

70+
func (g *slackGateway) reactRoutesShareGatewayOrigin() bool {
71+
gatewayURL, err := url.Parse(strings.TrimSpace(g.cfg.PublicURL))
72+
if err != nil || gatewayURL.Scheme == "" || gatewayURL.Host == "" {
73+
return false
74+
}
75+
reactURL, err := url.Parse(strings.TrimSpace(g.cfg.SpritzBaseURL))
76+
if err != nil || reactURL.Scheme == "" || reactURL.Host == "" {
77+
return false
78+
}
79+
return strings.EqualFold(gatewayURL.Scheme, reactURL.Scheme) &&
80+
strings.EqualFold(gatewayURL.Host, reactURL.Host)
81+
}
82+
7083
func (g *slackGateway) redirectToReactRoute(w http.ResponseWriter, r *http.Request, target string) {
7184
http.Redirect(w, r, g.reactRouteURL(target), http.StatusSeeOther)
7285
}

integrations/slack-gateway/slack_oauth.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ func (g *slackGateway) handleOAuthCallback(w http.ResponseWriter, r *http.Reques
215215
})
216216
return
217217
}
218+
if !g.reactRoutesShareGatewayOrigin() {
219+
g.renderInstallTargetPicker(w, pendingState, requestID, targets)
220+
return
221+
}
218222
g.setPendingInstallCookie(w, r, requestID, pendingState)
219223
g.redirectToReactRoute(w, r, reactSlackInstallSelectPath(requestID))
220224
return

0 commit comments

Comments
 (0)