Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions example/server/exampleop/op.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ func newOP(
UserFormPath: "/device",
UserCode: op.UserCodeBase20,
},

// mTLS authentication (RFC 8705) - uncomment to enable
// To use mTLS clients, you need to:
// 1. Configure TLS on your server to request client certificates
// 2. Set up a Trust Store with your CA certificates
// 3. Register clients using storage.MTLSClient() or storage.SelfSignedTLSClient()
//
// AuthMethodTLSClientAuth: true,
// AuthMethodSelfSignedTLSClientAuth: true,
// TLSClientCertificateBoundAccessTokens: true,
// MTLSConfig: &op.MTLSConfig{
// TrustStore: yourCACertPool, // x509.CertPool with trusted CAs
// // Optional: restrict by Policy OID or EKU
// // RequiredPolicyOIDs: []asn1.ObjectIdentifier{...},
// // RequiredEKUs: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
// },
}
handler, err := op.NewOpenIDProvider(issuer, config, storage,
append([]op.Option{
Expand Down
78 changes: 78 additions & 0 deletions example/server/storage/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package storage

import (
"crypto/x509"
"time"

"github.com/zitadel/oidc/v3/pkg/oidc"
Expand Down Expand Up @@ -34,6 +35,11 @@ type Client struct {
clockSkew time.Duration
postLogoutRedirectURIGlobs []string
redirectURIGlobs []string

// mTLS authentication (RFC 8705)
mtlsConfig *op.MTLSClientConfig // for tls_client_auth
registeredCerts []string // for self_signed_tls_client_auth (PEM-encoded)
registeredCertsParsed []*x509.Certificate // parsed certificates (internal)
}

// GetID must return the client_id
Expand Down Expand Up @@ -127,6 +133,18 @@ func (c *Client) ClockSkew() time.Duration {
return c.clockSkew
}

// GetMTLSConfig returns the mTLS client configuration for tls_client_auth.
// Implements op.HasMTLSConfig interface.
func (c *Client) GetMTLSConfig() *op.MTLSClientConfig {
return c.mtlsConfig
}

// GetRegisteredCertificates returns the registered certificates for self_signed_tls_client_auth.
// Implements op.HasSelfSignedCertificate interface.
func (c *Client) GetRegisteredCertificates() []string {
return c.registeredCerts
}

// RegisterClients enables you to register clients for the example implementation
// there are some clients (web and native) to try out different cases
// add more if necessary
Expand Down Expand Up @@ -211,6 +229,66 @@ func DeviceClient(id, secret string) *Client {
}
}

// MTLSClient creates a client that uses tls_client_auth (PKI-based mTLS authentication).
// The client is identified by Subject DN or SAN (DNS/URI/IP/Email) in the certificate.
// This implements RFC 8705 Section 2.1.1.
//
// Parameters:
// - id: client identifier
// - mtlsConfig: mTLS client configuration specifying how to identify the client
// - boundTokens: if true, access tokens will be certificate-bound (cnf claim)
//
// Example:
//
// MTLSClient("mtls-client", &op.MTLSClientConfig{
// SubjectDN: "CN=client1,O=Example,C=US",
// TLSClientCertificateBoundAccessTokens: true,
// })
func MTLSClient(id string, mtlsConfig *op.MTLSClientConfig) *Client {
return &Client{
id: id,
applicationType: op.ApplicationTypeWeb,
authMethod: oidc.AuthMethodTLSClientAuth,
loginURL: defaultLoginURL,
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken, oidc.GrantTypeClientCredentials},
accessTokenType: op.AccessTokenTypeJWT, // Required for certificate-bound tokens
mtlsConfig: mtlsConfig,
}
}

// SelfSignedTLSClient creates a client that uses self_signed_tls_client_auth.
// The client authenticates by presenting a certificate that matches one of the
// pre-registered certificates (compared by thumbprint).
// This implements RFC 8705 Section 2.1.2.
//
// Parameters:
// - id: client identifier
// - certificates: PEM-encoded certificates to register for this client
// - boundTokens: if true, access tokens will be certificate-bound (cnf claim)
//
// Example:
//
// certPEM := `-----BEGIN CERTIFICATE-----
// MIIBkTCB+wIJAK...
// -----END CERTIFICATE-----`
// SelfSignedTLSClient("self-signed-client", true, certPEM)
func SelfSignedTLSClient(id string, boundTokens bool, certificates ...string) *Client {
return &Client{
id: id,
applicationType: op.ApplicationTypeWeb,
authMethod: oidc.AuthMethodSelfSignedTLSClientAuth,
loginURL: defaultLoginURL,
responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode},
grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken, oidc.GrantTypeClientCredentials},
accessTokenType: op.AccessTokenTypeJWT, // Required for certificate-bound tokens
registeredCerts: certificates,
mtlsConfig: &op.MTLSClientConfig{
TLSClientCertificateBoundAccessTokens: boundTokens,
},
}
}

type hasRedirectGlobs struct {
*Client
}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/bmatcuk/doublestar/v4 v4.9.2
github.com/go-chi/chi/v5 v5.2.3
github.com/go-jose/go-jose/v4 v4.0.5
github.com/go-ldap/ldap/v3 v3.4.11
github.com/golang/mock v1.6.0
github.com/google/go-github/v31 v31.0.0
github.com/google/uuid v1.6.0
Expand All @@ -25,8 +26,10 @@ require (
)

require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
Expand Down
22 changes: 22 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand All @@ -31,6 +39,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA=
github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
Expand Down
22 changes: 22 additions & 0 deletions pkg/oidc/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ type DiscoveryConfiguration struct {
// BackChannelLogoutSessionSupported specifies whether the OP can pass a sid (session ID) Claim in the Logout Token to identify the RP session with the OP.
// If supported, the sid Claim is also included in ID Tokens issued by the OP. If omitted, the default value is false.
BackChannelLogoutSessionSupported bool `json:"backchannel_logout_session_supported,omitempty"`

// TLSClientCertificateBoundAccessTokens indicates whether the authorization server supports
// issuing certificate-bound access tokens as defined in RFC 8705.
TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"`

// MTLSEndpointAliases contains alternative endpoints for mTLS client authentication.
// These endpoints require mutual TLS authentication.
MTLSEndpointAliases *MTLSEndpointAliases `json:"mtls_endpoint_aliases,omitempty"`
}

// MTLSEndpointAliases contains alternative endpoints for mTLS client authentication
// as defined in RFC 8705 Section 5.
type MTLSEndpointAliases struct {
TokenEndpoint string `json:"token_endpoint,omitempty"`
IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"`
RevocationEndpoint string `json:"revocation_endpoint,omitempty"`
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
}

type AuthMethod string
Expand All @@ -162,8 +179,13 @@ const (
AuthMethodPost AuthMethod = "client_secret_post"
AuthMethodNone AuthMethod = "none"
AuthMethodPrivateKeyJWT AuthMethod = "private_key_jwt"

// RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication
AuthMethodTLSClientAuth AuthMethod = "tls_client_auth"
AuthMethodSelfSignedTLSClientAuth AuthMethod = "self_signed_tls_client_auth"
)

var AllAuthMethods = []AuthMethod{
AuthMethodBasic, AuthMethodPost, AuthMethodNone, AuthMethodPrivateKeyJWT,
AuthMethodTLSClientAuth, AuthMethodSelfSignedTLSClientAuth,
}
8 changes: 8 additions & 0 deletions pkg/oidc/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ func (i *IDTokenClaims) UnmarshalJSON(data []byte) error {
return unmarshalJSONMulti(data, (*itcAlias)(i), &i.Claims)
}

// Confirmation represents the "cnf" (confirmation) claim as defined in RFC 7800.
// This is used for certificate-bound access tokens per RFC 8705.
type Confirmation struct {
// X509CertificateSHA256Thumbprint is the SHA-256 thumbprint of the X.509 certificate
// that the access token is bound to, base64url encoded.
X509CertificateSHA256Thumbprint string `json:"x5t#S256,omitempty"`
}

// ActorClaims provides the `act` claims used for impersonation or delegation Token Exchange.
//
// An actor can be nested in case an obtained token is used as actor token to obtain impersonation or delegation.
Expand Down
105 changes: 98 additions & 7 deletions pkg/op/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,86 @@ type ClientJWTProfile interface {
JWTProfileVerifier(context.Context) *JWTProfileVerifier
}

// ClientMTLSProvider is an optional interface for providers that support mTLS client authentication.
type ClientMTLSProvider interface {
ClientProvider
MTLSConfig() *MTLSConfig
AuthMethodTLSClientAuthSupported() bool
AuthMethodSelfSignedTLSClientAuthSupported() bool
}

// ClientMTLSAuth authenticates a client using mTLS certificate.
// Returns:
// - (clientID, true, nil) on successful authentication
// - ("", false, nil) when mTLS is not configured or no certificate present (fallback to other methods)
// - ("", false, error) on authentication failure
func ClientMTLSAuth(r *http.Request, p ClientMTLSProvider) (clientID string, authenticated bool, err error) {
ctx, span := Tracer.Start(r.Context(), "ClientMTLSAuth")
defer span.End()

mtlsConfig := p.MTLSConfig()
// Determine client_id to identify which client needs to be validated.
// RFC 8705 requires the client_id parameter for mTLS client authentication, but
// we also check the BasicAuth username to fail-closed for mTLS-only clients.
clientID = r.FormValue("client_id")
if clientID == "" {
if basicID, _, ok := r.BasicAuth(); ok {
if decoded, err := url.QueryUnescape(basicID); err == nil {
clientID = decoded
}
}
}
if clientID == "" {
return "", false, nil // cannot identify client, fallback
}

// Get client from storage
client, err := p.Storage().GetClientByClientID(ctx, clientID)
if err != nil {
return "", false, nil // Client not found, fallback
}

// Check if client uses mTLS authentication
authMethod := client.AuthMethod()
if authMethod != oidc.AuthMethodTLSClientAuth && authMethod != oidc.AuthMethodSelfSignedTLSClientAuth {
return "", false, nil // Client doesn't use mTLS, fallback
}

// Try to extract certificate from request (required for mTLS clients)
certs, err := ClientCertificateFromRequest(r, mtlsConfig)
if err != nil || len(certs) == 0 {
return "", false, oidc.ErrInvalidClient().WithDescription("no client certificate provided")
}
cert := certs[0]

// Validate mTLS based on authentication method
if authMethod == oidc.AuthMethodTLSClientAuth {
if !p.AuthMethodTLSClientAuthSupported() {
return "", false, oidc.ErrInvalidClient().WithDescription("tls_client_auth not supported")
}
mtlsClient, ok := client.(HasMTLSConfig)
if !ok {
return "", false, oidc.ErrInvalidClient().WithDescription("client does not support mTLS configuration")
}
if err := ValidateTLSClientAuth(certs, mtlsConfig, mtlsClient.GetMTLSConfig()); err != nil {
return "", false, oidc.ErrInvalidClient().WithDescription("mTLS client authentication failed").WithParent(err)
}
} else { // self_signed_tls_client_auth
if !p.AuthMethodSelfSignedTLSClientAuthSupported() {
return "", false, oidc.ErrInvalidClient().WithDescription("self_signed_tls_client_auth not supported")
}
selfSignedClient, ok := client.(HasSelfSignedCertificate)
if !ok {
return "", false, oidc.ErrInvalidClient().WithDescription("client does not support self-signed certificates")
}
if err := ValidateSelfSignedTLSClientAuth(cert, selfSignedClient.GetRegisteredCertificates()); err != nil {
return "", false, oidc.ErrInvalidClient().WithDescription("mTLS client authentication failed").WithParent(err)
}
}

return clientID, true, nil
}

func ClientJWTAuth(ctx context.Context, ca oidc.ClientAssertionParams, verifier ClientJWTProfile) (clientID string, err error) {
ctx, span := Tracer.Start(ctx, "ClientJWTAuth")
defer span.End()
Expand Down Expand Up @@ -140,15 +220,14 @@ type clientData struct {
}

// ClientIDFromRequest parses the request form and tries to obtain the client ID
// and reports if it is authenticated, using a JWT or static client secrets over
// and reports if it is authenticated, using mTLS, JWT, or static client secrets over
// http basic auth.
//
// If the Provider implements IntrospectorJWTProfile and "client_assertion" is
// present in the form data, JWT assertion will be verified and the
// client ID is taken from there.
// If any of them is absent, basic auth is attempted.
// In absence of basic auth data, the unauthenticated client id from the form
// data is returned.
// Authentication methods are tried in this order:
// 1. mTLS (if provider implements ClientMTLSProvider and client uses tls_client_auth/self_signed_tls_client_auth)
// 2. JWT assertion (if provider implements ClientJWTProfile and client_assertion is present)
// 3. Basic auth (client_secret_basic)
// 4. Form body (client_secret_post / none)
//
// If no client id can be obtained by any method, oidc.ErrInvalidClient
// is returned with ErrMissingClientID wrapped in it.
Expand All @@ -167,6 +246,18 @@ func ClientIDFromRequest(r *http.Request, p ClientProvider) (clientID string, au
return "", false, err
}

// Try mTLS authentication first (RFC 8705)
if mtlsProvider, ok := p.(ClientMTLSProvider); ok {
clientID, authenticated, err = ClientMTLSAuth(r, mtlsProvider)
if err != nil {
return "", false, err // mTLS auth failed
}
if authenticated {
return clientID, true, nil // mTLS auth succeeded
}
// Fallback to other auth methods
}

JWTProfile, ok := p.(ClientJWTProfile)
if ok && data.ClientAssertion != "" {
// if JWTProfile is supported and client sent an assertion, check it and use it as response
Expand Down
Loading