How to bind access tokens to their clients

View Source

A plain bearer token works for whoever holds it: copy it from a log or a compromised proxy and you can call the API as its owner. A sender-constrained token is tied to its client, so a stolen copy is useless on its own. You need this when your tokens are worth stealing and your authorization server can issue bound tokens. There are two ways to bind them, and Livery checks both:

BindingThe client proves it withToken claimSpec
mutual TLSthe TLS client certificatecnf.x5t#S256RFC 8705
DPoPa signed proof on every requestcnf.jktRFC 9449

Check bound tokens

livery_auth_bearer and livery_auth_introspect already refuse a bound token presented by the wrong client: a certificate-bound token from a connection without that certificate, or a DPoP-bound token sent as a plain Bearer. To accept DPoP proofs, turn the scheme on:

{livery_auth_bearer, #{
    jwks_uri => JwksUri,
    issuer   => Issuer,
    audience => <<"https://api.example">>,
    dpop     => true
}}

The client then sends Authorization: DPoP <token> and a DPoP header with its proof. Livery checks the proof (method, URL, age, access token hash, single use) and that its key is the one the token names. A Livery service calling such an API sends the proofs with dpop => true on livery_client:oauth/1; see Call an API that wants an OAuth token.

Require bound tokens

To refuse plain bearer tokens altogether, say which binding you want:

{livery_auth_bearer, #{
    jwks_uri => JwksUri,
    issuer   => Issuer,
    audience => <<"https://api.example">>,
    binding  => dpop
}}

binding is none (default), mtls, dpop or any. With dpop, the challenge no longer offers Bearer, so clients know to use DPoP.

For mtls, the listener has to ask for client certificates; see Require client certificates.

Require fresh proofs with a server nonce

A client makes its own proofs, so if its DPoP key is copied even for a moment, whoever copied it can make proofs ahead of time and use them later. With a server nonce (RFC 9449 section 9), each proof has to carry a value your server handed out shortly before:

#{dpop => true,
  dpop_nonce => #{secret => NonceSecret, ttl => 300}}

A proof without a valid nonce gets 401 with error="use_dpop_nonce" and a DPoP-Nonce header; the client retries with that value. Every answer to a DPoP request carries the next nonce, so a busy client rarely sees the 401. A nonce is a timestamp signed with the secret. Nothing is stored, and nodes that share the secret accept each other's nonces.

Behind a proxy

A DPoP proof names the URL the client called. When a proxy rewrites the scheme, host or path, tell Livery the public URL:

#{dpop => true,
  dpop_url => fun(Req) ->
      <<"https://api.example", (livery_req:path(Req))/binary>>
  end}

Notes

  • A proof is accepted for 60 seconds after its iat (dpop_iat_window), plus leeway.
  • Each proof is accepted once. Livery remembers used proofs on each node. In a cluster, give every node the same store with dpop_replay => fun((Key, ExpiresAt) -> fresh | replayed), or a proof could be replayed against another node.
  • Browser clients on another origin can only read DPoP-Nonce if CORS exposes it: add <<"dpop-nonce">> to expose in livery_cors.
  • Tell clients what you accept by publishing protected resource metadata; see Verify JWT access tokens.

See also