From fe2fdde8a8ee3576fec598b2b73496dba2c15d67 Mon Sep 17 00:00:00 2001 From: sususu Date: Mon, 14 Sep 2026 09:56:35 +0800 Subject: [PATCH] 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) + } +}