LINUS ROUND 3: Unified AAMOS auth system for BOC
- auth/auth.go: AAMOS-standard JWT claims (sub, org_id, roles, scopes) - auth/auth_test.go: 18 tests (login, validation, middleware, roles) - Compatible with ouroboros-identity RS256 tokens - Middleware: Bearer validation + RequireRole - AAMOS_AUTH_AUDIT_REPORT.md: Full auth audit across all systems
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func setupAuthService(t *testing.T) (*Service, sqlmock.Sqlmock, *sql.DB) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewService(db, "test-secret-key-for-unit-tests-only")
|
||||
return svc, mock, db
|
||||
}
|
||||
|
||||
func TestNewService(t *testing.T) {
|
||||
db, _, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
defer db.Close()
|
||||
|
||||
svc := NewService(db, "secret")
|
||||
assert.NotNil(t, svc)
|
||||
assert.Equal(t, "aamos-identity", svc.issuer)
|
||||
assert.Equal(t, "boc", svc.audience)
|
||||
}
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
svc, mock, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
password := "correct-password"
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
userID := "user-123"
|
||||
orgID := "org-456"
|
||||
|
||||
// Expect SELECT
|
||||
mock.ExpectQuery("SELECT (.+) FROM boc_users").
|
||||
WithArgs("erik@wavult.com").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "name", "email", "password_hash", "org_id", "roles",
|
||||
}).AddRow(
|
||||
userID, "Erik", "erik@wavult.com", string(hash), orgID, "{admin,viewer}",
|
||||
))
|
||||
|
||||
// Expect UPDATE last_login
|
||||
mock.ExpectExec("UPDATE boc_users SET last_login").
|
||||
WithArgs(userID).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
resp, err := svc.Login(context.Background(), "erik@wavult.com", password)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, resp.Token)
|
||||
assert.Equal(t, "Bearer", resp.TokenType)
|
||||
assert.Equal(t, 86400, resp.ExpiresIn)
|
||||
assert.Equal(t, userID, resp.User.ID)
|
||||
assert.Equal(t, orgID, resp.User.OrgID)
|
||||
assert.Equal(t, []string{"admin", "viewer"}, resp.User.Roles)
|
||||
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestService_Login_InvalidPassword(t *testing.T) {
|
||||
svc, mock, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.DefaultCost)
|
||||
|
||||
mock.ExpectQuery("SELECT (.+) FROM boc_users").
|
||||
WithArgs("erik@wavult.com").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "name", "email", "password_hash", "org_id", "roles",
|
||||
}).AddRow(
|
||||
"user-123", "Erik", "erik@wavult.com", string(hash), "org-456", "{admin}",
|
||||
))
|
||||
|
||||
_, err := svc.Login(context.Background(), "erik@wavult.com", "wrong-password")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid credentials")
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestService_Login_UserNotFound(t *testing.T) {
|
||||
svc, mock, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
mock.ExpectQuery("SELECT (.+) FROM boc_users").
|
||||
WithArgs("missing@wavult.com").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
_, err := svc.Login(context.Background(), "missing@wavult.com", "password")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid credentials")
|
||||
assert.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_Success(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
// Issue a token
|
||||
token, err := svc.issueToken("user-123", "erik@wavult.com", "org-456", []string{"admin"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate it
|
||||
claims, err := svc.ValidateToken(token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-123", claims.Sub)
|
||||
assert.Equal(t, "erik@wavult.com", claims.Email)
|
||||
assert.Equal(t, "org-456", claims.OrgID)
|
||||
assert.Equal(t, []string{"admin"}, claims.Roles)
|
||||
assert.Equal(t, "aamos-identity", claims.Iss)
|
||||
assert.Equal(t, "boc", claims.Aud)
|
||||
assert.True(t, claims.Exp > time.Now().Unix())
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_Expired(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
// Create an expired token manually
|
||||
expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsImV4cCI6MTYwOTQ1OTIwMCwiaWF0IjoxNjA5NDU5MjAwfQ.WaJ6fZ1C8Y8aLm9X8Y8aLm9X8Y8aLm9X8Y8aLm9X8Y8"
|
||||
|
||||
_, err := svc.ValidateToken(expiredToken)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_InvalidSignature(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
// Token signed with different secret
|
||||
invalidToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMyIsImV4cCI6OTk5OTk5OTk5OX0.invalid-signature"
|
||||
|
||||
_, err := svc.ValidateToken(invalidToken)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_MissingSub(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
// Token without sub claim
|
||||
token, err := svc.issueToken("", "erik@wavult.com", "org-456", []string{"admin"})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.ValidateToken(token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "sub claim required")
|
||||
}
|
||||
|
||||
func TestMiddleware_ValidToken(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
// Issue token
|
||||
token, err := svc.issueToken("user-123", "erik@wavult.com", "org-456", []string{"admin"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create handler that checks claims
|
||||
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := FromContext(r.Context())
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "user-123", claims.Sub)
|
||||
assert.Equal(t, "erik@wavult.com", claims.Email)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_MissingHeader(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach handler")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_InvalidFormat(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach handler")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") // Basic auth, not Bearer
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestMiddleware_InvalidToken(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
handler := svc.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach handler")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestRequireRole_Success(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
handler := svc.RequireRole("admin", "superuser")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Create request with admin claims in context
|
||||
claims := &Claims{Sub: "user-123", Roles: []string{"admin", "viewer"}}
|
||||
ctx := WithClaims(context.Background(), claims)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil).WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusOK, rr.Code)
|
||||
}
|
||||
|
||||
func TestRequireRole_Forbidden(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
handler := svc.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach handler")
|
||||
}))
|
||||
|
||||
claims := &Claims{Sub: "user-123", Roles: []string{"viewer"}}
|
||||
ctx := WithClaims(context.Background(), claims)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil).WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusForbidden, rr.Code)
|
||||
}
|
||||
|
||||
func TestRequireRole_Unauthorized(t *testing.T) {
|
||||
svc, _, db := setupAuthService(t)
|
||||
defer db.Close()
|
||||
|
||||
handler := svc.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("should not reach handler")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, rr.Code)
|
||||
}
|
||||
|
||||
func TestClaims_Valid(t *testing.T) {
|
||||
// Valid claims
|
||||
claims := &Claims{Sub: "user-123", Exp: time.Now().Unix() + 3600}
|
||||
assert.NoError(t, claims.Valid())
|
||||
|
||||
// Missing sub
|
||||
claims = &Claims{Sub: "", Exp: time.Now().Unix() + 3600}
|
||||
assert.Error(t, claims.Valid())
|
||||
|
||||
// Expired
|
||||
claims = &Claims{Sub: "user-123", Exp: time.Now().Unix() - 3600}
|
||||
assert.Error(t, claims.Valid())
|
||||
}
|
||||
|
||||
func TestFromContext_Missing(t *testing.T) {
|
||||
_, ok := FromContext(context.Background())
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestWithClaims_RoundTrip(t *testing.T) {
|
||||
original := &Claims{Sub: "user-123", Email: "test@example.com"}
|
||||
ctx := WithClaims(context.Background(), original)
|
||||
|
||||
retrieved, ok := FromContext(ctx)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, original.Sub, retrieved.Sub)
|
||||
assert.Equal(t, original.Email, retrieved.Email)
|
||||
}
|
||||
Reference in New Issue
Block a user