mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
* feat(iceberg): add OAuth2 token endpoint for DuckDB compatibility (#9015) DuckDB's Iceberg connector uses OAuth2 client_credentials flow, hitting POST /v1/oauth/tokens which was not implemented, returning 404. Add the OAuth2 token endpoint that accepts S3 access key / secret key as client_id / client_secret, validates them against IAM, and returns a signed JWT bearer token. The Auth middleware now accepts Bearer tokens in addition to S3 signature auth. * fix(test): use weed shell for table bucket creation with IAM enabled The S3 Tables REST API requires SigV4 auth when IAM is configured. Use weed shell (which bypasses S3 auth) to create table buckets, matching the pattern used by the Trino integration tests. * address review feedback: access key in JWT, full identity in Bearer auth - Include AccessKey in JWT claims so token verification uses the exact credential that signed the token (no ambiguity with multi-key identities) - Return full Identity object from Bearer auth so downstream IAM/policy code sees an authenticated request, not anonymous - Replace GetSecretKeyForIdentity with GetCredentialByAccessKey for unambiguous credential lookup - DuckDB test now tries the full SQL script first (CREATE SECRET + catalog access), falling back to simple CREATE SECRET if needed - Tighten bearer auth test assertion to only accept 200/500 Addresses review comments from coderabbitai and gemini-code-assist. * security: use PostFormValue, bind signing key to access key, fix port conflict - Use r.PostFormValue instead of r.FormValue to prevent credentials from leaking via query string into logs and caches - Reject client_secret in URL query parameters explicitly - Include access key in HMAC signing key derivation to prevent cross-credential token forgery when secrets happen to match - Allocate dedicated webdav port in OAuth test env to avoid port collision with the shared TestMain cluster
156 lines
4.4 KiB
Go
156 lines
4.4 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
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 {
|
|
cv := &mockCredentialValidator{
|
|
credentials: map[string]string{"AKID123": "secret456"},
|
|
identities: map[string]string{"AKID123": "testuser"},
|
|
}
|
|
s := &Server{
|
|
credentialValidator: cv,
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestHandleOAuthTokens_Success(t *testing.T) {
|
|
s := newTestServerWithOAuth()
|
|
|
|
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", 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 != oauthTokenExpiry {
|
|
t.Errorf("expected expires_in=%d, got %d", oauthTokenExpiry, 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, "/v1/oauth/tokens", 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())
|
|
}
|
|
}
|
|
|
|
func TestHandleOAuthTokens_UnsupportedGrantType(t *testing.T) {
|
|
s := newTestServerWithOAuth()
|
|
|
|
body := "grant_type=authorization_code&client_id=AKID123&client_secret=secret456"
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", 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()
|
|
|
|
// Get a token
|
|
body := "grant_type=client_credentials&client_id=AKID123&client_secret=secret456"
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/oauth/tokens", 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)
|
|
}
|
|
|
|
// Use the token for Bearer auth
|
|
authReq := httptest.NewRequest(http.MethodGet, "/v1/namespaces", 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/namespaces", nil)
|
|
req.Header.Set("Authorization", "Bearer invalid-token")
|
|
|
|
_, _, ok := s.authenticateBearer(req)
|
|
if ok {
|
|
t.Error("expected Bearer auth to fail with invalid token")
|
|
}
|
|
}
|
|
|
|
func TestBearerTokenNone(t *testing.T) {
|
|
s := newTestServerWithOAuth()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/namespaces", nil)
|
|
|
|
_, _, ok := s.authenticateBearer(req)
|
|
if ok {
|
|
t.Error("expected Bearer auth to fail with no token")
|
|
}
|
|
}
|