aoa

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 14, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

agentOAuth

Go Reference Go Report Card Go Version License

OAuth 2.1 building blocks for MCP servers, written in Go.

import "github.com/0ndreu/aoa"

Why

The official modelcontextprotocol/go-sdk ships server-side auth (RequireBearerToken, a TokenVerifier interface, and RFC 9728 metadata) but does no token validation itself. The TokenVerifier is a bare callback the SDK hands the raw token: signature verification, alg:none/HS*-confusion defense, JWKS fetch/cache, iss, audience binding, and exp/nbf are all your job. That hand-rolled verifier is exactly where alg-confusion auth-bypass bugs come from.

aoa is that verifier, done fail-closed. Drop it into the SDK's RequireBearerToken seam and get hardened JWT validation and audience binding for free. On top of the mandatory resource-server loop (RFC 9728 metadata + RFC 6750/8707 Bearer), it adds the two RFCs the SDK has no support for at all: DPoP (RFC 9449, sender-constrained tokens) and Token Exchange (RFC 8693, delegation). It complements the SDK rather than replacing it.

Design principles:

  • One dependency. The core depends only on lestrrat-go/jwx (and the Go stdlib). DPoP is hand-rolled on top of it, with no extra crypto packages and no vendoring.
  • No leaked types. jwx is fully hidden behind aoa-owned types (aoa.Claims, KeysJWKS []byte, ClaimValidator func(*Claims)). A future JOSE swap stays an internal, non-breaking change; consumers never import jwx.
  • Fail closed. alg-confusion, none, HS*, expired/nbf, wrong issuer/audience, spoofed kid, and malformed tokens all reject. A cnf.jkt-bound token is never accepted as a plain Bearer.
  • net/http first. Everything is a standard http.Handler / middleware. Framework adapters (chi today) are thin and optional.

How it fits together

The three components form the MCP discovery-and-authorization loop. An unauthenticated request is bounced with a pointer to the metadata, the client discovers its authorization server and gets a token, and the same request succeeds on retry:

sequenceDiagram
    participant C as MCP client
    participant S as aoa-guarded server
    participant AS as Authorization Server

    C->>S: GET /mcp (no token)
    S-->>C: 401 WWW-Authenticate:<br/>Bearer resource_metadata=… (Bearer middleware)
    C->>S: GET /.well-known/oauth-protected-resource
    S-->>C: { authorization_servers, … } (Metadata handler, RFC 9728)
    C->>AS: OAuth 2.1 + PKCE (+ RFC 8707 resource)
    AS-->>C: access token (optionally cnf.jkt-bound for DPoP)
    C->>S: GET /mcp - Authorization: Bearer/DPoP <token> (+ DPoP proof)
    S-->>C: 200 OK (Bearer/DPoP middleware)

A gateway that calls downstream tools on the user's behalf adds a fourth step, Token Exchange (RFC 8693): it mints a downscoped token from the user's token before forwarding the request.

When do I need each piece?

The MCP authorization spec mandates only the first two; the rest are opt-in.

Piece Use it when Required by MCP spec?
Metadata (RFC 9728) Always: it's how clients discover where to authenticate. Yes
Bearer (RFC 6750 + 8707) Always: validate the token on every protected request. Yes
DPoP (RFC 9449) You want sender-constrained tokens, so a stolen token is useless without the client's key. Opt in via the DPoP field. No
Token Exchange (RFC 8693) You run a gateway that must act on a user's behalf downstream with a downscoped token (delegation or impersonation). No
How it compares to the SDK's auth

The official SDK does header extraction and a scope/expiry check, then delegates everything cryptographic to a TokenVerifier you supply. aoa is that verifier, hardened.

official go-sdk auth aoa
Bearer extraction + scope check
RFC 9728 protected-resource metadata
Signature verification ❌ (you write the TokenVerifier)
alg:none / HS*-confusion defense
JWKS fetch + cache, kid lookup
iss / nbf / audience (RFC 8707) validation
DPoP (RFC 9449)
Token Exchange (RFC 8693)

Plug aoa into the SDK's RequireBearerToken rather than replacing it.

Status

RFC What Status
RFC 9728 Protected Resource Metadata Done
RFC 6750 + RFC 8707 Bearer middleware + audience/scopes Done
RFC 9449 DPoP sender-constrained tokens Done
RFC 8693 OAuth 2.0 Token Exchange Done

Install

go get github.com/0ndreu/aoa@latest

Requires Go 1.25+.

Stability: aoa is pre-1.0 (v0.x). The API may change between minor versions until v1.0.0; breaking changes bump the minor version and are noted in release notes. Report security issues via SECURITY.md.

Usage

Serve metadata so clients can discover your authorization server, guard your MCP routes with the Bearer/DPoP middleware, and, if you run a gateway, exchange tokens for downscoped downstream credentials.

1. Protected Resource Metadata (RFC 9728)

Advertise your resource and its authorization server(s) at the well-known endpoint.

package main

import (
    "log"
    "net/http"

    "github.com/0ndreu/aoa"
)

func main() {
    meta := aoa.ProtectedResourceMetadata{
        Resource:             "https://mcp.example.com",
        AuthorizationServers: []string{"https://idp.example.com"},
        ScopesSupported:      []string{"mcp:read", "mcp:write"},
    }
    h, err := aoa.NewMetadataHandler(meta, aoa.HandlerOptions{})
    if err != nil {
        log.Fatal(err)
    }
    path, err := aoa.MetadataPathFor(meta.Resource)
    if err != nil {
        log.Fatal(err)
    }

    mux := http.NewServeMux()
    mux.Handle(path, h)
    log.Fatal(http.ListenAndServe(":8080", mux))
}

The metadata is served at aoa.MetadataPathFor(resource). For a path-less resource this equals aoa.WellKnownSuffix; for a resource with a path (e.g. https://mcp.example.com/api) it appends the path per RFC 9728 §3.1.

Validate() is strict-RFC by default (https-only, no fragment). For dev validation alone, use ValidateWithOptions(aoa.ValidateOptions{AllowInsecureLocalhost: true}).

Handler options:

aoa.HandlerOptions{
    AllowInsecureLocalhost: true,        // dev/test only; http://localhost permitted
    EnableCORS:             true,        // permissive CORS for browser MCP clients
    CacheControl:           "no-store",  // overrides default "public, max-age=3600"
}

See the ProtectedResourceMetadata GoDoc for every RFC 9728 field (JWKSURI, DPoPSigningAlgValuesSupported, etc.). Extension fields go in Extra and are merged into the JSON output without overriding typed fields.

With chi:

import (
    "github.com/go-chi/chi/v5"
    "github.com/0ndreu/aoa"
    chiadapter "github.com/0ndreu/aoa/adapters/chi"
)

r := chi.NewRouter()
chiadapter.Mount(r, aoa.ProtectedResourceMetadata{
    Resource: "https://mcp.example.com",
}, aoa.HandlerOptions{})
2. Bearer middleware (RFC 6750 + RFC 8707)

RequireBearer returns standard net/http middleware. It verifies the JWT signature against your authorization server's JWKS, enforces issuer/audience/scopes, and on failure emits a WWW-Authenticate header carrying a resource_metadata hint that points clients back at the metadata handler above, completing the MCP discovery loop.

guard, err := aoa.RequireBearer(aoa.BearerOpts{
    Resource:       "https://mcp.example.com",
    Issuer:         "https://idp.example.com",
    JWKSURI:        "https://idp.example.com/.well-known/jwks.json",
    RequiredScopes: []string{"mcp:read"}, // ALL must be present
})
if err != nil {
    log.Fatal(err)
}

mux.Handle("/mcp", guard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    claims, _ := aoa.ClaimsFromContext(r.Context())
    fmt.Fprintln(w, "authorized:", claims.Subject)
})))

Verified claims are placed on the request context; retrieve them with aoa.ClaimsFromContext. Decode custom claims with claims.Decode(&myStruct).

Common BearerOpts knobs:

aoa.BearerOpts{
    KeysJWKS:          jwksJSON,            // static JWKS bytes instead of JWKSURI
    Audience:          []string{"..."},     // explicit audience(s); Resource is also accepted as one
    AllowedAlgorithms: []string{"RS256"},   // access-token signature allowlist
    ClockSkew:         30 * time.Second,
    JWKSCacheTTL:      5 * time.Minute,      // remote JWKS refresh interval
    ClaimValidator:    func(c *aoa.Claims) error { /* extra checks */ return nil },
    AuditEmitter:      aoa.LogEmitter(slog.Default()),
}
3. DPoP: sender-constrained tokens (RFC 9449)

DPoP binds an access token to a client-held key, so a stolen token is useless without the corresponding private key. Turn it on with the DPoP field on the same BearerOpts:

guard, err := aoa.RequireBearer(aoa.BearerOpts{
    Resource: "https://mcp.example.com",
    Issuer:   "https://idp.example.com",
    JWKSURI:  "https://idp.example.com/.well-known/jwks.json",

    DPoP: aoa.DPoPRequired, // DPoPOff (default) | DPoPOptional | DPoPRequired
})

The middleware verifies the DPoP proof header, checks it against the token's cnf.jkt binding and the request method/URL (htu/htm) and access-token hash (ath), and rejects replays. The load-bearing rule, and the downgrade defense: a cnf.jkt-bound token is never accepted as plain Bearer, in any mode.

Replay protection. By default jti values are tracked in an in-memory TTL cache, which is correct for a single instance. For multi-instance deployments supply a shared DPoPReplayCache. A Redis reference implementation (SET NX PX) lives in examples/dpop-redis:

aoa.BearerOpts{
    DPoP:       aoa.DPoPRequired,
    DPoPReplay: myRedisReplayCache, // satisfies aoa.DPoPReplayCache
}

Server nonce. To require a server-issued nonce (use_dpop_nonce challenge), supply a DPoPNonceSource. NewDPoPNonceSource(secret) returns a stateless HMAC source that is multi-instance-safe with no shared store:

aoa.BearerOpts{
    DPoP:      aoa.DPoPRequired,
    DPoPNonce: aoa.NewDPoPNonceSource([]byte(os.Getenv("DPOP_NONCE_SECRET"))),
}

Other DPoP options: DPoPSigningAlgs (proof-algorithm allowlist, default ES256/RS256/EdDSA; none and HS* always rejected), DPoPProofMaxAge (default 60s), and TrustForwardedHeaders (derive htu from X-Forwarded-*; enable only behind a trusted proxy). See the end-to-end demo in examples/dpop.

4. Token Exchange (RFC 8693)
Client: minting a downstream token

A gateway holding a user's access token can exchange it for a downscoped token aimed at a specific downstream tool (the agent -> user -> tool delegation flow):

x, err := aoa.NewTokenExchanger(aoa.ExchangeConfig{
    TokenEndpoint: "https://idp.example.com/token", // or set Issuer for RFC 8414 discovery
    ClientAuth:    aoa.ClientSecretAuth("mcp-gateway", secret, true /* post */),
})
if err != nil {
    log.Fatal(err)
}

res, err := x.Exchange(ctx, aoa.ExchangeRequest{
    SubjectToken:     userAccessToken,
    SubjectTokenType: aoa.TokenTypeAccessToken,
    Audience:         []string{"https://tool.example.com"},
    Scope:            []string{"tool:invoke"},
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(res.AccessToken) // downscoped token for the downstream tool

Client authentication is pluggable: ClientSecretAuth(id, secret, post) or PrivateKeyJWTAuth(id, key, alg). The default HTTP client deliberately doesn't follow redirects, so credentials are never replayed to a redirected host.

DPoP-bound exchange. Set DPoPKey to request a sender-constrained downstream token; nonce challenges are handled automatically:

dpopKey, _ := aoa.NewDPoPKey(ecP256PKCS8PEM)
x, _ := aoa.NewTokenExchanger(aoa.ExchangeConfig{
    TokenEndpoint: endpoint,
    ClientAuth:    aoa.ClientSecretAuth("mcp-gateway", secret, true),
    DPoPKey:       dpopKey,
})

See examples/token-exchange and examples/token-exchange-dpop (both run against Keycloak).

Server: validating an exchange request

If you operate the security token service, ExchangeValidator validates an incoming RFC 8693 request and hands you a typed ExchangeGrant to authorize and turn into a token. Set Audience to your STS's own identifier(s) so tokens minted for other resources can't be exchanged here.

v, err := aoa.NewExchangeValidator(aoa.ExchangeValidatorOptions{
    JWKSURI:  "https://idp.example.com/.well-known/jwks.json",
    Issuer:   "https://idp.example.com",
    Audience: []string{"https://sts.example.com"}, // strongly recommended
    Policy:   myExchangePolicy,                    // optional Authorize(ctx, *ExchangeGrant) error
})
if err != nil {
    log.Fatal(err)
}

http.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
    grant, err := v.Validate(r.Context(), r)
    if err != nil {
        // err already carries the RFC 6749/8693 error response
        return
    }

    // grant.Subject / grant.Actor / grant.IsDelegation describe the request.
    // Mint your token here (the Act chain and cnf are pre-computed on the grant),
    // then write the RFC 8693 response:
    _ = aoa.WriteExchangeResponse(w, aoa.IssuedToken{
        AccessToken:     signedToken,
        IssuedTokenType: aoa.TokenTypeAccessToken,
        TokenType:       "Bearer", // or "DPoP"
        ExpiresIn:       5 * time.Minute,
        Scope:           grant.RequestedScope,
    })
})

ExchangeGrant distinguishes delegation (an actor token is present, and Act holds the nested chain) from impersonation (no actor). Confirmation carries the cnf.jkt to embed when binding the new token.

Audit / observability

Every component accepts an Emitter (AuditEmitter on BearerOpts, Audit on the exchange types). It receives typed Events (token_validated, token_rejected, token_exchanged, dpop_verified, dpop_rejected, jti_replay, metadata_served) with no PII in the payload. The default is a no-op.

aoa.BearerOpts{
    AuditEmitter: aoa.LogEmitter(slog.Default()),
}

// or a custom sink:
aoa.BearerOpts{
    AuditEmitter: aoa.FuncEmitter(func(ctx context.Context, e aoa.Event) {
        metrics.Count("auth."+string(e.Kind), e.Outcome)
    }),
}

Examples

Runnable programs under examples/:

Example Shows
metadata-nethttp / metadata-chi RFC 9728 metadata endpoint
bearer-nethttp / bearer-chi Bearer guard + live discovery loop
dpop End-to-end DPoP (proof -> 200, replayed-as-Bearer -> 401)
dpop-redis Multi-instance replay cache reference
token-exchange RFC 8693 client (Keycloak)
token-exchange-dpop DPoP-bound exchange (Keycloak)

Provider compatibility

aoa validates standard OAuth 2.1 / JOSE artifacts, so it works with any spec-compliant authorization server. Concretely:

  • Keycloak 26.2: validated end-to-end by the standalone aoa-conformance suite (RFC 9728 discovery, Bearer/DPoP challenges, and the RFC 8693 exchange against a live server). See its Keycloak pipeline.
  • Auth0 / Okta and other IdPs that publish JWKS without an alg on each key are handled: the algorithm is inferred from the key type, so alg-less JWKS verify correctly instead of rejecting everything.
  • Anything exposing a standard JWKS endpoint (set JWKSURI) or RFC 8414 metadata (set Issuer for discovery) should work; pin the accepted signature algorithms with AllowedAlgorithms for defense in depth.

Security

aoa is built to fail closed. Token verification rejects the classic attacks rather than trusting attacker-controlled input:

  • Algorithm attacks: none, symmetric HS* against an asymmetric key (alg-confusion), and any algorithm outside AllowedAlgorithms are rejected. The signing algorithm is taken from the trusted JWKS, never from the token header alone.
  • Claim validation: expired (exp), not-yet-valid (nbf), wrong iss, and wrong aud (RFC 8707 audience binding) all reject. Spoofed-kid and malformed tokens fail closed.
  • DPoP downgrade defense: a cnf.jkt-bound token is never accepted as a plain Bearer, in any mode. Possession of the bound key is required, so a leaked DPoP-bound token can't be downgraded to bearer use.
  • Replay protection: DPoP jti values are tracked (in-memory by default, pluggable for multi-instance), and proofs are bound to the request method/URL (htm/htu) and access-token hash (ath). An optional stateless HMAC nonce (use_dpop_nonce) adds a server-issued challenge with no shared store.
  • Credential exfiltration: the token-exchange client doesn't follow redirects from the token endpoint, so credentials (subject_token, client_secret, client_assertion) are never replayed to a redirected host. A custom HTTPClient should preserve this with CheckRedirect: http.ErrUseLastResponse.
  • Exchange audience restriction: set ExchangeValidatorOptions.Audience to your STS's own identifier(s) so a token minted for another resource cannot be exchanged at your endpoint (RFC 9700).

These properties are covered by the test suite, including adversarial cases.

⚠️ aoa is pre-release and has not yet had an external security audit. Review it for your own threat model before production use.

What's tested
  • Validation matrix (unit): alg-confusion (none, HS* against an asymmetric key), exp/nbf/iss/aud, spoofed kid, malformed tokens, DPoP proof + cnf.jkt binding, jti replay, and the RFC 8693 grant / may_act logic, including adversarial cases.
  • Fuzzing: the four highest-risk components (the JWT header reader, the DPoP proof parser, the replay cache, and the token-exchange request parser) have Go native fuzz tests (Fuzz*). Their seed corpora run on every go test.
  • Integration / conformance: validated end-to-end against live Keycloak 26.2 by the standalone aoa-conformance suite (RFC 9728 discovery, Bearer/DPoP challenges, RFC 8693 exchange, agent-loop). The client/server exchange logic is additionally unit-tested with httptest mocks.
Reporting a vulnerability

Please report security issues privately: see SECURITY.md. Do not open a public issue for a vulnerability.

Implemented

  • RFC 9728 Protected Resource Metadata
  • RFC 6750 Bearer middleware + RFC 8707 audience + scopes; WWW-Authenticate PRM discovery; net/http + chi examples
  • RFC 9449 DPoP: sender-constrained tokens; proof verification + cnf.jkt binding; jti replay cache (pluggable) + stateless HMAC nonce
  • RFC 8693 OAuth 2.0 Token Exchange: client TokenExchanger + server-side ExchangeValidator

Contributing

Issues and PRs welcome. Run the suite locally:

go test -race ./...        # unit tests + fuzz seed corpora
golangci-lint run ./...    # lint

Live Keycloak conformance testing lives in the separate aoa-conformance repo. For security issues, follow SECURITY.md (private disclosure) rather than opening a public issue.

License

Apache-2.0. See LICENSE.

Documentation

Index

Constants

View Source
const WellKnownSuffix = "/.well-known/oauth-protected-resource"

WellKnownSuffix is the RFC 9728 par.3.1 well-known URI path suffix.

Variables

This section is empty.

Functions

func MetadataPathFor

func MetadataPathFor(resource string) (string, error)

MetadataPathFor returns the RFC 9728 par.3.1 path for a resource URI. The resource's path component (if any) is appended to WellKnownSuffix. resource must be an absolute URI.

func NewMetadataHandler

func NewMetadataHandler(meta ProtectedResourceMetadata, opts HandlerOptions) (http.Handler, error)

NewMetadataHandler returns an http.Handler serving the RFC 9728 Protected Resource Metadata document. Supports GET and HEAD; validates meta before constructing the handler.

func RequireBearer

func RequireBearer(opts BearerOpts) (func(http.Handler) http.Handler, error)

RequireBearer returns net/http middleware that validates OAuth 2.0 Bearer tokens. It returns an error if opts are invalid.

func WriteExchangeError

func WriteExchangeError(w http.ResponseWriter, err error)

WriteExchangeError writes a token-endpoint error JSON response. If err is an *ExchangeError its code/status are used; any other error becomes a generic 500 server_error (never leaking internals).

func WriteExchangeResponse

func WriteExchangeResponse(w http.ResponseWriter, t IssuedToken) error

WriteExchangeResponse writes the RFC 8693 token-exchange JSON response.

Types

type BearerOpts

type BearerOpts struct {
	JWKSURI  string
	KeysJWKS []byte // raw JWKS JSON (static keys); parsed internally
	Issuer   string
	Audience []string

	Resource string // also accepted as audience; drives resource_metadata hint in WWW-Authenticate
	Realm    string // WWW-Authenticate realm; defaults to Resource, else "MCP"

	RequiredScopes    []string
	ClockSkew         time.Duration
	JWKSCacheTTL      time.Duration // remote JWKS refresh interval; 0 = 5m default
	AllowedAlgorithms []string
	DPoP              DPoPMode
	ClaimValidator    func(*Claims) error
	ErrorHandler      func(http.ResponseWriter, *http.Request, error)
	Logger            *slog.Logger
	AuditEmitter      Emitter
	TokenLookup       TokenLookup

	// DPoPSigningAlgs is the asymmetric-algorithm allowlist for DPoP *proofs*
	// (separate from AllowedAlgorithms, which governs access-token signatures).
	// Default: ["ES256", "RS256", "EdDSA"]. "none" and symmetric "HS*" always rejected.
	DPoPSigningAlgs []string

	// DPoPProofMaxAge bounds how old a proof's iat may be. Default 60s.
	// Future-dated proofs are tolerated up to ClockSkew.
	DPoPProofMaxAge time.Duration

	// DPoPReplay is the jti replay cache. Default: an in-memory TTL cache.
	// Supply a distributed implementation (see examples/dpop-redis) for
	// multi-instance deployments.
	DPoPReplay DPoPReplayCache

	// DPoPNonce, when non-nil, enables the server-nonce challenge.
	// Default nil (no nonce). NewDPoPNonceSource(secret) returns a stateless
	// HMAC source that is multi-instance-safe with no shared store.
	DPoPNonce DPoPNonceSource

	// TrustForwardedHeaders derives htu from X-Forwarded-Proto/-Host instead of
	// the request's own scheme+host. Default false. Enable only behind a trusted
	// proxy that sets these headers (otherwise they are client-spoofable).
	TrustForwardedHeaders bool
}

BearerOpts configures RequireBearer

type Claims

type Claims struct {
	Subject   string
	Issuer    string
	Audience  []string
	Expiry    time.Time
	IssuedAt  time.Time
	NotBefore time.Time
	Scope     []string // parsed from the space-delimited "scope" claim
	// contains filtered or unexported fields
}

Claims holds the verified claims from an access token. Standard registered claims are fields; use Decode to access non-registered claims.

func ClaimsFromContext

func ClaimsFromContext(ctx context.Context) (*Claims, bool)

ClaimsFromContext returns the claims stored by RequireBearer, or false if the request has no validated token.

func (*Claims) Decode

func (c *Claims) Decode(v any) error

Decode unmarshals the full token payload into v, giving access to non-registered claims. v must be a pointer to a struct or map.

type ClientAuth

type ClientAuth interface {
	// contains filtered or unexported methods
}

ClientAuth attaches client credentials to an outgoing token request. It is implemented by ClientSecretAuth and PrivateKeyJWTAuth; mTLS needs no implementation (configure it on ExchangeConfig.HTTPClient's transport).

func ClientSecretAuth

func ClientSecretAuth(clientID, secret string, post bool) ClientAuth

ClientSecretAuth authenticates with a client secret. post=false uses HTTP Basic (client_secret_basic); post=true uses form fields (client_secret_post).

func PrivateKeyJWTAuth

func PrivateKeyJWTAuth(clientID string, key []byte, alg string) (ClientAuth, error)

PrivateKeyJWTAuth authenticates with a signed JWT assertion (RFC 7523 / private_key_jwt). key is a PEM- or JWK-encoded asymmetric private key; alg is its signature algorithm (e.g. "ES256", "RS256", "EdDSA"). Symmetric algs and "none" are rejected.

type DPoPKey

type DPoPKey struct {
	// contains filtered or unexported fields
}

DPoPKey is a client-held asymmetric key pair used to mint DPoP proofs when exchanging tokens (RFC 9449 par.5). Construct it with NewDPoPKey. jwx is hidden: the caller supplies key bytes and never touches a jwx type.

invariant: all fields are set together on success; private==nil iff zero value (see isZero).

func NewDPoPKey

func NewDPoPKey(key []byte) (DPoPKey, error)

NewDPoPKey wraps a PEM- or JWK-encoded asymmetric private key for DPoP proof signing. Symmetric keys and unparseable input are rejected.

type DPoPMode

type DPoPMode int

DPoPMode controls DPoP enforcement (RFC 9449)

Regardless of mode, a sender-constrained access token (one carrying cnf.jkt) is never accepted as a plain Bearer token. Presenting one as Bearer always yields 401. The modes differ only in how they treat un-bound tokens:

  • DPoPOff: DPoP proofs are not processed; un-bound Bearer tokens are accepted. The DPoP scheme is unrecognized.
  • DPoPOptional: un-bound Bearer tokens are still accepted (migration mode); bound tokens require a verified DPoP proof.
  • DPoPRequired: all plain Bearer is rejected; every token must be presented as DPoP with a verified, cnf.jkt-bound proof.
const (
	DPoPOff DPoPMode = iota
	DPoPOptional
	DPoPRequired
)

type DPoPNonceSource

type DPoPNonceSource interface {
	Current(ctx context.Context, r *http.Request) string
	Valid(ctx context.Context, r *http.Request, nonce string) bool
}

DPoPNonceSource issues and validates server-chosen DPoP nonces (RFC 9449 par.8). Current returns the nonce to advertise in a DPoP-Nonce response header; Valid reports whether a nonce presented in a proof is acceptable. Implementations MUST be safe for concurrent use.

func NewDPoPNonceSource

func NewDPoPNonceSource(secret []byte) DPoPNonceSource

NewDPoPNonceSource returns a stateless HMAC-based DPoPNonceSource keyed by secret. The nonce is an HMAC over a rotating time window, so it requires no shared storage and is valid across every RS instance that shares secret. The current window and the immediately preceding one are accepted, tolerating clock skew and rotation at the boundary.

type DPoPReplayCache

type DPoPReplayCache interface {
	Seen(ctx context.Context, jti string, exp time.Time) (bool, error)
}

DPoPReplayCache rejects replayed DPoP proofs by their jti. Implementations MUST be safe for concurrent use and MUST perform an atomic check-and-record: Seen returns (true, nil) if jti was already recorded and unexpired (a replay); otherwise it records jti with the given expiry and returns (false, nil). A non-nil error signals a backend failure; the middleware fails closed (rejects the request) in that case.

func InMemoryReplayCache

func InMemoryReplayCache() DPoPReplayCache

InMemoryReplayCache returns the default jti replay cache: a concurrency-safe TTL map. Single-instance only - for multi-instance deployments supply a distributed implementation (see examples/dpop-redis).

type Emitter

type Emitter interface {
	Emit(ctx context.Context, e Event)
}

Emitter receives audit events. Implementations must be non-blocking and safe for concurrent use; Emit is called on the hot request path.

func LogEmitter

func LogEmitter(logger *slog.Logger) Emitter

LogEmitter returns an Emitter that writes each event to logger at Info level. A nil logger uses slog.Default().

type Event

type Event struct {
	Timestamp time.Time      `json:"timestamp"`
	Kind      EventKind      `json:"kind"`
	Outcome   Outcome        `json:"outcome"`
	Subject   string         `json:"subject,omitempty"`
	Issuer    string         `json:"issuer,omitempty"`
	Audience  []string       `json:"audience,omitempty"`
	Scope     []string       `json:"scope,omitempty"`
	Resource  string         `json:"resource,omitempty"`
	Reason    string         `json:"reason,omitempty"`
	Error     string         `json:"error,omitempty"`
	HTTP      HTTPContext    `json:"http,omitempty"`
	Extra     map[string]any `json:"extra,omitempty"`
}

Event is an audit record for a token-validation outcome.

type EventKind

type EventKind string

EventKind identifies the kind of audit event. Consumers MUST tolerate unknown values (treat as informational); new kinds may be added in minor versions.

const (
	EventTokenValidated EventKind = "token_validated"
	EventTokenRejected  EventKind = "token_rejected"
	EventTokenExchanged EventKind = "token_exchanged" // RFC 8693
	EventDPoPVerified   EventKind = "dpop_verified"
	EventDPoPRejected   EventKind = "dpop_rejected"
	EventMetadataServed EventKind = "metadata_served" // RFC 9728
	EventJTIReplay      EventKind = "jti_replay"
)

type ExchangeConfig

type ExchangeConfig struct {
	TokenEndpoint string     // explicit AS token endpoint; OR
	Issuer        string     // RFC 8414 discovery to resolve the token endpoint
	ClientAuth    ClientAuth // required; how the client authenticates
	// HTTPClient is an optional HTTP client for mTLS, timeouts, and proxy
	// configuration. The default client does NOT follow redirects from the token
	// endpoint: credentials (subject_token, client_secret, client_assertion) must
	// not be replayed to a redirected host. A custom
	// client SHOULD set CheckRedirect to return http.ErrUseLastResponse to
	// preserve this security property.
	HTTPClient *http.Client
	DPoPKey    DPoPKey // optional; zero value ⇒ no DPoP
	Audit      Emitter // optional; default no-op
}

ExchangeConfig configures a TokenExchanger.

type ExchangeError

type ExchangeError struct {
	Code        string // e.g. invalid_request, invalid_grant, invalid_target
	Description string
	URI         string
	HTTPStatus  int // status observed (client) or to write (server); 0 ⇒ derive from Code
}

ExchangeError is an RFC 6749 par.5.2 / RFC 8693 token-endpoint error. The client returns it from Exchange on a non-2xx response; the server side renders it via WriteExchangeError.

func (*ExchangeError) Error

func (e *ExchangeError) Error() string

type ExchangeGrant

type ExchangeGrant struct {
	Subject      *Claims
	Actor        *Claims // nil ⇒ impersonation
	IsDelegation bool

	RequestedAudience  []string
	RequestedScope     []string
	RequestedResource  []string
	RequestedTokenType TokenType

	Act          map[string]any // nested delegation chain to embed; nil for impersonation
	Confirmation string         // cnf.jkt to embed if binding the new token
}

ExchangeGrant is the verified, authorized result of an inbound token-exchange request. The consumer signs a token from it (aoa does not sign).

type ExchangePolicy

type ExchangePolicy interface {
	Authorize(ctx context.Context, g *ExchangeGrant) error
}

ExchangePolicy approves or narrows the requested downscope. Returning an error rejects the exchange; return an *ExchangeError to control the code (default invalid_target).

type ExchangeRequest

type ExchangeRequest struct {
	SubjectToken       string
	SubjectTokenType   TokenType // default access_token
	ActorToken         string    // optional ⇒ delegation
	ActorTokenType     TokenType // required iff ActorToken set
	Resource           []string
	Audience           []string
	Scope              []string
	RequestedTokenType TokenType
}

ExchangeRequest is one RFC 8693 token-exchange request.

type ExchangeResult

type ExchangeResult struct {
	AccessToken     string
	IssuedTokenType TokenType
	TokenType       string // "Bearer" | "DPoP" | "N_A"
	ExpiresIn       time.Duration
	Scope           []string
	RefreshToken    string
	// contains filtered or unexported fields
}

ExchangeResult is the parsed RFC 8693 token-exchange response.

func (*ExchangeResult) Decode

func (r *ExchangeResult) Decode(v any) error

Decode unmarshals the full token-endpoint response JSON into v for access to non-standard fields.

type ExchangeValidator

type ExchangeValidator struct {
	// contains filtered or unexported fields
}

ExchangeValidator verifies inbound RFC 8693 requests for a resource/gateway that mints its own tokens.

func NewExchangeValidator

func NewExchangeValidator(opts ExchangeValidatorOptions) (*ExchangeValidator, error)

NewExchangeValidator validates opts and returns a validator.

func (*ExchangeValidator) Validate

func (v *ExchangeValidator) Validate(ctx context.Context, r *http.Request) (*ExchangeGrant, error)

Validate parses and authorizes the request, returning the grant to sign.

type ExchangeValidatorOptions

type ExchangeValidatorOptions struct {
	KeysJWKS          []byte
	JWKSURI           string
	Issuer            string
	JWKSCacheTTL      time.Duration
	AllowedAlgorithms []string
	ClockSkew         time.Duration
	Policy            ExchangePolicy
	Audit             Emitter

	// Audience, when non-empty, is the set of acceptable audiences for the
	// INBOUND subject/actor tokens (this STS's own identifier(s)). A token whose
	// aud does not intersect Audience is rejected (RFC 9700 audience restriction;
	// prevents a token minted for another resource from being exchanged here).
	// When empty, tokens for ANY audience from the trusted issuer are accepted, a
	// deployment risk the consumer MUST mitigate (e.g. via Policy). Strongly
	// recommended to set this.
	Audience []string
}

ExchangeValidatorOptions configures an ExchangeValidator.

type FuncEmitter

type FuncEmitter func(ctx context.Context, e Event)

FuncEmitter lets you pass a closure as an Emitter without defining a type.

func (FuncEmitter) Emit

func (f FuncEmitter) Emit(ctx context.Context, e Event)

type HTTPContext

type HTTPContext struct {
	Method     string `json:"method,omitempty"`
	Path       string `json:"path,omitempty"`
	RemoteAddr string `json:"remote_addr,omitempty"`
	UserAgent  string `json:"user_agent,omitempty"`
}

HTTPContext holds request metadata attached to an Event.

type HandlerOptions

type HandlerOptions struct {
	// AllowInsecureLocalhost mirrors ValidateOptions.AllowInsecureLocalhost.
	AllowInsecureLocalhost bool

	// CacheControl overrides the default Cache-Control header ("public, max-age=3600").
	CacheControl string

	// EnableCORS enables permissive CORS headers on the discovery endpoint.
	EnableCORS bool
}

HandlerOptions configures NewMetadataHandler. The zero value is strict-RFC.

type IssuedToken

type IssuedToken struct {
	AccessToken     string
	IssuedTokenType TokenType
	TokenType       string // "Bearer" | "DPoP" | "N_A"
	ExpiresIn       time.Duration
	Scope           []string
	RefreshToken    string
}

IssuedToken is a token the consumer signed from an ExchangeGrant, to be written as the RFC 8693 response. aoa does not sign; the caller provides AccessToken.

type Outcome

type Outcome string

Outcome is the result of a validation attempt.

const (
	OutcomeAllow Outcome = "allow"
	OutcomeDeny  Outcome = "deny"
	OutcomeError Outcome = "error" // infrastructure failure, e.g. JWKS unreachable
)

type ProtectedResourceMetadata

type ProtectedResourceMetadata struct {
	Resource string `json:"resource"`

	AuthorizationServers               []string `json:"authorization_servers,omitempty"`
	JWKSURI                            string   `json:"jwks_uri,omitempty"`
	ScopesSupported                    []string `json:"scopes_supported,omitempty"`
	BearerMethodsSupported             []string `json:"bearer_methods_supported,omitempty"`
	ResourceSigningAlgValuesSupported  []string `json:"resource_signing_alg_values_supported,omitempty"`
	ResourceName                       string   `json:"resource_name,omitempty"`
	ResourceDocumentation              string   `json:"resource_documentation,omitempty"`
	ResourcePolicyURI                  string   `json:"resource_policy_uri,omitempty"`
	ResourceTOSURI                     string   `json:"resource_tos_uri,omitempty"`
	TLSClientCertBoundAccessTokens     bool     `json:"tls_client_certificate_bound_access_tokens,omitempty"`
	AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"`
	DPoPSigningAlgValuesSupported      []string `json:"dpop_signing_alg_values_supported,omitempty"`
	DPoPBoundAccessTokensRequired      bool     `json:"dpop_bound_access_tokens_required,omitempty"`
	SignedMetadata                     string   `json:"signed_metadata,omitempty"`

	// Extra holds non-standard metadata values merged into the JSON output.
	// Typed fields take precedence over same-key entries in Extra.
	Extra map[string]any `json:"-"`
}

ProtectedResourceMetadata is the RFC 9728 Protected Resource Metadata document. Resource is required; all other fields are optional.

func (ProtectedResourceMetadata) MarshalJSON

func (m ProtectedResourceMetadata) MarshalJSON() ([]byte, error)

MarshalJSON merges Extra into the typed JSON output. Typed fields win on collision.

func (ProtectedResourceMetadata) Validate

func (m ProtectedResourceMetadata) Validate() error

Validate checks the document against RFC 9728 par.1.2 / par.2. For localhost dev environments, use ValidateWithOptions(ValidateOptions{AllowInsecureLocalhost: true}).

func (ProtectedResourceMetadata) ValidateWithOptions

func (m ProtectedResourceMetadata) ValidateWithOptions(opts ValidateOptions) error

type TokenExchanger

type TokenExchanger struct {
	// contains filtered or unexported fields
}

TokenExchanger performs RFC 8693 token exchange against an AS token endpoint.

func NewTokenExchanger

func NewTokenExchanger(cfg ExchangeConfig) (*TokenExchanger, error)

NewTokenExchanger validates cfg and returns an exchanger.

func (*TokenExchanger) Exchange

Exchange runs one token exchange and returns the issued token.

type TokenLookup

type TokenLookup int

TokenLookup selects where the bearer token is read from.

const (
	HeaderBearer TokenLookup = iota
)

type TokenType

type TokenType string

TokenType is an RFC 8693 par.3 token-type URI used for subject_token_type, actor_token_type, requested_token_type, and the response's issued_token_type.

const (
	TokenTypeAccessToken  TokenType = "urn:ietf:params:oauth:token-type:access_token"
	TokenTypeRefreshToken TokenType = "urn:ietf:params:oauth:token-type:refresh_token"
	TokenTypeIDToken      TokenType = "urn:ietf:params:oauth:token-type:id_token"
	TokenTypeJWT          TokenType = "urn:ietf:params:oauth:token-type:jwt"
	TokenTypeSAML1        TokenType = "urn:ietf:params:oauth:token-type:saml1"
	TokenTypeSAML2        TokenType = "urn:ietf:params:oauth:token-type:saml2"
)

type ValidateOptions

type ValidateOptions struct {
	// AllowInsecureLocalhost permits http:// for localhost. Dev/test only.
	AllowInsecureLocalhost bool
}

ValidateOptions controls Validate behaviour. The zero value is strict-RFC.

Directories

Path Synopsis
internal
jwktest
Package jwktest provides test-only helpers for generating keys, signing JWTs, and serving a JWKS over httptest.
Package jwktest provides test-only helpers for generating keys, signing JWTs, and serving a JWKS over httptest.

Jump to

Keyboard shortcuts

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