Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 28 additions & 9 deletions pkg/cli/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions pkg/cli/init_authreconcile.go
Original file line number Diff line number Diff line change
@@ -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
}
138 changes: 138 additions & 0 deletions pkg/cli/init_authreconcile_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}