mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-18 05:29:00 +08:00
feat(devin): support port-free manual code login in no-browser mode
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user