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:
2026-03-25 14:24:52 -07:00
parent 8b4db22c93
commit 38da2e9a4b
5 changed files with 741 additions and 6 deletions

346
auth/auth_test.go Normal file
View 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)
}
}
}