package auth import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "sync" "time" "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" ) // ErrInvalidCredentials indicates that the supplied username/password is wrong. var ErrInvalidCredentials = errors.New("invalid credentials") // ErrInvalidToken indicates that the JWT is invalid or expired. var ErrInvalidToken = errors.New("invalid or expired token") // TokenExpiry is the lifetime of a JWT session token. const TokenExpiry = 24 * time.Hour // jwtClaims extends jwt.RegisteredClaims with application-specific fields. type jwtClaims struct { jwt.RegisteredClaims UserID string `json:"user_id"` Username string `json:"username"` Role string `json:"role"` } // LocalAuth handles password hashing and JWT token management for local auth mode. type LocalAuth struct { jwtSecret []byte mu sync.RWMutex blacklist map[string]time.Time // token hash -> expiry time } // NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key // using HMAC-SHA256. func NewLocalAuth(encKey [32]byte) *LocalAuth { mac := hmac.New(sha256.New, encKey[:]) mac.Write([]byte("tinyforge-jwt-secret")) la := &LocalAuth{ jwtSecret: mac.Sum(nil), blacklist: make(map[string]time.Time), } // Periodically clean expired blacklist entries. go la.cleanBlacklist() return la } // RevokeToken adds a token to the blacklist. func (la *LocalAuth) RevokeToken(tokenString string) { hash := sha256.Sum256([]byte(tokenString)) key := hex.EncodeToString(hash[:]) la.mu.Lock() defer la.mu.Unlock() la.blacklist[key] = time.Now().Add(TokenExpiry) } // IsRevoked checks if a token has been revoked. func (la *LocalAuth) IsRevoked(tokenString string) bool { hash := sha256.Sum256([]byte(tokenString)) key := hex.EncodeToString(hash[:]) la.mu.RLock() defer la.mu.RUnlock() _, exists := la.blacklist[key] return exists } // cleanBlacklist removes expired entries from the blacklist every hour. func (la *LocalAuth) cleanBlacklist() { ticker := time.NewTicker(1 * time.Hour) for range ticker.C { la.mu.Lock() now := time.Now() for k, expiry := range la.blacklist { if now.After(expiry) { delete(la.blacklist, k) } } la.mu.Unlock() } } // HashPassword hashes a plaintext password using bcrypt. func HashPassword(password string) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", fmt.Errorf("hash password: %w", err) } return string(hash), nil } // CheckPassword compares a plaintext password against a bcrypt hash. func CheckPassword(hash, password string) error { if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { return ErrInvalidCredentials } return nil } // GenerateToken creates a signed JWT for the given user claims. func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) { expiresAt := time.Now().Add(TokenExpiry) token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{ RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: "tinyforge", }, UserID: claims.UserID, Username: claims.Username, Role: claims.Role, }) signed, err := token.SignedString(la.jwtSecret) if err != nil { return SessionToken{}, fmt.Errorf("sign token: %w", err) } return SessionToken{ Token: signed, ExpiresAt: expiresAt, }, nil } // ValidateToken parses and validates a JWT, returning the embedded claims. func (la *LocalAuth) ValidateToken(tokenString string) (Claims, error) { token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return la.jwtSecret, nil }) if err != nil { return Claims{}, ErrInvalidToken } claims, ok := token.Claims.(*jwtClaims) if !ok || !token.Valid { return Claims{}, ErrInvalidToken } return Claims{ UserID: claims.UserID, Username: claims.Username, Role: claims.Role, }, nil }