diff --git a/config/oauth_client_email_authorization.json b/config/oauth_client_email_authorization.json new file mode 100644 index 0000000..36e0e29 --- /dev/null +++ b/config/oauth_client_email_authorization.json @@ -0,0 +1,9 @@ +{ + "client_id_1": [ + "email1@example.com", + "email2@example.com" + ], + "client_id_2": [ + "email3@example.com" + ] +} diff --git a/config/raven.yaml b/config/raven.yaml index d7b8aa1..0eee4b5 100644 --- a/config/raven.yaml +++ b/config/raven.yaml @@ -10,6 +10,12 @@ oauth_jwks_url: "https://idp.example.com/.well-known/jwks.json" oauth_audience: - "raven-imap" oauth_clock_skew_seconds: 60 +# Optional JSON mapping for client_credentials sender authorization: +# { +# "client_id_1": ["email1@example.com", "email2@example.com"], +# "client_id_2": ["email3@example.com"] +# } +oauth_client_email_authorization_file: "./config/oauth_client_email_authorization.json" # SASL Authentication Scope # Controls where SASL authentication is enforced @@ -28,4 +34,3 @@ blob_storage: access_key: "your-access-key" secret_key: "your-secret-key" timeout: 30 # seconds - diff --git a/go.mod b/go.mod index 0f0abc8..5d47ab2 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,10 @@ go 1.25.0 require ( github.com/aws/aws-sdk-go-v2 v1.41.6 - github.com/aws/aws-sdk-go-v2/config v1.32.6 - github.com/aws/aws-sdk-go-v2/credentials v1.19.6 - github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 - github.com/aws/smithy-go v1.25.0 + github.com/aws/aws-sdk-go-v2/config v1.32.16 + github.com/aws/aws-sdk-go-v2/credentials v1.19.15 + github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 + github.com/aws/smithy-go v1.25.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/mattn/go-sqlite3 v1.14.32 golang.org/x/sync v0.20.0 @@ -16,7 +16,7 @@ require ( require ( github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect @@ -25,8 +25,8 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect ) diff --git a/go.sum b/go.sum index e05ff3c..9c56f9b 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,16 @@ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= +github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= @@ -24,18 +30,28 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU= -github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls= +github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 h1:7G26Sae6PMKn4kMcU5JzNfrm1YrKwyOhowXPYR2WiWY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= diff --git a/internal/auth/oauthbearer/oauthbearer.go b/internal/auth/oauthbearer/oauthbearer.go index 2a1c964..13e6be0 100644 --- a/internal/auth/oauthbearer/oauthbearer.go +++ b/internal/auth/oauthbearer/oauthbearer.go @@ -40,6 +40,8 @@ type Claims struct { Audience []string Roles []string ExpiresAt time.Time + GrantType string + ClientID string } // RoleAccessRequest describes a role-based mailbox access derived from a @@ -310,6 +312,25 @@ func extractClaims(claims jwt.MapClaims) Claims { if expUnix, ok := claimAsInt64(claims, "exp"); ok { result.ExpiresAt = time.Unix(expUnix, 0) } + if result.GrantType == "" { + result.GrantType = claimAsString(claims, "grant_type") + } + if result.GrantType == "" { + result.GrantType = claimAsString(claims, "grantTypes") + } + if result.GrantType == "" { + result.GrantType = claimAsString(claims, "gty") + } + if result.ClientID == "" { + result.ClientID = claimAsString(claims, "client_id") + } + if result.ClientID == "" { + result.ClientID = claimAsString(claims, "clientId") + } + + if strings.EqualFold(result.GrantType, "client_credentials") { + log.Printf("OAUTHBEARER: extracted client_credentials claims grant_type=%q client_id=%q", result.GrantType, result.ClientID) + } return result } diff --git a/internal/auth/oauthbearer/oauthbearer_test.go b/internal/auth/oauthbearer/oauthbearer_test.go index c4cdbea..27afd71 100644 --- a/internal/auth/oauthbearer/oauthbearer_test.go +++ b/internal/auth/oauthbearer/oauthbearer_test.go @@ -259,6 +259,36 @@ func TestIdentityPrecedence(t *testing.T) { } } +func TestExtractClaims_GrantTypeAndClientID(t *testing.T) { + claims := extractClaims(jwt.MapClaims{ + "grant_type": "client_credentials", + "client_id": "client-a", + }) + + if claims.GrantType != "client_credentials" { + t.Fatalf("expected grant type client_credentials, got %q", claims.GrantType) + } + + if claims.ClientID != "client-a" { + t.Fatalf("expected client_id client-a, got %q", claims.ClientID) + } +} + +func TestExtractClaims_GrantTypeAndClientIDFallbacks(t *testing.T) { + claims := extractClaims(jwt.MapClaims{ + "gty": "client_credentials", + "clientId": "client-b", + }) + + if claims.GrantType != "client_credentials" { + t.Fatalf("expected grant type client_credentials from gty, got %q", claims.GrantType) + } + + if claims.ClientID != "client-b" { + t.Fatalf("expected client_id client-b from clientId, got %q", claims.ClientID) + } +} + func TestValidateAccessToken_ConcurrentJWKSRefreshSingleflight(t *testing.T) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { diff --git a/internal/conf/config.go b/internal/conf/config.go index d455b41..ffb8c49 100644 --- a/internal/conf/config.go +++ b/internal/conf/config.go @@ -29,14 +29,15 @@ const ( ) type Config struct { - Domain string `yaml:"domain"` - AuthServerURL string `yaml:"auth_server_url"` - SASLScope SASLScope `yaml:"sasl_scope"` - OAuthIssuer string `yaml:"oauth_issuer_url"` - OAuthJWKSURL string `yaml:"oauth_jwks_url"` - OAuthAudience []string `yaml:"oauth_audience"` - OAuthSkewSec int `yaml:"oauth_clock_skew_seconds"` - BlobStorage blobstorage.Config `yaml:"blob_storage"` + Domain string `yaml:"domain"` + AuthServerURL string `yaml:"auth_server_url"` + SASLScope SASLScope `yaml:"sasl_scope"` + OAuthIssuer string `yaml:"oauth_issuer_url"` + OAuthJWKSURL string `yaml:"oauth_jwks_url"` + OAuthAudience []string `yaml:"oauth_audience"` + OAuthSkewSec int `yaml:"oauth_clock_skew_seconds"` + OAuthClientEmailAuthorizationFile string `yaml:"oauth_client_email_authorization_file"` + BlobStorage blobstorage.Config `yaml:"blob_storage"` } func LoadConfig() (*Config, error) { diff --git a/internal/sasl/client_email_authorizer.go b/internal/sasl/client_email_authorizer.go new file mode 100644 index 0000000..6ecb986 --- /dev/null +++ b/internal/sasl/client_email_authorizer.go @@ -0,0 +1,78 @@ +package sasl + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" +) + +type clientEmailAuthorizer struct { + allowedByClientID map[string]map[string]struct{} +} + +func newClientEmailAuthorizerFromFile(path string) (*clientEmailAuthorizer, error) { + cleanPath := filepath.Clean(strings.TrimSpace(path)) + if cleanPath == "" { + return nil, fmt.Errorf("empty client email authorization file path") + } + + data, err := os.ReadFile(cleanPath) + if err != nil { + return nil, fmt.Errorf("failed to read client email authorization file: %w", err) + } + + var raw map[string][]string + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse client email authorization file: %w", err) + } + + a := &clientEmailAuthorizer{allowedByClientID: make(map[string]map[string]struct{}, len(raw))} + totalAllowedEmails := 0 + for clientID, emails := range raw { + normalizedClientID := strings.TrimSpace(clientID) + if normalizedClientID == "" { + log.Printf("SASL OAUTHBEARER: skipping empty client_id in authorization config file=%q", cleanPath) + continue + } + + allowed := make(map[string]struct{}, len(emails)) + for _, email := range emails { + normalizedEmail := strings.ToLower(strings.TrimSpace(email)) + if normalizedEmail == "" { + log.Printf("SASL OAUTHBEARER: skipping empty email for client_id=%q in authorization config", normalizedClientID) + continue + } + allowed[normalizedEmail] = struct{}{} + totalAllowedEmails++ + } + + a.allowedByClientID[normalizedClientID] = allowed + } + + log.Printf("SASL OAUTHBEARER: client email authorization config loaded file=%q clients=%d allowed_emails=%d", cleanPath, len(a.allowedByClientID), totalAllowedEmails) + + return a, nil +} + +func (a *clientEmailAuthorizer) IsEmailAuthorized(clientID, email string) bool { + if a == nil { + return false + } + + normalizedClientID := strings.TrimSpace(clientID) + normalizedEmail := strings.ToLower(strings.TrimSpace(email)) + if normalizedClientID == "" || normalizedEmail == "" { + return false + } + + allowedEmails, ok := a.allowedByClientID[normalizedClientID] + if !ok { + return false + } + + _, exists := allowedEmails[normalizedEmail] + return exists +} diff --git a/internal/sasl/client_email_authorizer_test.go b/internal/sasl/client_email_authorizer_test.go new file mode 100644 index 0000000..b944d0a --- /dev/null +++ b/internal/sasl/client_email_authorizer_test.go @@ -0,0 +1,88 @@ +package sasl + +import ( + "os" + "path/filepath" + "testing" + + "raven/internal/auth/oauthbearer" +) + +func TestClientEmailAuthorizer_LoadAndAuthorize(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "client-email-authz.json") + + content := `{ + "client_id_1": ["email1@example.com", "email2@example.com"], + "client_id_2": ["email3@example.com"] +}` + if err := os.WriteFile(configPath, []byte(content), 0600); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + authorizer, err := newClientEmailAuthorizerFromFile(configPath) + if err != nil { + t.Fatalf("failed to load authorizer: %v", err) + } + + if !authorizer.IsEmailAuthorized("client_id_1", "email1@example.com") { + t.Fatalf("expected email1@example.com to be authorized for client_id_1") + } + + if !authorizer.IsEmailAuthorized("client_id_1", "EMAIL2@example.com") { + t.Fatalf("expected case-insensitive email match for client_id_1") + } + + if authorizer.IsEmailAuthorized("client_id_1", "email3@example.com") { + t.Fatalf("did not expect email3@example.com to be authorized for client_id_1") + } + + if authorizer.IsEmailAuthorized("missing_client", "email1@example.com") { + t.Fatalf("did not expect authorization for unknown client_id") + } +} + +func TestAuthorizeOAuthClientCredentialsSender(t *testing.T) { + authorizer := &clientEmailAuthorizer{ + allowedByClientID: map[string]map[string]struct{}{ + "client-a": { + "sender@example.com": {}, + }, + }, + } + + s := &Server{ + domain: "example.com", + oauthClientEmailAuthorizer: authorizer, + } + + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "password"}, "", ""); err != nil { + t.Fatalf("expected non-client_credentials grant to skip check, got error: %v", err) + } + + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "client_credentials"}, "", "sender"); err == nil { + t.Fatalf("expected error when client_id is missing") + } + + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "client_credentials", ClientID: "client-a"}, "", ""); err == nil { + t.Fatalf("expected error when sender email is missing") + } + + s.oauthClientEmailAuthorizer = nil + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "client_credentials", ClientID: "client-a"}, "", "sender@example.com"); err == nil { + t.Fatalf("expected error when authorizer config is missing") + } + + s.oauthClientEmailAuthorizer = authorizer + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "client_credentials", ClientID: "client-a"}, "", "not-allowed@example.com"); err == nil { + t.Fatalf("expected error for unauthorized sender email") + } + + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "client_credentials", ClientID: "client-a"}, "", "sender@example.com"); err != nil { + t.Fatalf("expected sender@example.com to be authorized: %v", err) + } + + if err := s.authorizeOAuthClientCredentialsSender(&oauthbearer.Claims{GrantType: "client_credentials", ClientID: "client-a"}, "", "sender"); err != nil { + t.Fatalf("expected sender to be normalized with default domain and authorized: %v", err) + } +} diff --git a/internal/sasl/server.go b/internal/sasl/server.go index a9dcfdd..87a6fe1 100644 --- a/internal/sasl/server.go +++ b/internal/sasl/server.go @@ -40,19 +40,20 @@ type authState struct { // Server represents a SASL authentication server type Server struct { - socketPath string - tcpAddr string - authURL string - domain string - saslScope conf.SASLScope - oauthConfig *conf.Config - oauthValidator *oauthbearer.Validator - unixListener net.Listener - tcpListener net.Listener - mu sync.Mutex - wg sync.WaitGroup - shutdown chan struct{} - shutdownOnce sync.Once + socketPath string + tcpAddr string + authURL string + domain string + saslScope conf.SASLScope + oauthConfig *conf.Config + oauthValidator *oauthbearer.Validator + oauthClientEmailAuthorizer *clientEmailAuthorizer + unixListener net.Listener + tcpListener net.Listener + mu sync.Mutex + wg sync.WaitGroup + shutdown chan struct{} + shutdownOnce sync.Once } // NewServer creates a new SASL authentication server @@ -91,6 +92,18 @@ func (s *Server) initOAuthValidation() { s.oauthConfig = cfg s.oauthValidator = validator + + if strings.TrimSpace(cfg.OAuthClientEmailAuthorizationFile) != "" { + authorizer, loadErr := newClientEmailAuthorizerFromFile(cfg.OAuthClientEmailAuthorizationFile) + if loadErr != nil { + log.Printf("SASL OAUTHBEARER: client email authorization config load failed: %v", loadErr) + } else { + s.oauthClientEmailAuthorizer = authorizer + log.Printf("SASL OAUTHBEARER: client email authorization enabled file=%q", cfg.OAuthClientEmailAuthorizationFile) + } + } else { + log.Printf("SASL OAUTHBEARER: client email authorization file not configured (client_credentials sender checks disabled)") + } } // Start starts the SASL server @@ -515,13 +528,14 @@ func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided return } - accessToken, _, saslUser, err := oauthbearer.ParseInitialClientResponseDetails(resp) + accessToken, authzid, requestedUser, err := oauthbearer.ParseInitialClientResponseDetails(resp) if err != nil { response := fmt.Sprintf("FAIL\t%s\treason=Invalid OAUTHBEARER payload\n", id) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) return } + log.Printf("SASL OAUTHBEARER: parsed auth request id=%s authzid=%q requested_user=%q token_len=%d", id, authzid, requestedUser, len(accessToken)) if s.oauthConfig == nil || s.oauthValidator == nil { response := fmt.Sprintf("FAIL\t%s\treason=OAUTHBEARER configuration error\n", id) @@ -532,6 +546,16 @@ func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided claims, err := s.oauthValidator.ValidateAccessToken(accessToken) if err != nil { + log.Printf("SASL OAUTHBEARER: token validation failed id=%s error=%v", id, err) + response := fmt.Sprintf("FAIL\t%s\treason=Invalid credentials\n", id) + _, _ = conn.Write([]byte(response)) + log.Printf("SASL sent: %s", strings.TrimSpace(response)) + return + } + log.Printf("SASL OAUTHBEARER: token validated id=%s grant_type=%q client_id=%q identity=%q", id, claims.GrantType, claims.ClientID, claims.Identity()) + + if err := s.authorizeOAuthClientCredentialsSender(&claims, authzid, requestedUser); err != nil { + log.Printf("SASL OAUTHBEARER: sender authorization failed id=%s error=%v", id, err) response := fmt.Sprintf("FAIL\t%s\treason=Invalid credentials\n", id) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) @@ -547,7 +571,7 @@ func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided return } - saslUserEmail := normalizeOAuthIdentity(saslUser, s.oauthConfig.Domain) + saslUserEmail := normalizeOAuthIdentity(requestedUser, s.oauthConfig.Domain) if saslUserEmail != "" && !strings.EqualFold(saslUserEmail, user) { roleAccess := oauthbearer.EvaluateRoleAccess(saslUserEmail, claims) if roleAccess == nil { @@ -566,6 +590,59 @@ func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided log.Printf("SASL sent: %s", strings.TrimSpace(response)) } +func (s *Server) authorizeOAuthClientCredentialsSender(claims *oauthbearer.Claims, authzid, requestedUser string) error { + if !strings.EqualFold(strings.TrimSpace(claims.GrantType), "client_credentials") { + log.Printf("SASL OAUTHBEARER: skipping sender authorization grant_type=%q", claims.GrantType) + return nil + } + + clientID := strings.TrimSpace(claims.ClientID) + if clientID == "" { + log.Printf("SASL OAUTHBEARER: client_credentials authorization denied reason=missing_client_id") + return fmt.Errorf("missing client_id claim") + } + + defaultDomain := s.domain + if s.oauthConfig != nil { + defaultDomain = s.oauthConfig.Domain + } + + senderEmail := normalizeOAuthIdentity(firstNonEmpty(requestedUser, authzid), defaultDomain) + log.Printf("SASL OAUTHBEARER: client_credentials authorization input client_id=%q requested_user=%q authzid=%q normalized_sender=%q", clientID, strings.TrimSpace(requestedUser), strings.TrimSpace(authzid), senderEmail) + if senderEmail == "" { + log.Printf("SASL OAUTHBEARER: client_credentials authorization denied client_id=%q reason=missing_sender_email", clientID) + return fmt.Errorf("missing sender email") + } + + if s.oauthClientEmailAuthorizer == nil { + log.Printf("SASL OAUTHBEARER: client_credentials authorization denied client_id=%q sender=%q reason=missing_authorization_config", clientID, senderEmail) + return fmt.Errorf("missing client email authorization config") + } + + if !s.oauthClientEmailAuthorizer.IsEmailAuthorized(clientID, senderEmail) { + log.Printf("SASL OAUTHBEARER: client_credentials authorization denied client_id=%q sender=%q reason=email_not_allowed", clientID, senderEmail) + return fmt.Errorf("sender email is not authorized for client_id") + } + + log.Printf("SASL OAUTHBEARER: client_credentials authorization granted client_id=%q sender=%q", clientID, senderEmail) + + // Override the token identity with the authorized sender so that the + // downstream user resolved via claims.Identity() is the sender email + // rather than the client_id. + claims.Email = senderEmail + + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func normalizeOAuthIdentity(identity, defaultDomain string) string { identity = strings.TrimSpace(identity) if identity == "" {