From 3e9d152e1ca8baeb179da38c100dfa009476d713 Mon Sep 17 00:00:00 2001 From: Stefan von Torklus Date: Mon, 19 Oct 2026 12:07:54 +0200 Subject: [PATCH 1/5] fix(auth): enforce configured x5t validation --- auth/middleware.go | 31 +++----- auth/middleware_x5t_test.go | 136 ++++++++++++++++++++++++++++++++++++ auth/validator.go | 37 ++++++++-- 3 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 auth/middleware_x5t_test.go diff --git a/auth/middleware.go b/auth/middleware.go index 5563b68..67bc429 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -35,8 +35,16 @@ type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) // Options can be used as a argument to instantiate a AuthMiddle with NewMiddleware. type Options struct { - ErrorHandler ErrorHandler // ErrorHandler called if the jwt verification fails and the AuthenticationHandler middleware func is used. Default: DefaultErrorHandler - HTTPClient *http.Client // HTTPClient which is used for OIDC discovery and to retrieve JWKs (JSON Web Keys). Default: basic http.Client with a timeout of 15 seconds + ErrorHandler ErrorHandler // ErrorHandler called if the jwt verification fails and the AuthenticationHandler middleware func is used. Default: DefaultErrorHandler + HTTPClient *http.Client // HTTPClient which is used for OIDC discovery and to retrieve JWKs (JSON Web Keys). Default: basic http.Client with a timeout of 15 seconds + Validation ValidationOptions // Validation config for sender-constrained tokens. Disabled by default for backward compatibility. +} + +// ValidationOptions configures optional token ownership checks. +type ValidationOptions struct { + // X5T requires a client certificate from x-forwarded-client-cert and validates its SHA-256 thumbprint against cnf.x5t#S256. + // Enable this only when a trusted proxy strips caller-supplied headers and forwards the TLS client certificate. + X5T bool } // TokenFromCtx retrieves the claims of a request which @@ -117,25 +125,8 @@ func (m *Middleware) AuthenticateWithProofOfPossession(r *http.Request) (Token, return Token{}, nil, err } - token, err := m.ParseAndValidateJWT(rawToken) - if err != nil { - return Token{}, nil, err - } - const forwardedClientCertHeader = "x-forwarded-client-cert" - var cert *Certificate - cert, err = newCertificate(r.Header.Get(forwardedClientCertHeader)) - if err != nil { - return Token{}, nil, err - } - if "1" == "" && cert != nil { // TODO integrate proof of possession into middleware - err = validateCertificate(cert, token) - if err != nil { - return Token{}, nil, err - } - } - - return token, cert, nil + return m.parseAndValidateJWT(rawToken, r.Header.Get(forwardedClientCertHeader)) } // AuthenticationHandler authenticates a request and injects the claims into diff --git a/auth/middleware_x5t_test.go b/auth/middleware_x5t_test.go new file mode 100644 index 0000000..052ffea --- /dev/null +++ b/auth/middleware_x5t_test.go @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors +// +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sap/cloud-security-client-go/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthenticateWithProofOfPossession_X5TValidation(t *testing.T) { + mockServer, err := mocks.NewOIDCMockServer() + require.NoError(t, err) + defer mockServer.Server.Close() + + middleware := NewMiddleware(mockServer.Config, Options{ + HTTPClient: mockServer.Server.Client(), + Validation: ValidationOptions{X5T: true}, + }) + matchingToken, err := mockServer.SignTokenWithAdditionalClaims( + mockServer.DefaultClaims(), + map[string]interface{}{claimCnf: map[string]interface{}{claimCnfMemberX5t: "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM"}}, + mockServer.DefaultHeaders(), + ) + require.NoError(t, err) + missingConfirmationToken, err := mockServer.SignToken(mockServer.DefaultClaims(), mockServer.DefaultHeaders()) + require.NoError(t, err) + + tests := []struct { + name string + token string + certificate string + wantErr string + }{ + { + name: "accepts matching certificate", + token: matchingToken, + certificate: derCertFromFile, + }, + { + name: "rejects missing certificate", + token: matchingToken, + wantErr: ErrNoClientCert.Error(), + }, + { + name: "rejects non-matching certificate", + token: matchingToken, + certificate: derCertGenerated, + wantErr: "token thumbprint confirmation failed", + }, + { + name: "rejects token without x5t confirmation", + token: missingConfirmationToken, + certificate: derCertFromFile, + wantErr: "token provides no cnf member for thumbprint confirmation", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.test", http.NoBody) + req.Header.Set(authorization, "Bearer "+tt.token) + req.Header.Set("x-forwarded-client-cert", tt.certificate) + + _, cert, err := middleware.AuthenticateWithProofOfPossession(req) + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + assert.Nil(t, cert) + return + } + + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + }) + } +} + +func TestAuthenticationHandler_X5TValidationOnlyExposesValidatedCertificate(t *testing.T) { + mockServer, err := mocks.NewOIDCMockServer() + require.NoError(t, err) + defer mockServer.Server.Close() + + middleware := NewMiddleware(mockServer.Config, Options{ + HTTPClient: mockServer.Server.Client(), + Validation: ValidationOptions{X5T: true}, + }) + token, err := mockServer.SignTokenWithAdditionalClaims( + mockServer.DefaultClaims(), + map[string]interface{}{claimCnf: map[string]interface{}{claimCnfMemberX5t: "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM"}}, + mockServer.DefaultHeaders(), + ) + require.NoError(t, err) + + handlerCalled := false + handler := middleware.AuthenticationHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + cert, ok := ClientCertificateFromCtx(r) + require.True(t, ok) + require.NotNil(t, cert) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + w.WriteHeader(http.StatusNoContent) + })) + + t.Run("does not invoke downstream handler for a non-matching certificate", func(t *testing.T) { + handlerCalled = false + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.test", http.NoBody) + req.Header.Set(authorization, "Bearer "+token) + req.Header.Set("x-forwarded-client-cert", derCertGenerated) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + assert.False(t, handlerCalled) + }) + + t.Run("invokes downstream handler with a matching certificate", func(t *testing.T) { + handlerCalled = false + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.test", http.NoBody) + req.Header.Set(authorization, "Bearer "+token) + req.Header.Set("x-forwarded-client-cert", derCertFromFile) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, http.StatusNoContent, res.Code) + assert.True(t, handlerCalled) + }) +} diff --git a/auth/validator.go b/auth/validator.go index 5630dec..dba919d 100644 --- a/auth/validator.go +++ b/auth/validator.go @@ -17,30 +17,55 @@ import ( "github.com/sap/cloud-security-client-go/oidcclient" ) -// ParseAndValidateJWT parses the token into its claims, verifies the claims and verifies the signature +// ParseAndValidateJWT parses the token into its claims, verifies the claims and verifies the signature. +// +// When X5T validation is enabled, use AuthenticateWithProofOfPossession instead so the +// forwarded client certificate can be validated together with the token. func (m *Middleware) ParseAndValidateJWT(rawToken string) (Token, error) { + token, _, err := m.parseAndValidateJWT(rawToken, "") + return token, err +} + +func (m *Middleware) parseAndValidateJWT(rawToken, clientCertificateHeader string) (Token, *Certificate, error) { token, err := NewToken(rawToken) if err != nil { - return Token{}, err + return Token{}, nil, err + } + + var cert *Certificate + if m.options.Validation.X5T { + cert, err = newCertificate(clientCertificateHeader) + if err != nil { + return Token{}, nil, err + } + if cert == nil { + return Token{}, nil, ErrNoClientCert + } } // get keyset keySet, err := m.getOIDCTenant(token.Issuer(), token.CustomIssuer()) if err != nil { - return Token{}, err + return Token{}, nil, err } // verify claims if err := m.validateClaims(token, keySet); err != nil { - return Token{}, err + return Token{}, nil, err } // verify signature if err := m.verifySignature(token, keySet); err != nil { - return Token{}, err + return Token{}, nil, err + } + + if m.options.Validation.X5T { + if err := validateCertificate(cert, token); err != nil { + return Token{}, nil, err + } } - return token, nil + return token, cert, nil } func (m *Middleware) verifySignature(t Token, keySet *oidcclient.OIDCTenant) (err error) { From 0f440e3f5cd1a4c4d280bf4f18316851a7f710eb Mon Sep 17 00:00:00 2001 From: Stefan von Torklus Date: Mon, 19 Oct 2026 12:14:12 +0200 Subject: [PATCH 2/5] feat(auth): validate IAS proof tokens --- README.md | 17 +++ auth/certificate.go | 96 +++++++++++-- auth/certificate_test.go | 22 ++- auth/middleware.go | 3 + auth/middleware_proof_token_test.go | 108 ++++++++++++++ auth/token.go | 22 ++- auth/validator.go | 43 +++--- mocks/mockServer.go | 29 +++- oidcclient/jwk.go | 210 ++++++++++++++++++---------- oidcclient/jwk_test.go | 17 ++- 10 files changed, 451 insertions(+), 116 deletions(-) create mode 100644 auth/middleware_proof_token_test.go diff --git a/README.md b/README.md index e0c8f3e..b0794ce 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,23 @@ To access service instance configurations from the application, Kubernetes secre ### Usage Sample [samples/middleware.go](sample/middleware.go) +### Sender-constrained tokens + +`Middleware` can validate sender-constrained tokens when it runs behind a trusted proxy that removes caller-supplied `x-forwarded-client-cert` headers and forwards the TLS client certificate. + +```go +middleware := auth.NewMiddleware(identity, auth.Options{ + Validation: auth.ValidationOptions{ + X5T: true, + ProofToken: true, + }, +}) +``` + +- `X5T` validates the forwarded client certificate against the JWT's `cnf.x5t#S256` claim. +- `ProofToken` requires a client certificate for multi-audience App-to-Service tokens. The certificate is sent to IAS with the JWKS request, so IAS validates the caller before returning signing keys. Single-audience tokens do not require a proof token. +- After successful proof token validation, `Token.ServicePlans()` returns the consumer application service plans supplied by IAS. It returns `nil` when no proof token validation was performed. + ### Testing The client library offers an OIDC Mock Server with means to create arbitrary tokens for testing purposes. Examples for the usage of the Mock Server in combination with the OIDC Token Builder can be found in [auth/middleware_test.go](auth/middleware_test.go) diff --git a/auth/certificate.go b/auth/certificate.go index ea857e3..88ae24e 100644 --- a/auth/certificate.go +++ b/auth/certificate.go @@ -10,8 +10,12 @@ import ( "encoding/base64" "encoding/pem" "fmt" + "net/url" + "strings" ) +const pemCertificateType = "CERTIFICATE" + // Certificate is the public API to access claims of the X509 client certificate. type Certificate struct { x509Cert *x509.Certificate @@ -39,21 +43,21 @@ func (c *Certificate) GetThumbprint() string { return thumbprint } +func (c *Certificate) normalizedPEM() string { + pemCertificate := pem.EncodeToMemory(&pem.Block{Type: pemCertificateType, Bytes: c.x509Cert.Raw}) + return strings.NewReplacer("\\n", "", "\n", "").Replace(string(pemCertificate)) +} + func parseCertificate(certString string) (*x509.Certificate, error) { if certString == "" { return nil, nil } - const PEMIndicator string = "-----BEGIN" - decoded, err := base64.StdEncoding.DecodeString(certString) + + certificate := extractCertificateFromForwardedHeader(certString) + certificate = strings.ReplaceAll(certificate, "\\n", "\n") + decoded, err := decodeCertificate(certificate) if err != nil { - return nil, fmt.Errorf("cannot base64 decode certificate header: %w", err) - } - if bytes.HasPrefix(decoded, []byte(PEMIndicator)) { // in case of apache proxy - pemBlock, err := pem.Decode(decoded) - if pemBlock == nil { - return nil, fmt.Errorf("cannot decode PEM formatted certificate header: %v", err) - } - decoded = pemBlock.Bytes + return nil, err } cert, err := x509.ParseCertificate(decoded) if err != nil { @@ -61,3 +65,75 @@ func parseCertificate(certString string) (*x509.Certificate, error) { } return cert, nil } + +func decodeCertificate(certificate string) ([]byte, error) { + if pemBlock, _ := pem.Decode([]byte(certificate)); pemBlock != nil { + return pemBlock.Bytes, nil + } + + decoded, err := base64.StdEncoding.DecodeString(certificate) + if err != nil { + return nil, fmt.Errorf("cannot base64 decode certificate header: %w", err) + } + if bytes.HasPrefix(decoded, []byte("-----BEGIN")) { + pemBlock, _ := pem.Decode(decoded) + if pemBlock == nil { + return nil, fmt.Errorf("cannot decode PEM formatted certificate header: malformed PEM") + } + return pemBlock.Bytes, nil + } + return decoded, nil +} + +func extractCertificateFromForwardedHeader(header string) string { + if !strings.Contains(strings.ToLower(header), "hash=") { + return header + } + + entries := splitHeaderValue(header, ',') + if len(entries) == 0 { + return header + } + for _, field := range splitHeaderValue(entries[len(entries)-1], ';') { + name, value, found := strings.Cut(field, "=") + if !found || !strings.EqualFold(strings.TrimSpace(name), "cert") { + continue + } + certificate := strings.Trim(strings.TrimSpace(value), "\"'") + certificate = strings.ReplaceAll(certificate, `\"`, `"`) + if decoded, err := url.PathUnescape(certificate); err == nil { + return decoded + } + return certificate + } + return header +} + +func splitHeaderValue(value string, delimiter rune) []string { + var parts []string + var current strings.Builder + inQuotes := false + for index, character := range value { + if character == '"' && !isEscaped(value, index) { + inQuotes = !inQuotes + } + if character == delimiter && !inQuotes { + parts = append(parts, strings.TrimSpace(current.String())) + current.Reset() + continue + } + current.WriteRune(character) + } + if current.Len() > 0 { + parts = append(parts, strings.TrimSpace(current.String())) + } + return parts +} + +func isEscaped(value string, index int) bool { + backslashCount := 0 + for index--; index >= 0 && value[index] == '\\'; index-- { + backslashCount++ + } + return backslashCount%2 == 1 +} diff --git a/auth/certificate_test.go b/auth/certificate_test.go index e2ff87e..11fbc0b 100644 --- a/auth/certificate_test.go +++ b/auth/certificate_test.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/pem" "math/big" + "net/url" "testing" "github.com/lestrrat-go/jwx/jwt" @@ -50,6 +51,20 @@ func TestCertificate(t *testing.T) { cert, _ := newCertificate(derCertFromFile) assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) }) + + t.Run("GetThumbprint() for PEM forwarded by Cloud Foundry", func(t *testing.T) { + cert, err := newCertificate(certificatePEM(t, derCertFromFile)) + require.NoError(t, err) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + }) + + t.Run("GetThumbprint() for XFCC forwarded by Envoy", func(t *testing.T) { + certificate := certificatePEM(t, derCertFromFile) + xfcc := `By=spiffe://proxy;Hash=abc;Cert="` + url.PathEscape(certificate) + `"` + cert, err := newCertificate(xfcc) + require.NoError(t, err) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + }) } func generateToken(t *testing.T, claimCnfMemberX5tValue string) Token { @@ -64,11 +79,14 @@ func generateToken(t *testing.T, claimCnfMemberX5tValue string) Token { } func convertToPEM(t *testing.T, derCert string) string { + return base64.StdEncoding.EncodeToString([]byte(certificatePEM(t, derCert))) +} + +func certificatePEM(t *testing.T, derCert string) string { x509Cert, err := newCertificate(derCert) require.NoError(t, err, "failed to create certificate: %v", err) - bytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: x509Cert.x509Cert.Raw}) - return base64.StdEncoding.EncodeToString(bytes) + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: x509Cert.x509Cert.Raw})) } func generateDERCert() string { diff --git a/auth/middleware.go b/auth/middleware.go index 67bc429..1fc6ba3 100644 --- a/auth/middleware.go +++ b/auth/middleware.go @@ -45,6 +45,9 @@ type ValidationOptions struct { // X5T requires a client certificate from x-forwarded-client-cert and validates its SHA-256 thumbprint against cnf.x5t#S256. // Enable this only when a trusted proxy strips caller-supplied headers and forwards the TLS client certificate. X5T bool + // ProofToken requires a client certificate for multi-audience tokens and lets IAS validate the caller during the JWKS request. + // Single-audience tokens do not require proof token validation. + ProofToken bool } // TokenFromCtx retrieves the claims of a request which diff --git a/auth/middleware_proof_token_test.go b/auth/middleware_proof_token_test.go new file mode 100644 index 0000000..05249f6 --- /dev/null +++ b/auth/middleware_proof_token_test.go @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors +// +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/sap/cloud-security-client-go/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthenticateWithProofOfPossession_ProofTokenValidation(t *testing.T) { + mockServer, err := mocks.NewOIDCMockServer() + require.NoError(t, err) + defer mockServer.Server.Close() + + clientCertificateHeader := `By=spiffe://proxy;Hash=abc;Cert="` + url.PathEscape(certificatePEM(t, derCertFromFile)) + `"` + clientCertificate, err := newCertificate(clientCertificateHeader) + require.NoError(t, err) + mockServer.ProofTokenCertificate = clientCertificate.normalizedPEM() + mockServer.ProofTokenServicePlans = []string{"standard", "premium"} + middleware := NewMiddleware(mockServer.Config, Options{ + HTTPClient: mockServer.Server.Client(), + Validation: ValidationOptions{ProofToken: true}, + }) + multiAudienceToken := signProofToken(t, mockServer, mockServer.Config.ClientID, "provided-api") + + t.Run("rejects a multi-audience token without a client certificate", func(t *testing.T) { + req := proofTokenRequest(multiAudienceToken, "") + + _, _, err := middleware.AuthenticateWithProofOfPossession(req) + + assert.ErrorIs(t, err, ErrNoClientCert) + assert.Zero(t, mockServer.JWKsHitCounter) + }) + + t.Run("validates a multi-audience token online and exposes the consumer service plans", func(t *testing.T) { + req := proofTokenRequest(multiAudienceToken, clientCertificateHeader) + + token, cert, err := middleware.AuthenticateWithProofOfPossession(req) + + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, []string{"standard", "premium"}, token.ServicePlans()) + plans := token.ServicePlans() + plans[0] = "modified" + assert.Equal(t, []string{"standard", "premium"}, token.ServicePlans()) + assert.Equal(t, clientCertificate.normalizedPEM(), mockServer.LastJWKsRequestHeader.Get("x-client_cert")) + assert.Equal(t, "consumer-client", mockServer.LastJWKsRequestHeader.Get("x-azp")) + assert.Equal(t, "consumer-app-tid", mockServer.LastJWKsRequestHeader.Get("x-azp_app_tid")) + assert.Equal(t, mockServer.Config.ClientID, mockServer.LastJWKsRequestHeader.Get("x-client_id")) + assert.Equal(t, mockServer.DefaultClaims().AppTID, mockServer.LastJWKsRequestHeader.Get("x-app_tid")) + + cachedToken, _, err := middleware.AuthenticateWithProofOfPossession(req) + require.NoError(t, err) + assert.Equal(t, []string{"standard", "premium"}, cachedToken.ServicePlans()) + assert.Equal(t, 1, mockServer.JWKsHitCounter) + }) + + t.Run("does not reuse a proof token result for a different certificate", func(t *testing.T) { + req := proofTokenRequest(multiAudienceToken, derCertGenerated) + + _, _, err := middleware.AuthenticateWithProofOfPossession(req) + + assert.ErrorContains(t, err, "failed to fetch jwks from remote") + assert.Equal(t, 2, mockServer.JWKsHitCounter) + }) + + t.Run("does not require a certificate for a single-audience token", func(t *testing.T) { + mockServer.ProofTokenCertificate = "" + singleAudienceToken := signProofToken(t, mockServer, mockServer.Config.ClientID) + req := proofTokenRequest(singleAudienceToken, "") + + token, cert, err := middleware.AuthenticateWithProofOfPossession(req) + + require.NoError(t, err) + assert.Nil(t, cert) + assert.Nil(t, token.ServicePlans()) + assert.Empty(t, mockServer.LastJWKsRequestHeader.Get("x-client_cert")) + assert.Equal(t, "consumer-app-tid", mockServer.LastJWKsRequestHeader.Get("x-azp_app_tid")) + }) +} + +func signProofToken(t *testing.T, mockServer *mocks.MockServer, audiences ...string) string { + t.Helper() + claims := mocks.NewOIDCClaimsBuilder(mockServer.DefaultClaims()).Audience(audiences...).Build() + token, err := mockServer.SignTokenWithAdditionalClaims(claims, map[string]interface{}{ + claimAzp: "consumer-client", + claimAzpAppTID: "consumer-app-tid", + }, mockServer.DefaultHeaders()) + require.NoError(t, err) + return token +} + +func proofTokenRequest(token, certificate string) *http.Request { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.test", http.NoBody) + req.Header.Set(authorization, "Bearer "+token) + if certificate != "" { + req.Header.Set("x-forwarded-client-cert", certificate) + } + return req +} diff --git a/auth/token.go b/auth/token.go index 5defacf..c9448ca 100644 --- a/auth/token.go +++ b/auth/token.go @@ -25,6 +25,7 @@ const ( claimSapGlobalAppTID = "app_tid" claimIasIssuer = "ias_iss" claimAzp = "azp" + claimAzpAppTID = "azp_app_tid" claimScimID = "scim_id" claimGroups = "groups" claimIasAPIs = "ias_apis" @@ -33,6 +34,7 @@ const ( type Token struct { encodedToken string jwtToken jwt.Token + servicePlans []string } // NewToken creates a Token from an encoded jwt. !!! WARNING !!! No validation done when creating a Token this way. Use only in tests! @@ -144,6 +146,18 @@ func (t Token) Azp() string { return appTID } +// AzpAppTID returns "azp_app_tid" claim, if it doesn't exist empty string is returned. +func (t Token) AzpAppTID() string { + appTID, _ := t.GetClaimAsString(claimAzpAppTID) + return appTID +} + +// ServicePlans returns the service plans of the proof-token-authenticated caller. +// It returns nil when proof token validation was not performed for this token. +func (t Token) ServicePlans() []string { + return append([]string(nil), t.servicePlans...) +} + // UserUUID returns "user_uuid" claim, if it doesn't exist empty string is returned func (t Token) UserUUID() string { v, _ := t.GetClaimAsString(claimSapGlobalUserID) @@ -238,9 +252,9 @@ func (t Token) getCnfClaimMember(memberName string) string { if errors.Is(err, ErrClaimNotExists) || cnfClaim == nil { return "" } - res, ok := cnfClaim[memberName] - if ok { - return res.(string) + res, ok := cnfClaim[memberName].(string) + if !ok { + return "" } - return "" + return res } diff --git a/auth/validator.go b/auth/validator.go index dba919d..624b8eb 100644 --- a/auth/validator.go +++ b/auth/validator.go @@ -19,7 +19,7 @@ import ( // ParseAndValidateJWT parses the token into its claims, verifies the claims and verifies the signature. // -// When X5T validation is enabled, use AuthenticateWithProofOfPossession instead so the +// When X5T or proof token validation is enabled, use AuthenticateWithProofOfPossession instead so the // forwarded client certificate can be validated together with the token. func (m *Middleware) ParseAndValidateJWT(rawToken string) (Token, error) { token, _, err := m.parseAndValidateJWT(rawToken, "") @@ -32,8 +32,9 @@ func (m *Middleware) parseAndValidateJWT(rawToken, clientCertificateHeader strin return Token{}, nil, err } + proofTokenCheckRequired := m.options.Validation.ProofToken && len(token.Audience()) > 1 var cert *Certificate - if m.options.Validation.X5T { + if m.options.Validation.X5T || proofTokenCheckRequired { cert, err = newCertificate(clientCertificateHeader) if err != nil { return Token{}, nil, err @@ -55,7 +56,8 @@ func (m *Middleware) parseAndValidateJWT(rawToken, clientCertificateHeader strin } // verify signature - if err := m.verifySignature(token, keySet); err != nil { + servicePlans, err := m.verifySignature(token, keySet, proofTokenCheckRequired, cert) + if err != nil { return Token{}, nil, err } @@ -64,37 +66,46 @@ func (m *Middleware) parseAndValidateJWT(rawToken, clientCertificateHeader strin return Token{}, nil, err } } + if proofTokenCheckRequired { + token.servicePlans = servicePlans + } return token, cert, nil } -func (m *Middleware) verifySignature(t Token, keySet *oidcclient.OIDCTenant) (err error) { +func (m *Middleware) verifySignature(t Token, keySet *oidcclient.OIDCTenant, proofTokenCheckRequired bool, clientCertificate *Certificate) ([]string, error) { headers, err := getHeaders(t.TokenValue()) if err != nil { - return err + return nil, err } alg := headers.Algorithm() // fail early to avoid another parsing of encoded token if alg == "" { - return errors.New("alg is missing from jwt header") + return nil, errors.New("alg is missing from jwt header") } // parse and verify signature - tenantOpts := oidcclient.ClientInfo{ - ClientID: m.identity.GetClientID(), - AppTID: t.AppTID(), - Azp: t.Azp(), - } - jwks, err := keySet.GetJWKs(tenantOpts) + jwksRequest := oidcclient.JWKsRequest{ + ClientInfo: oidcclient.ClientInfo{ + ClientID: m.identity.GetClientID(), + AppTID: t.AppTID(), + Azp: t.Azp(), + }, + AzpAppTID: t.AzpAppTID(), + } + if proofTokenCheckRequired { + jwksRequest.ClientCertificate = clientCertificate.normalizedPEM() + } + jwksResponse, err := keySet.GetJWKsForRequest(jwksRequest) if err != nil { - return err + return nil, err } - _, err = jwt.ParseString(t.TokenValue(), jwt.WithKeySet(jwks), jwt.UseDefaultKey(true)) + _, err = jwt.ParseString(t.TokenValue(), jwt.WithKeySet(jwksResponse.KeySet), jwt.UseDefaultKey(true)) if err != nil { - return err + return nil, err } - return nil + return jwksResponse.ServicePlans, nil } func getHeaders(encodedToken string) (jws.Headers, error) { diff --git a/mocks/mockServer.go b/mocks/mockServer.go index 2c9b796..480cd9f 100644 --- a/mocks/mockServer.go +++ b/mocks/mockServer.go @@ -44,12 +44,15 @@ const dummyMockToken = "eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a // MockServer serves as a single tenant OIDC mock server for tests. // Requests to the MockServer must be done by the mockServers client: MockServer.Server.Client() type MockServer struct { - Server *httptest.Server // Server holds the httptest.Server and its Client. - Config *MockConfig // Config holds the OIDC config which applications bind to the application. - RSAKey *rsa.PrivateKey // RSAKey holds the servers private key to sign tokens. - WellKnownHitCounter int // JWKsHitCounter holds the number of requests to the WellKnownHandler. - JWKsHitCounter int // JWKsHitCounter holds the number of requests to the JWKsHandler. - CustomIssuer string // CustomIssuer holds a custom domain returned by the discovery endpoint + Server *httptest.Server // Server holds the httptest.Server and its Client. + Config *MockConfig // Config holds the OIDC config which applications bind to the application. + RSAKey *rsa.PrivateKey // RSAKey holds the servers private key to sign tokens. + WellKnownHitCounter int // JWKsHitCounter holds the number of requests to the WellKnownHandler. + JWKsHitCounter int // JWKsHitCounter holds the number of requests to the JWKsHandler. + CustomIssuer string // CustomIssuer holds a custom domain returned by the discovery endpoint. + ProofTokenCertificate string // ProofTokenCertificate is the client certificate accepted for proof token test requests. + ProofTokenServicePlans []string // ProofTokenServicePlans are returned in the IAS service plan response header. + LastJWKsRequestHeader http.Header // LastJWKsRequestHeader holds the headers from the latest JWKS request. } // InvalidAppTID represents a guid which is rejected by mock server on behalf of IAS tenant @@ -144,8 +147,20 @@ func (m *MockServer) tokenHandler(w http.ResponseWriter, r *http.Request) { } // JWKsHandler is the http handler which answers requests to the JWKS endpoint. -func (m *MockServer) JWKsHandler(w http.ResponseWriter, _ *http.Request) { +func (m *MockServer) JWKsHandler(w http.ResponseWriter, r *http.Request) { m.JWKsHitCounter++ + m.LastJWKsRequestHeader = r.Header.Clone() + if m.ProofTokenCertificate != "" && r.Header.Get("x-client_cert") != m.ProofTokenCertificate { + w.WriteHeader(http.StatusBadRequest) + return + } + if len(m.ProofTokenServicePlans) > 0 { + plans := make([]string, len(m.ProofTokenServicePlans)) + for i, plan := range m.ProofTokenServicePlans { + plans[i] = "\"" + plan + "\"" + } + w.Header().Set("x-osb_plan", strings.Join(plans, ",")) + } key := &JSONWebKey{ Kid: testKeyID, Kty: "RSA", diff --git a/oidcclient/jwk.go b/oidcclient/jwk.go index 8118a37..56bb22c 100644 --- a/oidcclient/jwk.go +++ b/oidcclient/jwk.go @@ -6,6 +6,8 @@ package oidcclient import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -24,130 +26,196 @@ const defaultJwkExpiration = 15 * time.Minute const appTIDHeader = "x-app_tid" const clientIDHeader = "x-client_id" const azpHeader = "x-azp" +const azpAppTIDHeader = "x-azp_app_tid" +const clientCertificateHeader = "x-client_cert" +const servicePlanHeader = "x-osb_plan" -// OIDCTenant represents one IAS tenant correlating with one app_tid and client_id with it's OIDC discovery results and cached JWKs +// OIDCTenant represents one IAS tenant correlating with one app_tid and client_id with its OIDC discovery results and cached JWKs. type OIDCTenant struct { - ProviderJSON ProviderJSON - acceptedClients map[ClientInfo]bool - httpClient *http.Client - // A set of cached keys and their expiry. - jwks jwk.Set - jwksExpiry time.Time - mu sync.RWMutex + ProviderJSON ProviderJSON + httpClient *http.Client + jwksCache map[jwksCacheKey]jwksCacheEntry + mu sync.RWMutex } +// ClientInfo identifies the application for which IAS validates the JWK request. type ClientInfo struct { ClientID string AppTID string Azp string } -type updateKeysResult struct { - keys jwk.Set - expiry time.Time +// JWKsRequest specifies the token and client information IAS needs to validate before it returns signing keys. +type JWKsRequest struct { + ClientInfo + AzpAppTID string + ClientCertificate string } -// NewOIDCTenant instantiates a new OIDCTenant and performs the OIDC discovery +// JWKsResponse contains the validation keys and, when proof token validation was requested, the consumer's service plans. +type JWKsResponse struct { + KeySet jwk.Set + ServicePlans []string +} + +type jwksCacheKey struct { + clientInfo ClientInfo + azpAppTID string + clientCertificateHash string +} + +type jwksCacheEntry struct { + response JWKsResponse + expiry time.Time + accepted bool +} + +func (r JWKsRequest) cacheKey() jwksCacheKey { + certificateHash := sha256.Sum256([]byte(r.ClientCertificate)) + return jwksCacheKey{ + clientInfo: r.ClientInfo, + azpAppTID: r.AzpAppTID, + clientCertificateHash: hex.EncodeToString(certificateHash[:]), + } +} + +// NewOIDCTenant instantiates a new OIDCTenant and performs the OIDC discovery. func NewOIDCTenant(httpClient *http.Client, targetIssHost string) (*OIDCTenant, error) { - ks := new(OIDCTenant) - ks.httpClient = httpClient - ks.acceptedClients = make(map[ClientInfo]bool) - err := ks.performDiscovery(targetIssHost) - if err != nil { + ks := &OIDCTenant{ + httpClient: httpClient, + jwksCache: make(map[jwksCacheKey]jwksCacheEntry), + } + if err := ks.performDiscovery(targetIssHost); err != nil { return nil, err } return ks, nil } -// GetJWKs returns the validation keys either cached or updated ones +// GetJWKs returns the validation keys either cached or updated ones. func (ks *OIDCTenant) GetJWKs(clientInfo ClientInfo) (jwk.Set, error) { - keys, err := ks.readJWKsFromMemory(clientInfo) - if keys == nil { - if err != nil { - return nil, err - } - return ks.updateJWKsMemory(clientInfo) + response, err := ks.GetJWKsForRequest(JWKsRequest{ClientInfo: clientInfo}) + if err != nil { + return nil, err } - return keys, nil + return response.KeySet, nil } -// readJWKsFromMemory returns the validation keys from memory, or error in case of invalid header combination or nil, in case nothing found in memory -func (ks *OIDCTenant) readJWKsFromMemory(clientInfo ClientInfo) (jwk.Set, error) { +// GetJWKsForRequest returns the validation keys and proof token service plans for a specific IAS validation request. +func (ks *OIDCTenant) GetJWKsForRequest(request JWKsRequest) (JWKsResponse, error) { + cacheKey := request.cacheKey() + response, err := ks.readJWKsFromMemory(cacheKey, request.ClientInfo) + if response.KeySet != nil || err != nil { + return response, err + } + return ks.updateJWKsMemory(cacheKey, request) +} + +// readJWKsFromMemory returns the validation keys from memory, or an error when IAS rejected the same request. +func (ks *OIDCTenant) readJWKsFromMemory(cacheKey jwksCacheKey, clientInfo ClientInfo) (JWKsResponse, error) { ks.mu.RLock() defer ks.mu.RUnlock() - isClientAccepted, isClientKnown := ks.acceptedClients[clientInfo] - - if time.Now().Before(ks.jwksExpiry) && isClientKnown { - if isClientAccepted { - return ks.jwks, nil - } - return nil, fmt.Errorf("client credentials: %+v are not accepted by the identity service", clientInfo) + entry, found := ks.jwksCache[cacheKey] + if !found || !time.Now().Before(entry.expiry) { + return JWKsResponse{}, nil } - return nil, nil + if !entry.accepted { + return JWKsResponse{}, fmt.Errorf("client credentials: %+v are not accepted by the identity service", clientInfo) + } + return cloneJWKsResponse(entry.response), nil } -// updateJWKsMemory updates and returns the validation keys from memory, or error in case of invalid header combination nil, in case nothing found in memory -func (ks *OIDCTenant) updateJWKsMemory(clientInfo ClientInfo) (jwk.Set, error) { +func (ks *OIDCTenant) updateJWKsMemory(cacheKey jwksCacheKey, request JWKsRequest) (JWKsResponse, error) { ks.mu.Lock() defer ks.mu.Unlock() - updatedKeys, err := ks.getJWKsFromServer(clientInfo) + if entry, found := ks.jwksCache[cacheKey]; found && time.Now().Before(entry.expiry) { + if !entry.accepted { + return JWKsResponse{}, fmt.Errorf("client credentials: %+v are not accepted by the identity service", request.ClientInfo) + } + return cloneJWKsResponse(entry.response), nil + } + + response, expiry, rejected, err := ks.getJWKsFromServer(request) if err != nil { - return nil, fmt.Errorf("error updating JWKs: %v", err) + // Rejecting proof requests must not let caller-supplied certificates fill the cache. + if rejected && request.ClientCertificate == "" { + ks.jwksCache[cacheKey] = jwksCacheEntry{expiry: expiry, accepted: false} + } + return JWKsResponse{}, fmt.Errorf("error updating JWKs: %v", err) } - keysResult := updatedKeys.(updateKeysResult) - ks.jwksExpiry = keysResult.expiry - ks.jwks = keysResult.keys - return ks.jwks, nil + ks.jwksCache[cacheKey] = jwksCacheEntry{response: cloneJWKsResponse(response), expiry: expiry, accepted: true} + return response, nil } -func (ks *OIDCTenant) getJWKsFromServer(clientInfo ClientInfo) (r interface{}, err error) { - result := updateKeysResult{} +func (ks *OIDCTenant) getJWKsFromServer(request JWKsRequest) (JWKsResponse, time.Time, bool, error) { + result := JWKsResponse{} req, err := httpclient.NewRequestWithUserAgent(context.TODO(), http.MethodGet, ks.ProviderJSON.JWKsURL, http.NoBody) if err != nil { - return result, fmt.Errorf("can't create request to fetch jwk: %v", err) + return result, time.Time{}, false, fmt.Errorf("can't create request to fetch jwk: %v", err) + } + // At least client-id is necessary; the remaining headers refine IAS' validation. + req.Header.Add(clientIDHeader, request.ClientID) + req.Header.Add(appTIDHeader, request.AppTID) + req.Header.Add(azpHeader, request.Azp) + if request.AzpAppTID != "" { + req.Header.Add(azpAppTIDHeader, request.AzpAppTID) + } + if request.ClientCertificate != "" { + req.Header.Add(clientCertificateHeader, request.ClientCertificate) } - // at least client-id is necessary, all further headers only refine the validation - req.Header.Add(clientIDHeader, clientInfo.ClientID) - req.Header.Add(appTIDHeader, clientInfo.AppTID) - req.Header.Add(azpHeader, clientInfo.Azp) resp, err := ks.httpClient.Do(req) if err != nil { - return result, fmt.Errorf("failed to fetch jwks from remote: %v", err) + return result, time.Time{}, false, fmt.Errorf("failed to fetch jwks from remote: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - // prevent caching ias backend flaps like 503 -> only cache 400 - if resp.StatusCode == http.StatusBadRequest { - ks.acceptedClients[clientInfo] = false + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return result, time.Time{}, false, fmt.Errorf( + "failed to fetch jwks from remote for client credentials %+v: %v", request.ClientInfo, readErr) } - resp, err := io.ReadAll(resp.Body) - if err != nil { - return result, fmt.Errorf( - "failed to fetch jwks from remote for client credentials %+v: %v", clientInfo, err) - } - return result, fmt.Errorf( - "failed to fetch jwks from remote for client credentials %+v: (%s)", clientInfo, resp) + return result, time.Now().Add(defaultJwkExpiration), resp.StatusCode == http.StatusBadRequest, fmt.Errorf( + "failed to fetch jwks from remote for client credentials %+v: (%s)", request.ClientInfo, body) } - ks.acceptedClients[clientInfo] = true - jwks, err := jwk.ParseReader(resp.Body) + + keySet, err := jwk.ParseReader(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to parse JWK set: %w", err) + return result, time.Time{}, false, fmt.Errorf("failed to parse JWK set: %w", err) } - result.keys = jwks + result.KeySet = keySet + result.ServicePlans = parseServicePlans(resp.Header.Get(servicePlanHeader)) + // If the server doesn't provide cache control headers, assume the keys expire in 15min. - result.expiry = time.Now().Add(defaultJwkExpiration) + expiry := time.Now().Add(defaultJwkExpiration) + _, cacheExpiry, err := cachecontrol.CachableResponse(req, resp, cachecontrol.Options{}) + if err == nil && cacheExpiry.After(expiry) { + expiry = cacheExpiry + } + return result, expiry, false, nil +} + +func cloneJWKsResponse(response JWKsResponse) JWKsResponse { + return JWKsResponse{ + KeySet: response.KeySet, + ServicePlans: append([]string(nil), response.ServicePlans...), + } +} + +func parseServicePlans(header string) []string { + if header == "" { + return nil + } - _, e, err := cachecontrol.CachableResponse(req, resp, cachecontrol.Options{}) - if err == nil && e.After(result.expiry) { - result.expiry = e + plans := strings.Split(header, ",") + for i, plan := range plans { + plans[i] = strings.Trim(strings.TrimSpace(plan), "\"") } - return result, nil + return plans } func (ks *OIDCTenant) performDiscovery(baseURL string) error { @@ -184,7 +252,7 @@ func (ks *OIDCTenant) performDiscovery(baseURL string) error { return nil } -// ProviderJSON represents data which is returned by the tenants /.well-known/openid-configuration endpoint +// ProviderJSON represents data which is returned by the tenants /.well-known/openid-configuration endpoint. type ProviderJSON struct { Issuer string `json:"issuer"` AuthURL string `json:"authorization_endpoint"` diff --git a/oidcclient/jwk_test.go b/oidcclient/jwk_test.go index 0cf2041..d119e5e 100644 --- a/oidcclient/jwk_test.go +++ b/oidcclient/jwk_test.go @@ -175,15 +175,20 @@ func TestOIDCTenant_ReadJWKs(t *testing.T) { providerJSON.JWKsURL = fmt.Sprintf("%s/oauth2/certs", localServerURL) } jwksJSON, _ := jwk.ParseString(jwksJSONString) + expiry := time.Now().Add(tt.fields.Duration) tenant := OIDCTenant{ - jwksExpiry: time.Now().Add(tt.fields.Duration), - acceptedClients: map[ClientInfo]bool{ - {ClientID: "client-id", AppTID: "app-tid", Azp: "azp"}: true, - {ClientID: "deleted-client-id", AppTID: "deleted-app-tid", Azp: "deleted-azp"}: true, - {ClientID: "invalid-client-id", AppTID: "invalid-app-tid", Azp: "invalid-azp"}: false, + jwksCache: map[jwksCacheKey]jwksCacheEntry{ + (JWKsRequest{ClientInfo: ClientInfo{ClientID: "client-id", AppTID: "app-tid", Azp: "azp"}}).cacheKey(): { + response: JWKsResponse{KeySet: jwksJSON}, expiry: expiry, accepted: true, + }, + (JWKsRequest{ClientInfo: ClientInfo{ClientID: "deleted-client-id", AppTID: "deleted-app-tid", Azp: "deleted-azp"}}).cacheKey(): { + response: JWKsResponse{KeySet: jwksJSON}, expiry: expiry, accepted: true, + }, + (JWKsRequest{ClientInfo: ClientInfo{ClientID: "invalid-client-id", AppTID: "invalid-app-tid", Azp: "invalid-azp"}}).cacheKey(): { + expiry: expiry, accepted: false, + }, }, httpClient: http.DefaultClient, - jwks: jwksJSON, ProviderJSON: providerJSON, } jwks, err := tenant.GetJWKs(tt.fields.Client) From 9423e4cbfc548ad09902e3a91d7ff7a29174962a Mon Sep 17 00:00:00 2001 From: Stefan von Torklus Date: Mon, 19 Oct 2026 14:18:02 +0200 Subject: [PATCH 3/5] ci: update golangci-lint for Go 1.27 --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 592aea1..bc0b034 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,4 +23,4 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v9.2.0 with: - version: v2.12.2 + version: v2.15.0 From 797eea4eabbac7b53b68bba3f205997b16b17f73 Mon Sep 17 00:00:00 2001 From: Stefan von Torklus Date: Mon, 19 Oct 2026 14:59:46 +0200 Subject: [PATCH 4/5] fix(auth): preserve optional client certificates --- auth/middleware_certificate_test.go | 66 +++++++++++++++++++++++++++++ auth/middleware_proof_token_test.go | 12 ++++++ auth/validator.go | 15 +++---- 3 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 auth/middleware_certificate_test.go diff --git a/auth/middleware_certificate_test.go b/auth/middleware_certificate_test.go new file mode 100644 index 0000000..b268d55 --- /dev/null +++ b/auth/middleware_certificate_test.go @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors +// +// SPDX-License-Identifier: Apache-2.0 +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sap/cloud-security-client-go/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthenticateWithProofOfPossession_PreservesOptionalCertificate(t *testing.T) { + mockServer, err := mocks.NewOIDCMockServer() + require.NoError(t, err) + defer mockServer.Server.Close() + + middleware := NewMiddleware(mockServer.Config, Options{HTTPClient: mockServer.Server.Client()}) + token, err := mockServer.SignToken(mockServer.DefaultClaims(), mockServer.DefaultHeaders()) + require.NoError(t, err) + + t.Run("returns a forwarded certificate without ownership validation", func(t *testing.T) { + req := certificateRequest(token, derCertFromFile) + + _, cert, err := middleware.AuthenticateWithProofOfPossession(req) + + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + }) + + t.Run("rejects an invalid forwarded certificate", func(t *testing.T) { + req := certificateRequest(token, "invalid certificate") + + _, _, err := middleware.AuthenticateWithProofOfPossession(req) + + assert.Error(t, err) + }) + + t.Run("adds a forwarded certificate to the request context", func(t *testing.T) { + handler := middleware.AuthenticationHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cert, ok := ClientCertificateFromCtx(r) + require.True(t, ok) + require.NotNil(t, cert) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + w.WriteHeader(http.StatusNoContent) + })) + req := certificateRequest(token, derCertFromFile) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + assert.Equal(t, http.StatusNoContent, res.Code) + }) +} + +func certificateRequest(token, certificate string) *http.Request { + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.test", http.NoBody) + req.Header.Set(authorization, "Bearer "+token) + req.Header.Set("x-forwarded-client-cert", certificate) + return req +} diff --git a/auth/middleware_proof_token_test.go b/auth/middleware_proof_token_test.go index 05249f6..40de31d 100644 --- a/auth/middleware_proof_token_test.go +++ b/auth/middleware_proof_token_test.go @@ -85,6 +85,18 @@ func TestAuthenticateWithProofOfPossession_ProofTokenValidation(t *testing.T) { assert.Empty(t, mockServer.LastJWKsRequestHeader.Get("x-client_cert")) assert.Equal(t, "consumer-app-tid", mockServer.LastJWKsRequestHeader.Get("x-azp_app_tid")) }) + + t.Run("returns an optional certificate for a single-audience token", func(t *testing.T) { + singleAudienceToken := signProofToken(t, mockServer, mockServer.Config.ClientID) + req := proofTokenRequest(singleAudienceToken, clientCertificateHeader) + + _, cert, err := middleware.AuthenticateWithProofOfPossession(req) + + require.NoError(t, err) + require.NotNil(t, cert) + assert.Equal(t, clientCertificate.GetThumbprint(), cert.GetThumbprint()) + assert.Empty(t, mockServer.LastJWKsRequestHeader.Get("x-client_cert")) + }) } func signProofToken(t *testing.T, mockServer *mocks.MockServer, audiences ...string) string { diff --git a/auth/validator.go b/auth/validator.go index 624b8eb..8b71064 100644 --- a/auth/validator.go +++ b/auth/validator.go @@ -33,15 +33,12 @@ func (m *Middleware) parseAndValidateJWT(rawToken, clientCertificateHeader strin } proofTokenCheckRequired := m.options.Validation.ProofToken && len(token.Audience()) > 1 - var cert *Certificate - if m.options.Validation.X5T || proofTokenCheckRequired { - cert, err = newCertificate(clientCertificateHeader) - if err != nil { - return Token{}, nil, err - } - if cert == nil { - return Token{}, nil, ErrNoClientCert - } + cert, err := newCertificate(clientCertificateHeader) + if err != nil { + return Token{}, nil, err + } + if (m.options.Validation.X5T || proofTokenCheckRequired) && cert == nil { + return Token{}, nil, ErrNoClientCert } // get keyset From dfaea335ac6069d42530c7f1710983200ad897eb Mon Sep 17 00:00:00 2001 From: Stefan von Torklus Date: Mon, 19 Oct 2026 15:00:58 +0200 Subject: [PATCH 5/5] fix(auth): parse XFCC certificates without hash --- auth/certificate.go | 4 ---- auth/certificate_test.go | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/auth/certificate.go b/auth/certificate.go index 88ae24e..bd0eeae 100644 --- a/auth/certificate.go +++ b/auth/certificate.go @@ -86,10 +86,6 @@ func decodeCertificate(certificate string) ([]byte, error) { } func extractCertificateFromForwardedHeader(header string) string { - if !strings.Contains(strings.ToLower(header), "hash=") { - return header - } - entries := splitHeaderValue(header, ',') if len(entries) == 0 { return header diff --git a/auth/certificate_test.go b/auth/certificate_test.go index 11fbc0b..7c996c6 100644 --- a/auth/certificate_test.go +++ b/auth/certificate_test.go @@ -65,6 +65,23 @@ func TestCertificate(t *testing.T) { require.NoError(t, err) assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) }) + + t.Run("GetThumbprint() for XFCC without hash", func(t *testing.T) { + certificate := certificatePEM(t, derCertFromFile) + xfcc := `By=spiffe://proxy;Subject="CN=consumer";Cert="` + url.PathEscape(certificate) + `"` + cert, err := newCertificate(xfcc) + require.NoError(t, err) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + }) + + t.Run("GetThumbprint() uses the last XFCC entry", func(t *testing.T) { + firstCertificate := certificatePEM(t, generateDERCert()) + lastCertificate := certificatePEM(t, derCertFromFile) + xfcc := `By=spiffe://first;Cert="` + url.PathEscape(firstCertificate) + `", By=spiffe://last;Cert="` + url.PathEscape(lastCertificate) + `"` + cert, err := newCertificate(xfcc) + require.NoError(t, err) + assert.Equal(t, "fU-XoQlhMTpQsz9ArXl6zHIpMGuRO4ExLKdLRTc5VjM", cert.GetThumbprint()) + }) } func generateToken(t *testing.T, claimCnfMemberX5tValue string) Token {