golib/auth/csvauth/csvauth_test.go
AJ ONeal a854fef67e
test(auth/csvauth): regression test for Authenticate token deadlock
Guards against the v1.2.4 bug (fixed in c32acd5) where Authenticate
held a.mux via defer for its full duration, then called
loadAndVerifyToken which also tries to acquire a.mux — deadlock on
every token auth request.

TestAuthenticateTokenNoDeadlock exercises both the bare-token
("", token) and named-username ("api", token) forms with a 1s
timeout, so a regression fails fast rather than hanging the suite.
2026-03-23 00:26:16 -06:00

198 lines
6.2 KiB
Go

package csvauth
import (
"encoding/base64"
"fmt"
"strings"
"testing"
"time"
)
// TestAuthenticateTokenNoDeadlock guards against the v1.2.4 regression where
// Authenticate held a.mux via defer and then called loadAndVerifyToken, which
// also tried to acquire a.mux — causing a deadlock on all token auth requests.
// Fixed in c32acd5 (ref(auth/csvauth): don't hold mutex longer than necessary).
func TestAuthenticateTokenNoDeadlock(t *testing.T) {
var key [16]byte
a := New(key[:])
const secret = "supersecrettoken"
c := a.NewCredential(PurposeToken, "ci-bot", secret, []string{"plain"}, []string{"deploy"}, "")
if err := a.CacheCredential(*c); err != nil {
t.Fatal(err)
}
type result struct {
p any
err error
}
// Authenticate("", token) — the token-as-password form
ch := make(chan result, 1)
go func() {
p, err := a.Authenticate("", secret)
ch <- result{p, err}
}()
select {
case r := <-ch:
if r.err != nil {
t.Fatalf("Authenticate(\"\", token): unexpected error: %v", r.err)
}
case <-time.After(time.Second):
t.Fatal("Authenticate deadlocked — mutex was not released before calling loadAndVerifyToken")
}
// Authenticate("api", token) — the named-token-username form
ch2 := make(chan result, 1)
go func() {
p, err := a.Authenticate("api", secret)
ch2 <- result{p, err}
}()
select {
case r := <-ch2:
if r.err != nil {
t.Fatalf("Authenticate(\"api\", token): unexpected error: %v", r.err)
}
case <-time.After(time.Second):
t.Fatal("Authenticate deadlocked — mutex was not released before calling loadAndVerifyToken")
}
}
func TestCredentialCreationAndVerification(t *testing.T) {
type testCase struct {
purpose string
name string
params []string
roles []string
extra string
isLogin bool
isRecoverable bool
}
tests := []testCase{
{"service1", "acme", []string{"aes-128-gcm"}, nil, "token1", false, true},
{"service2", "acme", []string{"plain"}, nil, "token2", false, true},
{"service3", "user3", []string{"pbkdf2", "1000", "16", "SHA-256"}, nil, "token3", false, false},
{"service4", "user4", []string{"bcrypt"}, []string{"audit", "triage"}, "token4", false, false},
{"login", "user1", []string{"pbkdf2", "1000", "16", "SHA-256"}, nil, "pass1", true, false},
{"login", "user2", []string{"bcrypt"}, nil, "pass2", true, false},
{"login", "user3", []string{"aes-128-gcm"}, nil, "pass3", true, true},
{"login", "user4", []string{"plain"}, nil, "pass4", true, true},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("%s/%s", tc.purpose, tc.name), func(t *testing.T) {
var key [16]byte
a := &Auth{
aes128key: key,
credentials: make(map[Name]Credential),
hashedCredentials: make(map[string]Credential),
serviceAccounts: make(map[Purpose]Credential),
}
secret := tc.extra
c := a.NewCredential(tc.purpose, tc.name, secret, tc.params, tc.roles, tc.extra)
if c == nil {
t.Fatal("NewCredential returned nil")
}
if tc.isLogin {
_ = a.CacheCredential(*c)
} else {
_ = a.CacheServiceAccount(*c)
}
record := c.ToRecord()
// Verify record format
if record[0] != tc.purpose {
t.Errorf("purpose mismatch: got %q want %q", record[0], tc.purpose)
}
if record[1] != tc.name {
t.Errorf("name mismatch: got %q want %q", record[1], tc.name)
}
if record[2] != strings.Join(tc.params, " ") {
t.Errorf("params mismatch: got %q want %q", record[2], strings.Join(tc.params, " "))
}
salt64 := record[3]
derived64 := record[4]
algo := tc.params[0]
switch algo {
case "plain":
if salt64 != "" {
t.Errorf("plain salt should be empty, got %q", salt64)
}
if derived64 != secret {
t.Errorf("plain derived mismatch: got %q want %q", derived64, secret)
}
case "aes-128-gcm":
saltb, err := base64.RawURLEncoding.DecodeString(salt64)
if err != nil || len(saltb) != 12 {
t.Errorf("gcm salt invalid: len %d err %v", len(saltb), err)
}
derivedb, err := base64.RawURLEncoding.DecodeString(derived64)
if err != nil {
t.Errorf("gcm derived %q invalid: err %v", derivedb, err)
}
case "pbkdf2":
saltb, err := base64.RawURLEncoding.DecodeString(salt64)
if err != nil || len(saltb) != 16 {
t.Errorf("pbkdf2 salt invalid: len %d err %v", len(saltb), err)
}
derivedb, err := base64.RawURLEncoding.DecodeString(derived64)
if err != nil || len(derivedb) != 16 {
t.Errorf("pbkdf2 derived invalid: len %d err %v", len(derivedb), err)
}
case "bcrypt":
if salt64 != "" {
t.Errorf("bcrypt salt should be empty, got %q", salt64)
}
if !strings.HasPrefix(derived64, "$2a$12$") {
t.Errorf("bcrypt derived invalid: got %q", derived64)
}
}
if len(tc.roles) > 0 && record[5] != strings.Join(tc.roles, " ") {
t.Errorf("roles mismatch: got %q want %q", record[5], strings.Join(tc.roles, " "))
}
if len(tc.extra) > 0 && record[6] != tc.extra {
t.Errorf("extra mismatch: got %q want %q", record[6], tc.extra)
}
// Verify functionality
var c2 Credential
var err error
if tc.isLogin {
if err := a.Verify(tc.name, secret); err != nil {
t.Errorf("Auth.Verify failed for %s %s with %s: %v", tc.purpose, tc.name, secret, err)
}
c2, err = a.LoadCredential(tc.name)
if err != nil {
t.Errorf("LoadCredential failed for %s %s: %v", tc.purpose, tc.name, err)
}
} else {
c2, err = a.LoadServiceAccount(tc.purpose)
if err != nil {
t.Errorf("LoadServiceAccount failed for %s %s: %v", tc.purpose, tc.name, err)
}
}
if tc.isRecoverable {
if c2.Secret() != secret {
t.Errorf("Secret mismatch: got %q want %q", c2.Secret(), secret)
}
} else {
if c2.Secret() != "" {
t.Errorf("Secret should be empty for hashed service account, got %q", c2.Secret())
}
}
if err := c2.Verify(tc.name, secret); err != nil {
t.Errorf("Auth.Verify failed for %s: %v", tc.name, err)
}
if err := c2.Verify(tc.name, ""); err == nil {
t.Errorf("Auth.Verify incorrectly passed an empty password for %s %s", tc.purpose, tc.name)
}
if err := c2.Verify(tc.name, "wrong"); err == nil {
t.Errorf("Auth.Verify incorrectly passed a wrong password for %s %s", tc.purpose, tc.name)
}
})
}
}