From fe2fdde8a8ee3576fec598b2b73496dba2c15d67 Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 14 Sep 2026 09:56:35 +0800 Subject: [PATCH 1/5] feat(devin): support port-free manual code login in no-browser mode --- internal/auth/devin/devin_auth.go | 23 +++- internal/auth/devin/devin_auth_test.go | 36 ++++++ sdk/auth/devin.go | 90 ++++++++++++--- sdk/auth/devin_test.go | 152 +++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 16 deletions(-) diff --git a/internal/auth/devin/devin_auth.go b/internal/auth/devin/devin_auth.go index 4e780bd11..cacb4c7fa 100644 --- a/internal/auth/devin/devin_auth.go +++ b/internal/auth/devin/devin_auth.go @@ -68,10 +68,29 @@ func (s *DevinAuthService) SetServerBaseURL(url string) { } } -// BuildAuthorizationURL constructs the PKCE login URL. +// SetAPIBaseURL overrides the token exchange and profile API base URL (used in tests). +func (s *DevinAuthService) SetAPIBaseURL(url string) { + if s != nil && strings.TrimSpace(url) != "" { + s.apiBaseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +// SetAppBaseURL overrides the user-facing OAuth authorization base URL (used in tests). +func (s *DevinAuthService) SetAppBaseURL(url string) { + if s != nil && strings.TrimSpace(url) != "" { + s.appBaseURL = strings.TrimRight(strings.TrimSpace(url), "/") + } +} + +// BuildAuthorizationURL constructs the PKCE login URL. When redirectURI is empty, +// it generates a headless manual code URL (with cli_pkce_marker=1) matching official Devin CLI. func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string { q := url.Values{} - q.Set("redirect_uri", redirectURI) + if strings.TrimSpace(redirectURI) != "" { + q.Set("redirect_uri", strings.TrimSpace(redirectURI)) + } else { + q.Set("cli_pkce_marker", "1") + } q.Set("code_challenge", codeChallenge) q.Set("code_challenge_method", "S256") q.Set("prompt", "select_account") diff --git a/internal/auth/devin/devin_auth_test.go b/internal/auth/devin/devin_auth_test.go index 245437d71..a66506d28 100644 --- a/internal/auth/devin/devin_auth_test.go +++ b/internal/auth/devin/devin_auth_test.go @@ -73,6 +73,9 @@ func TestBuildAuthorizationURL(t *testing.T) { if q.Get("redirect_uri") != "http://127.0.0.1:1234/callback" { t.Errorf("redirect_uri = %q", q.Get("redirect_uri")) } + if q.Get("cli_pkce_marker") != "" { + t.Errorf("cli_pkce_marker should be empty when redirect_uri is provided, got %q", q.Get("cli_pkce_marker")) + } if q.Get("code_challenge") != "test-challenge" { t.Errorf("code_challenge = %q", q.Get("code_challenge")) } @@ -84,6 +87,39 @@ func TestBuildAuthorizationURL(t *testing.T) { } } +func TestBuildAuthorizationURLHeadlessCodeFlow(t *testing.T) { + svc := NewDevinAuthService(nil) + u := svc.BuildAuthorizationURL("", "test-challenge-headless", "state-xyz") + + if !strings.HasPrefix(u, "https://app.devin.ai/auth/cli/continue?") { + t.Fatalf("unexpected url prefix: %s", u) + } + + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("failed to parse auth url: %v", err) + } + q := parsed.Query() + if q.Get("redirect_uri") != "" { + t.Errorf("redirect_uri should be omitted in headless flow, got %q", q.Get("redirect_uri")) + } + if q.Get("cli_pkce_marker") != "1" { + t.Errorf("cli_pkce_marker = %q, want '1'", q.Get("cli_pkce_marker")) + } + if q.Get("code_challenge") != "test-challenge-headless" { + t.Errorf("code_challenge = %q", q.Get("code_challenge")) + } + if q.Get("code_challenge_method") != "S256" { + t.Errorf("code_challenge_method = %q", q.Get("code_challenge_method")) + } + if q.Get("prompt") != "select_account" { + t.Errorf("prompt = %q", q.Get("prompt")) + } + if q.Get("state") != "state-xyz" { + t.Errorf("state = %q", q.Get("state")) + } +} + func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) { // Mock devin API backend mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/sdk/auth/devin.go b/sdk/auth/devin.go index 5b279eb11..d0f67404e 100644 --- a/sdk/auth/devin.go +++ b/sdk/auth/devin.go @@ -19,6 +19,7 @@ import ( // DevinAuthenticator implements OAuth and headless authentication for Devin / Cognition. type DevinAuthenticator struct { CallbackPort int + AuthService *devinauth.DevinAuthService } // NewDevinAuthenticator constructs a new Devin authenticator instance. @@ -60,6 +61,72 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts return nil, fmt.Errorf("devin state generation failed: %w", errState) } + authSvc := a.AuthService + if authSvc == nil { + authSvc = devinauth.NewDevinAuthService(util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second})) + } + + if opts.NoBrowser { + authURL := authSvc.BuildAuthorizationURL("", pkceCodes.CodeChallenge, state) + fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n\n", authURL) + + if opts.Prompt == nil { + return nil, fmt.Errorf("devin authentication in no-browser mode requires an interactive prompt") + } + + promptMsg := "Paste the Devin authorization code or session token directly: " + var authCode string + var rawPastedToken string + + manualInputCh, manualInputErrCh := misc.AsyncPrompt(opts.Prompt, promptMsg) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case errInput := <-manualInputErrCh: + if errInput != nil { + return nil, fmt.Errorf("failed to read devin input: %w", errInput) + } + return nil, fmt.Errorf("no input received") + case input := <-manualInputCh: + trimmed := strings.TrimSpace(input) + trimmed = strings.Trim(trimmed, "\"'") + if trimmed == "" { + return nil, fmt.Errorf("devin authentication canceled: empty input received") + } + + // 1. Direct manual session token paste (supports Devin --force-manual-token-flow) + if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") { + rawPastedToken = trimmed + } else if parsed, errParse := misc.ParseOAuthCallback(trimmed); errParse == nil && parsed != nil && parsed.Code != "" { + // 2. Full callback redirect URL + if state != "" && parsed.State != "" && parsed.State != state { + return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)") + } + authCode = parsed.Code + } else if !strings.ContainsAny(trimmed, " \t\r\n/?#=") { + // 3. Raw authorization code paste + authCode = trimmed + } else { + return nil, fmt.Errorf("unrecognized devin authorization code or token format") + } + } + + var sessionToken string + if rawPastedToken != "" { + sessionToken = devinauth.FormatSessionToken(rawPastedToken) + } else if authCode != "" { + token, errExchange := authSvc.ExchangeCodeForToken(ctx, authCode, pkceCodes.CodeVerifier) + if errExchange != nil { + return nil, fmt.Errorf("failed to exchange devin authorization code: %w", errExchange) + } + sessionToken = devinauth.FormatSessionToken(token) + } else { + return nil, fmt.Errorf("no authorization code or token received") + } + + return authSvc.CreateAuthRecord(ctx, sessionToken) + } + callbackPort := a.CallbackPort if opts.CallbackPort > 0 { callbackPort = opts.CallbackPort @@ -76,24 +143,18 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts _ = oauthServer.Stop(stopCtx) }() - authSvc := devinauth.NewDevinAuthService(util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second})) redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort) authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state) - if !opts.NoBrowser { - fmt.Println("Opening browser for Devin authentication...") - if !browser.IsAvailable() { - log.Warn("No browser available; please open the URL manually") - util.PrintSSHTunnelInstructions(actualPort) - fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) - } else if errOpen := browser.OpenURL(authURL); errOpen != nil { - log.Warnf("Failed to open browser automatically: %v", errOpen) - util.PrintSSHTunnelInstructions(actualPort) - fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) - } - } else { + fmt.Println("Opening browser for Devin authentication...") + if !browser.IsAvailable() { + log.Warn("No browser available; please open the URL manually") util.PrintSSHTunnelInstructions(actualPort) - fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n", authURL) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } else if errOpen := browser.OpenURL(authURL); errOpen != nil { + log.Warnf("Failed to open browser automatically: %v", errOpen) + util.PrintSSHTunnelInstructions(actualPort) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) } fmt.Println("Waiting for Devin authentication callback...") @@ -177,6 +238,7 @@ waitForResult: manualInputCh = nil manualInputErrCh = nil trimmed := strings.TrimSpace(input) + trimmed = strings.Trim(trimmed, "\"'") if trimmed == "" { continue } diff --git a/sdk/auth/devin_test.go b/sdk/auth/devin_test.go index 062e84301..a5b971729 100644 --- a/sdk/auth/devin_test.go +++ b/sdk/auth/devin_test.go @@ -2,8 +2,13 @@ package auth import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" + devinauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/devin" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" ) @@ -43,3 +48,150 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"]) } } + +func TestDevinAuthenticatorHeadlessManualCodeLogin(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/cli/token": + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if body["code"] != "devin-cli-auth-code-123" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid_code"}`)) + return + } + if body["code_verifier"] == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"missing_verifier"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token":"eyJtest.manual.code.token"}`)) + + case "/v3/self": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"user_name":"test-user","user_id":"uid-123","org_id":"org-456"}`)) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + authSvc := devinauth.NewDevinAuthService(mockServer.Client()) + authSvc.SetAPIBaseURL(mockServer.URL) + + authenticator := NewDevinAuthenticator() + authenticator.AuthService = authSvc + cfg := &config.Config{} + + mockPrompt := func(prompt string) (string, error) { + return "devin-cli-auth-code-123", nil + } + + opts := &LoginOptions{ + NoBrowser: true, + Prompt: mockPrompt, + } + + auth, err := authenticator.Login(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("Login failed: %v", err) + } + + if auth.Provider != "devin" { + t.Errorf("auth.Provider = %q, want devin", auth.Provider) + } + expectedToken := "devin-session-token$eyJtest.manual.code.token" + if auth.Attributes["api_key"] != expectedToken { + t.Errorf("api_key = %q, want %q", auth.Attributes["api_key"], expectedToken) + } + expectedLabel := "Devin (test-user)" + if auth.Label != expectedLabel { + t.Errorf("auth.Label = %q, want %q", auth.Label, expectedLabel) + } +} + +func TestDevinAuthenticatorHeadlessManualCallbackURLLogin(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/cli/token": + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if body["code"] != "parsed-callback-code" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"invalid_code"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"token":"eyJtest.callback.token"}`)) + + case "/v3/self": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"user_name":"url-user","user_id":"uid-url","org_id":"org-url"}`)) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + authSvc := devinauth.NewDevinAuthService(mockServer.Client()) + authSvc.SetAPIBaseURL(mockServer.URL) + + authenticator := NewDevinAuthenticator() + authenticator.AuthService = authSvc + cfg := &config.Config{} + + mockPrompt := func(prompt string) (string, error) { + // User accidentally pastes the full callback URL from browser address bar + return "http://127.0.0.1:12345/callback?code=parsed-callback-code", nil + } + + opts := &LoginOptions{ + NoBrowser: true, + Prompt: mockPrompt, + } + + auth, err := authenticator.Login(context.Background(), cfg, opts) + if err != nil { + t.Fatalf("Login failed: %v", err) + } + + expectedToken := "devin-session-token$eyJtest.callback.token" + if auth.Attributes["api_key"] != expectedToken { + t.Errorf("api_key = %q, want %q", auth.Attributes["api_key"], expectedToken) + } + expectedLabel := "Devin (url-user)" + if auth.Label != expectedLabel { + t.Errorf("auth.Label = %q, want %q", auth.Label, expectedLabel) + } +} + +func TestDevinAuthenticatorHeadlessEmptyInputAborts(t *testing.T) { + authenticator := NewDevinAuthenticator() + cfg := &config.Config{} + + mockPrompt := func(prompt string) (string, error) { + return "", nil + } + + opts := &LoginOptions{ + NoBrowser: true, + Prompt: mockPrompt, + } + + _, err := authenticator.Login(context.Background(), cfg, opts) + if err == nil { + t.Fatal("expected error on empty input, got nil") + } + if !strings.Contains(err.Error(), "canceled") { + t.Errorf("expected cancellation error message, got: %v", err) + } +} From 09807c57ea8e0b78dc4058aa6498c535e98aa907 Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 14 Sep 2026 09:59:31 +0800 Subject: [PATCH 2/5] fix(devin): align OAuth authorization URL parameter order with official CLI binary --- internal/auth/devin/devin_auth.go | 32 ++++++++++++++++---------- internal/auth/devin/devin_auth_test.go | 10 ++++++++ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/internal/auth/devin/devin_auth.go b/internal/auth/devin/devin_auth.go index cacb4c7fa..47e754ec1 100644 --- a/internal/auth/devin/devin_auth.go +++ b/internal/auth/devin/devin_auth.go @@ -83,21 +83,29 @@ func (s *DevinAuthService) SetAppBaseURL(url string) { } // BuildAuthorizationURL constructs the PKCE login URL. When redirectURI is empty, -// it generates a headless manual code URL (with cli_pkce_marker=1) matching official Devin CLI. +// it generates a headless manual code URL (with cli_pkce_marker=1) matching the exact +// query parameter ordering of the official Devin CLI binary. func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string { - q := url.Values{} - if strings.TrimSpace(redirectURI) != "" { - q.Set("redirect_uri", strings.TrimSpace(redirectURI)) + trimmedRedirect := strings.TrimSpace(redirectURI) + var queryParts []string + if trimmedRedirect != "" { + queryParts = append(queryParts, "redirect_uri="+url.QueryEscape(trimmedRedirect)) + if state != "" { + queryParts = append(queryParts, "state="+url.QueryEscape(state)) + } + queryParts = append(queryParts, "prompt=select_account") + queryParts = append(queryParts, "code_challenge="+url.QueryEscape(codeChallenge)) + queryParts = append(queryParts, "code_challenge_method=S256") } else { - q.Set("cli_pkce_marker", "1") + if state != "" { + queryParts = append(queryParts, "state="+url.QueryEscape(state)) + } + queryParts = append(queryParts, "prompt=select_account") + queryParts = append(queryParts, "code_challenge="+url.QueryEscape(codeChallenge)) + queryParts = append(queryParts, "code_challenge_method=S256") + queryParts = append(queryParts, "cli_pkce_marker=1") } - q.Set("code_challenge", codeChallenge) - q.Set("code_challenge_method", "S256") - q.Set("prompt", "select_account") - if state != "" { - q.Set("state", state) - } - return fmt.Sprintf("%s/auth/cli/continue?%s", strings.TrimRight(s.appBaseURL, "/"), q.Encode()) + return fmt.Sprintf("%s/auth/cli/continue?%s", strings.TrimRight(s.appBaseURL, "/"), strings.Join(queryParts, "&")) } // ExchangeCodeForToken exchanges the authorization code for a session token. diff --git a/internal/auth/devin/devin_auth_test.go b/internal/auth/devin/devin_auth_test.go index a66506d28..679af88c9 100644 --- a/internal/auth/devin/devin_auth_test.go +++ b/internal/auth/devin/devin_auth_test.go @@ -85,6 +85,11 @@ func TestBuildAuthorizationURL(t *testing.T) { if q.Get("state") != "state-abc" { t.Errorf("state = %q", q.Get("state")) } + + expectedQuery := "redirect_uri=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback&state=state-abc&prompt=select_account&code_challenge=test-challenge&code_challenge_method=S256" + if parsed.RawQuery != expectedQuery { + t.Errorf("raw query = %q, want %q", parsed.RawQuery, expectedQuery) + } } func TestBuildAuthorizationURLHeadlessCodeFlow(t *testing.T) { @@ -118,6 +123,11 @@ func TestBuildAuthorizationURLHeadlessCodeFlow(t *testing.T) { if q.Get("state") != "state-xyz" { t.Errorf("state = %q", q.Get("state")) } + + expectedQuery := "state=state-xyz&prompt=select_account&code_challenge=test-challenge-headless&code_challenge_method=S256&cli_pkce_marker=1" + if parsed.RawQuery != expectedQuery { + t.Errorf("raw query = %q, want %q", parsed.RawQuery, expectedQuery) + } } func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) { From c35db127b059492fe0d20ee9beab176f94d4a147 Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 14 Sep 2026 10:24:01 +0800 Subject: [PATCH 3/5] fix(devin): mock AuthService in headless token test and validate prompt before printing URL --- sdk/auth/devin.go | 9 +++++---- sdk/auth/devin_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/sdk/auth/devin.go b/sdk/auth/devin.go index d0f67404e..02d186e2c 100644 --- a/sdk/auth/devin.go +++ b/sdk/auth/devin.go @@ -67,13 +67,13 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts } if opts.NoBrowser { - authURL := authSvc.BuildAuthorizationURL("", pkceCodes.CodeChallenge, state) - fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n\n", authURL) - if opts.Prompt == nil { return nil, fmt.Errorf("devin authentication in no-browser mode requires an interactive prompt") } + authURL := authSvc.BuildAuthorizationURL("", pkceCodes.CodeChallenge, state) + fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n\n", authURL) + promptMsg := "Paste the Devin authorization code or session token directly: " var authCode string var rawPastedToken string @@ -98,7 +98,8 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") { rawPastedToken = trimmed } else if parsed, errParse := misc.ParseOAuthCallback(trimmed); errParse == nil && parsed != nil && parsed.Code != "" { - // 2. Full callback redirect URL + // 2. Full callback redirect URL. In headless mode, if state is present in the pasted URL, + // ensure it matches. PKCE verifier protects the code exchange against CSRF. if state != "" && parsed.State != "" && parsed.State != state { return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)") } diff --git a/sdk/auth/devin_test.go b/sdk/auth/devin_test.go index a5b971729..fa7d5ddab 100644 --- a/sdk/auth/devin_test.go +++ b/sdk/auth/devin_test.go @@ -24,7 +24,22 @@ func TestDevinAuthenticatorProviderAndRefreshLead(t *testing.T) { } func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v3/self": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"user_name":"token-user","user_id":"uid-token","org_id":"org-token"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + authSvc := devinauth.NewDevinAuthService(mockServer.Client()) + authSvc.SetAPIBaseURL(mockServer.URL) + authenticator := NewDevinAuthenticator() + authenticator.AuthService = authSvc cfg := &config.Config{} mockPrompt := func(prompt string) (string, error) { @@ -47,6 +62,27 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { if auth.Attributes["api_key"] != "devin-session-token$eyJmock.session.token" { t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"]) } + if auth.Label != "Devin (token-user)" { + t.Errorf("auth.Label = %q, want 'Devin (token-user)'", auth.Label) + } +} + +func TestDevinAuthenticatorHeadlessPromptNil(t *testing.T) { + authenticator := NewDevinAuthenticator() + cfg := &config.Config{} + + opts := &LoginOptions{ + NoBrowser: true, + Prompt: nil, + } + + _, err := authenticator.Login(context.Background(), cfg, opts) + if err == nil { + t.Fatal("expected error when prompt is nil, got nil") + } + if !strings.Contains(err.Error(), "requires an interactive prompt") { + t.Errorf("unexpected error message: %v", err) + } } func TestDevinAuthenticatorHeadlessManualCodeLogin(t *testing.T) { From f465ebdddef6d6335f6440a94ac42bdd6b5f026b Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 14 Sep 2026 11:26:08 +0800 Subject: [PATCH 4/5] test(devin): route status calls to mock server in headless auth tests --- sdk/auth/devin_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sdk/auth/devin_test.go b/sdk/auth/devin_test.go index fa7d5ddab..1448b156c 100644 --- a/sdk/auth/devin_test.go +++ b/sdk/auth/devin_test.go @@ -29,6 +29,8 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { case "/v3/self": w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"user_name":"token-user","user_id":"uid-token","org_id":"org-token"}`)) + case devinauth.DevinGetUserStatusPath: + w.WriteHeader(http.StatusServiceUnavailable) default: w.WriteHeader(http.StatusNotFound) } @@ -37,6 +39,7 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { authSvc := devinauth.NewDevinAuthService(mockServer.Client()) authSvc.SetAPIBaseURL(mockServer.URL) + authSvc.SetServerBaseURL(mockServer.URL) authenticator := NewDevinAuthenticator() authenticator.AuthService = authSvc @@ -111,6 +114,9 @@ func TestDevinAuthenticatorHeadlessManualCodeLogin(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"user_name":"test-user","user_id":"uid-123","org_id":"org-456"}`)) + case devinauth.DevinGetUserStatusPath: + w.WriteHeader(http.StatusServiceUnavailable) + default: w.WriteHeader(http.StatusNotFound) } @@ -119,6 +125,7 @@ func TestDevinAuthenticatorHeadlessManualCodeLogin(t *testing.T) { authSvc := devinauth.NewDevinAuthService(mockServer.Client()) authSvc.SetAPIBaseURL(mockServer.URL) + authSvc.SetServerBaseURL(mockServer.URL) authenticator := NewDevinAuthenticator() authenticator.AuthService = authSvc @@ -172,6 +179,9 @@ func TestDevinAuthenticatorHeadlessManualCallbackURLLogin(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"user_name":"url-user","user_id":"uid-url","org_id":"org-url"}`)) + case devinauth.DevinGetUserStatusPath: + w.WriteHeader(http.StatusServiceUnavailable) + default: w.WriteHeader(http.StatusNotFound) } @@ -180,6 +190,7 @@ func TestDevinAuthenticatorHeadlessManualCallbackURLLogin(t *testing.T) { authSvc := devinauth.NewDevinAuthService(mockServer.Client()) authSvc.SetAPIBaseURL(mockServer.URL) + authSvc.SetServerBaseURL(mockServer.URL) authenticator := NewDevinAuthenticator() authenticator.AuthService = authSvc From 5f74accd0e8328e8587fd5a736ace7eafa77c41f Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 14 Sep 2026 16:54:07 +0800 Subject: [PATCH 5/5] fix(devin): share manual paste parsing and surface oauth callback errors Extract parseDevinManualPaste for headless and loopback login, return access_denied (and CSRF mismatches) instead of an unrecognized-format error, collapse authorization URL query construction, and lock the headless URL shape plus paste edge cases in tests. --- internal/auth/devin/devin_auth.go | 23 ++- internal/auth/devin/devin_auth_test.go | 19 +++ sdk/auth/devin.go | 104 +++++++------ sdk/auth/devin_test.go | 195 +++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 59 deletions(-) diff --git a/internal/auth/devin/devin_auth.go b/internal/auth/devin/devin_auth.go index 47e754ec1..93ac53b8c 100644 --- a/internal/auth/devin/devin_auth.go +++ b/internal/auth/devin/devin_auth.go @@ -90,19 +90,16 @@ func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, sta var queryParts []string if trimmedRedirect != "" { queryParts = append(queryParts, "redirect_uri="+url.QueryEscape(trimmedRedirect)) - if state != "" { - queryParts = append(queryParts, "state="+url.QueryEscape(state)) - } - queryParts = append(queryParts, "prompt=select_account") - queryParts = append(queryParts, "code_challenge="+url.QueryEscape(codeChallenge)) - queryParts = append(queryParts, "code_challenge_method=S256") - } else { - if state != "" { - queryParts = append(queryParts, "state="+url.QueryEscape(state)) - } - queryParts = append(queryParts, "prompt=select_account") - queryParts = append(queryParts, "code_challenge="+url.QueryEscape(codeChallenge)) - queryParts = append(queryParts, "code_challenge_method=S256") + } + if state != "" { + queryParts = append(queryParts, "state="+url.QueryEscape(state)) + } + queryParts = append(queryParts, + "prompt=select_account", + "code_challenge="+url.QueryEscape(codeChallenge), + "code_challenge_method=S256", + ) + if trimmedRedirect == "" { queryParts = append(queryParts, "cli_pkce_marker=1") } return fmt.Sprintf("%s/auth/cli/continue?%s", strings.TrimRight(s.appBaseURL, "/"), strings.Join(queryParts, "&")) diff --git a/internal/auth/devin/devin_auth_test.go b/internal/auth/devin/devin_auth_test.go index 679af88c9..2c8a00f5d 100644 --- a/internal/auth/devin/devin_auth_test.go +++ b/internal/auth/devin/devin_auth_test.go @@ -130,6 +130,25 @@ func TestBuildAuthorizationURLHeadlessCodeFlow(t *testing.T) { } } +func TestBuildAuthorizationURLWhitespaceRedirectIsHeadless(t *testing.T) { + svc := NewDevinAuthService(nil) + u := svc.BuildAuthorizationURL(" \t", "challenge", "state-ws") + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("failed to parse auth url: %v", err) + } + if parsed.Query().Get("redirect_uri") != "" { + t.Errorf("redirect_uri = %q, want empty", parsed.Query().Get("redirect_uri")) + } + if parsed.Query().Get("cli_pkce_marker") != "1" { + t.Errorf("cli_pkce_marker = %q, want 1", parsed.Query().Get("cli_pkce_marker")) + } + want := "state=state-ws&prompt=select_account&code_challenge=challenge&code_challenge_method=S256&cli_pkce_marker=1" + if parsed.RawQuery != want { + t.Errorf("raw query = %q, want %q", parsed.RawQuery, want) + } +} + func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) { // Mock devin API backend mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/sdk/auth/devin.go b/sdk/auth/devin.go index 02d186e2c..bc8e57458 100644 --- a/sdk/auth/devin.go +++ b/sdk/auth/devin.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "net/http" "strings" @@ -83,32 +84,15 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts case <-ctx.Done(): return nil, ctx.Err() case errInput := <-manualInputErrCh: - if errInput != nil { - return nil, fmt.Errorf("failed to read devin input: %w", errInput) - } - return nil, fmt.Errorf("no input received") + return nil, fmt.Errorf("failed to read devin input: %w", errInput) case input := <-manualInputCh: - trimmed := strings.TrimSpace(input) - trimmed = strings.Trim(trimmed, "\"'") - if trimmed == "" { - return nil, fmt.Errorf("devin authentication canceled: empty input received") + var errPaste error + authCode, rawPastedToken, errPaste = parseDevinManualPaste(input, state) + if errPaste != nil { + return nil, errPaste } - - // 1. Direct manual session token paste (supports Devin --force-manual-token-flow) - if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") { - rawPastedToken = trimmed - } else if parsed, errParse := misc.ParseOAuthCallback(trimmed); errParse == nil && parsed != nil && parsed.Code != "" { - // 2. Full callback redirect URL. In headless mode, if state is present in the pasted URL, - // ensure it matches. PKCE verifier protects the code exchange against CSRF. - if state != "" && parsed.State != "" && parsed.State != state { - return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)") - } - authCode = parsed.Code - } else if !strings.ContainsAny(trimmed, " \t\r\n/?#=") { - // 3. Raw authorization code paste - authCode = trimmed - } else { - return nil, fmt.Errorf("unrecognized devin authorization code or token format") + if authCode == "" && rawPastedToken == "" { + return nil, fmt.Errorf("devin authentication canceled: empty input received") } } @@ -238,31 +222,19 @@ waitForResult: case input := <-manualInputCh: manualInputCh = nil manualInputErrCh = nil - trimmed := strings.TrimSpace(input) - trimmed = strings.Trim(trimmed, "\"'") - if trimmed == "" { - continue - } - - // 1. Direct manual session token paste (supports Devin --force-manual-token-flow) - if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") { - rawPastedToken = trimmed - break waitForResult - } - - // 2. Full callback redirect URL - parsed, errParse := misc.ParseOAuthCallback(trimmed) - if errParse == nil && parsed != nil && parsed.Code != "" { - if state != "" && parsed.State != state { - return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)") + pastedCode, pastedToken, errPaste := parseDevinManualPaste(input, state) + if errPaste != nil { + if errors.Is(errPaste, errDevinUnrecognizedPaste) { + continue } - authCode = parsed.Code + return nil, errPaste + } + if pastedToken != "" { + rawPastedToken = pastedToken break waitForResult } - - // 3. Raw authorization code paste - if !strings.ContainsAny(trimmed, " \t\r\n/?#=") { - authCode = trimmed + if pastedCode != "" { + authCode = pastedCode break waitForResult } @@ -290,3 +262,43 @@ waitForResult: return authSvc.CreateAuthRecord(ctx, sessionToken) } + +var errDevinUnrecognizedPaste = errors.New("unrecognized devin authorization code or token format") + +// parseDevinManualPaste classifies a pasted authorization code, callback URL, or session token. +// Empty input returns empty values with a nil error; callers decide whether to abort or keep waiting. +func parseDevinManualPaste(input, expectedState string) (authCode, rawToken string, err error) { + trimmed := strings.TrimSpace(input) + trimmed = strings.Trim(trimmed, "\"'") + trimmed = strings.TrimSpace(trimmed) + if trimmed == "" { + return "", "", nil + } + + // Direct manual session token paste (supports Devin --force-manual-token-flow). + if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") { + return "", trimmed, nil + } + + if parsed, errParse := misc.ParseOAuthCallback(trimmed); errParse == nil && parsed != nil { + if errMsg := strings.TrimSpace(parsed.Error); errMsg != "" { + if desc := strings.TrimSpace(parsed.ErrorDescription); desc != "" { + errMsg = fmt.Sprintf("%s: %s", errMsg, desc) + } + return "", "", fmt.Errorf("devin oauth error: %s", errMsg) + } + if parsed.Code != "" { + // If state is present in the pasted URL, it must match. PKCE still protects + // a code pasted without state. + if expectedState != "" && parsed.State != "" && parsed.State != expectedState { + return "", "", fmt.Errorf("devin oauth state mismatch (possible CSRF)") + } + return parsed.Code, "", nil + } + } + + if !strings.ContainsAny(trimmed, " \t\r\n/?#=") { + return trimmed, "", nil + } + return "", "", errDevinUnrecognizedPaste +} diff --git a/sdk/auth/devin_test.go b/sdk/auth/devin_test.go index 1448b156c..29a3a3c74 100644 --- a/sdk/auth/devin_test.go +++ b/sdk/auth/devin_test.go @@ -1,10 +1,15 @@ package auth import ( + "bytes" "context" "encoding/json" + "errors" + "io" "net/http" "net/http/httptest" + "net/url" + "os" "strings" "testing" @@ -242,3 +247,193 @@ func TestDevinAuthenticatorHeadlessEmptyInputAborts(t *testing.T) { t.Errorf("expected cancellation error message, got: %v", err) } } + +func TestParseDevinManualPaste(t *testing.T) { + tests := []struct { + name string + input string + expectedState string + wantCode string + wantToken string + wantErr string + wantSentinel error + }{ + {name: "empty", input: " "}, + {name: "quoted empty", input: `" "`}, + {name: "session token", input: "devin-session-token$eyJabc", wantToken: "devin-session-token$eyJabc"}, + {name: "jwt", input: "eyJabc.def", wantToken: "eyJabc.def"}, + {name: "quoted jwt", input: `"eyJabc"`, wantToken: "eyJabc"}, + {name: "raw code", input: "devin-cli-auth-code-123", wantCode: "devin-cli-auth-code-123"}, + {name: "quoted code", input: `"quoted-code-xyz"`, wantCode: "quoted-code-xyz"}, + {name: "callback without state", input: "http://127.0.0.1:9/callback?code=c1", wantCode: "c1"}, + {name: "callback matching state", input: "http://127.0.0.1:9/callback?code=c1&state=abc", expectedState: "abc", wantCode: "c1"}, + {name: "callback mismatched state", input: "http://127.0.0.1:9/callback?code=stolen-code&state=other", expectedState: "abc", wantErr: "state mismatch"}, + {name: "oauth error", input: "http://127.0.0.1:9/callback?error=access_denied&state=abc", wantErr: "oauth error"}, + {name: "oauth error with description", input: "http://127.0.0.1:9/callback?error=access_denied&error_description=user%20denied", wantErr: "user denied"}, + {name: "unrecognized", input: "not a code, has spaces", wantErr: "unrecognized", wantSentinel: errDevinUnrecognizedPaste}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, token, errPaste := parseDevinManualPaste(tt.input, tt.expectedState) + if tt.wantErr != "" { + if errPaste == nil || !strings.Contains(errPaste.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", errPaste, tt.wantErr) + } + if tt.wantSentinel != nil && !errors.Is(errPaste, tt.wantSentinel) { + t.Fatalf("error %v is not %v", errPaste, tt.wantSentinel) + } + return + } + if errPaste != nil { + t.Fatalf("unexpected error: %v", errPaste) + } + if code != tt.wantCode || token != tt.wantToken { + t.Fatalf("code=%q token=%q, want code=%q token=%q", code, token, tt.wantCode, tt.wantToken) + } + }) + } +} + +func TestDevinAuthenticatorHeadlessAuthURL(t *testing.T) { + authenticator := NewDevinAuthenticator() + out := captureStdout(t, func() { + _, _ = authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{ + NoBrowser: true, + Prompt: func(string) (string, error) { return "", nil }, + }) + }) + var authURL string + for _, line := range strings.Split(out, "\n") { + if strings.Contains(line, "/auth/cli/continue?") { + authURL = strings.TrimSpace(line) + break + } + } + if authURL == "" { + t.Fatalf("printed output did not contain auth URL:\n%s", out) + } + parsed, errParse := url.Parse(authURL) + if errParse != nil { + t.Fatal(errParse) + } + q := parsed.Query() + if q.Get("redirect_uri") != "" { + t.Fatalf("headless URL still has redirect_uri=%q", q.Get("redirect_uri")) + } + if q.Get("cli_pkce_marker") != "1" { + t.Fatalf("cli_pkce_marker=%q", q.Get("cli_pkce_marker")) + } + if q.Get("code_challenge_method") != "S256" || q.Get("prompt") != "select_account" || q.Get("state") == "" { + t.Fatalf("unexpected query: %s", parsed.RawQuery) + } + if !strings.HasSuffix(parsed.RawQuery, "&cli_pkce_marker=1") || strings.Contains(parsed.RawQuery, "redirect_uri=") { + t.Fatalf("raw query order/content mismatch: %s", parsed.RawQuery) + } +} + +func TestDevinAuthenticatorHeadlessStateMismatch(t *testing.T) { + var exchanged bool + authSvc := newTestDevinAuthService(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/auth/cli/token" { + exchanged = true + } + w.WriteHeader(http.StatusServiceUnavailable) + }) + authenticator := NewDevinAuthenticator() + authenticator.AuthService = authSvc + + _, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{ + NoBrowser: true, + Prompt: func(string) (string, error) { + return "http://127.0.0.1:9/callback?code=stolen-code&state=other-session", nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "state mismatch") { + t.Fatalf("error = %v, want state mismatch", err) + } + if exchanged { + t.Fatal("token exchange should not run on CSRF mismatch") + } +} + +func TestDevinAuthenticatorHeadlessOAuthErrorURL(t *testing.T) { + authenticator := NewDevinAuthenticator() + _, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{ + NoBrowser: true, + Prompt: func(string) (string, error) { + return "http://127.0.0.1:9/callback?error=access_denied&error_description=user%20denied", nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "access_denied") { + t.Fatalf("error = %v, want access_denied oauth error", err) + } +} + +func TestDevinAuthenticatorHeadlessQuotedCode(t *testing.T) { + authSvc := newTestDevinAuthService(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/cli/token": + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if body["code"] != "quoted-code-xyz" { + t.Errorf("code = %q", body["code"]) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"eyJquoted.token"}`)) + case "/v3/self": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"user_name":"quoted-user"}`)) + case devinauth.DevinGetUserStatusPath: + w.WriteHeader(http.StatusServiceUnavailable) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + authenticator := NewDevinAuthenticator() + authenticator.AuthService = authSvc + + auth, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{ + NoBrowser: true, + Prompt: func(string) (string, error) { return `"quoted-code-xyz"`, nil }, + }) + if err != nil { + t.Fatalf("quoted code login failed: %v", err) + } + if auth.Attributes["api_key"] != "devin-session-token$eyJquoted.token" { + t.Fatalf("api_key = %q", auth.Attributes["api_key"]) + } +} + +func newTestDevinAuthService(t *testing.T, handler http.HandlerFunc) *devinauth.DevinAuthService { + t.Helper() + mockServer := httptest.NewServer(handler) + t.Cleanup(mockServer.Close) + authSvc := devinauth.NewDevinAuthService(mockServer.Client()) + authSvc.SetAPIBaseURL(mockServer.URL) + authSvc.SetServerBaseURL(mockServer.URL) + return authSvc +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, errPipe := os.Pipe() + if errPipe != nil { + t.Fatal(errPipe) + } + old := os.Stdout + os.Stdout = w + fn() + _ = w.Close() + os.Stdout = old + var buf bytes.Buffer + if _, errCopy := io.Copy(&buf, r); errCopy != nil { + t.Fatal(errCopy) + } + _ = r.Close() + return buf.String() +}