Files
IRC-kosmi-relay/bridge/kosmi/auth.go
cottongin dd398c9a8c sync
2025-11-01 21:00:16 -04:00

397 lines
9.1 KiB
Go

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
}