History

fix(devin): use loopback callback endpoint for oauth redirect uri

- Enforce `http://127.0.0.1:<port>/callback` redirect URI to satisfy Devin authorization validation requirements. - Register `/callback` route while preserving `/devin/callback` handler.

Luis Pater committed 2 months ago

Commit cca35ae1 parent 8c5f6e15 files · +33 −15Browse files

Unified · Split

5 files
  1. docs/management-devin-oauth.md +1 −1
  2. internal/api/handlers/management/auth_files_devin_oauth.go +11 −1
  3. internal/api/handlers/management/auth_files_devin_oauth_test.go +1 −1
  4. internal/api/server_devin_oauth_test.go +15 −10
  5. internal/api/server_routes.go +5 −2
docs/management-devin-oauth.md+1−1
@@ -18,7 +18,7 @@ Devin uses the existing management OAuth callback/status contract. No session to · 17 unmodified lines
1818 4. Poll `GET /v0/management/get-auth-status?state=...` with the management key. `status` is `wait`, `ok` after credentials are saved, or `error` with an `error` message. A successful callback submission alone does not mean token exchange has completed.
1919 5. Cancel a pending attempt with `DELETE /v0/management/oauth-session?state=...`.
2020
21−The generated redirect uses the server's configured port and TLS mode at `127.0.0.1:<port>/devin/callback`. When the browser can reach that loopback server (local deployment or an appropriate tunnel), the public callback route accepts the redirect automatically. Remote deployments do not require opening an extra callback port: use step 3 instead. This is a browser authorization-code flow, not a device-code flow.
21+The generated redirect uses the server's configured port at `http://127.0.0.1:<port>/callback` (Devin's authorization page strictly validates that the redirect URI is `http://127.0.0.1:<port>/callback`). When the browser can reach that loopback server (local deployment or an appropriate tunnel), the public callback route accepts the redirect automatically. Remote deployments do not require opening an extra callback port: use step 3 instead. This is a browser authorization-code flow, not a device-code flow.
2222
2323 Authorization must finish within five minutes. Credentials use the same `devin-*.json` format as `--devin-login`, including normalized session tokens, OAuth auth kind, and best-effort profile/plan metadata. Account/quota enrichment failures do not invalidate an otherwise valid session token.
2424
internal/api/handlers/management/auth_files_devin_oauth.go+11−1
@@ -31,9 +31,19 @@ var newDevinOAuthService = func(cfg *config.Config) devinOAuthService { · 30 unmodified lines
3131 return devin.NewDevinAuthService(client)
3232 }
3333
34+// devinCallbackURL builds the required loopback callback URL for Devin OAuth.
35+// Devin's authorization page strictly validates that redirect_uri matches
36+// http://127.0.0.1:<port>/callback (http protocol, 127.0.0.1 host, and /callback path).
37+func (h *Handler) devinCallbackURL() (string, error) {
38+ if h == nil || h.cfg == nil || h.cfg.Port <= 0 {
39+ return "", fmt.Errorf("server port is not configured")
40+ }
41+ return fmt.Sprintf("http://127.0.0.1:%d/callback", h.cfg.Port), nil
42+}
43+
3444 // RequestDevinToken starts the same callback/status flow used by the other WebUI providers.
3545 func (h *Handler) RequestDevinToken(c *gin.Context) {
36− redirectURI, errRedirect := h.managementCallbackURL("/devin/callback")
46+ redirectURI, errRedirect := h.devinCallbackURL()
3747 if errRedirect != nil {
3848 c.JSON(http.StatusInternalServerError, gin.H{"error": "callback server unavailable"})
3949 return
internal/api/handlers/management/auth_files_devin_oauth_test.go+1−1
@@ -90,7 +90,7 @@ func TestDevinRemoteOAuthFlow(t *testing.T) { · 89 unmodified lines
9090 if start.Status != "ok" || start.State == "" || query.Get("state") != start.State || query.Get("code_challenge_method") != "S256" {
9191 t.Fatalf("invalid authorization response: %s", w.Body.String())
9292 }
93− if got := query.Get("redirect_uri"); got != "http://127.0.0.1:8317/devin/callback" {
93+ if got := query.Get("redirect_uri"); got != "http://127.0.0.1:8317/callback" {
9494 t.Fatalf("redirect_uri = %q", got)
9595 }
9696 if query.Get("code_verifier") != "" {
internal/api/server_devin_oauth_test.go+15−10
@@ -32,14 +32,15 @@ func TestDevinOAuthRoutes(t *testing.T) { · 31 unmodified lines
3232 }
3333
3434 for _, test := range []struct {
35− name, provider, query string
36− want int
35+ name, provider, query, callbackPath string
36+ want int
3737 }{
38− {name: "success", provider: "devin", query: "code=test-code", want: http.StatusOK},
39− {name: "denied", provider: "devin", query: "error=access_denied", want: http.StatusOK},
40− {name: "wrong provider", provider: "codex", query: "code=test-code", want: http.StatusBadRequest},
41− {name: "missing code", provider: "devin", want: http.StatusBadRequest},
42− {name: "unknown state", query: "code=test-code", want: http.StatusBadRequest},
38+ {name: "success", provider: "devin", query: "code=test-code", callbackPath: "/callback", want: http.StatusOK},
39+ {name: "success legacy path", provider: "devin", query: "code=test-code", callbackPath: "/devin/callback", want: http.StatusOK},
40+ {name: "denied", provider: "devin", query: "error=access_denied", callbackPath: "/callback", want: http.StatusOK},
41+ {name: "wrong provider", provider: "codex", query: "code=test-code", callbackPath: "/callback", want: http.StatusBadRequest},
42+ {name: "missing code", provider: "devin", callbackPath: "/callback", want: http.StatusBadRequest},
43+ {name: "unknown state", query: "code=test-code", callbackPath: "/callback", want: http.StatusBadRequest},
4344 } {
4445 t.Run(test.name, func(t *testing.T) {
4546 state := "devin-route-" + strings.ReplaceAll(test.name, " ", "-")
@@ -48,12 +49,16 @@ func TestDevinOAuthRoutes(t *testing.T) { · 2 unmodified lines
4849 defer management.CompleteOAuthSession(state)
4950 }
5051 w := httptest.NewRecorder()
51− server.engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/devin/callback?state="+state+"&"+test.query, nil))
52+ path := test.callbackPath
53+ if path == "" {
54+ path = "/callback"
55+ }
56+ server.engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path+"?state="+state+"&"+test.query, nil))
5257 if w.Code != test.want {
5358 t.Fatalf("callback: %d %s", w.Code, w.Body.String())
5459 }
55− path := filepath.Join(server.cfg.AuthDir, ".oauth-devin-"+state+".oauth")
56− data, errRead := os.ReadFile(path)
60+ filePath := filepath.Join(server.cfg.AuthDir, ".oauth-devin-"+state+".oauth")
61+ data, errRead := os.ReadFile(filePath)
5762 if test.want == http.StatusOK {
5863 if errRead != nil {
5964 t.Fatal(errRead)
internal/api/server_routes.go+5−2
@@ -184,7 +184,7 @@ func (s *Server) setupRoutes() { · 183 unmodified lines
184184 c.String(http.StatusOK, oauthCallbackSuccessHTML)
185185 })
186186
187− s.engine.GET("/devin/callback", func(c *gin.Context) {
187+ devinCallbackHandler := func(c *gin.Context) {
188188 c.Header("Cache-Control", "no-store")
189189 code := strings.TrimSpace(c.Query("code"))
190190 state := strings.TrimSpace(c.Query("state"))
@@ -202,7 +202,10 @@ func (s *Server) setupRoutes() { · 11 unmodified lines
202202 }
203203 c.Header("Content-Type", "text/html; charset=utf-8")
204204 c.String(http.StatusOK, oauthCallbackSuccessHTML)
205− })
205+ }
206+
207+ s.engine.GET("/callback", devinCallbackHandler)
208+ s.engine.GET("/devin/callback", devinCallbackHandler)
206209
207210 // Management routes are registered lazily by registerManagementRoutes when a secret is configured.
208211 }