// 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 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:///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) }