PrintToVeraeTimeStamp/internal/auth/portal.go

140 lines
4.5 KiB
Go

package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// Portal account API — the same JSON endpoints the customer.verae.ai/portal web
// client (portal/app.js) uses: POST /portal/api/login|signup → {token: session},
// POST /portal/api/tokens {name} (Bearer session) → {token: "xVerae-…"},
// POST /portal/api/logout. There is no device-code / email-code flow, so the
// installer uses email+password (+ optional authenticator code) and mints a key.
// PortalBase is overridable for testing via VERAE_PORTAL_BASE.
func PortalBase() string {
if v := strings.TrimRight(os.Getenv("VERAE_PORTAL_BASE"), "/"); v != "" {
return v
}
return "https://customer.verae.ai"
}
// ErrTOTPRequired means the account has 2FA and a 6-digit code is needed.
var ErrTOTPRequired = fmt.Errorf("totp_required")
var portalHTTP = &http.Client{Timeout: 20 * time.Second}
func decodeJSON(res *http.Response, v any) error {
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v)
}
func portalPost(path, bearer string, body any) (map[string]any, int, error) {
if !strings.HasPrefix(PortalBase(), "https://") && os.Getenv("VERAE_ALLOW_INSECURE_PORTAL") != "1" {
return nil, 0, fmt.Errorf("refusing non-HTTPS portal base %q", PortalBase())
}
b, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, PortalBase()+"/portal/api"+path, bytes.NewReader(b))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "VeraePrintStamp-Installer")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
res, err := portalHTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
out := map[string]any{}
_ = json.Unmarshal(raw, &out)
if res.StatusCode >= 300 {
msg, _ := out["error"].(string)
if msg == "totp_required" {
return out, res.StatusCode, ErrTOTPRequired
}
if msg == "" {
msg = strings.TrimSpace(string(raw))
if len(msg) > 200 {
msg = msg[:200]
}
}
return out, res.StatusCode, fmt.Errorf("HTTP %d: %s", res.StatusCode, msg)
}
return out, res.StatusCode, nil
}
// PortalMintKey signs in (or signs up), mints a long-lived xVerae- token named
// tokenName, and signs out. Credentials are never stored.
func PortalMintKey(signup bool, name, email, password, totp, tokenName string) (string, error) {
var body map[string]any
path := "/login"
if signup {
path = "/signup"
body = map[string]any{"name": name, "email": email, "password": password}
} else {
body = map[string]any{"email": email, "password": password}
if totp != "" {
body["totpCode"] = totp
}
}
out, _, err := portalPost(path, "", body)
if err != nil {
return "", err
}
session, _ := out["token"].(string)
if session == "" {
return "", fmt.Errorf("portal %s returned no session", path)
}
defer func() { _, _, _ = portalPost("/logout", session, map[string]any{}) }()
tk, _, err := portalPost("/tokens", session, map[string]any{"name": tokenName})
if err != nil {
return "", fmt.Errorf("mint key: %w", err)
}
key, _ := tk["token"].(string)
if !strings.HasPrefix(key, "xVerae-") && !strings.HasPrefix(key, "key-") {
return "", fmt.Errorf("mint key: unexpected response (no xVerae- token)")
}
return key, nil
}
// ValidateXVeraeKey checks a key against zap.verae.ai GET /v1/status (documented
// as free). Returns "valid", "invalid", "badformat", "unreachable" or "unknown".
func ValidateXVeraeKey(key string) (string, string) {
key = strings.TrimSpace(key)
if !(strings.HasPrefix(key, "xVerae-") || strings.HasPrefix(key, "key-")) || len(key) < 16 || strings.ContainsAny(key, " \t\r\n") {
return "badformat", "must start with xVerae- (or legacy key-)"
}
req, _ := http.NewRequest(http.MethodGet, "https://zap.verae.ai/v1/status", nil)
req.Header.Set("x-api-key", key)
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return "unreachable", err.Error()
}
res.Body.Close()
switch {
case res.StatusCode < 300:
return "valid", fmt.Sprintf("HTTP %d", res.StatusCode)
case res.StatusCode == 401 || res.StatusCode == 403:
return "invalid", fmt.Sprintf("HTTP %d from Verae", res.StatusCode)
default:
return "unknown", fmt.Sprintf("HTTP %d from Verae", res.StatusCode)
}
}
// MaskKey renders xVerae-...abcd.
func MaskKey(k string) string {
k = strings.TrimSpace(k)
if len(k) <= 12 {
return "***" // too short to reveal any part safely
}
return k[:7] + "..." + k[len(k)-4:]
}