diff --git a/internal/auth/oauthbearer/oauthbearer.go b/internal/auth/oauthbearer/oauthbearer.go index 95c07b1..2a1c964 100644 --- a/internal/auth/oauthbearer/oauthbearer.go +++ b/internal/auth/oauthbearer/oauthbearer.go @@ -38,9 +38,18 @@ type Claims struct { Subject string Issuer string Audience []string + Roles []string ExpiresAt time.Time } +// RoleAccessRequest describes a role-based mailbox access derived from a +// SASL user= field and the validated token claims. +type RoleAccessRequest struct { + Role string + Domain string + MailboxIdentity string +} + func (c Claims) Identity() string { if strings.TrimSpace(c.Email) != "" { return strings.TrimSpace(c.Email) @@ -294,12 +303,67 @@ func extractClaims(claims jwt.MapClaims) Claims { result.Issuer = strings.TrimSpace(iss) } result.Audience = audienceList(claims["aud"]) + result.Roles = stringListClaim(claims["roles"]) + if len(result.Roles) == 0 { + result.Roles = stringListClaim(claims["role"]) + } if expUnix, ok := claimAsInt64(claims, "exp"); ok { result.ExpiresAt = time.Unix(expUnix, 0) } return result } +// EvaluateRoleAccess returns a non-nil result when saslUserEmail is a +// role-based address (role@domain) AND the token's roles claim contains +// that role (case-insensitive). Callers should fall back to the normal +// personal-mailbox match when this returns nil. +func EvaluateRoleAccess(saslUserEmail string, claims Claims) *RoleAccessRequest { + saslUserEmail = strings.TrimSpace(saslUserEmail) + if saslUserEmail == "" || len(claims.Roles) == 0 { + return nil + } + at := strings.LastIndex(saslUserEmail, "@") + if at <= 0 || at == len(saslUserEmail)-1 { + return nil + } + local := strings.ToLower(strings.TrimSpace(saslUserEmail[:at])) + domain := strings.ToLower(strings.Trim(strings.TrimSpace(saslUserEmail[at+1:]), ".")) + if !isSafeMailboxComponent(local) || !isSafeMailboxComponent(domain) { + return nil + } + for _, role := range claims.Roles { + if strings.EqualFold(role, local) { + return &RoleAccessRequest{ + Role: local, + Domain: domain, + MailboxIdentity: "role_" + local + "@" + domain + ".db", + } + } + } + return nil +} + +// isSafeMailboxComponent restricts the local/domain parts that get +// embedded into a filesystem-style mailbox identity (role_@.db) +// to a conservative ASCII allowlist. Rejects empty input and any +// sequence ("..", "/", "\\", control chars, etc.) that could escape the +// mailbox namespace if the identity is later used as a path. +func isSafeMailboxComponent(s string) bool { + if s == "" || strings.Contains(s, "..") { + return false + } + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '.' || r == '-' || r == '_' || r == '+': + default: + return false + } + } + return true +} + func ParseInitialClientResponseDetails(encoded string) (token string, authzid string, user string, err error) { decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded)) if err != nil { @@ -419,6 +483,39 @@ func claimAsString(claims jwt.MapClaims, key string) string { return strings.TrimSpace(s) } +func stringListClaim(raw any) []string { + switch t := raw.(type) { + case nil: + return nil + case string: + s := strings.TrimSpace(t) + if s == "" { + return nil + } + return []string{s} + case []any: + out := make([]string, 0, len(t)) + for _, v := range t { + if s, ok := v.(string); ok { + if trimmed := strings.TrimSpace(s); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out + case []string: + out := make([]string, 0, len(t)) + for _, s := range t { + if trimmed := strings.TrimSpace(s); trimmed != "" { + out = append(out, trimmed) + } + } + return out + default: + return nil + } +} + func audienceList(raw any) []string { switch t := raw.(type) { case string: diff --git a/internal/auth/oauthbearer/oauthbearer_test.go b/internal/auth/oauthbearer/oauthbearer_test.go index a1e9314..f05f829 100644 --- a/internal/auth/oauthbearer/oauthbearer_test.go +++ b/internal/auth/oauthbearer/oauthbearer_test.go @@ -328,6 +328,118 @@ func TestValidateAccessToken_ConcurrentJWKSRefreshSingleflight(t *testing.T) { } } +func TestExtractClaims_RolesArray(t *testing.T) { + c := extractClaims(jwt.MapClaims{ + "email": "alice@example.com", + "roles": []any{"admin", " support ", ""}, + }) + if len(c.Roles) != 2 || c.Roles[0] != "admin" || c.Roles[1] != "support" { + t.Fatalf("expected [admin support], got %#v", c.Roles) + } +} + +func TestExtractClaims_RolesString(t *testing.T) { + c := extractClaims(jwt.MapClaims{ + "email": "alice@example.com", + "roles": "admin", + }) + if len(c.Roles) != 1 || c.Roles[0] != "admin" { + t.Fatalf("expected [admin], got %#v", c.Roles) + } +} + +func TestExtractClaims_RoleSingularFallback(t *testing.T) { + c := extractClaims(jwt.MapClaims{ + "email": "alice@example.com", + "role": "admin", + }) + if len(c.Roles) != 1 || c.Roles[0] != "admin" { + t.Fatalf("expected [admin] via singular role, got %#v", c.Roles) + } +} + +func TestExtractClaims_NoRoles(t *testing.T) { + c := extractClaims(jwt.MapClaims{ + "email": "alice@example.com", + }) + if len(c.Roles) != 0 { + t.Fatalf("expected no roles, got %#v", c.Roles) + } +} + +func TestEvaluateRoleAccess_Match(t *testing.T) { + got := EvaluateRoleAccess("admin@co.com", Claims{Roles: []string{"admin"}}) + if got == nil { + t.Fatal("expected role access, got nil") + } + if got.Role != "admin" || got.Domain != "co.com" || got.MailboxIdentity != "role_admin@co.com.db" { + t.Fatalf("unexpected role access: %#v", got) + } +} + +func TestEvaluateRoleAccess_CaseInsensitive(t *testing.T) { + got := EvaluateRoleAccess("ADMIN@CO.COM", Claims{Roles: []string{"Admin"}}) + if got == nil { + t.Fatal("expected role access, got nil") + } + if got.MailboxIdentity != "role_admin@co.com.db" { + t.Fatalf("expected lowercased mailbox identity, got %q", got.MailboxIdentity) + } +} + +func TestEvaluateRoleAccess_NotARole(t *testing.T) { + got := EvaluateRoleAccess("bob@co.com", Claims{Roles: []string{"admin"}}) + if got != nil { + t.Fatalf("expected nil, got %#v", got) + } +} + +func TestEvaluateRoleAccess_NoAt(t *testing.T) { + got := EvaluateRoleAccess("adminco.com", Claims{Roles: []string{"admin"}}) + if got != nil { + t.Fatalf("expected nil for address without @, got %#v", got) + } +} + +func TestEvaluateRoleAccess_EmptyRoles(t *testing.T) { + got := EvaluateRoleAccess("admin@co.com", Claims{}) + if got != nil { + t.Fatalf("expected nil for empty roles, got %#v", got) + } +} + +func TestEvaluateRoleAccess_TrailingDotDomain(t *testing.T) { + got := EvaluateRoleAccess("admin@co.com.", Claims{Roles: []string{"admin"}}) + if got == nil || got.Domain != "co.com" || got.MailboxIdentity != "role_admin@co.com.db" { + t.Fatalf("expected trailing-dot trimmed, got %#v", got) + } +} + +func TestEvaluateRoleAccess_RejectsUnsafeComponents(t *testing.T) { + // Each input is a SASL user= value whose local or domain part contains + // characters that must never make it into the mailbox identity, even + // when the token roles claim happens to match. + cases := map[string]string{ + "path traversal in domain": "admin@..", + "double dot inside domain": "admin@co..com", + "slash in domain": "admin@co/com", + "backslash in domain": "admin@co\\com", + "slash in local": "ad/min@co.com", + "double dot in local": "ad..min@co.com", + "non-ascii in domain": "admin@cö.com", + "whitespace inside local": "ad min@co.com", + "null byte in domain": "admin@co.com\x00evil", + } + for name, addr := range cases { + t.Run(name, func(t *testing.T) { + got := EvaluateRoleAccess(addr, Claims{Roles: []string{"admin", "ad..min", "ad/min", "ad min"}}) + if got != nil { + t.Fatalf("expected nil for unsafe address %q, got %#v", addr, got) + } + }) + } +} + func signToken(priv *rsa.PrivateKey, kid string, claims jwt.MapClaims) (string, error) { tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) tok.Header["kid"] = kid diff --git a/internal/sasl/server.go b/internal/sasl/server.go index 6619573..076f21e 100644 --- a/internal/sasl/server.go +++ b/internal/sasl/server.go @@ -483,7 +483,7 @@ func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided return } - accessToken, _, _, err := oauthbearer.ParseInitialClientResponseDetails(resp) + accessToken, _, saslUser, err := oauthbearer.ParseInitialClientResponseDetails(resp) if err != nil { response := fmt.Sprintf("FAIL\t%s\treason=Invalid OAUTHBEARER payload\n", id) _, _ = conn.Write([]byte(response)) @@ -515,6 +515,20 @@ func (s *Server) handleOAuthBearer(conn net.Conn, id, resp string, respProvided return } + saslUserEmail := normalizeOAuthIdentity(saslUser, s.oauthConfig.Domain) + if saslUserEmail != "" && !strings.EqualFold(saslUserEmail, user) { + roleAccess := oauthbearer.EvaluateRoleAccess(saslUserEmail, claims) + if roleAccess == nil { + log.Printf("SASL OAUTHBEARER: SASL user %q does not match resolved mailbox email %q", saslUserEmail, user) + 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: role-based access granted token_user=%q role=%q mailbox=%q", user, roleAccess.Role, roleAccess.MailboxIdentity) + user = roleAccess.MailboxIdentity + } + response := fmt.Sprintf("OK\t%s\tuser=%s\n", id, user) _, _ = conn.Write([]byte(response)) log.Printf("SASL sent: %s", strings.TrimSpace(response)) diff --git a/internal/server/auth/auth.go b/internal/server/auth/auth.go index 8a1bba1..05f4429 100644 --- a/internal/server/auth/auth.go +++ b/internal/server/auth/auth.go @@ -317,15 +317,21 @@ func HandleAuthenticate(deps ServerDeps, conn net.Conn, tag string, parts []stri return } + mailboxEmail := email if !strings.EqualFold(saslUserEmail, email) { - log.Printf("OAUTHBEARER: SASL user %q does not match resolved mailbox email %q", saslUserEmail, email) - deps.SendResponse(conn, fmt.Sprintf("%s NO [AUTHENTICATIONFAILED] Authentication failed", tag)) - return + roleAccess := oauthbearer.EvaluateRoleAccess(saslUserEmail, claims) + if roleAccess == nil { + log.Printf("OAUTHBEARER: SASL user %q does not match resolved mailbox email %q", saslUserEmail, email) + deps.SendResponse(conn, fmt.Sprintf("%s NO [AUTHENTICATIONFAILED] Authentication failed", tag)) + return + } + log.Printf("OAUTHBEARER: role-based access granted token_user=%q role=%q mailbox=%q", email, roleAccess.Role, roleAccess.MailboxIdentity) + mailboxEmail = roleAccess.MailboxIdentity } - actualUsername := deps.ExtractUsername(email) + actualUsername := deps.ExtractUsername(mailboxEmail) - if err := deps.EnsureUserAndMailboxes(email); err != nil { + if err := deps.EnsureUserAndMailboxes(mailboxEmail); err != nil { log.Printf("Failed to initialize user database for OAUTHBEARER: %v", err) deps.SendResponse(conn, fmt.Sprintf("%s NO [SERVERBUG] Server error", tag)) return @@ -333,7 +339,7 @@ func HandleAuthenticate(deps ServerDeps, conn net.Conn, tag string, parts []stri state.Authenticated = true state.Username = actualUsername - state.Email = email + state.Email = mailboxEmail capabilities := strings.Join(buildCapabilities(deps, true), " ") deps.SendResponse(conn, fmt.Sprintf("%s OK [CAPABILITY %s] Authenticated", tag, capabilities))