From 478a628b6f26d6ded2ad4811b57965260cb57297 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Sat, 9 Aug 2025 18:49:36 +0200 Subject: [PATCH] feat(app): implement EVE SSO login and waypoint setting functionality --- app.go | 62 +++ esi_sso.go | 394 ++++++++++++++++++ frontend/src/components/Header.tsx | 41 +- frontend/src/components/RegionMap.tsx | 1 - frontend/src/components/SystemContextMenu.tsx | 25 ++ 5 files changed, 513 insertions(+), 10 deletions(-) create mode 100644 esi_sso.go diff --git a/app.go b/app.go index af53038..60d91d6 100644 --- a/app.go +++ b/app.go @@ -2,12 +2,17 @@ package main import ( "context" + "errors" "fmt" + "os" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // App struct type App struct { ctx context.Context + ssi *ESISSO } // NewApp creates a new App application struct @@ -19,9 +24,66 @@ func NewApp() *App { // so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = ctx + + clientID := os.Getenv("EVE_SSO_CLIENT_ID") + if clientID == "" { + clientID = "5091f74037374697938384bdbac2698c" + } + redirectURI := os.Getenv("EVE_SSO_REDIRECT_URI") + if redirectURI == "" { + redirectURI = "http://localhost:8080/callback" + } + + a.ssi = NewESISSO(clientID, redirectURI, []string{"esi-ui.write_waypoint.v1"}) } // Greet returns a greeting for the given name func (a *App) Greet(name string) string { return fmt.Sprintf("Hello %s, It's show time!", name) } + +// StartESILogin begins the PKCE SSO flow and opens a browser to the EVE login page +func (a *App) StartESILogin() (string, error) { + if a.ssi == nil { + return "", errors.New("ESI not initialised") + } + url, err := a.ssi.BuildAuthorizeURL() + if err != nil { + return "", err + } + go func() { _ = a.ssi.StartCallbackServer() }() + runtime.BrowserOpenURL(a.ctx, url) + return url, nil +} + +// ESILoginStatus returns a short status string of the active token/character +func (a *App) ESILoginStatus() string { + if a.ssi == nil { + return "not initialised" + } + st := a.ssi.Status() + if st.LoggedIn { + return fmt.Sprintf("logged in as %s (%d)", st.CharacterName, st.CharacterID) + } + return "not logged in" +} + +// SetDestination posts a waypoint to ESI to set destination +func (a *App) SetDestination(destinationID int64, clearOthers bool, addToBeginning bool) error { + if a.ssi == nil { + return errors.New("ESI not initialised") + } + return a.ssi.PostWaypoint(destinationID, clearOthers, addToBeginning) +} + +// SetDestinationByName resolves a solar system name to ID and sets destination +func (a *App) SetDestinationByName(systemName string, clearOthers bool, addToBeginning bool) error { + if a.ssi == nil { + return errors.New("ESI not initialised") + } + id, err := a.ssi.ResolveSystemIDByName(a.ctx, systemName) + if err != nil { + return err + } + return a.ssi.PostWaypoint(id, clearOthers, addToBeginning) +} diff --git a/esi_sso.go b/esi_sso.go new file mode 100644 index 0000000..4951812 --- /dev/null +++ b/esi_sso.go @@ -0,0 +1,394 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // SSO endpoints + issuerAuthorizeURL = "https://login.eveonline.com/v2/oauth/authorize" + issuerTokenURL = "https://login.eveonline.com/v2/oauth/token" + + // ESI base + esiBase = "https://esi.evetech.net" +) + +// ESISSO encapsulates a minimal PKCE SSO client and token store +type ESISSO struct { + clientID string + redirectURI string + scopes []string + + state string + codeVerifier string + codeChallenge string + + mu sync.Mutex + accessToken string + refreshToken string + expiresAt time.Time + + characterID int64 + characterName string + + callbackOnce sync.Once + server *http.Server +} + +func NewESISSO(clientID string, redirectURI string, scopes []string) *ESISSO { + return &ESISSO{ + clientID: clientID, + redirectURI: redirectURI, + scopes: scopes, + } +} + +// BuildAuthorizeURL prepares state and PKCE challenge and returns the browser URL +func (s *ESISSO) BuildAuthorizeURL() (string, error) { + if s.clientID == "" { + return "", errors.New("EVE_SSO_CLIENT_ID not set") + } + verifier, challenge, err := generatePKCE() + if err != nil { + return "", err + } + s.codeVerifier = verifier + s.codeChallenge = challenge + s.state = randString(24) + + 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", s.state) + q.Set("code_challenge", s.codeChallenge) + q.Set("code_challenge_method", "S256") + + return issuerAuthorizeURL + "?" + q.Encode(), nil +} + +// StartCallbackServer starts a temporary local HTTP server to receive the SSO callback +func (s *ESISSO) StartCallbackServer() error { + u, err := url.Parse(s.redirectURI) + if err != nil { + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + return errors.New("redirect URI must be http(s)") + } + // Bind exact host:port + hostPort := u.Host + if !strings.Contains(hostPort, ":") { + // default ports + if u.Scheme == "https" { + hostPort += ":443" + } else { + hostPort += ":80" + } + } + + mux := http.NewServeMux() + mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) { + // Receive code + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + q := r.URL.Query() + code := q.Get("code") + st := q.Get("state") + if code == "" || st == "" || st != s.state { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("Invalid SSO response")) + return + } + // Exchange token + if err := s.exchangeToken(r.Context(), code); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("Token exchange failed: " + err.Error())) + return + } + _, _ = io.WriteString(w, "Login successful. You can close this window.") + go func() { + // stop shortly after responding + time.Sleep(200 * time.Millisecond) + _ = s.server.Shutdown(context.Background()) + }() + }) + + ln, err := net.Listen("tcp", hostPort) + if err != nil { + return err + } + + s.server = &http.Server{Handler: mux} + return s.server.Serve(ln) +} + +func (s *ESISSO) exchangeToken(ctx context.Context, code string) error { + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("client_id", s.clientID) + form.Set("code_verifier", s.codeVerifier) + + 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 exchange failed: %s: %s", resp.Status, string(b)) + } + var tr tokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if tr.AccessToken != "" { + s.accessToken = tr.AccessToken + } + if tr.RefreshToken != "" { + s.refreshToken = tr.RefreshToken + } + if tr.ExpiresIn > 0 { + s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) + } + // Parse basic claims for display + name, cid := parseTokenCharacter(tr.AccessToken) + s.characterName = name + s.characterID = cid + return nil +} + +func (s *ESISSO) refresh(ctx context.Context) error { + s.mu.Lock() + rt := s.refreshToken + s.mu.Unlock() + if rt == "" { + return errors.New("no refresh token") + } + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", rt) + 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 + } + + s.mu.Lock() + defer s.mu.Unlock() + s.accessToken = tr.AccessToken + if tr.RefreshToken != "" { + s.refreshToken = tr.RefreshToken + } + if tr.ExpiresIn > 0 { + s.expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn-30) * time.Second) + } + name, cid := parseTokenCharacter(tr.AccessToken) + s.characterName = name + s.characterID = cid + return nil +} + +func (s *ESISSO) ensureAccessToken(ctx context.Context) (string, error) { + s.mu.Lock() + tok := s.accessToken + exp := s.expiresAt + s.mu.Unlock() + if tok == "" { + return "", errors.New("not logged in") + } + if time.Now().After(exp.Add(-60 * time.Second)) { + if err := s.refresh(ctx); err != nil { + return "", err + } + s.mu.Lock() + tok = s.accessToken + s.mu.Unlock() + } + return tok, nil +} + +// PostWaypoint calls ESI to set destination or add waypoint +func (s *ESISSO) PostWaypoint(destinationID int64, clearOthers bool, addToBeginning bool) error { + tok, err := s.ensureAccessToken(context.Background()) + if err != nil { + return err + } + q := url.Values{} + q.Set("destination_id", strconv.FormatInt(destinationID, 10)) + q.Set("add_to_beginning", strconv.FormatBool(addToBeginning)) + q.Set("clear_other_waypoints", strconv.FormatBool(clearOthers)) + endpoint := esiBase + "/v2/ui/autopilot/waypoint/" + "?" + q.Encode() + + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK { + return nil + } + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("waypoint failed: %s: %s", resp.Status, string(b)) +} + +// Status reports current login state and character details +type SSOStatus struct { + LoggedIn bool + CharacterID int64 + CharacterName string + ExpiresAt time.Time +} + +func (s *ESISSO) Status() SSOStatus { + s.mu.Lock() + defer s.mu.Unlock() + return SSOStatus{ + LoggedIn: s.accessToken != "", + CharacterID: s.characterID, + CharacterName: s.characterName, + ExpiresAt: s.expiresAt, + } +} + +// ResolveSystemIDByName searches ESI for a solar system by exact name and returns its ID +func (s *ESISSO) ResolveSystemIDByName(ctx context.Context, name string) (int64, error) { + name = strings.TrimSpace(name) + if name == "" { + return 0, errors.New("empty system name") + } + q := url.Values{} + q.Set("categories", "solar_system") + q.Set("search", name) + q.Set("strict", "true") + endpoint := esiBase + "/v3/search/?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return 0, err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return 0, fmt.Errorf("search failed: %s: %s", resp.Status, string(b)) + } + var payload struct { + SolarSystem []int64 `json:"solar_system"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return 0, err + } + if len(payload.SolarSystem) == 0 { + return 0, fmt.Errorf("system not found: %s", name) + } + return payload.SolarSystem[0], nil +} + +// Helpers + +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +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 { + // format EVE:CHARACTER: + if idx := strings.LastIndexByte(v, ':'); idx > -1 { + if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil { + id = idv + } + } + } + return +} \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index da4a726..56197df 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,14 +1,17 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/hooks/use-toast'; +import { StartESILogin, ESILoginStatus } from '@/wailsjs/go/main/App'; interface HeaderProps { title: string; @@ -20,6 +23,20 @@ interface HeaderProps { export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => { const navigate = useNavigate(); + const [status, setStatus] = useState(''); + + useEffect(() => { + ESILoginStatus().then(setStatus).catch(() => setStatus('')); + }, []); + + const handleLogin = async () => { + try { + await StartESILogin(); + toast({ title: 'EVE Login', description: 'Complete login in your browser. Reopen menu to refresh status.' }); + } catch (e: any) { + toast({ title: 'Login failed', description: String(e), variant: 'destructive' }); + } + }; return (
@@ -52,8 +69,14 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
)} - {/* Title */} -

{title}

+ {/* Title + EVE SSO */} +
+

{title}

+
+ {status || 'EVE: not logged in'} + +
+
); }; diff --git a/frontend/src/components/RegionMap.tsx b/frontend/src/components/RegionMap.tsx index 6b705c1..1776128 100644 --- a/frontend/src/components/RegionMap.tsx +++ b/frontend/src/components/RegionMap.tsx @@ -289,7 +289,6 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho }, [viewBox]); const handleContextMenu = (e: React.MouseEvent, system: System) => { - if (!isWormholeRegion) return; e.preventDefault(); e.stopPropagation(); diff --git a/frontend/src/components/SystemContextMenu.tsx b/frontend/src/components/SystemContextMenu.tsx index 5af7178..8c9411f 100644 --- a/frontend/src/components/SystemContextMenu.tsx +++ b/frontend/src/components/SystemContextMenu.tsx @@ -1,5 +1,7 @@ import { useState, useRef } from 'react'; import { System } from '@/lib/types'; +import { toast } from '@/hooks/use-toast'; +import { StartESILogin, ESILoginStatus, SetDestinationByName } from '@/wailsjs/go/main/App'; interface SystemContextMenuProps { x: number; @@ -27,6 +29,22 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon setIsRenaming(false); }; + const handleSetDestination = async () => { + try { + const status = await ESILoginStatus(); + if (status.includes('not logged in')) { + await StartESILogin(); + toast({ title: 'EVE Login', description: 'Please complete login in your browser, then retry.' }); + return; + } + await SetDestinationByName(system.solarSystemName, true, false); + toast({ title: 'Destination set', description: `${system.solarSystemName}` }); + onClose(); + } catch (e: any) { + toast({ title: 'Failed to set destination', description: String(e), variant: 'destructive' }); + } + }; + return (
Delete +
+
)}