verae-mcp-timestamping-serv.../internal/veraetime/authprops.go
George Lambert 348ccd0bf2 feat: VeraeTime client, auth.properties, and host-token keeper
HTTPS GlobalClient (Stamp/Verify/HashReport/Signup/Login/Session/CreateUser)
with Fake RoundTripper tests; auth.properties read/write; Keeper refresh ~60s
before expiry or VERAE_GLOBAL_TOKEN; authctx.Resolve for require-bearer vs host fallback.
2026-09-29 11:57:40 -04:00

137 lines
3.2 KiB
Go

package veraetime
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// DefaultBaseURL is the production VeraeTime API.
const DefaultBaseURL = "https://api.veraetime.net"
// TokenRefreshSkew is how far before expires_at a host token is refreshed.
const TokenRefreshSkew = 60 * time.Second
var expiryRe = regexp.MustCompile(`^(.*T\d{2}:\d{2}:\d{2})(\.\d+)?([+-]\d{2}:\d{2}|Z)?$`)
// DefaultAuthPath returns VERAE_AUTH_PROPERTIES or $HOME/.verae/auth.properties.
func DefaultAuthPath() string {
if v := strings.TrimSpace(os.Getenv("VERAE_AUTH_PROPERTIES")); v != "" {
return v
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".verae", "auth.properties")
}
return filepath.Join(home, ".verae", "auth.properties")
}
// ReadProperties loads key=value lines from path. Missing file yields empty map.
func ReadProperties(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]string{}, nil
}
return nil, err
}
defer f.Close()
props := make(map[string]string)
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
continue
}
key, val, _ := strings.Cut(line, "=")
props[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
return props, sc.Err()
}
// WriteProperties merges props into path (mode 0600); parent dir 0700.
func WriteProperties(path string, props map[string]string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
merged, err := ReadProperties(path)
if err != nil {
return err
}
for k, v := range props {
merged[k] = v
}
tmp := path + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
for k, v := range merged {
if _, err := fmt.Fprintf(f, "%s=%s\n", k, v); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
_ = os.Chmod(tmp, 0o600)
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
_ = os.Chmod(path, 0o600)
return nil
}
// ParseExpiry parses VeraeTime expiresAt / expires_at strings.
func ParseExpiry(value string) (time.Time, bool) {
text := strings.TrimSpace(value)
if text == "" {
return time.Time{}, false
}
if strings.HasSuffix(text, "Z") {
text = text[:len(text)-1] + "+00:00"
}
m := expiryRe.FindStringSubmatch(text)
if m == nil {
t, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
return time.Time{}, false
}
return t.UTC(), true
}
frac := m[2]
if frac != "" {
if len(frac) > 7 {
frac = frac[:7]
}
}
zone := m[3]
if zone == "" || zone == "Z" {
zone = "+00:00"
}
parsed, err := time.Parse("2006-01-02T15:04:05.999999-07:00", m[1]+frac+zone)
if err != nil {
parsed, err = time.Parse("2006-01-02T15:04:05-07:00", m[1]+zone)
if err != nil {
return time.Time{}, false
}
}
return parsed.UTC(), true
}
func storedAccessToken(props map[string]string) string {
for _, k := range []string{"access_token", "id_token", "auth_token"} {
if v := strings.TrimSpace(props[k]); v != "" {
return v
}
}
return ""
}