Documentation
¶
Overview ¶
Package servicecall provides authenticated service-to-service HTTP calls within the kombify operator-managed network.
Standards reference: kombify Core/standards/API-COMMUNICATION-ARCHITECTURE.md §4.
Why HS256 shared-secret and not Auth0 M2M:
- operator-managed network is already isolated; auth here is for caller identification (audit, authz), not traffic protection.
- Auth0 M2M roundtrips add 50-200ms + rate-limit risk per request.
- A shared secret with dual-key rotation (analogous to EDGE_AUTH_SECRET) delivers identity at zero latency cost.
Secret lifecycle:
- SERVICE_AUTH_SECRET (primary, the configured secret source: kombify-io/prd)
- SERVICE_AUTH_SECRET_NEXT (optional, active during 180-day rotation)
Identity forwarding:
- Tokens may carry an OnBehalfOf claim that describes the end user the caller is acting for. The receiving middleware promotes this into identity.Identity so downstream handlers use identity.FromContext() uniformly — regardless of whether the request entered via the Edge or via another service.
See also: edgeauth (Edge->Origin trust), toolauth (desktop tools), nodeauth (Sim user-nodes, Phase B).
Index ¶
- Constants
- Variables
- func IsServiceCall(ctx context.Context) bool
- func IssueToken(cfg Config, target string, obo *OnBehalfOf, requestID string) (string, error)
- func LoadKeysFromEnv() (primary, next string, err error)
- func Middleware(cfg Config) func(next http.Handler) http.Handler
- func NewContext(ctx context.Context, c *Caller) context.Context
- func RequireServiceAuth(cfg Config) func(next http.Handler) http.Handler
- type Caller
- type Claims
- type Client
- func (c *Client) Do(ctx context.Context, req *http.Request, obo *OnBehalfOf) (*http.Response, error)
- func (c *Client) Get(ctx context.Context, url string, obo *OnBehalfOf) (*http.Response, error)
- func (c *Client) PostJSON(ctx context.Context, url string, body interface{}, obo *OnBehalfOf) (*http.Response, error)
- func (c *Client) PostStream(ctx context.Context, url string, body interface{}, obo *OnBehalfOf) (*http.Response, error)
- func (c *Client) WithHTTPClient(h *http.Client) *Client
- type Config
- type OnBehalfOf
Constants ¶
const ( EnvSecret = "SERVICE_AUTH_SECRET" // #nosec G101 -- public environment-variable identifier, not a secret value. // #nosec G101 -- public rotation environment-variable identifier, not a secret value. EnvSecretNext = "SERVICE_AUTH_SECRET_NEXT" )
Env var names for secret loading.
const DefaultTokenTTL = 5 * time.Minute
DefaultTokenTTL is the lifetime of issued service-call tokens. Short-lived by design: tokens are cheap to re-issue and do not need revocation infrastructure.
const HeaderServiceAuth = "X-Kombify-Service-Auth"
HeaderServiceAuth is the HTTP header carrying the service-call JWT. Deliberately distinct from Authorization so end-user Bearer tokens and service-call tokens can coexist on the same request if needed.
Variables ¶
var ( ErrBadToken = errors.New("servicecall: malformed token") ErrBadSignature = errors.New("servicecall: invalid signature") ErrExpired = errors.New("servicecall: token expired") ErrNotYetValid = errors.New("servicecall: token not yet valid") ErrEmptySecret = errors.New("servicecall: empty signing secret") ErrWrongAudience = errors.New("servicecall: wrong audience") ErrCallerDenied = errors.New("servicecall: caller not in allowlist") )
Token-validation errors. Kept package-level so callers can type-switch.
var ErrSecretMissing = errors.New("servicecall: SERVICE_AUTH_SECRET is not set")
ErrSecretMissing is returned by LoadKeysFromEnv when the primary secret is not set.
Functions ¶
func IsServiceCall ¶
IsServiceCall reports whether the request was authenticated as a service-to-service call.
func IssueToken ¶
IssueToken builds a signed service-call token from cfg and the supplied target/obo/requestID. cfg.ServiceName and cfg.Secret must be set.
func LoadKeysFromEnv ¶
LoadKeysFromEnv reads SERVICE_AUTH_SECRET (primary, required) and SERVICE_AUTH_SECRET_NEXT (optional, during rotation).
func Middleware ¶
Middleware validates X-Kombify-Service-Auth, stores the verified Caller in context and promotes OnBehalfOf to identity.Identity so downstream handlers use identity.FromContext() uniformly.
Behaviour:
- cfg.Enabled == false → pass-through (for self-hosted builds)
- no X-Kombify-Service-Auth → pass-through (the route may still be reachable via the edge; edgeauth.Middleware handles that path)
- token present but invalid/exp → 401
- audience mismatch → 401
- caller not in AllowedCallers → 403
- valid → Caller in ctx, identity in ctx
Use RequireServiceAuth for routes that must ONLY be callable service-to-service.
func NewContext ¶
NewContext stores a Caller in ctx.
Types ¶
type Caller ¶
type Caller struct {
Service string
OnBehalfOf *OnBehalfOf
RequestID string
IssuedAt time.Time
ExpiresAt time.Time
}
Caller is the verified caller identity after middleware validation.
func FromContext ¶
FromContext retrieves the verified Caller from ctx. Returns nil if none.
type Claims ¶
type Claims struct {
Iss string `json:"iss"`
Aud string `json:"aud"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
Svc string `json:"svc"`
OnBehalfOf *OnBehalfOf `json:"on_behalf_of,omitempty"`
RequestID string `json:"req_id,omitempty"`
}
Claims are the JWT claims carried in a service-call token.
func VerifyToken ¶
VerifyToken parses and validates token against primary (and optionally secretNext during rotation). Returns the decoded claims on success. Does NOT enforce audience or caller policy — that is the middleware's job.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is an HTTP client that attaches service-call tokens to outbound requests. Safe for concurrent use.
func NewClient ¶
NewClient builds a service-call client. cfg.ServiceName, cfg.Target and cfg.Secret are required; a zero TokenTTL falls back to DefaultTokenTTL.
func (*Client) Do ¶
func (c *Client) Do(ctx context.Context, req *http.Request, obo *OnBehalfOf) (*http.Response, error)
Do attaches the service-auth header to req and forwards it to the inner http.Client. If obo is non-nil, the end-user context is embedded in the token for downstream attribution.
func (*Client) PostJSON ¶
func (c *Client) PostJSON(ctx context.Context, url string, body interface{}, obo *OnBehalfOf) (*http.Response, error)
PostJSON marshals body as JSON and POSTs it to url with an auth header. A nil body sends no payload.
func (*Client) PostStream ¶
func (c *Client) PostStream(ctx context.Context, url string, body interface{}, obo *OnBehalfOf) (*http.Response, error)
PostStream POSTs body as JSON with `Accept: text/event-stream` and returns the http.Response for SSE consumption. The caller is responsible for closing resp.Body and parsing the `data: …` frames.
Unlike PostJSON, the underlying http.Client used here has no client-side timeout so long-running generations and agent streams are bounded only by ctx. Cancel ctx (or let it expire) to terminate the stream cleanly.
Typical caller pattern:
resp, err := client.PostStream(ctx, targetURL, body, obo)
if err != nil { return err }
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() { line := scanner.Text(); … }
type Config ¶
type Config struct {
// ServiceName is the short id of the local service (e.g. "cloud", "ai").
// Used as iss on outbound tokens and as the expected aud on inbound.
ServiceName string
// Target is the short id of the remote service. Client-side only.
Target string
// Secret is the primary HS256 signing secret (base64 or raw text).
// Outbound tokens are always signed with Secret; inbound tokens are
// verified against Secret first, then SecretNext if set.
Secret string
// SecretNext is the optional "next" secret active during rotation.
SecretNext string
// TokenTTL overrides DefaultTokenTTL. Client-side only.
TokenTTL time.Duration
// AllowedCallers is an optional whitelist of caller svc ids. When non-
// empty, tokens whose Svc claim is not in the list are rejected with
// 403 by the middleware. Middleware-side only.
AllowedCallers []string
// Enabled turns the middleware on. Defaults false so self-hosted
// builds without SERVICE_AUTH_SECRET keep working.
Enabled bool
}
Config governs both client and middleware behaviour. Fields used by only one side are noted.
type OnBehalfOf ¶
type OnBehalfOf struct {
Sub string `json:"sub,omitempty"`
OrgID string `json:"org_id,omitempty"`
Email string `json:"email,omitempty"`
Tier string `json:"tier,omitempty"`
Roles []string `json:"roles,omitempty"`
}
OnBehalfOf carries end-user context when a service call is made on a user's behalf. Promoted into identity.Identity at the receiving side.
func OnBehalfOfIdentity ¶
func OnBehalfOfIdentity(id *identity.Identity) *OnBehalfOf
OnBehalfOfIdentity converts an identity.Identity into an OnBehalfOf. Returns nil when id has no UserID — the call is then treated as purely service-to-service with no end-user attribution.