feat(devin): support port-free manual code login in no-browser mode

This commit is contained in:
sususu
2026-09-14 09:56:35 +08:00
parent 5f56ce928e
commit fe2fdde8a8
4 changed files with 285 additions and 16 deletions

View File

@@ -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")

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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)
}
}