Documentation
¶
Overview ¶
Package sts is an RFC 8693 OAuth 2.0 Token Exchange client.
Construct a Client with the issuer's BaseURL and the token endpoint path, then call Exchange with a populated ExchangeRequest. The package is provider-agnostic: every server-specific value (endpoint path, requested-token-type URIs, custom form fields) is supplied at call time. There is no provider detection.
Index ¶
Constants ¶
const ( GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // RFC 8693 grant_type URI, not a credential SubjectTokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint:gosec // RFC 8693 token-type URI, not a credential SubjectTokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint:gosec // RFC 8693 token-type URI, not a credential )
RFC 8693 grant_type and standard subject-token type URIs. Caller supplies RequestedTokenType (which is always implementation-specific outside of these RFC 8693 standard values).
const DefaultRequestTimeout = 30 * time.Second
DefaultRequestTimeout caps a single token-exchange round-trip. Set conservatively: even with a slow auth host plus TLS handshake, a healthy exchange completes in sub-seconds. The cap mainly defends against slow-loris responses dripping bytes within MaxResponseBytes — see Client.RequestTimeout for the per-Client override.
Variables ¶
var ErrAbsolutePath = oauthhttp.ErrAbsolutePath
ErrAbsolutePath is returned when Path is an absolute or scheme-relative URL rather than a path relative to BaseURL. See oauthhttp.ErrAbsolutePath for the rationale; re-exported here so callers can errors.Is on either package's sentinel.
var ErrInsecureBaseURL = oauthhttp.ErrInsecureBaseURL
ErrInsecureBaseURL is returned when Exchange is called against an http:// BaseURL without AllowInsecureHTTP set. Token exchange ships a subject_token (typically the user's core bearer) in the request body — over plain HTTP that's a credential in the clear. Re-exported from internal/oauthhttp so callers can errors.Is uniformly across deviceflow and sts.
Functions ¶
func SetNowForTest ¶ added in v0.3.1
SetNowForTest replaces c.now()'s clock for the lifetime of the test. The previous override (if any) is restored when t.Cleanup runs. Stores go through atomic.Pointer so they don't race the unsynchronised hot-path reads in Exchange.
Replaces the previous package-global `nowFunc` shim, which `t.Parallel`-running tests could race against each other on. With a per-Client field, two parallel tests each freeze their own Client's clock without interference.
Types ¶
type Client ¶
type Client struct {
// Transport supplies the http.RoundTripper used for all calls.
// nil → http.DefaultTransport. The library builds its own
// *http.Client around this transport so callers can't trivially
// pass a *http.Client with TLS verification disabled. See the
// deviceflow.Client.Transport doc for the security rationale.
Transport http.RoundTripper
BaseURL string
Path string
UserAgent string
// RequestTimeout is the per-Exchange deadline applied via
// context.WithTimeout on top of the caller's context. Zero falls
// back to DefaultRequestTimeout. Negative disables the cap (useful
// for tests that want to drive timing via the caller's ctx alone).
RequestTimeout time.Duration
// AllowInsecureHTTP permits http:// BaseURLs. Default (false) is
// reject — token exchanges carry the subject token (a bearer) on
// the wire and must be TLS-protected end to end. Production callers
// MUST leave this false; only tests and local development that pin
// the issuer to loopback should flip it.
AllowInsecureHTTP bool
// contains filtered or unexported fields
}
Client exchanges subject tokens for tokens of a different type at an RFC 8693 token endpoint.
All configuration is explicit; the package has no global state and no implicit URLs. Provide BaseURL and Path; the rest is RFC 8693.
func New ¶ added in v0.3.1
New validates a Client's required fields at construction time rather than at the first Exchange call. Returns an error if BaseURL or Path is empty — these would otherwise surface as a confusing "POST to :///token: ..." error from the caller at the worst moment.
Takes a *Client (rather than a Client value) because the struct embeds an atomic.Pointer for the test-clock seam, which can't be copied per the noCopy convention. Returns the same pointer on success.
Field-bag construction (`&sts.Client{...}`) is still supported for callers who want to set optional fields piecemeal, but `New` is the recommended path — it makes misconfiguration a startup error rather than a runtime one.
func (*Client) Exchange ¶
Exchange performs one RFC 8693 token exchange.
Returns a TokenSet with absolute ExpiresAt derived from the server's expires_in. Returns an error wrapping the response body when the server responds with a non-2xx status; callers can match on the returned error message for known OAuth error codes.
type ExchangeRequest ¶
type ExchangeRequest struct {
SubjectToken string
SubjectTokenType string
RequestedTokenType string
Audience string
Resource string
Scope string
// ClientID, when non-empty, is sent as the username component of an
// HTTP Basic Authorization header (RFC 6749 §2.3.1). ClientSecret
// is the password component; leave empty for public clients
// (Exchange will emit Basic base64("id:"), which §2.3.1 permits).
//
// Why both surfaces. Some authorization servers accept client
// credentials for the token-exchange grant only via Basic Auth and
// ignore form-body client_id — zitadel-based servers as of
// 2026-05 are a known example (pkg/op/token_exchange.go reads only
// from r.BasicAuth()). Callers are also free to set
// Extra["client_id"] for servers that read from the form body;
// both surfaces can be populated simultaneously and validate()
// rejects a divergence between them.
//
// Wire encoding. Both values are url.QueryEscape'd before being
// placed in the header — RFC 6749 §2.3.1 mandates that client
// credentials are form-urlencoded before going into Basic Auth, so
// spec-compliant servers decode via QueryUnescape on the way in.
// Without the escape, credentials containing reserved characters
// (':', '@', '%', non-ASCII) would not round-trip correctly even
// against compliant servers. validate() further rejects ClientID
// values that ':' or non-VSCHAR bytes can't reach via this path
// (RFC 7617 §2 + RFC 6749 §2.3.1).
ClientID string
ClientSecret string
Extra url.Values
}
ExchangeRequest is the input to a token exchange.
SubjectToken, SubjectTokenType, and RequestedTokenType are required. Audience, Resource, and Scope map to RFC 8693 §2.1 parameters and are sent only when non-empty. Extra carries implementation-specific form fields (e.g. server-defined parameters not in RFC 8693) that the caller's server expects; the standard fields above always win if Extra also sets them.
Extra values are NOT redacted by the Stringer — only SubjectToken is. Do not stuff bearer-equivalent credentials (client secrets, assertion JWTs, refresh tokens) into Extra; the package is targeted at public-client device flow where no such field is expected. If a caller's server demands a secret-bearing form field via Extra, log hygiene becomes that caller's responsibility.
func (ExchangeRequest) GoString ¶ added in v0.3.1
func (r ExchangeRequest) GoString() string
GoString delegates to String so %#v in fmt also redacts.
func (ExchangeRequest) String ¶ added in v0.3.1
func (r ExchangeRequest) String() string
String redacts SubjectToken (the user's core bearer) and ClientSecret (bearer-equivalent for confidential clients) so accidental log/print-debug exposure doesn't leak them. ClientID is shown verbatim — it's an identifier, not a credential. Other fields are configuration metadata and shown verbatim.
Redaction here is a log-hygiene defense, not a memory-safety or security boundary. The struct fields remain exported and readable via direct access or reflection; callers handling long-lived credentials are responsible for their own zeroization. When adding new fields to ExchangeRequest, update this method if the field carries bearer-equivalent data.