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