diff --git a/AGENTS.md b/AGENTS.md index dce3e6aa..129d7015 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,9 +81,13 @@ make clean ### Command Structure -grafanactl follows the Cobra command pattern with two main command groups: +grafanactl follows the Cobra command pattern with three main command groups: -1. **config**: Manage configuration contexts for connecting to Grafana instances +1. **auth**: Authenticate with Grafana via OpenID Connect (OIDC) + - `auth login`: Log in using OIDC (Authorization Code + PKCE flow) + - `auth status`: Show current OIDC authentication status + +2. **config**: Manage configuration contexts for connecting to Grafana instances - `config set`: Set configuration values - `config unset`: Unset configuration values - `config use-context`: Switch between configured contexts @@ -92,7 +96,7 @@ grafanactl follows the Cobra command pattern with two main command groups: - `config view`: View the current configuration - `config check`: Validate the configuration -2. **resources**: Manipulate Grafana resources (dashboards, folders, etc.) +3. **resources**: Manipulate Grafana resources (dashboards, folders, etc.) - `resources get`: Get resources from Grafana - `resources list`: List resources - `resources pull`: Pull resources from Grafana to local files @@ -106,15 +110,22 @@ grafanactl follows the Cobra command pattern with two main command groups: **cmd/grafanactl/** - CLI command implementations - `root/`: Root command setup with logging and flags -- `config/`: Configuration management commands +- `config/`: Configuration management commands and OIDC authentication commands (login, status) - `resources/`: Resource manipulation commands - `fail/`: Error handling and detailed error messages - `io/`: Output formatting and user messages +**internal/auth/** - OIDC authentication +- Authorization Code + PKCE flow with local callback server +- OIDC discovery via `.well-known/openid-configuration` +- Token refresh using `golang.org/x/oauth2` +- Token expiry checking and cache management + **internal/config/** - Configuration management - Context-based configuration (similar to kubectl contexts) - Support for multiple Grafana instances (contexts) -- Authentication: basic auth, API tokens +- Authentication: OIDC, basic auth, API tokens +- OIDC token cache stored separately at `$XDG_CACHE_HOME/grafanactl/tokens.yaml` - Environment variable overrides (GRAFANA_SERVER, GRAFANA_TOKEN, etc.) - Automatic Stack ID discovery for Grafana Cloud - TLS configuration support @@ -198,6 +209,10 @@ Environment variables can override configuration values: - `GRAFANA_PASSWORD`: Password for basic auth - `GRAFANA_ORG_ID`: Organization ID (on-prem) - `GRAFANA_STACK_ID`: Stack ID (Grafana Cloud) +- `GRAFANA_OIDC_ISSUER`: OIDC provider issuer URL +- `GRAFANA_OIDC_CLIENT_ID`: OIDC client ID +- `GRAFANA_OIDC_CLIENT_SECRET`: OIDC client secret (optional for PKCE) +- `GRAFANA_OIDC_SCOPES`: OIDC scopes (space-separated) ## Testing Patterns @@ -206,6 +221,10 @@ Environment variables can override configuration values: - Resource filtering/selector tests in `internal/resources/*_test.go` - Test data in `testdata/` directories - Use `make tests` to run all tests with race detection +- OIDC integration tests require Dex (`docker compose up -d dex`) and use a build tag: + ```bash + go test -tags=integration ./internal/auth/ + ``` ## Code Generation @@ -491,7 +510,7 @@ The codebase is designed for extension: 2. **New Formats**: Add codec to `internal/format/codec.go` 3. **New Processors**: Implement Processor interface 4. **Custom Handlers**: Add to `internal/server/handlers/` -5. **Authentication Methods**: Extend `internal/config/rest.go` +5. **Authentication Methods**: OIDC tokens are resolved into `APIToken` during config loading (`cmd/grafanactl/config/command.go`), keeping auth call sites in `rest.go`, `client.go`, and `requests.go` unchanged ### Dependencies of Note @@ -508,6 +527,7 @@ The codebase is designed for extension: - `github.com/grafana/grafana-app-sdk/logging`: Structured logging **Utilities**: +- `golang.org/x/oauth2` v0.30.0: OIDC token exchange and refresh - `golang.org/x/sync` v0.17.0: errgroup for concurrency - `github.com/goccy/go-yaml` v1.18.0: YAML codec - `github.com/fsnotify/fsnotify` v1.9.0: File watching @@ -516,7 +536,9 @@ The codebase is designed for extension: **Credential Management**: - API tokens stored in config file with 0600 permissions -- Password fields tagged with `datapolicy:"secret"` for redaction +- OIDC tokens cached separately at `$XDG_CACHE_HOME/grafanactl/tokens.yaml` with 0600 permissions +- OIDC provider config (issuer, client ID) stored in main config; tokens never in config +- Password and secret fields tagged with `datapolicy:"secret"` for redaction - TLS certificate data base64-encoded in config - Environment variables supported for CI/CD (avoid config files) diff --git a/cmd/grafanactl/config/auth.go b/cmd/grafanactl/config/auth.go new file mode 100644 index 00000000..1908d739 --- /dev/null +++ b/cmd/grafanactl/config/auth.go @@ -0,0 +1,164 @@ +package config + +import ( + "fmt" + + "github.com/grafana/grafanactl/cmd/grafanactl/io" + "github.com/grafana/grafanactl/internal/auth" + "github.com/grafana/grafanactl/internal/config" + "github.com/spf13/cobra" +) + +// AuthCommand returns the top-level auth command group. +func AuthCommand() *cobra.Command { + configOpts := &Options{} + + cmd := &cobra.Command{ + Use: "auth", + Short: "Authenticate with a Grafana instance", + Long: "Authenticate with a Grafana instance using OpenID Connect (OIDC).", + } + + configOpts.BindFlags(cmd.PersistentFlags()) + + cmd.AddCommand(loginCmd(configOpts)) + cmd.AddCommand(statusCmd(configOpts)) + + return cmd +} + +func loginCmd(configOpts *Options) *cobra.Command { + var callbackPort int + + cmd := &cobra.Command{ + Use: "login", + Args: cobra.NoArgs, + Short: "Log in using OIDC", + Long: `Log in to Grafana using OpenID Connect (OIDC) with the Authorization Code + PKCE flow. + +This opens your browser for authentication with the configured OIDC provider. +The resulting tokens are cached separately from the configuration file. + +Before running this command, configure OIDC settings for the context: + + grafanactl config set contexts..grafana.oidc.issuer-url https://your-idp.example.com + grafanactl config set contexts..grafana.oidc.client-id your-client-id`, + Example: "\n\tgrafanactl auth login\n\tgrafanactl auth login --context production\n\tgrafanactl auth login --callback-port 8085", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := configOpts.loadConfigTolerant(cmd.Context()) + if err != nil { + return err + } + + gCtx := cfg.GetCurrentContext() + if gCtx == nil { + return fmt.Errorf("no current context set") + } + + if gCtx.Grafana == nil { + gCtx.Grafana = &config.GrafanaConfig{} + } + + if gCtx.Grafana.OIDC == nil { + return fmt.Errorf("OIDC is not configured for context %q\n\nConfigure it with:\n grafanactl config set contexts.%[1]s.grafana.oidc.issuer-url \n grafanactl config set contexts.%[1]s.grafana.oidc.client-id ", gCtx.Name) + } + + if !gCtx.Grafana.OIDC.IsConfigured() { + return fmt.Errorf("OIDC issuer-url and client-id are required for context %q", gCtx.Name) + } + + stdout := cmd.OutOrStdout() + io.Info(stdout, "Opening browser for OIDC login to %s...", gCtx.Grafana.OIDC.IssuerURL) + + port := callbackPort + if port == 0 { + port = int(gCtx.Grafana.OIDC.CallbackPort) + } + + token, err := auth.Login(cmd.Context(), gCtx.Grafana.OIDC, auth.LoginOptions{ + CallbackPort: port, + }) + if err != nil { + return fmt.Errorf("OIDC login failed: %w", err) + } + + // Store tokens in the cache file, not the main config. + cache, _ := config.LoadTokenCache(cmd.Context()) + cache.Set(cfg.CurrentContext, auth.NewCachedToken(token)) + + if err := config.WriteTokenCache(cmd.Context(), cache); err != nil { + return fmt.Errorf("saving tokens to cache: %w", err) + } + + io.Success(stdout, "Successfully authenticated via OIDC") + + if token.Expiry.IsZero() { + io.Info(stdout, "Token has no expiry") + } else { + io.Info(stdout, "Token expires at %s", token.Expiry.Format("2006-01-02 15:04:05")) + } + + return nil + }, + } + + cmd.Flags().IntVar(&callbackPort, "callback-port", 0, "Fixed port for the local OIDC callback server (0 = random)") + + return cmd +} + +func statusCmd(configOpts *Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Args: cobra.NoArgs, + Short: "Show OIDC authentication status", + Example: "\n\tgrafanactl auth status", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := configOpts.loadConfigTolerant(cmd.Context()) + if err != nil { + return err + } + + gCtx := cfg.GetCurrentContext() + if gCtx == nil { + return fmt.Errorf("no current context set") + } + + stdout := cmd.OutOrStdout() + + if gCtx.Grafana == nil || gCtx.Grafana.OIDC == nil || !gCtx.Grafana.OIDC.IsConfigured() { + io.Warning(stdout, "OIDC is not configured for context %q", gCtx.Name) + return nil + } + + io.Info(stdout, "OIDC provider: %s", gCtx.Grafana.OIDC.IssuerURL) + io.Info(stdout, "Client ID: %s", gCtx.Grafana.OIDC.ClientID) + + cache, _ := config.LoadTokenCache(cmd.Context()) + cached := cache.Get(cfg.CurrentContext) + + if cached == nil || cached.AccessToken == "" { + io.Warning(stdout, "Not authenticated. Run 'grafanactl auth login' to log in.") + return nil + } + + if auth.TokenNeedsRefresh(cached) { + io.Warning(stdout, "Token expired") + if cached.RefreshToken != "" { + io.Info(stdout, "Refresh token available — token will be refreshed on next command") + } else { + io.Info(stdout, "No refresh token — run 'grafanactl auth login' to re-authenticate") + } + } else { + io.Success(stdout, "Authenticated") + if cached.TokenExpiry != "" { + io.Info(stdout, "Token expires: %s", cached.TokenExpiry) + } + } + + return nil + }, + } + + return cmd +} diff --git a/cmd/grafanactl/config/command.go b/cmd/grafanactl/config/command.go index d9b42fb1..a72a9a0a 100644 --- a/cmd/grafanactl/config/command.go +++ b/cmd/grafanactl/config/command.go @@ -9,6 +9,7 @@ import ( "github.com/caarlos0/env/v11" "github.com/grafana/grafanactl/cmd/grafanactl/fail" "github.com/grafana/grafanactl/cmd/grafanactl/io" + "github.com/grafana/grafanactl/internal/auth" "github.com/grafana/grafanactl/internal/config" "github.com/grafana/grafanactl/internal/format" "github.com/grafana/grafanactl/internal/grafana" @@ -77,6 +78,7 @@ func (opts *Options) loadConfigTolerant(ctx context.Context, extraOverrides ...c } // LoadConfig loads the configuration file (default, or explicitly set via flags) and validates it. +// If OIDC is configured, the cached token is resolved into APIToken so downstream code is unaware of OIDC. func (opts *Options) LoadConfig(ctx context.Context) (config.Config, error) { validator := func(cfg *config.Config) error { // Ensure that the current context actually exists. @@ -87,7 +89,31 @@ func (opts *Options) LoadConfig(ctx context.Context) (config.Config, error) { return cfg.GetCurrentContext().Validate() } - return opts.loadConfigTolerant(ctx, validator) + cfg, err := opts.loadConfigTolerant(ctx, validator) + if err != nil { + return cfg, err + } + + // If OIDC is configured, resolve the cached token into APIToken. + // This makes OIDC transparent to all downstream auth call sites. + if gCtx := cfg.GetCurrentContext(); gCtx != nil && gCtx.Grafana != nil && gCtx.Grafana.OIDC.IsConfigured() { + cache, _ := config.LoadTokenCache(ctx) + cached := cache.Get(cfg.CurrentContext) + + token, refreshed, err := auth.EnsureValidToken(ctx, gCtx.Grafana.OIDC, cached) + if err != nil { + return cfg, err + } + + gCtx.Grafana.APIToken = token + + if refreshed { + cache.Set(cfg.CurrentContext, cached) + _ = config.WriteTokenCache(ctx, cache) + } + } + + return cfg, nil } // LoadRESTConfig loads the configuration file and constructs a REST config from it. diff --git a/cmd/grafanactl/root/command.go b/cmd/grafanactl/root/command.go index afdde916..6efeb65f 100644 --- a/cmd/grafanactl/root/command.go +++ b/cmd/grafanactl/root/command.go @@ -57,6 +57,7 @@ func Command(version string) *cobra.Command { rootCmd.SetErr(os.Stderr) rootCmd.SetIn(os.Stdin) + rootCmd.AddCommand(config.AuthCommand()) rootCmd.AddCommand(config.Command()) rootCmd.AddCommand(resources.Command()) diff --git a/docker-compose.yml b/docker-compose.yml index d5661056..e9c073bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,23 @@ services: networks: - grafana + dex: + image: dexidp/dex:v2.41.1 + container_name: grafanactl-dex + restart: unless-stopped + ports: + - "5556:5556" + volumes: + - ./testdata/dex/config.yaml:/etc/dex/config.yaml:ro + command: ["dex", "serve", "/etc/dex/config.yaml"] + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5556/dex/.well-known/openid-configuration"] + interval: 5s + timeout: 3s + retries: 10 + networks: + - grafana + grafana: image: grafana/grafana:12.3@sha256:ba93c9d192e58b23e064c7f501d453426ccf4a85065bf25b705ab1e98602bfb1 container_name: grafanactl-grafana diff --git a/go.mod b/go.mod index 91d659ab..8563c7ed 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.19.0 k8s.io/apimachinery v0.35.1 k8s.io/cli-runtime v0.35.1 @@ -85,7 +86,6 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 00000000..e8a8e882 --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,319 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafanactl/internal/config" + "golang.org/x/oauth2" +) + +// DefaultScopes is the default set of OIDC scopes to request. +const DefaultScopes = "openid profile email" + +const ( + callbackPath = "/callback" + tokenExpiryBuffer = 30 * time.Second +) + +// OIDCEndpoints holds the discovered OIDC provider endpoints. +type OIDCEndpoints struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` +} + +// DiscoverEndpoints fetches the OIDC provider's endpoints from .well-known/openid-configuration. +func DiscoverEndpoints(ctx context.Context, issuerURL string) (*OIDCEndpoints, error) { + discoveryURL := strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil) + if err != nil { + return nil, fmt.Errorf("creating discovery request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching OIDC discovery document: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("OIDC discovery returned status %d", resp.StatusCode) + } + + var endpoints OIDCEndpoints + if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil { + return nil, fmt.Errorf("decoding OIDC discovery document: %w", err) + } + + if endpoints.AuthorizationEndpoint == "" || endpoints.TokenEndpoint == "" { + return nil, fmt.Errorf("OIDC discovery document missing required endpoints") + } + + return &endpoints, nil +} + +// LoginOptions configures the OIDC login flow. +type LoginOptions struct { + // CallbackPort sets a fixed port for the local callback server. + // If 0, a random available port is used. + CallbackPort int +} + +// Login performs the OIDC Authorization Code + PKCE flow. +// It starts a local HTTP server, opens the browser for authentication, +// waits for the callback, exchanges the code for tokens, and returns the token. +func Login(ctx context.Context, oidcCfg *config.OIDCConfig, opts LoginOptions) (*oauth2.Token, error) { + endpoints, err := DiscoverEndpoints(ctx, oidcCfg.IssuerURL) + if err != nil { + return nil, err + } + + listenAddr := fmt.Sprintf("127.0.0.1:%d", opts.CallbackPort) + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + return nil, fmt.Errorf("starting local listener on %s: %w", listenAddr, err) + } + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + redirectURL := fmt.Sprintf("http://127.0.0.1:%d%s", port, callbackPath) + + scopes := strings.Fields(oidcCfg.Scopes) + if len(scopes) == 0 { + scopes = strings.Fields(DefaultScopes) + } + + oauthCfg := &oauth2.Config{ + ClientID: oidcCfg.ClientID, + ClientSecret: oidcCfg.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: endpoints.AuthorizationEndpoint, + TokenURL: endpoints.TokenEndpoint, + }, + RedirectURL: redirectURL, + Scopes: scopes, + } + + // Generate PKCE verifier and state. + verifier, err := generateVerifier() + if err != nil { + return nil, fmt.Errorf("generating PKCE verifier: %w", err) + } + + state, err := generateState() + if err != nil { + return nil, fmt.Errorf("generating state: %w", err) + } + + challenge := s256Challenge(verifier) + + authURL := oauthCfg.AuthCodeURL( + state, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + // Channel to receive the authorization code. + type callbackResult struct { + code string + err error + } + resultCh := make(chan callbackResult, 1) + + mux := http.NewServeMux() + mux.HandleFunc(callbackPath, func(w http.ResponseWriter, r *http.Request) { + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + desc := r.URL.Query().Get("error_description") + resultCh <- callbackResult{err: fmt.Errorf("OIDC error: %s: %s", errMsg, desc)} + fmt.Fprintf(w, "

Authentication failed

%s: %s

You can close this window.

", errMsg, desc) + return + } + + code := r.URL.Query().Get("code") + returnedState := r.URL.Query().Get("state") + + if returnedState != state { + resultCh <- callbackResult{err: fmt.Errorf("state mismatch: possible CSRF attack")} + fmt.Fprint(w, "

Authentication failed

State mismatch.

") + return + } + + resultCh <- callbackResult{code: code} + fmt.Fprint(w, "

Authentication successful

You can close this window and return to the terminal.

") + }) + + server := &http.Server{Handler: mux} + go func() { + if serveErr := server.Serve(listener); serveErr != nil && serveErr != http.ErrServerClosed { + resultCh <- callbackResult{err: fmt.Errorf("callback server: %w", serveErr)} + } + }() + defer server.Shutdown(ctx) //nolint:errcheck + + // Open browser. + logging.FromContext(ctx).Debug("Opening browser for OIDC login", slog.String("url", authURL)) + if err := openBrowser(authURL); err != nil { + return nil, fmt.Errorf("opening browser: %w\n\nPlease open this URL manually:\n%s", err, authURL) + } + + // Wait for callback or context cancellation. + select { + case result := <-resultCh: + if result.err != nil { + return nil, result.err + } + + // Exchange authorization code for tokens. + token, err := oauthCfg.Exchange(ctx, result.code, + oauth2.SetAuthURLParam("code_verifier", verifier), + ) + if err != nil { + return nil, fmt.Errorf("exchanging authorization code: %w", err) + } + + return token, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// RefreshAccessToken refreshes the OIDC access token using the given refresh token. +func RefreshAccessToken(ctx context.Context, oidcCfg *config.OIDCConfig, refreshToken string) (*oauth2.Token, error) { + endpoints, err := DiscoverEndpoints(ctx, oidcCfg.IssuerURL) + if err != nil { + return nil, err + } + + oauthCfg := &oauth2.Config{ + ClientID: oidcCfg.ClientID, + ClientSecret: oidcCfg.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: endpoints.AuthorizationEndpoint, + TokenURL: endpoints.TokenEndpoint, + }, + } + + tokenSource := oauthCfg.TokenSource(ctx, &oauth2.Token{ + RefreshToken: refreshToken, + }) + + return tokenSource.Token() +} + +// TokenNeedsRefresh returns true if the cached token is expired or about to expire. +func TokenNeedsRefresh(cached *config.CachedToken) bool { + if cached == nil || cached.AccessToken == "" { + return true + } + + if cached.TokenExpiry == "" { + // No expiry info — assume token is still valid. + return false + } + + expiry, err := time.Parse(time.RFC3339, cached.TokenExpiry) + if err != nil { + return true + } + + return time.Now().Add(tokenExpiryBuffer).After(expiry) +} + +// NewCachedToken creates a CachedToken from an oauth2.Token. +func NewCachedToken(token *oauth2.Token) *config.CachedToken { + cached := &config.CachedToken{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + } + + if !token.Expiry.IsZero() { + cached.TokenExpiry = token.Expiry.Format(time.RFC3339) + } + + return cached +} + +// EnsureValidToken checks if the cached token is valid and refreshes it if needed. +// Returns the current access token, whether it was refreshed, and any error. +func EnsureValidToken(ctx context.Context, oidcCfg *config.OIDCConfig, cached *config.CachedToken) (string, bool, error) { + if cached == nil || cached.AccessToken == "" { + return "", false, fmt.Errorf("no OIDC tokens found, run 'grafanactl auth login' to authenticate") + } + + if !TokenNeedsRefresh(cached) { + return cached.AccessToken, false, nil + } + + if cached.RefreshToken == "" { + return "", false, fmt.Errorf("OIDC access token expired and no refresh token available, run 'grafanactl auth login' to re-authenticate") + } + + logging.FromContext(ctx).Debug("OIDC access token expired, refreshing") + + token, err := RefreshAccessToken(ctx, oidcCfg, cached.RefreshToken) + if err != nil { + return "", false, fmt.Errorf("refreshing OIDC token: %w\n\nRun 'grafanactl auth login' to re-authenticate", err) + } + + refreshed := NewCachedToken(token) + cached.AccessToken = refreshed.AccessToken + cached.RefreshToken = refreshed.RefreshToken + cached.TokenExpiry = refreshed.TokenExpiry + + return cached.AccessToken, true, nil +} + +func generateVerifier() (string, error) { + buf := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func s256Challenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func generateState() (string, error) { + buf := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func openBrowser(rawURL string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) + case "linux": + cmd = exec.Command("xdg-open", rawURL) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) + default: + return fmt.Errorf("unsupported platform %s", runtime.GOOS) + } + + return cmd.Start() +} + diff --git a/internal/auth/oidc_integration_test.go b/internal/auth/oidc_integration_test.go new file mode 100644 index 00000000..a59272eb --- /dev/null +++ b/internal/auth/oidc_integration_test.go @@ -0,0 +1,207 @@ +//go:build integration + +package auth_test + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "testing" + "time" + + "github.com/grafana/grafanactl/internal/auth" + "github.com/grafana/grafanactl/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +// These constants match testdata/dex/config.yaml. +const ( + dexIssuerURL = "http://localhost:5556/dex" + dexClientID = "grafanactl-test" + dexEmail = "admin@example.com" + dexPassword = "password" + callbackBase = "http://127.0.0.1:18085/callback" +) + +func TestOIDCDiscovery(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + endpoints, err := auth.DiscoverEndpoints(ctx, dexIssuerURL) + require.NoError(t, err) + + assert.Contains(t, endpoints.AuthorizationEndpoint, "/dex/auth") + assert.Contains(t, endpoints.TokenEndpoint, "/dex/token") +} + +func TestOIDCLoginAndRefresh(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + endpoints, err := auth.DiscoverEndpoints(ctx, dexIssuerURL) + require.NoError(t, err) + + oauthCfg := &oauth2.Config{ + ClientID: dexClientID, + Endpoint: oauth2.Endpoint{ + AuthURL: endpoints.AuthorizationEndpoint, + TokenURL: endpoints.TokenEndpoint, + }, + RedirectURL: callbackBase, + Scopes: []string{"openid", "profile", "email", "offline_access"}, + } + + verifier := generateTestVerifier(t) + challenge := s256Challenge(verifier) + state := "test-state-12345" + + authURL := oauthCfg.AuthCodeURL( + state, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + code, returnedState := programmaticDexLogin(t, authURL, dexEmail, dexPassword) + require.Equal(t, state, returnedState, "state mismatch") + require.NotEmpty(t, code) + + token, err := oauthCfg.Exchange(ctx, code, + oauth2.SetAuthURLParam("code_verifier", verifier), + ) + require.NoError(t, err) + assert.NotEmpty(t, token.AccessToken) + assert.NotEmpty(t, token.RefreshToken, "missing refresh token (offline_access scope)") + assert.False(t, token.Expiry.IsZero()) + + oidcCfg := &config.OIDCConfig{ + IssuerURL: dexIssuerURL, + ClientID: dexClientID, + } + cached := auth.NewCachedToken(token) + + assert.Equal(t, token.AccessToken, cached.AccessToken) + assert.Equal(t, token.RefreshToken, cached.RefreshToken) + assert.False(t, auth.TokenNeedsRefresh(cached)) + + accessToken, refreshed, err := auth.EnsureValidToken(ctx, oidcCfg, cached) + require.NoError(t, err) + assert.False(t, refreshed) + assert.Equal(t, token.AccessToken, accessToken) + + // Force expiry, then verify refresh works. + cached.TokenExpiry = time.Now().Add(-1 * time.Hour).Format(time.RFC3339) + assert.True(t, auth.TokenNeedsRefresh(cached)) + + accessToken, refreshed, err = auth.EnsureValidToken(ctx, oidcCfg, cached) + require.NoError(t, err) + assert.True(t, refreshed) + assert.NotEmpty(t, accessToken) +} + +func TestTokenNeedsRefresh(t *testing.T) { + tests := []struct { + name string + cfg *config.CachedToken + want bool + }{ + {"nil", nil, true}, + {"empty", &config.CachedToken{}, true}, + {"no expiry", &config.CachedToken{AccessToken: "tok"}, false}, + {"future expiry", &config.CachedToken{AccessToken: "tok", TokenExpiry: time.Now().Add(time.Hour).Format(time.RFC3339)}, false}, + {"past expiry", &config.CachedToken{AccessToken: "tok", TokenExpiry: time.Now().Add(-time.Hour).Format(time.RFC3339)}, true}, + {"malformed expiry", &config.CachedToken{AccessToken: "tok", TokenExpiry: "bad"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, auth.TokenNeedsRefresh(tt.cfg)) + }) + } +} + +// programmaticDexLogin walks through the exact Dex password-connector login flow: +// +// 1. GET /dex/auth?... → 302 → /dex/auth/local?... +// 2. GET /dex/auth/local?... → 302 → /dex/auth/local/login?state=... +// 3. GET /dex/auth/local/login?... → 200 (login form) +// 4. POST /dex/auth/local/login?... → 303 → callback?code=...&state=... +// +// it's a bit nicer than just following re-directs blindly, so if Dex ever has an issue +// we should be able to pinpoint it fast. +func programmaticDexLogin(t *testing.T, authURL, email, password string) (code, state string) { + t.Helper() + + jar, err := cookiejar.New(nil) + require.NoError(t, err) + + client := &http.Client{ + Jar: jar, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Step 1: GET auth URL → 302 to connector selection. + resp, err := client.Get(authURL) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusFound, resp.StatusCode) + + // Step 2: GET connector URL → 302 to login form. + resp, err = client.Get(locationURL(t, resp)) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusFound, resp.StatusCode) + + // Step 3: GET login form → 200. + loginFormURL := locationURL(t, resp) + resp, err = client.Get(loginFormURL) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Step 4: POST credentials → 303 to callback with code. + resp, err = client.PostForm(loginFormURL, url.Values{ + "login": {email}, + "password": {password}, + }) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusSeeOther, resp.StatusCode) + + callback, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + + return callback.Query().Get("code"), callback.Query().Get("state") +} + +func locationURL(t *testing.T, resp *http.Response) string { + t.Helper() + loc := resp.Header.Get("Location") + require.NotEmpty(t, loc) + u, err := url.Parse(loc) + require.NoError(t, err) + if !u.IsAbs() { + u = resp.Request.URL.ResolveReference(u) + } + return u.String() +} + +func generateTestVerifier(t *testing.T) string { + t.Helper() + buf := make([]byte, 32) + _, err := io.ReadFull(rand.Reader, buf) + require.NoError(t, err) + return base64.RawURLEncoding.EncodeToString(buf) +} + +func s256Challenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} diff --git a/internal/config/loader.go b/internal/config/loader.go index 5550e834..7208f0c3 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -18,6 +18,7 @@ const ( configFilePermissions = 0o600 StandardConfigFolder = "grafanactl" StandardConfigFileName = "config.yaml" + tokenCacheFileName = "tokens.yaml" ConfigFileEnvVar = "GRAFANACTL_CONFIG" defaultEmptyConfigFile = ` @@ -115,6 +116,57 @@ func Write(ctx context.Context, source Source, cfg Config) error { return codec.Encode(file, cfg) } +func tokenCachePath() (string, error) { + return xdg.CacheFile(filepath.Join(StandardConfigFolder, tokenCacheFileName)) +} + +// LoadTokenCache loads the token cache from $XDG_CACHE_HOME/grafanactl/tokens.yaml. +// Returns an empty cache if the file doesn't exist. +func LoadTokenCache(ctx context.Context) (TokenCache, error) { + cache := TokenCache{} + + cachePath, err := tokenCachePath() + if err != nil { + return cache, err + } + + logging.FromContext(ctx).Debug("Loading token cache", slog.String("filename", cachePath)) + + contents, err := os.ReadFile(cachePath) + if os.IsNotExist(err) { + return cache, nil + } + if err != nil { + return cache, err + } + + codec := &format.YAMLCodec{} + if err := codec.Decode(bytes.NewBuffer(contents), &cache); err != nil { + return cache, err + } + + return cache, nil +} + +// WriteTokenCache writes the token cache to $XDG_CACHE_HOME/grafanactl/tokens.yaml. +func WriteTokenCache(ctx context.Context, cache TokenCache) error { + cachePath, err := tokenCachePath() + if err != nil { + return err + } + + logging.FromContext(ctx).Debug("Writing token cache", slog.String("filename", cachePath)) + + file, err := os.OpenFile(cachePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, configFilePermissions) + if err != nil { + return err + } + defer file.Close() + + codec := &format.YAMLCodec{} + return codec.Encode(file, cache) +} + func annotateErrorWithSource(filename string, contents []byte, err error) error { if err == nil { return nil diff --git a/internal/config/types.go b/internal/config/types.go index 01c6790e..4452bc58 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -101,6 +101,9 @@ type GrafanaConfig struct { // TLS contains TLS-related configuration settings. TLS *TLS `json:"tls,omitempty" yaml:"tls,omitempty"` + + // OIDC contains OpenID Connect provider settings. + OIDC *OIDCConfig `json:"oidc,omitempty" yaml:"oidc,omitempty"` } func (grafana GrafanaConfig) validateNamespace(contextName string) error { @@ -166,6 +169,63 @@ func (grafana GrafanaConfig) IsEmpty() bool { return grafana == GrafanaConfig{} } +// OIDCConfig contains OpenID Connect provider settings. +type OIDCConfig struct { + // IssuerURL is the OIDC provider's issuer URL (e.g., https://your-org.okta.com). + // Used to discover authorization and token endpoints via .well-known/openid-configuration. + IssuerURL string `env:"GRAFANA_OIDC_ISSUER" json:"issuer-url,omitempty" yaml:"issuer-url,omitempty"` + + // ClientID is the OAuth2 client ID registered with the OIDC provider. + ClientID string `env:"GRAFANA_OIDC_CLIENT_ID" json:"client-id,omitempty" yaml:"client-id,omitempty"` + + // ClientSecret is the OAuth2 client secret. Optional for public clients using PKCE. + ClientSecret string `datapolicy:"secret" env:"GRAFANA_OIDC_CLIENT_SECRET" json:"client-secret,omitempty" yaml:"client-secret,omitempty"` + + // Scopes is a space-separated list of OAuth2 scopes to request. + // Defaults to "openid profile email" if not set. + Scopes string `env:"GRAFANA_OIDC_SCOPES" json:"scopes,omitempty" yaml:"scopes,omitempty"` + + // CallbackPort is a fixed port for the local OIDC callback server during login. + // If 0 or unset, a random available port is used. + // Set this when the IdP requires exact redirect URI matching. + CallbackPort int64 `env:"GRAFANA_OIDC_CALLBACK_PORT" json:"callback-port,omitempty" yaml:"callback-port,omitempty"` +} + +// IsConfigured returns true if OIDC provider settings are present. +func (o *OIDCConfig) IsConfigured() bool { + return o != nil && o.IssuerURL != "" && o.ClientID != "" +} + +// CachedToken holds cached OAuth2 tokens for a context. +type CachedToken struct { + AccessToken string `json:"access-token,omitempty" yaml:"access-token,omitempty"` + RefreshToken string `json:"refresh-token,omitempty" yaml:"refresh-token,omitempty"` + TokenExpiry string `json:"token-expiry,omitempty" yaml:"token-expiry,omitempty"` +} + +// TokenCache maps context names to their cached tokens. +type TokenCache struct { + Contexts map[string]*CachedToken `json:"contexts,omitempty" yaml:"contexts,omitempty"` +} + +// Get returns the cached token for a context, or nil if none exists. +func (tc *TokenCache) Get(contextName string) *CachedToken { + if tc == nil || tc.Contexts == nil { + return nil + } + + return tc.Contexts[contextName] +} + +// Set stores a cached token for a context. +func (tc *TokenCache) Set(contextName string, token *CachedToken) { + if tc.Contexts == nil { + tc.Contexts = make(map[string]*CachedToken) + } + + tc.Contexts[contextName] = token +} + // TLS contains settings to enable transport layer security. type TLS struct { // InsecureSkipTLSVerify disables the validation of the server's SSL certificate. diff --git a/testdata/dex/config.yaml b/testdata/dex/config.yaml new file mode 100644 index 00000000..73a823d7 --- /dev/null +++ b/testdata/dex/config.yaml @@ -0,0 +1,33 @@ +# Dex configuration for grafanactl OIDC integration tests. +# +# Static user credentials: +# Email: admin@example.com +# Password: password + +issuer: http://localhost:5556/dex + +storage: + type: memory + +web: + http: 0.0.0.0:5556 + +oauth2: + skipApprovalScreen: true + +staticClients: + - id: grafanactl-test + name: grafanactl Test Client + redirectURIs: + - http://127.0.0.1:18085/callback + - http://127.0.0.1:8085/callback + public: true + +enablePasswordDB: true + +staticPasswords: + - email: admin@example.com + # bcrypt hash of "password" + hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W" + username: admin + userID: 08a8684b-db88-4b73-90a9-3cd1661f5466