Files
seaweedfs/weed/s3api/iceberg/handlers_oauth.go
T
2026-04-10 17:31:14 -07:00

196 lines
6.2 KiB
Go

package iceberg
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"net/http"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/glog"
)
// OAuthTokenResponse is the response for POST /v1/oauth/tokens.
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"`
}
// IcebergClaims are JWT claims for Iceberg catalog OAuth tokens.
type IcebergClaims struct {
IdentityName string `json:"identity_name"`
AccessKey string `json:"access_key"`
jwt.RegisteredClaims
}
const oauthTokenExpiry = 3600 // 1 hour in seconds
// handleOAuthTokens implements the OAuth2 client_credentials flow.
// POST /v1/oauth/tokens
func (s *Server) handleOAuthTokens(w http.ResponseWriter, r *http.Request) {
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
}
grantType := r.PostFormValue("grant_type")
if grantType != "client_credentials" {
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 {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Missing client credentials")
return
}
}
if clientID == "" || clientSecret == "" {
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "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("Iceberg OAuth: credential validation failed for client_id=%s: %v", clientID, err)
writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "Invalid client credentials")
return
}
// Generate a JWT signed with a key derived from the client secret.
// Include the access key in claims so we can look up the exact credential for verification.
signingKey := deriveSigningKey(clientID, clientSecret)
now := time.Now()
claims := IcebergClaims{
IdentityName: identityName,
AccessKey: clientID,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(oauthTokenExpiry) * time.Second)),
Issuer: "seaweedfs-iceberg",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString(signingKey)
if err != nil {
glog.Errorf("Iceberg OAuth: failed to sign token: %v", err)
writeOAuthError(w, http.StatusInternalServerError, "server_error", "Failed to generate token")
return
}
scope := r.PostFormValue("scope")
resp := OAuthTokenResponse{
AccessToken: tokenString,
TokenType: "bearer",
ExpiresIn: oauthTokenExpiry,
Scope: scope,
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, resp)
}
// 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 := &IcebergClaims{}
_, _, err := parser.ParseUnverified(tokenString, unverified)
if err != nil {
glog.V(2).Infof("Iceberg OAuth: failed to parse token: %v", err)
return "", nil, false
}
if unverified.AccessKey == "" {
return "", nil, false
}
// Look up the credential by access key to get the signing key for verification
identityName, identity, secretKey, err := s.credentialValidator.GetCredentialByAccessKey(unverified.AccessKey)
if err != nil {
glog.V(2).Infof("Iceberg OAuth: failed to get credential for access key: %v", err)
return "", nil, false
}
signingKey := deriveSigningKey(unverified.AccessKey, secretKey)
claims := &IcebergClaims{}
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("Iceberg OAuth: token verification 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-iceberg-oauth"))
h.Write([]byte(accessKey))
h.Write([]byte{0}) // null separator
h.Write([]byte(secret))
return h.Sum(nil)
}
func writeOAuthError(w http.ResponseWriter, status int, errCode, description string) {
resp := OAuthErrorResponse{
Error: errCode,
Description: description,
}
writeJSON(w, status, resp)
}