diff --git a/pkg/server/auth.go b/pkg/server/auth.go index d4394ad3..7af28f7f 100644 --- a/pkg/server/auth.go +++ b/pkg/server/auth.go @@ -2,8 +2,10 @@ package server import ( "context" + "encoding/json" "fmt" "net/http" + "strings" "sync" "time" @@ -11,6 +13,7 @@ import ( authclient "github.com/ethpandaops/panda/pkg/auth/client" authstore "github.com/ethpandaops/panda/pkg/auth/store" + "github.com/ethpandaops/panda/pkg/proxy" "github.com/ethpandaops/panda/pkg/serverapi" ) @@ -18,6 +21,10 @@ import ( // approval when the provider does not advertise an expiry. const minLoginPollWindow = 5 * time.Minute +// proxyScopeDiscoveryTimeout bounds the /auth/metadata fetch performed at login +// so an unreachable proxy fails the login promptly instead of stalling it. +const proxyScopeDiscoveryTimeout = 5 * time.Second + // credentialTarget is the resolved proxy auth configuration the controller // operates against. It mirrors the values the proxy client uses to build its // own credential store so both resolve to the same on-disk credential file. @@ -44,6 +51,13 @@ type credentialController struct { newClient func(credentialTarget) authclient.Client newStore func(credentialTarget, authclient.Client) authstore.Store + // discoverScopes resolves the proxy's advertised login scope set at login + // time (from /auth/metadata) so the proxy — not a possibly-stale local + // config — decides which scopes the token carries. It is injected for tests; + // in production it is always set. A nil hook (tests only) requests the + // configured target scopes verbatim. + discoverScopes func(ctx context.Context) ([]string, error) + mu sync.Mutex pending *pendingLogin } @@ -58,7 +72,7 @@ type pendingLogin struct { // newCredentialController builds a controller for the resolved proxy auth // metadata. It returns nil when no interactive auth is configured (e.g. the // client_credentials service-account grant, which has no human login flow). -func newCredentialController(log logrus.FieldLogger, meta *serverapi.ProxyAuthMetadataResponse) *credentialController { +func newCredentialController(log logrus.FieldLogger, meta *serverapi.ProxyAuthMetadataResponse, proxyURL string) *credentialController { if meta == nil || !meta.Enabled { return nil } @@ -71,7 +85,7 @@ func newCredentialController(log logrus.FieldLogger, meta *serverapi.ProxyAuthMe enabled: meta.Enabled, } - return &credentialController{ + c := &credentialController{ log: log.WithField("component", "credential-controller"), target: target, newClient: func(t credentialTarget) authclient.Client { @@ -91,6 +105,17 @@ func newCredentialController(log logrus.FieldLogger, meta *serverapi.ProxyAuthMe }) }, } + + // The proxy owns which scopes its features require and advertises them at + // /auth/metadata (the same endpoint `panda init` reads). Resolve them at + // login so a config written before a feature existed still requests the + // scope it now needs, without a re-init — and without ever consulting the + // local scopes field. + c.discoverScopes = func(ctx context.Context) ([]string, error) { + return fetchProxyLoginScopes(ctx, proxyURL) + } + + return c } // Status reports the current credential state without exposing any token. @@ -130,7 +155,24 @@ func (c *credentialController) Status() serverapi.AuthStatusResponse { // that persists the tokens on approval. It returns the user-facing verification // details; the device code stays server-side. func (c *credentialController) BeginLogin(ctx context.Context) (serverapi.AuthLoginResponse, error) { - client := c.newClient(c.target) + // The proxy is authoritative for login scopes: it advertises the exact set + // its features require at /auth/metadata. Request those and never the + // configured scopes, so a stale config cannot mint a token silently missing + // a scope (e.g. "workflows") that only surfaces later as an opaque 401. If + // the proxy cannot be reached we fail loudly rather than guess. Only the + // scopes come from discovery; issuer/client/resource stay as configured so + // the credential file keys identically. + target := c.target + if c.discoverScopes != nil { + scopes, err := c.discoverScopes(ctx) + if err != nil { + return serverapi.AuthLoginResponse{}, fmt.Errorf("resolving login scopes from proxy: %w", err) + } + + target.scopes = scopes + } + + client := c.newClient(target) device, err := client.BeginDeviceLogin(ctx) if err != nil { @@ -202,6 +244,46 @@ func (c *credentialController) Stop() { c.pending = nil } +// fetchProxyLoginScopes reads the proxy's advertised login scope set from its +// public /auth/metadata discovery endpoint. A successful fetch returns the +// advertised scopes; an empty slice is a valid result meaning the proxy needs +// no extra scopes (the auth client then applies its own defaults). Any +// transport, status, or decode failure returns an error so the caller fails the +// login loudly instead of guessing a scope set. +func fetchProxyLoginScopes(ctx context.Context, proxyURL string) ([]string, error) { + base := strings.TrimSpace(proxyURL) + if base == "" { + return nil, fmt.Errorf("proxy URL is not configured") + } + + reqCtx, cancel := context.WithTimeout(ctx, proxyScopeDiscoveryTimeout) + defer cancel() + + metadataURL := strings.TrimRight(base, "/") + "/auth/metadata" + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, metadataURL, nil) + if err != nil { + return nil, fmt.Errorf("building metadata request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching %s: %w", metadataURL, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching %s: unexpected status %s", metadataURL, resp.Status) + } + + var meta proxy.AuthMetadataResponse + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return nil, fmt.Errorf("decoding %s: %w", metadataURL, err) + } + + return meta.Scopes, nil +} + // runLogin polls for device approval and persists the resulting tokens. The // terminal state and the credential write are committed under the lock only if // this attempt is still the current pending login, so a logout or a newer login diff --git a/pkg/server/auth_paths_test.go b/pkg/server/auth_paths_test.go index 1473329b..1f2cfa31 100644 --- a/pkg/server/auth_paths_test.go +++ b/pkg/server/auth_paths_test.go @@ -58,7 +58,7 @@ func TestCredentialPathMatchesProxyTokenSource(t *testing.T) { }).Path() // The credential file the server's controller operates on. - ctrl := newCredentialController(logrus.New(), buildProxyAuthMetadata(&config.Config{Proxy: tt.pc})) + ctrl := newCredentialController(logrus.New(), buildProxyAuthMetadata(&config.Config{Proxy: tt.pc}), tt.pc.URL) require.NotNil(t, ctrl) require.Equal(t, proxyPath, ctrl.Status().CredentialsPath) }) diff --git a/pkg/server/auth_test.go b/pkg/server/auth_test.go index b7a5def6..69631779 100644 --- a/pkg/server/auth_test.go +++ b/pkg/server/auth_test.go @@ -17,6 +17,7 @@ import ( authclient "github.com/ethpandaops/panda/pkg/auth/client" authstore "github.com/ethpandaops/panda/pkg/auth/store" + "github.com/ethpandaops/panda/pkg/proxy" "github.com/ethpandaops/panda/pkg/serverapi" ) @@ -92,13 +93,13 @@ func seedTokens(t *testing.T, path string, tokens *authclient.Tokens) { func TestNewCredentialController(t *testing.T) { t.Parallel() - require.Nil(t, newCredentialController(logrus.New(), nil), "nil metadata yields nil controller") - require.Nil(t, newCredentialController(logrus.New(), &serverapi.ProxyAuthMetadataResponse{}), + require.Nil(t, newCredentialController(logrus.New(), nil, ""), "nil metadata yields nil controller") + require.Nil(t, newCredentialController(logrus.New(), &serverapi.ProxyAuthMetadataResponse{}, ""), "disabled auth yields nil controller (e.g. client_credentials)") ctrl := newCredentialController(logrus.New(), &serverapi.ProxyAuthMetadataResponse{ Enabled: true, IssuerURL: "https://i", ClientID: "panda", Resource: "https://r", - }) + }, "") require.NotNil(t, ctrl) require.Equal(t, "https://i", ctrl.target.issuerURL) require.Equal(t, "panda", ctrl.target.clientID) @@ -363,3 +364,157 @@ func callJSON(t *testing.T, h http.HandlerFunc, method string) *httptest.Respons return rec } + +// TestBeginLoginScopeSelection verifies login requests the proxy-advertised +// scopes (never the configured ones) and aborts before minting a token when +// scope discovery fails, while leaving the credential-file keying +// (issuer/client/resource) untouched. +func TestBeginLoginScopeSelection(t *testing.T) { + t.Parallel() + + t.Run("proxy scopes replace configured scopes", func(t *testing.T) { + t.Parallel() + + var gotScopes []string + + path := filepath.Join(t.TempDir(), "creds.json") + fake := &fakeAuthClient{ + beginResp: &authclient.DeviceAuth{DeviceCode: "d", UserCode: "U", ExpiresIn: 600}, + pollResp: &authclient.Tokens{AccessToken: "at", ExpiresAt: time.Now().Add(time.Hour)}, + } + + ctrl := &credentialController{ + log: logrus.New(), + target: credentialTarget{ + issuerURL: "https://issuer.example", clientID: "panda", resource: "https://r", + enabled: true, scopes: []string{"stale-config-scope"}, + }, + discoverScopes: func(context.Context) ([]string, error) { + return []string{"openid", "workflows"}, nil + }, + newClient: func(tg credentialTarget) authclient.Client { + gotScopes = tg.scopes + + return fake + }, + newStore: func(_ credentialTarget, c authclient.Client) authstore.Store { + return authstore.New(logrus.New(), authstore.Config{Path: path, AuthClient: c}) + }, + } + + _, err := ctrl.BeginLogin(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{"openid", "workflows"}, gotScopes, "configured scopes must not be used") + + require.Eventually(t, func() bool { + return ctrl.LoginState().State == serverapi.AuthLoginAuthenticated + }, 2*time.Second, 10*time.Millisecond) + }) + + t.Run("discovery failure aborts login before minting a token", func(t *testing.T) { + t.Parallel() + + clientBuilt := false + ctrl := &credentialController{ + log: logrus.New(), + target: credentialTarget{ + issuerURL: "https://issuer.example", clientID: "panda", + enabled: true, scopes: []string{"stale-config-scope"}, + }, + discoverScopes: func(context.Context) ([]string, error) { + return nil, errors.New("proxy unreachable") + }, + newClient: func(credentialTarget) authclient.Client { + clientBuilt = true + + return &fakeAuthClient{} + }, + newStore: func(_ credentialTarget, c authclient.Client) authstore.Store { + return authstore.New(logrus.New(), authstore.Config{Path: filepath.Join(t.TempDir(), "c.json"), AuthClient: c}) + }, + } + + _, err := ctrl.BeginLogin(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "proxy unreachable") + require.False(t, clientBuilt, "login must not mint a token when scopes are unknown") + require.Equal(t, serverapi.AuthLoginNone, ctrl.LoginState().State) + }) +} + +// TestFetchProxyLoginScopes verifies scope discovery reads /auth/metadata, +// returns the advertised scopes (empty is a valid answer), and errors on any +// unreachable or non-200 proxy so the caller can fail the login loudly. +func TestFetchProxyLoginScopes(t *testing.T) { + t.Parallel() + + t.Run("empty url errors", func(t *testing.T) { + t.Parallel() + + _, err := fetchProxyLoginScopes(context.Background(), "") + require.Error(t, err) + }) + + t.Run("unreachable proxy errors", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + + _, err := fetchProxyLoginScopes(context.Background(), url) + require.Error(t, err) + }) + + tests := []struct { + name string + handler http.HandlerFunc + wantScopes []string + wantErr bool + }{ + { + name: "advertised scopes returned", + handler: func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/auth/metadata", r.URL.Path) + _ = json.NewEncoder(w).Encode(proxy.AuthMetadataResponse{ + Enabled: true, + Scopes: []string{"openid", "email", "groups", "offline_access", "workflows"}, + }) + }, + wantScopes: []string{"openid", "email", "groups", "offline_access", "workflows"}, + }, + { + name: "empty scopes are a valid result", + handler: func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(proxy.AuthMetadataResponse{Enabled: true}) + }, + wantScopes: nil, + }, + { + name: "non-200 errors", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + scopes, err := fetchProxyLoginScopes(context.Background(), srv.URL) + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + require.Equal(t, tt.wantScopes, scopes) + }) + } +} diff --git a/pkg/server/builder.go b/pkg/server/builder.go index 5472a79d..b268b82b 100644 --- a/pkg/server/builder.go +++ b/pkg/server/builder.go @@ -181,7 +181,7 @@ func (b *Builder) Build(ctx context.Context) (Service, error) { // uses the exact same issuer/client/resource — and therefore the exact same // on-disk credential file — as the proxy client's token source. proxyAuthMeta := buildProxyAuthMetadata(b.cfg) - credentials := newCredentialController(b.log, proxyAuthMeta) + credentials := newCredentialController(b.log, proxyAuthMeta, b.cfg.Proxy.URL) // Create and return the server service. return NewService(