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.
This commit is contained in:
George Lambert 2026-09-29 11:57:40 -04:00
commit 348ccd0bf2
6 changed files with 1424 additions and 0 deletions

View file

@ -0,0 +1,27 @@
package authctx
import (
"context"
"fmt"
"strings"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
)
// Resolve picks the bearer for VeraeTime calls.
// Caller Bearer wins when present. When requireBearer is true, an empty
// caller bearer is an error (no host fallback). When false (default), falls
// back to Keeper host token (VERAE_GLOBAL_TOKEN / auth.properties / login).
func Resolve(ctx context.Context, reqBearer string, requireBearer bool, k *veraetime.Keeper) (string, error) {
tok := strings.TrimSpace(reqBearer)
if tok != "" {
return tok, nil
}
if requireBearer {
return "", fmt.Errorf("authctx: bearer required (VERAE_MCP_REQUIRE_BEARER=1)")
}
if k == nil {
return "", fmt.Errorf("authctx: no bearer and nil host-token keeper")
}
return k.Token(ctx)
}

View file

@ -0,0 +1,117 @@
package authctx_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/authctx"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func jsonResp(status int, body any) *http.Response {
raw, _ := json.Marshal(body)
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(raw)),
}
}
func TestResolve_PreferCallerBearer(t *testing.T) {
k := veraetime.NewKeeper(veraetime.NewClient("https://api.example"), "")
tok, err := authctx.Resolve(context.Background(), "caller-jwt", false, k)
if err != nil {
t.Fatal(err)
}
if tok != "caller-jwt" {
t.Fatalf("tok=%q", tok)
}
}
func TestResolve_RequireBearerRejectsEmpty(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
_ = veraetime.WriteProperties(path, map[string]string{
"access_token": "host-token",
"expires_at": "2099-01-01T00:00:00Z",
"username": "admin",
"password": "x",
})
k := veraetime.NewKeeper(veraetime.NewClient("https://api.example"), path)
_, err := authctx.Resolve(context.Background(), "", true, k)
if err == nil {
t.Fatal("expected error when require-bearer and empty")
}
if !strings.Contains(err.Error(), "bearer") {
t.Fatalf("err=%v", err)
}
}
func TestResolve_HostFallbackWhenNotRequired(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
if err := veraetime.WriteProperties(path, map[string]string{
"access_token": "host-token",
"expires_at": "2099-01-01T00:00:00Z",
"username": "admin",
"password": "x",
}); err != nil {
t.Fatal(err)
}
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP")
return nil, nil
})))
k := veraetime.NewKeeper(c, path)
tok, err := authctx.Resolve(context.Background(), "", false, k)
if err != nil {
t.Fatal(err)
}
if tok != "host-token" {
t.Fatalf("tok=%q", tok)
}
}
func TestResolve_HostFallbackRefresh(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
if err := veraetime.WriteProperties(path, map[string]string{
"access_token": "old",
"expires_at": "2020-01-01T00:00:00Z",
"username": "admin",
"password": "secret-pass",
}); err != nil {
t.Fatal(err)
}
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if !strings.HasSuffix(r.URL.Path, "/auth/login") {
t.Fatalf("path=%s", r.URL.Path)
}
return jsonResp(200, map[string]any{
"token": "fresh-token",
"expiresAt": "2099-01-01T00:00:00Z",
"user": map[string]any{"username": "admin", "role": "admin"},
}), nil
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithAuthPath(path),
)
k := veraetime.NewKeeper(c, path)
tok, err := authctx.Resolve(context.Background(), " ", false, k)
if err != nil {
t.Fatal(err)
}
if tok != "fresh-token" {
t.Fatalf("tok=%q", tok)
}
}

View file

@ -0,0 +1,137 @@
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 ""
}

View file

@ -0,0 +1,592 @@
package veraetime
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
)
const (
defaultPollInterval = 250 * time.Millisecond
defaultMaxPolls = 40
)
// AuthError is returned for HTTP 401 from VeraeTime.
type AuthError struct {
Status int
Message string
}
func (e *AuthError) Error() string {
if e.Message != "" {
return fmt.Sprintf("veraetime: auth HTTP %d: %s", e.Status, e.Message)
}
return fmt.Sprintf("veraetime: auth HTTP %d", e.Status)
}
// Receipt is a global timestamp receipt.
type Receipt struct {
ReceiptID string `json:"receipt_id"`
Hash string `json:"hash"`
Scope string `json:"scope"`
CubeID string `json:"cube_id,omitempty"`
OrgID string `json:"org_id,omitempty"`
CreatedAt string `json:"created_at"`
Merkle string `json:"merkle"`
RestampOf string `json:"restamp_of,omitempty"`
}
// VerifyResult is the outcome of Verify.
type VerifyResult struct {
Valid bool
Error string
Receipt *Receipt
}
// LoginResult is returned by Login (never includes the JWT).
type LoginResult struct {
Username string
Role string
ExpiresAt string
}
// SessionResult is returned by Session /auth/validate.
type SessionResult struct {
Valid bool
Username string
Role string
ExpiresAt string
}
// CreateUserResult is returned by Signup and CreateUser.
type CreateUserResult struct {
ID string
Username string
Role string
}
// Client talks to the VeraeTime HTTPS API. Callers pass a resolved bearer;
// host-token minting lives on Keeper.
type Client struct {
baseURL string
httpClient *http.Client
authPath string
pollInterval time.Duration
maxPolls int
}
// Option configures Client.
type Option func(*Client)
// WithHTTPClient sets the underlying HTTP client.
func WithHTTPClient(c *http.Client) Option {
return func(cl *Client) {
if c != nil {
cl.httpClient = c
}
}
}
// WithRoundTripper injects a RoundTripper (unit tests).
func WithRoundTripper(rt http.RoundTripper) Option {
return func(cl *Client) {
if rt == nil {
return
}
cl.httpClient = &http.Client{Transport: rt}
}
}
// WithAuthPath sets the auth.properties path used by Login persistence.
func WithAuthPath(path string) Option {
return func(cl *Client) {
if path != "" {
cl.authPath = path
}
}
}
// WithPollInterval sets status-poll sleep (default 250ms).
func WithPollInterval(d time.Duration) Option {
return func(cl *Client) {
if d > 0 {
cl.pollInterval = d
}
}
}
// WithMaxPolls caps /api/status polls (default 40).
func WithMaxPolls(n int) Option {
return func(cl *Client) {
if n > 0 {
cl.maxPolls = n
}
}
}
// NewClient builds a VeraeTime HTTPS client.
func NewClient(baseURL string, opts ...Option) *Client {
u := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if u == "" {
u = DefaultBaseURL
}
cl := &Client{
baseURL: u,
httpClient: &http.Client{Timeout: 30 * time.Second},
pollInterval: defaultPollInterval,
maxPolls: defaultMaxPolls,
}
for _, opt := range opts {
opt(cl)
}
return cl
}
// AuthPath returns the configured auth.properties path (may be empty).
func (c *Client) AuthPath() string { return c.authPath }
// BaseURL returns the API base URL.
func (c *Client) BaseURL() string { return c.baseURL }
// Stamp posts a SHA-256 digest with restamp=false.
func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error) {
h, err := digest.NormalizeSHA256(dig)
if err != nil {
return nil, err
}
payload := map[string]any{
"data": h,
"digest": h,
"hashAlg": "SHA256",
"restamp": false,
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/timestamp", payload, bearer)
if err != nil {
return nil, err
}
if status == http.StatusUnauthorized {
return nil, authErr(status, body)
}
if status == http.StatusOK && (str(body, "certificate") != "" || truthy(body["existing"])) {
cid := str(body, "certificate")
if cid != "" {
return &Receipt{
ReceiptID: cid,
Hash: h,
Scope: "global",
CreatedAt: str(body, "timestamp"),
Merkle: str(body, "blockHash"),
}, nil
}
}
if status >= 300 {
return nil, httpErr("timestamp", status, body)
}
jobID := str(body, "jobId")
if jobID == "" {
jobID = str(body, "blockHash")
}
if jobID == "" {
return nil, fmt.Errorf("veraetime: timestamp response missing jobId")
}
created := str(body, "timestamp")
merkle := str(body, "blockHash")
receiptID, created, merkle, err := c.pollStatus(ctx, jobID, bearer, created, merkle)
if err != nil {
return nil, err
}
return &Receipt{
ReceiptID: receiptID,
Hash: h,
Scope: "global",
CreatedAt: created,
Merkle: merkle,
}, nil
}
func (c *Client) pollStatus(ctx context.Context, jobID, bearer, created, merkle string) (string, string, string, error) {
url := c.baseURL + "/api/status/" + jobID
for i := 0; i < c.maxPolls; i++ {
status, body, err := c.doJSON(ctx, http.MethodGet, url, nil, bearer)
if err != nil {
return "", created, merkle, err
}
if status == http.StatusUnauthorized {
return "", created, merkle, authErr(status, body)
}
if status >= 300 {
break
}
state := str(body, "status")
switch state {
case "failed":
msg := str(body, "error")
if msg == "" {
msg = "global timestamp job failed"
}
return "", created, merkle, fmt.Errorf("veraetime: %s", msg)
case "completed":
rid := str(body, "result")
if meta, ok := body["metadata"].(map[string]any); ok {
if t := str(meta, "timestamp"); t != "" {
created = t
}
if m := str(meta, "blockIndex"); m != "" {
merkle = m
} else if m := str(meta, "blockHash"); m != "" {
merkle = m
}
}
if t := str(body, "completedAt"); t != "" && created == "" {
created = t
}
if rid != "" {
return rid, created, merkle, nil
}
case "pending", "running", "processing", "":
select {
case <-ctx.Done():
return "", created, merkle, ctx.Err()
case <-time.After(c.pollInterval):
}
continue
default:
break
}
break
}
return "", created, merkle, fmt.Errorf("veraetime: global timestamp job %s not completed", jobID)
}
// Verify posts a certificate id to /api/verify.
func (c *Client) Verify(ctx context.Context, receiptID, bearer string) (VerifyResult, error) {
if strings.TrimSpace(receiptID) == "" {
return VerifyResult{Valid: false, Error: "receipt_id required"}, nil
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/verify",
map[string]any{"certificate": receiptID}, bearer)
if err != nil {
return VerifyResult{}, err
}
if status == http.StatusUnauthorized {
return VerifyResult{}, authErr(status, body)
}
if status >= 300 {
return VerifyResult{Valid: false, Error: fmt.Sprintf("verify HTTP %d", status)}, nil
}
if !truthy(body["valid"]) {
return VerifyResult{Valid: false, Error: "not valid"}, nil
}
merkle := str(body, "blockHash")
if merkle == "" {
merkle = str(body, "blockIndex")
}
return VerifyResult{
Valid: true,
Receipt: &Receipt{
ReceiptID: receiptID,
Hash: str(body, "hash"),
Scope: "global",
CreatedAt: str(body, "timestamp"),
Merkle: merkle,
},
}, nil
}
// HashReport GETs /api/hash/{digest} and optionally enriches via Verify.
func (c *Client) HashReport(ctx context.Context, dig, bearer string) ([]Receipt, error) {
h, err := digest.NormalizeSHA256(dig)
if err != nil {
return nil, err
}
status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/hash/"+h, nil, bearer)
if err != nil {
return nil, err
}
if status == http.StatusUnauthorized {
return nil, authErr(status, body)
}
if status == http.StatusNotFound {
return nil, nil
}
if status >= 300 {
return nil, httpErr("hash report", status, body)
}
rawRefs, _ := body["receipts"].([]any)
out := make([]Receipt, 0, len(rawRefs))
for _, refAny := range rawRefs {
ref, ok := refAny.(map[string]any)
if !ok {
continue
}
cid := str(ref, "certificate")
if cid == "" {
cid = str(ref, "receipt_id")
}
if cid == "" {
continue
}
created := str(ref, "completedAt")
if created == "" {
created = str(ref, "created_at")
}
restamp := str(ref, "restampOf")
if restamp == "" {
restamp = str(ref, "restamp_of")
}
hashVal := str(ref, "digest")
if hashVal == "" {
hashVal = str(ref, "hash")
}
if hashVal == "" {
hashVal = h
}
merkle := ""
if vr, vErr := c.Verify(ctx, cid, bearer); vErr == nil && vr.Valid && vr.Receipt != nil {
if vr.Receipt.CreatedAt != "" {
created = vr.Receipt.CreatedAt
}
if vr.Receipt.Merkle != "" {
merkle = vr.Receipt.Merkle
}
if vr.Receipt.Hash != "" {
hashVal = vr.Receipt.Hash
}
}
out = append(out, Receipt{
ReceiptID: cid,
Hash: hashVal,
Scope: "global",
CreatedAt: created,
Merkle: merkle,
RestampOf: restamp,
})
}
return out, nil
}
// Signup creates a role=user account (no Authorization header).
func (c *Client) Signup(ctx context.Context, username, password string) (CreateUserResult, error) {
if username == "" || password == "" {
return CreateUserResult{}, fmt.Errorf("username and password are required")
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/auth/signup",
map[string]any{"username": username, "password": password}, "")
if err != nil {
return CreateUserResult{}, err
}
if status >= 300 {
return CreateUserResult{}, httpErr("signup", status, body)
}
role := str(body, "role")
if role == "" {
role = "user"
}
uname := str(body, "username")
if uname == "" {
uname = username
}
return CreateUserResult{ID: str(body, "id"), Username: uname, Role: role}, nil
}
// Login authenticates and persists JWT + credentials to auth.properties.
func (c *Client) Login(ctx context.Context, username, password string) (LoginResult, error) {
res, token, err := c.authenticate(ctx, username, password)
if err != nil {
return LoginResult{}, err
}
path := c.authPath
if path == "" {
path = DefaultAuthPath()
c.authPath = path
}
if err := WriteProperties(path, map[string]string{
"access_token": token,
"id_token": token,
"auth_token": token,
"expires_at": res.ExpiresAt,
"username": username,
"password": password,
}); err != nil {
return LoginResult{}, err
}
return res, nil
}
// Authenticate checks username/password and returns the JWT without persisting.
func (c *Client) Authenticate(ctx context.Context, username, password string) (LoginResult, string, error) {
return c.authenticate(ctx, username, password)
}
func (c *Client) authenticate(ctx context.Context, username, password string) (LoginResult, string, error) {
if username == "" || password == "" {
return LoginResult{}, "", fmt.Errorf("username and password are required")
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/auth/login",
map[string]any{"username": username, "password": password}, "")
if err != nil {
return LoginResult{}, "", err
}
if status >= 300 {
return LoginResult{}, "", httpErr("login", status, body)
}
token := str(body, "token")
if token == "" {
return LoginResult{}, "", fmt.Errorf("login response missing token")
}
user, _ := body["user"].(map[string]any)
uname := str(user, "username")
if uname == "" {
uname = username
}
return LoginResult{
Username: uname,
Role: str(user, "role"),
ExpiresAt: str(body, "expiresAt"),
}, token, nil
}
// Session validates a bearer via GET /auth/validate.
func (c *Client) Session(ctx context.Context, bearer string) (SessionResult, error) {
if strings.TrimSpace(bearer) == "" {
return SessionResult{}, nil
}
status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/auth/validate", nil, bearer)
if err != nil {
return SessionResult{}, err
}
if status >= 300 || !truthy(body["valid"]) {
return SessionResult{}, nil
}
return SessionResult{
Valid: true,
Username: str(body, "username"),
Role: str(body, "role"),
ExpiresAt: str(body, "expiresAt"),
}, nil
}
// CreateUser posts /auth/users (admin bearer required by upstream).
func (c *Client) CreateUser(ctx context.Context, username, password, role, bearer string) (CreateUserResult, error) {
if username == "" || password == "" || role == "" {
return CreateUserResult{}, fmt.Errorf("username, password, and role are required")
}
sess, err := c.Session(ctx, bearer)
if err != nil {
return CreateUserResult{}, err
}
if !sess.Valid || sess.Role != "admin" {
return CreateUserResult{}, fmt.Errorf("admin login required")
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/auth/users",
map[string]any{"username": username, "password": password, "role": role}, bearer)
if err != nil {
return CreateUserResult{}, err
}
if status == http.StatusUnauthorized {
return CreateUserResult{}, authErr(status, body)
}
if status >= 300 {
return CreateUserResult{}, httpErr("create user", status, body)
}
uname := str(body, "username")
if uname == "" {
uname = username
}
r := str(body, "role")
if r == "" {
r = role
}
return CreateUserResult{ID: str(body, "id"), Username: uname, Role: r}, nil
}
func (c *Client) doJSON(ctx context.Context, method, url string, payload map[string]any, bearer string) (int, map[string]any, error) {
var bodyReader io.Reader
if method != http.MethodGet && payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
bodyReader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return 0, nil, err
}
if method != http.MethodGet {
req.Header.Set("Content-Type", "application/json")
}
if b := strings.TrimSpace(bearer); b != "" {
req.Header.Set("Authorization", "Bearer "+b)
}
res, err := c.httpClient.Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(res.Body)
if err != nil {
return res.StatusCode, nil, err
}
var parsed map[string]any
if len(raw) > 0 {
if err := json.Unmarshal(raw, &parsed); err != nil {
parsed = map[string]any{"error": string(raw)}
}
}
if parsed == nil {
parsed = map[string]any{}
}
return res.StatusCode, parsed, nil
}
func str(m map[string]any, key string) string {
if m == nil {
return ""
}
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case float64:
return fmt.Sprintf("%.0f", t)
case json.Number:
return t.String()
default:
return fmt.Sprint(t)
}
}
func truthy(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return t == "true" || t == "1"
default:
return false
}
}
func authErr(status int, body map[string]any) error {
msg := str(body, "error")
return &AuthError{Status: status, Message: msg}
}
func httpErr(op string, status int, body map[string]any) error {
detail := str(body, "error")
if detail == "" {
return fmt.Errorf("veraetime: %s HTTP %d", op, status)
}
return fmt.Errorf("veraetime: %s HTTP %d: %s", op, status, detail)
}

View file

@ -0,0 +1,407 @@
package veraetime_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
)
const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func jsonResp(status int, body any) *http.Response {
raw, _ := json.Marshal(body)
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(raw)),
}
}
func readJSON(t *testing.T, r *http.Request) map[string]any {
t.Helper()
defer r.Body.Close()
var m map[string]any
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
t.Fatalf("decode body: %v", err)
}
return m
}
func TestStamp_PostsDigestRestampFalse(t *testing.T) {
var gotBody map[string]any
var gotAuth string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/timestamp") {
t.Fatalf("unexpected %s %s", r.Method, r.URL)
}
gotAuth = r.Header.Get("Authorization")
gotBody = readJSON(t, r)
return jsonResp(200, map[string]any{
"jobId": "job-1",
"existing": true,
"certificate": "QmCert1",
"timestamp": "2026-08-15T00:00:00Z",
"blockHash": "0xdead",
}), nil
})
c := veraetime.NewClient("https://api.veraetime.net", veraetime.WithRoundTripper(rt))
rec, err := c.Stamp(context.Background(), sha256Empty, "secret")
if err != nil {
t.Fatal(err)
}
if gotAuth != "Bearer secret" {
t.Fatalf("auth=%q", gotAuth)
}
if gotBody["data"] != sha256Empty || gotBody["digest"] != sha256Empty {
t.Fatalf("body=%v", gotBody)
}
if gotBody["hashAlg"] != "SHA256" {
t.Fatalf("hashAlg=%v", gotBody["hashAlg"])
}
if gotBody["restamp"] != false {
t.Fatalf("restamp=%v want false", gotBody["restamp"])
}
if rec.ReceiptID != "QmCert1" || rec.Scope != "global" || rec.Hash != sha256Empty {
t.Fatalf("rec=%+v", rec)
}
}
func TestStamp_ExistingCertificateNoPoll(t *testing.T) {
calls := 0
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
if strings.Contains(r.URL.Path, "/api/status/") {
t.Fatal("must not poll status when certificate present")
}
return jsonResp(200, map[string]any{
"jobId": "28df9952-094b-45a9-b6e5-e3ff81389bdd",
"existing": true,
"certificate": "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA",
}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
rec, err := c.Stamp(context.Background(), sha256Empty, "tok")
if err != nil {
t.Fatal(err)
}
if rec.ReceiptID != "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA" {
t.Fatalf("receipt=%q", rec.ReceiptID)
}
if calls != 1 {
t.Fatalf("calls=%d", calls)
}
}
func TestStamp_PollsUntilCompleted(t *testing.T) {
statusCalls := 0
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(r.URL.Path, "/api/timestamp"):
return jsonResp(202, map[string]any{"jobId": "job-p"}), nil
case strings.HasSuffix(r.URL.Path, "/api/status/job-p"):
statusCalls++
if statusCalls < 3 {
return jsonResp(200, map[string]any{"id": "job-p", "status": "processing"}), nil
}
return jsonResp(200, map[string]any{
"id": "job-p",
"status": "completed",
"result": "QmDone",
"metadata": map[string]any{
"timestamp": "2026-09-23T00:00:00Z",
"blockHash": "0xabc",
},
}), nil
default:
return jsonResp(404, map[string]any{"error": "nope"}), nil
}
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithPollInterval(time.Millisecond),
)
rec, err := c.Stamp(context.Background(), sha256Empty, "tok")
if err != nil {
t.Fatal(err)
}
if rec.ReceiptID != "QmDone" {
t.Fatalf("receipt=%q", rec.ReceiptID)
}
if statusCalls != 3 {
t.Fatalf("statusCalls=%d", statusCalls)
}
}
func TestStamp_401AuthError(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResp(401, map[string]any{"error": "Unauthorized"}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
_, err := c.Stamp(context.Background(), sha256Empty, "bad")
if err == nil {
t.Fatal("expected error")
}
var ae *veraetime.AuthError
if !asAuthError(err, &ae) {
t.Fatalf("want AuthError, got %T %v", err, err)
}
if ae.Status != 401 {
t.Fatalf("status=%d", ae.Status)
}
}
func asAuthError(err error, target **veraetime.AuthError) bool {
if ae, ok := err.(*veraetime.AuthError); ok {
*target = ae
return true
}
return false
}
func TestVerify_PostsCertificate(t *testing.T) {
var gotBody map[string]any
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/verify") {
t.Fatalf("unexpected %s %s", r.Method, r.URL)
}
gotBody = readJSON(t, r)
return jsonResp(200, map[string]any{
"valid": true,
"hash": sha256Empty,
"timestamp": "2026-08-15T00:00:00Z",
"blockHash": "0xdead",
}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
vr, err := c.Verify(context.Background(), "QmCert1", "tok")
if err != nil {
t.Fatal(err)
}
if !vr.Valid || vr.Receipt == nil || vr.Receipt.Hash != sha256Empty {
t.Fatalf("%+v", vr)
}
if gotBody["certificate"] != "QmCert1" {
t.Fatalf("body=%v", gotBody)
}
}
func TestHashReport_UsesAPIHash(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case strings.Contains(r.URL.Path, "/api/hash/"):
return jsonResp(200, map[string]any{
"digest": sha256Empty,
"receipts": []map[string]any{{
"certificate": "job-1",
"digest": sha256Empty,
"completedAt": "2026-08-15T00:00:00Z",
}},
}), nil
case strings.HasSuffix(r.URL.Path, "/api/verify"):
return jsonResp(200, map[string]any{
"valid": true,
"hash": sha256Empty,
"timestamp": "2026-08-15T00:00:00Z",
"blockIndex": 113072,
}), nil
default:
return jsonResp(404, map[string]any{"error": "nope"}), nil
}
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
recs, err := c.HashReport(context.Background(), sha256Empty, "tok")
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 || recs[0].ReceiptID != "job-1" || recs[0].Merkle != "113072" {
t.Fatalf("%+v", recs)
}
}
func TestLogin_StoresAuthProperties(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if !strings.HasSuffix(r.URL.Path, "/auth/login") {
t.Fatalf("url=%s", r.URL)
}
body := readJSON(t, r)
if body["username"] != "admin" || body["password"] != "secret-pass" {
t.Fatalf("body=%v", body)
}
return jsonResp(200, map[string]any{
"token": "fresh-token",
"expiresAt": "2099-01-01T00:00:00Z",
"user": map[string]any{"username": "admin", "role": "admin"},
}), nil
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithAuthPath(path),
)
res, err := c.Login(context.Background(), "admin", "secret-pass")
if err != nil {
t.Fatal(err)
}
if res.Username != "admin" || res.Role != "admin" || res.ExpiresAt != "2099-01-01T00:00:00Z" {
t.Fatalf("%+v", res)
}
props, err := veraetime.ReadProperties(path)
if err != nil {
t.Fatal(err)
}
if props["access_token"] != "fresh-token" || props["password"] != "secret-pass" {
t.Fatalf("%v", props)
}
}
func TestSignup_DoesNotSendBearer(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Header.Get("Authorization") != "" {
t.Fatal("signup must not send Authorization")
}
return jsonResp(201, map[string]any{"id": "u3", "username": "newcomer", "role": "user"}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
res, err := c.Signup(context.Background(), "newcomer", "secret-pass")
if err != nil {
t.Fatal(err)
}
if res.ID != "u3" || res.Username != "newcomer" {
t.Fatalf("%+v", res)
}
}
func TestSession_AndCreateUser(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(r.URL.Path, "/auth/validate"):
if r.Header.Get("Authorization") != "Bearer fresh-token" {
return jsonResp(401, map[string]any{"error": "Unauthorized"}), nil
}
return jsonResp(200, map[string]any{
"valid": true, "username": "admin", "role": "admin", "expiresAt": "2099-01-01T00:00:00Z",
}), nil
case strings.HasSuffix(r.URL.Path, "/auth/users"):
body := readJSON(t, r)
return jsonResp(201, map[string]any{
"id": "u2", "username": body["username"], "role": body["role"],
}), nil
default:
return jsonResp(404, map[string]any{"error": "nope"}), nil
}
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
sess, err := c.Session(context.Background(), "fresh-token")
if err != nil {
t.Fatal(err)
}
if !sess.Valid || sess.Role != "admin" {
t.Fatalf("%+v", sess)
}
created, err := c.CreateUser(context.Background(), "newuser", "secret-pass", "user", "fresh-token")
if err != nil {
t.Fatal(err)
}
if created.ID != "u2" || created.Username != "newuser" {
t.Fatalf("%+v", created)
}
}
func TestKeeper_RefreshesNearExpiry(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
if err := veraetime.WriteProperties(path, map[string]string{
"access_token": "old-token",
"expires_at": time.Now().UTC().Add(30 * time.Second).Format(time.RFC3339),
"username": "admin",
"password": "secret-pass",
}); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var urls []string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
mu.Lock()
urls = append(urls, r.URL.Path)
mu.Unlock()
if strings.HasSuffix(r.URL.Path, "/auth/login") {
return jsonResp(200, map[string]any{
"token": "fresh-token",
"expiresAt": "2099-01-01T00:00:00Z",
"user": map[string]any{"username": "admin", "role": "admin"},
}), nil
}
return jsonResp(404, map[string]any{"error": "nope"}), nil
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithAuthPath(path),
)
k := veraetime.NewKeeper(c, path)
tok, err := k.Token(context.Background())
if err != nil {
t.Fatal(err)
}
if tok != "fresh-token" {
t.Fatalf("tok=%q", tok)
}
mu.Lock()
defer mu.Unlock()
if len(urls) == 0 || !strings.HasSuffix(urls[0], "/auth/login") {
t.Fatalf("urls=%v", urls)
}
}
func TestKeeper_UsesGlobalTokenEnv(t *testing.T) {
t.Setenv("VERAE_GLOBAL_TOKEN", "env-bearer")
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) {
t.Fatal("no HTTP expected when VERAE_GLOBAL_TOKEN set")
return nil, nil
})))
k := veraetime.NewKeeper(c, t.TempDir()+"/missing.properties", veraetime.WithGlobalTokenEnv(true))
tok, err := k.Token(context.Background())
if err != nil {
t.Fatal(err)
}
if tok != "env-bearer" {
t.Fatalf("tok=%q", tok)
}
}
func TestKeeper_UsesFreshStoredToken(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
if err := veraetime.WriteProperties(path, map[string]string{
"access_token": "still-good",
"expires_at": "2099-01-01T00:00:00Z",
"username": "admin",
"password": "secret-pass",
}); err != nil {
t.Fatal(err)
}
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) {
t.Fatal("no login expected")
return nil, nil
})))
k := veraetime.NewKeeper(c, path)
tok, err := k.Token(context.Background())
if err != nil {
t.Fatal(err)
}
if tok != "still-good" {
t.Fatalf("tok=%q", tok)
}
}

View file

@ -0,0 +1,144 @@
package veraetime
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Keeper mints and refreshes a host bearer for VeraeTime when callers omit one.
type Keeper struct {
client *Client
authPath string
globalToken string
useEnv bool
mu sync.Mutex
token string
expiresAt time.Time
username string
password string
loaded bool
}
// KeeperOption configures Keeper.
type KeeperOption func(*Keeper)
// WithGlobalToken sets a static host bearer (overrides file until empty).
func WithGlobalToken(tok string) KeeperOption {
return func(k *Keeper) {
k.globalToken = strings.TrimSpace(tok)
}
}
// WithGlobalTokenEnv loads VERAE_GLOBAL_TOKEN when Token is called.
func WithGlobalTokenEnv(enabled bool) KeeperOption {
return func(k *Keeper) {
k.useEnv = enabled
}
}
// NewKeeper builds a host-token keeper bound to client and auth.properties path.
func NewKeeper(client *Client, authPath string, opts ...KeeperOption) *Keeper {
k := &Keeper{
client: client,
authPath: authPath,
useEnv: true,
}
for _, opt := range opts {
opt(k)
}
return k
}
// Token returns a usable host bearer: VERAE_GLOBAL_TOKEN, stored access_token,
// or a fresh login persisted to auth.properties. Never logs the token.
func (k *Keeper) Token(ctx context.Context) (string, error) {
if k == nil {
return "", fmt.Errorf("veraetime: nil keeper")
}
k.mu.Lock()
defer k.mu.Unlock()
if k.useEnv {
if env := strings.TrimSpace(os.Getenv("VERAE_GLOBAL_TOKEN")); env != "" {
return env, nil
}
}
if tok := strings.TrimSpace(k.globalToken); tok != "" {
return tok, nil
}
if err := k.loadLocked(); err != nil {
return "", err
}
if k.token != "" && !k.needsRefreshLocked() {
return k.token, nil
}
if k.username == "" || k.password == "" {
if k.token != "" {
return k.token, nil
}
return "", fmt.Errorf("veraetime: no host token (set VERAE_GLOBAL_TOKEN or auth.properties)")
}
if k.client == nil {
return "", fmt.Errorf("veraetime: nil client")
}
if k.authPath != "" && k.client.authPath == "" {
k.client.authPath = k.authPath
}
res, err := k.client.Login(ctx, k.username, k.password)
if err != nil {
return "", err
}
props, err := ReadProperties(k.authPath)
if err != nil {
return "", err
}
tok := storedAccessToken(props)
if tok == "" {
return "", fmt.Errorf("veraetime: login did not persist access_token")
}
k.token = tok
if exp, ok := ParseExpiry(res.ExpiresAt); ok {
k.expiresAt = exp
}
return k.token, nil
}
func (k *Keeper) loadLocked() error {
if k.loaded {
return nil
}
k.loaded = true
if k.authPath == "" {
return nil
}
props, err := ReadProperties(k.authPath)
if err != nil {
return err
}
if k.token == "" {
k.token = storedAccessToken(props)
}
k.username = props["username"]
k.password = props["password"]
if exp, ok := ParseExpiry(props["expires_at"]); ok {
k.expiresAt = exp
}
return nil
}
func (k *Keeper) needsRefreshLocked() bool {
if k.username == "" || k.password == "" {
return false
}
if k.token == "" || k.expiresAt.IsZero() {
return true
}
return !time.Now().UTC().Before(k.expiresAt.Add(-TokenRefreshSkew))
}