5429da1f87
---ci--- project: orca phase: 4 milestone: v0.12 status: execute ---/ci--- internal/identity/oidc.go: OIDC client (provider discovery, JWKS, auth-code+PKCE+local-loopback redirect flow, device-code headless fallback, token verification, credentials store at ~/.orca/credentials.json 0600, refresh). VerifyIDTokenStatic for SSH-push applier. internal/cli/auth.go: orca auth login/logout/status/init-idp commands. Dependencies: github.com/coreos/go-oidc/v3, github.com/go-webauthn/webauthn (pre-added for P05). Bundled Dex deploy (init-idp) stubs to P05 (WebAuthn connector ships the full systemd unit + Traefik route). 9 tests pass (5 identity + 4 CLI). go vet clean. Full build green.
207 lines
7.1 KiB
Go
207 lines
7.1 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"
|
|
"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 <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 {
|
|
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)
|
|
}
|