Documentation
¶
Overview ¶
Package authn provides composable net/http RoundTrippers that perform OAuth 2.0 client authentication.
It implements the client-authentication methods defined across the OAuth 2.0 family of specifications — client_secret_basic and client_secret_post (RFC 6749 §2.3.1), client_secret_jwt and private_key_jwt (RFC 7523), and tls_client_auth and self_signed_tls_client_auth (RFC 8705) — as small, orthogonal http.RoundTripper decorators that wrap a base transport.
The package is intentionally framework-neutral: a configured method produces an http.RoundTripper (or a ready *http.Client) that authenticates every request it carries to a token endpoint, with no dependency on any particular OAuth client library. It composes with higher-level flows — for example an RFC 8693 token-exchange client — by sitting underneath them in the transport chain.
The composition core is the Method interface together with Transport and NewClient: a Method decorates a base http.RoundTripper, Transport applies it (defaulting to http.DefaultTransport), and NewClient wraps the result in an *http.Client ready to hand to a token-family client. The concrete methods land on top of this core in subsequent phases of the build.
Index ¶
- Constants
- func NewClient(m Method, base http.RoundTripper) *http.Client
- func SignerFromJWK(jwkJSON []byte) (crypto.Signer, error)
- func SignerFromPEM(pemBytes []byte) (crypto.Signer, error)
- func Transport(m Method, base http.RoundTripper) http.RoundTripper
- type AssertionOption
- type Method
- func ClientSecretBasic(clientID, clientSecret string) Method
- func ClientSecretJWT(clientID, clientSecret string, opts ...AssertionOption) Method
- func ClientSecretPost(clientID, clientSecret string) Method
- func PrivateKeyJWT(clientID string, signer crypto.Signer, opts ...AssertionOption) Method
- func SelfSignedTLSClientAuth(clientID string, cert tls.Certificate, opts ...TLSOption) Method
- func TLSClientAuth(clientID string, cert tls.Certificate, opts ...TLSOption) Method
- type TLSOption
Examples ¶
Constants ¶
const SpecVersion = "RFC 6749 / 7523 / 8705"
SpecVersion identifies the OAuth 2.0 client-authentication specifications this library implements. It is informational: it names the documents the wire behavior is verified against, not a negotiated protocol version.
Variables ¶
This section is empty.
Functions ¶
func NewClient ¶
func NewClient(m Method, base http.RoundTripper) *http.Client
NewClient returns an *http.Client whose Transport authenticates every request using m. When base is nil the client authenticates over http.DefaultTransport.
The returned client is exactly the value a token-family client (for example an RFC 8693 token-exchange client accepting a custom *http.Client) consumes: drop it in and every call it makes to the token endpoint carries client authentication.
Example ¶
ExampleNewClient shows the composition seam: a Method plus an optional base transport produce an *http.Client whose every request is authenticated. The same client is what a token-family client (e.g. an RFC 8693 token-exchange client) accepts as its HTTP client.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
authn "github.com/hstern/go-oauth-client-authn"
)
// headerMethod is a stand-in Method that authenticates by setting a fixed header.
// The library's concrete methods (client_secret_basic, private_key_jwt, …) land
// in later phases; this minimal one shows how any Method composes.
type headerMethod struct{}
func (headerMethod) Name() string { return "example_static_header" }
func (headerMethod) RoundTripper(base http.RoundTripper) http.RoundTripper {
return roundTripper(func(req *http.Request) (*http.Response, error) {
// Clone first — a RoundTripper must not mutate the request it is given.
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer example")
return base.RoundTrip(req)
})
}
type roundTripper func(*http.Request) (*http.Response, error)
func (f roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
// ExampleNewClient shows the composition seam: a Method plus an optional base
// transport produce an *http.Client whose every request is authenticated. The
// same client is what a token-family client (e.g. an RFC 8693 token-exchange
// client) accepts as its HTTP client.
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "saw %s", r.Header.Get("Authorization"))
}))
defer srv.Close()
// nil base => the client authenticates over http.DefaultTransport.
client := authn.NewClient(headerMethod{}, nil)
resp, err := client.Get(srv.URL)
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
Output: saw Bearer example
func SignerFromJWK ¶
SignerFromJWK parses a single JSON Web Key (RFC 7517) carrying private-key material and returns it as a crypto.Signer, ready to pass to PrivateKeyJWT. It accepts the asymmetric key types RFC 7518 §6 defines for signing — RSA (kty "RSA"), EC (kty "EC"), and OKP Ed25519 (kty "OKP", crv "Ed25519") — since each parses to a Go key (*rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey) that satisfies crypto.Signer.
A JWK that carries only public-key material is rejected: handing a public key to a method that must sign is a misuse worth catching at parse time rather than surfacing as an obscure failure when the first assertion cannot be signed. A structurally invalid JWK, or one whose parsed key is symmetric (kty "oct", which has no signer), is likewise rejected.
As with SignerFromPEM, no error embeds the key: the JWK bytes and every parsed parameter stay out of the returned error text.
Example ¶
ExampleSignerFromJWK loads a private key from its JSON Web Key (RFC 7517) encoding, the form an OAuth client registration response or a JWKS file carries. A JWK that holds only public-key material is rejected, since a key used to sign assertions must be private.
package main
import (
"fmt"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
privateJWK := []byte(`{
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
"d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A"
}`)
signer, err := authn.SignerFromJWK(privateJWK)
if err != nil {
panic(err)
}
fmt.Printf("%T\n", signer.Public())
publicOnly := []byte(`{
"kty": "OKP",
"crv": "Ed25519",
"x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"
}`)
if _, err := authn.SignerFromJWK(publicOnly); err != nil {
fmt.Println("public-only JWK rejected")
}
}
Output: ed25519.PublicKey public-only JWK rejected
func SignerFromPEM ¶
SignerFromPEM decodes a single PEM block and parses the private key it carries, returning it as a crypto.Signer. It accepts the three PEM encodings an OAuth client key is realistically distributed in:
- PKCS#8, "PRIVATE KEY" — any key type, via x509.ParsePKCS8PrivateKey
- PKCS#1, "RSA PRIVATE KEY" — RSA only, via x509.ParsePKCS1PrivateKey
- SEC 1, "EC PRIVATE KEY" — ECDSA only, via x509.ParseECPrivateKey
The parsed key is returned as a crypto.Signer; *rsa.PrivateKey, *ecdsa.PrivateKey, and ed25519.PrivateKey all satisfy the interface, so RSA, ECDSA, and (via PKCS#8) Ed25519 keys all load. The result is ready to pass to PrivateKeyJWT.
Errors are returned, never the key: an input with no PEM block, an unrecognized or encrypted block type, a parse failure, or a parsed key that is not a signer each produce a distinct error whose text names the structural cause but never the key bytes. Encrypted PEM (a legacy "Proc-Type: 4,ENCRYPTED" header, or a PKCS#8 "ENCRYPTED PRIVATE KEY" block) is rejected with a clear error rather than mis-parsed; decrypting passphrase-protected keys is out of scope (the caller decrypts first, then passes the plaintext PEM).
Example ¶
ExampleSignerFromPEM loads a private key from a PEM file into a crypto.Signer. The resulting signer is the canonical key input for the asymmetric JWT client-authentication method: pass it straight to private_key_jwt without hand-rolling crypto/x509 parsing.
A SEC 1 ("EC PRIVATE KEY"), PKCS#1 ("RSA PRIVATE KEY"), or PKCS#8 ("PRIVATE KEY") block all load the same way; this example uses an EC key.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
// In real code, pemBytes is os.ReadFile("client-key.pem"). Generated inline
// here so the example is self-contained and deterministic.
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
panic(err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
signer, err := authn.SignerFromPEM(pemBytes)
if err != nil {
panic(err)
}
// signer.Public() is the verification key the authorization server registers
// for this client; the private half never leaves the signer.
fmt.Printf("%T\n", signer.Public())
}
Output: *ecdsa.PublicKey
func Transport ¶
func Transport(m Method, base http.RoundTripper) http.RoundTripper
Transport returns an http.RoundTripper that applies m's client authentication on top of base. When base is nil it defaults to http.DefaultTransport, so a caller that only wants authentication need not also supply a transport.
The result is the seam that token-family clients consume: it can be assigned to http.Client.Transport directly, or passed wherever an http.RoundTripper is expected.
Types ¶
type AssertionOption ¶
type AssertionOption func(*assertionConfig)
AssertionOption configures how an RFC 7523 §2.2 JWT client assertion is built. Options are supplied to the JWT client-authentication method constructors (client_secret_jwt, private_key_jwt), which thread them through to the shared builder; they let a caller adapt to an authorization server that deviates from the defaults — most often by requiring the issuer URL as the audience instead of the concrete endpoint, or a different assertion lifetime.
func WithAssertionLifetime ¶
func WithAssertionLifetime(d time.Duration) AssertionOption
WithAssertionLifetime overrides the assertion validity window (the gap between the iat and exp claims). The default is 60 seconds; a non-positive duration resets it to that default rather than minting an already-expired assertion.
func WithAudience ¶
func WithAudience(aud string) AssertionOption
WithAudience overrides the audience of the client assertion. By default the audience is derived from the request URL (see [buildAssertion]); some authorization servers instead require their issuer identifier, which this option supplies verbatim.
Example ¶
ExampleWithAudience shows the WithAudience assertion option. By default a JWT client-authentication method (client_secret_jwt, private_key_jwt) derives the assertion's aud claim from the request URL, so one configured Method binds correctly at the token, PAR, introspection, and revocation endpoints. Some authorization servers instead require their issuer identifier as the audience; WithAudience supplies that value verbatim, and it is used unchanged regardless of which endpoint the request targets.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
const (
clientID = "s6BhdRkqt3"
clientSecret = "0123456789abcdef0123456789abcdef" // 32 bytes: HS256 floor.
issuer = "https://issuer.example.com"
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
tok, err := jwt.ParseSigned(
r.PostForm.Get("client_assertion"),
[]jose.SignatureAlgorithm{jose.HS256},
)
if err != nil {
http.Error(w, "bad assertion", http.StatusBadRequest)
return
}
var claims jwt.Claims
if err := tok.Claims([]byte(clientSecret), &claims); err != nil {
http.Error(w, "assertion does not verify", http.StatusUnauthorized)
return
}
// The aud is the configured issuer, not the request URL.
fmt.Printf("aud=%s\n", claims.Audience[0])
}))
defer srv.Close()
method := authn.ClientSecretJWT(clientID, clientSecret, authn.WithAudience(issuer))
client := authn.NewClient(method, nil)
resp, err := client.PostForm(srv.URL, url.Values{"grant_type": {"client_credentials"}})
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
}
Output: aud=https://issuer.example.com
func WithKeyID ¶
func WithKeyID(kid string) AssertionOption
WithKeyID sets the JWT "kid" header parameter, identifying which of a client's registered keys signed the assertion so the authorization server can select the matching verification key. It is most useful with private_key_jwt when a client has published several keys in its JWKS.
func WithRSAPSS ¶
func WithRSAPSS() AssertionOption
WithRSAPSS selects RSASSA-PSS signatures (the PS256 JWS algorithm) over the default RSASSA-PKCS1-v1_5 (RS256) for an RSA private_key_jwt signing key. RFC 7518 §3.5 RECOMMENDS PS256 for new deployments, but RS256 remains the most widely interoperable default, so the library defaults to RS256 and treats PSS as opt-in.
The option is meaningful only for private_key_jwt with an RSA key. It has no effect on EC or Ed25519 keys (whose algorithm is fixed by the curve), nor on client_secret_jwt (HS256), and is silently ignored there.
type Method ¶
type Method interface {
// Name reports the RFC 7591 token_endpoint_auth_method identifier for the
// method, for example "client_secret_basic" or "private_key_jwt". It is the
// value a client would register or advertise; it never varies per request.
Name() string
// RoundTripper returns an http.RoundTripper that authenticates every request
// it carries and delegates the actual transport to base. Implementations
// must not mutate the requests they are given (the http.RoundTripper
// contract); they clone before augmenting. A nil base is the caller's
// responsibility — use [Transport], which substitutes http.DefaultTransport.
RoundTripper(base http.RoundTripper) http.RoundTripper
}
Method is a single OAuth 2.0 client-authentication method. It names itself using the RFC 7591 token_endpoint_auth_method identifier and decorates a base http.RoundTripper so that every request the decorated transport carries is authenticated according to that method.
A Method is configuration, not transport: the same Method composes onto any base RoundTripper and authenticates requests to any token-family endpoint (token, pushed-authorization, introspection, revocation), because the wire effect is derived from each request rather than fixed at construction time.
func ClientSecretBasic ¶
ClientSecretBasic returns the client_secret_basic Method (RFC 6749 §2.3.1): the client id and secret are sent in the HTTP Basic Authorization header of every request the decorated transport carries.
Per RFC 6749 §2.3.1 the id and secret are each first encoded with the application/x-www-form-urlencoded algorithm, then joined with a single colon, then base64-encoded — not base64 of the raw "id:secret". The distinction is only observable when either value contains a character the form encoding touches (a colon, a space, "+", or any byte outside the unreserved set), but for those values the naive encoding produces a header an authorization server reading the spec literally will reject. Appendix B of RFC 6749 is explicit that this encoding applies even though many deployed servers are lax about it.
Example ¶
ExampleClientSecretBasic shows client_secret_basic on the wire: the resulting client sends the credentials in an HTTP Basic Authorization header on every request. The id and secret are form-urlencoded before being base64-encoded (RFC 6749 §2.3.1), which only becomes visible when either value contains a character the form encoding touches.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, "saw %s", r.Header.Get("Authorization"))
}))
defer srv.Close()
client := authn.NewClient(authn.ClientSecretBasic("s6BhdRkqt3", "7Fjfp0ZBr1KtDRbnfVdmIw"), nil)
resp, err := client.Get(srv.URL)
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
}
Output: saw Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
func ClientSecretJWT ¶
func ClientSecretJWT(clientID, clientSecret string, opts ...AssertionOption) Method
ClientSecretJWT returns the client_secret_jwt client-authentication method (RFC 7523 §2.2). It authenticates by signing a short-lived JWT client assertion with HMAC-SHA-256 keyed on clientSecret and posting it in the client_assertion form parameter, alongside the fixed client_assertion_type URN. HS256 is the algorithm the spec fixes for client_secret_jwt: the shared secret is the HMAC key, so the same value both signs the assertion and lets the authorization server verify it.
The assertion's claims follow RFC 7523 §3 — iss and sub are both clientID, aud is the request's endpoint URL (so one method value authenticates at the token, pushed-authorization, introspection, and revocation endpoints), jti is a fresh per-request crypto/rand identifier, and exp is a short window after iat. The opts adjust that build: WithAudience substitutes an authorization server that wants its issuer identifier as the audience, and WithAssertionLifetime changes the validity window. WithKeyID is accepted but rarely meaningful here — a client_secret_jwt client has a single shared secret, not a key set to select among; it is more useful with private_key_jwt.
clientSecret is bearer-equivalent: it is the HMAC key, and anyone holding it can mint assertions indistinguishable from the client's. The returned Method holds it in memory and writes it only into the assertion's signature; it is never logged and never embedded in an error (see [buildAssertion]).
HMAC-SHA-256 requires a key at least as long as its 256-bit output (RFC 7518 §3.2), so clientSecret MUST be at least 32 bytes; a shorter secret makes the signing step fail at request time with a key-size error (which, like every error here, never echoes the secret). Beyond that floor the security of client_secret_jwt rests entirely on the secret's entropy: operators SHOULD provision a high-entropy 256-bit-or-longer secret, since RFC 7523 §8 warns a weak shared secret is brute-forceable offline from a single captured assertion. private_key_jwt avoids the shared-secret exposure altogether and is preferable where asymmetric keys are an option.
Example ¶
ExampleClientSecretJWT shows client_secret_jwt (RFC 7523 §2.2): the constructed Method signs a short-lived JWT assertion with HMAC-SHA-256 keyed on the client secret and posts it as client_assertion, alongside the fixed client_assertion_type URN. The authorization server verifies the assertion with the same shared secret. Here the example server plays that role and reports the claims it recovers.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
const (
clientID = "s6BhdRkqt3"
// HS256 needs a key at least as long as its 256-bit output (RFC 7518
// §3.2); use a 32-byte secret. Real deployments use a high-entropy one.
clientSecret = "0123456789abcdef0123456789abcdef"
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
tok, err := jwt.ParseSigned(
r.PostForm.Get("client_assertion"),
[]jose.SignatureAlgorithm{jose.HS256},
)
if err != nil {
http.Error(w, "bad assertion", http.StatusBadRequest)
return
}
var claims jwt.Claims
if err := tok.Claims([]byte(clientSecret), &claims); err != nil {
http.Error(w, "assertion does not verify", http.StatusUnauthorized)
return
}
fmt.Printf("type=%s iss=%s sub=%s\n",
r.PostForm.Get("client_assertion_type"),
claims.Issuer,
claims.Subject,
)
}))
defer srv.Close()
client := authn.NewClient(authn.ClientSecretJWT(clientID, clientSecret), nil)
body := strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode())
req, err := http.NewRequest(http.MethodPost, srv.URL, body)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
}
Output: type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer iss=s6BhdRkqt3 sub=s6BhdRkqt3
func ClientSecretPost ¶
ClientSecretPost returns the client_secret_post client-authentication method (RFC 6749 §2.3.1). It authenticates by adding the client_id and client_secret form parameters to the token-endpoint request body, the alternative the spec permits to client_secret_basic for clients that cannot or prefer not to use the HTTP Basic authentication scheme.
The request MUST be an application/x-www-form-urlencoded POST — which every token-family endpoint request is (token, pushed-authorization, introspection, revocation are all form POSTs per RFC 6749 §4 and the endpoints that reuse its request shape). The credentials are written into that form body alongside whatever it already carries (grant_type, code, subject_token, …); the existing parameters are preserved and the body is re-encoded with a corrected Content-Length. A request that does not carry a form body is passed through unchanged, so a caller that hands this method a non-form request gets an unauthenticated request rather than a fabricated body — see [augmentForm].
client_id and client_secret are written with url.Values.Set, not Add: RFC 6749 §2.3.1 treats client authentication as single-valued, so a pre-populated client_id in the body is replaced rather than duplicated.
The secret is bearer-equivalent; like every Method here, the returned value holds it in memory and writes it only into the request body — it is never logged.
Example ¶
ExampleClientSecretPost shows client_secret_post (RFC 6749 §2.3.1): the constructed Method adds client_id and client_secret to the form body of every token-endpoint request the client carries, preserving the parameters the body already holds (here grant_type).
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
fmt.Printf("grant_type=%s client_id=%s client_secret=%s\n",
r.PostForm.Get("grant_type"),
r.PostForm.Get("client_id"),
r.PostForm.Get("client_secret"),
)
}))
defer srv.Close()
client := authn.NewClient(authn.ClientSecretPost("s6BhdRkqt3", "7Fjfp0ZBr1KtDRbnfVdmIw"), nil)
body := strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode())
req, err := http.NewRequest(http.MethodPost, srv.URL, body)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
}
Output: grant_type=client_credentials client_id=s6BhdRkqt3 client_secret=7Fjfp0ZBr1KtDRbnfVdmIw
func PrivateKeyJWT ¶
func PrivateKeyJWT(clientID string, signer crypto.Signer, opts ...AssertionOption) Method
PrivateKeyJWT returns the private_key_jwt client-authentication method (RFC 7523 §2.2). It authenticates by signing a short-lived JWT assertion with the client's private key and posting it in the client_assertion form parameter, alongside the fixed client_assertion_type URN — the asymmetric counterpart to client_secret_jwt, which signs with a shared secret.
signer is the client's private key as a crypto.Signer. This is the canonical key input precisely because crypto.Signer is the interface an HSM- or KMS-backed key satisfies without the raw private material ever leaving the device: the library asks the signer to sign bytes and never sees the key. An in-memory *rsa.PrivateKey, *ecdsa.PrivateKey, or ed25519.PrivateKey satisfies it too, so software keys work unchanged.
The JWS algorithm is derived from the key type returned by signer.Public(), not chosen by the caller:
- *rsa.PublicKey → RS256 (RSASSA-PKCS1-v1_5); WithRSAPSS selects PS256
- *ecdsa.PublicKey → ES256 / ES384 / ES512 by curve (P-256 / P-384 / P-521)
- ed25519.PublicKey → EdDSA
Any other key type — or an RSA key the signer reports as too small to be usable — is a construction error surfaced lazily: the returned Method's RoundTripper produces a transport whose RoundTrip fails with that error on the first request, so a misconfigured key never silently authenticates. A nil signer is treated the same way.
opts are the shared AssertionOption values (audience override, lifetime, kid, RSA-PSS selection). They are captured once and applied per request, so a single PrivateKeyJWT value authenticates requests to the token, pushed-authorization, introspection, and revocation endpoints unchanged — each assertion's aud is derived from that request's URL.
The signing key and the assertion are bearer-equivalent: like every Method here, this one writes the assertion only into the request body and never logs the key, the assertion, or either in an error.
Example ¶
ExamplePrivateKeyJWT shows private_key_jwt authenticating a token request: the client signs a short-lived JWT assertion with its private key, and the library posts it as client_assertion. Here the key is an in-memory *rsa.PrivateKey, but any crypto.Signer works — including an HSM- or KMS-backed key whose private material never leaves the device.
package main
import (
"crypto/rand"
"crypto/rsa"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
// In production this is the client's registered key (often an HSM/KMS
// crypto.Signer). A throwaway key keeps the example self-contained.
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
// A stand-in token endpoint that echoes which auth method it saw.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
_, _ = fmt.Fprintf(w, "client_assertion_type=%s assertion_present=%t",
r.PostForm.Get("client_assertion_type"),
r.PostForm.Get("client_assertion") != "")
}))
defer srv.Close()
client := authn.NewClient(authn.PrivateKeyJWT("s6BhdRkqt3", key), nil)
resp, err := client.PostForm(srv.URL, url.Values{"grant_type": {"client_credentials"}})
if err != nil {
panic(err)
}
defer func() { _ = resp.Body.Close() }()
var buf [256]byte
n, _ := resp.Body.Read(buf[:])
fmt.Println(string(buf[:n]))
}
Output: client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer assertion_present=true
func SelfSignedTLSClientAuth ¶
func SelfSignedTLSClientAuth(clientID string, cert tls.Certificate, opts ...TLSOption) Method
SelfSignedTLSClientAuth returns the self_signed_tls_client_auth client-authentication method (RFC 8705 §2.2, "Self-Signed Certificate Mutual-TLS"). It is identical to TLSClientAuth on the client side — it presents cert during the TLS handshake and sends client_id in the form body — and differs only in how the authorization server validates the certificate. Under §2.2 the server does not build a chain to a certificate authority; it matches the presented certificate against the client's registered JWKS by thumbprint, so the certificate may be self-signed.
Because the difference is entirely server-side, the constructed method behaves exactly like the one TLSClientAuth returns except for the value Method.Name reports. The same transport requirement applies — see TLSClientAuth for the *http.Transport rule and the non-*http.Transport failure mode.
Example ¶
ExampleSelfSignedTLSClientAuth shows self_signed_tls_client_auth (RFC 8705 §2.2): on the client side it is identical to tls_client_auth — it presents the client certificate during the TLS handshake and still sends client_id in the form body — and differs only in how the authorization server validates the certificate. Under §2.2 the server matches the presented certificate against the client's registered JWKS by thumbprint instead of building a chain to a certificate authority, so the certificate may be self-signed.
Like tls_client_auth, the Method clones the supplied base *http.Transport and sets the client certificate on a clone of its TLS configuration, leaving the caller's transport untouched.
package main
import (
"crypto/tls"
"fmt"
"net/http"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
var clientCert tls.Certificate // from tls.LoadX509KeyPair(certPEM, keyPEM)
base := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}}
client := authn.NewClient(authn.SelfSignedTLSClientAuth("s6BhdRkqt3", clientCert), base)
// Only the reported method name differs from tls_client_auth; the wire
// behavior and transport handling are the same.
fmt.Println(authn.SelfSignedTLSClientAuth("s6BhdRkqt3", clientCert).Name())
fmt.Println(base.TLSClientConfig.MinVersion == tls.VersionTLS13)
_ = client
}
Output: self_signed_tls_client_auth true
func TLSClientAuth ¶
func TLSClientAuth(clientID string, cert tls.Certificate, opts ...TLSOption) Method
TLSClientAuth returns the tls_client_auth client-authentication method (RFC 8705 §2.1, "PKI Mutual-TLS"). The client is authenticated by the TLS certificate it presents during the handshake; there is no client secret. The authorization server validates that certificate by building a chain to a configured certificate authority and matching it against the subject DN or a subject alternative name registered for the client.
On the wire the method does two things. It arranges for cert to be presented as the client certificate when the request's transport dials TLS, and it adds client_id to the form body: the certificate authenticates the connection, but RFC 8705 §2.1 still requires the request to identify the client, so client_id MUST be sent alongside it.
Transport requirement: a client certificate can only be injected into a TLS handshake through an *http.Transport, because that is the only standard RoundTripper that exposes a TLSClientConfig. When the base RoundTripper is an *http.Transport (including the http.DefaultTransport that Transport and NewClient substitute for a nil base), it is cloned and its TLS configuration is augmented with cert without disturbing the original. When the base is some other RoundTripper the certificate has nowhere to go; rather than silently send an unauthenticated request, the returned transport fails every RoundTrip with a clear local error. Wrap an *http.Transport, or let the method default to one, if you need mutual-TLS client authentication.
The same value safely decorates any number of base transports: it carries no per-request state, only the client id and certificate.
Example ¶
ExampleTLSClientAuth shows tls_client_auth (RFC 8705 §2.1): the client certificate authenticates the TLS connection while client_id is still sent in the form body. The Method clones the supplied base *http.Transport and sets the client certificate on a clone of its TLS configuration, leaving the caller's transport (here pinning TLS 1.3) untouched.
A real caller loads cert from disk — for example with tls.LoadX509KeyPair — and points the request at the authorization server's token endpoint.
package main
import (
"crypto/tls"
"fmt"
"net/http"
authn "github.com/hstern/go-oauth-client-authn"
)
func main() {
var clientCert tls.Certificate // from tls.LoadX509KeyPair(certPEM, keyPEM)
base := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}}
client := authn.NewClient(authn.TLSClientAuth("s6BhdRkqt3", clientCert), base)
// The cloned config carries the client certificate; the caller's MinVersion
// is preserved rather than clobbered, and the original base is not mutated.
fmt.Println(authn.TLSClientAuth("s6BhdRkqt3", clientCert).Name())
fmt.Println(base.TLSClientConfig.MinVersion == tls.VersionTLS13)
_ = client
}
Output: tls_client_auth true
type TLSOption ¶
type TLSOption func(*mtls)
TLSOption configures an mTLS Method beyond the client id and certificate every mutual-TLS request needs. The option set is intentionally small: the methods already clone and preserve the caller's TLSClientConfig (MinVersion, RootCAs, ServerName, and the rest survive untouched), so most TLS configuration belongs on the caller's base *http.Transport rather than here.
func WithServerName ¶
WithServerName sets tls.Config.ServerName on the cloned configuration, pinning the host name the *server* certificate is verified against (and the SNI sent in the handshake). It is a convenience for the common case of naming the expected token-endpoint host; it never affects the client certificate this method presents. A ServerName already present on the caller's base TLSClientConfig is preserved and this option is ignored, so the caller's explicit choice on their own base transport always wins.