servicecall

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 15, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

README

Service-call helpers

servicecall provides optional authenticated HTTP calls between operator-owned services. Authentication is disabled by default so local/self-hosted callers can use the same client without a hosted secret source.

client := servicecall.NewClient(servicecall.Config{Enabled: false})

The package carries no provider, deployment, or network-topology authority. Use the public module path when importing it:

import "github.com/kombifyio/go-common/servicecall"

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

View Source
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.

View Source
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.

View Source
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

View Source
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.

View Source
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

func IsServiceCall(ctx context.Context) bool

IsServiceCall reports whether the request was authenticated as a service-to-service call.

func IssueToken

func IssueToken(cfg Config, target string, obo *OnBehalfOf, requestID string) (string, error)

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

func LoadKeysFromEnv() (primary, next string, err error)

LoadKeysFromEnv reads SERVICE_AUTH_SECRET (primary, required) and SERVICE_AUTH_SECRET_NEXT (optional, during rotation).

func Middleware

func Middleware(cfg Config) func(next http.Handler) http.Handler

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

func NewContext(ctx context.Context, c *Caller) context.Context

NewContext stores a Caller in ctx.

func RequireServiceAuth

func RequireServiceAuth(cfg Config) func(next http.Handler) http.Handler

RequireServiceAuth is a stricter variant of Middleware that returns 401 when the service-auth header is missing. Use on routes that must not be reachable via the public edge.

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

func FromContext(ctx context.Context) *Caller

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

func VerifyToken(token, primary, next string) (*Claims, error)

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

func NewClient(cfg Config) (*Client, error)

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) Get

func (c *Client) Get(ctx context.Context, url string, obo *OnBehalfOf) (*http.Response, error)

Get executes GET url with an auth header attached.

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(); … }

func (*Client) WithHTTPClient

func (c *Client) WithHTTPClient(h *http.Client) *Client

WithHTTPClient swaps the underlying http.Client. Returns c for chaining.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL