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.
137 lines
3.2 KiB
Go
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 ""
|
|
}
|