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
68 }68 }
69}69}
70 70
71// BuildAuthorizationURL constructs the PKCE login URL.71// SetAPIBaseURL overrides the token exchange and profile API base URL (used in tests).
72func (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).
79func (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.
72func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string {87func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string {
73 q := url.Values{}88 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 }
75 q.Set("code_challenge", codeChallenge)94 q.Set("code_challenge", codeChallenge)
76 q.Set("code_challenge_method", "S256")95 q.Set("code_challenge_method", "S256")
77 q.Set("prompt", "select_account")96 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
73 if q.Get("redirect_uri") != "http://127.0.0.1:1234/callback" {73 if q.Get("redirect_uri") != "http://127.0.0.1:1234/callback" {
74 t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))74 t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
75 }75 }
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 }
76 if q.Get("code_challenge") != "test-challenge" {79 if q.Get("code_challenge") != "test-challenge" {
77 t.Errorf("code_challenge = %q", q.Get("code_challenge"))80 t.Errorf("code_challenge = %q", q.Get("code_challenge"))
78 }81 }
@@ -84,6 +87,39 @@ func TestBuildAuthorizationURL(t *testing.T) { · 5 unmodified lines
84 }87 }
85}88}
86 89
90func 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
87func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) {123func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) {
88 // Mock devin API backend124 // Mock devin API backend
89 mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {125 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
19// DevinAuthenticator implements OAuth and headless authentication for Devin / Cognition.19// DevinAuthenticator implements OAuth and headless authentication for Devin / Cognition.
20type DevinAuthenticator struct {20type DevinAuthenticator struct {
21 CallbackPort int21 CallbackPort int
22 AuthService *devinauth.DevinAuthService
22}23}
23 24
24// NewDevinAuthenticator constructs a new Devin authenticator instance.25// 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
60 return nil, fmt.Errorf("devin state generation failed: %w", errState)61 return nil, fmt.Errorf("devin state generation failed: %w", errState)
61 }62 }
62 63
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
63 callbackPort := a.CallbackPort130 callbackPort := a.CallbackPort
64 if opts.CallbackPort > 0 {131 if opts.CallbackPort > 0 {
65 callbackPort = opts.CallbackPort132 callbackPort = opts.CallbackPort
@@ -76,24 +143,18 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 10 unmodified lines
76 _ = oauthServer.Stop(stopCtx)143 _ = oauthServer.Stop(stopCtx)
77 }()144 }()
78 145
79 authSvc := devinauth.NewDevinAuthService(util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second}))
80 redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort)146 redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort)
81 authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state)147 authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state)
82 148
83 if !opts.NoBrowser {149 fmt.Println("Opening browser for Devin authentication...")
84 fmt.Println("Opening browser for Devin authentication...")150 if !browser.IsAvailable() {
85 if !browser.IsAvailable() {151 log.Warn("No browser available; please open the URL manually")
86 log.Warn("No browser available; please open the URL manually")152 util.PrintSSHTunnelInstructions(actualPort)
87 util.PrintSSHTunnelInstructions(actualPort)153 fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
88 fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)154 } else if errOpen := browser.OpenURL(authURL); errOpen != nil {
89 } else if errOpen := browser.OpenURL(authURL); errOpen != nil {155 log.Warnf("Failed to open browser automatically: %v", errOpen)
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 {
95 util.PrintSSHTunnelInstructions(actualPort)156 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)
97 }158 }
98 159
99 fmt.Println("Waiting for Devin authentication callback...")160 fmt.Println("Waiting for Devin authentication callback...")
@@ -177,6 +238,7 @@ waitForResult: · 77 unmodified lines
177 manualInputCh = nil238 manualInputCh = nil
178 manualInputErrCh = nil239 manualInputErrCh = nil
179 trimmed := strings.TrimSpace(input)240 trimmed := strings.TrimSpace(input)
241 trimmed = strings.Trim(trimmed, "\"'")
180 if trimmed == "" {242 if trimmed == "" {
181 continue243 continue
182 }244 }
sdk/auth/devin_test.go+152−0
@@ -2,8 +2,13 @@ package auth · 1 unmodified line
2 2
3import (3import (
4 "context"4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
5 "testing"9 "testing"
6 10
11 devinauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/devin"
7 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"12 "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
8)13)
9 14
@@ -43,3 +48,150 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) { · 33 unmodified lines
43 t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"])48 t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"])
44 }49 }
45}50}
51
52func 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
118func 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
177func 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