Initial sanitized code sync
This commit is contained in:
154
pkg/auth/codex.go
Normal file
154
pkg/auth/codex.go
Normal 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
47
pkg/auth/datadog.go
Normal 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
709
pkg/auth/oauth.go
Normal 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
441
pkg/auth/register.go
Normal 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
264
pkg/auth/sentinel.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user