Documentation
¶
Overview ¶
Package introspection implements RFC 7662, OAuth 2.0 Token Introspection: a typed request/response model and a small HTTP client and responder helpers for the introspection endpoint.
A protected resource uses Client to ask an authorization server whether a token is active and to read its metadata:
c := introspection.NewClient("https://as.example.com/introspect",
introspection.WithBasicAuth("resource-server", "secret"))
resp, err := c.Introspect(ctx, &introspection.Request{Token: tok})
if err != nil {
// transport failure, non-200 status, or an undecodable body
}
if !resp.Active {
// the token is not active — a normal answer, not an error
}
An inactive or unknown token comes back as a Response with Active false and a nil error (§2.3): it is a normal answer, not a failure. Errors are reserved for the request not completing.
On the authorization-server side, ParseRequest and WriteResponse are à-la-carte helpers for an introspection endpoint bolted onto an existing server; the library ships no HTTP handler, leaving routing and caller authentication to the responder.
The library depends only on the standard library. Client authentication beyond HTTP Basic (WithBasicAuth), along with TLS and timeouts, are transport concerns configured on the net/http.Client passed to WithHTTPClient. Verifying a signed (JWT) access token is out of scope — that is the local-validation alternative to introspection.
Index ¶
- Constants
- Variables
- func WriteResponse(w http.ResponseWriter, resp *Response) error
- type Audience
- type Client
- type ClientOption
- type HTTPError
- type NumericDate
- type Request
- type Response
- func (r *Response) GetExtra(name string, v any) (present bool, err error)
- func (r Response) MarshalJSON() ([]byte, error)
- func (r *Response) Scopes() []string
- func (r *Response) SetExtra(name string, v any) error
- func (r *Response) UnmarshalJSON(data []byte) error
- func (r *Response) Validate(opts ...ValidateOption) error
- type ValidateOption
- type ValidationError
Examples ¶
Constants ¶
const ( TokenTypeHintAccessToken = "access_token" TokenTypeHintRefreshToken = "refresh_token" )
Token type hints registered for the introspection (and revocation) endpoints by RFC 7009 §2.1. TokenTypeHint accepts other values too; these are the well-known ones.
const FormContentType = "application/x-www-form-urlencoded"
FormContentType is the media type of an introspection request body (§2.1).
const ResponseContentType = "application/json"
ResponseContentType is the media type of an introspection response body (RFC 7662 §2.2).
const SpecVersion = "RFC 7662"
SpecVersion is the version of the specification this package targets.
Variables ¶
var ( // client authentication with HTTP 401 (RFC 7662 §2.3). It is wrapped by the // *HTTPError returned for a 401 response. ErrUnauthorized = errors.New("introspection: endpoint rejected client authentication") // ErrUnexpectedStatus is reported for any non-200, non-401 HTTP status. It // is wrapped by the *HTTPError returned for such a response. ErrUnexpectedStatus = errors.New("introspection: unexpected response status") // ErrInvalidResponse is reported when a 200 response body is not valid JSON // or cannot be decoded into a Response. ErrInvalidResponse = errors.New("introspection: malformed response body") )
Errors returned by Introspect. Use errors.Is to test for them; an *HTTPError (via errors.As) carries the exact status code.
var ( // ErrTokenInactive is reported when the response's active member is false — // the authorization server considers the token not currently active (§2.2). ErrTokenInactive = errors.New("introspection: token is not active") // ErrTokenExpired is reported when exp is present and in the past (§2.2). ErrTokenExpired = errors.New("introspection: token has expired") // ErrTokenNotYetValid is reported when nbf is present and in the future // (§2.2). ErrTokenNotYetValid = errors.New("introspection: token is not yet valid") )
Validity errors reported by Response.Validate. Match them with errors.Is.
var ErrValidation = errors.New("introspection: validation failed")
ErrValidation is the sentinel that every *ValidationError unwraps to, so a caller can match any validation failure with errors.Is(err, ErrValidation) or inspect the specifics with errors.As.
Functions ¶
func WriteResponse ¶
func WriteResponse(w http.ResponseWriter, resp *Response) error
WriteResponse writes resp as the JSON body of an introspection response (RFC 7662 §2.2) with HTTP 200 and the application/json content type. It is the producer-side counterpart to Client decoding the response.
The active member is always emitted, so the §2.2 "active REQUIRED" rule holds by construction. WriteResponse does not strip an inactive response of its other members: §2.3 advises a responder not to reveal them, but that is the responder's policy to apply to resp before calling, not something the library enforces.
Types ¶
type Audience ¶
type Audience []string
Audience is a token audience: one or more service-specific string identifiers (§2.2). It decodes from either a single JSON string or an array of strings, and marshals back to a single string when it holds exactly one element.
func (Audience) MarshalJSON ¶
MarshalJSON emits a bare string for a single-element audience and a JSON array otherwise.
func (*Audience) UnmarshalJSON ¶
UnmarshalJSON accepts either a single string or an array of strings.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client calls an RFC 7662 introspection endpoint on behalf of a protected resource. It is safe for concurrent use; the zero value is not usable — build one with NewClient.
func NewClient ¶
func NewClient(endpoint string, opts ...ClientOption) *Client
NewClient returns a Client that introspects tokens at endpoint, a fully qualified introspection endpoint URL (RFC 7662 §2). By default it uses http.DefaultClient and sends no authentication; override with WithHTTPClient and WithBasicAuth.
func (*Client) Introspect ¶
Introspect submits req to the introspection endpoint and returns the decoded Response.
A token the authorization server reports as inactive or unknown is a normal result, not an error: the call returns a Response with Active == false and a nil error (RFC 7662 §2.2, §2.3). Errors are reserved for the request failing to complete: a transport failure, a non-200 status (*HTTPError, wrapping ErrUnauthorized or ErrUnexpectedStatus), or a body that will not decode (ErrInvalidResponse).
Example ¶
Introspect a token against an authorization server. An inactive token is a normal answer (Active false, nil error), not an error.
package main
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
introspection "github.com/hstern/go-token-introspection"
)
func main() {
// Stand in for a real authorization server.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"active":true,"client_id":"l238j323ds-23ij4","scope":"read write"}`)
}))
defer srv.Close()
c := introspection.NewClient(srv.URL,
introspection.WithHTTPClient(srv.Client()),
introspection.WithBasicAuth("resource-server", "secret"),
)
resp, err := c.Introspect(context.Background(), &introspection.Request{Token: "mF_9.B5f-4.1JqM"})
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Active, resp.ClientID, resp.Scopes())
}
Output: true l238j323ds-23ij4 [read write]
type ClientOption ¶
type ClientOption func(*Client)
ClientOption configures a Client in NewClient.
func WithBasicAuth ¶
func WithBasicAuth(clientID, clientSecret string) ClientOption
WithBasicAuth authenticates introspection requests with HTTP Basic, sending clientID and clientSecret as the credentials (RFC 7662 §2.1, RFC 6749 §2.3.1) — the one client-authentication scheme this library implements directly. Every other scheme (private-key-JWT, mTLS, secret-in-body) belongs on the transport supplied via WithHTTPClient.
func WithHTTPClient ¶
func WithHTTPClient(h *http.Client) ClientOption
WithHTTPClient sets the HTTP client used for introspection requests. This is the injection point for transport-level concerns the library deliberately leaves to the caller: TLS configuration, timeouts, and any client authentication scheme other than HTTP Basic (RFC 7662 §2.1). A nil client is ignored.
type HTTPError ¶
HTTPError reports an introspection response whose status was not 200 OK. It wraps ErrUnauthorized for a 401 and ErrUnexpectedStatus otherwise, so callers can match either the specific or the general case with errors.Is, or read the code with errors.As.
type NumericDate ¶
NumericDate is an RFC 7662 timestamp: an integer number of seconds since the Unix epoch (§2.2). It marshals to integer seconds and decodes liberally, tolerating a fractional part.
func NewNumericDate ¶
func NewNumericDate(t time.Time) *NumericDate
NewNumericDate returns a *NumericDate for t.
func (NumericDate) MarshalJSON ¶
func (n NumericDate) MarshalJSON() ([]byte, error)
MarshalJSON emits the timestamp as integer seconds since the Unix epoch.
func (*NumericDate) UnmarshalJSON ¶
func (n *NumericDate) UnmarshalJSON(data []byte) error
UnmarshalJSON parses a JSON number of seconds since the Unix epoch, tolerating a fractional part.
type Request ¶
type Request struct {
// Token is the string value of the token to introspect. REQUIRED (§2.1).
Token string
// TokenTypeHint is an optional hint about the type of Token, allowing the
// server to optimise its lookup (§2.1). It is advisory: a server MAY ignore
// it, and an incorrect hint MUST NOT change the result. Registered values
// are TokenTypeHintAccessToken and TokenTypeHintRefreshToken, but any string
// is accepted.
TokenTypeHint string
}
Request is an RFC 7662 §2.1 token introspection request: the token to introspect plus an optional hint about its type.
The endpoint requires the caller to authenticate (§2.1); that is handled by the HTTP transport, not carried on this struct. See Client.
func ParseRequest ¶
ParseRequest reads an RFC 7662 §2.1 introspection request from an incoming HTTP request on the authorization-server side. It is the producer-side counterpart to Client's request encoding.
Unlike the liberal RequestFromValues codec, ParseRequest enforces the HTTP boundary rules: the body must be form-encoded (application/x-www-form-urlencoded) and the required token parameter must be present (§2.1). Either failure is a *ValidationError, naming the offending field so the caller can answer with HTTP 400. Caller authentication (§2.1) is the responder's concern and is not checked here.
The request body is read through a 1 MiB limit to bound memory use on hostile input; an oversized body surfaces as a parse error.
Example ¶
Parse an introspection request on the authorization-server side.
package main
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
introspection "github.com/hstern/go-token-introspection"
)
func main() {
r := httptest.NewRequest(http.MethodPost, "/introspect",
// In a real handler this body is the incoming request.
strings.NewReader("token=mF_9.B5f-4.1JqM&token_type_hint=access_token"))
r.Header.Set("Content-Type", introspection.FormContentType)
req, err := introspection.ParseRequest(r)
if err != nil {
log.Fatal(err)
}
fmt.Println(req.Token, req.TokenTypeHint)
}
Output: mF_9.B5f-4.1JqM access_token
func RequestFromValues ¶
RequestFromValues builds a Request from parsed form values. It is liberal (Postel's law): it copies whatever is present without validating that token is non-empty — enforcement of the §2.1 "token REQUIRED" rule happens at the HTTP boundary in ParseRequest. The companion to FormValues.
func (*Request) EncodeForm ¶
EncodeForm returns the application/x-www-form-urlencoded request body.
func (*Request) FormValues ¶
FormValues encodes the request as url.Values. token is always set; an empty TokenTypeHint is omitted.
type Response ¶
type Response struct {
// Active indicates whether the token is currently active (§2.2). REQUIRED.
// A well-formed, authorized query for an inactive or unknown token returns
// Active == false — that is a normal response, not an error (§2.3).
Active bool `json:"active"`
// Scope is a space-separated list of scopes (§2.2, RFC 6749 §3.3). Stored
// verbatim; use Scopes to split it.
Scope string `json:"scope,omitempty"`
// ClientID is the client the token was issued to (§2.2).
ClientID string `json:"client_id,omitempty"`
// Username is a human-readable identifier for the resource owner (§2.2). It
// is not necessarily the same as Subject.
Username string `json:"username,omitempty"`
// TokenType is the type of the token, e.g. "Bearer" (§2.2, RFC 6749 §7.1).
TokenType string `json:"token_type,omitempty"`
// Expiry (exp), IssuedAt (iat), and NotBefore (nbf) are integer timestamps,
// seconds since the Unix epoch (§2.2).
Expiry *NumericDate `json:"exp,omitempty"`
IssuedAt *NumericDate `json:"iat,omitempty"`
NotBefore *NumericDate `json:"nbf,omitempty"`
// Subject is the subject of the token (§2.2); usually a machine-readable
// identifier of the resource owner.
Subject string `json:"sub,omitempty"`
// Audience is the service-specific audience of the token (§2.2): a single
// string identifier or a list of them.
Audience Audience `json:"aud,omitempty"`
// Issuer is the issuer of the token (§2.2).
Issuer string `json:"iss,omitempty"`
// JWTID is a unique identifier for the token (§2.2, jti).
JWTID string `json:"jti,omitempty"`
// Extra holds any response members not captured by the typed fields above —
// service-specific extensions and future registrations (§2.2). Values are
// kept as raw JSON for byte-stable round-trips and zero-cost pass-through.
Extra map[string]json.RawMessage `json:"-"`
}
Response is an RFC 7662 §2.2 introspection response.
Active is the only REQUIRED member; every other field is optional and is omitted from the wire when unset. Service-specific extension members (§2.2) are preserved verbatim in Extra for byte-stable round-trips.
Decoding is liberal (Postel's law): UnmarshalJSON accepts whatever the wire provides. Strict checks are opt-in via Validate.
func (*Response) GetExtra ¶
GetExtra unmarshals the service-specific extension member named name (§2.2) into v, which must be a non-nil pointer. It reports whether the member was present; a missing member is not an error (present == false, err == nil).
Only members not captured by a typed field land in Extra, so GetExtra is the way to read extension members byte-for-byte as the server sent them.
Example ¶
Read a service-specific extension member that has no typed field.
package main
import (
"encoding/json"
"fmt"
"log"
introspection "github.com/hstern/go-token-introspection"
)
func main() {
var resp introspection.Response
if err := json.Unmarshal([]byte(`{"active":true,"amr":["pwd","otp"]}`), &resp); err != nil {
log.Fatal(err)
}
var amr []string
present, err := resp.GetExtra("amr", &amr)
if err != nil {
log.Fatal(err)
}
fmt.Println(present, amr)
}
Output: true [pwd otp]
func (Response) MarshalJSON ¶
MarshalJSON serializes the typed members and merges Extra back in. Typed members win on key collision. Output is byte-stable: with no extension members the typed members serialize in their declared order; with extensions the whole object serializes in encoding/json's sorted-key order. Either way a given Response value always marshals to the same bytes.
func (*Response) Scopes ¶
Scopes splits the space-delimited Scope into its individual scope tokens (RFC 6749 §3.3). An empty Scope yields nil.
func (*Response) SetExtra ¶
SetExtra marshals v and stores it as the extension member named name (§2.2). It returns an error if name collides with a member that has its own typed field — set those through the field instead — or if v cannot be marshalled.
func (*Response) UnmarshalJSON ¶
UnmarshalJSON decodes the typed members and routes every other member of the JSON object into Extra.
func (*Response) Validate ¶
func (r *Response) Validate(opts ...ValidateOption) error
Validate is an opt-in check that the response describes a token usable right now: active is true, and the exp/nbf time bounds (when present) place the current time within the token's validity window, allowing for any configured leeway. It returns the first failing condition as ErrTokenInactive, ErrTokenExpired, or ErrTokenNotYetValid, or nil if the token is usable.
Validate is deliberately separate from decoding: the codec is liberal (RFC 7662 leaves the active determination to the server), and a consumer that only needs the active flag can read Active directly without calling Validate.
Example ¶
Validate that a response describes a token usable right now, with a fixed clock for reproducibility.
package main
import (
"fmt"
"time"
introspection "github.com/hstern/go-token-introspection"
)
func main() {
now := time.Unix(1_700_000_000, 0)
resp := introspection.Response{
Active: true,
Expiry: introspection.NewNumericDate(now.Add(-time.Minute)), // expired a minute ago
}
err := resp.Validate(introspection.WithClock(func() time.Time { return now }))
fmt.Println(err)
}
Output: introspection: token has expired
type ValidateOption ¶
type ValidateOption func(*validateConfig)
ValidateOption configures Response.Validate.
func WithClock ¶
func WithClock(clock func() time.Time) ValidateOption
WithClock sets the clock Validate reads the current time from. The default is time.Now. Pass a fixed clock in tests for deterministic results.
func WithLeeway ¶
func WithLeeway(d time.Duration) ValidateOption
WithLeeway allows up to d of clock skew when checking the exp and nbf time bounds. The default is zero.
type ValidationError ¶
type ValidationError struct {
// Field is the parameter or member at fault, e.g. "token".
Field string
// Message explains the problem, lowercase and without trailing punctuation.
Message string
}
ValidationError reports a value that does not satisfy an RFC 7662 wire-shape requirement — a missing required parameter, an unsupported content type, and the like. It names the offending field so a caller can build a precise response.
func (*ValidationError) Error ¶
func (e *ValidationError) Error() string
func (*ValidationError) Unwrap ¶
func (e *ValidationError) Unwrap() error
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
specfixtures
Package specfixtures holds RFC 7662 conformance vectors derived from the spec's example figures and prose MUSTs.
|
Package specfixtures holds RFC 7662 conformance vectors derived from the spec's example figures and prose MUSTs. |