Skip to content

Commit 6383d5f

Browse files
authored
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.
1 parent 6a0c4f5 commit 6383d5f

File tree

5 files changed

+97
-5
lines changed

5 files changed

+97
-5
lines changed

lib/auth/oidc/client_assertion.go

+25
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
package oidc
55

66
import (
7+
"bytes"
78
"crypto/rsa"
9+
810
// sha1 is used to derive an "x5t" jwt header from an x509 certificate,
911
// per the OIDC JWS spec:
1012
// https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.7
@@ -131,6 +133,9 @@ func getCassPrivateKey(k *structs.OIDCClientAssertionKey) (key *rsa.PrivateKey,
131133
bts = []byte(k.PemKey)
132134
}
133135

136+
// ensure newlines around pem header/footer
137+
bts = newlineHeaders(bts)
138+
134139
key, err = gojwt.ParseRSAPrivateKeyFromPEM(bts)
135140
if err != nil {
136141
return nil, fmt.Errorf("error parsing %s: %w", source, err)
@@ -162,6 +167,9 @@ func getCassCert(k *structs.OIDCClientAssertionKey) (*x509.Certificate, error) {
162167
bts = []byte(k.PemCert)
163168
}
164169

170+
// ensure newlines around pem header/footer
171+
bts = newlineHeaders(bts)
172+
165173
block, _ := pem.Decode(bts)
166174
if block == nil {
167175
return nil, fmt.Errorf("failed to decode %s PEM block", source)
@@ -195,3 +203,20 @@ func hashKeyID(cert *x509.Certificate, header structs.OIDCClientAssertionKeyIDHe
195203
hashed := hasher.Sum(nil)
196204
return base64.RawURLEncoding.EncodeToString(hashed), nil
197205
}
206+
207+
// newlineHeaders allows flexible copy-paste of a one-line key/cert PEM
208+
// by adding newlines around "----BEGIN.*-----" and
209+
// "-----END.*(KEY|CERTIFICATE)-----"
210+
// it's okay to have extra whitespace, but it's imperative that there be
211+
// at least one newline between the header/footer and the content.
212+
func newlineHeaders(bts []byte) []byte {
213+
cp := bytes.Clone(bts)
214+
cp = bytes.TrimSpace(cp)
215+
cp = bytes.ReplaceAll(cp, []byte("-----BEGIN"), []byte("\n-----BEGIN"))
216+
cp = bytes.ReplaceAll(cp, []byte("-----END"), []byte("\n-----END"))
217+
// key may be "PRIVATE KEY" or "RSA PRIVATE KEY", so just look for "KEY"
218+
cp = bytes.ReplaceAll(cp, []byte("KEY-----"), []byte("KEY-----\n"))
219+
cp = bytes.ReplaceAll(cp, []byte("CERTIFICATE-----"), []byte("CERTIFICATE-----\n"))
220+
cp = bytes.TrimSpace(cp)
221+
return cp
222+
}

lib/auth/oidc/client_assertion_test.go

+61
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,64 @@ func generateInvalidTestPrivateKey(t *testing.T) *rsa.PrivateKey {
518518

519519
return key
520520
}
521+
func TestNewlineHeaders(t *testing.T) {
522+
cases := []struct {
523+
name string
524+
content string
525+
expect string
526+
}{
527+
{
528+
name: "empty",
529+
content: "",
530+
expect: "",
531+
},
532+
{
533+
name: "nonsense",
534+
content: "not a key or cert",
535+
expect: "not a key or cert",
536+
},
537+
{
538+
name: "pem-shaped nonsense",
539+
content: "-----BEGIN RANDOM PEM-----stuff-----END RANDOM PEM-----",
540+
expect: "-----BEGIN RANDOM PEM-----stuff\n-----END RANDOM PEM-----",
541+
},
542+
{
543+
name: "no newlines key",
544+
content: "-----BEGIN ANY KIND OF PRIVATE KEY-----stuff-----END ANY PRIVATE KEY-----",
545+
expect: "-----BEGIN ANY KIND OF PRIVATE KEY-----\nstuff\n-----END ANY PRIVATE KEY-----",
546+
},
547+
{
548+
name: "no newlines cert",
549+
content: "-----BEGIN ANY KIND OF CERTIFICATE-----stuff-----END ANY CERTIFICATE-----",
550+
expect: "-----BEGIN ANY KIND OF CERTIFICATE-----\nstuff\n-----END ANY CERTIFICATE-----",
551+
},
552+
// extra newlines between header/footer and content is okay.
553+
{
554+
name: "with newlines key",
555+
content: "-----BEGIN ANY KIND OF PRIVATE KEY-----\nstuff\n-----END ANY PRIVATE KEY-----",
556+
expect: "-----BEGIN ANY KIND OF PRIVATE KEY-----\n\nstuff\n\n-----END ANY PRIVATE KEY-----",
557+
},
558+
{
559+
name: "with newlines cert",
560+
content: "-----BEGIN ANY KIND OF CERTIFICATE-----\nstuff\nmore\nstuff\n-----END ANY CERTIFICATE-----",
561+
expect: "-----BEGIN ANY KIND OF CERTIFICATE-----\n\nstuff\nmore\nstuff\n\n-----END ANY CERTIFICATE-----",
562+
},
563+
// extra junk outside the header/footer is okay.
564+
{
565+
name: "extra junk key",
566+
content: "note to self\n-----BEGIN ANY KIND OF PRIVATE KEY-----\nstuff\n-----END ANY PRIVATE KEY-----\nanother note",
567+
expect: "note to self\n\n-----BEGIN ANY KIND OF PRIVATE KEY-----\n\nstuff\n\n-----END ANY PRIVATE KEY-----\n\nanother note",
568+
},
569+
{
570+
name: "extra junk cert",
571+
content: "note to self\n-----BEGIN ANY KIND OF CERTIFICATE-----\nstuff\n-----END ANY CERTIFICATE-----\nanother note",
572+
expect: "note to self\n\n-----BEGIN ANY KIND OF CERTIFICATE-----\n\nstuff\n\n-----END ANY CERTIFICATE-----\n\nanother note",
573+
},
574+
}
575+
for _, tc := range cases {
576+
t.Run(tc.name, func(t *testing.T) {
577+
got := newlineHeaders([]byte(tc.content))
578+
must.Eq(t, tc.expect, string(got))
579+
})
580+
}
581+
}

lib/auth/oidc/provider.go

+9-1
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,18 @@ func providerConfig(authMethod *structs.ACLAuthMethod) (*oidc.Config, error) {
2525
algs = []oidc.Alg{oidc.RS256}
2626
}
2727

28+
// if client assertion is enabled, do not send a client secret normally;
29+
// if it is set to anything, it will be used as an HMAC to sign the client
30+
// assertion JWT, instead.
31+
clientSecret := authMethod.Config.OIDCClientSecret
32+
if authMethod.Config.OIDCClientAssertion != nil {
33+
clientSecret = ""
34+
}
35+
2836
return oidc.NewConfig(
2937
authMethod.Config.OIDCDiscoveryURL,
3038
authMethod.Config.OIDCClientID,
31-
oidc.ClientSecret(authMethod.Config.OIDCClientSecret),
39+
oidc.ClientSecret(clientSecret),
3240
algs,
3341
authMethod.Config.AllowedRedirectURIs,
3442
oidc.WithAudiences(authMethod.Config.BoundAudiences...),

nomad/structs/acl.go

+2-3
Original file line numberDiff line numberDiff line change
@@ -1123,10 +1123,9 @@ func (a *ACLAuthMethodConfig) Canonicalize() {
11231123
if len(a.OIDCClientAssertion.Audience) == 0 {
11241124
a.OIDCClientAssertion.Audience = []string{a.OIDCDiscoveryURL}
11251125
}
1126-
// move the client secret into the client assertion
1126+
// the client assertion inherits the client secret,
1127+
// in case KeySource = "client_secret"
11271128
a.OIDCClientAssertion.ClientSecret = a.OIDCClientSecret
1128-
// do not also send the client secret normally
1129-
a.OIDCClientSecret = ""
11301129
a.OIDCClientAssertion.Canonicalize()
11311130
}
11321131
}

nomad/structs/acl_test.go

-1
Original file line numberDiff line numberDiff line change
@@ -1403,7 +1403,6 @@ func TestACLAuthMethodConfig_Canonicalize(t *testing.T) {
14031403
am.Canonicalize()
14041404
must.Eq(t, []string{"test-disco-url"}, cass.Audience, must.Sprint("should inherit audience"))
14051405
must.Eq(t, "super secret", cass.ClientSecret, must.Sprint("should inherit secret"))
1406-
must.Eq(t, "", am.OIDCClientSecret, must.Sprint("secret should move to assertion"))
14071406
}
14081407

14091408
func TestACLAuthMethodConfig_Validate(t *testing.T) {

0 commit comments

Comments
 (0)