mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-17 21:19:36 +08:00
Merge pull request #5808 from router-for-me/feat/devin-code-login
feat(devin): support port-free manual code login in no-browser mode and align OAuth URL parameter order
This commit is contained in:
@@ -68,17 +68,41 @@ func (s *DevinAuthService) SetServerBaseURL(url string) {
|
||||
}
|
||||
}
|
||||
|
||||
// BuildAuthorizationURL constructs the PKCE login URL.
|
||||
func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string {
|
||||
q := url.Values{}
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
q.Set("code_challenge", codeChallenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
q.Set("prompt", "select_account")
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
// 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), "/")
|
||||
}
|
||||
return fmt.Sprintf("%s/auth/cli/continue?%s", strings.TrimRight(s.appBaseURL, "/"), q.Encode())
|
||||
}
|
||||
|
||||
// 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 the exact
|
||||
// query parameter ordering of the official Devin CLI binary.
|
||||
func (s *DevinAuthService) BuildAuthorizationURL(redirectURI, codeChallenge, state string) string {
|
||||
trimmedRedirect := strings.TrimSpace(redirectURI)
|
||||
var queryParts []string
|
||||
if trimmedRedirect != "" {
|
||||
queryParts = append(queryParts, "redirect_uri="+url.QueryEscape(trimmedRedirect))
|
||||
}
|
||||
if state != "" {
|
||||
queryParts = append(queryParts, "state="+url.QueryEscape(state))
|
||||
}
|
||||
queryParts = append(queryParts,
|
||||
"prompt=select_account",
|
||||
"code_challenge="+url.QueryEscape(codeChallenge),
|
||||
"code_challenge_method=S256",
|
||||
)
|
||||
if trimmedRedirect == "" {
|
||||
queryParts = append(queryParts, "cli_pkce_marker=1")
|
||||
}
|
||||
return fmt.Sprintf("%s/auth/cli/continue?%s", strings.TrimRight(s.appBaseURL, "/"), strings.Join(queryParts, "&"))
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges the authorization code for a session token.
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -82,6 +85,68 @@ func TestBuildAuthorizationURL(t *testing.T) {
|
||||
if q.Get("state") != "state-abc" {
|
||||
t.Errorf("state = %q", q.Get("state"))
|
||||
}
|
||||
|
||||
expectedQuery := "redirect_uri=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback&state=state-abc&prompt=select_account&code_challenge=test-challenge&code_challenge_method=S256"
|
||||
if parsed.RawQuery != expectedQuery {
|
||||
t.Errorf("raw query = %q, want %q", parsed.RawQuery, expectedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
expectedQuery := "state=state-xyz&prompt=select_account&code_challenge=test-challenge-headless&code_challenge_method=S256&cli_pkce_marker=1"
|
||||
if parsed.RawQuery != expectedQuery {
|
||||
t.Errorf("raw query = %q, want %q", parsed.RawQuery, expectedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuthorizationURLWhitespaceRedirectIsHeadless(t *testing.T) {
|
||||
svc := NewDevinAuthService(nil)
|
||||
u := svc.BuildAuthorizationURL(" \t", "challenge", "state-ws")
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse auth url: %v", err)
|
||||
}
|
||||
if parsed.Query().Get("redirect_uri") != "" {
|
||||
t.Errorf("redirect_uri = %q, want empty", parsed.Query().Get("redirect_uri"))
|
||||
}
|
||||
if parsed.Query().Get("cli_pkce_marker") != "1" {
|
||||
t.Errorf("cli_pkce_marker = %q, want 1", parsed.Query().Get("cli_pkce_marker"))
|
||||
}
|
||||
want := "state=state-ws&prompt=select_account&code_challenge=challenge&code_challenge_method=S256&cli_pkce_marker=1"
|
||||
if parsed.RawQuery != want {
|
||||
t.Errorf("raw query = %q, want %q", parsed.RawQuery, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeCodeForTokenAndFetchSelf(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -19,6 +20,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 +62,56 @@ 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 {
|
||||
if opts.Prompt == nil {
|
||||
return nil, fmt.Errorf("devin authentication in no-browser mode requires an interactive prompt")
|
||||
}
|
||||
|
||||
authURL := authSvc.BuildAuthorizationURL("", pkceCodes.CodeChallenge, state)
|
||||
fmt.Printf("Visit the following URL to continue Devin authentication:\n%s\n\n", authURL)
|
||||
|
||||
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:
|
||||
return nil, fmt.Errorf("failed to read devin input: %w", errInput)
|
||||
case input := <-manualInputCh:
|
||||
var errPaste error
|
||||
authCode, rawPastedToken, errPaste = parseDevinManualPaste(input, state)
|
||||
if errPaste != nil {
|
||||
return nil, errPaste
|
||||
}
|
||||
if authCode == "" && rawPastedToken == "" {
|
||||
return nil, fmt.Errorf("devin authentication canceled: empty input received")
|
||||
}
|
||||
}
|
||||
|
||||
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 +128,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...")
|
||||
@@ -176,30 +222,19 @@ waitForResult:
|
||||
case input := <-manualInputCh:
|
||||
manualInputCh = nil
|
||||
manualInputErrCh = nil
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
break waitForResult
|
||||
}
|
||||
|
||||
// 2. Full callback redirect URL
|
||||
parsed, errParse := misc.ParseOAuthCallback(trimmed)
|
||||
if errParse == nil && parsed != nil && parsed.Code != "" {
|
||||
if state != "" && parsed.State != state {
|
||||
return nil, fmt.Errorf("devin oauth state mismatch (possible CSRF)")
|
||||
pastedCode, pastedToken, errPaste := parseDevinManualPaste(input, state)
|
||||
if errPaste != nil {
|
||||
if errors.Is(errPaste, errDevinUnrecognizedPaste) {
|
||||
continue
|
||||
}
|
||||
authCode = parsed.Code
|
||||
return nil, errPaste
|
||||
}
|
||||
if pastedToken != "" {
|
||||
rawPastedToken = pastedToken
|
||||
break waitForResult
|
||||
}
|
||||
|
||||
// 3. Raw authorization code paste
|
||||
if !strings.ContainsAny(trimmed, " \t\r\n/?#=") {
|
||||
authCode = trimmed
|
||||
if pastedCode != "" {
|
||||
authCode = pastedCode
|
||||
break waitForResult
|
||||
}
|
||||
|
||||
@@ -227,3 +262,43 @@ waitForResult:
|
||||
|
||||
return authSvc.CreateAuthRecord(ctx, sessionToken)
|
||||
}
|
||||
|
||||
var errDevinUnrecognizedPaste = errors.New("unrecognized devin authorization code or token format")
|
||||
|
||||
// parseDevinManualPaste classifies a pasted authorization code, callback URL, or session token.
|
||||
// Empty input returns empty values with a nil error; callers decide whether to abort or keep waiting.
|
||||
func parseDevinManualPaste(input, expectedState string) (authCode, rawToken string, err error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
trimmed = strings.Trim(trimmed, "\"'")
|
||||
trimmed = strings.TrimSpace(trimmed)
|
||||
if trimmed == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// Direct manual session token paste (supports Devin --force-manual-token-flow).
|
||||
if strings.HasPrefix(trimmed, "devin-session-token$") || strings.HasPrefix(trimmed, "eyJ") {
|
||||
return "", trimmed, nil
|
||||
}
|
||||
|
||||
if parsed, errParse := misc.ParseOAuthCallback(trimmed); errParse == nil && parsed != nil {
|
||||
if errMsg := strings.TrimSpace(parsed.Error); errMsg != "" {
|
||||
if desc := strings.TrimSpace(parsed.ErrorDescription); desc != "" {
|
||||
errMsg = fmt.Sprintf("%s: %s", errMsg, desc)
|
||||
}
|
||||
return "", "", fmt.Errorf("devin oauth error: %s", errMsg)
|
||||
}
|
||||
if parsed.Code != "" {
|
||||
// If state is present in the pasted URL, it must match. PKCE still protects
|
||||
// a code pasted without state.
|
||||
if expectedState != "" && parsed.State != "" && parsed.State != expectedState {
|
||||
return "", "", fmt.Errorf("devin oauth state mismatch (possible CSRF)")
|
||||
}
|
||||
return parsed.Code, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.ContainsAny(trimmed, " \t\r\n/?#=") {
|
||||
return trimmed, "", nil
|
||||
}
|
||||
return "", "", errDevinUnrecognizedPaste
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
devinauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/devin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
||||
)
|
||||
|
||||
@@ -19,7 +29,25 @@ func TestDevinAuthenticatorProviderAndRefreshLead(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) {
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v3/self":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"user_name":"token-user","user_id":"uid-token","org_id":"org-token"}`))
|
||||
case devinauth.DevinGetUserStatusPath:
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
authSvc := devinauth.NewDevinAuthService(mockServer.Client())
|
||||
authSvc.SetAPIBaseURL(mockServer.URL)
|
||||
authSvc.SetServerBaseURL(mockServer.URL)
|
||||
|
||||
authenticator := NewDevinAuthenticator()
|
||||
authenticator.AuthService = authSvc
|
||||
cfg := &config.Config{}
|
||||
|
||||
mockPrompt := func(prompt string) (string, error) {
|
||||
@@ -42,4 +70,370 @@ func TestDevinAuthenticatorHeadlessManualTokenLogin(t *testing.T) {
|
||||
if auth.Attributes["api_key"] != "devin-session-token$eyJmock.session.token" {
|
||||
t.Errorf("api_key = %q, want expected", auth.Attributes["api_key"])
|
||||
}
|
||||
if auth.Label != "Devin (token-user)" {
|
||||
t.Errorf("auth.Label = %q, want 'Devin (token-user)'", auth.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinAuthenticatorHeadlessPromptNil(t *testing.T) {
|
||||
authenticator := NewDevinAuthenticator()
|
||||
cfg := &config.Config{}
|
||||
|
||||
opts := &LoginOptions{
|
||||
NoBrowser: true,
|
||||
Prompt: nil,
|
||||
}
|
||||
|
||||
_, err := authenticator.Login(context.Background(), cfg, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when prompt is nil, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires an interactive prompt") {
|
||||
t.Errorf("unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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"}`))
|
||||
|
||||
case devinauth.DevinGetUserStatusPath:
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
authSvc := devinauth.NewDevinAuthService(mockServer.Client())
|
||||
authSvc.SetAPIBaseURL(mockServer.URL)
|
||||
authSvc.SetServerBaseURL(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"}`))
|
||||
|
||||
case devinauth.DevinGetUserStatusPath:
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockServer.Close()
|
||||
|
||||
authSvc := devinauth.NewDevinAuthService(mockServer.Client())
|
||||
authSvc.SetAPIBaseURL(mockServer.URL)
|
||||
authSvc.SetServerBaseURL(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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDevinManualPaste(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedState string
|
||||
wantCode string
|
||||
wantToken string
|
||||
wantErr string
|
||||
wantSentinel error
|
||||
}{
|
||||
{name: "empty", input: " "},
|
||||
{name: "quoted empty", input: `" "`},
|
||||
{name: "session token", input: "devin-session-token$eyJabc", wantToken: "devin-session-token$eyJabc"},
|
||||
{name: "jwt", input: "eyJabc.def", wantToken: "eyJabc.def"},
|
||||
{name: "quoted jwt", input: `"eyJabc"`, wantToken: "eyJabc"},
|
||||
{name: "raw code", input: "devin-cli-auth-code-123", wantCode: "devin-cli-auth-code-123"},
|
||||
{name: "quoted code", input: `"quoted-code-xyz"`, wantCode: "quoted-code-xyz"},
|
||||
{name: "callback without state", input: "http://127.0.0.1:9/callback?code=c1", wantCode: "c1"},
|
||||
{name: "callback matching state", input: "http://127.0.0.1:9/callback?code=c1&state=abc", expectedState: "abc", wantCode: "c1"},
|
||||
{name: "callback mismatched state", input: "http://127.0.0.1:9/callback?code=stolen-code&state=other", expectedState: "abc", wantErr: "state mismatch"},
|
||||
{name: "oauth error", input: "http://127.0.0.1:9/callback?error=access_denied&state=abc", wantErr: "oauth error"},
|
||||
{name: "oauth error with description", input: "http://127.0.0.1:9/callback?error=access_denied&error_description=user%20denied", wantErr: "user denied"},
|
||||
{name: "unrecognized", input: "not a code, has spaces", wantErr: "unrecognized", wantSentinel: errDevinUnrecognizedPaste},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, token, errPaste := parseDevinManualPaste(tt.input, tt.expectedState)
|
||||
if tt.wantErr != "" {
|
||||
if errPaste == nil || !strings.Contains(errPaste.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", errPaste, tt.wantErr)
|
||||
}
|
||||
if tt.wantSentinel != nil && !errors.Is(errPaste, tt.wantSentinel) {
|
||||
t.Fatalf("error %v is not %v", errPaste, tt.wantSentinel)
|
||||
}
|
||||
return
|
||||
}
|
||||
if errPaste != nil {
|
||||
t.Fatalf("unexpected error: %v", errPaste)
|
||||
}
|
||||
if code != tt.wantCode || token != tt.wantToken {
|
||||
t.Fatalf("code=%q token=%q, want code=%q token=%q", code, token, tt.wantCode, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinAuthenticatorHeadlessAuthURL(t *testing.T) {
|
||||
authenticator := NewDevinAuthenticator()
|
||||
out := captureStdout(t, func() {
|
||||
_, _ = authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{
|
||||
NoBrowser: true,
|
||||
Prompt: func(string) (string, error) { return "", nil },
|
||||
})
|
||||
})
|
||||
var authURL string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "/auth/cli/continue?") {
|
||||
authURL = strings.TrimSpace(line)
|
||||
break
|
||||
}
|
||||
}
|
||||
if authURL == "" {
|
||||
t.Fatalf("printed output did not contain auth URL:\n%s", out)
|
||||
}
|
||||
parsed, errParse := url.Parse(authURL)
|
||||
if errParse != nil {
|
||||
t.Fatal(errParse)
|
||||
}
|
||||
q := parsed.Query()
|
||||
if q.Get("redirect_uri") != "" {
|
||||
t.Fatalf("headless URL still has redirect_uri=%q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("cli_pkce_marker") != "1" {
|
||||
t.Fatalf("cli_pkce_marker=%q", q.Get("cli_pkce_marker"))
|
||||
}
|
||||
if q.Get("code_challenge_method") != "S256" || q.Get("prompt") != "select_account" || q.Get("state") == "" {
|
||||
t.Fatalf("unexpected query: %s", parsed.RawQuery)
|
||||
}
|
||||
if !strings.HasSuffix(parsed.RawQuery, "&cli_pkce_marker=1") || strings.Contains(parsed.RawQuery, "redirect_uri=") {
|
||||
t.Fatalf("raw query order/content mismatch: %s", parsed.RawQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinAuthenticatorHeadlessStateMismatch(t *testing.T) {
|
||||
var exchanged bool
|
||||
authSvc := newTestDevinAuthService(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/auth/cli/token" {
|
||||
exchanged = true
|
||||
}
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
})
|
||||
authenticator := NewDevinAuthenticator()
|
||||
authenticator.AuthService = authSvc
|
||||
|
||||
_, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{
|
||||
NoBrowser: true,
|
||||
Prompt: func(string) (string, error) {
|
||||
return "http://127.0.0.1:9/callback?code=stolen-code&state=other-session", nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "state mismatch") {
|
||||
t.Fatalf("error = %v, want state mismatch", err)
|
||||
}
|
||||
if exchanged {
|
||||
t.Fatal("token exchange should not run on CSRF mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinAuthenticatorHeadlessOAuthErrorURL(t *testing.T) {
|
||||
authenticator := NewDevinAuthenticator()
|
||||
_, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{
|
||||
NoBrowser: true,
|
||||
Prompt: func(string) (string, error) {
|
||||
return "http://127.0.0.1:9/callback?error=access_denied&error_description=user%20denied", nil
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "access_denied") {
|
||||
t.Fatalf("error = %v, want access_denied oauth error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinAuthenticatorHeadlessQuotedCode(t *testing.T) {
|
||||
authSvc := newTestDevinAuthService(t, 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"] != "quoted-code-xyz" {
|
||||
t.Errorf("code = %q", body["code"])
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"token":"eyJquoted.token"}`))
|
||||
case "/v3/self":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"user_name":"quoted-user"}`))
|
||||
case devinauth.DevinGetUserStatusPath:
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
authenticator := NewDevinAuthenticator()
|
||||
authenticator.AuthService = authSvc
|
||||
|
||||
auth, err := authenticator.Login(context.Background(), &config.Config{}, &LoginOptions{
|
||||
NoBrowser: true,
|
||||
Prompt: func(string) (string, error) { return `"quoted-code-xyz"`, nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("quoted code login failed: %v", err)
|
||||
}
|
||||
if auth.Attributes["api_key"] != "devin-session-token$eyJquoted.token" {
|
||||
t.Fatalf("api_key = %q", auth.Attributes["api_key"])
|
||||
}
|
||||
}
|
||||
|
||||
func newTestDevinAuthService(t *testing.T, handler http.HandlerFunc) *devinauth.DevinAuthService {
|
||||
t.Helper()
|
||||
mockServer := httptest.NewServer(handler)
|
||||
t.Cleanup(mockServer.Close)
|
||||
authSvc := devinauth.NewDevinAuthService(mockServer.Client())
|
||||
authSvc.SetAPIBaseURL(mockServer.URL)
|
||||
authSvc.SetServerBaseURL(mockServer.URL)
|
||||
return authSvc
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
r, w, errPipe := os.Pipe()
|
||||
if errPipe != nil {
|
||||
t.Fatal(errPipe)
|
||||
}
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
fn()
|
||||
_ = w.Close()
|
||||
os.Stdout = old
|
||||
var buf bytes.Buffer
|
||||
if _, errCopy := io.Copy(&buf, r); errCopy != nil {
|
||||
t.Fatal(errCopy)
|
||||
}
|
||||
_ = r.Close()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user