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 | func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string { | 87 | func (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") |
| @@ -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 | ||
| 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 | func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) { | 123 | func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) { |
| 88 | // Mock devin API backend | 124 | // 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) { |
| @@ -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. |
| 20 | type DevinAuthenticator struct { | 20 | type DevinAuthenticator struct { |
| 21 | CallbackPort int | 21 | 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.CallbackPort | 130 | callbackPort := a.CallbackPort |
| 64 | if opts.CallbackPort > 0 { | 131 | if opts.CallbackPort > 0 { |
| 65 | callbackPort = opts.CallbackPort | 132 | 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 = nil | 238 | manualInputCh = nil |
| 178 | manualInputErrCh = nil | 239 | manualInputErrCh = nil |
| 179 | trimmed := strings.TrimSpace(input) | 240 | trimmed := strings.TrimSpace(input) |
| 241 | trimmed = strings.Trim(trimmed, "\"'") | ||
| 180 | if trimmed == "" { | 242 | if trimmed == "" { |
| 181 | continue | 243 | continue |
| 182 | } | 244 | } |
| @@ -2,8 +2,13 @@ package auth · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | |||
| 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 | } | ||