package app import ( "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/bnema/zerowrap" "golang.org/x/sys/unix" "github.com/bnema/gordon/internal/adapters/out/secrets" "github.com/bnema/gordon/internal/adapters/out/tokenstore" "github.com/bnema/gordon/internal/boundaries/out" "github.com/bnema/gordon/internal/domain" "github.com/bnema/gordon/internal/usecase/auth" "github.com/bnema/gordon/pkg/duration" ) // createAuthService creates the authentication service and token store. func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) { if !cfg.Auth.Enabled { log.Warn().Msg("auth.enabled=false detected: running in local-only mode (registry loopback-only, admin API disabled)") return nil, nil, nil } authType, err := resolveAuthType(cfg) if err != nil { return nil, nil, fmt.Errorf("resolve auth type: %w", err) } backend, err := resolveSecretsBackend(cfg.Auth.SecretsBackend) if err != nil { return nil, nil, log.WrapErr(err, "failed to resolve secrets backend") } dataDir := resolveDataDir(cfg.Server.DataDir) store, err := createTokenStore(backend, dataDir, log) if err != nil { return nil, nil, err } authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log) if err != nil { return nil, nil, err } authSvc := auth.NewService(authConfig, store, log) log.Info(). Str("type", string(authType)). Str("backend", string(backend)). Msg("registry authentication enabled") return store, authSvc, nil } // resolveAuthType determines the auth type from config. // Token-only authentication is the only supported mode. func resolveAuthType(cfg Config) (domain.AuthType, error) { if cfg.Auth.Type != "" && cfg.Auth.Type != "token" { return "", fmt.Errorf("unsupported auth.type %q; only \"token\" is supported", cfg.Auth.Type) } return domain.AuthTypeToken, nil } func resolveSecretsBackend(backend string) (domain.SecretsBackend, error) { switch backend { case "pass": return domain.SecretsBackendPass, nil case "sops": return domain.SecretsBackendSops, nil case "unsafe": return domain.SecretsBackendUnsafe, nil case "": return "", fmt.Errorf("auth.secrets_backend is required") default: return "", fmt.Errorf("unsupported auth.secrets_backend %q", backend) } } func createTokenStore(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { // Token store is always created since tokens work in both auth modes store, err := tokenstore.NewStore(backend, dataDir, log) if err != nil { return nil, log.WrapErr(err, "failed to create token store") } return store, nil } func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { authConfig := auth.Config{ Enabled: cfg.Auth.Enabled, AuthType: authType, Username: cfg.Auth.Username, } // Token config is always required (tokens work in all auth modes) secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) if err != nil { return auth.Config{}, err } authConfig.TokenSecret = secret authConfig.TokenExpiry = expiry accessTokenTTL := 15 * time.Minute // default if cfg.Auth.AccessTokenTTL != "" { parsed, err := time.ParseDuration(cfg.Auth.AccessTokenTTL) if err != nil { return auth.Config{}, fmt.Errorf("invalid auth.access_token_ttl %q: %w", cfg.Auth.AccessTokenTTL, err) } if parsed <= 0 { return auth.Config{}, fmt.Errorf("auth.access_token_ttl must be positive") } if parsed > auth.MaxAccessTokenLifetime { return auth.Config{}, fmt.Errorf("auth.access_token_ttl must not exceed %v", auth.MaxAccessTokenLifetime) } accessTokenTTL = parsed } authConfig.AccessTokenTTL = accessTokenTTL return authConfig, nil } func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log) if err != nil { return nil, 0, err } expiry, err := parseTokenExpiry(cfg.Auth.TokenExpiry) if err != nil { return nil, 0, err } return secret, expiry, nil } // TokenSecretEnvVar is the environment variable for the JWT signing secret. // SECURITY: This takes priority over config file to allow secure secret injection. const TokenSecretEnvVar = "GORDON_AUTH_TOKEN_SECRET" //nolint:gosec // This is an env var name, not a credential func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) { // SECURITY: Priority order for token secret: // 1. Environment variable (most secure - no disk exposure) // 2. Secrets backend (pass/sops - encrypted) // 3. Config file path (least preferred) const minTokenSecretLength = 32 // Check environment variable first if envSecret := os.Getenv(TokenSecretEnvVar); envSecret != "" { if len(envSecret) < minTokenSecretLength { return nil, fmt.Errorf("token secret from %s must be at least %d bytes (got %d)", TokenSecretEnvVar, minTokenSecretLength, len(envSecret)) } log.Debug().Msg("using token secret from environment variable") return []byte(envSecret), nil } // Fall back to config-specified path via secrets backend if cfg.Auth.TokenSecret == "" { return nil, fmt.Errorf("token_secret is required for JWT token generation; set %s environment variable or configure auth.token_secret", TokenSecretEnvVar) } secret, err := loadSecret(ctx, backend, cfg.Auth.TokenSecret, dataDir, log) if err != nil { return nil, log.WrapErr(err, "failed to load token secret") } if len(secret) < minTokenSecretLength { return nil, fmt.Errorf("token_secret must be at least %d bytes (got %d); use a strong random secret", minTokenSecretLength, len(secret)) } return []byte(secret), nil } func parseTokenExpiry(expiry string) (time.Duration, error) { if expiry == "" { return 0, nil } parsed, err := duration.Parse(expiry) if err != nil { return 0, fmt.Errorf("invalid token_expiry: %w", err) } return parsed, nil } // loadSecret loads a secret from the configured backend. func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) { switch backend { case domain.SecretsBackendPass: provider := secrets.NewPassProvider(log) return provider.GetSecret(ctx, path) case domain.SecretsBackendSops: provider := secrets.NewSopsProvider(log) return provider.GetSecret(ctx, path) case domain.SecretsBackendUnsafe: // For unsafe backend, path is relative to dataDir/secrets/. return readUnsafeSecret(dataDir, path) default: return "", fmt.Errorf("unknown secrets backend: %s", backend) } } func readFileBeneath(root, cleanedRelPath string) ([]byte, error) { rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) if err != nil { return nil, fmt.Errorf("failed to open secrets root: %w", err) } defer unix.Close(rootFD) parts := strings.Split(filepath.ToSlash(cleanedRelPath), "/") dirFD := rootFD var closeDirFDs []int defer func() { for i := len(closeDirFDs) - 1; i >= 0; i-- { _ = unix.Close(closeDirFDs[i]) } }() for i, part := range parts { if part == "" || part == "." || part == ".." { return nil, fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") } last := i == len(parts)-1 if last { fd, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) if err != nil { return nil, fmt.Errorf("failed to read secret file: %w", err) } defer unix.Close(fd) var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { return nil, fmt.Errorf("failed to stat secret file: %w", err) } if st.Mode&unix.S_IFMT != unix.S_IFREG { return nil, fmt.Errorf("invalid secret path: secret must be a regular file") } data, err := os.ReadFile(fmt.Sprintf("/proc/self/fd/%d", fd)) if err != nil { return nil, fmt.Errorf("failed to read secret file: %w", err) } return data, nil } nextFD, err := unix.Openat(dirFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) if err != nil { return nil, fmt.Errorf("failed to open secret path component: %w", err) } closeDirFDs = append(closeDirFDs, nextFD) dirFD = nextFD } return nil, fmt.Errorf("invalid secret path: empty path") } type unsafeSecretProvider struct { dataDir string } func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) } func (p unsafeSecretProvider) IsAvailable() bool { return true } func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) { return readUnsafeSecret(p.dataDir, path) } func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider { switch backend { case domain.SecretsBackendPass: return secrets.NewPassProvider(log) case domain.SecretsBackendSops: return secrets.NewSopsProvider(log) case domain.SecretsBackendUnsafe: return unsafeSecretProvider{dataDir: dataDir} default: return nil } } func readUnsafeSecret(dataDir, secretPath string) (string, error) { if filepath.IsAbs(secretPath) { return "", fmt.Errorf("invalid secret path: absolute paths are not allowed") } cleaned := filepath.Clean(secretPath) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { return "", fmt.Errorf("invalid secret path: path must stay under dataDir/secrets") } root := filepath.Clean(filepath.Join(dataDir, "secrets")) data, err := readFileBeneath(root, cleaned) if err != nil { return "", err } return string(data), nil }