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:
sususu98
2026-09-14 17:02:45 +08:00
committed by GitHub
4 changed files with 603 additions and 45 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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()
}