oauth_service.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log"
  6. "time"
  7. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  10. )
  11. // OpenAIOAuthClient interface for OpenAI OAuth operations
  12. type OpenAIOAuthClient interface {
  13. ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*openai.TokenResponse, error)
  14. RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*openai.TokenResponse, error)
  15. RefreshTokenWithClientID(ctx context.Context, refreshToken, proxyURL string, clientID string) (*openai.TokenResponse, error)
  16. }
  17. // GrokOAuthClient interface for xAI/Grok OAuth operations.
  18. type GrokOAuthClient interface {
  19. ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error)
  20. RefreshToken(ctx context.Context, refreshToken, proxyURL, clientID string) (*xai.TokenResponse, error)
  21. // LoginWithPassword exchanges email/password for a short-lived Web SSO cookie.
  22. // Callers must convert via ConvertSSOToBuild and must not persist password or raw SSO.
  23. LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*GrokPasswordLoginResult, error)
  24. ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error)
  25. }
  26. // GrokOAuthTokenService is the narrow refresh port used by Grok token providers.
  27. type GrokOAuthTokenService interface {
  28. RefreshAccountToken(ctx context.Context, account *Account) (*GrokTokenInfo, error)
  29. BuildAccountCredentials(tokenInfo *GrokTokenInfo) map[string]any
  30. }
  31. // ClaudeOAuthClient handles HTTP requests for Claude OAuth flows
  32. type ClaudeOAuthClient interface {
  33. GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error)
  34. GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
  35. ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error)
  36. RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
  37. }
  38. // OAuthService handles OAuth authentication flows
  39. type OAuthService struct {
  40. sessionStore *oauth.SessionStore
  41. proxyRepo ProxyRepository
  42. oauthClient ClaudeOAuthClient
  43. }
  44. // NewOAuthService creates a new OAuth service
  45. func NewOAuthService(proxyRepo ProxyRepository, oauthClient ClaudeOAuthClient) *OAuthService {
  46. return &OAuthService{
  47. sessionStore: oauth.NewSessionStore(),
  48. proxyRepo: proxyRepo,
  49. oauthClient: oauthClient,
  50. }
  51. }
  52. // GenerateAuthURLResult contains the authorization URL and session info
  53. type GenerateAuthURLResult struct {
  54. AuthURL string `json:"auth_url"`
  55. SessionID string `json:"session_id"`
  56. }
  57. // GenerateAuthURL generates an OAuth authorization URL with full scope
  58. func (s *OAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*GenerateAuthURLResult, error) {
  59. return s.generateAuthURLWithScope(ctx, oauth.ScopeOAuth, proxyID)
  60. }
  61. // GenerateSetupTokenURL generates an OAuth authorization URL for setup token (inference only)
  62. func (s *OAuthService) GenerateSetupTokenURL(ctx context.Context, proxyID *int64) (*GenerateAuthURLResult, error) {
  63. scope := oauth.ScopeInference
  64. return s.generateAuthURLWithScope(ctx, scope, proxyID)
  65. }
  66. func (s *OAuthService) generateAuthURLWithScope(ctx context.Context, scope string, proxyID *int64) (*GenerateAuthURLResult, error) {
  67. // Generate PKCE values
  68. state, err := oauth.GenerateState()
  69. if err != nil {
  70. return nil, fmt.Errorf("failed to generate state: %w", err)
  71. }
  72. codeVerifier, err := oauth.GenerateCodeVerifier()
  73. if err != nil {
  74. return nil, fmt.Errorf("failed to generate code verifier: %w", err)
  75. }
  76. codeChallenge := oauth.GenerateCodeChallenge(codeVerifier)
  77. // Generate session ID
  78. sessionID, err := oauth.GenerateSessionID()
  79. if err != nil {
  80. return nil, fmt.Errorf("failed to generate session ID: %w", err)
  81. }
  82. // Get proxy URL if specified
  83. var proxyURL string
  84. if proxyID != nil {
  85. proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
  86. if err == nil && proxy != nil {
  87. proxyURL = proxy.URL()
  88. }
  89. }
  90. // Store session
  91. session := &oauth.OAuthSession{
  92. State: state,
  93. CodeVerifier: codeVerifier,
  94. Scope: scope,
  95. ProxyURL: proxyURL,
  96. CreatedAt: time.Now(),
  97. }
  98. s.sessionStore.Set(sessionID, session)
  99. // Build authorization URL
  100. authURL := oauth.BuildAuthorizationURL(state, codeChallenge, scope)
  101. return &GenerateAuthURLResult{
  102. AuthURL: authURL,
  103. SessionID: sessionID,
  104. }, nil
  105. }
  106. // ExchangeCodeInput represents the input for code exchange
  107. type ExchangeCodeInput struct {
  108. SessionID string
  109. Code string
  110. ProxyID *int64
  111. }
  112. // TokenInfo represents the token information stored in credentials
  113. type TokenInfo struct {
  114. AccessToken string `json:"access_token"`
  115. TokenType string `json:"token_type"`
  116. ExpiresIn int64 `json:"expires_in"`
  117. ExpiresAt int64 `json:"expires_at"`
  118. RefreshToken string `json:"refresh_token,omitempty"`
  119. Scope string `json:"scope,omitempty"`
  120. OrgUUID string `json:"org_uuid,omitempty"`
  121. AccountUUID string `json:"account_uuid,omitempty"`
  122. EmailAddress string `json:"email_address,omitempty"`
  123. }
  124. // ExchangeCode exchanges authorization code for tokens
  125. func (s *OAuthService) ExchangeCode(ctx context.Context, input *ExchangeCodeInput) (*TokenInfo, error) {
  126. // Get session
  127. session, ok := s.sessionStore.Get(input.SessionID)
  128. if !ok {
  129. return nil, fmt.Errorf("session not found or expired")
  130. }
  131. // Get proxy URL
  132. proxyURL := session.ProxyURL
  133. if input.ProxyID != nil {
  134. proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
  135. if err == nil && proxy != nil {
  136. proxyURL = proxy.URL()
  137. }
  138. }
  139. // Determine if this is a setup token (scope is inference only)
  140. isSetupToken := session.Scope == oauth.ScopeInference
  141. // Exchange code for token
  142. tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL, isSetupToken)
  143. if err != nil {
  144. return nil, err
  145. }
  146. // Delete session after successful exchange
  147. s.sessionStore.Delete(input.SessionID)
  148. return tokenInfo, nil
  149. }
  150. // CookieAuthInput represents the input for cookie-based authentication
  151. type CookieAuthInput struct {
  152. SessionKey string
  153. ProxyID *int64
  154. Scope string // "full" or "inference"
  155. }
  156. // CookieAuth performs OAuth using sessionKey (cookie-based auto-auth)
  157. func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (*TokenInfo, error) {
  158. // Get proxy URL if specified
  159. var proxyURL string
  160. if input.ProxyID != nil {
  161. proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
  162. if err == nil && proxy != nil {
  163. proxyURL = proxy.URL()
  164. }
  165. }
  166. // Determine scope and if this is a setup token
  167. // Internal API call uses ScopeAPI (org:create_api_key not supported)
  168. scope := oauth.ScopeAPI
  169. isSetupToken := false
  170. if input.Scope == "inference" {
  171. scope = oauth.ScopeInference
  172. isSetupToken = true
  173. }
  174. // Step 1: Get organization info using sessionKey
  175. orgUUID, err := s.getOrganizationUUID(ctx, input.SessionKey, proxyURL)
  176. if err != nil {
  177. return nil, fmt.Errorf("failed to get organization info: %w", err)
  178. }
  179. // Step 2: Generate PKCE values
  180. codeVerifier, err := oauth.GenerateCodeVerifier()
  181. if err != nil {
  182. return nil, fmt.Errorf("failed to generate code verifier: %w", err)
  183. }
  184. codeChallenge := oauth.GenerateCodeChallenge(codeVerifier)
  185. state, err := oauth.GenerateState()
  186. if err != nil {
  187. return nil, fmt.Errorf("failed to generate state: %w", err)
  188. }
  189. // Step 3: Get authorization code using cookie
  190. authCode, err := s.getAuthorizationCode(ctx, input.SessionKey, orgUUID, scope, codeChallenge, state, proxyURL)
  191. if err != nil {
  192. return nil, fmt.Errorf("failed to get authorization code: %w", err)
  193. }
  194. // Step 4: Exchange code for token
  195. tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL, isSetupToken)
  196. if err != nil {
  197. return nil, fmt.Errorf("failed to exchange code: %w", err)
  198. }
  199. // Ensure org_uuid is set (from step 1 if not from token response)
  200. if tokenInfo.OrgUUID == "" && orgUUID != "" {
  201. tokenInfo.OrgUUID = orgUUID
  202. log.Printf("[OAuth] Set org_uuid from cookie auth")
  203. }
  204. return tokenInfo, nil
  205. }
  206. // getOrganizationUUID gets the organization UUID from claude.ai using sessionKey
  207. func (s *OAuthService) getOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error) {
  208. return s.oauthClient.GetOrganizationUUID(ctx, sessionKey, proxyURL)
  209. }
  210. // getAuthorizationCode gets the authorization code using sessionKey
  211. func (s *OAuthService) getAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error) {
  212. return s.oauthClient.GetAuthorizationCode(ctx, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL)
  213. }
  214. // exchangeCodeForToken exchanges authorization code for tokens
  215. func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*TokenInfo, error) {
  216. tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL, isSetupToken)
  217. if err != nil {
  218. return nil, err
  219. }
  220. tokenInfo := &TokenInfo{
  221. AccessToken: tokenResp.AccessToken,
  222. TokenType: tokenResp.TokenType,
  223. ExpiresIn: tokenResp.ExpiresIn,
  224. ExpiresAt: time.Now().Unix() + tokenResp.ExpiresIn,
  225. RefreshToken: tokenResp.RefreshToken,
  226. Scope: tokenResp.Scope,
  227. }
  228. if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" {
  229. tokenInfo.OrgUUID = tokenResp.Organization.UUID
  230. log.Printf("[OAuth] Got org_uuid")
  231. }
  232. if tokenResp.Account != nil {
  233. if tokenResp.Account.UUID != "" {
  234. tokenInfo.AccountUUID = tokenResp.Account.UUID
  235. log.Printf("[OAuth] Got account_uuid")
  236. }
  237. if tokenResp.Account.EmailAddress != "" {
  238. tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
  239. log.Printf("[OAuth] Got email_address")
  240. }
  241. }
  242. return tokenInfo, nil
  243. }
  244. // RefreshToken refreshes an OAuth token
  245. func (s *OAuthService) RefreshToken(ctx context.Context, refreshToken string, proxyURL string) (*TokenInfo, error) {
  246. tokenResp, err := s.oauthClient.RefreshToken(ctx, refreshToken, proxyURL)
  247. if err != nil {
  248. return nil, err
  249. }
  250. return &TokenInfo{
  251. AccessToken: tokenResp.AccessToken,
  252. TokenType: tokenResp.TokenType,
  253. ExpiresIn: tokenResp.ExpiresIn,
  254. ExpiresAt: time.Now().Unix() + tokenResp.ExpiresIn,
  255. RefreshToken: tokenResp.RefreshToken,
  256. Scope: tokenResp.Scope,
  257. }, nil
  258. }
  259. // RefreshAccountToken refreshes token for an account
  260. func (s *OAuthService) RefreshAccountToken(ctx context.Context, account *Account) (*TokenInfo, error) {
  261. refreshToken := account.GetCredential("refresh_token")
  262. if refreshToken == "" {
  263. return nil, fmt.Errorf("no refresh token available")
  264. }
  265. var proxyURL string
  266. if account.ProxyID != nil {
  267. proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
  268. if err == nil && proxy != nil {
  269. proxyURL = proxy.URL()
  270. }
  271. }
  272. return s.RefreshToken(ctx, refreshToken, proxyURL)
  273. }
  274. // Stop stops the session store cleanup goroutine
  275. func (s *OAuthService) Stop() {
  276. s.sessionStore.Stop()
  277. }