feat(app): implement EVE SSO login and waypoint setting functionality
This commit is contained in:
62
app.go
62
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)
|
||||
}
|
||||
|
394
esi_sso.go
Normal file
394
esi_sso.go
Normal file
@@ -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:<id>
|
||||
if idx := strings.LastIndexByte(v, ':'); idx > -1 {
|
||||
if idv, err := strconv.ParseInt(v[idx+1:], 10, 64); err == nil {
|
||||
id = idv
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
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<string>('');
|
||||
|
||||
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 (
|
||||
<div className="flex-shrink-0 py-4 px-4 border-b border-purple-500/20">
|
||||
@@ -52,8 +69,14 @@ export const Header = ({ title, breadcrumbs = [] }: HeaderProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{/* Title + EVE SSO */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">{title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-300">{status || 'EVE: not logged in'}</span>
|
||||
<Button size="sm" className="bg-purple-600 hover:bg-purple-700" onClick={handleLogin}>Log in</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -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();
|
||||
|
||||
|
@@ -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 (
|
||||
<div
|
||||
ref={menuRef}
|
||||
@@ -82,6 +100,13 @@ export const SystemContextMenu = ({ x, y, system, onRename, onDelete, onClearCon
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div className="h-px bg-slate-700 my-1" />
|
||||
<button
|
||||
onClick={handleSetDestination}
|
||||
className="w-full px-3 py-1 text-left text-green-400 hover:bg-slate-700 rounded text-sm"
|
||||
>
|
||||
Set destination
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
Reference in New Issue
Block a user