History

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

sususu committed 2 months ago

Commit fe2fdde1 parent 5f56ce94 files · +285 −16Browse files

Unified · Split

4 files
  1. internal/auth/devin/devin_auth.go +21 −2
  2. internal/auth/devin/devin_auth_test.go +36 −0
  3. sdk/auth/devin.go +76 −14
  4. sdk/auth/devin_test.go +152 −0
internal/auth/devin/devin_auth.go+21−2
@@ -68,10 +68,29 @@ func (s *DevinAuthService) SetServerBaseURL(url string) { · 67 unmodified lines
6868 }
6969 }
7070
71−// BuildAuthorizationURL constructs the PKCE login URL.
71+// SetAPIBaseURL overrides the token exchange and profile API base URL (used in tests).
72+func (s *DevinAuthService) SetAPIBaseURL(url string) {
73+ if s != nil && strings.TrimSpace(url) != "" {
74+ s.apiBaseURL = strings.TrimRight(strings.TrimSpace(url), "/")
75+ }
76+}
77+
78+// SetAppBaseURL overrides the user-facing OAuth authorization base URL (used in tests).
79+func (s *DevinAuthService) SetAppBaseURL(url string) {
80+ if s != nil && strings.TrimSpace(url) != "" {
81+ s.appBaseURL = strings.TrimRight(strings.TrimSpace(url), "/")
82+ }
83+}
84+
85+// BuildAuthorizationURL constructs the PKCE login URL. When redirectURI is empty,
86+// it generates a headless manual code URL (with cli_pkce_marker=1) matching official Devin CLI.
7287 func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string {
7388 q := url.Values{}
74− q.Set("redirect_uri", redirectURI)
89+ if strings.TrimSpace(redirectURI) != "" {
90+ q.Set("redirect_uri", strings.TrimSpace(redirectURI))
91+ } else {
92+ q.Set("cli_pkce_marker", "1")
93+ }
7594 q.Set("code_challenge", codeChallenge)
7695 q.Set("code_challenge_method", "S256")
7796 q.Set("prompt", "select_account")
internal/auth/devin/devin_auth_test.go+36−0
@@ -73,6 +73,9 @@ func TestBuildAuthorizationURL(t *testing.T) { · 72 unmodified lines
7373 if q.Get("redirect_uri") != "http://127.0.0.1:1234/callback" {
7474 t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
7575 }
76+ if q.Get("cli_pkce_marker") != "" {
77+ t.Errorf("cli_pkce_marker should be empty when redirect_uri is provided, got %q", q.Get("cli_pkce_marker"))
78+ }
7679 if q.Get("code_challenge") != "test-challenge" {
7780 t.Errorf("code_challenge = %q", q.Get("code_challenge"))
7881 }
@@ -84,6 +87,39 @@ func TestBuildAuthorizationURL(t *testing.T) { · 5 unmodified lines
8487 }
8588 }
8689
90+func TestBuildAuthorizationURLHeadlessCodeFlow(t *testing.T) {
91+ svc := NewDevinAuthService(nil)
92+ u := svc.BuildAuthorizationURL("", "test-challenge-headless", "state-xyz")
93+
94+ if !strings.HasPrefix(u, "https://app.devin.ai/auth/cli/continue?") {
95+ t.Fatalf("unexpected url prefix: %s", u)
96+ }
97+
98+ parsed, err := url.Parse(u)
99+ if err != nil {
100+ t.Fatalf("failed to parse auth url: %v", err)
101+ }
102+ q := parsed.Query()
103+ if q.Get("redirect_uri") != "" {
104+ t.Errorf("redirect_uri should be omitted in headless flow, got %q", q.Get("redirect_uri"))
105+ }
106+ if q.Get("cli_pkce_marker") != "1" {
107+ t.Errorf("cli_pkce_marker = %q, want '1'", q.Get("cli_pkce_marker"))
108+ }
109+ if q.Get("code_challenge") != "test-challenge-headless" {
110+ t.Errorf("code_challenge = %q", q.Get("code_challenge"))
111+ }
112+ if q.Get("code_challenge_method") != "S256" {
113+ t.Errorf("code_challenge_method = %q", q.Get("code_challenge_method"))
114+ }
115+ if q.Get("prompt") != "select_account" {
116+ t.Errorf("prompt = %q", q.Get("prompt"))
117+ }
118+ if q.Get("state") != "state-xyz" {
119+ t.Errorf("state = %q", q.Get("state"))
120+ }
121+}
122+
87123 func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) {
88124 // Mock devin API backend
89125 mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sdk/auth/devin.go+76−14
@@ -19,6 +19,7 @@ import ( · 18 unmodified lines
1919 // DevinAuthenticator implements OAuth and headless authentication for Devin / Cognition.
2020 type DevinAuthenticator struct {
2121 CallbackPort int
22+ AuthService *devinauth.DevinAuthService
2223 }
2324
2425 // NewDevinAuthenticator constructs a new Devin authenticator instance.
@@ -60,6 +61,72 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 35 unmodified lines
6061 return nil, fmt.Errorf("devin state generation failed: %w", errState)
6162 }
6263
64+ authSvc := a.AuthService
65+ if authSvc == nil {
66+ authSvc = devinauth.NewDevinAuthService(util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}))
67+ }
68+
69+ if opts.NoBrowser {
70+ authURL := authSvc.BuildAuthorizationURL("", pkceCodes.CodeChallenge, state)
71+ fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n\n", authURL)
72+
73+ if opts.Prompt == nil {
74+ return nil, fmt.Errorf("devin authentication in no-browser mode requires an interactive prompt")
75+ }
76+
77+ promptMsg := "Paste the Devin authorization code or session token directly: "
78+ var authCode string
79+ var rawPastedToken string
80+
81+ manualInputCh, manualInputErrCh := misc.AsyncPrompt(opts.Prompt, promptMsg)
82+ select {
83+ case <-ctx.Done():
84+ return nil, ctx.Err()
85+ case errInput := <-manualInputErrCh:
86+ if errInput != nil {
87+ return nil, fmt.Errorf("failed to read devin input: %w", errInput)
88+ }
89+ return nil, fmt.Errorf("no input received")
90+ case input := <-manualInputCh:
91+ trimmed := strings.TrimSpace(input)
92+ trimmed = strings.Trim(trimmed, "\"'")
93+ if trimmed == "" {
94+ return nil, fmt.Errorf("devin authentication canceled: empty input received")
95+ }
96+
97+ // 1. Direct manual session token paste (supports Devin --force-manual-token-flow)
98+ if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") {
99+ rawPastedToken = trimmed
100+ } else if parsed, errParse := misc.ParseOAuthCallback(trimmed); errParse == nil && parsed != nil && parsed.Code != "" {
101+ // 2. Full callback redirect URL
102+ if state != "" && parsed.State != "" && parsed.State != state {
103+ return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)")
104+ }
105+ authCode = parsed.Code
106+ } else if !strings.ContainsAny(trimmed, " \t\r\n/?#=") {
107+ // 3. Raw authorization code paste
108+ authCode = trimmed
109+ } else {
110+ return nil, fmt.Errorf("unrecognized devin authorization code or token format")
111+ }
112+ }
113+
114+ var sessionToken string
115+ if rawPastedToken != "" {
116+ sessionToken = devinauth.FormatSessionToken(rawPastedToken)
117+ } else if authCode != "" {
118+ token, errExchange := authSvc.ExchangeCodeForToken(ctx, authCode, pkceCodes.CodeVerifier)
119+ if errExchange != nil {
120+ return nil, fmt.Errorf("failed to exchange devin authorization code: %w", errExchange)
121+ }
122+ sessionToken = devinauth.FormatSessionToken(token)
123+ } else {
124+ return nil, fmt.Errorf("no authorization code or token received")
125+ }
126+
127+ return authSvc.CreateAuthRecord(ctx, sessionToken)
128+ }
129+
63130 callbackPort := a.CallbackPort
64131 if opts.CallbackPort > 0 {
65132 callbackPort = opts.CallbackPort
@@ -76,24 +143,18 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 10 unmodified lines
76143 _ = oauthServer.Stop(stopCtx)
77144 }()
78145
79− authSvc := devinauth.NewDevinAuthService(util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}))
80146 redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort)
81147 authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state)
82148
83− if !opts.NoBrowser {
84− fmt.Println("Opening browser for Devin authentication...")
85− if !browser.IsAvailable() {
86− log.Warn("No browser available; please open the URL manually")
87− util.PrintSSHTunnelInstructions(actualPort)
88− fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
89− } else if errOpen := browser.OpenURL(authURL); errOpen != nil {
90− log.Warnf("Failed to open browser automatically: %v", errOpen)
91− util.PrintSSHTunnelInstructions(actualPort)
92− fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
93− }
94− } else {
149+ fmt.Println("Opening browser for Devin authentication...")
150+ if !browser.IsAvailable() {
151+ log.Warn("No browser available; please open the URL manually")
152+ util.PrintSSHTunnelInstructions(actualPort)
153+ fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
154+ } else if errOpen := browser.OpenURL(authURL); errOpen != nil {
155+ log.Warnf("Failed to open browser automatically: %v", errOpen)
95156 util.PrintSSHTunnelInstructions(actualPort)
96− fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n", authURL)
157+ fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
97158 }
98159
99160 fmt.Println("Waiting for Devin authentication callback...")
@@ -177,6 +238,7 @@ waitForResult: · 77 unmodified lines
177238 manualInputCh = nil
178239 manualInputErrCh = nil
179240 trimmed := strings.TrimSpace(input)
241+ trimmed = strings.Trim(trimmed, "\"'")
180242 if trimmed == "" {
181243 continue
182244 }
sdk/auth/devin_test.go+152−0
@@ -2,8 +2,13 @@ package auth · 1 unmodified line
22
33 import (
44 "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
59 "testing"
610
11+ devinauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/devin"
712 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
813 )
914
@@ -43,3 +48,150 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { · 33 unmodified lines
4348 t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"])
4449 }
4550 }
51+
52+func TestDevinAuthenticatorHeadlessManualCodeLogin(t *testing.T) {
53+ mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
54+ switch r.URL.Path {
55+ case "/auth/cli/token":
56+ var body map[string]string
57+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
58+ w.WriteHeader(http.StatusBadRequest)
59+ return
60+ }
61+ if body["code"] != "devin-cli-auth-code-123" {
62+ w.WriteHeader(http.StatusBadRequest)
63+ w.Write([]byte(`{"error":"invalid_code"}`))
64+ return
65+ }
66+ if body["code_verifier"] == "" {
67+ w.WriteHeader(http.StatusBadRequest)
68+ w.Write([]byte(`{"error":"missing_verifier"}`))
69+ return
70+ }
71+ w.Header().Set("Content-Type", "application/json")
72+ w.Write([]byte(`{"token":"eyJtest.manual.code.token"}`))
73+
74+ case "/v3/self":
75+ w.Header().Set("Content-Type", "application/json")
76+ w.Write([]byte(`{"user_name":"test-user","user_id":"uid-123","org_id":"org-456"}`))
77+
78+ default:
79+ w.WriteHeader(http.StatusNotFound)
80+ }
81+ }))
82+ defer mockServer.Close()
83+
84+ authSvc := devinauth.NewDevinAuthService(mockServer.Client())
85+ authSvc.SetAPIBaseURL(mockServer.URL)
86+
87+ authenticator := NewDevinAuthenticator()
88+ authenticator.AuthService = authSvc
89+ cfg := &config.Config{}
90+
91+ mockPrompt := func(prompt string) (string, error) {
92+ return "devin-cli-auth-code-123", nil
93+ }
94+
95+ opts := &LoginOptions{
96+ NoBrowser: true,
97+ Prompt: mockPrompt,
98+ }
99+
100+ auth, err := authenticator.Login(context.Background(), cfg, opts)
101+ if err != nil {
102+ t.Fatalf("Login failed: %v", err)
103+ }
104+
105+ if auth.Provider != "devin" {
106+ t.Errorf("auth.Provider = %q, want devin", auth.Provider)
107+ }
108+ expectedToken := "devin-session-token$eyJtest.manual.code.token"
109+ if auth.Attributes["api_key"] != expectedToken {
110+ t.Errorf("api_key = %q, want %q", auth.Attributes["api_key"], expectedToken)
111+ }
112+ expectedLabel := "Devin (test-user)"
113+ if auth.Label != expectedLabel {
114+ t.Errorf("auth.Label = %q, want %q", auth.Label, expectedLabel)
115+ }
116+}
117+
118+func TestDevinAuthenticatorHeadlessManualCallbackURLLogin(t *testing.T) {
119+ mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120+ switch r.URL.Path {
121+ case "/auth/cli/token":
122+ var body map[string]string
123+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
124+ w.WriteHeader(http.StatusBadRequest)
125+ return
126+ }
127+ if body["code"] != "parsed-callback-code" {
128+ w.WriteHeader(http.StatusBadRequest)
129+ w.Write([]byte(`{"error":"invalid_code"}`))
130+ return
131+ }
132+ w.Header().Set("Content-Type", "application/json")
133+ w.Write([]byte(`{"token":"eyJtest.callback.token"}`))
134+
135+ case "/v3/self":
136+ w.Header().Set("Content-Type", "application/json")
137+ w.Write([]byte(`{"user_name":"url-user","user_id":"uid-url","org_id":"org-url"}`))
138+
139+ default:
140+ w.WriteHeader(http.StatusNotFound)
141+ }
142+ }))
143+ defer mockServer.Close()
144+
145+ authSvc := devinauth.NewDevinAuthService(mockServer.Client())
146+ authSvc.SetAPIBaseURL(mockServer.URL)
147+
148+ authenticator := NewDevinAuthenticator()
149+ authenticator.AuthService = authSvc
150+ cfg := &config.Config{}
151+
152+ mockPrompt := func(prompt string) (string, error) {
153+ // User accidentally pastes the full callback URL from browser address bar
154+ return "http://127.0.0.1:12345/callback?code=parsed-callback-code", nil
155+ }
156+
157+ opts := &LoginOptions{
158+ NoBrowser: true,
159+ Prompt: mockPrompt,
160+ }
161+
162+ auth, err := authenticator.Login(context.Background(), cfg, opts)
163+ if err != nil {
164+ t.Fatalf("Login failed: %v", err)
165+ }
166+
167+ expectedToken := "devin-session-token$eyJtest.callback.token"
168+ if auth.Attributes["api_key"] != expectedToken {
169+ t.Errorf("api_key = %q, want %q", auth.Attributes["api_key"], expectedToken)
170+ }
171+ expectedLabel := "Devin (url-user)"
172+ if auth.Label != expectedLabel {
173+ t.Errorf("auth.Label = %q, want %q", auth.Label, expectedLabel)
174+ }
175+}
176+
177+func TestDevinAuthenticatorHeadlessEmptyInputAborts(t *testing.T) {
178+ authenticator := NewDevinAuthenticator()
179+ cfg := &config.Config{}
180+
181+ mockPrompt := func(prompt string) (string, error) {
182+ return "", nil
183+ }
184+
185+ opts := &LoginOptions{
186+ NoBrowser: true,
187+ Prompt: mockPrompt,
188+ }
189+
190+ _, err := authenticator.Login(context.Background(), cfg, opts)
191+ if err == nil {
192+ t.Fatal("expected error on empty input, got nil")
193+ }
194+ if !strings.Contains(err.Error(), "canceled") {
195+ t.Errorf("expected cancellation error message, got: %v", err)
196+ }
197+}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t