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