diff --git a/pkg/cli/init.go b/pkg/cli/init.go index f83727b6..ccd6fd02 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -109,7 +109,7 @@ func runInit(cmd *cobra.Command, _ []string) error { discoverCtx, discoverCancel := context.WithTimeout(ctx, 15*time.Second) defer discoverCancel() - authCfg := discoverProxyAuth(discoverCtx, initProxyURL) + authCfg, authDiscovered := discoverProxyAuth(discoverCtx, initProxyURL) fmt.Printf("Auth issuer: %s\n", authCfg.IssuerURL) fmt.Printf("Auth client: %s\n", authCfg.ClientID) @@ -157,6 +157,22 @@ func runInit(cmd *cobra.Command, _ []string) error { fmt.Printf("Docker Compose already exists: %s (use --force to overwrite)\n", composePath) } + // Reconcile the auth block of an existing (not overwritten) config when the + // proxy advertises different settings, so a proxy-side issuer change is + // picked up without --force. Runs before authentication so the login below + // uses the corrected issuer. + if configCreated == 0 && authDiscovered { + reconciled, reconcileErr := reconcileProxyAuth(configPath, authCfg) + + switch { + case reconcileErr != nil: + fmt.Printf("Warning: could not reconcile proxy auth in %s: %v\n", configPath, reconcileErr) + case reconciled: + fmt.Printf("Updated proxy auth in existing config to issuer %s (client %s)\n", + authCfg.IssuerURL, authCfg.ClientID) + } + } + // 4. Authenticate against the proxy. if !initSkipAuth { fmt.Println() @@ -284,8 +300,11 @@ proxy: } // discoverProxyAuth fetches auth metadata from the proxy's /auth/metadata -// endpoint. Falls back to using the proxy URL as the issuer if discovery fails. -func discoverProxyAuth(ctx context.Context, proxyURL string) initAuthConfig { +// endpoint. It falls back to using the proxy URL as the issuer if discovery +// fails, and reports whether the returned config came from the proxy (true) or +// the fallback (false). Callers must not persist fallback values over an +// existing config, since they are guesses rather than the proxy's real issuer. +func discoverProxyAuth(ctx context.Context, proxyURL string) (initAuthConfig, bool) { fallback := initAuthConfig{ IssuerURL: proxyURL, ClientID: defaultProxyAuthClientID, @@ -295,35 +314,35 @@ func discoverProxyAuth(ctx context.Context, proxyURL string) initAuthConfig { req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) if err != nil { - return fallback + return fallback, false } httpClient := &http.Client{Timeout: 10 * time.Second} resp, err := httpClient.Do(req) if err != nil { - return fallback + return fallback, false } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fallback + return fallback, false } var meta proxy.AuthMetadataResponse if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { - return fallback + return fallback, false } if meta.IssuerURL == "" || meta.ClientID == "" { - return fallback + return fallback, false } return initAuthConfig{ Mode: meta.Mode, IssuerURL: meta.IssuerURL, ClientID: meta.ClientID, - } + }, true } func buildComposeTemplate(serverImage, configDir string) string { diff --git a/pkg/cli/init_authreconcile.go b/pkg/cli/init_authreconcile.go new file mode 100644 index 00000000..e92dbfcf --- /dev/null +++ b/pkg/cli/init_authreconcile.go @@ -0,0 +1,151 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// reconcileProxyAuth updates the proxy.auth block of an existing config file to +// match the auth settings discovered from the proxy, when they have drifted +// (e.g. the proxy switched OIDC issuers). Only issuer_url, client_id and mode +// are touched; all other config and comments are preserved. It returns true +// when the file was modified. +// +// This exists because 'panda init' does not overwrite an existing config.yaml +// without --force, so an issuer change on the proxy would otherwise leave the +// local config pinned to a stale issuer, and 'panda auth login' resolves the +// issuer from that config before consulting the proxy. +func reconcileProxyAuth(path string, auth initAuthConfig) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("reading config %s: %w", path, err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return false, fmt.Errorf("parsing config %s: %w", path, err) + } + + authNode := proxyAuthNode(&doc) + if authNode == nil { + // No proxy.auth block to reconcile (unusual for an init-generated + // config); leave the file untouched. + return false, nil + } + + // The config template omits mode when it is the legacy "oauth" default, + // so treat "oauth" as "no mode key". + desiredMode := strings.TrimSpace(auth.Mode) + if desiredMode == "oauth" { + desiredMode = "" + } + + changed := setMappingScalar(authNode, "issuer_url", auth.IssuerURL) + + if setMappingScalar(authNode, "client_id", auth.ClientID) { + changed = true + } + + if desiredMode == "" { + if removeMappingKey(authNode, "mode") { + changed = true + } + } else if setMappingScalar(authNode, "mode", desiredMode) { + changed = true + } + + if !changed { + return false, nil + } + + out, err := yaml.Marshal(&doc) + if err != nil { + return false, fmt.Errorf("encoding config %s: %w", path, err) + } + + if err := os.WriteFile(path, out, 0o644); err != nil { + return false, fmt.Errorf("writing config %s: %w", path, err) + } + + return true, nil +} + +// proxyAuthNode returns the mapping node for proxy.auth, or nil when it is +// absent or not a mapping. +func proxyAuthNode(doc *yaml.Node) *yaml.Node { + root := doc + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + root = doc.Content[0] + } + + if root.Kind != yaml.MappingNode { + return nil + } + + proxyNode := mappingValue(root, "proxy") + if proxyNode == nil || proxyNode.Kind != yaml.MappingNode { + return nil + } + + authNode := mappingValue(proxyNode, "auth") + if authNode == nil || authNode.Kind != yaml.MappingNode { + return nil + } + + return authNode +} + +// mappingValue returns the value node for key in a mapping node, or nil. +func mappingValue(m *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + + return nil +} + +// setMappingScalar sets key=value in a mapping, appending the key when absent. +// It returns true when the mapping changed. +func setMappingScalar(m *yaml.Node, key, value string) bool { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value != key { + continue + } + + if m.Content[i+1].Value == value { + return false + } + + m.Content[i+1].Value = value + m.Content[i+1].Tag = "!!str" + m.Content[i+1].Style = yaml.DoubleQuotedStyle + + return true + } + + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value, Style: yaml.DoubleQuotedStyle}, + ) + + return true +} + +// removeMappingKey deletes key (and its value) from a mapping. It returns true +// when a key was removed. +func removeMappingKey(m *yaml.Node, key string) bool { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content = append(m.Content[:i], m.Content[i+2:]...) + + return true + } + } + + return false +} diff --git a/pkg/cli/init_authreconcile_test.go b/pkg/cli/init_authreconcile_test.go new file mode 100644 index 00000000..4d81b941 --- /dev/null +++ b/pkg/cli/init_authreconcile_test.go @@ -0,0 +1,138 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +const staleDexConfig = `# panda configuration +server: + base_url: "http://localhost:2480" + +storage: + base_dir: "/data/storage" + +proxy: + url: "https://panda-proxy.ethpandaops.io" + auth: + issuer_url: "https://dex.example.com" + client_id: "panda" +` + +func TestReconcileProxyAuth(t *testing.T) { + t.Parallel() + + authentik := initAuthConfig{ + Mode: "oidc", + IssuerURL: "https://authentik.example.com/application/o/panda-proxy/", + ClientID: "panda-proxy", + } + + t.Run("rewrites a stale issuer and preserves the rest", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(staleDexConfig), 0o644)) + + changed, err := reconcileProxyAuth(path, authentik) + require.NoError(t, err) + assert.True(t, changed, "stale config should be reconciled") + + data, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(data, &parsed)) + + proxy, ok := parsed["proxy"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "https://panda-proxy.ethpandaops.io", proxy["url"], + "unrelated proxy fields must be preserved") + + auth, ok := proxy["auth"].(map[string]any) + require.True(t, ok) + assert.Equal(t, authentik.IssuerURL, auth["issuer_url"]) + assert.Equal(t, authentik.ClientID, auth["client_id"]) + assert.Equal(t, "oidc", auth["mode"]) + + // Unrelated sections are untouched. + server, ok := parsed["server"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "http://localhost:2480", server["base_url"]) + }) + + t.Run("is a no-op when already current", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(staleDexConfig), 0o644)) + + // First reconcile brings it current; second must report no change. + _, err := reconcileProxyAuth(path, authentik) + require.NoError(t, err) + + before, err := os.ReadFile(path) + require.NoError(t, err) + + changed, err := reconcileProxyAuth(path, authentik) + require.NoError(t, err) + assert.False(t, changed, "matching config should not be rewritten") + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, string(before), string(after), "file must be byte-identical when unchanged") + }) + + t.Run("drops mode when reverting to the oauth default", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(staleDexConfig), 0o644)) + + // Bring it to oidc first. + _, err := reconcileProxyAuth(path, authentik) + require.NoError(t, err) + + legacy := initAuthConfig{ + Mode: "oauth", + IssuerURL: "https://panda-proxy.ethpandaops.io", + ClientID: "panda", + } + + changed, err := reconcileProxyAuth(path, legacy) + require.NoError(t, err) + assert.True(t, changed) + + data, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal(data, &parsed)) + + auth := parsed["proxy"].(map[string]any)["auth"].(map[string]any) + _, hasMode := auth["mode"] + assert.False(t, hasMode, "mode key should be removed for the oauth default") + }) + + t.Run("leaves config without a proxy.auth block untouched", func(t *testing.T) { + t.Parallel() + + const noAuth = "server:\n base_url: \"http://localhost:2480\"\nproxy:\n url: \"https://p.example.com\"\n" + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(noAuth), 0o644)) + + changed, err := reconcileProxyAuth(path, authentik) + require.NoError(t, err) + assert.False(t, changed) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, noAuth, string(data)) + }) +}