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
97 changes: 97 additions & 0 deletions internal/auth/oauthbearer/oauthbearer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<email> 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)
Expand Down Expand Up @@ -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:]), "."))
Comment thread
Aravinda-HWK marked this conversation as resolved.
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
}
Comment thread
Aravinda-HWK marked this conversation as resolved.

// isSafeMailboxComponent restricts the local/domain parts that get
// embedded into a filesystem-style mailbox identity (role_<local>@<domain>.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 {
Expand Down Expand Up @@ -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:
Expand Down
112 changes: 112 additions & 0 deletions internal/auth/oauthbearer/oauthbearer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion internal/sasl/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
18 changes: 12 additions & 6 deletions internal/server/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,23 +317,29 @@ 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
}

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))
Expand Down
Loading