Documentation
¶
Overview ¶
Package btpingo (BTPinGo) is a Go client for the SAP BTP Cloud Foundry runtime: XSUAA JWT validation, Destination-service lookup, and on-premise calls through the Connectivity proxy / Cloud Connector. Around that it provides pluggable destination-level authenticators, a typed API error envelope with request-ID correlation, and transparent CSRF handshaking for mutating calls.
This package is framework-neutral and does not import Gin. A Gin-based service additionally imports github.com/hochfrequenz/btpingo/ginpingo for the Gin middleware and handlers (JWT validation, request ID, scope enforcement, the error writer, and a generic proxy handler); a huma or other non-Gin service uses this package's context helpers directly and never needs that subpackage. A service with connect-go (gRPC, gRPC-Web, Connect) handlers imports github.com/hochfrequenz/btpingo/grpcpingo for the matching net/http middleware; this package imports neither connect-go nor gin.
API surface ¶
## Service & on-prem plumbing
- Service, NewService
- *Service.CallOnPremise, *Service.CallOnPremiseMutating
- OnPremCaller — interface handlers should depend on for reads
- OnPremMutator — interface handlers should depend on for writes (CSRF)
- *Service.Authenticators — registry accessor for startup wiring of SSO / PP / …
- NewOnPremiseTransport, ConnTokenProvider
- ServiceOption, WithUserAgent, WithMgmtTimeout, WithOnPremiseTimeout, WithCSRFFetchPath, WithDestinationCacheTTL, WithOnPremResponseSizeLimit
- DefaultOnPremiseTimeout, DefaultMgmtTimeout, DefaultUserAgent, DefaultCSRFFetchPath, DefaultOnPremResponseSizeLimit
## JWT validation & request-scoped context
- JWTValidator, NewJWTValidator, *JWTValidator.Parse
- ForwardedUserTokenKey
- ScopesFromClaims — normalises the "scope" claim for a framework adapter
- RequestIDHeader, RequestIDFromContext, ContextWithRequestID
- ValidRequestID, NewRequestID — the request-ID validation and generation a framework adapter's request-ID middleware needs
- ContextWithScopes, ScopesFromContext, HasScope — scope reads for handlers with no Gin context
## Error envelope
- ErrorEnvelope, ErrorDetail, ErrorCode
- CodeInvalidRequest, CodeUnauthorized, CodeForbidden, CodeNotFound, CodeMethodNotAllowed, CodeRequestTooLarge, CodeUpstreamUnreachable, CodeInternal
## Environment / VCAP bindings
- Env, LoadEnv, *Env.Validate
- XSUAACredentials, DestCredentials, ConnCredentials
- Destination, LookupDestination
- DestinationCache, NewDestinationCache, DefaultDestinationCacheTTL
## Authenticator registry
- AuthenticatorRegistry, *AuthenticatorRegistry.Register, *AuthenticatorRegistry.SetFallback, *AuthenticatorRegistry.Apply
- DestinationAuthenticator interface
- DefaultAuthenticators, NoAuthenticator, BasicAuthenticator
- AuthType, ProxyType
- AuthNone, AuthBasic, AuthPrincipalPropagation, … and ProxyOnPremise, ProxyInternet, ProxyPrivateLink
## Tokens
## Sentinel errors
- ErrNoDestinationBinding, ErrNoConnectivityBinding, ErrNoXSUAABinding
- ErrDestinationNotFound, ErrNotInCloudFoundry
- ErrOnPremResponseTooLarge, ErrOnPremCrossOriginRedirect
## Response compression
- CompressHandler — wraps an http.Handler with gzip/zstd response compression and a Content-Length guard; srv.Handler = CompressHandler(router)
- CompressHandlerSkipping — the same, but path prefixes (a gRPC service) bypass it
## On-prem failure classification
- OnPremFailureKind — typed classifier; stable wire format
- OnPremFailureDestinationNotFound, OnPremFailureResponseTooLarge, OnPremFailureTimeout, OnPremFailureCanceled, OnPremFailureTransport
- ClassifyOnPremError — (kind, detail) for huma 502 envelopes (err path)
- OnPremNon2xxDetail — stable detail string for the non-2xx-from-SAP path
Conservative retries ¶
Retry wraps read-only calls with RetryPolicy: by default, at most two attempts separated by a jittered 100–200ms wait. Retryable permits only plain transport errors and HTTP 502/503. Timeouts and HTTP 504 are excluded because another attempt doubles latency and adds load to an already slow upstream. Cancellation, missing destinations, oversized responses, and all other statuses (including 401/403) are returned without retrying. The service's existing credential and CSRF retries are unchanged.
Use RetryMutating for writes: it makes only one attempt unless RetryPolicy.Idempotent is explicitly true. A transport failure may happen after a mutation was applied; buffering its body does not make replay safe. The callback must rebuild any request body on every attempt and honor its context. Retry closes discarded response bodies; callers close the final body. RetryPolicy.Clock accepts an After(time.Duration) channel clock for tests without real sleeps.
Retry does not parse application error codes or consume response bodies. Application failures returned under statuses other than 502/503 are not retried. Callers with application-specific failure signals must handle those before opting into a retry; they must not present them as plain transport errors.
Index ¶
- Constants
- Variables
- func CompressHandler(h http.Handler) http.Handler
- func CompressHandlerSkipping(h http.Handler, pathPrefixes ...string) http.Handler
- func ContextWithRequestID(ctx context.Context, id string) context.Context
- func ContextWithScopes(ctx context.Context, scopes []string) context.Context
- func HasScope(ctx context.Context, scope string) bool
- func NewOnPremiseTransport(conn *ConnCredentials, provider ConnTokenProvider) (http.RoundTripper, error)
- func NewRequestID() string
- func OnPremNon2xxDetail(status int) string
- func RequestIDFromContext(ctx context.Context) string
- func Retry(ctx context.Context, p RetryPolicy, ...) (*http.Response, error)
- func RetryMutating(ctx context.Context, p RetryPolicy, ...) (*http.Response, error)
- func Retryable(resp *http.Response, err error) bool
- func ScopesFromClaims(claims jwt.MapClaims) []string
- func ScopesFromContext(ctx context.Context) []string
- func ValidRequestID(id string) bool
- type AuthType
- type AuthenticatorRegistry
- type BasicAuthenticator
- type ConnCredentials
- type ConnTokenProvider
- type DestCredentials
- type Destination
- type DestinationAuthenticator
- type DestinationCache
- type Env
- type ErrorCode
- type ErrorDetail
- type ErrorEnvelope
- type ForwardedUserTokenKey
- type JWTValidator
- type NoAuthenticator
- type OnPremCaller
- type OnPremFailureKind
- type OnPremMutator
- type ProxyType
- type RetryPolicy
- type Service
- func (s *Service) Authenticators() *AuthenticatorRegistry
- func (s *Service) CallOnPremise(ctx context.Context, destName, method, pathSuffix string, headers http.Header, ...) (*http.Response, error)
- func (s *Service) CallOnPremiseMutating(ctx context.Context, destName, method, pathSuffix string, headers http.Header, ...) (*http.Response, error)
- type ServiceOption
- func WithCSRFFetchPath(path string) ServiceOption
- func WithDestinationCacheTTL(d time.Duration) ServiceOption
- func WithMgmtTimeout(d time.Duration) ServiceOption
- func WithOnPremResponseSizeLimit(bytes int64) ServiceOption
- func WithOnPremiseTimeout(d time.Duration) ServiceOption
- func WithUserAgent(ua string) ServiceOption
- type TokenFetcher
- type XSUAACredentials
Examples ¶
Constants ¶
const DefaultCSRFFetchPath = "/sap/bc/adt/discovery"
DefaultCSRFFetchPath is the SAP path CallOnPremiseMutating calls with `X-CSRF-Token: Fetch` to retrieve a token. Defaults to ADT's bootstrap endpoint because ADT is the most common target for mutating on-prem calls. Override with WithCSRFFetchPath if the destination doesn't route /sap/bc/adt/* — a bare GET against a REST endpoint on the same ICF node works too.
const DefaultDestinationCacheTTL = 5 * time.Minute
DefaultDestinationCacheTTL bounds how long a looked-up destination is reused before DestinationCache asks the Destination service again. Destinations are operator-configured and change rarely, so this is generous compared to a token TTL — but it is finite rather than "cache forever", so a destination fixed in the cockpit after a misconfiguration heals here within one TTL rather than requiring an app restart.
const DefaultMgmtTimeout = 10 * time.Second
DefaultMgmtTimeout is the per-call timeout for management calls (XSUAA token exchange, Destination service lookup). Override with WithMgmtTimeout when a specific service needs a different value.
const DefaultOnPremResponseSizeLimit int64 = 10 << 20
DefaultOnPremResponseSizeLimit caps how much of an on-prem response body Service will let a caller read. The Cloud Connector tunnel itself is trusted, but a misbehaving SAP backend, a misrouted hostname, or — for customer-managed CC topologies — a MITM between CC and SAP could otherwise stream gigabytes into the app's 128 MiB CF memory quota. 10 MiB is well above any legitimate typed-API response (ADT XML, a few hundred KiB of FI line items, etc.).
On overshoot, the wrapped resp.Body returns ErrOnPremResponseTooLarge from Read; the caller's io.ReadAll surfaces it and the handler translates to btpingo.CodeUpstreamUnreachable (502).
Override with WithOnPremResponseSizeLimit when a specific service has a route that legitimately returns more.
const DefaultOnPremiseTimeout = 10 * time.Minute
DefaultOnPremiseTimeout is the per-call timeout for proxied requests. Sized for a realistic worst case: an older, heavily loaded on-prem SAP system reached through Cloud Connector + (for mutating routes) a CSRF handshake. Such calls regularly take minutes under normal load and have been observed up to ~5 minutes in the worst case. 10 minutes leaves headroom over that worst case without normalising calls that are genuinely hung. Override per-instance with WithOnPremiseTimeout when your SAP system is reliably faster.
Keep your http.Server's WriteTimeout strictly *above* this value (e.g. 15 min). The asymmetry is deliberate: this per-call budget fires first on a hung SAP and surfaces a clean upstream_unreachable envelope; setting it equal to or larger than WriteTimeout would let the server-side timeout race this one and produce a less-helpful failure mode. If you raise this value, raise WriteTimeout proportionally.
const DefaultUserAgent = "btpingo"
DefaultUserAgent is the User-Agent header sent on outbound on-prem requests when no WithUserAgent option is supplied. Services should set an explicit UA (e.g. name plus version from debug.ReadBuildInfo) so traces on the SAP side can tell callers of this library apart.
const RequestIDHeader = "X-Request-Id"
RequestIDHeader is the inbound/outbound HTTP header carrying the request ID. Downstream systems (approuter, BTP app-logging) use this exact spelling; keeping it as a constant means nobody can change it in one place and silently break correlation with the platform.
const TokenRefreshLeeway = 30 * time.Second
TokenRefreshLeeway is subtracted from the server-reported TTL when deciding whether a cached token is still usable. Without leeway, a token fetched milliseconds before expiry will be handed out, travel across the network, and be rejected on arrival — a classic bug in hand-rolled XSUAA clients.
Variables ¶
var ErrDestinationNotFound = errors.New("destination not found")
ErrDestinationNotFound wraps a 404 from the Destination service so callers can branch on it via errors.Is. Any other 4xx/5xx returns a plain error.
var ErrNoConnectivityBinding = errors.New("connectivity service not bound")
ErrNoConnectivityBinding signals the Connectivity service binding is missing. Returned when an on-premise call is attempted without the binding.
var ErrNoDestinationBinding = errors.New("destination service not bound")
ErrNoDestinationBinding signals the Destination service binding is missing. Returned when a feature that needs the Destination service is used.
var ErrNoXSUAABinding = errors.New("xsuaa binding not found")
ErrNoXSUAABinding signals the XSUAA binding is missing. XSUAA is always required: without it the app cannot validate any incoming JWT.
var ErrNotInCloudFoundry = errors.New("no Cloud Foundry environment detected (VCAP_APPLICATION/VCAP_SERVICES unset)")
ErrNotInCloudFoundry signals that VCAP_APPLICATION / VCAP_SERVICES were absent. LoadEnv refuses to proceed in that case; there is no meaningful local-dev mode for a BTP app, and stubbing would invite code paths that only ever run on a developer laptop.
var ErrOnPremCrossOriginRedirect = errors.New("on-prem redirect to a different scheme or host")
ErrOnPremCrossOriginRedirect is returned by CallOnPremise and CallOnPremiseMutating when the on-prem system answers with a redirect to a different scheme or host than the request it redirects. Such a redirect is not followed: CallOnPremise pins the first request to the destination's scheme+host, and following a Location elsewhere would send the next request — through the Connectivity proxy, with a fresh Proxy-Authorization, and for the same hostname on another port or scheme also with the destination's Authorization — to a target nobody configured. Same-origin redirects are still followed. Like any transport error it classifies as OnPremFailureTransport.
var ErrOnPremResponseTooLarge = errors.New("on-prem response exceeds configured size limit")
ErrOnPremResponseTooLarge is returned by reads on an on-prem response body once the per-call cap configured via WithOnPremResponseSizeLimit (default DefaultOnPremResponseSizeLimit) is exceeded. Callers using io.ReadAll surface this error from their ReadAll call; the typical translation in handlers is btpingo.CodeUpstreamUnreachable (502).
Functions ¶
func CompressHandler ¶ added in v0.2.0
CompressHandler wraps h with response compression at the net/http level, plus a Content-Length guard that closes a truncation/overflow gap compression would otherwise introduce (see contentLengthGuard).
A response is compressed with zstd or gzip only when the request's Accept-Encoding accepts one of them by q-value (zstd is preferred when both are equally acceptable — gzhttp's PreferZstd default). A request with no Accept-Encoding header, one that excludes both (e.g. "gzip;q=0, zstd;q=0"), or a response body under gzhttp's minimum size or of a skip-listed content type is sent uncompressed instead.
Because it operates on plain http.Handler/http.ResponseWriter, it works the same whether the wrapped handler is a *gin.Engine, a huma mux, or an http.ServeMux — and it keeps this package gin-free.
If the wrapped handler writes a different number of body bytes than the Content-Length it declared, CompressHandler logs one line at Error level on the default slog logger and aborts the connection with panic(http.ErrAbortHandler), so the client sees an unexpected EOF instead of a clean short or overlong response. A recover middleware placed around CompressHandler must re-panic http.ErrAbortHandler.
Usage:
srv.Handler = btpingo.CompressHandler(router)
gzhttp's own defaults are kept as-is, including its zstd support (not restricted to gzip-only): a client that never advertises "zstd" simply gets gzip, and a client that does gets real additional bandwidth savings. An SAP approuter placed in front of a service passes a backend's Content-Encoding through unchanged rather than stripping or re-encoding it (checked at @sap/approuter 23.0.0), so compressing here is safe even when an approuter sits between the caller and this service. gzhttp also keeps its own minimum-size threshold and content-type skip list, so small bodies and already-compressed upstream responses are left alone rather than being (re-)compressed.
func CompressHandlerSkipping ¶ added in v0.4.0
CompressHandlerSkipping is CompressHandler except that a request whose URL path starts with one of pathPrefixes goes to h directly: its response is neither compressed nor checked against its Content-Length. With no prefixes it is CompressHandler.
The use case is gRPC. gzhttp compresses application/grpc* responses by default, and gRPC clients reject an HTTP Content-Encoding (gRPC and Connect compress messages on their own, via Grpc-Encoding and Connect-Content-Encoding). Pass the service's path prefix, for example "/test.v1.TestService/":
srv.Handler = btpingo.CompressHandlerSkipping(router, "/test.v1.TestService/")
A prefix should end with "/", as the path connect-go generates does, so that "/test.v1.TestService/" does not also match "/test.v1.TestServiceAdmin/". An empty prefix would skip every path and panics.
Skipping the Content-Length guard on those paths is harmless: connect-go never sets a response Content-Length.
func ContextWithRequestID ¶
ContextWithRequestID returns ctx carrying the given request ID, as a request-ID middleware (e.g. ginpingo.RequestID) does for a live request.
Exported for the same reason as ContextWithScopes: a handler test that needs a request-scoped id without standing up the whole middleware chain, and any future composer of such a context. The production writer is that middleware and nothing else — this does not reach the X-Request-Id response header, so a caller that uses it outside a test is creating an id no client will ever see.
func ContextWithScopes ¶
ContextWithScopes returns ctx carrying the given scopes.
github.com/hochfrequenz/btpingo/ginpingo.JWT is the only production caller, and it calls this only after the signature, audience and expiry have been verified — so a scope in a request context is always one XSUAA actually issued. It is exported for the other two legitimate composers of such a context: a handler test that needs a caller with a scope without minting a JWT, and any future middleware that authenticates by some other means. It is not an authorization decision; HasScope is.
func HasScope ¶
HasScope reports whether the validated token on this request carries the given qualified scope (e.g. "myapp!t1234.ReadData").
This is the huma-side counterpart of github.com/hochfrequenz/btpingo/ginpingo.RequireScope, which is gin middleware and can only abort. A huma handler that must REPORT a capability rather than enforce it (e.g. a "may read data" flag in a response) needs to ask the question without rejecting the request, which is what this is for.
func NewOnPremiseTransport ¶
func NewOnPremiseTransport(conn *ConnCredentials, provider ConnTokenProvider) (http.RoundTripper, error)
NewOnPremiseTransport builds a RoundTripper that routes every request through the Connectivity service's on-premise reverse proxy.
Proxy-Authorization carries `Bearer <conn-token>`, but only on the leg that actually needs it. For HTTPS targets the request travels inside the CONNECT tunnel, so the far end (the SAP origin) never sees this header — only the CONNECT itself does, via Transport.GetProxyConnectHeader (wired here, per-request). For plain HTTP targets there is no tunnel, so the header has to travel with the forwarded request itself (the proxy consumes it before forwarding). Wiring the CONNECT header per-request rather than per-Transport is what lets the RoundTripper stop cloning the Transport on every call — the idle-connection pool stays shared across calls.
For plain-HTTP targets the transport attaches Proxy-Authorization to every request it carries, including a redirect's follow-up; for HTTPS targets every new CONNECT tunnel carries it. Service's own client therefore only follows redirects that stay on the original scheme+host (see ErrOnPremCrossOriginRedirect); a caller wrapping this transport in its own http.Client should set an equivalent CheckRedirect.
func NewRequestID ¶
func NewRequestID() string
NewRequestID generates a short hex token suitable as a request ID when no valid inbound X-Request-Id is present.
The generated ID is 16 hex chars (8 random bytes) — short enough to eyeball, wide enough to be effectively unique within a single deployment's log retention. A UUID is fine too; this is only a default.
func OnPremNon2xxDetail ¶
OnPremNon2xxDetail returns a stable, client-safe detail string for the case where Service.CallOnPremise / Service.CallOnPremiseMutating returned without error but the on-prem system replied with a non-2xx status. The handler is responsible for the 502 envelope status; this helper only supplies the `detail` text.
Format: "on-premise system returned HTTP <status>".
**Stable wire format** — clients may switch on it, tests will assert against it, alert rules might match on it. Changes (rewording, added reason phrase, etc.) require a CHANGELOG entry and a migration note.
Why no SAP response body or reason phrase: bodies can leak internal SAP detail (Short Dump trace IDs, ABAP field names) into the public envelope; reason phrases (`"Bad Request"`) are derivable client-side from the integer. Operators get the full body via slog server-side.
See ClassifyOnPremError for the parallel classifier on the err path.
Example ¶
package main
import (
"fmt"
"github.com/hochfrequenz/btpingo"
)
func main() {
fmt.Println(btpingo.OnPremNon2xxDetail(400))
fmt.Println(btpingo.OnPremNon2xxDetail(500))
}
Output: on-premise system returned HTTP 400 on-premise system returned HTTP 500
func RequestIDFromContext ¶
RequestIDFromContext returns the request ID stored on the request Context by a request-ID middleware (e.g. ginpingo.RequestID), or "" if no such middleware ran. Handlers that need the ID outside a Gin context (e.g. a background goroutine they kick off) can read it here.
It is what lets a huma (or other non-Gin) error path put `request_id` in its error envelope, where there is no Gin context to read.
func Retry ¶ added in v0.3.0
func Retry(ctx context.Context, p RetryPolicy, fn func(context.Context) (*http.Response, error)) (*http.Response, error)
Retry calls fn for a read-only operation up to MaxAttempts times, using Retryable to decide whether to repeat it. For mutating operations use RetryMutating instead: fn is opaque, so Retry cannot infer retry safety.
Each invocation must rebuild its request body; an io.Reader is read-once. The final response and error are returned unchanged, and the caller owns that response body. Discarded bodies are closed before waiting. If ctx is done before a retry would start, Retry closes the discarded response and returns nil, ctx.Err(); a final outcome is never replaced by ctx.Err(). fn must honor ctx so cancellation can interrupt an in-flight call.
HTTP 401/403 are not retried here; the service's cache/CSRF retries remain responsible for those cases.
func RetryMutating ¶ added in v0.3.0
func RetryMutating(ctx context.Context, p RetryPolicy, fn func(context.Context) (*http.Response, error)) (*http.Response, error)
RetryMutating is Retry for mutating operations, including calls to Service.CallOnPremiseMutating. Unless p.Idempotent is true, fn is called at most once regardless of MaxAttempts. An ambiguous transport failure may have applied the mutation already; only the caller can establish that repeating it is safe (for example, through an upstream idempotency key).
func Retryable ¶ added in v0.3.0
Retryable reports whether an attempt is worth repeating: a plain transport error classified by ClassifyOnPremError, or an HTTP 502 or 503 without an error. Timeouts (including net.Error timeouts), cancellation, missing destinations, oversized responses, and all other statuses are not retried. It neither reads nor closes resp.Body and does not inspect application codes.
func ScopesFromClaims ¶
ScopesFromClaims reads the "scope" claim in either XSUAA shape (an array of strings) or the OAuth 2 bare-string shape (whitespace-separated). Both are valid in the wild; normalising at the read site keeps a strict, exact scope check (e.g. ginpingo.RequireScope) from needing two near-identical code paths.
Exported for the gin adapters (ginpingo.JWT populates ContextWithScopes with this, and ginpingo.RequireScope reads the claims by the same rule) and for any other framework adapter that needs the same normalisation.
func ScopesFromContext ¶
ScopesFromContext returns the scopes of the validated token on this request, or nil when the request did not pass through github.com/hochfrequenz/btpingo/ginpingo.JWT.
Fail-closed by construction: an unauthenticated request, or a mis-wired router that skipped the validator, yields nil and therefore carries no scope. Callers must treat nil as "no scope", never as "check skipped".
func ValidRequestID ¶
ValidRequestID reports whether id is safe to trust as an inbound X-Request-Id: 1-64 characters, restricted to the charset a UUID or a hex token uses. A request-ID middleware (e.g. ginpingo.RequestID) should fall back to NewRequestID when this returns false, rather than echoing an arbitrary caller-supplied string into logs and error envelopes.
Types ¶
type AuthType ¶
type AuthType string
AuthType enumerates the values SAP BTP puts in a destination's Authentication property. A typed alias keeps the authenticator registry compile-safe — a typo in a constant fails to build instead of silently falling through to the reject-fallback at runtime.
const ( AuthNone AuthType = "NoAuthentication" AuthBasic AuthType = "BasicAuthentication" AuthOAuth2ClientCredentials AuthType = "OAuth2ClientCredentials" AuthOAuth2UserTokenExchange AuthType = "OAuth2UserTokenExchange" AuthOAuth2JWTBearer AuthType = "OAuth2JWTBearer" AuthOAuth2SAMLBearer AuthType = "OAuth2SAMLBearerAssertion" AuthPrincipalPropagation AuthType = "PrincipalPropagation" AuthSAMLAssertion AuthType = "SamlAssertion" AuthClientCertificate AuthType = "ClientCertificateAuthentication" )
type AuthenticatorRegistry ¶
type AuthenticatorRegistry struct {
// contains filtered or unexported fields
}
AuthenticatorRegistry dispatches Destination auth handling by type. The zero value is ready to use.
func DefaultAuthenticators ¶
func DefaultAuthenticators() *AuthenticatorRegistry
DefaultAuthenticators returns a registry seeded with the auth types this package ships. Callers register additional ones (Auth0, OAuth2ClientCredentials, PrincipalPropagation) alongside these at startup.
func (*AuthenticatorRegistry) Apply ¶
func (r *AuthenticatorRegistry) Apply(ctx context.Context, req *http.Request, dest *Destination) error
func (*AuthenticatorRegistry) Register ¶
func (r *AuthenticatorRegistry) Register(a DestinationAuthenticator)
func (*AuthenticatorRegistry) SetFallback ¶
func (r *AuthenticatorRegistry) SetFallback(a DestinationAuthenticator)
SetFallback registers a handler invoked when no registered AuthType matches. Useful to reject unknown auth types loudly rather than silently sending an unauthenticated request.
type BasicAuthenticator ¶
type BasicAuthenticator struct{}
func (BasicAuthenticator) Apply ¶
func (BasicAuthenticator) Apply(_ context.Context, req *http.Request, dest *Destination) error
func (BasicAuthenticator) AuthType ¶
func (BasicAuthenticator) AuthType() AuthType
type ConnCredentials ¶
type ConnCredentials struct {
ClientID string `json:"clientid"`
ClientSecret string `json:"clientsecret"`
URL string `json:"url"`
OnPremiseProxyHost string `json:"onpremise_proxy_host"`
OnPremiseProxyPort string `json:"onpremise_proxy_port"`
}
ConnCredentials is the Connectivity service binding.
func (*ConnCredentials) String ¶
func (c *ConnCredentials) String() string
func (*ConnCredentials) Validate ¶
func (c *ConnCredentials) Validate(errs *[]string)
type ConnTokenProvider ¶
ConnTokenProvider yields a fresh connectivity-service bearer token per request. Accepting a request here means cancellation on the caller's request propagates into the token fetch — without it, a slow XSUAA could hang the proxied call after the client already gave up.
type DestCredentials ¶
type DestCredentials struct {
URI string `json:"uri"`
ClientID string `json:"clientid"`
ClientSecret string `json:"clientsecret"`
// URL is the XSUAA token endpoint for this service instance; distinct
// from XSUAACredentials.URL because the destination service may be
// bound to a different UAA tenant.
URL string `json:"url"`
}
DestCredentials is the Destination service binding.
func (*DestCredentials) String ¶
func (d *DestCredentials) String() string
func (*DestCredentials) Validate ¶
func (d *DestCredentials) Validate(errs *[]string)
type Destination ¶
type Destination struct {
Name string `json:"Name"`
Type string `json:"Type"`
URL string `json:"URL"`
Authentication AuthType `json:"Authentication"`
ProxyType ProxyType `json:"ProxyType"`
User string `json:"User,omitempty"`
Password string `json:"Password,omitempty"`
CloudConnectorLocationID string `json:"CloudConnectorLocationId,omitempty"`
}
Destination is the subset of a Destination-service record this library reads. Additional Destination-service properties (sap-client, WebIDEUsage, etc.) are intentionally dropped: callers that need them can re-fetch the raw JSON or extend this struct.
func LookupDestination ¶
func LookupDestination(ctx context.Context, httpClient *http.Client, cred *DestCredentials, bearer, name string) (*Destination, error)
LookupDestination fetches a destination by name from the Destination service. It calls the generic /destinations/{name} endpoint, which searches instance- and subaccount-scope and is recommended by SAP over the scope-specific variants (`/instanceDestinations/`, `/subaccountDestinations/`).
func (*Destination) Format ¶
func (d *Destination) Format(s fmt.State, _ rune)
Format routes %v/%+v/%#v through String() so password scrubbing survives every formatter that callers might reach for in a log line.
func (*Destination) IsOnPremise ¶
func (d *Destination) IsOnPremise() bool
IsOnPremise reports whether the destination targets a system reachable only via the Connectivity service's reverse proxy.
func (*Destination) String ¶
func (d *Destination) String() string
String masks Password so accidental %v / %+v logging cannot leak it. Mirrors the precedent set by *Credentials in env.go.
type DestinationAuthenticator ¶
type DestinationAuthenticator interface {
// AuthType is the Destination.Authentication value this authenticator
// handles.
AuthType() AuthType
// Apply mutates req so the on-premise / target system accepts it. It
// must NOT set Proxy-Authorization — that is the Connectivity service's
// concern and is handled by the on-premise transport.
Apply(ctx context.Context, req *http.Request, dest *Destination) error
}
DestinationAuthenticator attaches credentials to an outbound request, using the configuration of a resolved Destination. Implementations are per-authentication-type (AuthBasic, AuthOAuth2ClientCredentials, …). This is the extension point for adding Auth0, SSO-on-top-of-Basic, principal propagation, etc. without touching the call site.
type DestinationCache ¶
type DestinationCache struct {
// contains filtered or unexported fields
}
DestinationCache wraps LookupDestination with the same caching shape TokenFetcher already gives the token fetch it runs beside on every on-premise call. Without it, the destination lookup would be the one leg of that three-leg sequence — token, destination, proxied call — with no cache at all, so every call would pay a full Destination-service round trip, and a burst of concurrent requests during a cold cache would become a burst of concurrent Destination-service calls. One instance is safe for concurrent use.
func NewDestinationCache ¶
func NewDestinationCache(httpClient *http.Client, ttl time.Duration) *DestinationCache
NewDestinationCache returns a cache that looks up destinations via httpClient (nil selects a 10s-timeout default, matching LookupDestination's own fallback) and reuses each one for ttl (zero or negative selects DefaultDestinationCacheTTL).
func (*DestinationCache) Invalidate ¶
func (c *DestinationCache) Invalidate(cred *DestCredentials, name string)
Invalidate drops the cached destination for (cred.URI, cred.URL, cred.ClientID, name). Call this if a destination lookup's result turns out to be stale in a way the TTL alone would not catch quickly enough — mirrors TokenFetcher.Invalidate's reason for existing. Service.CallOnPremise calls it on a 401 so a rotated destination credential (e.g. a BasicAuthentication password) is looked up fresh on the retry instead of serving the stale entry for up to the cache's TTL.
func (*DestinationCache) Lookup ¶
func (c *DestinationCache) Lookup(ctx context.Context, cred *DestCredentials, bearer, name string) (*Destination, error)
Lookup returns a cached destination if one is still fresh, else performs a fresh LookupDestination call. The cache key is (cred.URI, cred.URL, cred.ClientID, name) — deliberately not bearer, because the destination's own content does not depend on which valid token was used to ask for it, and keying on a token that itself rotates would defeat the cache on every token refresh.
Keying by binding identity (URI, URL and ClientID) rather than URI alone keeps this safe when one *DestinationCache is shared across multiple *DestCredentials on a regional Destination-service endpoint that hands out the same URI to every caller: ClientID separates bindings, and URL separates subscriber tenants of one binding — they share a ClientID but get a tenant-subdomain token URL of their own, the same distinction TokenFetcher keys on.
Concurrent misses for the same key collapse into one upstream call via singleflight, for the same reason TokenFetcher.Fetch does: otherwise a burst of incoming requests hitting a cold cache would each dial the Destination service instead of one dialing it and the rest waiting on that one result.
type Env ¶
type Env struct {
XSUAA *XSUAACredentials
Dest *DestCredentials
Conn *ConnCredentials
}
Env is the app's view of its Cloud Foundry bindings.
type ErrorCode ¶
type ErrorCode string
ErrorCode is the typed code field on the API error envelope. The canonical set below covers the errors this package's own middleware and handlers produce; callers may declare their own codes as needed, but should keep the shape — clients are expected to switch on `code`, not on the human-readable `message`.
const ( CodeInvalidRequest ErrorCode = "invalid_request" CodeForbidden ErrorCode = "forbidden" CodeNotFound ErrorCode = "not_found" CodeMethodNotAllowed ErrorCode = "method_not_allowed" CodeRequestTooLarge ErrorCode = "request_too_large" CodeUpstreamUnreachable ErrorCode = "upstream_unreachable" CodeInternal ErrorCode = "internal" )
type ErrorDetail ¶
type ErrorDetail struct {
Code ErrorCode `` /* 126-byte string literal not displayed */
Message string `` /* 182-byte string literal not displayed */
RequestID string `` /* 171-byte string literal not displayed */
}
ErrorDetail is the structured payload the client actually reads. RequestID is populated by github.com/hochfrequenz/btpingo/ginpingo.AbortError from the request ID stashed by github.com/hochfrequenz/btpingo/ginpingo.RequestID when that middleware is installed; omitted otherwise so the envelope stays compact.
The `doc`/`example` struct tags are read by huma when it generates the published OpenAPI document's error schema — a huma API that marshals this very struct gets a documented error shape and an emitted one that are one definition rather than two that can drift. They are inert for the gin surface.
type ErrorEnvelope ¶
type ErrorEnvelope struct {
Error ErrorDetail `json:"error"`
}
ErrorEnvelope is the shape every error response takes. Keeping all errors under a fixed key means clients can disambiguate success vs. failure by the presence of `error` without inspecting the HTTP status alone, and it leaves room to grow the envelope with metadata later.
type ForwardedUserTokenKey ¶
type ForwardedUserTokenKey struct{}
ForwardedUserTokenKey is the request-context key under which the JWT middleware stashes the raw bearer token from the approuter. PrincipalPropagation authenticators read it to populate SAP-Connectivity-Authentication; regular authenticators ignore it.
type JWTValidator ¶
type JWTValidator struct {
// contains filtered or unexported fields
}
JWTValidator validates tokens minted by this app's XSUAA tenant. Construct once at startup; safe for concurrent use. The underlying keyfunc keeps the JWKS fresh on a background refresh loop.
func NewJWTValidator ¶
func NewJWTValidator(ctx context.Context, xsuaa *XSUAACredentials) (*JWTValidator, error)
NewJWTValidator fetches the JWKS at xsuaa.JWKSURL() and returns a ready validator. It verifies: RS256 signature (keys pinned to xsuaa.JWKSURL()), audience = xsuaa.ClientID, standard exp/nbf/iat with TokenRefreshLeeway.
The issuer claim is intentionally not checked. XSUAA emits an internal "http://<zone>.localhost:8080/uaa/oauth/token" iss that cannot be derived from VCAP_SERVICES without hardcoding a SAP implementation detail. Security is preserved by deriving the JWKS URL from this app's own XSUAA binding (xsuaa.URL + "/token_keys"), so the keyset only ever contains our tenant's signing keys; a token minted by a different tenant fails signature verification. Callers must keep that invariant — do not let the JWKS URL come from anywhere but the bound XSUAACredentials, or the iss-drop argument no longer holds.
type NoAuthenticator ¶
type NoAuthenticator struct{}
func (NoAuthenticator) Apply ¶
func (NoAuthenticator) Apply(context.Context, *http.Request, *Destination) error
func (NoAuthenticator) AuthType ¶
func (NoAuthenticator) AuthType() AuthType
type OnPremCaller ¶
type OnPremCaller interface {
CallOnPremise(ctx context.Context, destName, method, pathSuffix string,
headers http.Header, body io.Reader) (*http.Response, error)
}
OnPremCaller is the single-method contract Gin handlers should depend on for read-only on-premise calls. *Service satisfies it in production; unit tests substitute a fake with one method to exercise handler logic without standing up the XSUAA / Destination / Cloud Connector stack.
Signature mirrors Service.CallOnPremise exactly, so the interface is a no-op refactor for existing callers: swap the handler's parameter type from *Service to OnPremCaller and every call site keeps compiling.
type OnPremFailureKind ¶
type OnPremFailureKind string
OnPremFailureKind is the typed classifier on the result of an on-prem call returned by Service.CallOnPremise / Service.CallOnPremiseMutating.
The kind values below are the **stable wire format** — clients may switch on them, tests will assert against them, alert rules might match on them. Adding a kind is non-breaking; renaming or removing a kind requires a CHANGELOG entry and a migration note.
const ( OnPremFailureDestinationNotFound OnPremFailureKind = "destination_not_found" OnPremFailureResponseTooLarge OnPremFailureKind = "response_too_large" OnPremFailureTimeout OnPremFailureKind = "timeout" OnPremFailureCanceled OnPremFailureKind = "canceled" OnPremFailureTransport OnPremFailureKind = "transport_error" )
Classifier kinds returned by ClassifyOnPremError. Stable wire format — see OnPremFailureKind docstring.
func ClassifyOnPremError ¶
func ClassifyOnPremError(err error) (OnPremFailureKind, string)
ClassifyOnPremError maps an error from Service.CallOnPremise or Service.CallOnPremiseMutating to a typed kind plus a stable, client-safe detail string suitable for the huma 502 envelope's `detail` field. Returns ("", "") when err is nil — callers should guard the err themselves; this is a classifier, not a status check.
Recognised branches (in priority order):
- errors.Is(err, ErrDestinationNotFound) → (OnPremFailureDestinationNotFound, "destination not found")
- errors.Is(err, ErrOnPremResponseTooLarge) → (OnPremFailureResponseTooLarge, "on-premise response exceeded configured size limit")
- errors.Is(err, context.DeadlineExceeded) → (OnPremFailureTimeout, "on-premise call timed out")
- errors.Is(err, context.Canceled) → (OnPremFailureCanceled, "on-premise call canceled")
- default → (OnPremFailureTransport, "on-premise transport error")
The default branch is intentionally coarse: the on-prem stack today does not tag connectivity-proxy failures, token-fetch failures, TLS handshake errors, or DNS failures with sentinel errors, so a finer split would be guessing. Tightening the default is follow-up work that requires adding sentinels in this package first.
The wire format (kind values + detail strings) is part of the public API surface; see OnPremFailureKind.
Example ¶
package main
import (
"context"
"fmt"
"github.com/hochfrequenz/btpingo"
)
func main() {
kind, detail := btpingo.ClassifyOnPremError(context.DeadlineExceeded)
fmt.Println(kind, "/", detail)
}
Output: timeout / on-premise call timed out
type OnPremMutator ¶
type OnPremMutator interface {
CallOnPremiseMutating(ctx context.Context, destName, method, pathSuffix string,
headers http.Header, body io.Reader) (*http.Response, error)
}
OnPremMutator is the mutating-call contract handlers should depend on for writes (POST / PUT / DELETE / PATCH against SAP endpoints that enforce CSRF). Implementations perform the X-CSRF-Token handshake transparently — callers just invoke CallOnPremiseMutating and get a response that already went through fetch-token → attach-token-and-cookies → retry-once-on-403.
Handlers that mix reads and writes declare a composite interface at the usage site:
type InvoiceClient interface {
btpingo.OnPremCaller
btpingo.OnPremMutator
}
*Service satisfies both so wiring stays unchanged.
type ProxyType ¶
type ProxyType string
ProxyType enumerates SAP BTP destination proxy modes. Only OnPremise traffic must route through the Connectivity service's reverse proxy; for Internet and PrivateLink the transport is a plain outbound call. The Service uses this to decide whether the Connectivity binding must be present at all.
type RetryPolicy ¶ added in v0.3.0
type RetryPolicy struct {
// MaxAttempts includes the initial call. Values <= 0 default to 2.
MaxAttempts int
// Backoff defaults to 200ms when <= 0. Each wait is independently
// jittered between half of Backoff and Backoff, inclusive.
Backoff time.Duration
// Idempotent explicitly permits RetryMutating to repeat a call.
// Rebuilding a request body alone does not make an operation idempotent.
Idempotent bool
// Clock supplies backoff waits; nil uses time.After.
Clock interface {
After(time.Duration) <-chan time.Time
}
}
RetryPolicy controls Retry and RetryMutating.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service orchestrates a call to an on-premise SAP system behind the Cloud Connector: destination lookup → connectivity token → proxied call with the right auth headers. Safe for concurrent use.
Service satisfies the OnPremCaller interface — handlers should accept that interface rather than *Service so unit tests can substitute a fake.
func NewService ¶
func NewService(env *Env, opts ...ServiceOption) (*Service, error)
NewService wires a Service using the defaults listed in DefaultMgmtTimeout, DefaultOnPremiseTimeout, and DefaultUserAgent, plus DefaultAuthenticators() and a TokenFetcher with its own http.Client. Pass ServiceOptions to tune timeouts or the outbound User-Agent.
func (*Service) Authenticators ¶
func (s *Service) Authenticators() *AuthenticatorRegistry
Authenticators exposes the registry so callers can plug in Auth0 / SSO / PrincipalPropagation handlers at startup.
func (*Service) CallOnPremise ¶
func (s *Service) CallOnPremise(ctx context.Context, destName, method, pathSuffix string, headers http.Header, body io.Reader) (*http.Response, error)
CallOnPremise runs the three-leg sequence for destination `destName` and forwards `method path` (path is appended to the destination's URL). The destination token fetch and the destination lookup itself are both cached (DefaultDestinationCacheTTL for the latter), so a call does not pay a full Destination-service round trip every time. On EVERY 401 the connectivity token is invalidated and the cached destination is dropped — bearer tokens can expire between cache check and on-prem receipt, and a destination's own credentials (e.g. a BasicAuthentication password) can be rotated in the cockpit, so neither stale value should keep being served. The call itself is retried only when body is nil: a body-carrying call's io.Reader is already consumed by the failed attempt and cannot be safely re-sent, so it returns the 401 as-is: but because the caches were already dropped, the next call on this destination looks both up fresh and heals itself. 403 is NOT retried and does NOT invalidate either cache: it means "authenticated but not authorized", which a fresh token cannot fix and re-trying would mask real auth-policy bugs. The returned response body must be closed by the caller.
The returned resp.Body is capped at DefaultOnPremResponseSizeLimit (override via WithOnPremResponseSizeLimit). A read past the cap returns ErrOnPremResponseTooLarge; an io.ReadAll caller sees that error from the call and should surface it as 502 (CodeUpstreamUnreachable). The cap protects against an SAP/CC misroute streaming gigabytes into the CF memory quota.
func (*Service) CallOnPremiseMutating ¶
func (s *Service) CallOnPremiseMutating(ctx context.Context, destName, method, pathSuffix string, headers http.Header, body io.Reader) (*http.Response, error)
CallOnPremiseMutating is the write counterpart of CallOnPremise. SAP ICF endpoints that accept POST / PUT / DELETE / PATCH typically require a CSRF token in `X-CSRF-Token`, together with the SAP session cookies set by the server when the token was first minted. This method runs that handshake for you:
- Look up the token + cookies for `destName` in the Service's CSRF cache; on a miss, GET the configured CSRF fetch path (WithCSRFFetchPath, default DefaultCSRFFetchPath) with `X-CSRF-Token: Fetch` and cache what comes back.
- Attach the cached token and cookies to the mutating call and send it.
- On a 403 carrying `X-CSRF-Token: Required` (SAP's signal that the server-side session was recycled), invalidate the cache, re-fetch once, and retry the mutating call a single time.
The request body is buffered up-front so the retry can re-read it. If your body is too large to buffer, write your own handshake on top of CallOnPremise rather than using this helper.
Returns the final response (body must be closed by the caller) or the underlying error. A 403 that is NOT a CSRF-required signal (i.e. a real authorization failure) surfaces to the caller as-is, no retry.
As with CallOnPremise, resp.Body is capped at DefaultOnPremResponseSizeLimit (override via WithOnPremResponseSizeLimit); a read past the cap returns ErrOnPremResponseTooLarge.
type ServiceOption ¶
type ServiceOption func(*serviceOptions)
ServiceOption configures NewService. Use WithUserAgent, WithMgmtTimeout, and WithOnPremiseTimeout to tune the defaults; zero options keeps the built-in values.
func WithCSRFFetchPath ¶
func WithCSRFFetchPath(path string) ServiceOption
WithCSRFFetchPath overrides the SAP path CallOnPremiseMutating calls to pick up a fresh X-CSRF-Token. Empty string resets to DefaultCSRFFetchPath. Change this if your destination doesn't host /sap/bc/adt/* — pass the path of any GET-capable SAP endpoint that responds to `X-CSRF-Token: Fetch` with a token and a session cookie.
func WithDestinationCacheTTL ¶
func WithDestinationCacheTTL(d time.Duration) ServiceOption
WithDestinationCacheTTL overrides how long a looked-up destination is reused before CallOnPremise asks the Destination service again. Zero or negative resets to DefaultDestinationCacheTTL.
func WithMgmtTimeout ¶
func WithMgmtTimeout(d time.Duration) ServiceOption
WithMgmtTimeout overrides the management-call timeout (XSUAA, Destination service lookup). Zero resets to DefaultMgmtTimeout.
func WithOnPremResponseSizeLimit ¶
func WithOnPremResponseSizeLimit(bytes int64) ServiceOption
WithOnPremResponseSizeLimit caps the per-call on-prem response body. Reads that would push a single response past `bytes` return ErrOnPremResponseTooLarge from the wrapped resp.Body. Zero or negative resets to DefaultOnPremResponseSizeLimit — a negative limit has no sensible meaning as a cap, and limitedOnPremBody's slice math assumes a non-negative one. Pass a larger value if a single route legitimately returns more.
func WithOnPremiseTimeout ¶
func WithOnPremiseTimeout(d time.Duration) ServiceOption
WithOnPremiseTimeout overrides the per-call timeout for on-prem requests. Zero resets to DefaultOnPremiseTimeout.
func WithUserAgent ¶
func WithUserAgent(ua string) ServiceOption
WithUserAgent sets the User-Agent header for outbound on-prem requests. An empty string resets to DefaultUserAgent.
type TokenFetcher ¶
type TokenFetcher struct {
// contains filtered or unexported fields
}
TokenFetcher obtains and caches XSUAA client-credentials tokens. One instance is safe for concurrent use and serves multiple bindings (keyed on tokenURL + clientID). Concurrent misses collapse into a single upstream exchange via singleflight — otherwise a burst of incoming requests during a token TTL gap would hammer XSUAA and risk rate-limits.
func NewTokenFetcher ¶
func NewTokenFetcher(httpClient *http.Client) *TokenFetcher
NewTokenFetcher returns a fetcher. If httpClient is nil a sensible default with a 10s timeout is used.
func (*TokenFetcher) Fetch ¶
func (f *TokenFetcher) Fetch(ctx context.Context, tokenBaseURL, clientID, clientSecret string) (string, error)
Fetch returns a cached token if one is still valid (minus leeway), else performs a fresh client-credentials exchange. tokenBaseURL is the `url` field of the service binding; /oauth/token is appended.
func (*TokenFetcher) Invalidate ¶
func (f *TokenFetcher) Invalidate(tokenBaseURL, clientID string)
Invalidate drops the cached token for (tokenBaseURL, clientID). Call this when a downstream call rejected the token (401/403) so the next Fetch obtains a fresh one rather than re-handing the bad one back.
type XSUAACredentials ¶
type XSUAACredentials struct {
URL string `json:"url"`
ClientID string `json:"clientid"`
ClientSecret string `json:"clientsecret"`
XSAppName string `json:"xsappname"`
UAADomain string `json:"uaadomain"`
VerificationKey string `json:"verificationkey"`
Identityzone string `json:"identityzone"`
}
XSUAACredentials is the subset of the XSUAA binding this package reads. The full payload has ~20 additional fields; decoding only what we use keeps us resilient to unrelated changes in the service.
func (*XSUAACredentials) Format ¶
func (x *XSUAACredentials) Format(s fmt.State, _ rune)
Format routes %v/%+v/%#v through String() so secret scrubbing survives every formatter that callers might reach for in a log line.
func (*XSUAACredentials) JWKSURL ¶
func (x *XSUAACredentials) JWKSURL() string
JWKSURL is where XSUAA publishes its signing keys.
func (*XSUAACredentials) String ¶
func (x *XSUAACredentials) String() string
String masks secrets so accidental %v / %+v logging cannot leak them.
func (*XSUAACredentials) Validate ¶
func (x *XSUAACredentials) Validate(errs *[]string)
Validate appends all structural problems of x to errs with an "xsuaa: " prefix. Aggregation instead of early return is deliberate — we want every misconfiguration in one pass so operators fix them all at once.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package ginpingo provides Gin adapters for btpingo: middleware and handlers that wrap the framework-neutral core in github.com/hochfrequenz/btpingo with Gin's *gin.Context.
|
Package ginpingo provides Gin adapters for btpingo: middleware and handlers that wrap the framework-neutral core in github.com/hochfrequenz/btpingo with Gin's *gin.Context. |
|
Package grpcpingo is the gRPC / Connect counterpart of ginpingo: the JWT and scope check, the request-size limit and the error mapping of a btpingo service, for handlers built with connect-go (connectrpc.com/connect).
|
Package grpcpingo is the gRPC / Connect counterpart of ginpingo: the JWT and scope check, the request-size limit and the error mapping of a btpingo service, for handlers built with connect-go (connectrpc.com/connect). |
|
internal
|
|
|
fwdheader
Package fwdheader holds the header-forwarding filter shared by btpingo's core on-prem call path and ginpingo's ProxyHandler.
|
Package fwdheader holds the header-forwarding filter shared by btpingo's core on-prem call path and ginpingo's ProxyHandler. |
|
testkit
Package testkit holds test fixtures shared by btpingo's own tests and ginpingo's tests.
|
Package testkit holds test fixtures shared by btpingo's own tests and ginpingo's tests. |