package bkosmi import ( "bytes" "encoding/json" "fmt" "net/http" "sync" "time" "github.com/sirupsen/logrus" ) const ( // Token expiry buffer - refresh if token expires within this window tokenExpiryBuffer = 5 * time.Minute ) // AuthManager handles authentication with Kosmi type AuthManager struct { email string password string token string tokenExpiry time.Time refreshToken string userID string mu sync.RWMutex log *logrus.Entry httpClient *http.Client } // NewAuthManager creates a new authentication manager func NewAuthManager(email, password string, log *logrus.Entry) *AuthManager { return &AuthManager{ email: email, password: password, log: log, httpClient: &http.Client{Timeout: 10 * time.Second}, } } // Login performs email/password authentication // // NOTE: The actual login API format needs to be verified through monitoring. // This implementation is based on common GraphQL patterns and may need adjustment. func (a *AuthManager) Login() error { a.mu.Lock() defer a.mu.Unlock() a.log.Info("Logging in to Kosmi...") // Prepare login mutation // Based on common GraphQL patterns, likely something like: // mutation { login(email: "...", password: "...") { token user { id displayName } } } mutation := map[string]interface{}{ "query": `mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { token refreshToken expiresIn user { id displayName username } } }`, "variables": map[string]interface{}{ "email": a.email, "password": a.password, }, } jsonBody, err := json.Marshal(mutation) if err != nil { return &AuthError{ Op: "login", Reason: "failed to marshal request", Err: err, } } req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody)) if err != nil { return &AuthError{ Op: "login", Reason: "failed to create request", Err: err, } } req.Header.Set("Content-Type", "application/json") req.Header.Set("Referer", "https://app.kosmi.io/") req.Header.Set("User-Agent", userAgent) resp, err := a.httpClient.Do(req) if err != nil { return &AuthError{ Op: "login", Reason: "request failed", Err: err, } } defer resp.Body.Close() if resp.StatusCode == 401 { return &AuthError{ Op: "login", Reason: "invalid credentials", Err: ErrAuthFailed, } } if resp.StatusCode != 200 { return &AuthError{ Op: "login", Reason: fmt.Sprintf("HTTP %d", resp.StatusCode), Err: ErrAuthFailed, } } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return &AuthError{ Op: "login", Reason: "failed to parse response", Err: err, } } // Extract token and user info if data, ok := result["data"].(map[string]interface{}); ok { if login, ok := data["login"].(map[string]interface{}); ok { if token, ok := login["token"].(string); ok { a.token = token // Extract refresh token if present if refreshToken, ok := login["refreshToken"].(string); ok { a.refreshToken = refreshToken } // Calculate token expiry if expiresIn, ok := login["expiresIn"].(float64); ok { a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second) } else { // Default to 24 hours if not specified a.tokenExpiry = time.Now().Add(24 * time.Hour) } // Extract user ID if user, ok := login["user"].(map[string]interface{}); ok { if userID, ok := user["id"].(string); ok { a.userID = userID } if displayName, ok := user["displayName"].(string); ok { a.log.Infof("Logged in as: %s", displayName) } } a.log.Info("✅ Login successful") return nil } } } // Check for GraphQL errors if errors, ok := result["errors"].([]interface{}); ok && len(errors) > 0 { if errObj, ok := errors[0].(map[string]interface{}); ok { if message, ok := errObj["message"].(string); ok { return &AuthError{ Op: "login", Reason: message, Err: ErrAuthFailed, } } } } return &AuthError{ Op: "login", Reason: "no token in response", Err: ErrAuthFailed, } } // GetToken returns a valid token, refreshing if necessary func (a *AuthManager) GetToken() (string, error) { a.mu.RLock() // Check if token is still valid if a.token != "" && time.Now().Before(a.tokenExpiry.Add(-tokenExpiryBuffer)) { token := a.token a.mu.RUnlock() return token, nil } a.mu.RUnlock() // Token is expired or about to expire, refresh it if a.refreshToken != "" { if err := a.RefreshToken(); err != nil { a.log.Warnf("Token refresh failed, attempting re-login: %v", err) if err := a.Login(); err != nil { return "", err } } } else { // No refresh token, need to login again if err := a.Login(); err != nil { return "", err } } a.mu.RLock() defer a.mu.RUnlock() return a.token, nil } // RefreshToken renews the access token using the refresh token func (a *AuthManager) RefreshToken() error { a.mu.Lock() defer a.mu.Unlock() if a.refreshToken == "" { return &AuthError{ Op: "token_refresh", Reason: "no refresh token available", Err: ErrTokenExpired, } } a.log.Debug("Refreshing authentication token...") // Prepare refresh mutation mutation := map[string]interface{}{ "query": `mutation RefreshToken($refreshToken: String!) { refreshToken(refreshToken: $refreshToken) { token refreshToken expiresIn } }`, "variables": map[string]interface{}{ "refreshToken": a.refreshToken, }, } jsonBody, err := json.Marshal(mutation) if err != nil { return &AuthError{ Op: "token_refresh", Reason: "failed to marshal request", Err: err, } } req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody)) if err != nil { return &AuthError{ Op: "token_refresh", Reason: "failed to create request", Err: err, } } req.Header.Set("Content-Type", "application/json") req.Header.Set("Referer", "https://app.kosmi.io/") req.Header.Set("User-Agent", userAgent) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token)) resp, err := a.httpClient.Do(req) if err != nil { return &AuthError{ Op: "token_refresh", Reason: "request failed", Err: err, } } defer resp.Body.Close() if resp.StatusCode != 200 { return &AuthError{ Op: "token_refresh", Reason: fmt.Sprintf("HTTP %d", resp.StatusCode), Err: ErrTokenExpired, } } var result map[string]interface{} if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return &AuthError{ Op: "token_refresh", Reason: "failed to parse response", Err: err, } } // Extract new token if data, ok := result["data"].(map[string]interface{}); ok { if refresh, ok := data["refreshToken"].(map[string]interface{}); ok { if token, ok := refresh["token"].(string); ok { a.token = token // Update refresh token if provided if newRefreshToken, ok := refresh["refreshToken"].(string); ok { a.refreshToken = newRefreshToken } // Update expiry if expiresIn, ok := refresh["expiresIn"].(float64); ok { a.tokenExpiry = time.Now().Add(time.Duration(expiresIn) * time.Second) } else { a.tokenExpiry = time.Now().Add(24 * time.Hour) } a.log.Debug("✅ Token refreshed successfully") return nil } } } return &AuthError{ Op: "token_refresh", Reason: "no token in response", Err: ErrTokenExpired, } } // Logout invalidates the current session func (a *AuthManager) Logout() error { a.mu.Lock() defer a.mu.Unlock() if a.token == "" { return nil // Already logged out } a.log.Info("Logging out from Kosmi...") // Prepare logout mutation mutation := map[string]interface{}{ "query": `mutation Logout { logout { ok } }`, } jsonBody, err := json.Marshal(mutation) if err != nil { a.log.Warnf("Failed to marshal logout request: %v", err) // Continue with local cleanup } else { req, err := http.NewRequest("POST", kosmiHTTPURL, bytes.NewReader(jsonBody)) if err == nil { req.Header.Set("Content-Type", "application/json") req.Header.Set("Referer", "https://app.kosmi.io/") req.Header.Set("User-Agent", userAgent) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.token)) resp, err := a.httpClient.Do(req) if err != nil { a.log.Warnf("Logout request failed: %v", err) } else { resp.Body.Close() if resp.StatusCode != 200 { a.log.Warnf("Logout returned HTTP %d", resp.StatusCode) } } } } // Clear local state regardless of server response a.token = "" a.refreshToken = "" a.tokenExpiry = time.Time{} a.userID = "" a.log.Info("✅ Logged out") return nil } // IsAuthenticated checks if we have a valid token func (a *AuthManager) IsAuthenticated() bool { a.mu.RLock() defer a.mu.RUnlock() return a.token != "" && time.Now().Before(a.tokenExpiry) } // GetUserID returns the authenticated user's ID func (a *AuthManager) GetUserID() string { a.mu.RLock() defer a.mu.RUnlock() return a.userID }