Documentation
¶
Overview ¶
Package tokenexchange implements the OAuth 2.0 Token Exchange grant defined in RFC 8693.
The package surface is split between protocol primitives (typed request and response structures, the token-type URI registry, parse and validate helpers) and an HTTP client that performs an exchange against a token endpoint. The form-encoded request body follows RFC 6749 §3.2; the JSON response body follows RFC 8693 §2.2.
Index ¶
- Constants
- Variables
- func BuiltinTokenTypes() []string
- func IsRegisteredTokenType(uri string) bool
- func RegisterTokenType(uri string) error
- func WriteTokenExchangeError(w http.ResponseWriter, e *TokenExchangeError) error
- func WriteTokenExchangeResponse(w http.ResponseWriter, resp *TokenExchangeResponse) error
- type Client
- type Option
- type TokenExchangeError
- type TokenExchangeRequest
- type TokenExchangeResponse
- type UnknownTokenType
- type ValidationError
Examples ¶
Constants ¶
const ( // ParamGrantType is the OAuth 2.0 grant_type form parameter. ParamGrantType = "grant_type" // ParamResource is the RFC 8707 resource form parameter; multi- // valued. ParamResource = "resource" // ParamAudience is the RFC 8693 §2.1 audience form parameter; // multi-valued. ParamAudience = "audience" // ParamScope is the RFC 6749 §3.3 scope form parameter; one // space-separated string value. ParamScope = "scope" // ParamRequestedTokenType is the RFC 8693 §2.1 // requested_token_type form parameter. ParamRequestedTokenType = "requested_token_type" // ParamSubjectToken is the RFC 8693 §2.1 subject_token form // parameter. ParamSubjectToken = "subject_token" // ParamSubjectTokenType is the RFC 8693 §2.1 subject_token_type // form parameter. ParamSubjectTokenType = "subject_token_type" // ParamActorToken is the RFC 8693 §2.1 actor_token form // parameter. ParamActorToken = "actor_token" // ParamActorTokenType is the RFC 8693 §2.1 actor_token_type form // parameter. ParamActorTokenType = "actor_token_type" )
RFC 8693 §2.1 wire parameter names. Exported as constants so AS- side helpers and callers building custom Extra entries can name the reserved keys by symbol rather than re-typing the strings.
const ( // FieldAccessToken is the access_token JSON member; carries the // issued security token regardless of its actual type (see // TokenExchangeResponse.AccessToken). FieldAccessToken = "access_token" // FieldIssuedTokenType is the issued_token_type JSON member; the // token-type URI for access_token. FieldIssuedTokenType = "issued_token_type" // FieldTokenType is the token_type JSON member (RFC 6749 §7.1). FieldTokenType = "token_type" // FieldExpiresIn is the expires_in JSON member; lifetime of // access_token in seconds. FieldExpiresIn = "expires_in" // FieldScope is the scope JSON member; space-separated per // RFC 6749 §3.3. FieldScope = "scope" // FieldRefreshToken is the refresh_token JSON member; optional // refresh token issued alongside the exchange. FieldRefreshToken = "refresh_token" )
RFC 8693 §2.2 JSON object member names.
const ( // ErrCodeInvalidRequest indicates the request is missing a // required parameter, contains an unsupported parameter value // (other than grant type), repeats a parameter, includes // multiple credentials, uses more than one mechanism for // authenticating the client, or is otherwise malformed. // RFC 6749 §5.2. ErrCodeInvalidRequest = "invalid_request" // ErrCodeInvalidClient indicates client authentication failed // (e.g. unknown client, no client authentication included, or // unsupported authentication method). The AS responds with // HTTP 401 (instead of HTTP 400) when this code is used. // RFC 6749 §5.2. ErrCodeInvalidClient = "invalid_client" // ErrCodeInvalidGrant indicates the provided authorization // grant (in token exchange, the subject_token or actor_token) // is invalid, expired, revoked, does not match the redirection // URI used in the authorization request, or was issued to // another client. RFC 6749 §5.2. ErrCodeInvalidGrant = "invalid_grant" // is not authorized to use this authorization grant type. // RFC 6749 §5.2. ErrCodeUnauthorizedClient = "unauthorized_client" // ErrCodeUnsupportedGrantType indicates the authorization // grant type is not supported by the AS. RFC 6749 §5.2. ErrCodeUnsupportedGrantType = "unsupported_grant_type" // ErrCodeInvalidScope indicates the requested scope is // invalid, unknown, malformed, or exceeds the scope granted by // the resource owner. RFC 6749 §5.2. ErrCodeInvalidScope = "invalid_scope" // ErrCodeInvalidTarget indicates the requested resource or // audience is unknown, malformed, or not authorized for the // client. RFC 8693 §2.4. ErrCodeInvalidTarget = "invalid_target" )
RFC 6749 §5.2 + RFC 8693 §2.4 error codes.
The OAuth 2.0 token endpoint returns errors as a JSON object whose "error" member is one of a fixed set of codes. RFC 6749 §5.2 registered the first six values; RFC 8693 §2.4 added the seventh (ErrCodeInvalidTarget) for resource and audience validation failures. AS implementations MUST emit one of these values; client implementations MUST be prepared to receive any of them.
The library uses these constants for both emission (AS-side helpers) and dispatch (errors.Is matches by Code).
const ( // TokenTypeAccessToken indicates an OAuth 2.0 access token (RFC 6749). TokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" // TokenTypeRefreshToken indicates an OAuth 2.0 refresh token (RFC 6749). TokenTypeRefreshToken = "urn:ietf:params:oauth:token-type:refresh_token" // TokenTypeIDToken indicates an OpenID Connect ID Token. TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" // TokenTypeSAML1 indicates a base64url-encoded SAML 1.1 assertion. TokenTypeSAML1 = "urn:ietf:params:oauth:token-type:saml1" // TokenTypeSAML2 indicates a base64url-encoded SAML 2.0 assertion. TokenTypeSAML2 = "urn:ietf:params:oauth:token-type:saml2" // TokenTypeJWT indicates a JSON Web Token (RFC 7519). TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" )
Token-type URIs defined by RFC 8693 §3.
These URIs identify the type of a security token carried in subject_token, actor_token, or access_token. The set below is the snapshot registered at the time RFC 8693 was published; the IANA "OAuth URI" registry grows additively, so consumers may encounter URIs not listed here. Such URIs are not errors — see UnknownTokenType and RegisterTokenType.
const GrantTypeTokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange"
GrantTypeTokenExchange is the OAuth 2.0 grant-type URI for token exchange. It is sent in the request's grant_type parameter (RFC 8693 §2.1) and is the only valid value for that field.
const SpecVersion = "RFC 8693"
SpecVersion identifies the version of the OAuth 2.0 Token Exchange specification this library implements.
Variables ¶
var ( // ErrTokenTypeReserved is returned by RegisterTokenType when the // supplied URI collides with a token-type URI defined by RFC 8693 // §3. The library reserves the built-in URIs so a downstream // consumer cannot accidentally shadow a spec-defined type. ErrTokenTypeReserved = errors.New("tokenexchange: token-type URI is reserved by RFC 8693") // ErrUnknownTokenType identifies an unrecognized token-type URI. // The library does NOT return this by default — unknown URIs // parse to [UnknownTokenType] so forward-compat round-trips // without losing data. Callers who prefer strict rejection use // this sentinel from their own dispatch logic. ErrUnknownTokenType = errors.New("tokenexchange: token-type URI is not registered") // ErrInvalidGrantType is returned when a parsed request's // grant_type parameter is not [GrantTypeTokenExchange]. The AS- // side helper translates this to an RFC 6749 §5.2 // unsupported_grant_type response. ErrInvalidGrantType = errors.New("tokenexchange: grant_type is not the token-exchange grant") )
Package-level sentinel errors.
These cover internal failure modes that arise at the Go boundary — registry collisions, explicit-rejection paths, grant-type mismatch — not the RFC 6749 §5.2 wire codes. The wire codes live alongside TokenExchangeError in token_error.go because they are protocol values, not Go errors. Callers match these with errors.Is.
Functions ¶
func BuiltinTokenTypes ¶
func BuiltinTokenTypes() []string
BuiltinTokenTypes returns the token-type URIs RFC 8693 §3 registered at the time of publication. The returned slice is a fresh copy and safe to mutate; the order matches the order the URIs appear in RFC 8693 §3.
func IsRegisteredTokenType ¶
IsRegisteredTokenType reports whether uri is currently recognized by the registry — either as one of the six RFC 8693 §3 built-ins or as an extension added via RegisterTokenType.
Returning false does NOT mean uri is invalid; an unknown URI parses to UnknownTokenType so a request or response carrying it round-trips through the library. Strict consumers can compose IsRegisteredTokenType with explicit rejection (ErrUnknownTokenType) at the policy layer.
Safe for concurrent use.
func RegisterTokenType ¶
RegisterTokenType adds uri to the set of recognized token-type URIs so IsRegisteredTokenType returns true for it. It returns ErrTokenTypeReserved when uri collides with one of the six RFC 8693 §3 built-in URIs — the library refuses to let consumers shadow a spec-defined type.
Re-registering a previously-registered extension URI is a no-op; the function returns nil on the second call so consumers can run initialization idempotently (e.g. inside a sync.Once or at every process restart).
Empty or non-URI-shaped uri values are rejected with a wrapped *ValidationError so callers can match the failure shape uniformly:
if errors.Is(err, &ValidationError{Reason: "not a valid URI"}) { … }
RegisterTokenType is safe for concurrent use; the registry is guarded by a sync.RWMutex.
Example ¶
ExampleRegisterTokenType adds a downstream URI to the recognized set; IsRegisteredTokenType returns true for it on subsequent calls. Re-registration is idempotent.
package main
import (
"fmt"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
const uri = "urn:example:demo-token-type"
if err := tokenexchange.RegisterTokenType(uri); err != nil {
fmt.Println("register failed:", err)
return
}
fmt.Println("registered:", tokenexchange.IsRegisteredTokenType(uri))
}
Output: registered: true
func WriteTokenExchangeError ¶
func WriteTokenExchangeError(w http.ResponseWriter, e *TokenExchangeError) error
WriteTokenExchangeError writes e as the body of an RFC 6749 §5.2 token-endpoint error response (extended by RFC 8693 §2.4).
The HTTP status follows the RFC 6749 §5.2 mapping: invalid_client returns 401 so the AS can challenge with WWW-Authenticate; every other code returns 400. The status is derived from e.Code rather than supplied by the caller, so the spec mapping is enforced at the library boundary rather than discoverable only via the spec text.
The function:
- Validates that e is non-nil and e.Code is non-empty. An empty Code is a wire shape the AS must not emit; the function returns an error rather than write malformed bytes.
- Marshals e through encoding/json (the TokenExchangeError custom Error/Is methods are not on the marshal path).
- Sets the RFC 6749 §5.2 response headers — Content-Type: application/json, Cache-Control: no-store, Pragma: no-cache.
- Writes the mapped status code.
- Writes the marshaled body.
Note: a TokenExchangeError carrying a cause via WithCause writes the same wire bytes as one without — cause is a Go-side chain artifact and never crosses the wire.
Example ¶
ExampleWriteTokenExchangeError shows the RFC 6749 §5.2 status mapping the AS-side helper enforces: invalid_client returns 401, every other code returns 400.
package main
import (
"fmt"
"net/http/httptest"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
for _, code := range []string{
tokenexchange.ErrCodeInvalidTarget,
tokenexchange.ErrCodeInvalidClient,
} {
rr := httptest.NewRecorder()
_ = tokenexchange.WriteTokenExchangeError(rr, &tokenexchange.TokenExchangeError{
Code: code,
})
fmt.Printf("%s -> %d\n", code, rr.Code)
}
}
Output: invalid_target -> 400 invalid_client -> 401
func WriteTokenExchangeResponse ¶
func WriteTokenExchangeResponse(w http.ResponseWriter, resp *TokenExchangeResponse) error
WriteTokenExchangeResponse writes resp as the success body of an RFC 8693 §2.2 token-exchange response.
The function:
- Calls resp.Validate. A validation failure returns the *ValidationError without writing anything to w, so the AS handler can route the failure through its usual error path (typically WriteTokenExchangeError with ErrCodeInvalidRequest or a server-class 5xx).
- Marshals resp via the custom JSON codec (which round-trips the Extra map per TKEX-14).
- Sets the RFC 6749 §5.1 response headers — Content-Type: application/json, Cache-Control: no-store, Pragma: no-cache.
- Writes HTTP 200.
- Writes the marshaled body.
A nil w returns a wrapped error rather than panicking. A nil resp is rejected by resp.Validate before any headers are touched.
The function returns a non-nil error from any of the three failure modes (validation, marshal, body write). It does not partially write a response; once the headers go out, the caller assumes a best-effort write of the body (the underlying http.ResponseWriter may have already buffered the headers).
Example ¶
ExampleWriteTokenExchangeResponse writes a success response with the RFC 6749 §5.1 headers from inside an AS handler.
package main
import (
"fmt"
"net/http/httptest"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
rr := httptest.NewRecorder()
resp := &tokenexchange.TokenExchangeResponse{
AccessToken: "newly-issued",
IssuedTokenType: tokenexchange.TokenTypeAccessToken,
TokenType: "Bearer",
ExpiresIn: 60,
}
if err := tokenexchange.WriteTokenExchangeResponse(rr, resp); err != nil {
fmt.Println("write failed:", err)
return
}
fmt.Println("status:", rr.Code)
fmt.Println("content-type:", rr.Header().Get("Content-Type"))
fmt.Println("cache-control:", rr.Header().Get("Cache-Control"))
}
Output: status: 200 content-type: application/json cache-control: no-store
Types ¶
type Client ¶
type Client interface {
// Exchange performs a single RFC 8693 §2.1 token-exchange
// request and returns the parsed §2.2 success response on
// HTTP 2xx, a typed [*TokenExchangeError] on a JSON 4xx, or
// a wrapped transport error otherwise.
Exchange(ctx context.Context, req *TokenExchangeRequest) (*TokenExchangeResponse, error)
}
Client is the client-side surface of the OAuth 2.0 Token Exchange grant defined in RFC 8693. A Client performs one method — Exchange — against a single token endpoint.
The interface shape lets callers wrap or mock without depending on the library's concrete implementation. Tests can substitute a stub Client; downstream packages can decorate the default with retry, telemetry, or client-authentication transport. The concrete implementation returned by NewClient is intentionally unexported so the only stable surface is this interface plus the constructors that build it.
Client authentication is out of scope for v0.1. RFC 8693 §2.1 inherits the RFC 6749 §2.3.1 client-authentication rules; the library lets the caller bring an *http.Client whose Transport applies whatever flavor the AS requires (HTTP Basic, mTLS, private-key-JWT, etc.) rather than bundling the auth flavor explosion.
func NewClient ¶
NewClient returns a Client that talks to tokenEndpoint. Use WithHTTPClient (and any other Option this package ships) to override the defaults — most importantly, to inject a configured *http.Client carrying the client authentication the AS requires.
tokenEndpoint must be the absolute URL of the AS token endpoint (e.g. https://as.example.com/token). The constructor does not validate the URL beyond being non-empty; downstream callers that want strict validation should do that at configuration time.
Options are applied in argument order, so a later option wins on any field. A nil option is silently skipped.
Example ¶
ExampleNewClient constructs a default Client pointed at an AS token endpoint. The injected http.Client is the seam for transport-layer client authentication (HTTP Basic via a wrapping RoundTripper, mTLS via http.Transport, private-key-JWT via a signing wrapper).
package main
import (
"net/http"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
c := tokenexchange.NewClient(
"https://as.example.com/token",
tokenexchange.WithHTTPClient(http.DefaultClient),
)
_ = c // use c.Exchange(ctx, req) to perform a token exchange
}
Output:
type Option ¶
type Option func(*httpClient)
Option configures a Client at construction time. It is the functional-options shape used by NewClient: each Option is a small closure that mutates the unexported implementation struct before NewClient hands the wrapped value back to the caller as a Client interface.
The Option type is intentionally opaque (its parameter is an unexported type) so the set of valid options is closed: only the With* helpers in this package can produce one. New options can be added in later releases without breaking callers — the variadic signature on NewClient and the closed Option set together form a forward-compatible surface.
func WithHTTPClient ¶
WithHTTPClient returns an Option that overrides the http.Client the Client uses for the underlying POST. The injected client is where transport-layer client authentication lives: a wrapping http.RoundTripper that adds HTTP Basic, an mTLS-configured Transport, a private-key-JWT signing wrapper, and so on are all applied at the http.Client / http.Transport layer rather than as library options.
Passing nil restores the default (http.DefaultClient). This is the explicit-reset shape: a caller writing NewClient(endpoint, WithHTTPClient(nil)) gets the documented default rather than a silently broken client.
type TokenExchangeError ¶
type TokenExchangeError struct {
// Code is the RFC 6749 §5.2 error code. One of the ErrCode*
// constants in this package, or an extension code defined by
// a downstream profile. Required; an empty Code is a wire
// shape the AS must not emit and a client must treat as
// invalid.
Code string `json:"error"`
// Description is a human-readable explanation suitable for
// surfacing to a developer. Optional.
Description string `json:"error_description,omitempty"`
// URI is a URI identifying a human-readable web page with
// information about the error. Optional.
URI string `json:"error_uri,omitempty"`
// contains filtered or unexported fields
}
TokenExchangeError is the typed Go form of the RFC 6749 §5.2 token- endpoint error response (extended by RFC 8693 §2.4 with the invalid_target code).
The wire encoding is application/json. Two of the three fields are optional and use omitempty so an error with only a Code round-trips to the minimal JSON object {"error":"<code>"}.
HTTP status mapping (RFC 6749 §5.2): all codes map to HTTP 400 except invalid_client, which maps to HTTP 401. The AS-side helper WriteTokenExchangeError encodes this mapping; this type carries only the wire shape, not the transport semantics.
TokenExchangeError implements the error interface and supports errors.Is matching by Code via the Is method, so client code can write:
if errors.Is(err, &TokenExchangeError{Code: ErrCodeInvalidTarget}) { … }
or, equivalently, hold the sentinel value as a package-level var.
TokenExchangeError also wraps an underlying cause via WithCause / Unwrap, so a client that receives a transport or decode error in the same turn as a protocol error can return a single typed value that errors.Is and errors.As can walk through.
Naming: same TokenExchange prefix as TokenExchangeRequest and TokenExchangeResponse, for the same spec-name reason.
func (*TokenExchangeError) Error ¶
func (e *TokenExchangeError) Error() string
Error returns a brief string describing the error, suitable for logging or wrapping. It combines Code with Description when Description is non-empty; otherwise it returns Code alone, prefixed with the package name so the origin is identifiable in mixed- source error chains.
func (*TokenExchangeError) Is ¶
func (e *TokenExchangeError) Is(target error) bool
Is reports whether the error matches target. It returns true when target is a *TokenExchangeError with the same Code, ignoring Description and URI. This lets callers test for a specific RFC 6749 §5.2 condition without constructing a value that matches the description text:
if errors.Is(err, &TokenExchangeError{Code: ErrCodeInvalidTarget}) { … }
Is does NOT match across types (a wrapped *TokenExchangeError still matches; an error whose string contains the code does not).
Example ¶
ExampleTokenExchangeError_Is matches a parsed error against a sentinel value by Code. The sentinel can be a freshly-constructed value or a package-level variable; both work with errors.Is.
package main
import (
"errors"
"fmt"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
var got error = &tokenexchange.TokenExchangeError{
Code: tokenexchange.ErrCodeInvalidTarget,
Description: "audience https://other.example.com is not authorized",
}
if errors.Is(got, &tokenexchange.TokenExchangeError{Code: tokenexchange.ErrCodeInvalidTarget}) {
fmt.Println("matched invalid_target")
}
}
Output: matched invalid_target
func (*TokenExchangeError) Unwrap ¶
func (e *TokenExchangeError) Unwrap() error
Unwrap returns the underlying error attached via WithCause, or nil if none. It enables errors.Is and errors.As to walk past the typed protocol error to a transport or decode cause held by the client-side Exchange.
func (*TokenExchangeError) WithCause ¶
func (e *TokenExchangeError) WithCause(cause error) *TokenExchangeError
WithCause returns a shallow copy of e with cause attached, so errors.Is and errors.As can walk through the typed protocol error to the underlying transport, decode, or I/O error. The receiver is not mutated, which matters because sentinel TokenExchangeError values are often constructed once and reused (e.g. via errors.Is(err, &TokenExchangeError{Code: ErrCodeInvalidTarget})).
Calling WithCause on a nil receiver returns nil. Setting cause to nil clears any previously-attached cause on the copy.
type TokenExchangeRequest ¶
type TokenExchangeRequest struct {
// GrantType is the OAuth 2.0 grant-type URI. For a token-exchange
// request it is [GrantTypeTokenExchange]; any other value is
// rejected by Validate.
GrantType string
// Resource is the URI or URIs identifying the target resource
// the issued token will be used against, per RFC 8707 and
// RFC 8693 §2.1. May appear zero or more times on the wire.
Resource []string
// Audience is the logical name or names of the target service.
// May appear zero or more times on the wire (RFC 8693 §2.1).
Audience []string
// Scope is the requested scope, modeled as a slice of individual
// scope values. RFC 6749 §3.3 transports scope as a single
// space-separated parameter; the codec joins on encode and splits
// on parse.
Scope []string
// RequestedTokenType is the token-type URI naming the type the
// caller would like back. Optional; when empty, RFC 8693 §2.1
// SHOULD treat the request as asking for the same type as
// SubjectTokenType.
RequestedTokenType string
// SubjectToken is the security token being exchanged. Required;
// Validate rejects an empty value.
SubjectToken string
// SubjectTokenType is the token-type URI naming the type of
// SubjectToken. Required; Validate rejects an empty value.
SubjectTokenType string
// ActorToken is the security token identifying the acting party
// in a delegation. Optional, but if set, ActorTokenType MUST also
// be set.
ActorToken string
// ActorTokenType is the token-type URI naming the type of
// ActorToken. Optional, but if set, ActorToken MUST also be set.
ActorTokenType string
// Extra holds form parameters this library does not define. It
// round-trips on Encode. Keys colliding with built-in fields are
// shadowed by the built-in value; the codec emits built-in fields
// last so they win on the wire as well.
Extra url.Values
}
TokenExchangeRequest is the typed Go form of the RFC 8693 §2.1 token-exchange request body.
The wire encoding is application/x-www-form-urlencoded (RFC 6749 §3.2 inherited convention), not JSON; the struct therefore carries no encoding/json tags. Encode and parse helpers land alongside the codec in a later phase, and they convert to and from net/url.Values.
Field invariants matching RFC 8693 §2.1:
- GrantType is always GrantTypeTokenExchange. Validate() rejects any other value.
- SubjectToken and SubjectTokenType MUST be present and non-empty.
- ActorToken and ActorTokenType MUST both be present or both absent. Validate() enforces the pairing.
- Resource and Audience are slices because the spec allows each parameter to appear multiple times on the wire; the slice preserves wire order on encode.
- Scope is a slice because RFC 6749 §3.3 defines the scope value as a space-separated list; Encode joins the slice with a single space, and the parser splits on whitespace and drops empties.
- RequestedTokenType is optional. RFC 8693 §2.1 SHOULD treat an omitted value as a request for the same type as SubjectTokenType; the library preserves the wire shape (omitted = "") and leaves the default to the AS implementer. See TokenExchangeRequest.RequestedOrSubjectTokenType.
Extra captures form parameters this library does not define. It round-trips on Encode so a spec extension that adds a new request parameter passes through unchanged; built-in fields take precedence when a key in Extra collides with a field name.
Naming: the TokenExchange prefix mirrors the spec name (RFC 8693) and is load-bearing in the AS-side helper signatures (ParseTokenExchangeRequest, WriteTokenExchangeResponse, WriteTokenExchangeError) where the protocol identity disambiguates "request" / "response" / "error" at the call site.
func ParseTokenExchangeRequest ¶
func ParseTokenExchangeRequest(r *http.Request) (*TokenExchangeRequest, error)
ParseTokenExchangeRequest reads the form-encoded body of r and returns a typed TokenExchangeRequest. It is the inverse of TokenExchangeRequest.Encode: form parameters whose names are defined by RFC 8693 §2.1 populate the corresponding typed fields, and any other form parameters are captured into Extra so they round-trip on a subsequent Encode.
The parser does NOT validate. It is lenient on receive per Postel's law: missing required fields decode to zero values, duplicate single-valued parameters take the first value (matching url.Values.Get semantics), and unknown values for the grant_type or token-type URIs decode without error. Apply (*TokenExchangeRequest).Validate() afterwards to enforce the §2.1 MUSTs.
ParseTokenExchangeRequest calls http.Request.ParseForm, which requires the request's Content-Type to be application/x-www-form-urlencoded for POST/PUT/PATCH bodies. A parse error on the body is wrapped and returned. An empty body returns a non-nil request whose fields are all zero.
The function does not consume r.Body any differently than ParseForm itself; if the caller has already invoked ParseForm (e.g. in a middleware), the call here is idempotent.
Example ¶
ExampleParseTokenExchangeRequest parses an inbound form-encoded token-exchange request from an http.Request.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
body := `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange` +
`&subject_token=incoming-token` +
`&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token` +
`&resource=https%3A%2F%2Fbackend.example.com%2Fapi`
r := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(body))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req, err := tokenexchange.ParseTokenExchangeRequest(r)
if err != nil {
fmt.Println("parse failed:", err)
return
}
fmt.Println("subject:", req.SubjectToken)
fmt.Println("type:", req.SubjectTokenType)
fmt.Println("resource:", req.Resource)
}
Output: subject: incoming-token type: urn:ietf:params:oauth:token-type:access_token resource: [https://backend.example.com/api]
func (*TokenExchangeRequest) Encode ¶
func (r *TokenExchangeRequest) Encode() url.Values
Encode returns the form-encoded body of r per RFC 8693 §2.1.
Emission rules:
- GrantType, SubjectToken, SubjectTokenType are always emitted (even when empty) so a Parse / Encode round-trip preserves "present but empty" shapes. Validate, not Encode, enforces the §2.1 MUSTs.
- Resource emits one resource form parameter per slice element, in slice order. An empty or nil slice emits nothing.
- Audience emits one audience form parameter per slice element, in slice order. An empty or nil slice emits nothing.
- Scope is space-joined (RFC 6749 §3.3) into a single scope value. An empty or nil slice emits nothing; a slice whose only element is the empty string emits an empty scope value.
- RequestedTokenType, ActorToken, ActorTokenType emit only when non-empty.
- Extra contributions are added first; the built-in fields overwrite or extend collisions, so a built-in key in Extra cannot smuggle a competing value onto the wire.
The returned url.Values is freshly allocated; the caller may mutate it (e.g. to add transport headers carried as form fields). Marshaling to bytes is the caller's responsibility — typically via the returned Values.Encode().
Example ¶
ExampleTokenExchangeRequest_Encode shows how a typed request turns into a form-encoded body. The form keys are alphabetized by net/url.Values.Encode, so output ordering is stable.
package main
import (
"fmt"
"sort"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
req := &tokenexchange.TokenExchangeRequest{
GrantType: tokenexchange.GrantTypeTokenExchange,
SubjectToken: "tok",
SubjectTokenType: tokenexchange.TokenTypeAccessToken,
Scope: []string{"read", "write"},
}
values := req.Encode()
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s=%v\n", k, values[k])
}
}
Output: grant_type=[urn:ietf:params:oauth:grant-type:token-exchange] scope=[read write] subject_token=[tok] subject_token_type=[urn:ietf:params:oauth:token-type:access_token]
func (*TokenExchangeRequest) RequestedOrSubjectTokenType ¶
func (r *TokenExchangeRequest) RequestedOrSubjectTokenType() string
RequestedOrSubjectTokenType returns RequestedTokenType when set, or SubjectTokenType otherwise. It implements the RFC 8693 §2.1 SHOULD for AS implementers that want one-call access to the effective requested token type without having to encode the default in their own policy layer.
func (*TokenExchangeRequest) Validate ¶
func (r *TokenExchangeRequest) Validate() error
Validate reports whether r satisfies the RFC 8693 §2.1 MUST rules for a token-exchange request:
- grant_type MUST equal GrantTypeTokenExchange.
- subject_token MUST be present and non-empty.
- subject_token_type MUST be present and non-empty.
- actor_token and actor_token_type MUST both be present or both absent (paired).
- subject_token_type, requested_token_type (when present), and actor_token_type (when present) MUST each be a syntactically valid URI per RFC 3986.
The first failing rule returns a *ValidationError naming the rule, the wire parameter, and a short reason. Validate does not inspect contents beyond the spec MUSTs — token-type URIs are not required to be registered, and token contents (JWT shape, opacity, signatures) are out of scope.
A grant_type that is not the token-exchange URI returns the sentinel ErrInvalidGrantType wrapped with the validation citation, so callers can match both shapes:
if errors.Is(err, ErrInvalidGrantType) { … }
if errors.Is(err, &ValidationError{Parameter: ParamGrantType}) { … }
Validate on a nil receiver returns a *ValidationError rather than panicking; the AS-side helper uses that to translate to a 400 invalid_request without bespoke nil handling.
Example ¶
ExampleTokenExchangeRequest_Validate flags a missing required field with a typed *ValidationError naming the spec citation, the wire parameter, and a reason.
package main
import (
"fmt"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
req := &tokenexchange.TokenExchangeRequest{
GrantType: tokenexchange.GrantTypeTokenExchange,
// SubjectToken intentionally empty
SubjectTokenType: tokenexchange.TokenTypeAccessToken,
}
err := req.Validate()
fmt.Println(err)
}
Output: tokenexchange: RFC 8693 §2.1: subject_token: missing
type TokenExchangeResponse ¶
type TokenExchangeResponse struct {
// AccessToken carries the issued security token, regardless of
// type. RFC 8693 §2.2.1 inherits the field name from RFC 6749
// §5.1, so an issued id_token, JWT, or SAML assertion also
// arrives here. Required; Validate rejects an empty value.
AccessToken string `json:"access_token"`
// IssuedTokenType is the token-type URI naming the type of
// AccessToken. Required; Validate rejects an empty value.
IssuedTokenType string `json:"issued_token_type"`
// TokenType is the OAuth 2.0 token_type response parameter
// (RFC 6749 §7.1 — typically "Bearer", or "N_A" when the
// issued token is not an access token). Required; Validate
// rejects an empty value.
TokenType string `json:"token_type"`
// ExpiresIn is the recommended lifetime of AccessToken in
// seconds. Optional; zero means the AS did not advertise a
// lifetime.
ExpiresIn int `json:"expires_in,omitempty"`
// Scope is the scope granted to AccessToken, as a single
// space-separated string per RFC 6749 §3.3. Present only when
// the AS narrows or otherwise differs from the requested scope.
Scope string `json:"scope,omitempty"`
// RefreshToken is an optional refresh token the AS may issue
// alongside the exchange result.
RefreshToken string `json:"refresh_token,omitempty"`
// Extra holds JSON object members this library does not define.
// The tag json:"-" suppresses the default codec; the custom
// Marshal and Unmarshal that arrive in a later phase round-trip
// Extra through the JSON object.
Extra map[string]json.RawMessage `json:"-"`
}
TokenExchangeResponse is the typed Go form of the RFC 8693 §2.2 successful token-exchange response.
The wire encoding is application/json (RFC 6749 §5.1 extended by RFC 8693 §2.2), so the struct carries encoding/json tags and is serialized by encoding/json. Custom Marshal and Unmarshal methods that round-trip the Extra map land in a later phase; until then Extra is annotated json:"-" so the default codec does not visit it (an empty Extra map would otherwise serialize as a missing field noisily, and a populated one would not survive a round trip).
Field invariants matching RFC 8693 §2.2:
- AccessToken, IssuedTokenType, and TokenType MUST be present and non-empty. Validate enforces all three.
- ExpiresIn, Scope, and RefreshToken are OPTIONAL. Zero values are serialized as missing fields via omitempty.
AccessToken note: the wire field is named access_token regardless of the type of the issued security token. RFC 8693 §2.2.1 inherits the field name from RFC 6749 §5.1, so an exchange that issues a JWT id_token still carries the JWT in access_token; the typed access lives in IssuedTokenType. Surprising once; permanent.
Extra captures JSON object members this library does not define. It round-trips through the custom Marshal and Unmarshal that arrive in a later phase, so a spec extension that adds a new response field passes through unchanged; built-in fields take precedence when a key in Extra collides with a field name.
Naming: the TokenExchange prefix mirrors the spec name (RFC 8693) and matches the request and error types in this package; see TokenExchangeRequest for the longer explanation.
func (TokenExchangeResponse) MarshalJSON ¶
func (r TokenExchangeResponse) MarshalJSON() ([]byte, error)
MarshalJSON implements encoding/json.Marshaler so the Extra map round-trips into the JSON object.
Built-in fields are emitted with their json-tag semantics from TokenExchangeResponse (omitempty on ExpiresIn, Scope, and RefreshToken). Extra entries are merged into the object after the built-ins, but any Extra key whose name collides with a built-in field name is dropped — even when the built-in was omitted by omitempty. The drop rule keeps Extra from backfilling a built-in (e.g. an Extra "scope" entry cannot survive when r.Scope is empty); to override a built-in, set the typed field.
Example ¶
ExampleTokenExchangeResponse_MarshalJSON demonstrates that the custom codec round-trips the Extra map: unknown JSON members emit alongside the typed fields.
package main
import (
"encoding/json"
"fmt"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
resp := tokenexchange.TokenExchangeResponse{
AccessToken: "tok",
IssuedTokenType: tokenexchange.TokenTypeAccessToken,
TokenType: "Bearer",
Extra: map[string]json.RawMessage{
"x-tenant": json.RawMessage(`"acme"`),
},
}
out, err := json.Marshal(resp)
if err != nil {
fmt.Println("marshal failed:", err)
return
}
// json.Marshal of a map sorts keys alphabetically; the output
// shape is deterministic.
fmt.Println(string(out))
}
Output: {"access_token":"tok","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","x-tenant":"acme"}
func (*TokenExchangeResponse) UnmarshalJSON ¶
func (r *TokenExchangeResponse) UnmarshalJSON(data []byte) error
UnmarshalJSON implements encoding/json.Unmarshaler so JSON members the library does not define are captured into r.Extra rather than silently dropped.
Built-in member names decode into their typed fields with the same semantics as the default codec. Every other member name (including any future RFC 8693 extension) is placed in r.Extra verbatim, so a subsequent MarshalJSON re-emits it byte-stably.
Decoding "null" leaves r at its zero value; decoding a non-object returns an error.
func (*TokenExchangeResponse) Validate ¶
func (r *TokenExchangeResponse) Validate() error
Validate reports whether r satisfies the RFC 8693 §2.2 MUST rules for a successful token-exchange response:
- access_token MUST be present and non-empty.
- issued_token_type MUST be present, non-empty, and a syntactically valid URI per RFC 3986.
- token_type MUST be present and non-empty.
The first failing rule returns a *ValidationError naming the rule citation, the wire field name, and a short reason. Validate does NOT enforce that issued_token_type be a registered URI — forward-compat for IANA registry growth is handled by UnknownTokenType.
Validate on a nil receiver returns a *ValidationError rather than panicking; the client-side Exchange uses this so a server that returns the literal JSON "null" surfaces as a validation failure rather than a panic.
type UnknownTokenType ¶
type UnknownTokenType struct {
// URI is the unrecognized token-type URI exactly as it appeared
// on the wire.
URI string
}
UnknownTokenType wraps a token-type URI the library does not recognize. It exists so that a request or response using a URI registered after this library was built round-trips verbatim rather than failing to parse.
The wire form is the URI itself, unchanged. Library code that receives an UnknownTokenType has three options: treat it as opaque and pass through, call RegisterTokenType so subsequent parses recognize it, or reject it at the application layer.
Example ¶
ExampleUnknownTokenType wraps a URI the registry does not yet recognize. The wrapper exists at the Go-type boundary; the wire carries the URI string unchanged.
package main
import (
"fmt"
tokenexchange "github.com/hstern/go-token-exchange"
)
func main() {
u := tokenexchange.UnknownTokenType{URI: "urn:example:future:token-type"}
fmt.Println(u)
}
Output: urn:example:future:token-type
func (UnknownTokenType) String ¶
func (u UnknownTokenType) String() string
String returns the wrapped URI, satisfying fmt.Stringer. It is equivalent to reading the URI field directly.
type ValidationError ¶
type ValidationError struct {
// Rule is the spec citation that defines the violated invariant,
// e.g. "RFC 8693 §2.1".
Rule string
// Parameter is the wire parameter name whose value caused the
// failure, e.g. "subject_token". Empty when the failure is not
// scoped to one parameter.
Parameter string
// Reason is a short human-readable explanation of why the
// invariant failed, e.g. "missing" or "must pair with
// actor_token".
Reason string
}
ValidationError reports that a typed TokenExchangeRequest or TokenExchangeResponse failed an RFC 8693 §2.1 or §2.2 MUST invariant.
The three fields name the failure in a way an AS-side or client implementer can translate to either a logged operator message or a user-visible message:
- Rule cites the spec clause that defines the invariant (e.g. "RFC 8693 §2.1") so the reader of a log line can verify the library's interpretation against the source.
- Parameter names the wire parameter that failed (e.g. "subject_token", "actor_token_type"). Empty when the failure is structural rather than scoped to one parameter.
- Reason is a short human-readable description (e.g. "missing" or "must pair with actor_token").
ValidationError is a value type — Validate methods return * pointers because the type carries enough context that copies are rare and identity matters for errors.Is on a sentinel value.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string
Error returns a brief string describing the validation failure, prefixed with the package name so the origin is identifiable in mixed-source error chains. The Parameter is included only when non-empty; the Rule is always included so the spec citation is visible at the surface of any log line.
func (*ValidationError) Is ¶
func (e *ValidationError) Is(target error) bool
Is reports whether the error matches target. It returns true when target is a *ValidationError whose non-empty fields all equal the corresponding fields on the receiver. An empty Rule, Parameter, or Reason in target is treated as a wildcard, so callers can match on Parameter alone:
if errors.Is(err, &ValidationError{Parameter: "subject_token"}) { … }
Wildcarding on all three fields would match any ValidationError and is intentionally allowed — that is the "is this a validation failure at all" question.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
delegation
command
Delegation example: a service exchanges its own access token alongside the original subject's token (the "actor_token" pair) to obtain a token usable on the subject's behalf for a specific audience.
|
Delegation example: a service exchanges its own access token alongside the original subject's token (the "actor_token" pair) to obtain a token usable on the subject's behalf for a specific audience. |
|
impersonation
command
Impersonation example: a client exchanges its access token at an AS token endpoint to obtain a downscoped access token usable against a specific backend resource.
|
Impersonation example: a client exchanges its access token at an AS token endpoint to obtain a downscoped access token usable against a specific backend resource. |
|
internal
|
|
|
specfixtures
Package specfixtures embeds the RFC 8693 example payloads the library uses as its conformance corpus.
|
Package specfixtures embeds the RFC 8693 example payloads the library uses as its conformance corpus. |