Add auth package: MCIAS token validation with caching
- Authenticator with Login, ValidateToken, Logout - 30-second SHA-256-keyed cache with lazy eviction - TLS 1.3, custom CA support, service context (name + tags) - Error types: ErrInvalidToken, ErrInvalidCredentials, ErrForbidden, ErrUnavailable - Context helpers for TokenInfo propagation - 14 tests with mock MCIAS server and injectable clock Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
346
auth/auth_test.go
Normal file
346
auth/auth_test.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockMCIAS returns a test HTTP server that simulates MCIAS endpoints.
|
||||
func mockMCIAS(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ServiceName string `json:"service_name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Username == "admin" && req.Password == "secret" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
exp := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"token": "tok-admin-123",
|
||||
"expires_at": exp,
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Username == "denied" && req.Password == "secret" {
|
||||
http.Error(w, `{"error":"forbidden by policy"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch req.Token {
|
||||
case "tok-admin-123":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": true,
|
||||
"sub": "uuid-admin",
|
||||
"username": "admin",
|
||||
"roles": []string{"admin", "user"},
|
||||
})
|
||||
case "tok-user-456":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": true,
|
||||
"sub": "uuid-user",
|
||||
"username": "alice",
|
||||
"roles": []string{"user"},
|
||||
})
|
||||
case "tok-expired":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": false,
|
||||
})
|
||||
default:
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /v1/auth/logout", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func newTestAuth(t *testing.T, serverURL string) *Authenticator {
|
||||
t.Helper()
|
||||
a, err := New(Config{
|
||||
ServerURL: serverURL,
|
||||
ServiceName: "test-service",
|
||||
Tags: []string{"env:test"},
|
||||
}, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
token, exp, err := a.Login("admin", "secret", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if token != "tok-admin-123" {
|
||||
t.Fatalf("token = %q, want %q", token, "tok-admin-123")
|
||||
}
|
||||
if exp.IsZero() {
|
||||
t.Fatal("expiresAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginInvalidCredentials(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
_, _, err := a.Login("admin", "wrong", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid credentials")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("err = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForbidden(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
_, _, err := a.Login("denied", "secret", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for forbidden login")
|
||||
}
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("err = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateToken(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
info, err := a.ValidateToken("tok-admin-123")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken: %v", err)
|
||||
}
|
||||
if info.Username != "admin" {
|
||||
t.Fatalf("Username = %q, want %q", info.Username, "admin")
|
||||
}
|
||||
if !info.IsAdmin {
|
||||
t.Fatal("IsAdmin = false, want true")
|
||||
}
|
||||
if len(info.Roles) != 2 {
|
||||
t.Fatalf("Roles = %v, want 2 roles", info.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenNonAdmin(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
info, err := a.ValidateToken("tok-user-456")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken: %v", err)
|
||||
}
|
||||
if info.Username != "alice" {
|
||||
t.Fatalf("Username = %q, want %q", info.Username, "alice")
|
||||
}
|
||||
if info.IsAdmin {
|
||||
t.Fatal("IsAdmin = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenExpired(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
_, err := a.ValidateToken("tok-expired")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidToken) {
|
||||
t.Fatalf("err = %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenUnknown(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
_, err := a.ValidateToken("tok-unknown")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenCache(t *testing.T) {
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, _ *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": true,
|
||||
"username": "cached-user",
|
||||
"roles": []string{"user"},
|
||||
})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
// First call: cache miss, hits server.
|
||||
info1, err := a.ValidateToken("tok-cache-test")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken (1st): %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("server calls = %d, want 1", callCount)
|
||||
}
|
||||
|
||||
// Second call: cache hit, no server call.
|
||||
info2, err := a.ValidateToken("tok-cache-test")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken (2nd): %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("server calls = %d, want 1 (cached)", callCount)
|
||||
}
|
||||
if info1.Username != info2.Username {
|
||||
t.Fatalf("cached username mismatch: %q vs %q", info1.Username, info2.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenCacheExpiry(t *testing.T) {
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, _ *http.Request) {
|
||||
callCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"valid": true,
|
||||
"username": "user",
|
||||
"roles": []string{"user"},
|
||||
})
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
// Override the cache clock to simulate time passing.
|
||||
now := time.Now()
|
||||
a.cache.now = func() time.Time { return now }
|
||||
|
||||
_, err := a.ValidateToken("tok-expiry-test")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken (1st): %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatalf("server calls = %d, want 1", callCount)
|
||||
}
|
||||
|
||||
// Advance past cache TTL.
|
||||
now = now.Add(cacheTTL + 1*time.Second)
|
||||
|
||||
_, err = a.ValidateToken("tok-expiry-test")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken (2nd): %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("server calls = %d, want 2 (cache expired)", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
srv := mockMCIAS(t)
|
||||
defer srv.Close()
|
||||
a := newTestAuth(t, srv.URL)
|
||||
|
||||
if err := a.Logout("tok-admin-123"); err != nil {
|
||||
t.Fatalf("Logout: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresServerURL(t *testing.T) {
|
||||
_, err := New(Config{}, slog.Default())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty server_url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextRoundtrip(t *testing.T) {
|
||||
info := &TokenInfo{
|
||||
Username: "testuser",
|
||||
Roles: []string{"user"},
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
ctx := ContextWithTokenInfo(context.Background(), info)
|
||||
got := TokenInfoFromContext(ctx)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("TokenInfoFromContext returned nil")
|
||||
}
|
||||
if got.Username != "testuser" {
|
||||
t.Fatalf("Username = %q, want %q", got.Username, "testuser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMissing(t *testing.T) {
|
||||
got := TokenInfoFromContext(context.Background())
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil from empty context, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
roles []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"admin", "user"}, true},
|
||||
{[]string{"admin"}, true},
|
||||
{[]string{"user"}, false},
|
||||
{[]string{}, false},
|
||||
{nil, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := hasRole(tt.roles, "admin")
|
||||
if got != tt.want {
|
||||
t.Errorf("hasRole(%v, admin) = %v, want %v", tt.roles, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user