Skip to content

Commit 3d11b1e

Browse files
aaronnwcmacnaug
andcommitted
auth: enable S3 JWT auth as automatic fallback
Co-authored-by: Colin MacNaughton <cmacnaughton@nvidia.com> Signed-off-by: Aaron Wilson <aawilson@nvidia.com>
1 parent 2ab4c28 commit 3d11b1e

File tree

8 files changed

+64
-167
lines changed

8 files changed

+64
-167
lines changed

ais/prxauth.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,16 +219,17 @@ func (p *proxy) delToken(w http.ResponseWriter, r *http.Request) {
219219
}
220220
}
221221

222-
// Validates a token from the request header
223-
// When config.Auth.AllowS3TokenCompat is enabled, also checks X-Amz-Security-Token header
222+
// Validates a token from the request header.
223+
// Supports both standard Bearer tokens and X-Amz-Security-Token
224+
// as fallback for AWS SDK compatibility.
224225
func (p *proxy) validateToken(hdr http.Header) (*tok.AISClaims, error) {
225-
token, err := tok.ExtractToken(hdr, cmn.Rom.AllowS3TokenCompat())
226+
tokenHdr, err := tok.ExtractToken(hdr)
226227
if err != nil {
227228
return nil, err
228229
}
229-
claims, err := p.authn.validateToken(token)
230+
claims, err := p.authn.validateToken(tokenHdr.Token)
230231
if err != nil {
231-
nlog.Errorf("invalid token: %v", err)
232+
nlog.Errorf("invalid token from header %q: %v ", tokenHdr.Header, err)
232233
return nil, err
233234
}
234235
return claims, nil

ais/test/s3_compat_test.go

Lines changed: 5 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
"net/http"
1616
"net/url"
1717
"os"
18-
"strconv"
1918
"strings"
2019
"sync"
2120
"testing"
@@ -142,8 +141,8 @@ func getS3Credentials(t *testing.T) aws.CredentialsProvider {
142141
}
143142

144143
// setupS3Compat configures the cluster for S3 compatibility tests
145-
// If auth is enabled, it automatically enables S3 reverse proxy and JWT token compat mode
146-
// Returns a cleanup function that restores original settings
144+
// If auth is enabled, it enables S3 reverse proxy feature.
145+
// S3 JWT authentication (via X-Amz-Security-Token) will be checked as a fallback if no Authorization header is present.
147146
func setupS3Compat(t *testing.T) {
148147
config, err := api.GetClusterConfig(tools.BaseAPIParams())
149148
tassert.CheckFatal(t, err)
@@ -153,19 +152,16 @@ func setupS3Compat(t *testing.T) {
153152
return
154153
}
155154

156-
// Auth is enabled - ensure S3 JWT compat mode is enabled
155+
// Auth is enabled - ensure S3 reverse proxy is enabled
157156
originalFeatures := config.Features.String()
158-
originalS3TokenCompat := strconv.FormatBool(config.Auth.AllowS3TokenCompat)
159157

160158
tools.SetClusterConfig(t, cos.StrKVs{
161-
"features": feat.S3ReverseProxy.String(),
162-
"auth.allow_s3_token_compat": "true",
159+
"features": feat.S3ReverseProxy.String(),
163160
})
164161

165162
t.Cleanup(func() {
166163
tools.SetClusterConfig(t, cos.StrKVs{
167-
"features": originalFeatures,
168-
"auth.allow_s3_token_compat": originalS3TokenCompat,
164+
"features": originalFeatures,
169165
})
170166
})
171167
}
@@ -712,13 +708,6 @@ func TestS3JWTAuth(t *testing.T) {
712708
// Create test bucket
713709
tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
714710

715-
// VERIFY: S3 JWT compat is enabled
716-
updatedConfig, err := api.GetClusterConfig(authBP)
717-
tassert.CheckFatal(t, err)
718-
tassert.Fatalf(t, updatedConfig.Auth.AllowS3TokenCompat,
719-
"S3 JWT compat mode should be enabled but got: %v", updatedConfig.Auth.AllowS3TokenCompat)
720-
tlog.Logfln("✓ S3 JWT compatibility mode enabled")
721-
722711
// Get a valid JWT token from the authenticated BaseParams
723712
testJWT := authBP.Token
724713
tassert.Fatalf(t, testJWT != "", "Expected valid auth token from tools.BaseAPIParams()")
@@ -816,19 +805,9 @@ func TestS3JWTAuthFailures(t *testing.T) {
816805
bck = cmn.Bck{Name: "test-s3-jwt-fail-" + trand.String(6), Provider: apc.AIS}
817806
)
818807

819-
// Get authenticated BaseParams
820-
authBP := tools.BaseAPIParams()
821-
822808
// Create test bucket
823809
tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
824810

825-
// VERIFY: S3 JWT compat is enabled
826-
updatedConfig, err := api.GetClusterConfig(authBP)
827-
tassert.CheckFatal(t, err)
828-
tassert.Fatalf(t, updatedConfig.Auth.AllowS3TokenCompat,
829-
"S3 JWT compat mode should be enabled but got: %v", updatedConfig.Auth.AllowS3TokenCompat)
830-
tlog.Logfln("✓ S3 JWT compatibility mode enabled")
831-
832811
// Test 1: Request with NO credentials at all
833812
tlog.Logln("Test 1: Request with NO credentials should fail...")
834813
noCfg, err := config.LoadDefaultConfig(
@@ -914,77 +893,3 @@ func TestS3JWTAuthFailures(t *testing.T) {
914893
tassert.Fatalf(t, err != nil, "Expected request with malformed JWT to fail, but it succeeded")
915894
tlog.Logfln("✓ Malformed JWT signature failed as expected: %v", err)
916895
}
917-
918-
// TestS3JWTAuthDisabledByDefault validates backward compatibility:
919-
// When auth is enabled but allow_s3_token_compat is false (default),
920-
// valid JWT tokens in X-Amz-Security-Token should be rejected
921-
func TestS3JWTAuthDisabledByDefault(t *testing.T) {
922-
tools.CheckSkip(t, &tools.SkipTestArgs{RequiresAuth: true})
923-
924-
var (
925-
proxyURL = tools.GetPrimaryURL()
926-
bck = cmn.Bck{Name: "test-s3-jwt-disabled-" + trand.String(6), Provider: apc.AIS}
927-
)
928-
929-
// Get authenticated BaseParams
930-
authBP := tools.BaseAPIParams()
931-
932-
// Get current config
933-
clusterConfig, err := api.GetClusterConfig(authBP)
934-
tassert.CheckFatal(t, err)
935-
originalFeatures := clusterConfig.Features.String()
936-
originalS3TokenCompat := strconv.FormatBool(clusterConfig.Auth.AllowS3TokenCompat)
937-
938-
// Create test bucket
939-
tools.CreateBucket(t, proxyURL, bck, nil, true /*cleanup*/)
940-
941-
// Enable S3 reverse proxy but keep allow_s3_token_compat disabled (false)
942-
tlog.Logln("Enabling S3 reverse proxy with JWT auth mode DISABLED...")
943-
tools.SetClusterConfig(t, cos.StrKVs{
944-
"features": feat.S3ReverseProxy.String(),
945-
"auth.allow_s3_token_compat": "false",
946-
})
947-
t.Cleanup(func() {
948-
// Restore original config values
949-
tools.SetClusterConfig(t, cos.StrKVs{
950-
"features": originalFeatures,
951-
"auth.allow_s3_token_compat": originalS3TokenCompat,
952-
})
953-
})
954-
955-
// VERIFY: S3 JWT compat is disabled
956-
updatedConfig, err := api.GetClusterConfig(authBP)
957-
tassert.CheckFatal(t, err)
958-
tassert.Fatalf(t, !updatedConfig.Auth.AllowS3TokenCompat,
959-
"S3 JWT compat mode should be disabled but got: %v", updatedConfig.Auth.AllowS3TokenCompat)
960-
tlog.Logfln("✓ S3 JWT compatibility mode is disabled (backward compatibility mode)")
961-
962-
// Get a valid JWT token
963-
testJWT := authBP.Token
964-
tassert.Fatalf(t, testJWT != "", "Expected valid auth token from tools.BaseAPIParams()")
965-
tlog.Logfln("Attempting S3 request with valid JWT token (length: %d bytes)", len(testJWT))
966-
967-
// Create AWS SDK client with JWT as SessionToken
968-
cfg, err := config.LoadDefaultConfig(
969-
context.Background(),
970-
config.WithCredentialsProvider(
971-
credentials.NewStaticCredentialsProvider(
972-
"dummy-access-key",
973-
"dummy-secret-key",
974-
testJWT, // Valid JWT in X-Amz-Security-Token header
975-
),
976-
),
977-
config.WithRegion(env.AwsDefaultRegion()),
978-
)
979-
tassert.CheckFatal(t, err)
980-
981-
cfg.HTTPClient = newS3Client(false)
982-
cfg.BaseEndpoint = aws.String(proxyURL + "/s3")
983-
s3Client := s3.NewFromConfig(cfg)
984-
985-
// Attempt S3 request - should FAIL because allow_s3_token_compat is disabled
986-
tlog.Logln("Testing that valid JWT is rejected when feature is disabled...")
987-
_, err = s3Client.ListBuckets(context.Background(), &s3.ListBucketsInput{})
988-
tassert.Fatalf(t, err != nil, "Expected request to fail when allow_s3_token_compat=false, but it succeeded")
989-
tlog.Logfln("✓ Valid JWT was correctly rejected (backward compatibility preserved): %v", err)
990-
}

api/authn/config.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,11 @@ type (
3838
UseHTTPS bool `json:"use_https"`
3939
}
4040
ServerConf struct {
41-
psecret *string `json:"-"`
42-
pexpire *cos.Duration `json:"-"`
43-
Secret string `json:"secret"`
44-
Expire cos.Duration `json:"expiration_time"`
45-
PubKey *string `json:"public_key"`
46-
AllowS3TokenCompat bool `json:"allow_s3_token_compat"`
41+
psecret *string `json:"-"`
42+
pexpire *cos.Duration `json:"-"`
43+
Secret string `json:"secret"`
44+
Expire cos.Duration `json:"expiration_time"`
45+
PubKey *string `json:"public_key"`
4746
}
4847
TimeoutConf struct {
4948
Default cos.Duration `json:"default_timeout"`

cmd/authn/hserv.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,15 +299,15 @@ func (h *hserv) httpUserGet(w http.ResponseWriter, r *http.Request) {
299299
}
300300

301301
func getToken(r *http.Request) (*tok.AISClaims, error) {
302-
tokenStr, err := tok.ExtractToken(r.Header, Conf.Server.AllowS3TokenCompat)
302+
tokenHdr, err := tok.ExtractToken(r.Header)
303303
if err != nil {
304304
return nil, err
305305
}
306306
tkParser := tok.NewTokenParser(Conf.Secret(), nil, nil)
307-
claims, err := tkParser.ValidateToken(tokenStr)
307+
claims, err := tkParser.ValidateToken(tokenHdr.Token)
308308
if err != nil {
309309
if errors.Is(err, tok.ErrInvalidToken) {
310-
return nil, fmt.Errorf("not authorized (token expired): %q", tokenStr)
310+
return nil, fmt.Errorf("not authorized (token expired): %q", tokenHdr.Token)
311311
}
312312
return nil, err
313313
}

cmd/authn/tok/token.go

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ type (
4545
parseOpts []jwt.ParserOption
4646
}
4747

48+
TokenHdr struct {
49+
// Request header containing token string
50+
Header string
51+
// Raw token string from request
52+
Token string
53+
}
54+
4855
Parser interface {
4956
// ValidateToken verifies JWT signature and extracts token claims.
5057
ValidateToken(tokenStr string) (*AISClaims, error)
@@ -107,41 +114,36 @@ func AdminClaims(expires time.Time, userID, aud string) *AISClaims {
107114

108115
// extractBearerToken extracts a bearer token from the Authorization header.
109116
// Header format: 'Authorization: Bearer <token>'
110-
func extractBearerToken(hdr http.Header) (string, error) {
117+
func extractBearerToken(hdr http.Header) (*TokenHdr, error) {
111118
s := hdr.Get(apc.HdrAuthorization)
112119
if s == "" {
113-
return "", ErrNoToken
120+
return nil, ErrNoToken
114121
}
115122
idx := strings.Index(s, " ")
116123
if idx == -1 || s[:idx] != apc.AuthenticationTypeBearer {
117-
return "", ErrNoBearerToken
124+
return nil, ErrNoBearerToken
118125
}
119-
return s[idx+1:], nil
126+
return &TokenHdr{Header: apc.HdrAuthorization, Token: s[idx+1:]}, nil
120127
}
121128

122129
// ExtractToken extracts JWT token from either Authorization header (Bearer token)
123-
// or X-Amz-Security-Token header (AWS SDK compatibility mode).
124-
// This enables native AWS SDK clients to authenticate using JWT tokens passed via the
125-
// X-Amz-Security-Token header, bypassing SigV4 validation.
126-
//
127-
// Priority:
130+
// or X-Amz-Security-Token header with the following priority:
128131
// 1. Authorization: Bearer <token> (standard JWT auth)
129-
// 2. X-Amz-Security-Token: <token> (AWS SDK compatibility when s3CompatEnabled=true)
130-
func ExtractToken(hdr http.Header, s3CompatEnabled bool) (string, error) {
132+
// 2. X-Amz-Security-Token: enables native AWS SDK clients to authenticate using AIS-compatible JWT tokens passed when
133+
// using SigV4 authentication.
134+
func ExtractToken(hdr http.Header) (*TokenHdr, error) {
131135
// First, try standard Bearer token from Authorization header
132-
s, err := extractBearerToken(hdr)
136+
t, err := extractBearerToken(hdr)
133137
if err == nil {
134-
return s, nil
138+
return t, nil
135139
}
136140

137141
// Fallback to X-Amz-Security-Token for AWS SDK compatibility
138-
if s3CompatEnabled {
139-
s := hdr.Get(s3.HeaderSecurityToken)
140-
if s != "" {
141-
return s, nil
142-
}
142+
s := hdr.Get(s3.HeaderSecurityToken)
143+
if s != "" {
144+
return &TokenHdr{Header: s3.HeaderSecurityToken, Token: s}, nil
143145
}
144-
return "", ErrNoToken
146+
return nil, ErrNoToken
145147
}
146148

147149
/////////////////

cmd/authn/tok/token_test.go

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -145,13 +145,14 @@ func TestExtractToken(t *testing.T) {
145145
// Test bearer token extraction (s3CompatEnabled=false)
146146
hdr := http.Header{}
147147
hdr.Set("Authorization", "Bearer sometoken")
148-
token, err := tok.ExtractToken(hdr, false)
148+
token, err := tok.ExtractToken(hdr)
149149
tassert.Fatalf(t, err == nil, "ExtractToken failed: %v", err)
150-
tassert.Errorf(t, token == "sometoken", "Expected 'sometoken', got %q", token)
150+
tassert.Errorf(t, token.Token == "sometoken", "Expected 'sometoken', got %q", token.Token)
151+
tassert.Errorf(t, token.Header == "Authorization", "Expected 'Authorization', got %q", token.Header)
151152

152153
// Test missing Bearer prefix
153154
hdr.Set("Authorization", "sometoken")
154-
_, err = tok.ExtractToken(hdr, false)
155+
_, err = tok.ExtractToken(hdr)
155156
tassert.Error(t, err != nil, "Expected failure due to missing 'Bearer' prefix")
156157
}
157158

@@ -303,35 +304,29 @@ func TestExtractTokenS3Compat(t *testing.T) {
303304
hdr.Set("X-Amz-Security-Token", testJWT)
304305
hdr.Set("X-Amz-Date", "20130524T000000Z")
305306

306-
token, err := tok.ExtractToken(hdr, true)
307+
token, err := tok.ExtractToken(hdr)
307308
tassert.Fatalf(t, err == nil, "ExtractToken failed: %v", err)
308-
tassert.Errorf(t, token == testJWT, "Expected JWT from X-Amz-Security-Token, got %q", token)
309+
tassert.Errorf(t, token.Token == testJWT, "Expected test JWT, got %q", token.Token)
310+
tassert.Errorf(t, token.Header == "X-Amz-Security-Token", "Expected JWT from X-Amz-Security-Token, got %q", token.Header)
309311

310312
// Test 2: Bearer token takes precedence
311313
hdr.Set("Authorization", "Bearer priority-token")
312-
token, err = tok.ExtractToken(hdr, true)
314+
token, err = tok.ExtractToken(hdr)
313315
tassert.Fatalf(t, err == nil, "ExtractToken failed: %v", err)
314-
tassert.Errorf(t, token == "priority-token", "Expected Bearer token to take precedence, got %q", token)
316+
tassert.Errorf(t, token.Token == "priority-token", "Expected Bearer token to take precedence, got %q", token)
315317

316318
// Test 3: X-Amz-Security-Token with non-JWT data extracts successfully
317319
// (validation happens later in ValidateToken, not during extraction)
318320
hdr = http.Header{}
319321
hdr.Set("Authorization", "AWS4-HMAC-SHA256 Credential=...")
320322
hdr.Set("X-Amz-Security-Token", "not-a-jwt-no-dots")
321-
token, err = tok.ExtractToken(hdr, true)
323+
token, err = tok.ExtractToken(hdr)
322324
tassert.Fatalf(t, err == nil, "ExtractToken should succeed, got: %v", err)
323-
tassert.Errorf(t, token == "not-a-jwt-no-dots", "Expected extracted token, got %q", token)
325+
tassert.Errorf(t, token.Token == "not-a-jwt-no-dots", "Expected extracted token, got %q", token)
324326

325327
// Test 4: No token at all
326328
hdr = http.Header{}
327329
hdr.Set("Authorization", "AWS4-HMAC-SHA256 Credential=...")
328-
_, err = tok.ExtractToken(hdr, true)
330+
_, err = tok.ExtractToken(hdr)
329331
tassert.Fatalf(t, errors.Is(err, tok.ErrNoToken), "Expected ErrNoToken, got: %v", err)
330-
331-
// Test 5: S3 compat disabled - should NOT check X-Amz-Security-Token
332-
hdr = http.Header{}
333-
hdr.Set("Authorization", "AWS4-HMAC-SHA256 Credential=...")
334-
hdr.Set("X-Amz-Security-Token", testJWT)
335-
_, err = tok.ExtractToken(hdr, false) // s3CompatEnabled=false
336-
tassert.Fatalf(t, errors.Is(err, tok.ErrNoToken), "Expected ErrNoToken when S3 compat disabled, got: %v", err)
337332
}

cmn/config.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -641,18 +641,16 @@ type (
641641
}
642642

643643
AuthConf struct {
644-
Secret string `json:"secret"`
645-
PubKey string `json:"public_key"`
646-
Aud []string `json:"aud"`
647-
Enabled bool `json:"enabled"`
648-
AllowS3TokenCompat bool `json:"allow_s3_token_compat,omitempty"` // Allow X-Amz-Security-Token header to contain JWT instead of SigV4
644+
Secret string `json:"secret"`
645+
Enabled bool `json:"enabled"`
646+
PubKey string `json:"public_key"`
647+
Aud []string `json:"aud"`
649648
}
650649
AuthConfToSet struct {
651-
Secret *string `json:"secret,omitempty"`
652-
Enabled *bool `json:"enabled,omitempty"`
653-
AllowS3TokenCompat *bool `json:"allow_s3_token_compat,omitempty"`
654-
PubKey string `json:"public_key,omitempty"`
655-
Aud []string `json:"aud,omitempty"`
650+
Secret *string `json:"secret,omitempty"`
651+
Enabled *bool `json:"enabled,omitempty"`
652+
PubKey string `json:"public_key,omitempty"`
653+
Aud []string `json:"aud,omitempty"`
656654
}
657655

658656
// keepalive

0 commit comments

Comments
 (0)