From 683e6871fb883e74f36eb44b43cb1213fbaa25d3 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Fri, 10 Oct 2025 19:38:38 +0200 Subject: [PATCH] Add eve sso code --- .env.example | 3 + .gitignore | 1 + esi_sso.go | 396 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 62 ++++++++ 4 files changed, 462 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 esi_sso.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..57c96ce --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DB_PATH=db.sqlite +CLIENT_ID=your-client-id +REDIRECT_URI=http://localhost:8080/callback \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/esi_sso.go b/esi_sso.go new file mode 100644 index 0000000..659a5d5 --- /dev/null +++ b/esi_sso.go @@ -0,0 +1,396 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +const ( + issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize" + issuerTokenURL = "https://login.eveonline.com/v2/oauth/token" +) + +type Token struct { + ID uint `gorm:"primaryKey"` + CharacterName string `gorm:"uniqueIndex"` + AccessToken string + RefreshToken string + ExpiresAt time.Time + UpdatedAt time.Time + CreatedAt time.Time +} + +type SSO struct { + clientID string + redirectURI string + scopes []string + db *gorm.DB + mu sync.Mutex + server *http.Server + state string + callbackChan chan struct { + code string + state string + err error + } +} + +// NewSSO creates a new SSO instance +func NewSSO(clientID, redirectURI string, scopes []string) (*SSO, error) { + s := &SSO{ + clientID: clientID, + redirectURI: redirectURI, + scopes: scopes, + } + + if err := s.initDB(); err != nil { + return nil, err + } + + return s, nil +} + +func (s *SSO) initDB() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + dbPath := filepath.Join(home, ".industrializer", "sqlite-latest.sqlite") + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return err + } + if err := db.AutoMigrate(&Token{}); err != nil { + return err + } + s.db = db + return nil +} + +// GetToken returns a valid access token for the given character name +// If no token exists, it will start the OAuth flow +// If token is expired, it will refresh it automatically +func (s *SSO) GetToken(ctx context.Context, characterName string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + // Try to get existing token from DB + var token Token + if err := s.db.Where("character_name = ?", characterName).First(&token).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // No token exists, need to authenticate + if err := s.startAuthFlow(ctx, characterName); err != nil { + return "", err + } + // After authentication, fetch the token from DB + if err := s.db.Where("character_name = ?", characterName).First(&token).Error; err != nil { + return "", err + } + } else { + return "", err + } + } + + // Check if token needs refresh + if time.Now().After(token.ExpiresAt.Add(-60 * time.Second)) { + if err := s.refreshToken(ctx, &token); err != nil { + // Refresh failed, need to re-authenticate + if err := s.startAuthFlow(ctx, characterName); err != nil { + return "", err + } + // After re-authentication, fetch the token from DB + if err := s.db.Where("character_name = ?", characterName).First(&token).Error; err != nil { + return "", err + } + } + } + + return token.AccessToken, nil +} + +func (s *SSO) startAuthFlow(ctx context.Context, characterName string) error { + // Generate PKCE + verifier, challenge, err := generatePKCE() + if err != nil { + return err + } + + s.state = randString(24) + s.callbackChan = make(chan struct { + code string + state string + err error + }, 1) + authURL := s.buildAuthURL(challenge, s.state) + + fmt.Printf("Please visit this URL to authenticate:\n%s\n", authURL) + fmt.Println("Waiting for authentication...") + + // Start callback server + server, err := s.startCallbackServer() + if err != nil { + return err + } + s.server = server + defer server.Shutdown(ctx) + + // Wait for callback + code, receivedState, err := s.waitForCallback() + if err != nil { + return err + } + + if receivedState != s.state { + return errors.New("invalid state parameter") + } + + // Exchange code for token + token, err := s.exchangeCodeForToken(ctx, code, verifier) + if err != nil { + return err + } + + // Save token to DB + token.CharacterName = characterName + return s.db.Save(&token).Error +} + +func (s *SSO) buildAuthURL(challenge, state string) string { + q := url.Values{} + q.Set("response_type", "code") + q.Set("client_id", s.clientID) + q.Set("redirect_uri", s.redirectURI) + if len(s.scopes) > 0 { + q.Set("scope", strings.Join(s.scopes, " ")) + } + q.Set("state", state) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + + return issuerAuthorizeURL + "?" + q.Encode() +} + +func (s *SSO) startCallbackServer() (*http.Server, error) { + u, err := url.Parse(s.redirectURI) + if err != nil { + return nil, err + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, errors.New("redirect URI must be http(s)") + } + hostPort := u.Host + if !strings.Contains(hostPort, ":") { + if u.Scheme == "https" { + hostPort += ":443" + } else { + hostPort += ":80" + } + } + + mux := http.NewServeMux() + mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + s.callbackChan <- struct { + code string + state string + err error + }{"", "", errors.New("method not allowed")} + return + } + q := r.URL.Query() + code := q.Get("code") + st := q.Get("state") + if code == "" || st == "" || st != s.state { + fmt.Printf("Invalid SSO response: code=%s, state=%s, expected_state=%s\n", code, st, s.state) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("Invalid SSO response")) + s.callbackChan <- struct { + code string + state string + err error + }{"", "", errors.New("invalid state")} + return + } + fmt.Printf("Exchanging token for code: %s\n", code) + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("

Login successful!

You can close this window.

")) + s.callbackChan <- struct { + code string + state string + err error + }{code, st, nil} + go func() { + time.Sleep(200 * time.Millisecond) + _ = s.server.Shutdown(context.Background()) + }() + }) + + ln, err := net.Listen("tcp", hostPort) + if err != nil { + return nil, err + } + + server := &http.Server{Handler: mux} + go func() { _ = server.Serve(ln) }() + return server, nil +} + +func (s *SSO) waitForCallback() (code, state string, err error) { + // Wait for callback through channel + select { + case result := <-s.callbackChan: + return result.code, result.state, result.err + case <-time.After(30 * time.Second): + return "", "", errors.New("callback timeout") + } +} + +func (s *SSO) exchangeCodeForToken(ctx context.Context, code, verifier string) (*Token, error) { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", s.clientID) + form.Set("code_verifier", verifier) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token exchange failed: %s: %s", resp.Status, string(b)) + } + + var tr tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return nil, err + } + + // Parse character info from token + name, _ := parseTokenCharacter(tr.AccessToken) + + return &Token{ + CharacterName: name, + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second), + }, nil +} + +func (s *SSO) refreshToken(ctx context.Context, token *Token) error { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", token.RefreshToken) + form.Set("client_id", s.clientID) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, issuerTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("token refresh failed: %s: %s", resp.Status, string(b)) + } + + var tr tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return err + } + + // Update token + token.AccessToken = tr.AccessToken + if tr.RefreshToken != "" { + token.RefreshToken = tr.RefreshToken + } + if tr.ExpiresIn > 0 { + token.ExpiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) + } + + return s.db.Save(token).Error +} + +// Utility functions +func generatePKCE() (verifier string, challenge string, err error) { + buf := make([]byte, 32) + if _, err = rand.Read(buf); err != nil { + return + } + v := base64.RawURLEncoding.EncodeToString(buf) + h := sha256.Sum256([]byte(v)) + c := base64.RawURLEncoding.EncodeToString(h[:]) + return v, c, nil +} + +func randString(n int) string { + buf := make([]byte, n) + _, _ = rand.Read(buf) + return base64.RawURLEncoding.EncodeToString(buf) +} + +func parseTokenCharacter(jwt string) (name string, id int64) { + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + return "", 0 + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", 0 + } + var m map[string]any + if err := json.Unmarshal(payload, &m); err != nil { + return "", 0 + } + if v, ok := m["name"].(string); ok { + name = v + } + if v, ok := m["sub"].(string); ok { + if idx := strings.LastIndexByte(v, ':'); idx > -1 { + if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil { + id = idv + } + } + } + return +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} diff --git a/main.go b/main.go index 01bc6e2..1d10dc3 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,74 @@ package main import ( + "context" "flag" logger "git.site.quack-lab.dev/dave/cylogger" ) +import ( + "context" + "flag" + "os" + "strings" + + "github.com/joho/godotenv" + logger "git.site.quack-lab.dev/dave/cylogger" +) + func main() { flag.Parse() logger.InitFlag() + logger.Info("Starting Eve PI") + + // Load environment variables strictly from .env file (fail if there's an error loading) + if err := godotenv.Load(); err != nil { + logger.Error("Error loading .env file: %v", err) + return + } + + clientID := os.Getenv("ESI_CLIENT_ID") + if clientID == "" { + logger.Error("ESI_CLIENT_ID is required in .env file") + return + } + redirectURI := os.Getenv("ESI_REDIRECT_URI") + if redirectURI == "" { + logger.Error("ESI_REDIRECT_URI is required in .env file") + return + } + rawScopes := os.Getenv("ESI_SCOPES") + if rawScopes == "" { + logger.Error("ESI_SCOPES is required in .env file") + return + } + characterName := os.Getenv("ESI_CHARACTER_NAME") + if characterName == "" { + logger.Error("ESI_CHARACTER_NAME is required in .env file") + return + } + scopes := strings.Fields(rawScopes) + + // Create SSO instance + sso, err := NewSSO( + clientID, + redirectURI, + scopes, + ) + if err != nil { + logger.Error("Failed to create SSO instance %v", err) + return + } + + // Get token for character + token, err := sso.GetToken(context.Background(), characterName) + if err != nil { + logger.Error("Failed to get token %v", err) + return + } + + logger.Info("Got token %s", token) + // Use the token for ESI API calls + // The SSO handles all the complexity behind the scenes } \ No newline at end of file