authn

package module
v0.1.0 Latest Latest
Warning

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

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

README

go-oauth-client-authn

CI Go Reference Go Report Card License

Composable net/http round-trippers for OAuth 2.0 client authentication.

go-oauth-client-authn implements the client-authentication methods defined across the OAuth 2.0 specifications as small, orthogonal http.RoundTripper decorators. Pick a method, wrap your base transport, and every request the transport carries to a token endpoint is authenticated — no coupling to any particular OAuth client library.

Status: pre-publication. The API is taking shape; the first tagged release will be v0.1.0. Interfaces may change before then.

Install

go get github.com/hstern/go-oauth-client-authn

Requires Go 1.26 or newer. Runtime dependencies are the standard library plus go-jose/go-jose/v4, which is used only by the JWT assertion methods (client_secret_jwt, private_key_jwt). The shared- secret and mutual-TLS methods are stdlib-only. There are no other runtime dependencies; the test suite additionally pulls go-token-exchange to prove the composition seam, but that never enters a consumer's go.sum.

Choosing a method

All six methods authenticate the same way at the API level — construct a Method, wrap a transport — but they differ sharply in their security posture. Pick by the credential you can hold and the assurance you need.

Method Spec Constructor When to choose it Security posture
client_secret_basic RFC 6749 §2.3.1 ClientSecretBasic Simplest registration; the server only supports HTTP Basic. Shared secret sent on every request (base64, not encrypted). Requires TLS. Weakest.
client_secret_post RFC 6749 §2.3.1 ClientSecretPost The server requires credentials in the form body rather than a header. Shared secret sent in the body on every request. Requires TLS. Equivalent strength to basic.
client_secret_jwt RFC 7523 §2.2 ClientSecretJWT You hold a shared secret but want to keep it off the wire. HMAC-SHA-256 over a short-lived assertion: the secret signs but is never transmitted. Still a shared secret (both sides hold it). Secret MUST be ≥ 32 bytes.
private_key_jwt RFC 7523 §2.2 PrivateKeyJWT New high-security deployments; you can hold a private key. Asymmetric. The server holds only the public key; the private key can live in an HSM/KMS and never leaves it. Strongest software option.
tls_client_auth RFC 8705 §2.1 TLSClientAuth The server validates a client certificate against a CA (PKI). Asymmetric, bound to the TLS channel. Private key can be HSM/KMS-backed. Strongest; resists token replay across channels.
self_signed_tls_client_auth RFC 8705 §2.2 SelfSignedTLSClientAuth mTLS without a CA; the server matches the cert against your registered JWKS. Same channel-bound strength as tls_client_auth; differs only in server-side validation.

Guidance in short:

  • For new, high-security deployments, prefer private_key_jwt or mutual TLS. Both are asymmetric — the authorization server never holds your private key, so a breach of the server cannot impersonate you, and the key can sit in an HSM or KMS.
  • client_secret_jwt is a meaningful step up from the plaintext-secret methods because the secret never travels, but it remains a shared secret: the server stores a verifier capable of minting your assertions.
  • client_secret_basic / client_secret_post are fine for low-risk or legacy integrations, but they put a long-lived bearer secret on the wire on every request and depend entirely on TLS to protect it.

Usage

The package exposes a Method interface and one constructor per method. A configured Method decorates a base http.RoundTripper; NewClient is the convenience wrapper that returns a ready *http.Client, and Transport returns the bare http.RoundTripper when you want to assign it yourself. A nil base defaults to http.DefaultTransport.

import authn "github.com/hstern/go-oauth-client-authn"

client_secret_basic — credentials in the Authorization header:

client := authn.NewClient(
    authn.ClientSecretBasic("s6BhdRkqt3", clientSecret),
    nil,
)

client_secret_post — credentials in the form body:

client := authn.NewClient(
    authn.ClientSecretPost("s6BhdRkqt3", clientSecret),
    nil,
)

client_secret_jwt — HMAC-signed assertion (secret ≥ 32 bytes):

client := authn.NewClient(
    authn.ClientSecretJWT("s6BhdRkqt3", clientSecret),
    nil,
)

private_key_jwt — assertion signed by a crypto.Signer:

client := authn.NewClient(
    authn.PrivateKeyJWT("s6BhdRkqt3", signer), // signer is any crypto.Signer
    nil,
)

tls_client_auth / self_signed_tls_client_auth — mutual TLS. These need an *http.Transport base so the client certificate can be injected into the TLS handshake (see Security notes):

cert, err := tls.LoadX509KeyPair("client.crt", "client.key")
// handle err

base := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}}
client := authn.NewClient(authn.TLSClientAuth("s6BhdRkqt3", cert), base)
Plugging into an existing transport chain

Method decorates whatever base http.RoundTripper you pass, so it slots into an existing chain (logging, retries, tracing) by wrapping the transport you already have:

authenticated := authn.Transport(method, myExistingTransport)
httpClient := &http.Client{Transport: authenticated, Timeout: 10 * time.Second}

The same Method value is reusable and stateless: it composes onto any number of base transports and authenticates requests to any token-family endpoint (token, pushed-authorization, introspection, revocation), because the wire effect is derived per request, not fixed at construction.

Key input

For the asymmetric methods, a crypto.Signer is the canonical key input: it is the interface an HSM- or KMS-backed key implements, so the private material can stay on the device and never enter your process memory. PrivateKeyJWT takes a crypto.Signer directly.

When the key is on disk or arrives in a registration response, two convenience parsers turn it into a crypto.Signer so you don't hand-roll crypto/x509:

signer, err := authn.SignerFromPEM(pemBytes) // SEC 1, PKCS#1, or PKCS#8
signer, err := authn.SignerFromJWK(jwkBytes) // RFC 7517 JSON Web Key

SignerFromJWK rejects a JWK that carries only public-key material, since a key used to sign assertions must be private.

Composition

The library's reason to exist is to authenticate the requests a token-family client makes. NewClient returns exactly the *http.Client such a client consumes through its HTTP-client seam. Using go-token-exchange (RFC 8693) as the worked example — the same composition the test suite verifies end to end:

hc := authn.NewClient(authn.PrivateKeyJWT(clientID, signer), nil)

client := tokenexchange.NewClient(
    tokenEndpoint,
    tokenexchange.WithHTTPClient(hc),
)

resp, err := client.Exchange(ctx, &tokenexchange.TokenExchangeRequest{
    GrantType:        tokenexchange.GrantTypeTokenExchange,
    SubjectToken:     subjectToken,
    SubjectTokenType: tokenexchange.TokenTypeAccessToken,
})

Client authentication and the grant share one form body without clobbering each other: the method adds client_id, client_assertion, or an Authorization header as appropriate, while the grant parameters (grant_type, subject_token, …) pass through untouched. Any client that accepts a custom *http.Client composes the same way.

Security notes

  • Secrets, keys, and assertions are bearer-equivalent and never logged by the library. Error values never embed a secret, a signing key, or an assertion. Treat them the same way in your own logs.
  • Always use TLS. The shared-secret methods put a long-lived credential on the wire; the assertion methods put a bearer assertion on the wire. None of them is safe over plaintext HTTP.
  • client_secret_jwt requires a 32-byte secret floor. HS256 needs a key at least as long as its 256-bit output (RFC 7518 §3.2); shorter secrets are rejected rather than silently weakened.
  • Assertions are short-lived and single-use. Each assertion carries a fresh 128-bit jti (RFC 7523 §3) and a short default lifetime (60 seconds), so a captured assertion is useless almost immediately. Tune the window with WithAssertionLifetime only if your authorization server's clock skew demands it.
  • Bind the assertion audience to the endpoint. By default the aud claim is derived from the request URL, so one Method works across the token, PAR, introspection, and revocation endpoints. Use WithAudience when the server requires its issuer identifier instead.
  • mTLS requires an *http.Transport base. A client certificate can only be injected through a transport that exposes a TLSClientConfig. The mTLS methods clone the base transport and set only the client certificate, leaving MinVersion, RootCAs, ServerName, and the rest untouched; if the base is not an *http.Transport, every request fails with a clear local error rather than going out unauthenticated.
  • Rotate keys without downtime. Set the JWT kid header with WithKeyID and publish multiple keys in your JWKS so the authorization server can select the right verification key during a rollover.

Spec references

  • RFC 6749 §2.3.1 — client password (client_secret_basic, client_secret_post).
  • RFC 7523 — JWT profile for OAuth 2.0 client authentication (client_secret_jwt, private_key_jwt).
  • RFC 8705 — mutual-TLS client authentication (tls_client_auth, self_signed_tls_client_auth).
  • RFC 7591 — dynamic client registration, source of the token_endpoint_auth_method identifiers each Method reports via Name().

Status and versioning

This library is pre-1.0. It follows Semantic Versioning; while the major version is 0, the public API may change between minor releases. Pin a version and review the changelog before upgrading. The first tagged release will be v0.1.0. The version is independent of the OAuth specifications it implements.

License

Apache License 2.0.

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

Examples

Constants

View Source
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

func SignerFromJWK(jwkJSON []byte) (crypto.Signer, error)

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

func SignerFromPEM(pemBytes []byte) (crypto.Signer, error)

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:

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

func ClientSecretBasic(clientID, clientSecret string) Method

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

func ClientSecretPost(clientID, clientSecret string) Method

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

func WithServerName(serverName string) TLSOption

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.

Jump to

Keyboard shortcuts

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