mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-16 04:28:34 +08:00
Extract parseDevinManualPaste for headless and loopback login, return access_denied (and CSRF mismatches) instead of an unrecognized-format error, collapse authorization URL query construction, and lock the headless URL shape plus paste edge cases in tests.
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package devin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
const (
|
|
// DefaultAppBaseURL is the user-facing web app for Devin OAuth.
|
|
DefaultAppBaseURL = "https://app.devin.ai"
|
|
// DefaultAPIBaseURL is the API backend for token exchange and user status.
|
|
DefaultAPIBaseURL = "https://api.devin.ai"
|
|
// DefaultServerURL is the upstream Codeium/Devin reasoning backend.
|
|
DefaultServerURL = "https://server.codeium.com"
|
|
|
|
devinTokenPrefix = "devin-session-token$"
|
|
)
|
|
|
|
// DevinTokenBundle holds the completed authentication result.
|
|
type DevinTokenBundle struct {
|
|
SessionToken string `json:"session_token"`
|
|
RawToken string `json:"raw_token"`
|
|
UserName string `json:"user_name,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
OrgID string `json:"org_id,omitempty"`
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
}
|
|
|
|
// DevinAuthService coordinates Devin PKCE authorization and token exchange.
|
|
type DevinAuthService struct {
|
|
client *http.Client
|
|
appBaseURL string
|
|
apiBaseURL string
|
|
serverBaseURL string
|
|
}
|
|
|
|
// NewDevinAuthService creates a new Devin authentication service instance.
|
|
func NewDevinAuthService(client *http.Client) *DevinAuthService {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 30 * time.Second}
|
|
}
|
|
return &DevinAuthService{
|
|
client: client,
|
|
appBaseURL: DefaultAppBaseURL,
|
|
apiBaseURL: DefaultAPIBaseURL,
|
|
serverBaseURL: DefaultServerURL,
|
|
}
|
|
}
|
|
|
|
// SetServerBaseURL overrides the upstream reasoning/seat management base URL (used in tests).
|
|
func (s *DevinAuthService) SetServerBaseURL(url string) {
|
|
if s != nil && strings.TrimSpace(url) != "" {
|
|
s.serverBaseURL = strings.TrimRight(strings.TrimSpace(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 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.
|
|
func (s *DevinAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier string) (string, error) {
|
|
reqBody := map[string]string{
|
|
"code": strings.TrimSpace(code),
|
|
"code_verifier": strings.TrimSpace(codeVerifier),
|
|
}
|
|
jsonBody, errMarshal := json.Marshal(reqBody)
|
|
if errMarshal != nil {
|
|
return "", errMarshal
|
|
}
|
|
|
|
endpoint := fmt.Sprintf("%s/auth/cli/token", strings.TrimRight(s.apiBaseURL, "/"))
|
|
req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonBody))
|
|
if errReq != nil {
|
|
return "", errReq
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, errDo := s.client.Do(req)
|
|
if errDo != nil {
|
|
return "", fmt.Errorf("devin token exchange failed: %w", errDo)
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
respBytes, errRead := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if errRead != nil {
|
|
return "", fmt.Errorf("read token exchange response: %w", errRead)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBytes))
|
|
}
|
|
|
|
token := strings.TrimSpace(gjson.GetBytes(respBytes, "token").String())
|
|
if token == "" {
|
|
return "", fmt.Errorf("response did not contain a valid token: %s", string(respBytes))
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// FetchSelfProfile retrieves the authenticated user's profile from api.devin.ai/v3/self.
|
|
func (s *DevinAuthService) FetchSelfProfile(ctx context.Context, sessionToken string) (userName, userID, orgID string, err error) {
|
|
endpoint := fmt.Sprintf("%s/v3/self", strings.TrimRight(s.apiBaseURL, "/"))
|
|
req, errReq := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if errReq != nil {
|
|
return "", "", "", errReq
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, errDo := s.client.Do(req)
|
|
if errDo != nil {
|
|
return "", "", "", errDo
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
respBytes, errRead := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if errRead != nil {
|
|
return "", "", "", errRead
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
root := gjson.ParseBytes(respBytes)
|
|
userName = root.Get("user_name").String()
|
|
userID = root.Get("user_id").String()
|
|
orgID = root.Get("org_id").String()
|
|
}
|
|
|
|
return userName, userID, orgID, nil
|
|
}
|
|
|
|
// FormatSessionToken ensures the token carries the mandatory devin-session-token$ prefix.
|
|
func FormatSessionToken(rawToken string) string {
|
|
t := strings.TrimSpace(rawToken)
|
|
if strings.HasPrefix(t, devinTokenPrefix) {
|
|
return t
|
|
}
|
|
if strings.HasPrefix(t, "eyJ") {
|
|
return devinTokenPrefix + t
|
|
}
|
|
return t
|
|
}
|
|
|
|
// OAuthServer handles local loopback HTTP callbacks for Devin authentication.
|
|
type OAuthServer struct {
|
|
server *http.Server
|
|
listener net.Listener
|
|
port int
|
|
resultChan chan *OAuthResult
|
|
errorChan chan error
|
|
mu sync.Mutex
|
|
running bool
|
|
}
|
|
|
|
// OAuthResult carries the authorization code from the browser callback.
|
|
type OAuthResult struct {
|
|
Code string
|
|
State string
|
|
Error string
|
|
}
|
|
|
|
// NewOAuthServer creates a local loopback server for Devin OAuth.
|
|
func NewOAuthServer(port int) *OAuthServer {
|
|
return &OAuthServer{
|
|
port: port,
|
|
resultChan: make(chan *OAuthResult, 1),
|
|
errorChan: make(chan error, 1),
|
|
}
|
|
}
|
|
|
|
// Start initiates the local HTTP server. If port is 0, an available ephemeral port is chosen.
|
|
func (s *OAuthServer) Start() (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.running {
|
|
return s.port, nil
|
|
}
|
|
|
|
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
|
ln, errLn := net.Listen("tcp", addr)
|
|
if errLn != nil {
|
|
return 0, fmt.Errorf("failed to bind local OAuth server to %s: %w", addr, errLn)
|
|
}
|
|
|
|
s.listener = ln
|
|
s.port = ln.Addr().(*net.TCPAddr).Port
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/callback", s.handleCallback)
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/callback" {
|
|
s.handleCallback(w, r)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
})
|
|
|
|
s.server = &http.Server{
|
|
Handler: mux,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
}
|
|
|
|
s.running = true
|
|
go func() {
|
|
if errServe := s.server.Serve(ln); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
|
|
log.Debugf("devin oauth server error: %v", errServe)
|
|
select {
|
|
case s.errorChan <- errServe:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
|
|
return s.port, nil
|
|
}
|
|
|
|
// Stop gracefully stops the server.
|
|
func (s *OAuthServer) Stop(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if !s.running {
|
|
return nil
|
|
}
|
|
s.running = false
|
|
if s.server != nil {
|
|
return s.server.Shutdown(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WaitForCallback waits for the browser redirect or times out.
|
|
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
|
return s.WaitForCallbackWithContext(context.Background(), timeout)
|
|
}
|
|
|
|
// WaitForCallbackWithContext waits for the browser redirect, context cancellation, or times out.
|
|
func (s *OAuthServer) WaitForCallbackWithContext(ctx context.Context, timeout time.Duration) (*OAuthResult, error) {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case res := <-s.resultChan:
|
|
return res, nil
|
|
case err := <-s.errorChan:
|
|
return nil, err
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-timer.C:
|
|
return nil, errors.New("devin authentication timed out")
|
|
}
|
|
}
|
|
|
|
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
code := strings.TrimSpace(q.Get("code"))
|
|
state := strings.TrimSpace(q.Get("state"))
|
|
errStr := strings.TrimSpace(q.Get("error"))
|
|
errDesc := strings.TrimSpace(q.Get("error_description"))
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
if errStr != "" || code == "" {
|
|
errMsg := errStr
|
|
if errDesc != "" {
|
|
errMsg = fmt.Sprintf("%s: %s", errStr, errDesc)
|
|
}
|
|
if errMsg == "" {
|
|
errMsg = "missing authorization code"
|
|
}
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(fmt.Sprintf(loginFailureHTML, html.EscapeString(errMsg))))
|
|
select {
|
|
case s.resultChan <- &OAuthResult{Error: errMsg}:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(loginSuccessHTML))
|
|
|
|
select {
|
|
case s.resultChan <- &OAuthResult{Code: code, State: state}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
const loginSuccessHTML = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Authentication Successful - Devin</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f172a; color: #f8fafc; }
|
|
.card { background: #1e293b; padding: 2.5rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.4); text-align: center; max-width: 420px; }
|
|
h2 { margin-top: 0; color: #38bdf8; }
|
|
p { color: #94a3b8; font-size: 15px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h2>Authentication Complete</h2>
|
|
<p>You have successfully logged in to Devin via CLIProxyAPI.</p>
|
|
<p>You may safely close this window and return to your terminal.</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
const loginFailureHTML = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Authentication Failed - Devin</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0f172a; color: #f8fafc; }
|
|
.card { background: #1e293b; padding: 2.5rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.4); text-align: center; max-width: 420px; }
|
|
h2 { margin-top: 0; color: #f87171; }
|
|
p { color: #94a3b8; font-size: 15px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h2>Authentication Failed</h2>
|
|
<p>Devin authentication encountered an error: %s</p>
|
|
<p>Please check your terminal and try again.</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|