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,283 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ── AAMOS Standard JWT Claims ──────────────────────────────────────────────
|
||||
// Unified claims structure used across all AAMOS services:
|
||||
// ouroboros-identity (3207), aamos-ledger (3250), boc (9092)
|
||||
type Claims struct {
|
||||
Sub string `json:"sub"` // User UUID
|
||||
Email string `json:"email,omitempty"` // User email
|
||||
OrgID string `json:"org_id,omitempty"` // Organization UUID
|
||||
Roles []string `json:"roles,omitempty"` // ["admin", "accountant", "viewer"]
|
||||
Scopes []string `json:"scopes,omitempty"` // ["read:invoices", "write:payroll"]
|
||||
Iss string `json:"iss"` // "aamos-identity"
|
||||
Aud string `json:"aud"` // "boc"
|
||||
Exp int64 `json:"exp"` // Unix timestamp
|
||||
Iat int64 `json:"iat"` // Unix timestamp
|
||||
}
|
||||
|
||||
func (c Claims) Valid() error {
|
||||
if c.Sub == "" {
|
||||
return fmt.Errorf("sub claim required")
|
||||
}
|
||||
if c.Exp < time.Now().Unix() {
|
||||
return fmt.Errorf("token expired")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Context Key ────────────────────────────────────────────────────────────
|
||||
type contextKey int
|
||||
|
||||
const claimsKey contextKey = iota
|
||||
|
||||
func WithClaims(ctx context.Context, claims *Claims) context.Context {
|
||||
return context.WithValue(ctx, claimsKey, claims)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (*Claims, bool) {
|
||||
claims, ok := ctx.Value(claimsKey).(*Claims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
audience string
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB, jwtSecret string) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
issuer: "aamos-identity",
|
||||
audience: "boc",
|
||||
}
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns AAMOS-standard JWT
|
||||
func (s *Service) Login(ctx context.Context, email, password string) (*TokenResponse, error) {
|
||||
var user struct {
|
||||
ID string
|
||||
Name string
|
||||
Email string
|
||||
PasswordHash string
|
||||
OrgID string
|
||||
Roles []string
|
||||
}
|
||||
|
||||
var roles pq.StringArray
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT u.id, u.name, u.email, u.password_hash, u.org_id, COALESCE(u.roles, '{}')
|
||||
FROM boc_users u
|
||||
WHERE u.email = $1 AND u.status = 'active'
|
||||
`, email).Scan(&user.ID, &user.Name, &user.Email, &user.PasswordHash, &user.OrgID, &roles)
|
||||
user.Roles = []string(roles)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Update last login
|
||||
s.db.ExecContext(ctx, `UPDATE boc_users SET last_login = NOW() WHERE id = $1`, user.ID)
|
||||
|
||||
// Issue AAMOS-standard token
|
||||
token, err := s.issueToken(user.ID, user.Email, user.OrgID, user.Roles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token issuance failed: %w", err)
|
||||
}
|
||||
|
||||
return &TokenResponse{
|
||||
Token: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: 86400, // 24h
|
||||
User: UserInfo{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
OrgID: user.OrgID,
|
||||
Roles: user.Roles,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken verifies an AAMOS-standard JWT
|
||||
func (s *Service) ValidateToken(tokenString string) (*Claims, error) {
|
||||
// Parse JWT
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
mapClaims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid claims format")
|
||||
}
|
||||
|
||||
claims := &Claims{
|
||||
Sub: getStringClaim(mapClaims, "sub"),
|
||||
Iss: getStringClaim(mapClaims, "iss"),
|
||||
Aud: getStringClaim(mapClaims, "aud"),
|
||||
Exp: getInt64Claim(mapClaims, "exp"),
|
||||
Iat: getInt64Claim(mapClaims, "iat"),
|
||||
}
|
||||
|
||||
// Optional claims
|
||||
if email, ok := mapClaims["email"].(string); ok {
|
||||
claims.Email = email
|
||||
}
|
||||
if orgID, ok := mapClaims["org_id"].(string); ok {
|
||||
claims.OrgID = orgID
|
||||
}
|
||||
if roles, ok := mapClaims["roles"].([]interface{}); ok {
|
||||
claims.Roles = make([]string, len(roles))
|
||||
for i, r := range roles {
|
||||
claims.Roles[i] = fmt.Sprint(r)
|
||||
}
|
||||
}
|
||||
if scopes, ok := mapClaims["scopes"].([]interface{}); ok {
|
||||
claims.Scopes = make([]string, len(scopes))
|
||||
for i, s := range scopes {
|
||||
claims.Scopes[i] = fmt.Sprint(s)
|
||||
}
|
||||
}
|
||||
|
||||
if err := claims.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Middleware returns HTTP middleware that validates Bearer tokens
|
||||
func (s *Service) Middleware() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := s.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := WithClaims(r.Context(), claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole returns middleware that requires specific roles
|
||||
func (s *Service) RequireRole(roles ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := FromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
for _, required := range roles {
|
||||
for _, has := range claims.Roles {
|
||||
if has == required {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Service) issueToken(sub, email, orgID string, roles []string) (string, error) {
|
||||
now := time.Now().Unix()
|
||||
claims := jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"email": email,
|
||||
"org_id": orgID,
|
||||
"roles": roles,
|
||||
"iss": s.issuer,
|
||||
"aud": s.audience,
|
||||
"iat": now,
|
||||
"exp": now + 86400, // 24h
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
func getStringClaim(m jwt.MapClaims, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt64Claim(m jwt.MapClaims, key string) int64 {
|
||||
switch v := m[key].(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case int64:
|
||||
return v
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User UserInfo `json:"user"`
|
||||
}
|
||||
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
OrgID string `json:"org_id"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
@@ -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