978334a4bc
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---
400 lines
14 KiB
Go
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)
|
|
}
|