Unified · Split
| @@ -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). | |
| 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. | |
| 72 | 87 | func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string { |
| 73 | 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 | 94 | q.Set("code_challenge", codeChallenge) |
| 76 | 95 | q.Set("code_challenge_method", "S256") |
| 77 | 96 | q.Set("prompt", "select_account") |
| @@ -73,6 +73,9 @@ func TestBuildAuthorizationURL(t *testing.T) { · 72 unmodified lines | ||
| 73 | 73 | if q.Get("redirect_uri") != "http://127.0.0.1:1234/callback" { |
| 74 | 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 | 79 | if q.Get("code_challenge") != "test-challenge" { |
| 77 | 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 | |
| 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 | + | |
| 87 | 123 | func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) { |
| 88 | 124 | // Mock devin API backend |
| 89 | 125 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| @@ -19,6 +19,7 @@ import ( · 18 unmodified lines | ||
| 19 | 19 | // DevinAuthenticator implements OAuth and headless authentication for Devin / Cognition. |
| 20 | 20 | type DevinAuthenticator struct { |
| 21 | 21 | CallbackPort int |
| 22 | + AuthService *devinauth.DevinAuthService | |
| 22 | 23 | } |
| 23 | 24 | |
| 24 | 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 | 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 | 130 | callbackPort := a.CallbackPort |
| 64 | 131 | if opts.CallbackPort > 0 { |
| 65 | 132 | callbackPort = opts.CallbackPort |
| @@ -76,24 +143,18 @@ func (a *DevinAuthenticator) Login(ctx context.Context, cfg *config.Config, opts · 10 unmodified lines | ||
| 76 | 143 | _ = oauthServer.Stop(stopCtx) |
| 77 | 144 | }() |
| 78 | 145 | |
| 79 | − authSvc := devinauth.NewDevinAuthService(util.SetProxy(&cfg.SDKConfig, &http.Client{Timeout: 30 * time.Second})) | |
| 80 | 146 | redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", actualPort) |
| 81 | 147 | authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state) |
| 82 | 148 | |
| 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) | |
| 95 | 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 | 160 | fmt.Println("Waiting for Devin authentication callback...") |
| @@ -177,6 +238,7 @@ waitForResult: · 77 unmodified lines | ||
| 177 | 238 | manualInputCh = nil |
| 178 | 239 | manualInputErrCh = nil |
| 179 | 240 | trimmed := strings.TrimSpace(input) |
| 241 | + trimmed = strings.Trim(trimmed, "\"'") | |
| 180 | 242 | if trimmed == "" { |
| 181 | 243 | continue |
| 182 | 244 | } |
| @@ -2,8 +2,13 @@ package auth · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | + "encoding/json" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 5 | 9 | "testing" |
| 6 | 10 | |
| 11 | + devinauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/devin" | |
| 7 | 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 | 48 | t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"]) |
| 44 | 49 | } |
| 45 | 50 | } |
| 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 | +} | |