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:
parent
0a2d3177c6
commit
348ccd0bf2
6 changed files with 1424 additions and 0 deletions
27
internal/authctx/resolve.go
Normal file
27
internal/authctx/resolve.go
Normal 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)
|
||||||
|
}
|
||||||
117
internal/authctx/resolve_test.go
Normal file
117
internal/authctx/resolve_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
internal/veraetime/authprops.go
Normal file
137
internal/veraetime/authprops.go
Normal 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 ""
|
||||||
|
}
|
||||||
592
internal/veraetime/client.go
Normal file
592
internal/veraetime/client.go
Normal 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)
|
||||||
|
}
|
||||||
407
internal/veraetime/client_test.go
Normal file
407
internal/veraetime/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
144
internal/veraetime/keeper.go
Normal file
144
internal/veraetime/keeper.go
Normal 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))
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue