Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
92 changes: 82 additions & 10 deletions auth/certificate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,25 +43,93 @@ 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 {
return nil, err
}
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 {
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
}
39 changes: 37 additions & 2 deletions auth/certificate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"encoding/base64"
"encoding/pem"
"math/big"
"net/url"
"testing"

"github.com/lestrrat-go/jwx/jwt"
Expand Down Expand Up @@ -50,6 +51,37 @@ 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())
})

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 {
Expand All @@ -64,11 +96,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 {
Expand Down
34 changes: 14 additions & 20 deletions auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,19 @@ 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
// 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
Expand Down Expand Up @@ -117,25 +128,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
Expand Down
66 changes: 66 additions & 0 deletions auth/middleware_certificate_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading