- TokenInfo now includes AccountType ("human" or "system") from the
MCIAS validate response
- Required for policy engines (MCR, Metacrypt) that match on account type
- Mock MCIAS in tests updated to return account_type
- New assertion in TestValidateToken verifies AccountType is populated
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
352 lines
8.5 KiB
Go
352 lines
8.5 KiB
Go
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",
|
|
"account_type": "human",
|
|
"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",
|
|
"account_type": "human",
|
|
"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.AccountType != "human" {
|
|
t.Fatalf("AccountType = %q, want %q", info.AccountType, "human")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|