Files
nomad/lib/auth/oidc/client_assertion_test.go
Daniel Bennett 6383d5f54d auth: oidc client assertion tweaks (#25565)
* allow for newline flexibility in client assertion key/cert

* if client assertion, don't send the client secret,
but do keep the client secret in both places in state
(on the parent Config, and within the OIDCClientAssertion)
mainly so that it shows up as "redacted" instead of empty
when inspecting the auth method config via API.
2025-04-03 11:53:37 -05:00

582 lines
16 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package oidc
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"math/big"
"os"
"path"
"path/filepath"
"testing"
"time"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/shoenig/test/must"
)
func TestBuildClientAssertionJWT_ClientSecret(t *testing.T) {
tests := []struct {
name string
config *structs.ACLAuthMethodConfig
wantErr bool
expectedErr string
}{
{
name: "valid client secret",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "1234567890abcdefghijklmnopqrstuvwxyz",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceClientSecret,
KeyAlgorithm: "HS256",
Audience: []string{"test-audience"},
ExtraHeaders: map[string]string{
"test-header": "test-value",
},
},
},
wantErr: false,
},
{
name: "nil config",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "test-client-secret",
OIDCClientAssertion: nil,
},
wantErr: true,
expectedErr: `no auth method config or client assertion`,
},
{
name: "invalid client secret length",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "test-client-secret",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceClientSecret,
KeyAlgorithm: "HS256",
Audience: []string{"test-audience"},
ExtraHeaders: map[string]string{
"test-header": "test-value",
},
},
},
wantErr: true,
expectedErr: `invalid secret length for algorithm: "HS256" must be at least 32 bytes long`,
},
{
name: "invalid client secret kid in extra header",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "1234567890abcdefghijklmnopqrstuvwxyz",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceClientSecret,
KeyAlgorithm: "HS256",
Audience: []string{"test-audience"},
ExtraHeaders: map[string]string{
"kid": "test-kid",
},
},
},
wantErr: true,
expectedErr: `WithHeaders: "kid" not allowed in WithHeaders; use WithKeyID instead`,
},
{
name: "invalid key algorithm none",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "1234567890abcdefghijklmnopqrstuvwxyz",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceClientSecret,
KeyAlgorithm: "none",
Audience: []string{"test-audience"},
ExtraHeaders: map[string]string{
"test-header": "test-value",
},
},
},
wantErr: true,
expectedErr: `unsupported algorithm "none" for client secret`,
},
{
name: "invalid key algorithm None",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "1234567890abcdefghijklmnopqrstuvwxyz",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceClientSecret,
KeyAlgorithm: "None",
Audience: []string{"test-audience"},
ExtraHeaders: map[string]string{
"test-header": "test-value",
},
},
},
wantErr: true,
expectedErr: `unsupported algorithm "None" for client secret`,
},
// expected non-nil error; got nil
{
name: "invalid missing audience",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientSecret: "1234567890abcdefghijklmnopqrstuvwxyz",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceClientSecret,
KeyAlgorithm: "HS256",
ExtraHeaders: map[string]string{
"test-header": "test-value",
},
},
},
wantErr: true,
expectedErr: "missing Audience",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.config.Canonicalize() // inherits ClientSecret from OIDCClientAssertion
jwt, err := BuildClientAssertionJWT(tt.config, nil, "")
if tt.wantErr {
must.Error(t, err)
must.StrContains(t, err.Error(), tt.expectedErr)
} else {
must.NoError(t, err)
must.NotNil(t, jwt)
}
})
}
}
func TestBuildClientAssertionJWT_PrivateKey(t *testing.T) {
nomadKey := generateTestPrivateKey(t)
nomadKeyPath := writeTestPrivateKeyToFile(t, nomadKey)
nomadKID := "anything"
nomadCert := generateTestCertificate(t, nomadKey)
nomadCertPath := writeTestCertToFile(t, nomadCert)
nonKeyCertFile := path.Join(t.TempDir(), "bad.key.cert.pem")
must.NoError(t, os.WriteFile(nonKeyCertFile, []byte("not a key or cert"), 0644))
tests := []struct {
name string
config *structs.ACLAuthMethodConfig
wantErr bool
expectedErr string
}{
{
name: "nil config",
config: &structs.ACLAuthMethodConfig{
OIDCClientAssertion: nil,
},
wantErr: true,
},
{
name: "valid private key source with pem key",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKey: encodeTestPrivateKey(nomadKey),
KeyID: nomadKID,
},
},
},
wantErr: false,
},
{
name: "valid private key source with pem key with pem cert",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKey: encodeTestPrivateKey(nomadKey),
PemCert: encodeTestCert(nomadCert),
},
},
},
wantErr: false,
},
{
name: "valid private key source with pem cert file",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKey: encodeTestPrivateKey(nomadKey),
PemCertFile: nomadCertPath,
},
},
},
wantErr: false,
},
{
name: "valid private key source with pem key file with Key ID",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKeyFile: nomadKeyPath,
KeyID: nomadKID,
},
},
},
wantErr: false,
},
// invalid pem key file location
{
name: "invalid private key source with pem key file",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKeyFile: nomadKeyPath + "/invalid",
KeyID: nomadKID,
},
},
},
wantErr: true,
expectedErr: "error reading PemKeyFile",
},
// file does exist but is not an rsa key
{
name: "invalid private key file contents",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKeyFile: nonKeyCertFile,
KeyID: nomadKID,
},
},
},
wantErr: true,
expectedErr: "error parsing PemKeyFile: invalid key:",
},
{
name: "invalid certificate file contents",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKey: encodeTestPrivateKey(nomadKey),
PemCertFile: nonKeyCertFile,
},
},
},
wantErr: true,
expectedErr: "failed to decode PemCertFile PEM block",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.config.Canonicalize() // inherits clientSecret from OIDCClientAssertion
jwt, err := BuildClientAssertionJWT(tt.config, nomadKey, nomadKID)
if tt.wantErr {
must.Error(t, err)
must.StrContains(t, err.Error(), tt.expectedErr)
} else {
must.NoError(t, err)
must.NotNil(t, jwt)
}
})
}
}
func TestBuildClientAssertionJWT_NomadKey(t *testing.T) {
nomadKey := generateTestPrivateKey(t)
nomadKID := "anything"
tests := []struct {
name string
config *structs.ACLAuthMethodConfig
wantErr bool
}{
{
name: "nil config",
config: &structs.ACLAuthMethodConfig{
OIDCClientAssertion: nil,
},
wantErr: true,
},
{
name: "nomad key source",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourceNomad,
KeyAlgorithm: "RS256",
Audience: []string{"test-audience"},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.config.Canonicalize() // inherits clientSecret from OIDCClientAssertion
jwt, err := BuildClientAssertionJWT(tt.config, nomadKey, nomadKID)
if tt.wantErr {
must.Error(t, err)
} else {
must.NoError(t, err)
must.NotNil(t, jwt)
}
})
}
}
func TestBuildClientAssertionJWT_PrivateKeyExpiredCert(t *testing.T) {
nomadKey := generateTestPrivateKey(t)
nomadInvalidKey := generateInvalidTestPrivateKey(t)
nomadKID := "anything"
nomadCert := generateTestCertificate(t, nomadKey)
nomadExpiredCert := generateExpiredTestCertificate(t, nomadKey)
tests := []struct {
name string
config *structs.ACLAuthMethodConfig
wantErr bool
expectedErr string
}{
{
name: "invalid PemKey",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKey: encodeTestPrivateKey(nomadInvalidKey),
PemCert: encodeTestCert(nomadCert),
},
},
},
wantErr: true,
expectedErr: "failed to parse private key",
},
{
name: "expired certificate PemCert",
config: &structs.ACLAuthMethodConfig{
OIDCClientID: "test-client-id",
OIDCClientAssertion: &structs.OIDCClientAssertion{
KeySource: structs.OIDCKeySourcePrivateKey,
Audience: []string{"test-audience"},
KeyAlgorithm: "RS256",
PrivateKey: &structs.OIDCClientAssertionKey{
PemKey: encodeTestPrivateKey(nomadKey),
PemCert: encodeTestCert(nomadExpiredCert),
},
},
},
wantErr: true,
expectedErr: "certificate has expired or is not yet valid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.config.Canonicalize() // inherits clientSecret from OIDCClientAssertion
jwt, err := BuildClientAssertionJWT(tt.config, nomadKey, nomadKID)
if tt.wantErr {
must.Error(t, err)
must.StrContains(t, err.Error(), tt.expectedErr)
} else {
must.NoError(t, err)
must.NotNil(t, jwt)
}
})
}
}
func generateTestPrivateKey(t *testing.T) *rsa.PrivateKey {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must.NoError(t, err)
return key
}
func writeTestPrivateKeyToFile(t *testing.T, key *rsa.PrivateKey) string {
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "testkey.pem")
keyFile, err := os.Create(keyPath)
must.NoError(t, err)
defer keyFile.Close()
keyBytes := x509.MarshalPKCS1PrivateKey(key)
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyBytes,
}
err = pem.Encode(keyFile, block)
must.NoError(t, err)
return keyPath
}
func encodeTestPrivateKey(key *rsa.PrivateKey) string {
keyBytes := x509.MarshalPKCS1PrivateKey(key)
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyBytes,
}
return string(pem.EncodeToMemory(block))
}
func generateTestCertificate(t *testing.T, key *rsa.PrivateKey) *x509.Certificate {
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
must.NoError(t, err)
cert, err := x509.ParseCertificate(certDER)
must.NoError(t, err)
return cert
}
func encodeTestCert(cert *x509.Certificate) string {
block := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
return string(pem.EncodeToMemory(block))
}
func writeTestCertToFile(t *testing.T, cert *x509.Certificate) string {
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "testcert.pem")
certFile, err := os.Create(certPath)
must.NoError(t, err)
defer certFile.Close()
block := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
err = pem.Encode(certFile, block)
must.NoError(t, err)
return certPath
}
func generateExpiredTestCertificate(t *testing.T, key *rsa.PrivateKey) *x509.Certificate {
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
NotBefore: time.Now().Add(-2 * time.Hour),
NotAfter: time.Now().Add(-1 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
must.NoError(t, err)
cert, err := x509.ParseCertificate(certDER)
must.NoError(t, err)
return cert
}
func generateInvalidTestPrivateKey(t *testing.T) *rsa.PrivateKey {
key, err := rsa.GenerateKey(rand.Reader, 2048)
must.NoError(t, err)
// Simulate an invalid key by modifying the key's modulus
key.N = big.NewInt(0) // This is just a placeholder to simulate an invalid key
return key
}
func TestNewlineHeaders(t *testing.T) {
cases := []struct {
name string
content string
expect string
}{
{
name: "empty",
content: "",
expect: "",
},
{
name: "nonsense",
content: "not a key or cert",
expect: "not a key or cert",
},
{
name: "pem-shaped nonsense",
content: "-----BEGIN RANDOM PEM-----stuff-----END RANDOM PEM-----",
expect: "-----BEGIN RANDOM PEM-----stuff\n-----END RANDOM PEM-----",
},
{
name: "no newlines key",
content: "-----BEGIN ANY KIND OF PRIVATE KEY-----stuff-----END ANY PRIVATE KEY-----",
expect: "-----BEGIN ANY KIND OF PRIVATE KEY-----\nstuff\n-----END ANY PRIVATE KEY-----",
},
{
name: "no newlines cert",
content: "-----BEGIN ANY KIND OF CERTIFICATE-----stuff-----END ANY CERTIFICATE-----",
expect: "-----BEGIN ANY KIND OF CERTIFICATE-----\nstuff\n-----END ANY CERTIFICATE-----",
},
// extra newlines between header/footer and content is okay.
{
name: "with newlines key",
content: "-----BEGIN ANY KIND OF PRIVATE KEY-----\nstuff\n-----END ANY PRIVATE KEY-----",
expect: "-----BEGIN ANY KIND OF PRIVATE KEY-----\n\nstuff\n\n-----END ANY PRIVATE KEY-----",
},
{
name: "with newlines cert",
content: "-----BEGIN ANY KIND OF CERTIFICATE-----\nstuff\nmore\nstuff\n-----END ANY CERTIFICATE-----",
expect: "-----BEGIN ANY KIND OF CERTIFICATE-----\n\nstuff\nmore\nstuff\n\n-----END ANY CERTIFICATE-----",
},
// extra junk outside the header/footer is okay.
{
name: "extra junk key",
content: "note to self\n-----BEGIN ANY KIND OF PRIVATE KEY-----\nstuff\n-----END ANY PRIVATE KEY-----\nanother note",
expect: "note to self\n\n-----BEGIN ANY KIND OF PRIVATE KEY-----\n\nstuff\n\n-----END ANY PRIVATE KEY-----\n\nanother note",
},
{
name: "extra junk cert",
content: "note to self\n-----BEGIN ANY KIND OF CERTIFICATE-----\nstuff\n-----END ANY CERTIFICATE-----\nanother note",
expect: "note to self\n\n-----BEGIN ANY KIND OF CERTIFICATE-----\n\nstuff\n\n-----END ANY CERTIFICATE-----\n\nanother note",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := newlineHeaders([]byte(tc.content))
must.Eq(t, tc.expect, string(got))
})
}
}