diff --git a/weed/command/s3.go b/weed/command/s3.go index d054aac62..672d4da7d 100644 --- a/weed/command/s3.go +++ b/weed/command/s3.go @@ -618,6 +618,7 @@ func (s3opt *S3Options) startLanceServer(s3ApiServer *s3api.S3ApiServer) { lanceRouter.Use(util_http.EscapeSemicolonsInQuery) lanceServer := lance.NewServer(s3ApiServer, s3ApiServer) + lanceServer.SetCredentialValidator(s3ApiServer) if s3opt.icebergCredentialRole != nil && *s3opt.icebergCredentialRole != "" { lanceServer.SetCredentialVendor(lanceCredentialVendor{s3ApiServer}) } diff --git a/weed/s3api/lance/handlers_oauth.go b/weed/s3api/lance/handlers_oauth.go new file mode 100644 index 000000000..5e7003a74 --- /dev/null +++ b/weed/s3api/lance/handlers_oauth.go @@ -0,0 +1,245 @@ +package lance + +import ( + "crypto/hmac" + "crypto/sha256" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/seaweedfs/seaweedfs/weed/glog" +) + +// OAuthTokenResponse is the response for POST /oauth/token. +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} + +// OAuthErrorResponse is the error response for the OAuth endpoint. +type OAuthErrorResponse struct { + Error string `json:"error"` + Description string `json:"error_description,omitempty"` +} + +// LanceClaims are JWT claims for Lance catalog OAuth tokens. +type LanceClaims struct { + IdentityName string `json:"identity_name"` + AccessKey string `json:"access_key"` + jwt.RegisteredClaims +} + +const defaultOauthTokenExpiry = 3600 + +// maxOauthTokenExpiry bounds the configured TTL so seconds-to-Duration +// conversions cannot overflow into already-expired tokens. +const maxOauthTokenExpiry = 365 * 24 * 3600 + +const grantTypeClientCredentials = "client_credentials" + +// oauthExpirySeconds returns the OAuth token TTL. Lance clients hold a static +// Authorization header and cannot refresh on 401, so deployments can raise +// this to survive beyond the default hour. +func oauthExpirySeconds() int { + if v := os.Getenv("LANCE_OAUTH_TOKEN_EXPIRY"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + if n > maxOauthTokenExpiry { + return maxOauthTokenExpiry + } + return int(n) + } + } + return defaultOauthTokenExpiry +} + +// handleOAuthTokens implements the OAuth2 client_credentials flow. +// POST /oauth/token +func (s *Server) handleOAuthTokens(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) + if err := r.ParseForm(); err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "Could not parse form body") + return + } + + // Reject credentials in query string to prevent leaking secrets into logs and caches. + if r.URL.Query().Get("client_secret") != "" { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "client_secret must not be sent in the URL") + return + } + + if grantType := r.PostFormValue("grant_type"); grantType != grantTypeClientCredentials { + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", + fmt.Sprintf("Unsupported grant_type: %s", grantType)) + return + } + + clientID := r.PostFormValue("client_id") + clientSecret := r.PostFormValue("client_secret") + + // Also support HTTP Basic auth per OAuth2 spec + if clientID == "" && clientSecret == "" { + var ok bool + clientID, clientSecret, ok = r.BasicAuth() + if !ok { + writeInvalidClient(w, "Missing client credentials") + return + } + } + + if clientID == "" || clientSecret == "" { + writeInvalidClient(w, "Missing client_id or client_secret") + return + } + + if s.credentialValidator == nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "Credential validation not configured") + return + } + + identityName, _, err := s.credentialValidator.ValidateS3Credential(clientID, clientSecret) + if err != nil { + glog.V(2).Infof("Lance OAuth: credential validation failed for client_id=%s: %v", clientID, err) + writeInvalidClient(w, "Invalid client credentials") + return + } + + tokenString, err := mintToken(identityName, clientID, clientSecret, oauthExpirySeconds()) + if err != nil { + glog.Errorf("Lance OAuth: failed to sign token: %v", err) + writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token") + return + } + + resp := OAuthTokenResponse{ + AccessToken: tokenString, + TokenType: "bearer", + ExpiresIn: oauthExpirySeconds(), + Scope: r.PostFormValue("scope"), + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, resp) +} + +// mintToken issues a signed access token for the given identity and +// credential, valid for ttlSeconds. +func mintToken(identityName, accessKey, secret string, ttlSeconds int) (string, error) { + signingKey := deriveSigningKey(accessKey, secret) + now := time.Now() + claims := LanceClaims{ + IdentityName: identityName, + AccessKey: accessKey, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlSeconds) * time.Second)), + Issuer: "seaweedfs-lance", + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(signingKey) +} + +// authenticateBearer validates a Bearer token from the Authorization header. +// Returns the identity name, identity object, and whether auth succeeded. +func (s *Server) authenticateBearer(r *http.Request) (string, interface{}, bool) { + auth := r.Header.Get("Authorization") + if auth == "" { + return "", nil, false + } + if !strings.HasPrefix(strings.ToLower(auth), "bearer ") { + return "", nil, false + } + tokenString := strings.TrimSpace(auth[7:]) + if tokenString == "" { + return "", nil, false + } + + if s.credentialValidator == nil { + return "", nil, false + } + + // Parse the token without verification first to get the access key, + // then look up the exact credential to verify the signature. + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + unverified := &LanceClaims{} + _, _, err := parser.ParseUnverified(tokenString, unverified) + if err != nil { + glog.V(2).Infof("Lance OAuth: failed to parse token: %v", err) + return "", nil, false + } + + if unverified.AccessKey == "" { + return "", nil, false + } + + identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey) + if err != nil { + glog.V(2).Infof("Lance OAuth: failed to get credential for access key: %v", err) + return "", nil, false + } + + signingKey := deriveSigningKey(unverified.AccessKey, secretKey) + claims := &LanceClaims{} + verified, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return signingKey, nil + }) + if err != nil || !verified.Valid { + glog.V(2).Infof("Lance OAuth: token verification failed: %v", err) + return "", nil, false + } + + return identityName, identity, true +} + +// authenticateApiKey validates an x-api-key header carrying an S3 credential +// as "access_key:secret_key". Unlike a Bearer token it does not expire, which +// suits clients that hold static headers. +func (s *Server) authenticateApiKey(apiKey string) (string, interface{}, bool) { + if s.credentialValidator == nil { + return "", nil, false + } + accessKey, secretKey, ok := strings.Cut(apiKey, ":") + if !ok || accessKey == "" || secretKey == "" { + return "", nil, false + } + identityName, identity, err := s.credentialValidator.ValidateS3Credential(accessKey, secretKey) + if err != nil { + glog.V(2).Infof("Lance x-api-key: credential validation failed: %v", err) + return "", nil, false + } + return identityName, identity, true +} + +// deriveSigningKey derives a signing key from the access key and secret using HMAC-SHA256. +// Including the access key prevents cross-credential token forgery when two +// credentials happen to share the same secret. +func deriveSigningKey(accessKey, secret string) []byte { + h := hmac.New(sha256.New, []byte("seaweedfs-lance-oauth")) + h.Write([]byte(accessKey)) + h.Write([]byte{0}) // null separator + h.Write([]byte(secret)) + return h.Sum(nil) +} + +// writeInvalidClient answers 401 with the Basic challenge RFC 6749 ยง5.2 +// requires, so a client knows which scheme to retry with. +func writeInvalidClient(w http.ResponseWriter, description string) { + w.Header().Set("WWW-Authenticate", `Basic realm="lance"`) + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", description) +} + +func writeOAuthError(w http.ResponseWriter, status int, errCode, description string) { + resp := OAuthErrorResponse{ + Error: errCode, + Description: description, + } + writeJSON(w, status, resp) +} diff --git a/weed/s3api/lance/handlers_oauth_test.go b/weed/s3api/lance/handlers_oauth_test.go new file mode 100644 index 000000000..236b101f3 --- /dev/null +++ b/weed/s3api/lance/handlers_oauth_test.go @@ -0,0 +1,340 @@ +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) + } +} diff --git a/weed/s3api/lance/server.go b/weed/s3api/lance/server.go index 0218556fe..33ab48dc6 100644 --- a/weed/s3api/lance/server.go +++ b/weed/s3api/lance/server.go @@ -3,6 +3,7 @@ package lance import ( "context" "net/http" + "strings" "time" "github.com/gorilla/mux" @@ -23,6 +24,18 @@ type S3Authenticator interface { DefaultAllow() bool } +// CredentialValidator validates S3 access key / secret key pairs +// and provides credential lookup for OAuth token verification. +type CredentialValidator interface { + // ValidateS3Credential checks if the access key and secret key are valid. + // Returns the identity name and identity object on success. + ValidateS3Credential(accessKey, secretKey string) (identityName string, identity interface{}, err error) + // GetCredentialByAccessKey looks up a credential by access key. + // Returns the identity name, identity object, and secret key. + // Used for verifying Bearer tokens signed with a specific credential. + GetCredentialByAccessKey(accessKey string) (identityName string, identity interface{}, secretKey string, err error) +} + // VendedCredentials are short-lived S3 credentials scoped to one table. type VendedCredentials struct { AccessKeyID string @@ -40,12 +53,13 @@ type CredentialVendor interface { // Server implements the Lance Namespace REST spec. type Server struct { - filerClient FilerClient - tablesManager *s3tables.Manager - authenticator S3Authenticator - credentialVendor CredentialVendor - s3Endpoint string - s3Region string + filerClient FilerClient + tablesManager *s3tables.Manager + authenticator S3Authenticator + credentialValidator CredentialValidator + credentialVendor CredentialVendor + s3Endpoint string + s3Region string } // NewServer creates a Lance namespace server over the given filer. @@ -63,6 +77,11 @@ func NewServer(filerClient FilerClient, authenticator S3Authenticator) *Server { } } +// SetCredentialValidator sets the credential validator for OAuth token support. +func (s *Server) SetCredentialValidator(cv CredentialValidator) { + s.credentialValidator = cv +} + // SetCredentialVendor enables storage_options credential vending for clients // that ask for it with vend_credentials. func (s *Server) SetCredentialVendor(vendor CredentialVendor) { @@ -87,6 +106,9 @@ func (s *Server) SetS3Region(region string) { func (s *Server) RegisterRoutes(router *mux.Router) { router.Use(loggingMiddleware) + // OAuth2 token endpoint - no auth needed (this IS the auth endpoint) + router.HandleFunc("/oauth/token", s.handleOAuthTokens).Methods(http.MethodPost) + router.HandleFunc("/v1/namespace/{id}/create", s.Auth(s.handleCreateNamespace)).Methods(http.MethodPost) router.HandleFunc("/v1/namespace/{id}/list", s.Auth(s.handleListNamespaces)).Methods(http.MethodGet) router.HandleFunc("/v1/namespace/{id}/describe", s.Auth(s.handleDescribeNamespace)).Methods(http.MethodPost) @@ -124,7 +146,7 @@ func (s *Server) RegisterRoutes(router *mux.Router) { } router.PathPrefix("/").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - glog.V(2).Infof("lance: no route for %s %s", r.Method, r.RequestURI) + glog.V(2).Infof("lance: no route for %s %s", r.Method, r.URL.EscapedPath()) writeError(w, r, http.StatusNotFound, codeUnsupported, "no such operation") }) @@ -138,16 +160,52 @@ func (s *Server) handleUnsupported(w http.ResponseWriter, r *http.Request) { func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - glog.V(2).Infof("lance request: %s %s from %s", r.Method, r.RequestURI, r.RemoteAddr) + glog.V(2).Infof("lance request: %s %s from %s", r.Method, r.URL.EscapedPath(), r.RemoteAddr) next.ServeHTTP(w, r) }) } // Auth authenticates the caller and puts the identity in the request context. -// The Lance spec maps identity onto the same headers the S3 authenticator -// already understands, so SigV4 and bearer tokens both keep working. +// Lance clients authenticate the catalog with a Bearer token or an x-api-key +// header; the S3 authenticator stays for callers that can SigV4-sign. func (s *Server) Auth(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // A request carrying a Bearer token is a Lance REST client. An invalid + // or expired token gets 401 immediately; falling through to the S3 + // authenticator would parse the header as SigV4 and fail with a + // different error that clients cannot act on. + // The auth scheme is case-insensitive (RFC 7235). + if strings.HasPrefix(strings.ToLower(r.Header.Get("Authorization")), "bearer ") { + if identityName, identity, ok := s.authenticateBearer(r); ok { + ctx := r.Context() + ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) + if identity != nil { + ctx = s3_constants.SetIdentityInContext(ctx, identity) + } + r = r.WithContext(ctx) + handler(w, r) + return + } + w.Header().Set("WWW-Authenticate", "Bearer") + writeError(w, r, http.StatusUnauthorized, codeUnauthenticated, "Bearer token is invalid or expired") + return + } + + if apiKey := r.Header.Get("x-api-key"); apiKey != "" { + if identityName, identity, ok := s.authenticateApiKey(apiKey); ok { + ctx := r.Context() + ctx = s3_constants.SetIdentityNameInContext(ctx, identityName) + if identity != nil { + ctx = s3_constants.SetIdentityInContext(ctx, identity) + } + r = r.WithContext(ctx) + handler(w, r) + return + } + writeError(w, r, http.StatusUnauthorized, codeUnauthenticated, "invalid x-api-key") + return + } + if s.authenticator == nil { writeError(w, r, http.StatusUnauthorized, codeUnauthenticated, "authentication required") return