Help us shape the future of Go! Take the 2026 Go Developer Survey

btpingo

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: MIT Imports: 26 Imported by: 0

README

BTPinGo 🐧

Unittests coverage golangci-lint

A Go client for apps on SAP BTP Cloud Foundry that call an on-premise SAP system. It covers:

  • XSUAA: it validates the JWTs your app receives.
  • Destination service: it looks up the destination by name.
  • Connectivity service and Cloud Connector: it fetches the Destination-service and Connectivity tokens a call needs and routes the call through the Connectivity proxy. It handles the CSRF handshake for mutating calls and the destination's own authentication.

SAP's Cloud SDK covers this for Java and JavaScript only. Without it, every Go app on BTP rebuilds it.

It ships as three packages:

  • btpingo (this module's root) — the framework-neutral core above. It does not import Gin, so a huma (or other non-Gin) service can depend on it alone.
  • btpingo/ginpingo — Gin middleware and handlers built on the core: JWT validation, request ID, MaxBodySize, BindStrictJSON, scope enforcement, the error writer, and a generic proxy handler. Import this only from a Gin-based service.
  • btpingo/grpcpingo — the same for gRPC: net/http middleware for connect-go handlers (JWT and scope check, request-size limit, error mapping with ErrorInfo). It serves gRPC, gRPC-Web and Connect. Import this only from a service with connect-go handlers.
go get github.com/hochfrequenz/btpingo

Quick start

ctx := context.Background()
env, err := btpingo.LoadEnv() // VCAP_SERVICES: xsuaa, destination, connectivity
if err != nil {
	log.Fatal(err)
}
svc, err := btpingo.NewService(env, btpingo.WithUserAgent("my-service/1.0"))
if err != nil {
	log.Fatal(err)
}
validator, err := btpingo.NewJWTValidator(ctx, env.XSUAA)
if err != nil {
	log.Fatal(err)
}

r := gin.New()
r.Use(ginpingo.RequestID())
api := r.Group("/api", ginpingo.JWT(validator))
api.GET("/ping", func(c *gin.Context) {
	resp, err := svc.CallOnPremise(c.Request.Context(), "MY_SAP_DESTINATION",
		http.MethodGet, "/sap/bc/ping?sap-client=100", nil, nil)
	if err != nil {
		ginpingo.AbortError(c, http.StatusBadGateway, btpingo.CodeUpstreamUnreachable,
			"on-premise system unreachable", err)
		return
	}
	defer func() { _ = resp.Body.Close() }()
	c.Status(resp.StatusCode)
})

Handlers should depend on the OnPremCaller (reads) or OnPremMutator (writes with CSRF) interface, not on *Service. Then a test can pass a one-method fake.

The full API surface is listed in the package documentation: btpingo, btpingo/ginpingo, btpingo/grpcpingo.

ginpingo

BindStrictJSON(c, &dst, opts...) decodes exactly one JSON value, allowing only whitespace after it. It returns false after calling AbortError on failure, so handlers should return immediately:

var input struct {
	Name string `json:"name"`
}
if !ginpingo.BindStrictJSON(c, &input,
	ginpingo.WithMaxBytes(64<<10),
	ginpingo.WithDisallowUnknownFields(),
) {
	return
}

The default limit is DefaultMaxBodyBytes (1 MiB); WithMaxBytes sets a per-call limit without overriding a smaller MaxBodySize middleware limit. Overflow yields 413 CodeRequestTooLarge. Empty, malformed, type-mismatched, or trailing JSON yields 400 CodeInvalidRequest; WithDisallowUnknownFields also rejects unknown struct fields. Client messages are stable and generic; underlying errors are logged server-side via AbortError. Binding-tag validation is not performed.

grpcpingo (gRPC / Connect)

grpcpingo guards connect-go handlers, which serve gRPC, gRPC-Web and Connect from one http.Handler. Its middleware have the plain func(http.Handler) http.Handler shape, so they wrap a handler directly or sit in a gin route through gin.WrapH. A rejected call is answered in the protocol of the request, and on a streaming call the status arrives on the stream. The package imports connect-go and connectrpc.com/authn; the root package and ginpingo do not.

m := grpcpingo.Mapper{Domain: "myservice.example.com"} // ErrorInfo domain, required
path, h := testv1connect.NewTestServiceHandler(impl,
	connect.WithRecover(m.Recover),  // a panic becomes Internal with ErrorInfo
	connect.WithReadMaxBytes(1<<20), // limit for bodies without a Content-Length
)
guard := func(next http.Handler) http.Handler {
	return grpcpingo.Auth(validator, "myapp!t1.User", m)(grpcpingo.MaxBodySize(1<<20, m)(next))
}
r.POST(path+"*rpc", gin.WrapH(guard(h)))

srv := &http.Server{Handler: btpingo.CompressHandlerSkipping(r, path)}
  • Auth(validator, scope, m) is ginpingo.JWT plus RequireScope for connect: Authorization: Bearer is required, the token is validated, scope must be in it. The context carries the forwarded token and the scopes as with ginpingo.JWT, and WithContextFromClaims adds more values from the claims. A missing or invalid token is Unauthenticated (reason unauthorized), a missing scope PermissionDenied (forbidden). Only an invalid token is logged, once at ERROR, as with AbortError. An empty scope panics at construction.
  • MaxBodySize(limit, m) answers a Content-Length over limit with ResourceExhausted (request_too_large) and caps every body at limit bytes. Put Auth before it. A body without a Content-Length is caught by connect.WithReadMaxBytes, which gives the same code but no ErrorInfo, so set both. Auth and MaxBodySize panic at construction when Mapper.Domain is empty.
  • Mapper maps a btpingo.ErrorCode to a gRPC code and exactly one google.rpc.ErrorInfo whose reason is the code and whose domain is Mapper.Domain. DefaultCodes() holds the mapping of the canonical codes; pass a changed copy as Mapper.Codes to add your own codes. An unknown code becomes Internal. m.Recover is the connect.WithRecover handler: it logs the panic and stack once and returns Internal with ErrorInfo.
  • CompressHandlerSkipping(h, prefixes...) is CompressHandler except that paths under the prefixes go to h directly. A prefix should end with /, as connect-go's generated path does; an empty prefix panics. Use it for the RPC path: gzhttp would add an HTTP Content-Encoding to application/grpc responses, and gRPC clients reject that. gRPC and Connect compress messages on their own.

The example routes POST only. A service that enables Connect GET (connect.WithIdempotency plus connect.WithHTTPGet) must register GET for its path as well, and MaxBodySize does not limit the message a GET carries in its query string.

An access log in front of a gRPC service shows HTTP 200 for a failed call: the gRPC status travels in the trailers. Log the gRPC code if you want it in the access log.

Response compression

srv := &http.Server{Handler: btpingo.CompressHandler(router)}

CompressHandler wraps any http.Handler with response compression. A response is compressed with zstd or gzip only when the request's Accept-Encoding accepts one of them by q-value (zstd preferred on a tie); a request with no Accept-Encoding, one that excludes both, or a body under the minimum size is sent uncompressed. If a handler writes fewer or more bytes than its declared Content-Length (e.g. a proxied on-prem response cut off mid-copy), it aborts the connection instead of sending a cleanly compressed short or long body.

Safety defaults

  • Host pin: an on-premise request always goes to the destination's own scheme and host. A path suffix, an authenticator or a redirect cannot steer it elsewhere. Redirects are followed only while they stay on that origin.
  • Response size cap: on-premise response bodies are capped at 10 MiB by default (WithOnPremResponseSizeLimit).
  • Caching: tokens and destination lookups are cached, and concurrent cache misses share a single upstream call.
  • Request IDs: an inbound X-Request-Id is accepted only if it matches ^[A-Za-z0-9._-]{1,64}$. Otherwise a fresh one is generated.

Origin and stability

This code started as internal/btp in go-sap-btp-cf-template. It is being extracted so that fixes reach every service as a dependency bump instead of being copied between forks.

Until v1.0.0 the API may change between minor versions. A breaking change is always named in the release notes. The gin-specific helpers (JWT, RequestID, RequireScope, MaxBodySize, AbortError, ProxyHandler) have moved into the ginpingo subpackage, so that the core can be used without gin.

License

MIT, see LICENSE.

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

## JWT validation & request-scoped context

## Error envelope

## Environment / VCAP bindings

## Authenticator registry

## Tokens

## Sentinel errors

## 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

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

Examples

Constants

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

func CompressHandler(h http.Handler) http.Handler

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

func CompressHandlerSkipping(h http.Handler, pathPrefixes ...string) http.Handler

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

func ContextWithRequestID(ctx context.Context, id string) context.Context

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

func ContextWithScopes(ctx context.Context, scopes []string) context.Context

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

func HasScope(ctx context.Context, scope string) bool

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

func OnPremNon2xxDetail(status int) string

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

func RequestIDFromContext(ctx context.Context) string

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

func Retryable(resp *http.Response, err error) bool

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

func ScopesFromClaims(claims jwt.MapClaims) []string

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

func ScopesFromContext(ctx context.Context) []string

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

func ValidRequestID(id string) bool

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 (*AuthenticatorRegistry) SetFallback

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

func (c *ConnCredentials) Format(s fmt.State, _ rune)

func (*ConnCredentials) String

func (c *ConnCredentials) String() string

func (*ConnCredentials) Validate

func (c *ConnCredentials) Validate(errs *[]string)

type ConnTokenProvider

type ConnTokenProvider func(ctx *http.Request) (string, error)

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

func (d *DestCredentials) Format(s fmt.State, _ rune)

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.

func LoadEnv

func LoadEnv() (*Env, error)

LoadEnv parses VCAP_SERVICES / VCAP_APPLICATION into typed credentials and runs full validation. Returns ErrNotInCloudFoundry if the app is not running under CF; callers should treat that as fatal at startup.

func (*Env) Validate

func (e *Env) Validate() error

Validate aggregates problems across all bindings and returns them as a single error with one bullet per problem, so operators see every misconfiguration in one startup log line instead of discovering them one request at a time.

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"
	CodeUnauthorized        ErrorCode = "unauthorized"
	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.

func (*JWTValidator) Parse

func (v *JWTValidator) Parse(raw string) (jwt.MapClaims, error)

type NoAuthenticator

type NoAuthenticator struct{}

func (NoAuthenticator) Apply

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

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.

const (
	ProxyOnPremise   ProxyType = "OnPremise"
	ProxyInternet    ProxyType = "Internet"
	ProxyPrivateLink ProxyType = "PrivateLink"
)

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:

  1. 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.
  2. Attach the cached token and cookies to the mutating call and send it.
  3. 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.

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.

Jump to

Keyboard shortcuts

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