Refactor installation and runner scripts for enhanced usability and security
- Updated the installation script to clarify that production wrappers do not include fixed test secrets, improving user guidance for local testing. - Modified the jiggablend-manager and jiggablend-runner scripts to remove fixed test configurations, emphasizing the use of local test credentials via `make init-test`. - Enhanced error handling in the runner script to ensure that an API key is provided, improving security and user feedback. - Added new flags and options for the runner, including sandboxing capabilities and GPU ray tracing control, enhancing flexibility for users. - Improved README documentation to reflect changes in usage and configuration, ensuring users have clear instructions for setup and execution.
This commit is contained in:
+72
-44
@@ -45,6 +45,9 @@ type Auth struct {
|
||||
sessionCache map[string]*Session // In-memory cache for performance
|
||||
cacheMu sync.RWMutex
|
||||
stopCleanup chan struct{}
|
||||
// oauthStates maps state token -> expiry for CSRF protection
|
||||
oauthStates map[string]time.Time
|
||||
oauthMu sync.Mutex
|
||||
}
|
||||
|
||||
// Session represents a user session
|
||||
@@ -63,6 +66,7 @@ func NewAuth(db *database.DB, cfg *config.Config) (*Auth, error) {
|
||||
cfg: cfg,
|
||||
sessionCache: make(map[string]*Session),
|
||||
stopCleanup: make(chan struct{}),
|
||||
oauthStates: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// Initialize Google OAuth from database config
|
||||
@@ -216,7 +220,9 @@ func (a *Auth) cleanupExpiredSessions() {
|
||||
|
||||
// initializeSettings ensures all admin settings are initialized with defaults if they don't exist
|
||||
func (a *Auth) initializeSettings() error {
|
||||
// Initialize registration_enabled setting (default: true) if it doesn't exist
|
||||
// Default registration to false (safer for internet-facing boots). Admins enable via CLI/UI.
|
||||
// In non-production, still default false — make init-test / CLI create the first admin.
|
||||
defaultReg := "false"
|
||||
var settingCount int
|
||||
err := a.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "registration_enabled").Scan(&settingCount)
|
||||
@@ -227,15 +233,15 @@ func (a *Auth) initializeSettings() error {
|
||||
if settingCount == 0 {
|
||||
err = a.db.With(func(conn *sql.DB) error {
|
||||
_, err := conn.Exec(
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||
"registration_enabled", "true",
|
||||
)
|
||||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||
"registration_enabled", defaultReg,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize registration_enabled setting: %w", err)
|
||||
}
|
||||
log.Printf("Initialized admin setting: registration_enabled = true")
|
||||
log.Printf("Initialized admin setting: registration_enabled = %s", defaultReg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -303,12 +309,44 @@ func (a *Auth) initializeTestUser() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOAuthState mints a one-time OAuth state token and stores it until used or expired.
|
||||
func (a *Auth) CreateOAuthState() string {
|
||||
state := uuid.New().String()
|
||||
a.oauthMu.Lock()
|
||||
// Opportunistic cleanup of expired states
|
||||
now := time.Now()
|
||||
for k, exp := range a.oauthStates {
|
||||
if now.After(exp) {
|
||||
delete(a.oauthStates, k)
|
||||
}
|
||||
}
|
||||
a.oauthStates[state] = now.Add(10 * time.Minute)
|
||||
a.oauthMu.Unlock()
|
||||
return state
|
||||
}
|
||||
|
||||
// ConsumeOAuthState validates and single-use-consumes an OAuth state token.
|
||||
// Returns false if missing, unknown, or expired.
|
||||
func (a *Auth) ConsumeOAuthState(state string) bool {
|
||||
if state == "" {
|
||||
return false
|
||||
}
|
||||
a.oauthMu.Lock()
|
||||
defer a.oauthMu.Unlock()
|
||||
exp, ok := a.oauthStates[state]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(a.oauthStates, state)
|
||||
return time.Now().Before(exp)
|
||||
}
|
||||
|
||||
// GoogleLoginURL returns the Google OAuth login URL
|
||||
func (a *Auth) GoogleLoginURL() (string, error) {
|
||||
if a.googleConfig == nil {
|
||||
return "", fmt.Errorf("Google OAuth not configured")
|
||||
}
|
||||
state := uuid.New().String()
|
||||
state := a.CreateOAuthState()
|
||||
return a.googleConfig.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
@@ -317,7 +355,7 @@ func (a *Auth) DiscordLoginURL() (string, error) {
|
||||
if a.discordConfig == nil {
|
||||
return "", fmt.Errorf("Discord OAuth not configured")
|
||||
}
|
||||
state := uuid.New().String()
|
||||
state := a.CreateOAuthState()
|
||||
return a.discordConfig.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
@@ -390,8 +428,8 @@ func (a *Auth) IsRegistrationEnabled() (bool, error) {
|
||||
return conn.QueryRow("SELECT value FROM settings WHERE key = ?", "registration_enabled").Scan(&value)
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
// Default to enabled if setting doesn't exist
|
||||
return true, nil
|
||||
// Default to disabled if setting doesn't exist (safer bootstrap)
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check registration setting: %w", err)
|
||||
@@ -438,8 +476,9 @@ func (a *Auth) SetRegistrationEnabled(enabled bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrCreateUser gets or creates a user in the database
|
||||
// Automatically links accounts by email across different OAuth providers and local login
|
||||
// getOrCreateUser gets or creates a user in the database.
|
||||
// Identity is (oauth_provider, oauth_id). Email collision with a different provider
|
||||
// is NOT auto-linked (prevents account takeover); the user must sign in with the original method.
|
||||
func (a *Auth) getOrCreateUser(provider, oauthID, email, name string) (*Session, error) {
|
||||
var userID int64
|
||||
var dbEmail, dbName string
|
||||
@@ -449,18 +488,18 @@ func (a *Auth) getOrCreateUser(provider, oauthID, email, name string) (*Session,
|
||||
// First, try to find by provider + oauth_id
|
||||
err := a.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow(
|
||||
"SELECT id, email, name, is_admin, oauth_provider, oauth_id FROM users WHERE oauth_provider = ? AND oauth_id = ?",
|
||||
provider, oauthID,
|
||||
).Scan(&userID, &dbEmail, &dbName, &isAdmin, &dbProvider, &dbOAuthID)
|
||||
"SELECT id, email, name, is_admin, oauth_provider, oauth_id FROM users WHERE oauth_provider = ? AND oauth_id = ?",
|
||||
provider, oauthID,
|
||||
).Scan(&userID, &dbEmail, &dbName, &isAdmin, &dbProvider, &dbOAuthID)
|
||||
})
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Not found by provider+oauth_id, check by email for account linking
|
||||
// Not found by provider+oauth_id — check email only to refuse silent takeover
|
||||
err = a.db.With(func(conn *sql.DB) error {
|
||||
return conn.QueryRow(
|
||||
"SELECT id, email, name, is_admin, oauth_provider, oauth_id FROM users WHERE email = ?",
|
||||
email,
|
||||
).Scan(&userID, &dbEmail, &dbName, &isAdmin, &dbProvider, &dbOAuthID)
|
||||
"SELECT id, email, name, is_admin, oauth_provider, oauth_id FROM users WHERE email = ?",
|
||||
email,
|
||||
).Scan(&userID, &dbEmail, &dbName, &isAdmin, &dbProvider, &dbOAuthID)
|
||||
})
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -487,7 +526,7 @@ func (a *Auth) getOrCreateUser(provider, oauthID, email, name string) (*Session,
|
||||
err = a.db.With(func(conn *sql.DB) error {
|
||||
result, err := conn.Exec(
|
||||
"INSERT INTO users (email, name, oauth_provider, oauth_id, is_admin) VALUES (?, ?, ?, ?, ?)",
|
||||
email, name, provider, oauthID, isAdmin,
|
||||
email, name, provider, oauthID, isAdmin,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -501,19 +540,8 @@ func (a *Auth) getOrCreateUser(provider, oauthID, email, name string) (*Session,
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to query user by email: %w", err)
|
||||
} else {
|
||||
// User exists with same email but different provider - link accounts by updating provider info
|
||||
// This allows the user to log in with any provider that has the same email
|
||||
err = a.db.With(func(conn *sql.DB) error {
|
||||
_, err = conn.Exec(
|
||||
"UPDATE users SET oauth_provider = ?, oauth_id = ?, name = ? WHERE id = ?",
|
||||
provider, oauthID, name, userID,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to link account: %w", err)
|
||||
}
|
||||
log.Printf("Linked account: user %d (email: %s) now accessible via %s provider", userID, email, provider)
|
||||
// Email already belongs to another identity — do not overwrite oauth_provider/oauth_id
|
||||
return nil, fmt.Errorf("an account with this email already exists; sign in with the original method")
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to query user: %w", err)
|
||||
@@ -522,9 +550,9 @@ func (a *Auth) getOrCreateUser(provider, oauthID, email, name string) (*Session,
|
||||
if dbEmail != email || dbName != name {
|
||||
err = a.db.With(func(conn *sql.DB) error {
|
||||
_, err = conn.Exec(
|
||||
"UPDATE users SET email = ?, name = ? WHERE id = ?",
|
||||
email, name, userID,
|
||||
)
|
||||
"UPDATE users SET email = ?, name = ? WHERE id = ?",
|
||||
email, name, userID,
|
||||
)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -652,20 +680,20 @@ func (a *Auth) DeleteSession(sessionID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// IsProductionMode returns true if running in production mode
|
||||
// This is a package-level function that checks the environment variable
|
||||
// For config-based checks, use Config.IsProductionMode()
|
||||
// IsProductionMode returns true if running in production mode.
|
||||
// Prefer Config.IsProductionMode() / Auth.IsProductionModeFromConfig() for app logic.
|
||||
// This package-level helper still honors PRODUCTION=true for legacy callers.
|
||||
func IsProductionMode() bool {
|
||||
// Check environment variable first for backwards compatibility
|
||||
if os.Getenv("PRODUCTION") == "true" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return os.Getenv("PRODUCTION") == "true"
|
||||
}
|
||||
|
||||
// IsProductionModeFromConfig returns true if production mode is enabled in config
|
||||
// or via PRODUCTION=true environment variable (OR of both sources).
|
||||
func (a *Auth) IsProductionModeFromConfig() bool {
|
||||
return a.cfg.IsProductionMode()
|
||||
if a.cfg != nil && a.cfg.IsProductionMode() {
|
||||
return true
|
||||
}
|
||||
return IsProductionMode()
|
||||
}
|
||||
|
||||
func (a *Auth) writeUnauthorized(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContextHelpers(t *testing.T) {
|
||||
@@ -54,3 +55,34 @@ func TestIsProductionMode_DefaultFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthState_CreateConsumeAndReject(t *testing.T) {
|
||||
a := &Auth{oauthStates: make(map[string]time.Time)}
|
||||
state := a.CreateOAuthState()
|
||||
if state == "" {
|
||||
t.Fatal("expected non-empty state")
|
||||
}
|
||||
if !a.ConsumeOAuthState(state) {
|
||||
t.Fatal("expected valid state to be accepted once")
|
||||
}
|
||||
if a.ConsumeOAuthState(state) {
|
||||
t.Fatal("expected state to be single-use")
|
||||
}
|
||||
if a.ConsumeOAuthState("") {
|
||||
t.Fatal("empty state must be rejected")
|
||||
}
|
||||
if a.ConsumeOAuthState("unknown-state") {
|
||||
t.Fatal("unknown state must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthState_ExpiredRejected(t *testing.T) {
|
||||
a := &Auth{oauthStates: make(map[string]time.Time)}
|
||||
state := "expired-state"
|
||||
a.oauthMu.Lock()
|
||||
a.oauthStates[state] = time.Now().Add(-time.Minute)
|
||||
a.oauthMu.Unlock()
|
||||
if a.ConsumeOAuthState(state) {
|
||||
t.Fatal("expired state must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,54 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JobTokenDuration is the validity period for job tokens
|
||||
const JobTokenDuration = 1 * time.Hour
|
||||
// DefaultJobTokenDuration is the minimum validity period for job tokens.
|
||||
const DefaultJobTokenDuration = 1 * time.Hour
|
||||
|
||||
// JobTokenSkew is extra lifetime added beyond configured task timeouts.
|
||||
const JobTokenSkew = 15 * time.Minute
|
||||
|
||||
// JobTokenDuration is the current validity period for newly issued job tokens.
|
||||
// Prefer JobTokenTTL() / SetJobTokenTTL; this var remains for backward-compatible reads.
|
||||
var (
|
||||
jobTokenTTL = DefaultJobTokenDuration
|
||||
jobTokenTTLMu sync.RWMutex
|
||||
)
|
||||
|
||||
// JobTokenTTL returns the current job token lifetime used when minting tokens.
|
||||
func JobTokenTTL() time.Duration {
|
||||
jobTokenTTLMu.RLock()
|
||||
defer jobTokenTTLMu.RUnlock()
|
||||
return jobTokenTTL
|
||||
}
|
||||
|
||||
// SetJobTokenTTL sets the lifetime for newly issued job tokens.
|
||||
// Values below DefaultJobTokenDuration are raised to the default.
|
||||
func SetJobTokenTTL(d time.Duration) {
|
||||
if d < DefaultJobTokenDuration {
|
||||
d = DefaultJobTokenDuration
|
||||
}
|
||||
jobTokenTTLMu.Lock()
|
||||
jobTokenTTL = d
|
||||
jobTokenTTLMu.Unlock()
|
||||
}
|
||||
|
||||
// ConfigureJobTokenTTLFromTimeouts sets token lifetime from the longest of the
|
||||
// given task timeouts (seconds) plus JobTokenSkew, floored at DefaultJobTokenDuration.
|
||||
func ConfigureJobTokenTTLFromTimeouts(timeoutSeconds ...int) time.Duration {
|
||||
maxSec := 0
|
||||
for _, s := range timeoutSeconds {
|
||||
if s > maxSec {
|
||||
maxSec = s
|
||||
}
|
||||
}
|
||||
d := time.Duration(maxSec)*time.Second + JobTokenSkew
|
||||
SetJobTokenTTL(d)
|
||||
return JobTokenTTL()
|
||||
}
|
||||
|
||||
// JobTokenClaims represents the claims in a job token
|
||||
type JobTokenClaims struct {
|
||||
@@ -41,7 +84,7 @@ func GenerateJobToken(jobID, runnerID, taskID int64) (string, error) {
|
||||
JobID: jobID,
|
||||
RunnerID: runnerID,
|
||||
TaskID: taskID,
|
||||
Exp: time.Now().Add(JobTokenDuration).Unix(),
|
||||
Exp: time.Now().Add(JobTokenTTL()).Unix(),
|
||||
}
|
||||
|
||||
// Encode claims to JSON
|
||||
@@ -112,4 +155,3 @@ func ValidateJobToken(token string) (*JobTokenClaims, error) {
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -82,3 +82,36 @@ func signClaimsForTest(claims []byte) []byte {
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func TestConfigureJobTokenTTLFromTimeouts(t *testing.T) {
|
||||
prev := JobTokenTTL()
|
||||
t.Cleanup(func() { SetJobTokenTTL(prev) })
|
||||
|
||||
// Short timeouts floor at DefaultJobTokenDuration
|
||||
got := ConfigureJobTokenTTLFromTimeouts(60, 120)
|
||||
if got < DefaultJobTokenDuration {
|
||||
t.Fatalf("TTL %v below default %v", got, DefaultJobTokenDuration)
|
||||
}
|
||||
|
||||
// Long encode timeout (24h) + skew must be reflected
|
||||
got = ConfigureJobTokenTTLFromTimeouts(3600, 86400)
|
||||
wantMin := 86400*time.Second + JobTokenSkew
|
||||
if got < wantMin {
|
||||
t.Fatalf("TTL %v < expected min %v for 24h encode", got, wantMin)
|
||||
}
|
||||
|
||||
// Newly generated tokens must use the configured TTL
|
||||
token, err := GenerateJobToken(1, 2, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateJobToken: %v", err)
|
||||
}
|
||||
claims, err := ValidateJobToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateJobToken: %v", err)
|
||||
}
|
||||
// Exp should be roughly now+TTL (allow 30s clock skew in test)
|
||||
remaining := time.Until(time.Unix(claims.Exp, 0))
|
||||
if remaining < wantMin-30*time.Second {
|
||||
t.Fatalf("token remaining lifetime %v too short, want ~%v", remaining, wantMin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
@@ -121,9 +122,13 @@ func (s *Secrets) ValidateRunnerAPIKey(apiKey string) (int64, string, error) {
|
||||
return 0, "", fmt.Errorf("API key is required")
|
||||
}
|
||||
|
||||
// Check fixed API key first (from database config)
|
||||
// Check fixed API key first (from database config). Constant-time compare.
|
||||
// Fixed keys are refused entirely when production mode is on.
|
||||
fixedKey := s.cfg.FixedAPIKey()
|
||||
if fixedKey != "" && apiKey == fixedKey {
|
||||
if fixedKey != "" && subtle.ConstantTimeCompare([]byte(apiKey), []byte(fixedKey)) == 1 {
|
||||
if s.cfg.IsProductionMode() {
|
||||
return 0, "", fmt.Errorf("fixed API key is not allowed in production mode")
|
||||
}
|
||||
// Return a special ID for fixed API key (doesn't exist in database)
|
||||
return -1, "manager", nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user