# Protect a Go API with DPoP-bound access tokens

> Protect a Go REST API with DPoP-bound access tokens from Nauthera, validated locally with jwx, and register its callers as private_key_jwt OIDCClient resources.

This guide protects a Go REST API with DPoP-bound access tokens from Nauthera and
registers the Go services that call it. You need a Nauthera issuer with its
[operator](https://www.nauthera.io/docs/v1/operator) running in the cluster, and Go 1.25 or later. At the
end, the API accepts only tokens bound to a key its caller holds, and each caller
authenticates with a private key instead of a secret. The main limit: the API refuses
bearer tokens, so every caller must be able to request DPoP-bound tokens.

A Go API validates Nauthera access tokens locally: it reads the issuer's discovery
document once, keeps the issuer's JWKS cached, and checks each token's signature and
claims in process. This guide sets it up as strictly as Nauthera allows:

- Every caller authenticates with a private key (`private_key_jwt`, RFC 7523). Nauthera
  holds no secret that could stand in for it.
- Every access token is bound to a second key the caller holds (DPoP, RFC 9449). The API
  refuses a token without that binding, a request without a valid proof, and any token
  sent as `Bearer`. A stolen token is useless without the caller's DPoP key.

It uses [jwx](https://github.com/lestrrat-go/jwx) v3 on both sides, because it covers
every piece this needs (JWKS caching, header checks, the proof's embedded key with its
curve check, RFC 7638 thumbprints, and signing), where the dedicated go-dpop library has
no algorithm allowlist or replay protection.

The API is named `https://orders.example.com/`, serves `/orders`, and expects the scopes
`orders:read` and `orders:write`. jwx v3 needs Go 1.25 or later.

## 1. Register the callers

The API itself needs no client. Each service that calls it does, and holds two keys:

| Key | Proves | Lives |
|---|---|---|
| Assertion key | Who the caller is, at the token endpoint | A Kubernetes Secret you create. Nauthera fetches its public half from `jwksUri`. |
| DPoP key | That the caller sent the token | Generated in memory at startup, never stored |

Keeping them apart means a leaked DPoP key cannot get new tokens, and rotating the
assertion key leaves running tokens alone.

Generate the assertion key and store it as a Secret:

```bash
openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out key.pem
kubectl -n billing create secret generic billing-worker-assertion-key --from-file=key.pem
rm key.pem
```

The caller serves the public half on port `8081` ([step 7](#7-call-it)). Nauthera
fetches it itself, so `jwksUri` must be `https` on a public DNS name: it refuses
loopback, private and link-local addresses and does not follow redirects. A cluster-local
Service URL does not work. Expose that one path through your ingress:

```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: billing-worker-jwks
  namespace: billing
spec:
  tls:
    - hosts: [billing-worker.example.com]
      secretName: billing-worker-tls
  rules:
    - host: billing-worker.example.com
      http:
        paths:
          - path: /.well-known/jwks.json
            pathType: Exact
            backend:
              service: { name: billing-worker, port: { number: 8081 } }
```

Then register the client:

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: billing-worker
  namespace: billing      # a namespace the organization admits
spec:
  clientId: billing-worker
  displayName: Billing worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
    - orders:write
  tokenEndpointAuthMethod: private_key_jwt
  tokenEndpointAuthSigningAlg: ES256
  jwksUri: https://billing-worker.example.com/.well-known/jwks.json
  accessTokenTtlSeconds: 300
```

```bash
kubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker   # PHASE Ready, reason Registered
```

There is no `secret` block: the `OIDCClient` schema refuses a generated secret for
`private_key_jwt` when the manifest is applied, so the operator writes no Secret. The
Deployment names the issuer and client itself and mounts the key:

```yaml
# in the container
env:
  - { name: NAUTHERA_ISSUER, value: https://auth.example.com }
  - { name: NAUTHERA_CLIENT_ID, value: billing-worker }
  - { name: NAUTHERA_ASSERTION_KEY, value: /var/run/secrets/nauthera/key.pem }
  - { name: ORDERS_API, value: https://orders.example.com/ }
volumeMounts:
  - { name: assertion-key, mountPath: /var/run/secrets/nauthera, readOnly: true }
# in the pod
volumes:
  - name: assertion-key
    secret: { secretName: billing-worker-assertion-key }
```

To rotate the assertion key, serve the new public key next to the old one (add it to the
set `JWKSHandler` builds) and follow
[Keys instead of secrets](https://www.nauthera.io/docs/v1/guides#keys-instead-of-secrets).

A web app whose users call the API registers as in the
[Better Auth guide](https://www.nauthera.io/docs/v1/guides/better-auth), with the API's scopes in its
`spec.scopes` and `resource=https://orders.example.com/` on its authorization request. It
must also send a DPoP proof to `/token` and with every API call, or this API refuses its
tokens.

## 2. Add the dependencies

```bash
go get github.com/lestrrat-go/jwx/v3 github.com/lestrrat-go/httprc/v3
```

## 3. Write the token verifier

```go
// Package auth admits only DPoP-bound Nauthera access tokens (RFC 9068, RFC 9449).
package auth

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"slices"
	"strings"
	"time"

	"github.com/lestrrat-go/httprc/v3"
	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jws"
	"github.com/lestrrat-go/jwx/v3/jwt"
)

// Claims are the access token claims this API reads.
type Claims struct {
	Subject  string
	ClientID string
	Scopes   []string
	JKT      string // cnf.jkt: the thumbprint of the caller's DPoP key
}

type confirmation struct {
	JKT string `json:"jkt"`
}

type Verifier struct {
	issuer   string
	audience string
	keys     jwk.Set
	replay   *replayCache
}

// NewVerifier reads the issuer's discovery document and starts a JWKS cache
// that refreshes in the background until ctx is cancelled.
func NewVerifier(ctx context.Context, issuer, audience string) (*Verifier, error) {
	jwksURI, err := discoverJWKS(ctx, issuer)
	if err != nil {
		return nil, err
	}
	cache, err := jwk.NewCache(ctx, httprc.NewClient())
	if err != nil {
		return nil, err
	}
	if err := cache.Register(ctx, jwksURI, jwk.WithMaxInterval(5*time.Minute)); err != nil {
		return nil, fmt.Errorf("load jwks: %w", err)
	}
	keys, err := cache.CachedSet(jwksURI)
	if err != nil {
		return nil, err
	}
	return &Verifier{issuer: issuer, audience: audience, keys: keys, replay: newReplayCache()}, nil
}

// verifyToken checks a raw access token and returns its claims.
func (v *Verifier) verifyToken(raw string) (*Claims, error) {
	tok, err := jwt.ParseString(raw,
		jwt.WithKeyProvider(jws.KeyProviderFunc(v.accessTokenKey)),
		jwt.WithIssuer(v.issuer),
		jwt.WithAudience(v.audience),
		jwt.WithRequiredClaim(jwt.ExpirationKey),
		jwt.WithAcceptableSkew(5*time.Second),
		jwt.WithTypedClaim("cnf", confirmation{}),
	)
	if err != nil {
		return nil, err
	}
	var c Claims
	var scope string
	var cnf confirmation
	c.Subject, _ = tok.Subject()
	_ = tok.Get("client_id", &c.ClientID)
	_ = tok.Get("scope", &scope)
	_ = tok.Get("cnf", &cnf)
	if cnf.JKT == "" {
		return nil, errors.New("token is not DPoP-bound")
	}
	c.Scopes, c.JKT = strings.Fields(scope), cnf.JKT
	return &c, nil
}

// accessTokenKey hands the parser the issuer key named by kid, and only for an
// ES256 token typed at+jwt.
func (v *Verifier) accessTokenKey(_ context.Context, sink jws.KeySink, sig *jws.Signature, _ *jws.Message) error {
	h := sig.ProtectedHeaders()
	// An ID token is signed by the same keys; only typ tells them apart.
	if typ, _ := h.Type(); typ != "at+jwt" {
		return errors.New("not an access token")
	}
	if alg, _ := h.Algorithm(); alg != jwa.ES256() {
		return errors.New("algorithm not allowed")
	}
	kid, _ := h.KeyID()
	key, ok := v.keys.LookupKeyID(kid)
	if !ok {
		return errors.New("unknown key id")
	}
	sink.Key(jwa.ES256(), key)
	return nil
}

func (c *Claims) HasScope(scope string) bool { return slices.Contains(c.Scopes, scope) }

func discoverJWKS(ctx context.Context, issuer string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		issuer+"/.well-known/openid-configuration", nil)
	if err != nil {
		return "", err
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("fetch discovery: %w", err)
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return "", fmt.Errorf("fetch discovery: %s", res.Status)
	}
	var doc struct {
		Issuer  string `json:"issuer"`
		JWKSURI string `json:"jwks_uri"`
	}
	if err := json.NewDecoder(res.Body).Decode(&doc); err != nil {
		return "", fmt.Errorf("decode discovery: %w", err)
	}
	if doc.Issuer != issuer {
		return "", fmt.Errorf("discovery names issuer %q, want %q", doc.Issuer, issuer)
	}
	return doc.JWKSURI, nil
}
```

What it checks:

| Check | Where |
|---|---|
| Header `typ` is `at+jwt` | `accessTokenKey` |
| `alg` is ES256, signed by the issuer key `kid` names | `accessTokenKey` |
| `iss` is the issuer, `aud` contains this API | `WithIssuer`, `WithAudience` |
| `exp` is present; `exp`, `nbf` and `iat` hold within 5 seconds | `WithRequiredClaim`, `WithAcceptableSkew` |
| The token is DPoP-bound (`cnf.jkt`) | `verifyToken` |

Nauthera signs access tokens with `keys.default_alg`, which is ES256 unless the server
holds only an RSA key or is set to `RS256`. Pin the algorithm the server uses. The JWKS
cache refreshes at least every five minutes. Nauthera's
[rotation procedure](https://www.nauthera.io/docs/v1/keys-and-secrets#rotating-the-main-orgs-signing-key)
publishes a new key at least eleven minutes before signing with it, so the API needs no
restart.

## 4. Verify the DPoP proof

```go
package auth

import (
	"context"
	"crypto"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"maps"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jws"
	"github.com/lestrrat-go/jwx/v3/jwt"
)

// A proof is accepted from 60 seconds before now to 5 seconds after, the
// window nauthera-server applies at its own endpoints.
const (
	proofMaxAge    = 60 * time.Second
	proofMaxFuture = 5 * time.Second
)

// verifyProof checks the request's DPoP proof (RFC 9449 §4.3) against the
// access token it accompanies and the key that token is bound to.
func (v *Verifier) verifyProof(r *http.Request, accessToken, jkt string) error {
	proofs := r.Header.Values("DPoP")
	if len(proofs) != 1 {
		return errors.New("want exactly one DPoP header")
	}
	var thumbprint string
	proof, err := jwt.ParseString(proofs[0], jwt.WithValidate(false),
		jwt.WithKeyProvider(jws.KeyProviderFunc(
			func(_ context.Context, sink jws.KeySink, sig *jws.Signature, _ *jws.Message) error {
				h := sig.ProtectedHeaders()
				typ, _ := h.Type()
				alg, _ := h.Algorithm()
				key, ok := h.JWK()
				if typ != "dpop+jwt" || alg != jwa.ES256() || !ok {
					return errors.New("not an ES256 DPoP proof")
				}
				// A jwx private key also satisfies ECDSAPublicKey, so it is
				// refused by IsPrivateKey, not by the type.
				ec, ok := key.(jwk.ECDSAPublicKey)
				if !ok {
					return errors.New("proof key is not an EC key")
				}
				crv, _ := ec.Crv()
				if private, _ := jwk.IsPrivateKey(key); private || crv != jwa.P256() {
					return errors.New("proof key is not a public P-256 key")
				}
				tp, err := key.Thumbprint(crypto.SHA256)
				if err != nil {
					return err
				}
				thumbprint = base64.RawURLEncoding.EncodeToString(tp)
				sink.Key(jwa.ES256(), key)
				return nil
			})))
	if err != nil {
		return err
	}

	var htm, htu, ath string
	_ = proof.Get("htm", &htm)
	_ = proof.Get("htu", &htu)
	_ = proof.Get("ath", &ath)
	jti, _ := proof.JwtID()
	iat, _ := proof.IssuedAt()
	sum := sha256.Sum256([]byte(accessToken))
	now := time.Now()
	switch {
	case subtle.ConstantTimeCompare([]byte(thumbprint), []byte(jkt)) != 1:
		return errors.New("proof key is not the key the token is bound to")
	case ath != base64.RawURLEncoding.EncodeToString(sum[:]):
		return errors.New("ath does not match the access token")
	case htm != r.Method:
		return errors.New("htm does not match the request method")
	case htu == "" || normalizeHTU(htu) != normalizeHTU(v.audience+strings.TrimPrefix(r.URL.EscapedPath(), "/")):
		return errors.New("htu does not match the request URL")
	case iat.Before(now.Add(-proofMaxAge)) || iat.After(now.Add(proofMaxFuture)):
		return errors.New("iat is outside the acceptance window")
	case jti == "":
		return errors.New("jti is missing")
	}
	// Last, so a proof that fails any other check cannot use up a jti.
	if !v.replay.claim(jkt+" "+jti, proofMaxAge+proofMaxFuture) {
		return errors.New("proof replayed")
	}
	return nil
}

// normalizeHTU applies RFC 9449 §4.3 step 9: scheme and host in lower case, the
// default port dropped, query and fragment ignored, an empty path written as /.
func normalizeHTU(raw string) string {
	u, err := url.Parse(raw)
	if err != nil || !u.IsAbs() || u.Host == "" {
		return ""
	}
	host := strings.TrimSuffix(strings.ToLower(u.Host), map[string]string{"https": ":443", "http": ":80"}[u.Scheme])
	path := u.EscapedPath()
	if path == "" {
		path = "/"
	}
	return u.Scheme + "://" + host + path
}

// replayCache remembers each proof's jti for as long as the proof could still
// be accepted.
type replayCache struct {
	mu      sync.Mutex
	seen    map[string]time.Time
	sweepAt time.Time
}

func newReplayCache() *replayCache { return &replayCache{seen: map[string]time.Time{}} }

// claim records key until ttl from now and reports whether it was unused.
func (c *replayCache) claim(key string, ttl time.Duration) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	now := time.Now()
	if now.After(c.sweepAt) {
		maps.DeleteFunc(c.seen, func(_ string, until time.Time) bool { return now.After(until) })
		c.sweepAt = now.Add(ttl)
	}
	if until, ok := c.seen[key]; ok && now.Before(until) {
		return false
	}
	c.seen[key] = now.Add(ttl)
	return true
}
```

What it checks (RFC 9449 §4.3):

| Check | Refused when |
|---|---|
| One `DPoP` header | there are none or several |
| Header `typ` is `dpop+jwt`, `alg` is ES256 | another type or algorithm, including `none` and `HS256` |
| Header `jwk` is a public P-256 key, and the signature verifies with it | a private member, another key type or curve, or a bad signature |
| `jwk`'s RFC 7638 thumbprint is the token's `cnf.jkt` | the proof was signed by another key |
| `ath` is the base64url SHA-256 of the access token | the proof was made for another token |
| `htm` and `htu` are this request's method and URL | the proof was made for another request |
| `iat` is at most 60 seconds old and 5 seconds ahead | the proof is stale |
| `jti` was not seen before | the proof is replayed |

The expected `htu` is built from the API's resource URI and the request path, never from
the `Host` header, which a proxy may rewrite and a client controls. Serve the API at the
root of its resource URI, or build the URL from its public base instead.

The replay cache lives in process. With more than one replica a proof could be replayed
once against each other replica, so keep it in a store they share, for example Redis with
`SET dpop:<jkt>:<jti> 1 NX PX 65000`.

The API issues no DPoP nonces. They are optional at a resource server (RFC 9449 §9), and
the tight `iat` window with the `jti` cache already bounds a replay. A nonce would need
state shared by every replica and cost the caller a second round trip.

## 5. Add the middleware

```go
package auth

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
)

type ctxKey struct{}

// ClaimsFrom returns the claims Require stored on the request context.
func ClaimsFrom(ctx context.Context) *Claims {
	c, _ := ctx.Value(ctxKey{}).(*Claims)
	return c
}

// Require lets a request through only with a valid DPoP-bound access token,
// a valid proof for it, and a token granting scope.
func (v *Verifier) Require(scope string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		scheme, raw, ok := strings.Cut(r.Header.Get("Authorization"), " ")
		if !ok || !strings.EqualFold(scheme, "DPoP") {
			deny(w, r, http.StatusUnauthorized, "", nil)
			return
		}
		claims, err := v.verifyToken(raw)
		if err != nil {
			deny(w, r, http.StatusUnauthorized, `error="invalid_token", `, err)
			return
		}
		if err := v.verifyProof(r, raw, claims.JKT); err != nil {
			deny(w, r, http.StatusUnauthorized, `error="invalid_dpop_proof", `, err)
			return
		}
		if !claims.HasScope(scope) {
			deny(w, r, http.StatusForbidden, fmt.Sprintf(`error="insufficient_scope", scope=%q, `, scope), nil)
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, claims)))
	})
}

// deny answers with a DPoP challenge (RFC 9449 §7.1). The reason goes to the
// log, never to the caller.
func deny(w http.ResponseWriter, r *http.Request, status int, params string, reason error) {
	if reason != nil {
		slog.InfoContext(r.Context(), "request refused", "path", r.URL.Path, "reason", reason)
	}
	w.Header().Set("WWW-Authenticate", `DPoP `+params+`algs="ES256"`)
	w.WriteHeader(status)
}
```

| Request | Answer |
|---|---|
| No `Authorization`, or a scheme other than `DPoP` (including `Bearer`) | `401`, `WWW-Authenticate: DPoP algs="ES256"` |
| The access token fails a check | `401`, `DPoP error="invalid_token", algs="ES256"` |
| The proof fails a check | `401`, `DPoP error="invalid_dpop_proof", algs="ES256"` |
| The token lacks the scope | `403`, `DPoP error="insufficient_scope", scope="orders:read", algs="ES256"` |

The challenges follow RFC 9449 §7.1. The body never says why a request failed: the
reason is for your logs, not for the caller.

## 6. Wire it up

```go
package main

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"os/signal"

	"example.com/orders/auth"
)

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	verifier, err := auth.NewVerifier(ctx,
		os.Getenv("NAUTHERA_ISSUER"),   // https://auth.example.com
		os.Getenv("NAUTHERA_AUDIENCE"), // https://orders.example.com/
	)
	if err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.Handle("GET /orders", verifier.Require("orders:read", http.HandlerFunc(listOrders)))
	mux.Handle("POST /orders", verifier.Require("orders:write", http.HandlerFunc(createOrder)))
	log.Fatal(http.ListenAndServe(":8090", mux))
}

func listOrders(w http.ResponseWriter, r *http.Request) {
	c := auth.ClaimsFrom(r.Context())
	// c.Subject is the user, or the client for a client_credentials token.
	// c.ClientID is the application that called.
	json.NewEncoder(w).Encode(map[string]string{"subject": c.Subject, "client": c.ClientID})
}

func createOrder(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusCreated)
}
```

The issuer must be spelled exactly as Nauthera publishes it, without a trailing slash.
For an organization other than the main one it is `https://auth.example.com/orgs/acme`.
The audience is the API's resource URI in the normalized form Nauthera puts in `aud`,
with the trailing slash.

## 7. Call it

The `billing-worker` service gets a token with `client_credentials`, authenticating with
an assertion signed by its assertion key and binding the token with a DPoP proof. It then
sends each request with `Authorization: DPoP` and a fresh proof carrying `ath`. An
`http.RoundTripper` does all of it:

```go
// Package nauthera gets DPoP-bound access tokens with client_credentials and
// private_key_jwt, and sends them with a fresh DPoP proof on every request.
package nauthera

import (
	"context"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
	"github.com/lestrrat-go/jwx/v3/jws"
	"github.com/lestrrat-go/jwx/v3/jwt"
)

// Transport is an http.RoundTripper that authenticates every request it sends.
type Transport struct {
	Issuer       string  // https://auth.example.com
	ClientID     string  // billing-worker
	Resource     string  // https://orders.example.com/
	Scope        string  // orders:read
	AssertionKey jwk.Key // the private key whose public half is at jwksUri
	ClientSecret string  // set only against a local server; replaces the assertion
	Base         http.RoundTripper

	dpopKey jwk.Key
	dpopPub jwk.Key

	mu      sync.Mutex
	token   string
	expires time.Time
	nonce   string
}

// NewDPoPKey gives t a DPoP key that lives only in this process. Tokens bound
// to it die with the process, which their short lifetime already accepts.
func (t *Transport) NewDPoPKey() error {
	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return err
	}
	if t.dpopKey, err = jwk.Import(priv); err != nil {
		return err
	}
	t.dpopPub, err = t.dpopKey.PublicKey()
	return err
}

func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
	token, err := t.accessToken(req.Context())
	if err != nil {
		return nil, err
	}
	proof, err := t.proof(req.Method, req.URL, "", token)
	if err != nil {
		return nil, err
	}
	req = req.Clone(req.Context())
	req.Header.Set("Authorization", "DPoP "+token)
	req.Header.Set("DPoP", proof)
	return t.base().RoundTrip(req)
}

func (t *Transport) accessToken(ctx context.Context) (string, error) {
	t.mu.Lock()
	defer t.mu.Unlock()
	if t.token != "" && time.Now().Before(t.expires) {
		return t.token, nil
	}
	// The token endpoint always requires a DPoP nonce: the first attempt
	// without one, or with an expired one, is answered use_dpop_nonce.
	for range 2 {
		res, err := t.requestToken(ctx)
		if err != nil {
			return "", err
		}
		if n := res.Header.Get("DPoP-Nonce"); n != "" {
			t.nonce = n
		}
		var body struct {
			AccessToken string `json:"access_token"`
			TokenType   string `json:"token_type"`
			ExpiresIn   int    `json:"expires_in"`
			Error       string `json:"error"`
		}
		err = json.NewDecoder(res.Body).Decode(&body)
		res.Body.Close()
		switch {
		case err != nil:
			return "", fmt.Errorf("token response: %w", err)
		case res.StatusCode == http.StatusBadRequest && body.Error == "use_dpop_nonce":
			continue
		case res.StatusCode != http.StatusOK:
			return "", fmt.Errorf("token request: %s %s", res.Status, body.Error)
		case body.TokenType != "DPoP":
			return "", fmt.Errorf("token request: token_type %q, want DPoP", body.TokenType)
		}
		t.token = body.AccessToken
		t.expires = time.Now().Add(time.Duration(body.ExpiresIn)*time.Second - 30*time.Second)
		return t.token, nil
	}
	return "", fmt.Errorf("token request: nonce refused twice")
}

func (t *Transport) requestToken(ctx context.Context) (*http.Response, error) {
	form := url.Values{
		"grant_type": {"client_credentials"},
		"scope":      {t.Scope},
		"resource":   {t.Resource},
	}
	if t.ClientSecret == "" {
		// An assertion's jti is single use, so each attempt signs a new one.
		assertion, err := t.assertion()
		if err != nil {
			return nil, err
		}
		form.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
		form.Set("client_assertion", assertion)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.Issuer+"/token",
		strings.NewReader(form.Encode()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	if t.ClientSecret != "" {
		req.SetBasicAuth(url.QueryEscape(t.ClientID), url.QueryEscape(t.ClientSecret))
	}
	proof, err := t.proof(http.MethodPost, req.URL, t.nonce, "")
	if err != nil {
		return nil, err
	}
	req.Header.Set("DPoP", proof)
	return t.base().RoundTrip(req)
}

// assertion is the RFC 7523 client assertion: iss and sub are the client, aud
// is the issuer, and it expires a minute after it is made.
func (t *Transport) assertion() (string, error) {
	now := time.Now()
	tok, err := jwt.NewBuilder().
		Issuer(t.ClientID).
		Subject(t.ClientID).
		Audience([]string{t.Issuer}).
		IssuedAt(now).
		Expiration(now.Add(time.Minute)).
		JwtID(rand.Text()).
		Build()
	if err != nil {
		return "", err
	}
	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256(), t.AssertionKey))
	return string(signed), err
}

// proof is a DPoP proof (RFC 9449 §4.2) for one request. ath binds it to the
// access token it accompanies.
func (t *Transport) proof(method string, target *url.URL, nonce, accessToken string) (string, error) {
	htu := url.URL{Scheme: target.Scheme, Host: target.Host, Path: target.Path, RawPath: target.RawPath}
	b := jwt.NewBuilder().
		JwtID(rand.Text()).
		IssuedAt(time.Now()).
		Claim("htm", method).
		Claim("htu", htu.String())
	if nonce != "" {
		b = b.Claim("nonce", nonce)
	}
	if accessToken != "" {
		sum := sha256.Sum256([]byte(accessToken))
		b = b.Claim("ath", base64.RawURLEncoding.EncodeToString(sum[:]))
	}
	tok, err := b.Build()
	if err != nil {
		return "", err
	}
	h := jws.NewHeaders()
	if err := h.Set(jws.TypeKey, "dpop+jwt"); err != nil {
		return "", err
	}
	if err := h.Set(jws.JWKKey, t.dpopPub); err != nil {
		return "", err
	}
	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.ES256(), t.dpopKey, jws.WithProtectedHeaders(h)))
	return string(signed), err
}

func (t *Transport) base() http.RoundTripper {
	if t.Base != nil {
		return t.Base
	}
	return http.DefaultTransport
}
```

Nauthera requires a DPoP nonce at `/token`. The first request gets `400` with
`use_dpop_nonce` and a `DPoP-Nonce` header, and the transport retries once with the
nonce in a new proof and a new assertion: Nauthera authenticates the client before it
checks the proof, so the refused request has already used up the first assertion's `jti`.
Every successful token response carries the next nonce, so later token requests succeed
on the first try while it is valid (five minutes).

The assertion's `aud` is the issuer; Nauthera also accepts `{issuer}/token`. Its `exp` is
one minute out, and Nauthera refuses more than 60 minutes.

The assertion key and the JWKS the client registers come from the same file:

```go
package nauthera

import (
	"encoding/json"
	"net/http"
	"os"

	"github.com/lestrrat-go/jwx/v3/jwa"
	"github.com/lestrrat-go/jwx/v3/jwk"
)

// LoadAssertionKey reads the PEM private key and names it by its RFC 7638
// thumbprint, the kid the published JWKS carries.
func LoadAssertionKey(path string) (jwk.Key, error) {
	pem, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	key, err := jwk.ParseKey(pem, jwk.WithPEM(true))
	if err != nil {
		return nil, err
	}
	return key, jwk.AssignKeyID(key)
}

// JWKSHandler serves the public half of key as the JWKS the client registers
// as its jwksUri.
func JWKSHandler(key jwk.Key) (http.Handler, error) {
	pub, err := key.PublicKey()
	if err != nil {
		return nil, err
	}
	if err := pub.Set(jwk.AlgorithmKey, jwa.ES256()); err != nil {
		return nil, err
	}
	if err := pub.Set(jwk.KeyUsageKey, jwk.ForSignature); err != nil {
		return nil, err
	}
	set := jwk.NewSet()
	if err := set.AddKey(pub); err != nil {
		return nil, err
	}
	body, err := json.Marshal(set)
	if err != nil {
		return nil, err
	}
	return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "max-age=300")
		w.Write(body)
	}), nil
}
```

```go
package main

import (
	"context"
	"io"
	"log"
	"net/http"
	"os"

	"example.com/billing/nauthera"
)

func main() {
	t := &nauthera.Transport{
		Issuer:   os.Getenv("NAUTHERA_ISSUER"),
		ClientID: os.Getenv("NAUTHERA_CLIENT_ID"),
		Resource: os.Getenv("ORDERS_API"), // https://orders.example.com/
		Scope:    "orders:read",
	}
	if err := t.NewDPoPKey(); err != nil {
		log.Fatal(err)
	}
	if path := os.Getenv("NAUTHERA_ASSERTION_KEY"); path != "" {
		key, err := nauthera.LoadAssertionKey(path) // /var/run/secrets/nauthera/key.pem
		if err != nil {
			log.Fatal(err)
		}
		t.AssertionKey = key
		jwks, err := nauthera.JWKSHandler(key)
		if err != nil {
			log.Fatal(err)
		}
		go func() { log.Fatal(http.ListenAndServe(":8081", jwks)) }()
	} else {
		t.ClientSecret = os.Getenv("NAUTHERA_CLIENT_SECRET") // local development
	}

	client := &http.Client{Transport: t}
	req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, t.Resource+"orders", nil)
	res, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)
	log.Printf("%s %s", res.Status, body)
}
```

## Local development

Against the [Quick Start](https://www.nauthera.io/docs/v1/quick-start) stack the issuer is
`https://localhost:8443`, and its certificate comes from a CA the chart created:
`nauthera-local-ca.crt`, which [step 1](https://www.nauthera.io/docs/v1/quick-start#1-start-the-stack) saves.
On Linux, point Go at it:

```bash
SSL_CERT_FILE="$PWD/nauthera-local-ca.crt" go run .
```

On macOS, Go reads the system trust store, so add the CA there; `helm get notes nauthera
-n nauthera` shows how, and how to remove it again.

The local server cannot fetch a JWKS from your machine, so `private_key_jwt` does not work
there. Register the caller with a generated secret instead, and keep DPoP, with an
`OIDCClient` as in [Quick Start step 4](https://www.nauthera.io/docs/v1/quick-start#4-register-your-own-client):

```yaml
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
  name: billing-worker
  namespace: nauthera
spec:
  clientId: billing-worker
  grantTypes:
    - client_credentials
  scopes:
    - orders:read
    - orders:write
  tokenEndpointAuthMethod: client_secret_basic
  secret:
    generate: true
```

```bash
kubectl apply -f billing-worker.yaml
kubectl -n nauthera get secret billing-worker -o jsonpath='{.data.client-secret}' | base64 -d
```

Run the API (it listens on `8090`) with `NAUTHERA_ISSUER=https://localhost:8443` and
`NAUTHERA_AUDIENCE=http://localhost:8090/`, and the caller with `NAUTHERA_CLIENT_SECRET` set to that secret, `NAUTHERA_ASSERTION_KEY`
unset and `ORDERS_API=http://localhost:8090/`. The transport then authenticates with HTTP
Basic and sends the same proofs.

## Notes

- A revoked access token stays valid until it expires (10 minutes by default, 5 for
  `billing-worker` above), because revocation does not reach access tokens. Keep access
  token lifetimes short.
- Nauthera binds a token whenever the token request carries a proof, but does not
  require one for a client (`dpop_bound_access_tokens`). The API's refusal of unbound
  tokens is what enforces DPoP. See
  [Tokens & Sender Constraint](https://www.nauthera.io/docs/v1/tokens#dpop-sender-constrained-tokens).
- Do not use `/introspect` from the API: only the client a token was issued to can
  introspect it. See
  [Connect an Application](https://www.nauthera.io/docs/v1/integration#9-validate-access-tokens-in-your-api).
