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
}