Initial sanitized code sync

This commit is contained in:
zqq61
2026-03-15 20:48:19 +08:00
commit 17ee51ba04
126 changed files with 22546 additions and 0 deletions

154
pkg/auth/codex.go Normal file
View File

@@ -0,0 +1,154 @@
package auth
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"gpt-plus/pkg/httpclient"
)
// ObtainCodexTokens performs a lightweight OAuth re-authorization using existing
// session cookies to obtain Codex CLI tokens (with refresh_token) for a specific workspace.
//
// This should be called AFTER activation is complete, when the cookie jar already
// contains valid session cookies from the initial login.
//
// targetWorkspaceID: the workspace/account ID to authorize for. Pass "" to use the default (first) workspace.
func ObtainCodexTokens(
ctx context.Context,
client *httpclient.Client,
deviceID string,
targetWorkspaceID string,
) (*LoginResult, error) {
log.Printf("[codex] obtaining Codex CLI tokens for workspace=%s", targetWorkspaceID)
// Ensure oai-did cookie is set
cookieURL, _ := url.Parse(oauthIssuer)
client.GetCookieJar().SetCookies(cookieURL, []*http.Cookie{
{Name: "oai-did", Value: deviceID},
})
// Generate fresh PKCE pair and state for this authorization
codeVerifier, codeChallenge := generatePKCE()
state := generateState()
// Build authorize URL with Codex CLI params
authorizeParams := url.Values{
"response_type": {"code"},
"client_id": {oauthClientID},
"redirect_uri": {oauthRedirectURI},
"scope": {oauthScope},
"code_challenge": {codeChallenge},
"code_challenge_method": {"S256"},
"state": {state},
"id_token_add_organizations": {"true"},
"codex_cli_simplified_flow": {"true"},
}
authorizeURL := oauthIssuer + "/oauth/authorize?" + authorizeParams.Encode()
navH := navigateHeaders()
if targetWorkspaceID == "" {
// Default workspace: auto-follow redirects and use shortcut if code is returned directly.
resp, err := client.Get(authorizeURL, navH)
if err != nil {
return nil, fmt.Errorf("codex authorize request: %w", err)
}
httpclient.ReadBody(resp)
if resp.Request != nil {
if code := extractCodeFromURL(resp.Request.URL.String()); code != "" {
log.Printf("[codex] got code directly from authorize redirect (default workspace)")
return exchangeCodeForTokens(ctx, client, code, codeVerifier)
}
}
log.Printf("[codex] authorize response: status=%d, url=%s", resp.StatusCode, resp.Request.URL.String())
// Fallback: go through consent flow
consentURL := oauthIssuer + "/sign-in-with-chatgpt/codex/consent"
authCode, err := followConsentRedirects(ctx, client, deviceID, consentURL, "")
if err != nil {
return nil, fmt.Errorf("codex consent flow: %w", err)
}
return exchangeCodeForTokens(ctx, client, authCode, codeVerifier)
}
// ===== Specific workspace requested (e.g. Team) =====
log.Printf("[codex] specific workspace requested, using manual redirect flow")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
if err != nil {
return nil, fmt.Errorf("codex build authorize request: %w", err)
}
for k, v := range navH {
req.Header.Set(k, v)
}
// Follow redirects manually. Stop when we see the consent page or a code for the wrong workspace.
currentURL := authorizeURL
for i := 0; i < 15; i++ {
resp, err := client.DoNoRedirect(req)
if err != nil {
if code := extractCodeFromURL(currentURL); code != "" {
log.Printf("[codex] got code from failed redirect (default workspace), ignoring — need workspace %s", targetWorkspaceID)
break
}
return nil, fmt.Errorf("codex authorize redirect: %w", err)
}
httpclient.ReadBody(resp)
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc == "" {
break
}
if !strings.HasPrefix(loc, "http") {
loc = oauthIssuer + loc
}
if code := extractCodeFromURL(loc); code != "" {
log.Printf("[codex] auto-redirect has code (default workspace), ignoring — need workspace %s", targetWorkspaceID)
break
}
log.Printf("[codex] redirect %d: %d → %s", i+1, resp.StatusCode, truncURL(loc))
currentURL = loc
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, loc, nil)
for k, v := range navH {
req.Header.Set(k, v)
}
continue
}
log.Printf("[codex] reached page: status=%d, url=%s", resp.StatusCode, currentURL)
break
}
// Now go through the consent flow with the target workspace.
consentURL := oauthIssuer + "/sign-in-with-chatgpt/codex/consent"
authCode, err := followConsentRedirects(ctx, client, deviceID, consentURL, targetWorkspaceID)
if err != nil {
return nil, fmt.Errorf("codex consent flow for workspace %s: %w", targetWorkspaceID, err)
}
tokens, err := exchangeCodeForTokens(ctx, client, authCode, codeVerifier)
if err != nil {
return nil, fmt.Errorf("codex token exchange: %w", err)
}
log.Printf("[codex] tokens obtained for workspace %s: has_refresh=%v, account_id=%s",
targetWorkspaceID, tokens.RefreshToken != "", tokens.ChatGPTAccountID)
return tokens, nil
}
// truncURL shortens a URL for logging.
func truncURL(u string) string {
if len(u) > 100 {
return u[:100] + "..."
}
return u
}

47
pkg/auth/datadog.go Normal file
View File

@@ -0,0 +1,47 @@
package auth
import (
"crypto/rand"
"fmt"
"math/big"
)
// GenerateDatadogHeaders generates Datadog APM tracing headers
// matching the format used by the OpenAI frontend.
func GenerateDatadogHeaders() map[string]string {
traceID := randomDigits(19)
parentID := randomDigits(16)
traceHex := fmt.Sprintf("%016x", mustParseUint(traceID))
parentHex := fmt.Sprintf("%016x", mustParseUint(parentID))
return map[string]string{
"traceparent": fmt.Sprintf("00-0000000000000000%s-%s-01", traceHex, parentHex),
"tracestate": "dd=s:1;o:rum",
"x-datadog-origin": "rum",
"x-datadog-parent-id": parentID,
"x-datadog-sampling-priority": "1",
"x-datadog-trace-id": traceID,
}
}
func randomDigits(n int) string {
result := make([]byte, n)
for i := range result {
v, _ := rand.Int(rand.Reader, big.NewInt(10))
result[i] = '0' + byte(v.Int64())
}
// Ensure first digit is not 0
if result[0] == '0' {
v, _ := rand.Int(rand.Reader, big.NewInt(9))
result[0] = '1' + byte(v.Int64())
}
return string(result)
}
func mustParseUint(s string) uint64 {
var result uint64
for _, c := range s {
result = result*10 + uint64(c-'0')
}
return result
}

709
pkg/auth/oauth.go Normal file
View File

@@ -0,0 +1,709 @@
package auth
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"gpt-plus/pkg/httpclient"
"gpt-plus/pkg/provider/email"
)
// LoginResult holds the tokens obtained from a successful OAuth login.
type LoginResult struct {
AccessToken string
RefreshToken string
IDToken string
ChatGPTAccountID string
ChatGPTUserID string
}
// Login performs the full OAuth login flow: authorize -> password verify -> token exchange.
func Login(
ctx context.Context,
client *httpclient.Client,
emailAddr, password, deviceID string,
sentinel *SentinelGenerator,
mailboxID string,
emailProvider email.EmailProvider,
) (*LoginResult, error) {
// Keep registration cookies — the verified email session helps skip OTP during login.
// Only ensure oai-did is set.
cookieURL, _ := url.Parse(oauthIssuer)
client.GetCookieJar().SetCookies(cookieURL, []*http.Cookie{
{Name: "oai-did", Value: deviceID},
})
// Generate PKCE and state
codeVerifier, codeChallenge := generatePKCE()
state := generateState()
// ===== Step 1: GET /oauth/authorize (no screen_hint) =====
authorizeParams := url.Values{
"response_type": {"code"},
"client_id": {oauthClientID},
"redirect_uri": {oauthRedirectURI},
"scope": {oauthScope},
"code_challenge": {codeChallenge},
"code_challenge_method": {"S256"},
"state": {state},
"id_token_add_organizations": {"true"},
"codex_cli_simplified_flow": {"true"},
}
authorizeURL := oauthIssuer + "/oauth/authorize?" + authorizeParams.Encode()
navH := navigateHeaders()
resp, err := client.Get(authorizeURL, navH)
if err != nil {
return nil, fmt.Errorf("step1 authorize: %w", err)
}
httpclient.ReadBody(resp)
// ===== Step 2: POST authorize/continue with email =====
sentinelToken, err := sentinel.GenerateToken(ctx, client, "authorize_continue")
if err != nil {
return nil, fmt.Errorf("step2 sentinel: %w", err)
}
headers := commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/log-in"
headers["openai-sentinel-token"] = sentinelToken
continueBody := map[string]interface{}{
"username": map[string]string{"kind": "email", "value": emailAddr},
}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/authorize/continue", continueBody, headers)
if err != nil {
return nil, fmt.Errorf("step2 authorize/continue: %w", err)
}
body, _ := httpclient.ReadBody(resp)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("step2 failed (%d): %s", resp.StatusCode, string(body))
}
// ===== Step 3: POST password/verify =====
sentinelToken, err = sentinel.GenerateToken(ctx, client, "password_verify")
if err != nil {
return nil, fmt.Errorf("step3 sentinel: %w", err)
}
headers = commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/log-in/password"
headers["openai-sentinel-token"] = sentinelToken
pwBody := map[string]string{"password": password}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/password/verify", pwBody, headers)
if err != nil {
return nil, fmt.Errorf("step3 password/verify: %w", err)
}
body, _ = httpclient.ReadBody(resp)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("step3 password verify failed (%d): %s", resp.StatusCode, string(body))
}
var verifyResp struct {
ContinueURL string `json:"continue_url"`
Page struct {
Type string `json:"type"`
} `json:"page"`
}
json.Unmarshal(body, &verifyResp)
continueURL := verifyResp.ContinueURL
pageType := verifyResp.Page.Type
log.Printf("[login] step3 password/verify: status=%d, page=%s, continue=%s", resp.StatusCode, pageType, continueURL)
// ===== Step 3.5: Email OTP verification (if triggered for new accounts) =====
if pageType == "email_otp_verification" || pageType == "email_otp_send" ||
strings.Contains(continueURL, "email-verification") || strings.Contains(continueURL, "email-otp") {
if mailboxID == "" || emailProvider == nil {
return nil, errors.New("email verification required but no mailbox/provider available")
}
// Trigger the OTP send explicitly (like registration does) and poll for delivery.
log.Printf("[login] step3.5 OTP required — triggering email-otp/send...")
// Snapshot existing emails BEFORE triggering the send, to skip the registration OTP.
sendHeaders := commonHeaders(deviceID)
sendHeaders["referer"] = oauthIssuer + "/email-verification"
// GET /api/accounts/email-otp/send to trigger the OTP email
_, err = client.Get(oauthIssuer+"/api/accounts/email-otp/send", sendHeaders)
if err != nil {
log.Printf("[login] step3.5 email-otp/send failed (non-fatal): %v", err)
}
otpCode, err := emailProvider.WaitForVerificationCode(ctx, mailboxID, 120*time.Second, time.Now())
if err != nil {
return nil, fmt.Errorf("step3.5 wait for otp: %w", err)
}
if otpCode == "" {
return nil, errors.New("step3.5 wait for otp: empty code returned")
}
log.Printf("[login] step3.5 got OTP code: %s", otpCode)
headers = commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/email-verification"
otpBody := map[string]string{"code": otpCode}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/email-otp/validate", otpBody, headers)
if err != nil {
return nil, fmt.Errorf("step3.5 validate otp: %w", err)
}
body, _ = httpclient.ReadBody(resp)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("step3.5 otp validate failed (%d): %s", resp.StatusCode, string(body))
}
json.Unmarshal(body, &verifyResp)
continueURL = verifyResp.ContinueURL
pageType = verifyResp.Page.Type
log.Printf("[login] step3.5 otp validate: page=%s, continue=%s", pageType, continueURL)
// If about-you step needed, submit name/birthdate
if strings.Contains(continueURL, "about-you") {
firstName, lastName := generateRandomName()
birthdate := generateRandomBirthday()
headers = commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/about-you"
createBody := map[string]string{
"name": firstName + " " + lastName,
"birthdate": birthdate,
}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/create_account", createBody, headers)
if err != nil {
return nil, fmt.Errorf("step3.5 create_account: %w", err)
}
body, _ = httpclient.ReadBody(resp)
if resp.StatusCode == 200 {
var createResp struct {
ContinueURL string `json:"continue_url"`
}
json.Unmarshal(body, &createResp)
continueURL = createResp.ContinueURL
} else if resp.StatusCode == 400 && strings.Contains(string(body), "already_exists") {
continueURL = oauthIssuer + "/sign-in-with-chatgpt/codex/consent"
}
}
}
// Handle consent page type
if strings.Contains(pageType, "consent") || continueURL == "" {
continueURL = oauthIssuer + "/sign-in-with-chatgpt/codex/consent"
}
log.Printf("[login] step4 entering consent flow: page=%s, continue=%s", pageType, continueURL)
// ===== Step 4: Follow consent/workspace/organization redirects to extract code =====
authCode, err := followConsentRedirects(ctx, client, deviceID, continueURL, "")
if err != nil {
return nil, fmt.Errorf("step4 consent flow: %w", err)
}
// ===== Step 5: Exchange code for tokens =====
tokens, err := exchangeCodeForTokens(ctx, client, authCode, codeVerifier)
if err != nil {
return nil, fmt.Errorf("step5 token exchange: %w", err)
}
return tokens, nil
}
// decodeAuthSessionCookie parses the oai-client-auth-session cookie.
// Format: base64(json).timestamp.signature (Flask/itsdangerous style).
func decodeAuthSessionCookie(client *httpclient.Client) (map[string]interface{}, error) {
cookieURL, _ := url.Parse(oauthIssuer)
for _, c := range client.GetCookieJar().Cookies(cookieURL) {
if c.Name == "oai-client-auth-session" {
val := c.Value
firstPart := val
if idx := strings.Index(val, "."); idx > 0 {
firstPart = val[:idx]
}
// Add base64 padding
if pad := 4 - len(firstPart)%4; pad != 4 {
firstPart += strings.Repeat("=", pad)
}
raw, err := base64.URLEncoding.DecodeString(firstPart)
if err != nil {
// Try standard base64
raw, err = base64.StdEncoding.DecodeString(firstPart)
if err != nil {
return nil, fmt.Errorf("decode auth session base64: %w", err)
}
}
var data map[string]interface{}
if err := json.Unmarshal(raw, &data); err != nil {
return nil, fmt.Errorf("parse auth session json: %w", err)
}
return data, nil
}
}
return nil, errors.New("oai-client-auth-session cookie not found")
}
// followConsentRedirects navigates through consent, workspace/select, organization/select
// until extracting the authorization code from the callback URL.
// targetWorkspaceID: if non-empty, select this specific workspace instead of the first one.
func followConsentRedirects(ctx context.Context, client *httpclient.Client, deviceID, continueURL, targetWorkspaceID string) (string, error) {
// Normalize URL
if strings.HasPrefix(continueURL, "/") {
continueURL = oauthIssuer + continueURL
}
navH := navigateHeaders()
var resp *http.Response
if targetWorkspaceID == "" {
// Default: auto-follow redirects. If we get a code directly, use it.
var err error
resp, err = client.Get(continueURL, navH)
if err != nil {
return "", fmt.Errorf("get consent: %w", err)
}
httpclient.ReadBody(resp)
if resp.Request != nil {
if code := extractCodeFromURL(resp.Request.URL.String()); code != "" {
return code, nil
}
}
log.Printf("[login] consent page: status=%d, final_url=%s", resp.StatusCode, resp.Request.URL.String())
} else {
// Specific workspace: use DoNoRedirect to prevent auto-selecting the default workspace.
currentURL := continueURL
for i := 0; i < 15; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil)
if err != nil {
return "", fmt.Errorf("build consent redirect request: %w", err)
}
for k, v := range navH {
req.Header.Set(k, v)
}
resp, err := client.DoNoRedirect(req)
if err != nil {
if code := extractCodeFromURL(currentURL); code != "" {
log.Printf("[login] consent redirect got default code, ignoring for workspace %s", targetWorkspaceID)
break
}
return "", fmt.Errorf("consent redirect: %w", err)
}
httpclient.ReadBody(resp)
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc == "" {
break
}
if !strings.HasPrefix(loc, "http") {
loc = oauthIssuer + loc
}
if code := extractCodeFromURL(loc); code != "" {
log.Printf("[login] consent redirect has default code, ignoring for workspace %s", targetWorkspaceID)
break
}
log.Printf("[login] consent redirect %d: %d → %s", i+1, resp.StatusCode, loc)
currentURL = loc
continue
}
log.Printf("[login] consent page reached: status=%d, url=%s", resp.StatusCode, currentURL)
break
}
}
// Decode oai-client-auth-session cookie to extract workspace data
sessionData, err := decodeAuthSessionCookie(client)
if err != nil {
log.Printf("[login] warning: %v", err)
cookieURL, _ := url.Parse(oauthIssuer)
for _, c := range client.GetCookieJar().Cookies(cookieURL) {
log.Printf("[login] cookie: %s (len=%d)", c.Name, len(c.Value))
}
return "", fmt.Errorf("consent flow: %w", err)
}
log.Printf("[login] decoded auth session, keys: %v", getMapKeys(sessionData))
// Extract workspace_id from session data
var workspaceID string
if workspaces, ok := sessionData["workspaces"].([]interface{}); ok && len(workspaces) > 0 {
// If a specific workspace is requested, find it by ID
if targetWorkspaceID != "" {
for _, w := range workspaces {
if ws, ok := w.(map[string]interface{}); ok {
if id, ok := ws["id"].(string); ok && id == targetWorkspaceID {
workspaceID = id
kind, _ := ws["kind"].(string)
log.Printf("[login] matched target workspace_id: %s (kind: %s)", workspaceID, kind)
break
}
}
}
if workspaceID == "" {
log.Printf("[login] target workspace %s not found in session, falling back to first", targetWorkspaceID)
}
}
// Fallback to first workspace if target not found or not specified
if workspaceID == "" {
if ws, ok := workspaces[0].(map[string]interface{}); ok {
if id, ok := ws["id"].(string); ok {
workspaceID = id
kind, _ := ws["kind"].(string)
log.Printf("[login] workspace_id: %s (kind: %s)", workspaceID, kind)
}
}
}
}
if workspaceID == "" {
// For new accounts that have no workspaces yet, try direct authorize flow
// The consent page itself might have set up the session — try following the
// authorize URL again which should now redirect with a code
log.Printf("[login] no workspaces in session, trying re-authorize...")
code, err := followRedirectsForCode(ctx, client, continueURL)
if err == nil && code != "" {
return code, nil
}
return "", errors.New("no workspace_id found in auth session cookie and re-authorize failed")
}
// POST workspace/select (no redirect following)
headers := commonHeaders(deviceID)
headers["referer"] = continueURL
wsBody := map[string]string{"workspace_id": workspaceID}
wsJSON, _ := json.Marshal(wsBody)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthIssuer+"/api/accounts/workspace/select", strings.NewReader(string(wsJSON)))
if err != nil {
return "", fmt.Errorf("build workspace/select request: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err = client.DoNoRedirect(req)
if err != nil {
return "", fmt.Errorf("workspace/select: %w", err)
}
wsData, _ := httpclient.ReadBody(resp)
log.Printf("[login] workspace/select: status=%d", resp.StatusCode)
// Check for redirect with code
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if code := extractCodeFromURL(loc); code != "" {
return code, nil
}
// Follow redirect chain
if loc != "" {
if !strings.HasPrefix(loc, "http") {
loc = oauthIssuer + loc
}
code, err := followRedirectsForCode(ctx, client, loc)
if err == nil && code != "" {
return code, nil
}
}
}
// Parse workspace/select response for org data
if resp.StatusCode == 200 {
var wsResp struct {
Data struct {
Orgs []struct {
ID string `json:"id"`
Projects []struct {
ID string `json:"id"`
} `json:"projects"`
} `json:"orgs"`
} `json:"data"`
ContinueURL string `json:"continue_url"`
}
json.Unmarshal(wsData, &wsResp)
// Extract org_id and project_id
var orgID, projectID string
if len(wsResp.Data.Orgs) > 0 {
orgID = wsResp.Data.Orgs[0].ID
if len(wsResp.Data.Orgs[0].Projects) > 0 {
projectID = wsResp.Data.Orgs[0].Projects[0].ID
}
}
if orgID != "" {
log.Printf("[login] org_id: %s, project_id: %s", orgID, projectID)
// POST organization/select
orgBody := map[string]string{"org_id": orgID}
if projectID != "" {
orgBody["project_id"] = projectID
}
orgJSON, _ := json.Marshal(orgBody)
orgReq, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthIssuer+"/api/accounts/organization/select", strings.NewReader(string(orgJSON)))
if err != nil {
return "", fmt.Errorf("build organization/select request: %w", err)
}
for k, v := range headers {
orgReq.Header.Set(k, v)
}
resp, err = client.DoNoRedirect(orgReq)
if err != nil {
return "", fmt.Errorf("organization/select: %w", err)
}
orgData, _ := httpclient.ReadBody(resp)
log.Printf("[login] organization/select: status=%d", resp.StatusCode)
// Check for redirect with code
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if code := extractCodeFromURL(loc); code != "" {
return code, nil
}
if loc != "" {
if !strings.HasPrefix(loc, "http") {
loc = oauthIssuer + loc
}
code, err := followRedirectsForCode(ctx, client, loc)
if err == nil && code != "" {
return code, nil
}
}
}
// Parse continue_url from response
if resp.StatusCode == 200 {
var orgResp struct {
ContinueURL string `json:"continue_url"`
}
json.Unmarshal(orgData, &orgResp)
if orgResp.ContinueURL != "" {
continueURL = orgResp.ContinueURL
}
}
} else if wsResp.ContinueURL != "" {
continueURL = wsResp.ContinueURL
}
}
// Follow the final redirect chain to extract the authorization code
if strings.HasPrefix(continueURL, "/") {
continueURL = oauthIssuer + continueURL
}
code, err := followRedirectsForCode(ctx, client, continueURL)
if err != nil {
return "", err
}
if code == "" {
return "", errors.New("could not extract authorization code from redirect chain")
}
return code, nil
}
// getMapKeys returns the keys of a map for logging.
func getMapKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// followRedirectsForCode follows HTTP redirects manually to capture the code from the callback URL.
func followRedirectsForCode(ctx context.Context, client *httpclient.Client, startURL string) (string, error) {
currentURL := startURL
navH := navigateHeaders()
for i := 0; i < 20; i++ { // max 20 redirects
// Before making the request, check if the current URL itself contains the code
// (e.g. localhost callback that we can't actually connect to)
if code := extractCodeFromURL(currentURL); code != "" {
log.Printf("[login] extracted code from URL before request: %s", currentURL)
return code, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil)
if err != nil {
return "", fmt.Errorf("build redirect request: %w", err)
}
for k, v := range navH {
req.Header.Set(k, v)
}
resp, err := client.DoNoRedirect(req)
if err != nil {
// Connection error to localhost callback — extract code from the URL we were trying to reach
if code := extractCodeFromURL(currentURL); code != "" {
log.Printf("[login] extracted code from failed redirect URL: %s", currentURL)
return code, nil
}
return "", fmt.Errorf("redirect request to %s: %w", currentURL, err)
}
httpclient.ReadBody(resp)
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc == "" {
return "", errors.New("redirect with no Location header")
}
if !strings.HasPrefix(loc, "http") {
loc = oauthIssuer + loc
}
log.Printf("[login] redirect %d: %d → %s", i+1, resp.StatusCode, loc)
if code := extractCodeFromURL(loc); code != "" {
return code, nil
}
currentURL = loc
continue
}
log.Printf("[login] redirect chain ended: status=%d, url=%s", resp.StatusCode, currentURL)
// Check the final URL for code
if resp.Request != nil {
if code := extractCodeFromURL(resp.Request.URL.String()); code != "" {
return code, nil
}
}
break
}
return "", nil
}
// exchangeCodeForTokens exchanges an authorization code for OAuth tokens.
func exchangeCodeForTokens(ctx context.Context, client *httpclient.Client, code, codeVerifier string) (*LoginResult, error) {
tokenURL := oauthIssuer + "/oauth/token"
values := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {oauthRedirectURI},
"client_id": {oauthClientID},
"code_verifier": {codeVerifier},
}
headers := map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
resp, err := client.PostForm(tokenURL, values, headers)
if err != nil {
lastErr = fmt.Errorf("token exchange: %w", err)
time.Sleep(1 * time.Second)
continue
}
body, _ := httpclient.ReadBody(resp)
if resp.StatusCode != 200 {
lastErr = fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, string(body))
time.Sleep(1 * time.Second)
continue
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("parse token response: %w", err)
}
lastErr = nil
break
}
if lastErr != nil {
return nil, lastErr
}
// Extract account/user IDs from JWT
accountID, userID := extractIDsFromJWT(tokenResp.AccessToken)
return &LoginResult{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
IDToken: tokenResp.IDToken,
ChatGPTAccountID: accountID,
ChatGPTUserID: userID,
}, nil
}
// extractCodeFromURL extracts the "code" query parameter from a URL.
func extractCodeFromURL(rawURL string) string {
if !strings.Contains(rawURL, "code=") {
return ""
}
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsed.Query().Get("code")
}
// extractIDsFromJWT decodes the JWT payload and extracts chatgpt account/user IDs
// from the "https://api.openai.com/auth" claim.
func extractIDsFromJWT(token string) (accountID, userID string) {
parts := strings.SplitN(token, ".", 3)
if len(parts) < 2 {
return
}
payload := parts[1]
// Add padding if needed
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
// Try RawURLEncoding
decoded, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return
}
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return
}
authClaim, ok := claims["https://api.openai.com/auth"]
if !ok {
return
}
authMap, ok := authClaim.(map[string]interface{})
if !ok {
return
}
if uid, ok := authMap["user_id"].(string); ok {
userID = uid
}
// Account ID is nested in organizations or accounts
if orgs, ok := authMap["organizations"].([]interface{}); ok && len(orgs) > 0 {
if org, ok := orgs[0].(map[string]interface{}); ok {
if id, ok := org["id"].(string); ok {
accountID = id
}
}
}
return
}

441
pkg/auth/register.go Normal file
View File

@@ -0,0 +1,441 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math/big"
"net/http"
"net/url"
"strings"
"time"
"gpt-plus/pkg/httpclient"
"gpt-plus/pkg/provider/email"
)
// DefaultAcceptLanguage is the Accept-Language header value, set via SetLocale().
var DefaultAcceptLanguage = "en-US,en;q=0.9"
// DefaultLanguage is the browser language code (e.g. "ko-KR"), set via SetLocale().
var DefaultLanguage = "en-US"
// SetLocale updates the default language settings for the auth package.
func SetLocale(language, acceptLanguage string) {
if language != "" {
DefaultLanguage = language
}
if acceptLanguage != "" {
DefaultAcceptLanguage = acceptLanguage
}
}
const (
oauthIssuer = "https://auth.openai.com"
oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
oauthRedirectURI = "http://localhost:1455/auth/callback"
oauthScope = "openid profile email offline_access"
)
// RegisterResult holds the output of a successful registration.
type RegisterResult struct {
Email string
Password string
DeviceID string
MailboxID string
CodeVerifier string
State string
FirstName string
LastName string
// Tokens are populated when registration completes the full OAuth flow.
Tokens *LoginResult
}
// generatePKCE generates a PKCE code_verifier and code_challenge (S256).
func generatePKCE() (verifier, challenge string) {
b := make([]byte, 32)
rand.Read(b)
verifier = base64.RawURLEncoding.EncodeToString(b)
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return
}
// generateState returns a random URL-safe base64 state token.
func generateState() string {
b := make([]byte, 32)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
// commonHeaders returns the base API headers used for auth.openai.com requests.
func commonHeaders(deviceID string) map[string]string {
h := map[string]string{
"accept": "application/json",
"accept-language": DefaultAcceptLanguage,
"content-type": "application/json",
"origin": oauthIssuer,
"sec-ch-ua": `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": `"Windows"`,
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"oai-device-id": deviceID,
}
for k, v := range GenerateDatadogHeaders() {
h[k] = v
}
return h
}
// navigateHeaders returns headers for page-navigation GET requests.
func navigateHeaders() map[string]string {
h := map[string]string{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"accept-language": DefaultAcceptLanguage,
"sec-ch-ua": `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": `"Windows"`,
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
}
for k, v := range GenerateDatadogHeaders() {
h[k] = v
}
return h
}
// Register performs the full 6-step registration flow.
func Register(ctx context.Context, client *httpclient.Client, emailProvider email.EmailProvider, password string) (*RegisterResult, error) {
// Create mailbox
emailAddr, mailboxID, err := emailProvider.CreateMailbox(ctx)
if err != nil {
return nil, fmt.Errorf("create mailbox: %w", err)
}
deviceID := generateDeviceID()
sentinel := &SentinelGenerator{DeviceID: deviceID, SID: generateUUID()}
firstName, lastName := generateRandomName()
birthdate := generateRandomBirthday()
// Set oai-did cookie
cookieURL, _ := url.Parse(oauthIssuer)
client.GetCookieJar().SetCookies(cookieURL, []*http.Cookie{
{Name: "oai-did", Value: deviceID},
})
// PKCE
codeVerifier, codeChallenge := generatePKCE()
state := generateState()
// ===== Step 0: OAuth session init =====
authorizeParams := url.Values{
"response_type": {"code"},
"client_id": {oauthClientID},
"redirect_uri": {oauthRedirectURI},
"scope": {oauthScope},
"code_challenge": {codeChallenge},
"code_challenge_method": {"S256"},
"state": {state},
"screen_hint": {"signup"},
"prompt": {"login"},
"id_token_add_organizations": {"true"},
"codex_cli_simplified_flow": {"true"},
}
authorizeURL := oauthIssuer + "/oauth/authorize?" + authorizeParams.Encode()
navH := navigateHeaders()
resp, err := client.DoWithRetry(ctx, 5, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
if err != nil {
return nil, err
}
for k, v := range navH {
req.Header.Set(k, v)
}
return req, nil
})
if err != nil {
return nil, fmt.Errorf("step0 authorize: %w", err)
}
httpclient.ReadBody(resp)
log.Printf("[register] step0 authorize: status=%d, url=%s", resp.StatusCode, resp.Request.URL.String())
// Check for login_session cookie
hasLoginSession := false
for _, cookie := range client.GetCookieJar().Cookies(cookieURL) {
log.Printf("[register] cookie: %s=%s (domain implicit)", cookie.Name, cookie.Value[:min(len(cookie.Value), 20)]+"...")
if cookie.Name == "login_session" {
hasLoginSession = true
}
}
if !hasLoginSession {
log.Printf("[register] WARNING: no login_session cookie found after step0")
}
// ===== Step 0b: POST authorize/continue with email =====
sentinelToken, err := sentinel.GenerateToken(ctx, client, "authorize_continue")
if err != nil {
return nil, fmt.Errorf("step0b sentinel: %w", err)
}
headers := commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/create-account"
headers["openai-sentinel-token"] = sentinelToken
continueBody := map[string]interface{}{
"username": map[string]string{"kind": "email", "value": emailAddr},
"screen_hint": "signup",
}
continueJSON, _ := json.Marshal(continueBody)
resp, err = client.DoWithRetry(ctx, 5, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, oauthIssuer+"/api/accounts/authorize/continue", strings.NewReader(string(continueJSON)))
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
})
if err != nil {
return nil, fmt.Errorf("step0b authorize/continue: %w", err)
}
body, _ := httpclient.ReadBody(resp)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("step0b failed (%d): %s", resp.StatusCode, string(body))
}
log.Printf("[register] step0b authorize/continue: status=%d", resp.StatusCode)
// ===== Step 2: POST register =====
sentinelToken, err = sentinel.GenerateToken(ctx, client, "register")
if err != nil {
return nil, fmt.Errorf("step2 sentinel: %w", err)
}
headers = commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/create-account/password"
headers["openai-sentinel-token"] = sentinelToken
registerBody := map[string]string{
"username": emailAddr,
"password": password,
}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/user/register", registerBody, headers)
if err != nil {
return nil, fmt.Errorf("step2 register: %w", err)
}
body, _ = httpclient.ReadBody(resp)
if resp.StatusCode != 200 && resp.StatusCode != 302 {
return nil, fmt.Errorf("step2 register failed (%d): %s", resp.StatusCode, string(body))
}
log.Printf("[register] step2 register: status=%d, body=%s", resp.StatusCode, string(body)[:min(len(body), 200)])
// ===== Step 3: GET email-otp/send =====
navH["referer"] = oauthIssuer + "/create-account/password"
resp, err = client.Get(oauthIssuer+"/api/accounts/email-otp/send", navH)
if err != nil {
return nil, fmt.Errorf("step3 send otp: %w", err)
}
otpBody, _ := httpclient.ReadBody(resp)
log.Printf("[register] step3 send otp: status=%d, body=%s", resp.StatusCode, string(otpBody)[:min(len(otpBody), 200)])
// Also GET /email-verification page to accumulate cookies
resp, err = client.Get(oauthIssuer+"/email-verification", navH)
if err != nil {
return nil, fmt.Errorf("step3 email-verification page: %w", err)
}
httpclient.ReadBody(resp)
log.Printf("[register] step3 email-verification page: status=%d", resp.StatusCode)
// ===== Step 4: Wait for OTP and validate =====
code, err := emailProvider.WaitForVerificationCode(ctx, mailboxID, 120*time.Second, time.Time{})
if err != nil {
return nil, fmt.Errorf("step4 wait for otp: %w", err)
}
headers = commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/email-verification"
validateBody := map[string]string{"code": code}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/email-otp/validate", validateBody, headers)
if err != nil {
return nil, fmt.Errorf("step4 validate otp: %w", err)
}
body, _ = httpclient.ReadBody(resp)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("step4 validate failed (%d): %s", resp.StatusCode, string(body))
}
// Parse continue_url from validate response
var validateResp struct {
ContinueURL string `json:"continue_url"`
Page struct {
Type string `json:"type"`
} `json:"page"`
}
json.Unmarshal(body, &validateResp)
// ===== Step 5: POST create_account =====
headers = commonHeaders(deviceID)
headers["referer"] = oauthIssuer + "/about-you"
createBody := map[string]string{
"name": firstName + " " + lastName,
"birthdate": birthdate,
}
resp, err = client.PostJSON(oauthIssuer+"/api/accounts/create_account", createBody, headers)
if err != nil {
return nil, fmt.Errorf("step5 create account: %w", err)
}
body, _ = httpclient.ReadBody(resp)
// 200 = success, 400 with "already_exists" is also acceptable
if resp.StatusCode != 200 {
if resp.StatusCode == 400 && strings.Contains(string(body), "already_exists") {
// Account already created during registration, this is fine
} else {
return nil, fmt.Errorf("step5 create account failed (%d): %s", resp.StatusCode, string(body))
}
}
// ===== Step 6: Complete OAuth flow (consent → callback → tokens) =====
log.Printf("[register] step6 completing OAuth flow to get tokens")
// Determine consent continue URL
var createResp struct {
ContinueURL string `json:"continue_url"`
}
json.Unmarshal(body, &createResp)
consentURL := createResp.ContinueURL
if consentURL == "" {
consentURL = oauthIssuer + "/sign-in-with-chatgpt/codex/consent"
}
authCode, err := followConsentRedirects(ctx, client, deviceID, consentURL, "")
if err != nil {
log.Printf("[register] step6 consent flow failed (non-fatal): %v", err)
// Return without tokens — caller will need to do a separate login
return &RegisterResult{
Email: emailAddr,
Password: password,
DeviceID: deviceID,
MailboxID: mailboxID,
CodeVerifier: codeVerifier,
State: state,
FirstName: firstName,
LastName: lastName,
}, nil
}
tokens, err := exchangeCodeForTokens(ctx, client, authCode, codeVerifier)
if err != nil {
log.Printf("[register] step6 token exchange failed (non-fatal): %v", err)
return &RegisterResult{
Email: emailAddr,
Password: password,
DeviceID: deviceID,
MailboxID: mailboxID,
CodeVerifier: codeVerifier,
State: state,
FirstName: firstName,
LastName: lastName,
}, nil
}
log.Printf("[register] step6 OAuth flow complete, got tokens")
return &RegisterResult{
Email: emailAddr,
Password: password,
DeviceID: deviceID,
MailboxID: mailboxID,
CodeVerifier: codeVerifier,
State: state,
FirstName: firstName,
LastName: lastName,
Tokens: tokens,
}, nil
}
// GenerateRandomPassword creates a random password with mixed character types.
func GenerateRandomPassword(length int) string {
if length < 8 {
length = 16
}
const (
upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
lower = "abcdefghijklmnopqrstuvwxyz"
digits = "0123456789"
special = "!@#$%^&*"
)
all := upper + lower + digits + special
// Ensure at least one of each type
pw := make([]byte, length)
pw[0] = upper[randInt(len(upper))]
pw[1] = lower[randInt(len(lower))]
pw[2] = digits[randInt(len(digits))]
pw[3] = special[randInt(len(special))]
for i := 4; i < length; i++ {
pw[i] = all[randInt(len(all))]
}
// Shuffle
for i := len(pw) - 1; i > 0; i-- {
j := randInt(i + 1)
pw[i], pw[j] = pw[j], pw[i]
}
return string(pw)
}
var firstNames = []string{
"James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda",
"David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica",
"Thomas", "Sarah", "Charles", "Karen", "Christopher", "Lisa", "Daniel", "Nancy",
}
var lastNames = []string{
"Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis",
"Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson",
"Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White",
}
func generateRandomName() (first, last string) {
first = firstNames[randInt(len(firstNames))]
last = lastNames[randInt(len(lastNames))]
return
}
func generateRandomBirthday() string {
year := 1985 + randInt(16) // 1985-2000
month := 1 + randInt(12)
day := 1 + randInt(28)
return fmt.Sprintf("%04d-%02d-%02d", year, month, day)
}
func generateDeviceID() string {
return generateUUID()
}
func generateUUID() string {
b := make([]byte, 16)
rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func randInt(max int) int {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max)))
return int(n.Int64())
}

264
pkg/auth/sentinel.go Normal file
View File

@@ -0,0 +1,264 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"math"
"math/big"
"net/http"
"strings"
"time"
"gpt-plus/pkg/httpclient"
)
const (
sentinelReqURL = "https://sentinel.openai.com/backend-api/sentinel/req"
maxPowAttempts = 500000
errorPrefix = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D"
defaultUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
)
// SentinelGenerator generates openai-sentinel-token values.
type SentinelGenerator struct {
DeviceID string
SID string // session UUID
client *httpclient.Client
}
// NewSentinelGenerator creates a new SentinelGenerator with a random DeviceID.
func NewSentinelGenerator(client *httpclient.Client) *SentinelGenerator {
return &SentinelGenerator{
DeviceID: generateUUID(),
SID: generateUUID(),
client: client,
}
}
// NewSentinelGeneratorWithDeviceID creates a SentinelGenerator reusing an existing DeviceID.
// Use this to keep sentinel.DeviceID consistent with oai-device-id in request headers.
func NewSentinelGeneratorWithDeviceID(client *httpclient.Client, deviceID string) *SentinelGenerator {
return &SentinelGenerator{
DeviceID: deviceID,
SID: generateUUID(),
client: client,
}
}
// sentinelRequirements is the response from the sentinel /req endpoint.
type sentinelRequirements struct {
Token string `json:"token"`
ProofOfWork struct {
Required bool `json:"required"`
Seed string `json:"seed"`
Difficulty string `json:"difficulty"`
} `json:"proofofwork"`
}
// fnv1a32 computes FNV-1a 32-bit hash with murmurhash3 finalizer mixing,
// matching the sentinel SDK's JavaScript implementation.
func fnv1a32(text string) string {
h := uint32(2166136261) // FNV offset basis
for _, c := range []byte(text) {
h ^= uint32(c)
h *= 16777619 // FNV prime
}
// xorshift mixing (murmurhash3 finalizer)
h ^= h >> 16
h *= 2246822507
h ^= h >> 13
h *= 3266489909
h ^= h >> 16
return fmt.Sprintf("%08x", h)
}
// buildConfig constructs the 19-element browser environment config array.
func (sg *SentinelGenerator) buildConfig() []interface{} {
now := time.Now().UTC()
dateStr := now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)")
navRandom := cryptoRandomFloat()
perfNow := 100.5 + cryptoRandomFloat()*50
timeOrigin := float64(now.UnixMilli()) - perfNow
config := []interface{}{
"1920x1080", // [0] screen
dateStr, // [1] date
4294705152, // [2] jsHeapSizeLimit
0, // [3] nonce placeholder
defaultUA, // [4] userAgent
"https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js", // [5] script src
nil, // [6] script version
nil, // [7] data build
DefaultLanguage, // [8] language
0, // [9] elapsed_ms placeholder
navRandom, // [10] random float
0, // [11] nav val
0, // [12] doc key
10, // [13] win key
perfNow, // [14] performance.now
sg.SID, // [15] session UUID
"", // [16] URL params
16, // [17] hardwareConcurrency
timeOrigin, // [18] timeOrigin
}
return config
}
// base64EncodeConfig JSON-encodes the config array with compact separators, then base64 encodes it.
func base64EncodeConfig(config []interface{}) string {
jsonBytes, _ := json.Marshal(config)
return base64.StdEncoding.EncodeToString(jsonBytes)
}
// runCheck performs a single PoW check iteration.
func (sg *SentinelGenerator) runCheck(startTime time.Time, seed, difficulty string, config []interface{}, nonce int) string {
config[3] = nonce
config[9] = int(time.Since(startTime).Milliseconds())
data := base64EncodeConfig(config)
hashHex := fnv1a32(seed + data)
diffLen := len(difficulty)
if diffLen > 0 && hashHex[:diffLen] <= difficulty {
return data + "~S"
}
return ""
}
// solvePoW runs the PoW loop and returns the solution token.
func (sg *SentinelGenerator) solvePoW(seed, difficulty string) string {
startTime := time.Now()
config := sg.buildConfig()
for i := 0; i < maxPowAttempts; i++ {
result := sg.runCheck(startTime, seed, difficulty, config, i)
if result != "" {
return "gAAAAAB" + result
}
}
// PoW failed after max attempts, return error token
errData := base64EncodeConfig([]interface{}{nil})
return "gAAAAAB" + errorPrefix + errData
}
// generateRequirementsToken creates a requirements token (no server seed needed).
// This is the SDK's getRequirementsToken() equivalent.
func (sg *SentinelGenerator) generateRequirementsToken() string {
config := sg.buildConfig()
config[3] = 1
config[9] = 5 + int(cryptoRandomFloat()*45) // simulate small delay 5-50ms
data := base64EncodeConfig(config)
return "gAAAAAC" + data // note: prefix is C, not B
}
// fetchChallenge calls the sentinel backend to get challenge data.
// Retries on 403/network errors since the proxy rotates IP every ~30s.
func (sg *SentinelGenerator) fetchChallenge(ctx context.Context, client *httpclient.Client, flow string) (*sentinelRequirements, error) {
const maxRetries = 5
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
result, err := sg.doFetchChallenge(ctx, client, flow)
if err == nil {
return result, nil
}
lastErr = err
log.Printf("[sentinel] attempt %d/%d failed: %v", attempt, maxRetries, err)
if attempt < maxRetries {
// Wait for proxy IP rotation, use shorter waits for early attempts
wait := time.Duration(3*attempt) * time.Second
log.Printf("[sentinel] waiting %v for proxy IP rotation before retry...", wait)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
}
return nil, fmt.Errorf("sentinel failed after %d attempts: %w", maxRetries, lastErr)
}
func (sg *SentinelGenerator) doFetchChallenge(ctx context.Context, client *httpclient.Client, flow string) (*sentinelRequirements, error) {
pToken := sg.generateRequirementsToken()
reqBody := map[string]string{
"p": pToken,
"id": sg.DeviceID,
"flow": flow,
}
bodyBytes, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sentinelReqURL, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("create sentinel request: %w", err)
}
req.Header.Set("Content-Type", "text/plain;charset=UTF-8")
req.Header.Set("Origin", "https://sentinel.openai.com")
req.Header.Set("Referer", "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"Windows"`)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("sentinel request: %w", err)
}
body, err := httpclient.ReadBody(resp)
if err != nil {
return nil, fmt.Errorf("read sentinel response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("sentinel returned %d", resp.StatusCode)
}
var result sentinelRequirements
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parse sentinel response: %w", err)
}
return &result, nil
}
// GenerateToken builds the full openai-sentinel-token JSON string for a given flow.
func (sg *SentinelGenerator) GenerateToken(ctx context.Context, client *httpclient.Client, flow string) (string, error) {
if client == nil {
client = sg.client
}
challenge, err := sg.fetchChallenge(ctx, client, flow)
if err != nil {
return "", fmt.Errorf("fetch sentinel challenge: %w", err)
}
cValue := challenge.Token
var pValue string
if challenge.ProofOfWork.Required && challenge.ProofOfWork.Seed != "" {
pValue = sg.solvePoW(challenge.ProofOfWork.Seed, challenge.ProofOfWork.Difficulty)
} else {
pValue = sg.generateRequirementsToken()
}
token := map[string]string{
"p": pValue,
"t": "",
"c": cValue,
"id": sg.DeviceID,
"flow": flow,
}
tokenBytes, _ := json.Marshal(token)
return string(tokenBytes), nil
}
// cryptoRandomFloat returns a cryptographically random float64 in [0, 1).
func cryptoRandomFloat() float64 {
n, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
return float64(n.Int64()) / float64(math.MaxInt64)
}