Skip to content

Commit a08ed99

Browse files
feat(auth-callout): configure jwt algs (#78)
Signed-off-by: Frank Spitulski <fspitulski@nvidia.com>
1 parent 9d51344 commit a08ed99

11 files changed

Lines changed: 223 additions & 23 deletions

File tree

auth-callout/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jwks:
3333
url: "https://keycloak/realms/master/protocol/openid-connect/certs"
3434
issuer: "https://keycloak/realms/master"
3535
audience: "dsx-exchange"
36+
signing-algorithms:
37+
- RS256
38+
- ES256
3639

3740
mtls:
3841
ca-path: "/etc/ssl/certs/ca.crt"

auth-callout/api/env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ NATS_URL=nats://nats:4222
99
JWKS_URL=http://keycloak.127-0-0-1.nip.io:8080/realms/auth-callout/protocol/openid-connect/certs
1010
JWKS_ISSUER=http://keycloak.127-0-0-1.nip.io:8080/realms/auth-callout
1111
JWKS_AUDIENCE=dsx-exchange
12+
JWKS_SIGNING_ALGORITHMS=RS256

auth-callout/deploy/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ serviceConfig:
8484
url: "https://keycloak/realms/master/protocol/openid-connect/certs"
8585
issuer: "https://keycloak/realms/master"
8686
audience: "dsx-exchange"
87+
signing-algorithms:
88+
- RS256
89+
- ES256
8790
```
8891

8992
### mTLS Configuration

auth-callout/env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ NATS_URL=nats://nats:4222
99
JWKS_URL=http://keycloak.127-0-0-1.nip.io:8080/realms/auth-callout/protocol/openid-connect/certs
1010
JWKS_ISSUER=http://keycloak.127-0-0-1.nip.io:8080/realms/auth-callout
1111
JWKS_AUDIENCE=dsx-exchange
12+
JWKS_SIGNING_ALGORITHMS=RS256
1213

1314
# NATS keys are secrets and must come from Vault or generated local files.
1415
# Generate development values with scripts/devspace-get-key.sh or nsc.

auth-callout/src/cmd/auth_callout/config/defaults.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ jwks:
1616
url: "" # JWKS endpoint URL
1717
issuer: "" # Expected JWT issuer
1818
audience: "" # Expected JWT audience
19+
signing-algorithms:
20+
- RS256 # Allowed JWT signing algorithms
1921

2022
# mTLS configuration
2123
mtls:

auth-callout/src/cmd/auth_callout/main_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ func TestRunServerDoesNotLogConfiguredNATSSeeds(t *testing.T) {
4343
}
4444
}
4545

46+
func TestDefaultConfigIncludesJWKSSigningAlgorithms(t *testing.T) {
47+
// Load overlays env and config-file sources after defaultConfigYAML; clear
48+
// those inputs so this test only proves the embedded app defaults.
49+
t.Setenv("AUTH_CALLOUT_CONFIG", "")
50+
t.Setenv("AUTH_CALLOUT_CONFIG_FILE", "")
51+
t.Setenv("AUTH_CALLOUT_HOT_CONFIG", "")
52+
t.Setenv("AUTH_CALLOUT_JWKS_SIGNING_ALGORITHMS", "")
53+
t.Setenv("JWKS_SIGNING_ALGORITHMS", "")
54+
55+
manager := appconfig.New(defaultConfigYAML)
56+
require.NoError(t, manager.Load())
57+
58+
type jwksConfig struct {
59+
JWKS struct {
60+
SigningAlgorithms []string `koanf:"signing-algorithms"`
61+
} `koanf:"jwks"`
62+
}
63+
var config jwksConfig
64+
require.NoError(t, manager.Unmarshal(&config))
65+
require.Equal(t, []string{"RS256"}, config.JWKS.SigningAlgorithms)
66+
}
67+
4668
func captureStderr(t *testing.T, fn func()) string {
4769
t.Helper()
4870

auth-callout/src/internal/appconfig/manager.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func applyAliases(k *koanf.Koanf) {
108108
setString(k, "jwks.url", "JWKS_URL", envPrefix+"JWKS_URL")
109109
setString(k, "jwks.issuer", "JWKS_ISSUER", envPrefix+"JWKS_ISSUER")
110110
setString(k, "jwks.audience", "JWKS_AUDIENCE", envPrefix+"JWKS_AUDIENCE")
111+
setStringSlice(k, "jwks.signing-algorithms", "JWKS_SIGNING_ALGORITHMS", envPrefix+"JWKS_SIGNING_ALGORITHMS")
111112
setString(k, "mtls.ca-path", "MTLS_CA_PATH", envPrefix+"MTLS_CA_PATH")
112113
setString(k, "permissions.file", "PERMISSIONS_FILE", envPrefix+"PERMISSIONS_FILE")
113114
setString(k, "observability.telemetry.service-name", envPrefix+"SERVICE_NAME")
@@ -120,6 +121,12 @@ func setString(k *koanf.Koanf, key string, names ...string) {
120121
}
121122
}
122123

124+
func setStringSlice(k *koanf.Koanf, key string, names ...string) {
125+
if value := envValue(names...); value != "" {
126+
k.Set(key, strings.Split(value, ","))
127+
}
128+
}
129+
123130
func setInt(k *koanf.Koanf, key string, names ...string) {
124131
value := envValue(names...)
125132
if value == "" {

auth-callout/src/internal/appconfig/manager_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ nats:
3838
require.Equal(t, "SXFAKE", config.NATS.XKeySeed)
3939
}
4040

41+
func TestLoadAllowsJWKSAlgorithmsInEnvironment(t *testing.T) {
42+
t.Setenv("AUTH_CALLOUT_JWKS_SIGNING_ALGORITHMS", "RS256,ES256")
43+
44+
manager := New("")
45+
require.NoError(t, manager.Load())
46+
47+
type jwksConfig struct {
48+
JWKS struct {
49+
SigningAlgorithms []string `koanf:"signing-algorithms"`
50+
} `koanf:"jwks"`
51+
}
52+
var config jwksConfig
53+
require.NoError(t, manager.Unmarshal(&config))
54+
require.Equal(t, []string{"RS256", "ES256"}, config.JWKS.SigningAlgorithms)
55+
}
56+
4157
func writeConfigFile(t *testing.T, name, content string) string {
4258
t.Helper()
4359

auth-callout/src/internal/auth/auth_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package auth
55

66
import (
77
"context"
8+
"crypto/ecdsa"
9+
"crypto/elliptic"
810
"crypto/rand"
911
"crypto/rsa"
1012
"crypto/x509"
@@ -37,6 +39,51 @@ func testLogger() *otelzap.Logger {
3739
return otelzap.New(zapLogger)
3840
}
3941

42+
func TestValidateOAuth2SigningAlgorithms(t *testing.T) {
43+
tests := []struct {
44+
name string
45+
input []string
46+
expectErr string
47+
}{
48+
{
49+
name: "rejects missing algorithms",
50+
expectErr: "OAuth2 signing algorithms are required",
51+
},
52+
{
53+
name: "accepts configured algorithms",
54+
input: []string{"RS256", "ES256", "RS256"},
55+
},
56+
{
57+
name: "rejects algorithms with whitespace",
58+
input: []string{"RS256", " ES256"},
59+
expectErr: `unsupported OAuth2 signing algorithm " ES256"`,
60+
},
61+
{
62+
name: "rejects empty algorithm",
63+
input: []string{"RS256", ""},
64+
expectErr: `unsupported OAuth2 signing algorithm ""`,
65+
},
66+
{
67+
name: "rejects unsupported algorithm",
68+
input: []string{"HS256"},
69+
expectErr: `unsupported OAuth2 signing algorithm "HS256"`,
70+
},
71+
}
72+
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
err := validateOAuth2SigningAlgorithms(tt.input)
76+
if tt.expectErr != "" {
77+
require.Error(t, err)
78+
assert.Contains(t, err.Error(), tt.expectErr)
79+
return
80+
}
81+
82+
require.NoError(t, err)
83+
})
84+
}
85+
}
86+
4087
// TestOAuth2Authentication tests OAuth2/JWKS authentication with mock server
4188
func TestOAuth2Authentication(t *testing.T) {
4289
// Generate RSA key pair for JWT signing
@@ -88,6 +135,7 @@ func TestOAuth2Authentication(t *testing.T) {
88135
jwksServer.URL,
89136
"https://auth.example.com/",
90137
"test-audience",
138+
[]string{gojwt.SigningMethodRS256.Alg()},
91139
pm,
92140
testLogger(),
93141
testServiceName,
@@ -287,6 +335,7 @@ func TestOAuth2RejectsUnexpectedSigningMethod(t *testing.T) {
287335
jwksServer.URL,
288336
"https://auth.example.com/",
289337
"test-audience",
338+
[]string{gojwt.SigningMethodRS256.Alg()},
290339
pm,
291340
testLogger(),
292341
testServiceName,
@@ -310,6 +359,68 @@ func TestOAuth2RejectsUnexpectedSigningMethod(t *testing.T) {
310359
assert.Contains(t, err.Error(), "signing method HS256 is invalid")
311360
}
312361

362+
func TestOAuth2AllowsConfiguredES256SigningMethod(t *testing.T) {
363+
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
364+
require.NoError(t, err)
365+
366+
jwkSet := jwkset.NewMemoryStorage()
367+
jwk, err := jwkset.NewJWKFromKey(privateKey, jwkset.JWKOptions{
368+
Metadata: jwkset.JWKMetadataOptions{
369+
KID: "test-key-1",
370+
},
371+
})
372+
require.NoError(t, err)
373+
require.NoError(t, jwkSet.KeyWrite(context.Background(), jwk))
374+
375+
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
376+
jwks, err := jwkSet.JSONPublic(context.Background())
377+
if err != nil {
378+
http.Error(w, "Failed to get JWKS", http.StatusInternalServerError)
379+
return
380+
}
381+
w.Header().Set("Content-Type", "application/json")
382+
if _, err := w.Write(jwks); err != nil {
383+
http.Error(w, "Failed to write JWKS", http.StatusInternalServerError)
384+
}
385+
}))
386+
defer jwksServer.Close()
387+
388+
permFile := createTestPermissionsFile(t)
389+
defer os.Remove(permFile)
390+
391+
pm, err := config.NewPermissionsManager(permFile, testLogger())
392+
require.NoError(t, err)
393+
defer pm.Close()
394+
395+
oauth2Auth, err := NewOAuth2Authenticator(
396+
jwksServer.URL,
397+
"https://auth.example.com/",
398+
"test-audience",
399+
[]string{gojwt.SigningMethodRS256.Alg(), gojwt.SigningMethodES256.Alg()},
400+
pm,
401+
testLogger(),
402+
testServiceName,
403+
)
404+
require.NoError(t, err)
405+
defer oauth2Auth.Close()
406+
407+
token := gojwt.NewWithClaims(gojwt.SigningMethodES256, gojwt.MapClaims{
408+
"iss": "https://auth.example.com/",
409+
"sub": "user@example.com",
410+
"aud": "test-audience",
411+
"exp": time.Now().Add(1 * time.Hour).Unix(),
412+
"scope": "mqtt",
413+
})
414+
token.Header["kid"] = "test-key-1"
415+
tokenString, err := token.SignedString(privateKey)
416+
require.NoError(t, err)
417+
418+
profile, err := oauth2Auth.Authenticate(context.Background(), tokenString)
419+
require.NoError(t, err)
420+
require.NotNil(t, profile)
421+
assert.Equal(t, "APP1", profile.Account)
422+
}
423+
313424
// TestOAuth2RequiredScope tests per-client required scope validation
314425
func TestOAuth2RequiredScope(t *testing.T) {
315426
// Generate RSA key pair for JWT signing
@@ -382,6 +493,7 @@ func TestOAuth2RequiredScope(t *testing.T) {
382493
jwksServer.URL,
383494
"https://auth.example.com/",
384495
"test-audience",
496+
[]string{gojwt.SigningMethodRS256.Alg()},
385497
pm,
386498
testLogger(),
387499
testServiceName,

auth-callout/src/internal/auth/oauth2.go

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,40 @@ import (
2323

2424
// OAuth2Authenticator handles OAuth2/JWKS-based authentication
2525
type OAuth2Authenticator struct {
26-
jwks keyfunc.Keyfunc
27-
pm *config.PermissionsManager
28-
issuer string
29-
audience string
30-
jwksURL string
31-
logger *otelzap.Logger
32-
serviceName string
33-
cancel context.CancelFunc
26+
jwks keyfunc.Keyfunc
27+
pm *config.PermissionsManager
28+
issuer string
29+
audience string
30+
signingAlgorithms []string
31+
logger *otelzap.Logger
32+
serviceName string
33+
cancel context.CancelFunc
34+
}
35+
36+
var supportedOAuth2SigningAlgorithms = map[string]struct{}{
37+
jwt.SigningMethodRS256.Alg(): {},
38+
jwt.SigningMethodRS384.Alg(): {},
39+
jwt.SigningMethodRS512.Alg(): {},
40+
jwt.SigningMethodES256.Alg(): {},
41+
jwt.SigningMethodES384.Alg(): {},
42+
jwt.SigningMethodES512.Alg(): {},
43+
jwt.SigningMethodPS256.Alg(): {},
44+
jwt.SigningMethodPS384.Alg(): {},
45+
jwt.SigningMethodPS512.Alg(): {},
46+
jwt.SigningMethodEdDSA.Alg(): {},
3447
}
3548

3649
// NewOAuth2Authenticator creates a new OAuth2 authenticator
37-
func NewOAuth2Authenticator(jwksURL string, issuer string, audience string, pm *config.PermissionsManager, logger *otelzap.Logger, serviceName string) (*OAuth2Authenticator, error) {
50+
func NewOAuth2Authenticator(jwksURL string, issuer string, audience string, signingAlgorithms []string, pm *config.PermissionsManager, logger *otelzap.Logger, serviceName string) (*OAuth2Authenticator, error) {
3851
if issuer == "" {
3952
return nil, fmt.Errorf("OAuth2 issuer is required")
4053
}
4154
if audience == "" {
4255
return nil, fmt.Errorf("OAuth2 audience is required")
4356
}
57+
if err := validateOAuth2SigningAlgorithms(signingAlgorithms); err != nil {
58+
return nil, err
59+
}
4460

4561
// Create JWKS client with automatic refresh - context controls lifecycle
4662
ctx, cancel := context.WithCancel(context.Background())
@@ -50,20 +66,36 @@ func NewOAuth2Authenticator(jwksURL string, issuer string, audience string, pm *
5066
return nil, fmt.Errorf("failed to create JWKS client: %w", err)
5167
}
5268

53-
logger.Info("OAuth2 authenticator initialized", zap.String("jwks_url", jwksURL))
69+
logger.Info("OAuth2 authenticator initialized",
70+
zap.String("jwks_url", jwksURL),
71+
zap.Strings("signing_algorithms", signingAlgorithms),
72+
)
5473

5574
return &OAuth2Authenticator{
56-
jwks: k,
57-
pm: pm,
58-
issuer: issuer,
59-
audience: audience,
60-
jwksURL: jwksURL,
61-
logger: logger,
62-
serviceName: serviceName,
63-
cancel: cancel,
75+
jwks: k,
76+
pm: pm,
77+
issuer: issuer,
78+
audience: audience,
79+
signingAlgorithms: signingAlgorithms,
80+
logger: logger,
81+
serviceName: serviceName,
82+
cancel: cancel,
6483
}, nil
6584
}
6685

86+
func validateOAuth2SigningAlgorithms(configured []string) error {
87+
if len(configured) == 0 {
88+
return fmt.Errorf("OAuth2 signing algorithms are required")
89+
}
90+
91+
for _, algorithm := range configured {
92+
if _, ok := supportedOAuth2SigningAlgorithms[algorithm]; !ok {
93+
return fmt.Errorf("unsupported OAuth2 signing algorithm %q", algorithm)
94+
}
95+
}
96+
return nil
97+
}
98+
6799
// CanAuthenticate checks if OAuth2 credentials are present
68100
func (o *OAuth2Authenticator) CanAuthenticate(rc *natsjwt.AuthorizationRequestClaims) bool {
69101
return rc.ConnectOptions.Token != "" || (rc.ConnectOptions.Username == "oauthtoken" && rc.ConnectOptions.Password != "")
@@ -91,7 +123,7 @@ func (o *OAuth2Authenticator) Authenticate(ctx context.Context, token string) (*
91123
token,
92124
&Claims{},
93125
o.jwks.Keyfunc,
94-
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
126+
jwt.WithValidMethods(o.signingAlgorithms),
95127
jwt.WithExpirationRequired(),
96128
jwt.WithIssuer(o.issuer),
97129
jwt.WithAudience(o.audience),

0 commit comments

Comments
 (0)