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.
This commit is contained in:
Chris Lu
2026-09-24 07:02:59 +08:00
committed by GitHub
parent b9ad62fc16
commit b3a8701989
4 changed files with 654 additions and 10 deletions
+1
View File
@@ -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})
}
+245
View File
@@ -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)
}
+340
View File
@@ -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)
}
}
+68 -10
View File
@@ -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