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
88 changes: 85 additions & 3 deletions pkg/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@ package server

import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"

"github.com/sirupsen/logrus"

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"
)

// minLoginPollWindow bounds how long the server keeps polling for device
// 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.
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/server/auth_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
161 changes: 158 additions & 3 deletions pkg/server/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
})
}
}
2 changes: 1 addition & 1 deletion pkg/server/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading