// 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" "runtime" "time" "github.com/spf13/cobra" "git.cloudinit.dev/coreci/orca/internal/identity" ) 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 { if authInitRPID == "" { return fmt.Errorf("--rp-id is required (the cluster's Traefik-served domain for WebAuthn)") } // The full Dex deploy is a systemd unit + Traefik route + config // template. For v0.12 P04 we emit the config + unit files; the // WebAuthn connector ships in P05. fmt.Fprintf(cmd.OutOrStdout(), "Dex bootstrap planned for RP ID: %s\n", authInitRPID) fmt.Fprintln(cmd.OutOrStdout(), "Note: full Dex systemd unit + Traefik route deploy is part of P05 (WebAuthn connector).") fmt.Fprintln(cmd.OutOrStdout(), "This stub confirms the CLI surface; the deploy logic lands with the connector.") return nil }, } // loadOIDCConfig loads the OIDC config from flags or the cluster config. func loadOIDCConfig() (*identity.OIDCConfig, error) { cfg := &identity.OIDCConfig{ Issuer: authIssuer, ClientID: authClientID, ClientSecret: authClientSecret, } if cfg.Issuer == "" { // TODO: load from cluster config (oidc block). For v0.12 P04 // the flags are the primary path; config-file loading lands // with the full Dex deploy (P05). return nil, fmt.Errorf("auth: --issuer is required (or set oidc.issuer in config)") } 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) } 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)") authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authLogoutCmd) authCmd.AddCommand(authStatusCmd) authCmd.AddCommand(authInitIDPCmd) rootCmd.AddCommand(authCmd) }