a73x

internal/oidcprovider/cli.go

Ref:   Size: 8.1 KiB   History

// cli.go is the eitri-oidc command line: `eitri-oidc [-config path]` serves
// the issuer; `eitri-oidc user add|list|rm` manages the flat user file and
// works whether or not the daemon is running (the daemon re-reads the file per
// auth attempt). It lives here rather than in cmd/eitri-oidc so it is testable
// and coverage-gated (arch R14: main packages are wiring only).

package oidcprovider

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"golang.org/x/term"
)

// DefaultConfigPath is where the binary looks for its config when -config is
// not given; the quickstart writes this file.
const DefaultConfigPath = "/etc/eitri/eitri-oidc.json"

// CLIConfig is the on-disk JSON schema (spec §2.1). The serve path needs every
// field; the `user` subcommands need only users_file.
type CLIConfig struct {
	Listen     string   `json:"listen"`
	Issuer     string   `json:"issuer"`
	UsersFile  string   `json:"users_file"`
	SigningKey string   `json:"signing_key"`
	Clients    []Client `json:"clients"`
}

// RunCLI dispatches the eitri-oidc command line (everything after the binary
// name, --version excluded — that stays in main). stdout carries command
// output (user list); stderr carries confirmations and prompts.
func RunCLI(args []string, stdout, stderr io.Writer) error {
	// Subcommands dispatch before flags: `eitri-oidc user ...` edits the flat
	// user file and never starts the daemon.
	if len(args) > 0 && args[0] == "user" {
		return runUser(args[1:], stdout, stderr)
	}
	fs := flag.NewFlagSet("eitri-oidc", flag.ContinueOnError)
	fs.SetOutput(stderr)
	cfgPath := fs.String("config", DefaultConfigPath, "config file")
	if err := fs.Parse(args); err != nil {
		return err
	}
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	return Serve(ctx, *cfgPath)
}

// Serve loads and validates the config, then runs the issuer until ctx is
// cancelled (graceful shutdown, mirroring eitri-server) or the listener fails.
func Serve(ctx context.Context, cfgPath string) error {
	cfg, err := loadCLIConfig(cfgPath)
	if err != nil {
		return err
	}
	if err := validateCLIConfig(cfg); err != nil {
		return fmt.Errorf("%s: %w", cfgPath, err)
	}

	p, err := New(Config{
		UsersFile:  cfg.UsersFile,
		SigningKey: cfg.SigningKey,
		Clients:    cfg.Clients,
	})
	if err != nil {
		return fmt.Errorf("init provider: %w", err)
	}
	// The issuer must equal the URL browsers and eitri-server reach it at, or
	// discovery verification fails — set it before the handler serves.
	p.SetIssuer(cfg.Issuer)

	srv := &http.Server{Addr: cfg.Listen, Handler: p.Handler()}
	errCh := make(chan error, 1)
	go func() {
		slog.Info("oidc listening", "addr", cfg.Listen, "issuer", cfg.Issuer)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
	}()

	select {
	case err := <-errCh:
		return fmt.Errorf("http serve: %w", err)
	case <-ctx.Done():
	}
	slog.Info("shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		slog.Warn("http graceful shutdown", "err", err)
	}
	return nil
}

// validateCLIConfig names every missing key in one error rather than failing
// one restart at a time.
func validateCLIConfig(cfg CLIConfig) error {
	var missing []string
	if cfg.Listen == "" {
		missing = append(missing, "listen")
	}
	if cfg.Issuer == "" {
		missing = append(missing, "issuer")
	}
	if cfg.UsersFile == "" {
		missing = append(missing, "users_file")
	}
	if cfg.SigningKey == "" {
		missing = append(missing, "signing_key")
	}
	if len(cfg.Clients) == 0 {
		missing = append(missing, "clients (at least one)")
	}
	for i, c := range cfg.Clients {
		if c.ID == "" {
			missing = append(missing, fmt.Sprintf("clients[%d].id", i))
		}
		if c.RedirectURL == "" {
			missing = append(missing, fmt.Sprintf("clients[%d].redirect_url", i))
		}
	}
	if len(missing) > 0 {
		return fmt.Errorf("config incomplete: missing %s", strings.Join(missing, ", "))
	}
	return nil
}

// runUser dispatches the flat-file user subcommands.
func runUser(args []string, stdout, stderr io.Writer) error {
	if len(args) < 1 {
		return errors.New("usage: eitri-oidc user <add|list|rm> [args]")
	}
	switch args[0] {
	case "add":
		return userAdd(args[1:], stderr)
	case "list":
		return userList(args[1:], stdout, stderr)
	case "rm":
		return userRemove(args[1:], stderr)
	default:
		return fmt.Errorf("unknown user subcommand %q (want add, list, rm)", args[0])
	}
}

func userAdd(args []string, stderr io.Writer) error {
	fs := flag.NewFlagSet("user add", flag.ContinueOnError)
	fs.SetOutput(stderr)
	cfgPath := fs.String("config", DefaultConfigPath, "config file")
	pwFile := fs.String("password-file", "", "read the password from this file instead of prompting")
	if err := fs.Parse(args); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("usage: eitri-oidc user add [--password-file <path>] <email>")
	}
	usersFile, err := usersFileFrom(*cfgPath)
	if err != nil {
		return err
	}
	password, err := readPassword(*pwFile, stderr)
	if err != nil {
		return err
	}
	if err := AddUser(usersFile, rest[0], password); err != nil {
		return err
	}
	fmt.Fprintf(stderr, "added %s\n", rest[0])
	return nil
}

func userList(args []string, stdout, stderr io.Writer) error {
	fs := flag.NewFlagSet("user list", flag.ContinueOnError)
	fs.SetOutput(stderr)
	cfgPath := fs.String("config", DefaultConfigPath, "config file")
	if err := fs.Parse(args); err != nil {
		return err
	}
	usersFile, err := usersFileFrom(*cfgPath)
	if err != nil {
		return err
	}
	for _, u := range ListUsers(usersFile) {
		fmt.Fprintln(stdout, u.Email)
	}
	return nil
}

func userRemove(args []string, stderr io.Writer) error {
	fs := flag.NewFlagSet("user rm", flag.ContinueOnError)
	fs.SetOutput(stderr)
	cfgPath := fs.String("config", DefaultConfigPath, "config file")
	if err := fs.Parse(args); err != nil {
		return err
	}
	rest := fs.Args()
	if len(rest) != 1 {
		return errors.New("usage: eitri-oidc user rm <email>")
	}
	usersFile, err := usersFileFrom(*cfgPath)
	if err != nil {
		return err
	}
	if err := RemoveUser(usersFile, rest[0]); err != nil {
		return err
	}
	fmt.Fprintf(stderr, "removed %s\n", rest[0])
	return nil
}

// usersFileFrom resolves the users_file path from the config. A missing config
// file (or an unset users_file) is a clear, fatal error for the caller.
func usersFileFrom(cfgPath string) (string, error) {
	cfg, err := loadCLIConfig(cfgPath)
	if err != nil {
		return "", err
	}
	if cfg.UsersFile == "" {
		return "", fmt.Errorf("users_file not set in %s", cfgPath)
	}
	return cfg.UsersFile, nil
}

// readPassword returns the new password either from pwFile (scripts) or an
// interactive double prompt (humans). The file form trims exactly one trailing
// newline so an `echo`-written file round-trips while internal whitespace is
// preserved; both forms reject an empty password.
func readPassword(pwFile string, stderr io.Writer) (string, error) {
	if pwFile != "" {
		b, err := os.ReadFile(pwFile)
		if err != nil {
			return "", err
		}
		pw := strings.TrimRight(string(b), "\r\n")
		if pw == "" {
			return "", fmt.Errorf("password file %s is empty", pwFile)
		}
		return pw, nil
	}
	if !term.IsTerminal(int(os.Stdin.Fd())) {
		return "", errors.New("no terminal for the password prompt; pass --password-file for scripts")
	}
	fmt.Fprint(stderr, "password: ")
	first, err := term.ReadPassword(int(os.Stdin.Fd()))
	fmt.Fprintln(stderr)
	if err != nil {
		return "", err
	}
	fmt.Fprint(stderr, "confirm password: ")
	second, err := term.ReadPassword(int(os.Stdin.Fd()))
	fmt.Fprintln(stderr)
	if err != nil {
		return "", err
	}
	if string(first) != string(second) {
		return "", errors.New("passwords do not match")
	}
	if len(first) == 0 {
		return "", errors.New("password must not be empty")
	}
	return string(first), nil
}

func loadCLIConfig(path string) (CLIConfig, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return CLIConfig{}, err
	}
	var cfg CLIConfig
	if err := json.Unmarshal(raw, &cfg); err != nil {
		return CLIConfig{}, fmt.Errorf("parse %s: %w", path, err)
	}
	return cfg, nil
}