442 lines
14 KiB
Go
442 lines
14 KiB
Go
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())
|
|
}
|