// package auth handles Yggdrasil authentication with the backend. package auth import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "gitea.mrixs.me/Mrixs/MrixsCraft-launcher/internal/config" ) // Session represents an authenticated Yggdrasil session stored in session.json. type Session struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` UUID string `json:"uuid"` Username string `json:"username"` ExpiresAt time.Time `json:"expiresAt"` } // Yggdrasil client endpoints. type Client struct { baseURL string httpClient *http.Client } // New creates a new Yggdrasil client bound to the given base URL. func New(baseURL string) *Client { return &Client{ baseURL: baseURL, httpClient: &http.Client{ Timeout: 15 * time.Second, }, } } // NewFromConfig creates a client using the server URL from launcher settings. func NewFromConfig() (*Client, error) { s, err := config.Load() if err != nil { return nil, fmt.Errorf("loading config: %w", err) } return New(s.ServerURL), nil } // sessionPath returns the file path for session.json under the launcher root. func sessionPath() (string, error) { root, err := config.RootDir() if err != nil { return "", err } return filepath.Join(root, "session.json"), nil } // generateClientToken creates a random hex token (16 bytes → 32 hex chars). func generateClientToken() string { b := make([]byte, 16) _, _ = rand.Read(b) return hex.EncodeToString(b) } // Load reads the session from disk. func Load() (*Session, error) { p, err := sessionPath() if err != nil { return nil, err } data, err := os.ReadFile(p) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("reading session: %w", err) } var s Session if err := json.Unmarshal(data, &s); err != nil { return nil, fmt.Errorf("parsing session: %w", err) } return &s, nil } // Save writes the session to disk with restricted permissions. func (s *Session) Save() error { p, err := sessionPath() if err != nil { return err } data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("serializing session: %w", err) } return os.WriteFile(p, data, 0o600) } // Delete removes the session file (logout). func Delete() error { p, err := sessionPath() if err != nil { return err } return os.Remove(p) } // Authenticate performs a Yggdrasil /authserver/authenticate request. func (c *Client) Authenticate(username, password string) (*Session, error) { body, err := json.Marshal(map[string]interface{}{ "username": username, "password": password, }) if err != nil { return nil, fmt.Errorf("encoding request: %w", err) } resp, err := c.httpClient.Post( c.baseURL+"/authserver/authenticate", "application/json", bytes.NewReader(body), ) if err != nil { return nil, fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("authentication failed (%d): %s", resp.StatusCode, string(msg)) } var result struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` Profile struct { ID string `json:"id"` Name string `json:"name"` } `json:"selectedProfile"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } clientToken := result.ClientToken if clientToken == "" { clientToken = generateClientToken() } return &Session{ AccessToken: result.AccessToken, ClientToken: clientToken, UUID: result.Profile.ID, Username: result.Profile.Name, ExpiresAt: time.Now().Add(24 * time.Hour), }, nil } // Refresh performs a Yggdrasil /authserver/refresh request. func (c *Client) Refresh(s *Session) (*Session, error) { body, err := json.Marshal(map[string]string{ "accessToken": s.AccessToken, "clientToken": s.ClientToken, }) if err != nil { return nil, fmt.Errorf("encoding request: %w", err) } resp, err := c.httpClient.Post( c.baseURL+"/authserver/refresh", "application/json", bytes.NewReader(body), ) if err != nil { return nil, fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("refresh failed (%d): %s", resp.StatusCode, string(msg)) } var result struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decoding response: %w", err) } return &Session{ AccessToken: result.AccessToken, ClientToken: result.ClientToken, UUID: s.UUID, Username: s.Username, ExpiresAt: time.Now().Add(24 * time.Hour), }, nil } // Validate checks if the current session token is still valid. func (c *Client) Validate(s *Session) error { body, err := json.Marshal(map[string]string{ "accessToken": s.AccessToken, "clientToken": s.ClientToken, }) if err != nil { return fmt.Errorf("encoding request: %w", err) } resp, err := c.httpClient.Post( c.baseURL+"/authserver/validate", "application/json", bytes.NewReader(body), ) if err != nil { return fmt.Errorf("sending request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { msg, _ := io.ReadAll(resp.Body) return fmt.Errorf("validation failed (%d): %s", resp.StatusCode, string(msg)) } return nil } // EnsureValid tries validate → refresh. Returns a valid session or an error. func (c *Client) EnsureValid() (*Session, error) { s, err := Load() if err != nil { return nil, err } if s == nil { return nil, nil // not logged in } if err := c.Validate(s); err == nil { return s, nil // still valid } // Try refresh. refreshed, err := c.Refresh(s) if err != nil { _ = Delete() return nil, nil // need to re-login } if err := refreshed.Save(); err != nil { return nil, fmt.Errorf("saving refreshed session: %w", err) } return refreshed, nil }