Files
seaweedfs/weed/s3api/lance/handlers_oauth_test.go
T
Chris LuandGitHub b3a8701989 lance: authenticate the catalog with Bearer tokens and x-api-key (#11431)
* lance: accept OAuth2 bearer tokens for catalog auth

Lance and LanceDB clients can only send OAuth2 / Bearer / API-Key
headers on catalog calls, never SigV4, so behind an auth-enabled S3
gateway every namespace request failed with 403 Access Denied.

Mirror the Iceberg catalog's OAuth2 support: POST /oauth/token accepts
an S3 access key / secret key as client_id / client_secret, validates
them against IAM, and returns a signed JWT. The Auth middleware accepts
that token as a Bearer credential before falling through to SigV4.

Closes #11430

* lance: accept x-api-key header carrying an S3 credential

The Lance namespace spec's third auth scheme maps api_key onto the
x-api-key header. Accept "access_key:secret_key" there and validate it
against IAM, so clients that only hold static headers can authenticate
without minting a token first.

* lance: answer invalid_client with the Basic challenge

RFC 6749 5.2 requires a 401 from the token endpoint to carry
WWW-Authenticate matching the scheme the client used, so it knows how
to retry.

* lance: cap the token endpoint request body

/oauth/token is unauthenticated, so ParseForm needs the same size
bound decodeBody applies to every other catalog request.

* lance: keep query strings out of request logs

/oauth/token rejects a client_secret sent in the query, but the
logging middleware and the catch-all wrote RequestURI to the log
before that rejection ran. Log the path alone so a mis-sent secret
never reaches the log.

* lance: log the escaped path, not the decoded one

URL.Path decodes percent escapes, so a request like /%0aFORGED could
split log lines. EscapedPath keeps the encoding while still dropping
the query string.
2026-09-24 07:02:59 +08:00

341 lines
10 KiB
Go

package lance
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
type mockCredentialValidator struct {
credentials map[string]string // accessKey -> secretKey
identities map[string]string // accessKey -> identityName
}
func (m *mockCredentialValidator) ValidateS3Credential(accessKey, secretKey string) (string, interface{}, error) {
expected, ok := m.credentials[accessKey]
if !ok {
return "", nil, fmt.Errorf("access key not found")
}
if expected != secretKey {
return "", nil, fmt.Errorf("invalid secret key")
}
return m.identities[accessKey], nil, nil
}
func (m *mockCredentialValidator) GetCredentialByAccessKey(accessKey string) (string, interface{}, string, error) {
secret, ok := m.credentials[accessKey]
if !ok {
return "", nil, "", fmt.Errorf("access key not found")
}
return m.identities[accessKey], nil, secret, nil
}
func newTestServerWithOAuth() *Server {
return &Server{
credentialValidator: &mockCredentialValidator{
credentials: map[string]string{"AKID123": "secret456"},
identities: map[string]string{"AKID123": "testuser"},
},
}
}
type mockS3Authenticator struct {
called bool
errCode s3err.ErrorCode
}
func (m *mockS3Authenticator) AuthenticateRequest(r *http.Request) (string, interface{}, s3err.ErrorCode) {
m.called = true
if m.errCode != s3err.ErrNone {
return "", nil, m.errCode
}
return "s3user", nil, s3err.ErrNone
}
func (m *mockS3Authenticator) DefaultAllow() bool { return false }
func mintTestToken(t *testing.T, accessKey, secret string, issuedAt, expiresAt time.Time) string {
t.Helper()
key := deriveSigningKey(accessKey, secret)
claims := LanceClaims{
IdentityName: "testuser",
AccessKey: accessKey,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(issuedAt),
ExpiresAt: jwt.NewNumericDate(expiresAt),
Issuer: "seaweedfs-lance",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
func TestHandleOAuthTokens_Success(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.TokenType != "bearer" {
t.Errorf("expected token_type=bearer, got %s", resp.TokenType)
}
if resp.AccessToken == "" {
t.Error("expected non-empty access_token")
}
if resp.ExpiresIn != oauthExpirySeconds() {
t.Errorf("expected expires_in=%d, got %d", oauthExpirySeconds(), resp.ExpiresIn)
}
}
func TestHandleOAuthTokens_InvalidCredentials(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=AKID123&client_secret=wrongsecret"
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
}
if wa := w.Header().Get("WWW-Authenticate"); wa != `Basic realm="lance"` {
t.Fatalf("WWW-Authenticate = %q, want Basic challenge", wa)
}
}
func TestHandleOAuthTokens_OversizedBody(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=" + strings.Repeat("x", maxRequestBody)
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestHandleOAuthTokens_UnsupportedGrantType(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=authorization_code&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBearerTokenRoundTrip(t *testing.T) {
s := newTestServerWithOAuth()
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
req := httptest.NewRequest(http.MethodPost, "/oauth/token", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.handleOAuthTokens(w, req)
var resp OAuthTokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
authReq := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
authReq.Header.Set("Authorization", "Bearer "+resp.AccessToken)
identityName, _, ok := s.authenticateBearer(authReq)
if !ok {
t.Fatal("expected Bearer auth to succeed")
}
if identityName != "testuser" {
t.Errorf("expected identity 'testuser', got '%s'", identityName)
}
}
func TestBearerTokenInvalid(t *testing.T) {
s := newTestServerWithOAuth()
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
_, _, ok := s.authenticateBearer(req)
if ok {
t.Error("expected Bearer auth to fail with invalid token")
}
}
// The issue-11430 reproduction: behind an auth-enabled gateway, a catalog
// request with no signature is denied, but the same request carrying a Bearer
// token minted from S3 credentials must pass.
func TestAuthBearerRunsHandler(t *testing.T) {
s := newTestServerWithOAuth()
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
s.authenticator = auth
now := time.Now()
fresh := mintTestToken(t, "AKID123", "secret456", now, now.Add(time.Hour))
var gotIdentity string
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
gotIdentity = s3_constants.GetIdentityNameFromContext(r)
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
req.Header.Set("Authorization", "Bearer "+fresh)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("fresh Bearer: status = %d, want 200", rec.Code)
}
if auth.called {
t.Fatalf("fresh Bearer should not need the S3 authenticator")
}
if gotIdentity != "testuser" {
t.Fatalf("identity in context = %q, want testuser", gotIdentity)
}
}
// An expired or malformed Bearer token answers 401 rather than falling
// through to the S3 authenticator, which would misread the header as SigV4.
func TestAuthExpiredBearerReturns401(t *testing.T) {
s := newTestServerWithOAuth()
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
s.authenticator = auth
now := time.Now()
expired := mintTestToken(t, "AKID123", "secret456", now.Add(-2*time.Hour), now.Add(-1*time.Hour))
var handlerCalled bool
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
req.Header.Set("Authorization", scheme+" "+expired)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("scheme %q: status = %d, want 401", scheme, rec.Code)
}
if wa := rec.Header().Get("WWW-Authenticate"); wa != "Bearer" {
t.Fatalf("scheme %q: WWW-Authenticate = %q, want Bearer", scheme, wa)
}
}
if auth.called {
t.Fatalf("expired Bearer must not fall through to the S3 authenticator")
}
if handlerCalled {
t.Fatalf("handler must not run for an expired token")
}
}
// x-api-key carries "access_key:secret_key" straight to the catalog, with no
// token to mint or expire - the header form LanceDB documents for API keys.
func TestAuthApiKeyRunsHandler(t *testing.T) {
s := newTestServerWithOAuth()
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
s.authenticator = auth
var gotIdentity string
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
gotIdentity = s3_constants.GetIdentityNameFromContext(r)
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
req.Header.Set("x-api-key", "AKID123:secret456")
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("x-api-key: status = %d, want 200", rec.Code)
}
if gotIdentity != "testuser" {
t.Fatalf("identity in context = %q, want testuser", gotIdentity)
}
}
func TestAuthApiKeyInvalid(t *testing.T) {
s := newTestServerWithOAuth()
auth := &mockS3Authenticator{errCode: s3err.ErrAccessDenied}
s.authenticator = auth
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
for _, key := range []string{"AKID123:wrongsecret", "AKID123", ":secret456", "unknown:key"} {
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
req.Header.Set("x-api-key", key)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("x-api-key %q: status = %d, want 401", key, rec.Code)
}
}
if auth.called {
t.Fatalf("bad x-api-key must not fall through to the S3 authenticator")
}
}
func TestAuthNoBearerStillUsesS3Authenticator(t *testing.T) {
s := newTestServerWithOAuth()
auth := &mockS3Authenticator{errCode: s3err.ErrNone}
s.authenticator = auth
var handlerCalled bool
handler := s.Auth(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/v1/namespace/$/list", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if !auth.called {
t.Fatalf("request without Bearer header must use the S3 authenticator")
}
if rec.Code != http.StatusOK || !handlerCalled {
t.Fatalf("status = %d, handler called = %v", rec.Code, handlerCalled)
}
}