88 lines
3.2 KiB
Go
88 lines
3.2 KiB
Go
// Package sendico consumes only Sendico's public HTTP contracts.
|
|
package sendico
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
merrors "git.sendico.io/tech/sxchnge/shared/errors"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
)
|
|
|
|
type Config struct {
|
|
APIURL string `yaml:"api_url"`
|
|
OrganizationID string `yaml:"organization_ref"`
|
|
SourceID string `yaml:"source_account_ref"`
|
|
TokenFile string `yaml:"token_file"`
|
|
}
|
|
|
|
type Client struct {
|
|
config Config
|
|
base string
|
|
namespace string
|
|
http *http.Client
|
|
}
|
|
|
|
func Open(c Config) (*Client, error) {
|
|
u, err := url.Parse(c.APIURL)
|
|
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.RawPath != "" || (u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"))) {
|
|
return nil, merrors.InvalidArgument("Sendico API URL must be absolute HTTPS (HTTP allowed on localhost)")
|
|
}
|
|
for _, ref := range []string{c.OrganizationID, c.SourceID} {
|
|
id, err := bson.ObjectIDFromHex(ref)
|
|
if err != nil || id.IsZero() || ref != id.Hex() {
|
|
return nil, merrors.InvalidArgument("Sendico organization and source references must be lowercase object IDs")
|
|
}
|
|
}
|
|
if strings.TrimSpace(c.TokenFile) == "" {
|
|
return nil, merrors.InvalidArgument("Sendico token file required")
|
|
}
|
|
base := strings.TrimRight(c.APIURL, "/")
|
|
hash := sha256.Sum256([]byte(base + "\x00" + c.OrganizationID + "\x00" + c.SourceID))
|
|
client := &Client{config: c, base: base, namespace: hex.EncodeToString(hash[:]), http: &http.Client{Timeout: 4 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
|
if _, err := client.token(); err != nil {
|
|
return nil, err
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
func (c *Client) Namespace() string { return c.namespace }
|
|
|
|
// Rotation may replace this file, but must retain the authenticated account.
|
|
// Claim inspection only protects the replay namespace from operator mistakes;
|
|
// Sendico remains responsible for signature validation and authorization.
|
|
func (c *Client) token() (string, error) {
|
|
raw, err := os.ReadFile(c.config.TokenFile)
|
|
if err != nil {
|
|
return "", merrors.OpaqueWrap(merrors.ErrServiceUnavailable, err, "read Sendico credential")
|
|
}
|
|
token := strings.TrimSpace(string(raw))
|
|
parts := strings.Split(token, ".")
|
|
if len(token) > 16384 || len(parts) != 3 {
|
|
return "", merrors.FailedPrecondition("invalid Sendico credential")
|
|
}
|
|
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
|
}
|
|
var payload struct {
|
|
ID string `json:"id"`
|
|
Pending bool `json:"pending"`
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
if err := json.Unmarshal(claims, &payload); err != nil {
|
|
return "", merrors.OpaqueWrap(merrors.ErrFailedPrecondition, err, "invalid Sendico credential claims")
|
|
}
|
|
if payload.ID != c.config.SourceID || payload.Pending || payload.Exp <= time.Now().Unix() {
|
|
return "", merrors.FailedPrecondition("Sendico credential must be unexpired and match configured source account")
|
|
}
|
|
return token, nil
|
|
}
|