Files
boc/backend/auth/auth.go
T
Bernt (LandveX AI) 6bb355ce17 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
2026-07-14 16:33:54 +00:00

284 lines
8.0 KiB
Go

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"`
}