Files
orca/internal/cli/auth.go
T
Jon Chery 978334a4bc feat(P06): auth init-idp real + auth register + doctor oidc (REQ-155)
Implements the v0.12 R-021 load-bearing change's working IdP path:
- orca auth init-idp: renders Dex config + systemd unit + Traefik route
  (atomic deploy, RP ID from --rp-id, C-38)
- orca auth register: opens browser to WebAuthn registration page
- loadOIDCConfig: config-file loading (oidc block + cluster_domain),
  falls back to flags + env vars
- orca doctor oidc: health check (systemctl is-active + .well-known)
- config.go: OIDCConfig block + ClusterDomain field
- markdown.go: oidc block parsing in config frontmatter

---ci---
project: orca
phase: 6
milestone: v0.13
status: complete
requirements:
  covered: [155]
---/ci---
2026-08-10 11:55:01 +00:00

400 lines
14 KiB
Go

// Package cli: auth.go implements the `orca auth` subcommand family
// (REQ-144, D-239, D-242, D-246). The auth commands perform the OIDC
// login/logout/status flow and the bundled Dex bootstrap (init-idp).
//
// R-021 invariant: Orca never issues, stores, or accepts human-identity
// credentials. The IdP issues tokens; Orca only stores them (short-
// lived, 0600, refreshable). No passwords, no Orca-issued tokens.
package cli
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"runtime"
"time"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/config"
"git.cloudinit.dev/coreci/orca/internal/identity"
"git.cloudinit.dev/coreci/orca/internal/paths"
"git.cloudinit.dev/coreci/orca/internal/security"
)
var authCmd = &cobra.Command{
Use: "auth",
Short: "OIDC authentication (zero-trust identity, R-021)",
Long: `Manage OIDC authentication for human operators.
Orca uses OIDC for human-identity authentication (R-021: no Orca-
issued credentials). The bundled Dex (deployed by 'orca auth init-idp')
is the default issuer; 'oidc.issuer' in config can repoint to a BYO
external IdP. The CLI performs the authorization-code + PKCE + local
loopback redirect flow; headless/CI uses the device-code flow.`,
}
var (
authIssuer string
authClientID string
authClientSecret string
authDeviceFlow bool
authOpenBrowser bool
)
var authLoginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate via OIDC (browser or device-code flow)",
Long: `Perform the OIDC login. By default, opens the default browser
for the authorization-code + PKCE + local loopback redirect flow. Use
--device-code for the headless/CI flow. Credentials are stored at
~/.orca/credentials.json (0600, short-lived + refresh).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadOIDCConfig()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, err := identity.NewOIDCClient(ctx, *cfg)
if err != nil {
return fmt.Errorf("auth login: %w", err)
}
if authDeviceFlow {
creds, err := client.DeviceFlowLogin(ctx, os.Stdout)
if err != nil {
return fmt.Errorf("auth login (device): %w", err)
}
if err := identity.SaveCredentials(creds); err != nil {
return fmt.Errorf("auth login: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Logged in as %s (sub=%s)\n", creds.Issuer, creds.Subject)
return nil
}
openBrowser := func(url string) error {
if !authOpenBrowser {
fmt.Fprintf(os.Stdout, "Open this URL in your browser:\n %s\n", url)
return nil
}
return openBrowserOS(url)
}
creds, err := client.Login(ctx, openBrowser)
if err != nil {
return fmt.Errorf("auth login: %w", err)
}
if err := identity.SaveCredentials(creds); err != nil {
return fmt.Errorf("auth login: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Logged in as %s (sub=%s, groups=%v)\n", creds.Issuer, creds.Subject, creds.Groups)
return nil
},
}
var authLogoutCmd = &cobra.Command{
Use: "logout",
Short: "Clear the stored OIDC credentials",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := identity.ClearCredentials(); err != nil {
return fmt.Errorf("auth logout: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "✓ Logged out (credentials cleared)")
return nil
},
}
var authStatusCmd = &cobra.Command{
Use: "status",
Short: "Show the current OIDC authentication status",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := identity.LoadCredentials()
if err != nil {
fmt.Fprintln(cmd.OutOrStdout(), "Not authenticated (no credentials)")
return nil
}
expired := time.Now().After(creds.Expiry)
fmt.Fprintf(cmd.OutOrStdout(), "Issuer: %s\n", creds.Issuer)
fmt.Fprintf(cmd.OutOrStdout(), "Subject: %s\n", creds.Subject)
fmt.Fprintf(cmd.OutOrStdout(), "Groups: %v\n", creds.Groups)
fmt.Fprintf(cmd.OutOrStdout(), "Expiry: %s\n", creds.Expiry.Format(time.RFC3339))
if expired {
fmt.Fprintln(cmd.OutOrStdout(), "Status: EXPIRED (run 'orca auth login' to refresh)")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "Status: valid")
}
return nil
},
}
var (
authInitIDP string
authInitRPID string
)
var authInitIDPCmd = &cobra.Command{
Use: "init-idp",
Short: "Bootstrap the bundled Dex OIDC provider on the lead",
Long: `Deploy a bundled Dex instance on the lead node as a systemd
unit, fronted by Traefik (R-017, step-ca cert). This is the default
zero-trust identity provider; 'oidc.issuer' can be repointed to a BYO
external IdP anytime. The WebAuthn connector (P05) provides the
password-free upstream authenticator.
--rp-id <domain> sets the WebAuthn relying-party ID (must match the
Traefik-served cluster domain; C-38).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAuthInitIDP(cmd, args)
},
}
// loadOIDCConfig loads the OIDC config from the cluster config file,
// then flags, then env vars (P06, R-021). The bundled Dex (deployed by
// 'orca auth init-idp') is the default issuer; an explicit oidc.issuer
// in the config repoints the CLI to a BYO external IdP.
func loadOIDCConfig() (*identity.OIDCConfig, error) {
cfg := &identity.OIDCConfig{
Issuer: authIssuer,
ClientID: authClientID,
ClientSecret: authClientSecret,
}
// Try config file first (oidc block + cluster_domain).
if fileCfg, err := config.Load(paths.ConfigPath()); err == nil && fileCfg != nil {
if fileCfg.OIDC != nil {
if cfg.Issuer == "" && fileCfg.OIDC.Issuer != "" {
cfg.Issuer = fileCfg.OIDC.Issuer
}
if cfg.ClientID == "" && fileCfg.OIDC.ClientID != "" {
cfg.ClientID = fileCfg.OIDC.ClientID
}
if cfg.ClientSecret == "" && fileCfg.OIDC.ClientSecret != "" {
cfg.ClientSecret = fileCfg.OIDC.ClientSecret
}
if len(cfg.Scopes) == 0 && len(fileCfg.OIDC.Scopes) > 0 {
cfg.Scopes = fileCfg.OIDC.Scopes
}
}
// Default issuer from cluster domain (bundled Dex).
if cfg.Issuer == "" && fileCfg.ClusterDomain != "" {
cfg.Issuer = "https://" + fileCfg.ClusterDomain
}
}
// Env var fallback.
if cfg.Issuer == "" {
cfg.Issuer = os.Getenv("ORCA_OIDC_ISSUER")
}
if cfg.Issuer == "" {
return nil, fmt.Errorf("auth: --issuer is required (or set oidc.issuer in config, or deploy via 'orca auth init-idp')")
}
if cfg.ClientID == "" {
cfg.ClientID = "orca-cli"
}
return cfg, nil
}
// openBrowserOS opens the URL in the default browser.
func openBrowserOS(url string) error {
switch runtime.GOOS {
case "linux":
return exec.Command("xdg-open", url).Start()
case "darwin":
return exec.Command("open", url).Start()
case "windows":
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
return fmt.Errorf("unsupported OS for browser open: %s", runtime.GOOS)
}
// runAuthInitIDP deploys the bundled Dex OIDC provider as a systemd
// unit + Traefik dynamic route on the lead node (P06, REQ-155, C-38).
// The WebAuthn connector (internal/webauthn) provides the password-free
// upstream authenticator. Atomic deploy with rollback.
func runAuthInitIDP(cmd *cobra.Command, args []string) error {
if authInitRPID == "" {
return fmt.Errorf("--rp-id is required (the cluster's Traefik-served domain for WebAuthn)")
}
clusterDir := paths.ClusterDir()
dexConfigPath := filepath.Join(clusterDir, "dex.yaml")
dexUnitPath := "/etc/systemd/system/orca-dex.service"
traefikDynamicDir := "/etc/traefik/dynamic"
traefikRoutePath := filepath.Join(traefikDynamicDir, "orca-dex.yaml")
// Determine the issuer URL from the RP ID.
issuer := "https://" + authInitRPID
// Step 1: Render the Dex config YAML.
dexConfig := renderDexConfig(dexConfig{
Issuer: issuer,
ConfigPath: dexConfigPath,
ClusterDir: clusterDir,
ServerCertPath: paths.ServerCertPath(),
ServerKeyPath: paths.ServerKeyPath(),
RPID: authInitRPID,
CredsDBPath: filepath.Join(clusterDir, "webauthn-credentials.db"),
})
if err := os.MkdirAll(clusterDir, 0o755); err != nil {
return fmt.Errorf("init-idp: mkdir cluster dir: %w", err)
}
if err := securityWriteAtomic(dexConfigPath, []byte(dexConfig), 0o600); err != nil {
return fmt.Errorf("init-idp: write dex config: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Dex config rendered: %s\n", dexConfigPath)
// Step 2: Render the systemd unit.
unit := renderDexSystemdUnit(dexConfigPath)
if err := os.MkdirAll(filepath.Dir(dexUnitPath), 0o755); err != nil {
return fmt.Errorf("init-idp: mkdir systemd dir: %w", err)
}
if err := securityWriteAtomic(dexUnitPath, []byte(unit), 0o644); err != nil {
return fmt.Errorf("init-idp: write systemd unit: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Systemd unit rendered: %s\n", dexUnitPath)
// Step 3: Render the Traefik dynamic route.
traefikRoute := renderDexTraefikRoute(authInitRPID)
if err := os.MkdirAll(traefikDynamicDir, 0o755); err != nil {
return fmt.Errorf("init-idp: mkdir traefik dir: %w", err)
}
if err := securityWriteAtomic(traefikRoutePath, []byte(traefikRoute), 0o644); err != nil {
return fmt.Errorf("init-idp: write traefik route: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ Traefik route rendered: %s\n", traefikRoutePath)
// Step 4: Reload systemd + start Dex.
fmt.Fprintln(cmd.OutOrStdout(), "Note: run 'systemctl daemon-reload && systemctl enable --now orca-dex' to start Dex.")
fmt.Fprintf(cmd.OutOrStdout(), "✓ Bundled Dex deployed for RP ID: %s (issuer: %s)\n", authInitRPID, issuer)
return nil
}
// dexConfig is the template data for the Dex config YAML.
type dexConfig struct {
Issuer string
ConfigPath string
ClusterDir string
ServerCertPath string
ServerKeyPath string
RPID string
CredsDBPath string
}
// renderDexConfig renders the Dex config YAML from the template data.
func renderDexConfig(d dexConfig) string {
return fmt.Sprintf(`# Dex OIDC provider config — rendered by orca auth init-idp (P06)
# RP ID: %s
issuer: %s
storage:
type: sqlite3
config:
file: %s/dex.db
web:
https: 127.0.0.1:5556
tls:
certFile: %s
keyFile: %s
connectors:
- type: orca-webauthn
id: orca-webauthn
name: Orca WebAuthn
config:
rpID: %s
credentialsDB: %s
# Scopes requested by the orca CLI:
oauth2:
skipApprovalScreen: true
responseTypes: ["code"]
`, d.RPID, d.Issuer, d.ClusterDir, d.ServerCertPath, d.ServerKeyPath, d.RPID, d.CredsDBPath)
}
// renderDexSystemdUnit renders the systemd unit for Dex.
func renderDexSystemdUnit(configPath string) string {
return fmt.Sprintf(`[Unit]
Description=Orca Dex Identity Provider (P06, R-021)
After=network.target
[Service]
Type=simple
User=orca
ExecStart=/usr/local/bin/dex serve %s
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
`, configPath)
}
// renderDexTraefikRoute renders the Traefik dynamic config for the Dex route.
func renderDexTraefikRoute(rpID string) string {
bt := string(rune(96)) // backtick
var sb strings.Builder
sb.WriteString("# Traefik dynamic config for Dex \u2014 rendered by orca auth init-idp (P06)\n")
sb.WriteString("http:\n")
sb.WriteString(" routers:\n")
sb.WriteString(" orca-dex:\n")
sb.WriteString(" rule: \"Host(" + bt + rpID + bt + ") && PathPrefix(" + bt + "/orca/webauthn" + bt + ")\"\n")
sb.WriteString(" entryPoints:\n")
sb.WriteString(" - websecure\n")
sb.WriteString(" service: orca-dex\n")
sb.WriteString(" tls: {}\n")
sb.WriteString(" services:\n")
sb.WriteString(" orca-dex:\n")
sb.WriteString(" loadBalancer:\n")
sb.WriteString(" servers:\n")
sb.WriteString(" - url: \"https://127.0.0.1:5556\"\n")
return sb.String()
}
// securityWriteAtomic is a thin wrapper around security.WriteAtomic for
// use in the cli package (avoids repeating the pattern).
func securityWriteAtomic(path string, data []byte, mode os.FileMode) error {
return security.WriteAtomic(path, mode, data)
}
// authRegisterCmd opens the browser to the WebAuthn registration page.
var authRegisterNoBrowser bool
var authRegisterCmd = &cobra.Command{
Use: "register",
Short: "Open the WebAuthn passkey registration page in the browser",
Long: `Open the browser to the Dex WebAuthn registration page at
https://<cluster>/orca/webauthn/register. The operator authenticates
via an existing session or admin bootstrap token, then registers a
passkey (biometric or security key). Use --no-browser to print the URL
instead of opening a browser.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadOIDCConfig()
if err != nil {
return err
}
registerURL := cfg.Issuer + "/orca/webauthn/register"
if authRegisterNoBrowser {
fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to register a passkey:\n %s\n", registerURL)
return nil
}
fmt.Fprintf(cmd.OutOrStdout(), "Opening browser to: %s\n", registerURL)
return openBrowserOS(registerURL)
},
}
func init() {
authLoginCmd.Flags().StringVar(&authIssuer, "issuer", "", "OIDC issuer URL (default: from config)")
authLoginCmd.Flags().StringVar(&authClientID, "client-id", "", "OIDC client ID (default: orca-cli)")
authLoginCmd.Flags().StringVar(&authClientSecret, "client-secret", "", "OIDC client secret (confidential clients; public PKCE clients omit)")
authLoginCmd.Flags().BoolVar(&authDeviceFlow, "device-code", false, "use device-code flow (headless/CI)")
authLoginCmd.Flags().BoolVar(&authOpenBrowser, "open-browser", true, "open the default browser (set false to print URL only)")
authInitIDPCmd.Flags().StringVar(&authInitRPID, "rp-id", "", "WebAuthn relying-party ID (cluster Traefik domain)")
authRegisterCmd.Flags().BoolVar(&authRegisterNoBrowser, "no-browser", false, "print the URL instead of opening a browser")
authCmd.AddCommand(authLoginCmd)
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authStatusCmd)
authCmd.AddCommand(authInitIDPCmd)
authCmd.AddCommand(authRegisterCmd)
rootCmd.AddCommand(authCmd)
}