oauth.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. package openai
  2. import (
  3. "crypto/rand"
  4. "crypto/sha256"
  5. "encoding/base64"
  6. "encoding/hex"
  7. "encoding/json"
  8. "fmt"
  9. "net/url"
  10. "strings"
  11. "sync"
  12. "time"
  13. )
  14. // OpenAI OAuth Constants (from CRS project - Codex CLI client)
  15. const (
  16. // OAuth Client ID for OpenAI (Codex CLI official)
  17. ClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
  18. // OAuth endpoints
  19. AuthorizeURL = "https://auth.openai.com/oauth/authorize"
  20. TokenURL = "https://auth.openai.com/oauth/token"
  21. // Default redirect URI (can be customized)
  22. DefaultRedirectURI = "http://localhost:1455/auth/callback"
  23. // Scopes
  24. DefaultScopes = "openid profile email offline_access"
  25. // RefreshScopes - scope for token refresh (without offline_access, aligned with CRS project)
  26. RefreshScopes = "openid profile email"
  27. // Session TTL
  28. SessionTTL = 30 * time.Minute
  29. )
  30. const (
  31. // OAuthPlatformOpenAI uses OpenAI Codex-compatible OAuth client.
  32. OAuthPlatformOpenAI = "openai"
  33. )
  34. // OAuthSession stores OAuth flow state for OpenAI
  35. type OAuthSession struct {
  36. State string `json:"state"`
  37. CodeVerifier string `json:"code_verifier"`
  38. ClientID string `json:"client_id,omitempty"`
  39. ProxyURL string `json:"proxy_url,omitempty"`
  40. RedirectURI string `json:"redirect_uri"`
  41. CreatedAt time.Time `json:"created_at"`
  42. }
  43. // SessionStore manages OAuth sessions in memory
  44. type SessionStore struct {
  45. mu sync.RWMutex
  46. sessions map[string]*OAuthSession
  47. stopOnce sync.Once
  48. stopCh chan struct{}
  49. }
  50. // NewSessionStore creates a new session store
  51. func NewSessionStore() *SessionStore {
  52. store := &SessionStore{
  53. sessions: make(map[string]*OAuthSession),
  54. stopCh: make(chan struct{}),
  55. }
  56. // Start cleanup goroutine
  57. go store.cleanup()
  58. return store
  59. }
  60. // Set stores a session
  61. func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
  62. s.mu.Lock()
  63. defer s.mu.Unlock()
  64. s.sessions[sessionID] = session
  65. }
  66. // Get retrieves a session
  67. func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
  68. s.mu.RLock()
  69. defer s.mu.RUnlock()
  70. session, ok := s.sessions[sessionID]
  71. if !ok {
  72. return nil, false
  73. }
  74. // Check if expired
  75. if time.Since(session.CreatedAt) > SessionTTL {
  76. return nil, false
  77. }
  78. return session, true
  79. }
  80. // Delete removes a session
  81. func (s *SessionStore) Delete(sessionID string) {
  82. s.mu.Lock()
  83. defer s.mu.Unlock()
  84. delete(s.sessions, sessionID)
  85. }
  86. // Stop stops the cleanup goroutine
  87. func (s *SessionStore) Stop() {
  88. s.stopOnce.Do(func() {
  89. close(s.stopCh)
  90. })
  91. }
  92. // cleanup removes expired sessions periodically
  93. func (s *SessionStore) cleanup() {
  94. ticker := time.NewTicker(5 * time.Minute)
  95. defer ticker.Stop()
  96. for {
  97. select {
  98. case <-s.stopCh:
  99. return
  100. case <-ticker.C:
  101. s.mu.Lock()
  102. for id, session := range s.sessions {
  103. if time.Since(session.CreatedAt) > SessionTTL {
  104. delete(s.sessions, id)
  105. }
  106. }
  107. s.mu.Unlock()
  108. }
  109. }
  110. }
  111. // GenerateRandomBytes generates cryptographically secure random bytes
  112. func GenerateRandomBytes(n int) ([]byte, error) {
  113. b := make([]byte, n)
  114. _, err := rand.Read(b)
  115. if err != nil {
  116. return nil, err
  117. }
  118. return b, nil
  119. }
  120. // GenerateState generates a random state string for OAuth
  121. func GenerateState() (string, error) {
  122. bytes, err := GenerateRandomBytes(32)
  123. if err != nil {
  124. return "", err
  125. }
  126. return hex.EncodeToString(bytes), nil
  127. }
  128. // GenerateSessionID generates a unique session ID
  129. func GenerateSessionID() (string, error) {
  130. bytes, err := GenerateRandomBytes(16)
  131. if err != nil {
  132. return "", err
  133. }
  134. return hex.EncodeToString(bytes), nil
  135. }
  136. // GenerateCodeVerifier generates a PKCE code verifier (64 bytes -> hex for OpenAI)
  137. // OpenAI uses hex encoding instead of base64url
  138. func GenerateCodeVerifier() (string, error) {
  139. bytes, err := GenerateRandomBytes(64)
  140. if err != nil {
  141. return "", err
  142. }
  143. return hex.EncodeToString(bytes), nil
  144. }
  145. // GenerateCodeChallenge generates a PKCE code challenge using S256 method
  146. // Uses base64url encoding as per RFC 7636
  147. func GenerateCodeChallenge(verifier string) string {
  148. hash := sha256.Sum256([]byte(verifier))
  149. return base64URLEncode(hash[:])
  150. }
  151. // base64URLEncode encodes bytes to base64url without padding
  152. func base64URLEncode(data []byte) string {
  153. encoded := base64.URLEncoding.EncodeToString(data)
  154. // Remove padding
  155. return strings.TrimRight(encoded, "=")
  156. }
  157. // BuildAuthorizationURL builds the OpenAI OAuth authorization URL
  158. func BuildAuthorizationURL(state, codeChallenge, redirectURI string) string {
  159. return BuildAuthorizationURLForPlatform(state, codeChallenge, redirectURI, OAuthPlatformOpenAI)
  160. }
  161. // BuildAuthorizationURLForPlatform builds authorization URL by platform.
  162. func BuildAuthorizationURLForPlatform(state, codeChallenge, redirectURI, platform string) string {
  163. if redirectURI == "" {
  164. redirectURI = DefaultRedirectURI
  165. }
  166. clientID, codexFlow := OAuthClientConfigByPlatform(platform)
  167. params := url.Values{}
  168. params.Set("response_type", "code")
  169. params.Set("client_id", clientID)
  170. params.Set("redirect_uri", redirectURI)
  171. params.Set("scope", DefaultScopes)
  172. params.Set("state", state)
  173. params.Set("code_challenge", codeChallenge)
  174. params.Set("code_challenge_method", "S256")
  175. // OpenAI specific parameters
  176. params.Set("id_token_add_organizations", "true")
  177. if codexFlow {
  178. params.Set("codex_cli_simplified_flow", "true")
  179. }
  180. return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode())
  181. }
  182. // OAuthClientConfigByPlatform returns oauth client_id and whether codex simplified flow should be enabled.
  183. func OAuthClientConfigByPlatform(platform string) (clientID string, codexFlow bool) {
  184. return ClientID, true
  185. }
  186. // TokenRequest represents the token exchange request body
  187. type TokenRequest struct {
  188. GrantType string `json:"grant_type"`
  189. ClientID string `json:"client_id"`
  190. Code string `json:"code"`
  191. RedirectURI string `json:"redirect_uri"`
  192. CodeVerifier string `json:"code_verifier"`
  193. }
  194. // TokenResponse represents the token response from OpenAI OAuth
  195. type TokenResponse struct {
  196. AccessToken string `json:"access_token"`
  197. IDToken string `json:"id_token"`
  198. TokenType string `json:"token_type"`
  199. ExpiresIn int64 `json:"expires_in"`
  200. RefreshToken string `json:"refresh_token,omitempty"`
  201. Scope string `json:"scope,omitempty"`
  202. }
  203. // RefreshTokenRequest represents the refresh token request
  204. type RefreshTokenRequest struct {
  205. GrantType string `json:"grant_type"`
  206. RefreshToken string `json:"refresh_token"`
  207. ClientID string `json:"client_id"`
  208. Scope string `json:"scope"`
  209. }
  210. // IDTokenClaims represents the claims from OpenAI ID Token
  211. type IDTokenClaims struct {
  212. // Standard claims
  213. Sub string `json:"sub"`
  214. Email string `json:"email"`
  215. EmailVerified bool `json:"email_verified"`
  216. Iss string `json:"iss"`
  217. Aud []string `json:"aud"` // OpenAI returns aud as an array
  218. Exp int64 `json:"exp"`
  219. Iat int64 `json:"iat"`
  220. // OpenAI specific claims (nested under https://api.openai.com/auth)
  221. OpenAIAuth *OpenAIAuthClaims `json:"https://api.openai.com/auth,omitempty"`
  222. }
  223. // OpenAIAuthClaims represents the OpenAI specific auth claims
  224. type OpenAIAuthClaims struct {
  225. ChatGPTAccountID string `json:"chatgpt_account_id"`
  226. ChatGPTUserID string `json:"chatgpt_user_id"`
  227. ChatGPTPlanType string `json:"chatgpt_plan_type"`
  228. UserID string `json:"user_id"`
  229. POID string `json:"poid"` // organization ID in access_token JWT
  230. Organizations []OrganizationClaim `json:"organizations"`
  231. }
  232. // OrganizationClaim represents an organization in the ID Token
  233. type OrganizationClaim struct {
  234. ID string `json:"id"`
  235. Role string `json:"role"`
  236. Title string `json:"title"`
  237. IsDefault bool `json:"is_default"`
  238. }
  239. // BuildTokenRequest creates a token exchange request for OpenAI
  240. func BuildTokenRequest(code, codeVerifier, redirectURI string) *TokenRequest {
  241. if redirectURI == "" {
  242. redirectURI = DefaultRedirectURI
  243. }
  244. return &TokenRequest{
  245. GrantType: "authorization_code",
  246. ClientID: ClientID,
  247. Code: code,
  248. RedirectURI: redirectURI,
  249. CodeVerifier: codeVerifier,
  250. }
  251. }
  252. // BuildRefreshTokenRequest creates a refresh token request for OpenAI
  253. func BuildRefreshTokenRequest(refreshToken string) *RefreshTokenRequest {
  254. return &RefreshTokenRequest{
  255. GrantType: "refresh_token",
  256. RefreshToken: refreshToken,
  257. ClientID: ClientID,
  258. Scope: RefreshScopes,
  259. }
  260. }
  261. // ToFormData converts TokenRequest to URL-encoded form data
  262. func (r *TokenRequest) ToFormData() string {
  263. params := url.Values{}
  264. params.Set("grant_type", r.GrantType)
  265. params.Set("client_id", r.ClientID)
  266. params.Set("code", r.Code)
  267. params.Set("redirect_uri", r.RedirectURI)
  268. params.Set("code_verifier", r.CodeVerifier)
  269. return params.Encode()
  270. }
  271. // ToFormData converts RefreshTokenRequest to URL-encoded form data
  272. func (r *RefreshTokenRequest) ToFormData() string {
  273. params := url.Values{}
  274. params.Set("grant_type", r.GrantType)
  275. params.Set("client_id", r.ClientID)
  276. params.Set("refresh_token", r.RefreshToken)
  277. params.Set("scope", r.Scope)
  278. return params.Encode()
  279. }
  280. // DecodeIDToken decodes the ID Token JWT payload without validating expiration.
  281. // Use this for best-effort extraction (e.g., during data import) where the token may be expired.
  282. func DecodeIDToken(idToken string) (*IDTokenClaims, error) {
  283. parts := strings.Split(idToken, ".")
  284. if len(parts) != 3 {
  285. return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
  286. }
  287. // Decode payload (second part)
  288. payload := parts[1]
  289. // Add padding if necessary
  290. switch len(payload) % 4 {
  291. case 2:
  292. payload += "=="
  293. case 3:
  294. payload += "="
  295. }
  296. decoded, err := base64.URLEncoding.DecodeString(payload)
  297. if err != nil {
  298. // Try standard encoding
  299. decoded, err = base64.StdEncoding.DecodeString(payload)
  300. if err != nil {
  301. return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
  302. }
  303. }
  304. var claims IDTokenClaims
  305. if err := json.Unmarshal(decoded, &claims); err != nil {
  306. return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
  307. }
  308. return &claims, nil
  309. }
  310. // ParseIDToken parses the ID Token JWT and extracts claims.
  311. // 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。
  312. // 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名:
  313. //
  314. // https://auth.openai.com/.well-known/jwks.json
  315. func ParseIDToken(idToken string) (*IDTokenClaims, error) {
  316. claims, err := DecodeIDToken(idToken)
  317. if err != nil {
  318. return nil, err
  319. }
  320. // 校验 ID Token 是否已过期(允许 2 分钟时钟偏差,防止因服务器时钟略有差异误判刚颁发的令牌)
  321. const clockSkewTolerance = 120 // 秒
  322. now := time.Now().Unix()
  323. if claims.Exp > 0 && now > claims.Exp+clockSkewTolerance {
  324. return nil, fmt.Errorf("id_token has expired (exp: %d, now: %d, skew_tolerance: %ds)", claims.Exp, now, clockSkewTolerance)
  325. }
  326. return claims, nil
  327. }
  328. // UserInfo represents user information extracted from ID Token claims.
  329. type UserInfo struct {
  330. Email string
  331. ChatGPTAccountID string
  332. ChatGPTUserID string
  333. PlanType string
  334. UserID string
  335. OrganizationID string
  336. Organizations []OrganizationClaim
  337. }
  338. // GetUserInfo extracts user info from ID Token claims
  339. func (c *IDTokenClaims) GetUserInfo() *UserInfo {
  340. info := &UserInfo{
  341. Email: c.Email,
  342. }
  343. if c.OpenAIAuth != nil {
  344. info.ChatGPTAccountID = c.OpenAIAuth.ChatGPTAccountID
  345. info.ChatGPTUserID = c.OpenAIAuth.ChatGPTUserID
  346. info.PlanType = c.OpenAIAuth.ChatGPTPlanType
  347. info.UserID = c.OpenAIAuth.UserID
  348. info.Organizations = c.OpenAIAuth.Organizations
  349. // Get default organization ID
  350. for _, org := range c.OpenAIAuth.Organizations {
  351. if org.IsDefault {
  352. info.OrganizationID = org.ID
  353. break
  354. }
  355. }
  356. // If no default, use first org
  357. if info.OrganizationID == "" && len(c.OpenAIAuth.Organizations) > 0 {
  358. info.OrganizationID = c.OpenAIAuth.Organizations[0].ID
  359. }
  360. }
  361. return info
  362. }