644 lines
18 KiB
Go
644 lines
18 KiB
Go
package auth
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"math/rand"
|
||
"net"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
utls "github.com/refraction-networking/utls"
|
||
"golang.org/x/net/http2"
|
||
)
|
||
|
||
func init() {
|
||
rand.Seed(time.Now().UnixNano())
|
||
}
|
||
|
||
// FixedAuthURL 固定的授权 URL(用于测试,绕过 S2A)
|
||
const FixedAuthURL = "https://auth.openai.com/oauth/authorize?client_id=app_EMoamEEZ73f0CkXaXp7hrann&code_challenge=fEepJO0_NJiqP-_FC_HLH-aqZsq68JeFtNvYY6q3qbQ&code_challenge_method=S256&codex_cli_simplified_flow=true&id_token_add_organizations=true&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&response_type=code&scope=openid+profile+email+offline_access&state=da3ec35b7368c91193d27c90e3ecd0c4fa45bebd430bcc6b5c236461d2742e93"
|
||
|
||
// UseFixedAuthURL 是否使用固定的授权 URL(设为 true 可绕过 S2A 进行测试)
|
||
var UseFixedAuthURL = false
|
||
|
||
// 常量 CodexClientID, CodexRedirectURI, CodexScope 已在 s2a.go 中定义
|
||
|
||
// CodexAPIAuth 纯 API 授权 (无浏览器) - 基于 get_code.go 的实现
|
||
type CodexAPIAuth struct {
|
||
client *http.Client
|
||
email string
|
||
password string
|
||
workspaceID string
|
||
authURL string // S2A 生成的授权 URL
|
||
sessionID string // S2A 会话 ID
|
||
deviceID string
|
||
sid string
|
||
sentinelToken string
|
||
solvedPow string
|
||
userAgent string
|
||
proxyURL string
|
||
logger *AuthLogger
|
||
}
|
||
|
||
// utlsRoundTripper - 模拟 Chrome TLS 指纹
|
||
type utlsRoundTripper struct {
|
||
proxyURL *url.URL
|
||
}
|
||
|
||
func (rt *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||
var conn net.Conn
|
||
var err error
|
||
|
||
targetHost := req.URL.Host
|
||
if !strings.Contains(targetHost, ":") {
|
||
if req.URL.Scheme == "https" {
|
||
targetHost += ":443"
|
||
} else {
|
||
targetHost += ":80"
|
||
}
|
||
}
|
||
|
||
// 通过代理连接
|
||
if rt.proxyURL != nil {
|
||
proxyHost := rt.proxyURL.Host
|
||
conn, err = net.DialTimeout("tcp", proxyHost, 30*time.Second)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("proxy dial error: %w", err)
|
||
}
|
||
|
||
// 构建 CONNECT 请求,支持代理认证
|
||
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", targetHost, targetHost)
|
||
|
||
// 添加代理认证头
|
||
if rt.proxyURL.User != nil {
|
||
username := rt.proxyURL.User.Username()
|
||
password, _ := rt.proxyURL.User.Password()
|
||
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||
connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", auth)
|
||
}
|
||
connectReq += "\r\n"
|
||
|
||
_, err = conn.Write([]byte(connectReq))
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("proxy connect write error: %w", err)
|
||
}
|
||
|
||
// 读取代理响应
|
||
buf := make([]byte, 4096)
|
||
n, err := conn.Read(buf)
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("proxy connect read error: %w", err)
|
||
}
|
||
response := string(buf[:n])
|
||
if !strings.Contains(response, "200") {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("proxy connect failed: %s", response)
|
||
}
|
||
} else {
|
||
conn, err = net.DialTimeout("tcp", targetHost, 30*time.Second)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dial error: %w", err)
|
||
}
|
||
}
|
||
|
||
// 使用 uTLS 进行 TLS 握手,模拟 Chrome
|
||
tlsConn := utls.UClient(conn, &utls.Config{
|
||
ServerName: req.URL.Hostname(),
|
||
InsecureSkipVerify: true,
|
||
}, utls.HelloChrome_Auto)
|
||
|
||
err = tlsConn.Handshake()
|
||
if err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("tls handshake error: %w", err)
|
||
}
|
||
|
||
// 使用 HTTP/2 或 HTTP/1.1
|
||
alpn := tlsConn.ConnectionState().NegotiatedProtocol
|
||
if alpn == "h2" {
|
||
// HTTP/2
|
||
t2 := &http2.Transport{
|
||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||
return tlsConn, nil
|
||
},
|
||
}
|
||
return t2.RoundTrip(req)
|
||
}
|
||
|
||
// HTTP/1.1
|
||
t1 := &http.Transport{
|
||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||
return tlsConn, nil
|
||
},
|
||
}
|
||
return t1.RoundTrip(req)
|
||
}
|
||
|
||
// NewCodexAPIAuth 创建 CodexAuth 实例
|
||
// authURL 和 sessionID 由 S2A 生成
|
||
func NewCodexAPIAuth(email, password, workspaceID, authURL, sessionID, proxy string, logger *AuthLogger) (*CodexAPIAuth, error) {
|
||
var proxyURL *url.URL
|
||
if proxy != "" {
|
||
var err error
|
||
proxyURL, err = url.Parse(proxy)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析代理地址失败: %v", err)
|
||
}
|
||
}
|
||
|
||
jar, _ := cookiejar.New(nil)
|
||
client := &http.Client{
|
||
Transport: &utlsRoundTripper{proxyURL: proxyURL},
|
||
Jar: jar,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
return http.ErrUseLastResponse // 禁用自动重定向,手动跟随
|
||
},
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
|
||
return &CodexAPIAuth{
|
||
client: client,
|
||
email: email,
|
||
password: password,
|
||
workspaceID: workspaceID,
|
||
authURL: authURL,
|
||
sessionID: sessionID,
|
||
deviceID: generateUUID(),
|
||
sid: generateUUID(),
|
||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36",
|
||
proxyURL: proxy,
|
||
logger: logger,
|
||
}, nil
|
||
}
|
||
|
||
// generateUUID 生成 UUID v4
|
||
func generateUUID() string {
|
||
b := make([]byte, 16)
|
||
rand.Read(b)
|
||
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||
}
|
||
|
||
// fnv1a32 FNV-1a 32-bit hash
|
||
func fnv1a32(data []byte) uint32 {
|
||
h := uint32(2166136261)
|
||
for _, b := range data {
|
||
h ^= uint32(b)
|
||
h *= 16777619
|
||
}
|
||
h ^= (h >> 16)
|
||
h *= 2246822507
|
||
h ^= (h >> 13)
|
||
h *= 3266489909
|
||
h ^= (h >> 16)
|
||
return h
|
||
}
|
||
|
||
// getParseTime 生成 JS Date().toString() 格式的时间字符串
|
||
func getParseTime() string {
|
||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||
now := time.Now().In(loc)
|
||
days := []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}
|
||
months := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
|
||
return fmt.Sprintf("%s %s %02d %d %02d:%02d:%02d GMT+0800 (中国标准时间)",
|
||
days[now.Weekday()], months[now.Month()-1], now.Day(), now.Year(),
|
||
now.Hour(), now.Minute(), now.Second())
|
||
}
|
||
|
||
// getConfig 生成 PoW 配置数组
|
||
func (c *CodexAPIAuth) getConfig() []interface{} {
|
||
return []interface{}{
|
||
rand.Intn(1000) + 2500,
|
||
getParseTime(),
|
||
4294967296,
|
||
0,
|
||
c.userAgent,
|
||
"chrome-extension://pgojnojmmhpofjgdmaebadhbocahppod/assets/aW5qZWN0X2hhc2g/aW5qZ",
|
||
nil,
|
||
"zh-CN",
|
||
"zh-CN",
|
||
0,
|
||
"canShare−function canShare() { [native code] }",
|
||
fmt.Sprintf("_reactListening%d", rand.Intn(9000000)+1000000),
|
||
"onhashchange",
|
||
float64(time.Now().UnixNano()) / 1e6,
|
||
c.sid,
|
||
"",
|
||
24,
|
||
time.Now().UnixMilli() - int64(rand.Intn(40000)+10000),
|
||
}
|
||
}
|
||
|
||
// solvePow 解决 PoW 挑战
|
||
func (c *CodexAPIAuth) solvePow(seed, difficulty string, cfg []interface{}, maxIterations int) string {
|
||
startTime := time.Now()
|
||
seedBytes := []byte(seed)
|
||
|
||
for i := 0; i < maxIterations; i++ {
|
||
cfg[3] = i
|
||
cfg[9] = 0
|
||
|
||
jsonStr, _ := json.Marshal(cfg)
|
||
encoded := base64.StdEncoding.EncodeToString(jsonStr)
|
||
|
||
combined := append(seedBytes, []byte(encoded)...)
|
||
h := fnv1a32(combined)
|
||
hexHash := fmt.Sprintf("%08x", h)
|
||
|
||
if hexHash[:len(difficulty)] <= difficulty {
|
||
elapsed := time.Since(startTime).Seconds()
|
||
c.logStep(StepNavigate, "[PoW] Solved in %.2fs (iteration %d, difficulty=%s)", elapsed, i, difficulty)
|
||
return encoded + "~S"
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// getRequirementsToken 生成初始 token
|
||
func (c *CodexAPIAuth) getRequirementsToken() string {
|
||
cfg := c.getConfig()
|
||
cfg[3] = 0
|
||
cfg[9] = 0
|
||
jsonStr, _ := json.Marshal(cfg)
|
||
encoded := base64.StdEncoding.EncodeToString(jsonStr)
|
||
return "gAAAAAC" + encoded + "~S"
|
||
}
|
||
|
||
// doRequest 执行 HTTP 请求
|
||
func (c *CodexAPIAuth) doRequest(method, urlStr string, body interface{}, headers map[string]string) (*http.Response, []byte, error) {
|
||
var bodyReader io.Reader
|
||
if body != nil {
|
||
jsonBody, _ := json.Marshal(body)
|
||
bodyReader = bytes.NewReader(jsonBody)
|
||
}
|
||
|
||
req, err := http.NewRequest(method, urlStr, bodyReader)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
|
||
// 设置默认头
|
||
req.Header.Set("User-Agent", c.userAgent)
|
||
req.Header.Set("Accept", "*/*")
|
||
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
||
req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="110", "Google Chrome";v="110"`)
|
||
req.Header.Set("sec-ch-ua-mobile", "?0")
|
||
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
|
||
req.Header.Set("sec-fetch-dest", "empty")
|
||
req.Header.Set("sec-fetch-mode", "cors")
|
||
req.Header.Set("sec-fetch-site", "same-origin")
|
||
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
resp, err := c.client.Do(req)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return resp, respBody, nil
|
||
}
|
||
|
||
// callSentinelReq 调用 Sentinel 获取 token
|
||
func (c *CodexAPIAuth) callSentinelReq(flow string) bool {
|
||
initToken := c.getRequirementsToken()
|
||
payload := map[string]string{
|
||
"p": initToken,
|
||
"id": c.deviceID,
|
||
"flow": flow,
|
||
}
|
||
|
||
headers := map[string]string{"Content-Type": "application/json"}
|
||
resp, body, err := c.doRequest("POST", "https://sentinel.openai.com/backend-api/sentinel/req", payload, headers)
|
||
if err != nil || resp.StatusCode != 200 {
|
||
c.logError(StepNavigate, "Sentinel 请求失败: %v (status: %d)", err, resp.StatusCode)
|
||
return false
|
||
}
|
||
|
||
var data map[string]interface{}
|
||
json.Unmarshal(body, &data)
|
||
|
||
if token, ok := data["token"].(string); ok {
|
||
c.sentinelToken = token
|
||
}
|
||
|
||
if powReq, ok := data["proofofwork"].(map[string]interface{}); ok {
|
||
if required, ok := powReq["required"].(bool); ok && required {
|
||
seed, _ := powReq["seed"].(string)
|
||
difficulty, _ := powReq["difficulty"].(string)
|
||
c.logStep(StepNavigate, "[Sentinel] flow=%s, PoW required, difficulty=%s", flow, difficulty)
|
||
config := c.getConfig()
|
||
solved := c.solvePow(seed, difficulty, config, 5000000)
|
||
if solved != "" {
|
||
c.solvedPow = "gAAAAAB" + solved
|
||
} else {
|
||
c.logError(StepNavigate, "PoW 求解失败")
|
||
return false
|
||
}
|
||
} else {
|
||
c.logStep(StepNavigate, "[Sentinel] flow=%s, PoW not required", flow)
|
||
c.solvedPow = initToken
|
||
}
|
||
} else {
|
||
c.logStep(StepNavigate, "[Sentinel] flow=%s, no PoW in response", flow)
|
||
c.solvedPow = initToken
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// getSentinelHeader 构建 sentinel header
|
||
func (c *CodexAPIAuth) getSentinelHeader(flow string) string {
|
||
sentinelObj := map[string]string{
|
||
"p": c.solvedPow,
|
||
"id": c.deviceID,
|
||
"flow": flow,
|
||
}
|
||
if c.sentinelToken != "" {
|
||
sentinelObj["c"] = c.sentinelToken
|
||
}
|
||
header, _ := json.Marshal(sentinelObj)
|
||
return string(header)
|
||
}
|
||
|
||
// logStep 记录日志
|
||
func (c *CodexAPIAuth) logStep(step AuthStep, format string, args ...interface{}) {
|
||
if c.logger != nil {
|
||
c.logger.LogStep(step, format, args...)
|
||
}
|
||
}
|
||
|
||
// logError 记录错误
|
||
func (c *CodexAPIAuth) logError(step AuthStep, format string, args ...interface{}) {
|
||
if c.logger != nil {
|
||
c.logger.LogError(step, format, args...)
|
||
}
|
||
}
|
||
|
||
// GetSessionID 获取 S2A 会话 ID(用于后续入库)
|
||
func (c *CodexAPIAuth) GetSessionID() string {
|
||
return c.sessionID
|
||
}
|
||
|
||
// ObtainAuthorizationCode 获取授权码
|
||
func (c *CodexAPIAuth) ObtainAuthorizationCode() (string, error) {
|
||
c.logStep(StepNavigate, "开始 Codex API 授权流程...")
|
||
|
||
// 选择使用固定 URL 还是 S2A 生成的 URL
|
||
authURL := c.authURL
|
||
if UseFixedAuthURL {
|
||
authURL = FixedAuthURL
|
||
c.logStep(StepNavigate, "使用固定授权 URL(测试模式)")
|
||
} else if authURL == "" {
|
||
return "", fmt.Errorf("authURL 未设置,请先通过 S2A 生成授权 URL")
|
||
}
|
||
|
||
headers := map[string]string{
|
||
"Origin": "https://auth.openai.com",
|
||
"Referer": "https://auth.openai.com/log-in",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
// 访问授权页面并手动跟随重定向
|
||
c.logStep(StepNavigate, "访问授权页面...")
|
||
resp, _, err := c.doRequest("GET", authURL, nil, headers)
|
||
if err != nil {
|
||
c.logError(StepNavigate, "访问授权页失败: %v", err)
|
||
return "", fmt.Errorf("访问授权页失败: %v", err)
|
||
}
|
||
|
||
// 手动跟随重定向
|
||
currentURL := authURL
|
||
for resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||
location := resp.Header.Get("Location")
|
||
if location == "" {
|
||
break
|
||
}
|
||
if !strings.HasPrefix(location, "http") {
|
||
parsedURL, _ := url.Parse(currentURL)
|
||
location = parsedURL.Scheme + "://" + parsedURL.Host + location
|
||
}
|
||
currentURL = location
|
||
resp, _, err = c.doRequest("GET", currentURL, nil, headers)
|
||
if err != nil {
|
||
break
|
||
}
|
||
}
|
||
headers["Referer"] = currentURL
|
||
|
||
// 提交邮箱
|
||
c.logStep(StepInputEmail, "提交邮箱: %s", c.email)
|
||
if !c.callSentinelReq("login_web_init") {
|
||
return "", fmt.Errorf("Sentinel 请求失败")
|
||
}
|
||
|
||
authHeaders := make(map[string]string)
|
||
for k, v := range headers {
|
||
authHeaders[k] = v
|
||
}
|
||
authHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("authorize_continue")
|
||
|
||
emailPayload := map[string]interface{}{
|
||
"username": map[string]string{
|
||
"kind": "email",
|
||
"value": c.email,
|
||
},
|
||
}
|
||
|
||
resp, body, err := c.doRequest("POST", "https://auth.openai.com/api/accounts/authorize/continue", emailPayload, authHeaders)
|
||
if err != nil || resp.StatusCode != 200 {
|
||
c.logError(StepInputEmail, "提交邮箱失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
|
||
return "", fmt.Errorf("提交邮箱失败: %d", resp.StatusCode)
|
||
}
|
||
|
||
var data map[string]interface{}
|
||
json.Unmarshal(body, &data)
|
||
|
||
// 检查是否需要密码验证
|
||
pageType := ""
|
||
if page, ok := data["page"].(map[string]interface{}); ok {
|
||
if pt, ok := page["type"].(string); ok {
|
||
pageType = pt
|
||
}
|
||
}
|
||
|
||
c.logStep(StepInputPassword, "验证密码...")
|
||
|
||
if pageType == "password" || strings.Contains(string(body), "password") {
|
||
// 5. 验证密码
|
||
if !c.callSentinelReq("authorize_continue__auto") {
|
||
return "", fmt.Errorf("Sentinel 请求失败")
|
||
}
|
||
|
||
verifyHeaders := make(map[string]string)
|
||
for k, v := range headers {
|
||
verifyHeaders[k] = v
|
||
}
|
||
verifyHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("password_verify")
|
||
|
||
passwordPayload := map[string]string{
|
||
"username": c.email,
|
||
"password": c.password,
|
||
}
|
||
|
||
resp, body, err = c.doRequest("POST", "https://auth.openai.com/api/accounts/password/verify", passwordPayload, verifyHeaders)
|
||
if err != nil || resp.StatusCode != 200 {
|
||
c.logError(StepInputPassword, "密码验证失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
|
||
return "", fmt.Errorf("密码验证失败: %d", resp.StatusCode)
|
||
}
|
||
c.logStep(StepInputPassword, "密码验证成功")
|
||
|
||
// 解析密码验证响应
|
||
json.Unmarshal(body, &data)
|
||
|
||
// 检查下一步是什么
|
||
nextPageType := ""
|
||
if page, ok := data["page"].(map[string]interface{}); ok {
|
||
if pt, ok := page["type"].(string); ok {
|
||
nextPageType = pt
|
||
}
|
||
}
|
||
|
||
// 如果需要邮箱验证,这是新账号的问题
|
||
if nextPageType == "email_otp_verification" {
|
||
c.logError(StepInputPassword, "账号需要邮箱验证,无法继续 Codex 授权流程")
|
||
return "", fmt.Errorf("账号需要邮箱验证,请使用浏览器模式或等待账号状态更新")
|
||
}
|
||
}
|
||
|
||
// 6. 选择工作区
|
||
c.logStep(StepSelectWorkspace, "选择工作区: %s", c.workspaceID)
|
||
if !c.callSentinelReq("password_verify__auto") {
|
||
return "", fmt.Errorf("Sentinel 请求失败")
|
||
}
|
||
|
||
// 选择工作区时带上 Sentinel Header
|
||
workspaceHeaders := make(map[string]string)
|
||
for k, v := range headers {
|
||
workspaceHeaders[k] = v
|
||
}
|
||
workspaceHeaders["OpenAI-Sentinel-Token"] = c.getSentinelHeader("workspace_select")
|
||
|
||
workspacePayload := map[string]string{
|
||
"workspace_id": c.workspaceID,
|
||
}
|
||
|
||
resp, body, err = c.doRequest("POST", "https://auth.openai.com/api/accounts/workspace/select", workspacePayload, workspaceHeaders)
|
||
if err != nil || resp.StatusCode != 200 {
|
||
c.logError(StepSelectWorkspace, "选择工作区失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
|
||
return "", fmt.Errorf("选择工作区失败: %d", resp.StatusCode)
|
||
}
|
||
|
||
json.Unmarshal(body, &data)
|
||
continueURL, ok := data["continue_url"].(string)
|
||
if !ok || continueURL == "" {
|
||
c.logError(StepSelectWorkspace, "未获取到 continue_url, 响应: %s", string(body[:min(500, len(body))]))
|
||
return "", fmt.Errorf("未获取到 continue_url")
|
||
}
|
||
|
||
// 7. 跟随重定向获取授权码
|
||
c.logStep(StepWaitCallback, "跟随重定向...")
|
||
for i := 0; i < 10; i++ {
|
||
resp, _, err = c.doRequest("GET", continueURL, nil, headers)
|
||
if err != nil {
|
||
break
|
||
}
|
||
|
||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||
location := resp.Header.Get("Location")
|
||
if strings.Contains(location, "localhost:1455") {
|
||
code := ExtractCodeFromCallbackURL(location)
|
||
if code != "" {
|
||
c.logStep(StepComplete, "授权成功,获取到授权码")
|
||
return code, nil
|
||
}
|
||
}
|
||
continueURL = location
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
c.logError(StepWaitCallback, "未能获取授权码")
|
||
return "", fmt.Errorf("未能获取授权码")
|
||
}
|
||
|
||
// ExchangeCodeForTokens 用授权码换取 tokens
|
||
func (c *CodexAPIAuth) ExchangeCodeForTokens(code, codeVerifier string) (*CodexTokens, error) {
|
||
payload := map[string]string{
|
||
"grant_type": "authorization_code",
|
||
"client_id": CodexClientID,
|
||
"code_verifier": codeVerifier,
|
||
"code": code,
|
||
"redirect_uri": CodexRedirectURI,
|
||
}
|
||
|
||
headers := map[string]string{"Content-Type": "application/json"}
|
||
resp, body, err := c.doRequest("POST", "https://auth.openai.com/oauth/token", payload, headers)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("token 交换失败: %v", err)
|
||
}
|
||
|
||
if resp.StatusCode != 200 {
|
||
return nil, fmt.Errorf("token 交换失败: %d - %s", resp.StatusCode, string(body[:min(200, len(body))]))
|
||
}
|
||
|
||
var tokens CodexTokens
|
||
if err := json.Unmarshal(body, &tokens); err != nil {
|
||
return nil, fmt.Errorf("解析 token 失败: %v", err)
|
||
}
|
||
|
||
if tokens.ExpiresIn > 0 {
|
||
expiredAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
||
tokens.ExpiredAt = expiredAt.Format(time.RFC3339)
|
||
}
|
||
|
||
return &tokens, nil
|
||
}
|
||
|
||
// min 返回较小值
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// CompleteWithCodexAPI 使用纯 API 方式完成授权
|
||
// authURL 和 sessionID 由 S2A 生成
|
||
func CompleteWithCodexAPI(email, password, workspaceID, authURL, sessionID, proxy string, logger *AuthLogger) (string, error) {
|
||
if logger != nil {
|
||
logger.LogStep(StepBrowserStart, "使用 CodexAuth API 模式...")
|
||
}
|
||
|
||
auth, err := NewCodexAPIAuth(email, password, workspaceID, authURL, sessionID, proxy, logger)
|
||
if err != nil {
|
||
if logger != nil {
|
||
logger.LogError(StepBrowserStart, "创建 CodexAuth 失败: %v", err)
|
||
}
|
||
return "", err
|
||
}
|
||
|
||
code, err := auth.ObtainAuthorizationCode()
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return code, nil
|
||
}
|