Go
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 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:
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.pemThe caller serves the public half on port 8081 (step 7). 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:
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:
apiVersion: nauthera.io/v1alpha1
kind: OIDCClient
metadata:
name: billing-worker
namespace: billing
spec:
clientId: billing-worker
issuer: https://auth.example.com
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: 300kubectl apply -f billing-worker-client.yaml
kubectl -n billing get oidcclient billing-worker # PHASE Ready, reason RegisteredThere is no secret block: the operator refuses a generated secret for
private_key_jwt and writes no Secret, so the Deployment names the issuer and client
itself and mounts the key:
# 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 }Nauthera caches a fetched JWKS for up to ten minutes. To rotate the assertion key, add
the new public key to the set JWKSHandler serves, wait that long, then sign with the new
key and remove the old one.
A web app whose users call the API registers as in the
Better Auth guide, 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
go get github.com/lestrrat-go/jwx/v3 github.com/lestrrat-go/httprc/v33. Write the token verifier
// 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
publishes a new key at least eleven minutes before signing with it, so the API needs no
restart.
4. Verify the DPoP proof
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
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
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:
// 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:
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
}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 stack the issuer is
https://localhost:8080. Go reads the system trust store, so mkcert -install is enough.
On Linux without it, point Go at the mkcert root:
SSL_CERT_FILE="$(mkcert -CAROOT)/rootCA.pem" go run .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:
go run ./cmd/nauthera-cli create client billing-worker \
--grant-types client_credentials --scopes orders:read,orders:writeRun the API (it listens on 8090) with NAUTHERA_ISSUER=https://localhost:8080 and
NAUTHERA_AUDIENCE=http://localhost:8090/, and the caller with NAUTHERA_CLIENT_SECRET set to the printed 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-workerabove), 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 yet
require one for a client (
dpop_bound_access_tokens). The API's refusal of unbound tokens is what enforces DPoP. See Tokens & Sender Constraint. - Do not use
/introspectfrom the API: only the client a token was issued to can introspect it. See Connect an Application.