Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions config/oauth_client_email_authorization.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"client_id_1": [
"email1@example.com",
"email2@example.com"
],
"client_id_2": [
"email3@example.com"
]
}
7 changes: 6 additions & 1 deletion config/raven.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,4 +34,3 @@ blob_storage:
access_key: "your-access-key"
secret_key: "your-secret-key"
timeout: 30 # seconds

18 changes: 9 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
)
20 changes: 18 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
21 changes: 21 additions & 0 deletions internal/auth/oauthbearer/oauthbearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
30 changes: 30 additions & 0 deletions internal/auth/oauthbearer/oauthbearer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 9 additions & 8 deletions internal/conf/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
78 changes: 78 additions & 0 deletions internal/sasl/client_email_authorizer.go
Original file line number Diff line number Diff line change
@@ -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
}
88 changes: 88 additions & 0 deletions internal/sasl/client_email_authorizer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading