mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
196 lines
6.2 KiB
Go
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)
|
|
}
|