Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ See [docs/AGENT_BUILDER.md](docs/AGENT_BUILDER.md) for comprehensive documentati
|------|-----------------|-------------|
| File System | `fs.NewFsTool(root)` | File operations within root directory |
| Git | `git.NewGitTool(repoRoot)` | Git operations (add, commit, push, etc.) |
| Web | `web.NewWebTool(workingDir)` | Web automation with headless browser |
| Web | `web.NewWebTool(workingDir)` | Web automation with browser sessions; headless by default |
| Postgres | `postgres.NewPostgresTool(url, mode, tables, schemas)` | Database operations with whitelisting |
| API | `api.NewApiTool(name, endpoints, authHook)` | HTTP API calls with auth support |
| Vector | `vector.NewVectorTool(db, embeddings)` | Semantic search and indexing |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.0
0.6.1
71 changes: 71 additions & 0 deletions cmd/localforge/src/auth/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package auth

import (
"net/http"
"os"
"strings"
"time"
)

const (
DefaultCookieName = "localforge_session"
DefaultSessionTTL = 24 * time.Hour
)

type Config struct {
Enabled bool
Username string
PasswordHash string
CookieName string
SessionTTL time.Duration
SecureCookie bool
}

func LoadConfigFromEnv() Config {
cfg := Config{
Username: strings.TrimSpace(os.Getenv("AUTH_USERNAME")),
PasswordHash: strings.TrimSpace(os.Getenv("AUTH_PASSWORD_HASH")),
CookieName: DefaultCookieName,
SessionTTL: DefaultSessionTTL,
SecureCookie: parseBoolEnv("AUTH_COOKIE_SECURE"),
}

if cookieName := strings.TrimSpace(os.Getenv("AUTH_COOKIE_NAME")); cookieName != "" {
cfg.CookieName = cookieName
}

if ttlRaw := strings.TrimSpace(os.Getenv("AUTH_SESSION_TTL")); ttlRaw != "" {
if ttl, err := time.ParseDuration(ttlRaw); err == nil && ttl > 0 {
cfg.SessionTTL = ttl
}
}

cfg.Enabled = cfg.Username != "" && cfg.PasswordHash != ""
return cfg
}

func parseBoolEnv(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

func (c Config) CookieMaxAgeSeconds() int {
return int(c.SessionTTL.Seconds())
}

func (c Config) ShouldUseSecureCookie(r *http.Request) bool {
if c.SecureCookie {
return true
}
if r == nil {
return false
}
if r.TLS != nil {
return true
}
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
77 changes: 77 additions & 0 deletions cmd/localforge/src/auth/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package auth

import (
"net/http"
"net/url"
"strings"

"github.com/gin-gonic/gin"
)

const sessionContextKey = "localforge.auth.session"

type UnauthorizedMode int

const (
UnauthorizedJSON UnauthorizedMode = iota
UnauthorizedRedirect
)

func Middleware(cfg Config, store *SessionStore, mode UnauthorizedMode) gin.HandlerFunc {
return func(c *gin.Context) {
if !cfg.Enabled {
c.Next()
return
}

token, err := c.Cookie(cfg.CookieName)
if err == nil && token != "" {
session, ok := store.Get(token)
if ok {
c.Set(sessionContextKey, session)
c.Next()
return
}

clearSessionCookie(c, cfg)
}

switch mode {
case UnauthorizedRedirect:
next := SafeNextPath(c.Request.URL.RequestURI())
c.Redirect(http.StatusFound, "/login?next="+url.QueryEscape(next))
c.Abort()
default:
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
}
}
}

func CurrentSession(c *gin.Context) (Session, bool) {
value, ok := c.Get(sessionContextKey)
if !ok {
return Session{}, false
}

session, ok := value.(Session)
return session, ok
}

func clearSessionCookie(c *gin.Context, cfg Config) {
http.SetCookie(c.Writer, &http.Cookie{
Name: cfg.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: cfg.ShouldUseSecureCookie(c.Request),
})
}

func SafeNextPath(next string) string {
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return "/"
}
return next
}
10 changes: 10 additions & 0 deletions cmd/localforge/src/auth/password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package auth

import "golang.org/x/crypto/bcrypt"

func VerifyPassword(password, hash string) bool {
if password == "" || hash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
106 changes: 106 additions & 0 deletions cmd/localforge/src/auth/session_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package auth

import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"sync"
"time"
)

type Session struct {
Username string
TokenHash string
ExpiresAt time.Time
}

type SessionStore struct {
mu sync.RWMutex
sessions map[string]Session
}

func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]Session),
}
}

func (s *SessionStore) Create(username string, ttl time.Duration) (string, Session, error) {
token, err := randomToken()
if err != nil {
return "", Session{}, err
}

session := Session{
Username: username,
TokenHash: hashToken(token),
ExpiresAt: time.Now().UTC().Add(ttl),
}

s.mu.Lock()
s.sessions[session.TokenHash] = session
s.mu.Unlock()

return token, session, nil
}

func (s *SessionStore) Get(token string) (Session, bool) {
tokenHash := hashToken(token)

s.mu.RLock()
session, ok := s.sessions[tokenHash]
s.mu.RUnlock()
if !ok {
return Session{}, false
}
if time.Now().UTC().After(session.ExpiresAt) {
s.Delete(token)
return Session{}, false
}
return session, true
}

func (s *SessionStore) Delete(token string) {
s.mu.Lock()
delete(s.sessions, hashToken(token))
s.mu.Unlock()
}

func (s *SessionStore) CleanupExpired() {
now := time.Now().UTC()

s.mu.Lock()
for key, session := range s.sessions {
if now.After(session.ExpiresAt) {
delete(s.sessions, key)
}
}
s.mu.Unlock()
}

func (s *SessionStore) StartCleanup(interval time.Duration) {
if interval <= 0 {
interval = time.Hour
}

go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
s.CleanupExpired()
}
}()
}

func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}

func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
115 changes: 115 additions & 0 deletions cmd/localforge/src/handlers_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"crypto/subtle"
"io/fs"
"net/http"

"github.com/gin-gonic/gin"
localauth "github.com/thinktwiceco/agent-forge/cmd/localforge/src/auth"
)

func (s *Server) handleLoginPage(c *gin.Context) {
if !s.authConfig.Enabled {
c.Redirect(http.StatusFound, "/")
return
}

if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" {
if _, ok := s.sessionStore.Get(token); ok {
c.Redirect(http.StatusFound, requestedNextPath(c))
return
}
}

data, err := fs.ReadFile(s.staticFS, "login.html")
if err != nil {
c.String(http.StatusNotFound, "login.html not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}

func (s *Server) handleAuthLogin(c *gin.Context) {
if !s.authConfig.Enabled {
c.JSON(http.StatusNotFound, gin.H{"error": "authentication is disabled"})
return
}

var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}

if subtle.ConstantTimeCompare([]byte(req.Username), []byte(s.authConfig.Username)) != 1 ||
!localauth.VerifyPassword(req.Password, s.authConfig.PasswordHash) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
return
}

token, session, err := s.sessionStore.Create(s.authConfig.Username, s.authConfig.SessionTTL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session"})
return
}

http.SetCookie(c.Writer, &http.Cookie{
Name: s.authConfig.CookieName,
Value: token,
Path: "/",
MaxAge: s.authConfig.CookieMaxAgeSeconds(),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.authConfig.ShouldUseSecureCookie(c.Request),
})

c.JSON(http.StatusOK, AuthStatusResponse{
Enabled: true,
Authenticated: true,
Username: session.Username,
Next: requestedNextPath(c),
})
}

func (s *Server) handleAuthLogout(c *gin.Context) {
if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" {
s.sessionStore.Delete(token)
}

http.SetCookie(c.Writer, &http.Cookie{
Name: s.authConfig.CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.authConfig.ShouldUseSecureCookie(c.Request),
})

c.Status(http.StatusNoContent)
}

func (s *Server) handleAuthMe(c *gin.Context) {
resp := AuthStatusResponse{
Enabled: s.authConfig.Enabled,
Next: requestedNextPath(c),
}
if !s.authConfig.Enabled {
c.JSON(http.StatusOK, resp)
return
}

if token, err := c.Cookie(s.authConfig.CookieName); err == nil && token != "" {
if session, ok := s.sessionStore.Get(token); ok {
resp.Authenticated = true
resp.Username = session.Username
}
}

c.JSON(http.StatusOK, resp)
}

func requestedNextPath(c *gin.Context) string {
return localauth.SafeNextPath(c.Query("next"))
}
Loading