From cb683773258ac434285d5f5801c66558371071b9 Mon Sep 17 00:00:00 2001 From: dlddu <39251873+dlddu@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:01:47 +0000 Subject: [PATCH] feat(auth): OAuth2 Device Authorization Grant login to Grafana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an interactive OAuth2 login so the server can authenticate to Grafana as the user via an identity provider (SSO) instead of a long-lived service account token, keeping only short-lived, per-user, revocable tokens on the machine running the server — no static shared secret. Uses the Device Authorization Grant (RFC 8628): it has no browser redirect or loopback callback, so it works even when the server runs somewhere without a browser (a container, a remote host, over SSH). On the first Grafana request the server performs a device authorization and returns an actionable error naming the verification URL and user code; the user approves in any browser and retries, while the server polls the token endpoint in the background and then sends the access token as `Authorization: Bearer`. - New OAuthConfig (oauth.go) + GRAFANA_OAUTH_CLIENT_ID / _DEVICE_AUTH_URL / _TOKEN_URL / _CLIENT_SECRET / _SCOPES / _AUDIENCE / _TOKEN_CACHE / _AUTH_TIMEOUT env vars. Public client — no secret required. - AuthRoundTripper gains OAuth handling, priority OBO > OAuth > service account token > basic auth. Applied to raw HTTP tool clients (BuildTransport) and the OpenAPI client (transport-level injection, like OBO). - Login is strictly lazy and non-blocking: it only starts on a real tool request. Startup work that authenticates to Grafana (the public-URL fetch and the stdio proxied-tool discovery) runs under WithoutInteractiveOAuth, so it never blocks the MCP handshake; it uses a cached token when present and otherwise degrades gracefully. The device poll runs in a background goroutine, so the tool call returns immediately with instructions. - Access/refresh tokens are cached under the user config dir (0600) and refreshed automatically. OAuth config is read once from the environment and shared by pointer so the cached token is reused across requests. - Promote golang.org/x/oauth2 to a direct dependency. - Tests cover config parsing, the full device flow against a fake IdP (pending → approve → token), silent refresh, the non-interactive startup path, and RoundTripper precedence; README documents the option and scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 50 +++++ cmd/mcp-grafana/main.go | 7 +- go.mod | 2 +- mcpgrafana.go | 66 ++++++- oauth.go | 424 ++++++++++++++++++++++++++++++++++++++++ oauth_test.go | 338 ++++++++++++++++++++++++++++++++ 6 files changed, 874 insertions(+), 13 deletions(-) create mode 100644 oauth.go create mode 100644 oauth_test.go diff --git a/README.md b/README.md index 26f56eff8..8bece1bda 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,56 @@ volumes: Surrounding whitespace (including a trailing newline) is trimmed from the file contents. If both `GRAFANA_SERVICE_ACCOUNT_TOKEN` and `GRAFANA_SERVICE_ACCOUNT_TOKEN_FILE` are set, the inline token takes precedence. +### OAuth2 login (Device Authorization Grant) + +Instead of storing a long-lived service account token on your machine, the server can log you in through your identity provider (Authentik, Keycloak, Okta, Auth0, Azure AD, an OIDC-enabled reverse proxy, etc.) using the OAuth2 [Device Authorization Grant](https://oauth.net/2/device-flow/) (RFC 8628). This keeps only **short-lived, per-user, revocable** tokens on the machine running the server — no static shared secret. + +The device flow has no browser redirect or loopback callback, so it works even when the server runs somewhere without a browser: a container, a remote host, or over SSH. On the first Grafana request the server surfaces a verification URL and a short user code — the tool call returns an error like `sign in to Grafana — open ... (user code ABCD-1234), then retry your request`. Open that URL in any browser, approve, and retry: the server polls the token endpoint in the background and, once approved, sends the access token to Grafana as `Authorization: Bearer `. + +The access token — and a refresh token when the provider issues one — are cached under your user config dir (`~/.config/mcp-grafana/` on Linux, `~/Library/Application Support/mcp-grafana/` on macOS) with `0600` permissions. Expired access tokens are refreshed automatically; the device login is only requested again when there is no valid or refreshable token. + +This is a **public client** flow: no client secret is required (`GRAFANA_OAUTH_CLIENT_SECRET` is optional and only used for confidential clients). + +Set the following environment variables to enable it: + +| Variable | Required | Description | +| --- | --- | --- | +| `GRAFANA_OAUTH_CLIENT_ID` | yes | OAuth2 public client ID. | +| `GRAFANA_OAUTH_DEVICE_AUTH_URL` | yes | Device authorization endpoint (RFC 8628). | +| `GRAFANA_OAUTH_TOKEN_URL` | yes | Token endpoint. | +| `GRAFANA_OAUTH_CLIENT_SECRET` | no | Only for confidential clients; omit for a public client. | +| `GRAFANA_OAUTH_SCOPES` | no | Space- or comma-separated scopes. Default `openid profile email offline_access` (`offline_access` requests a refresh token). | +| `GRAFANA_OAUTH_AUDIENCE` | no | `audience` parameter sent to the device endpoint (required by some providers, e.g. Auth0). | +| `GRAFANA_OAUTH_TOKEN_CACHE` | no | Override the on-disk token cache path. | +| `GRAFANA_OAUTH_AUTH_TIMEOUT` | no | How long a pending device login may wait for approval (Go duration, default `10m`). | + +**Example:** + +```json +{ + "mcpServers": { + "grafana": { + "command": "mcp-grafana", + "args": [], + "env": { + "GRAFANA_URL": "https://grafana.example.com", + "GRAFANA_OAUTH_CLIENT_ID": "", + "GRAFANA_OAUTH_DEVICE_AUTH_URL": "https://auth.example.com/application/o/device/", + "GRAFANA_OAUTH_TOKEN_URL": "https://auth.example.com/application/o/token/" + } + } + } +} +``` + +Notes: + +- `GRAFANA_OAUTH_CLIENT_ID`, `GRAFANA_OAUTH_DEVICE_AUTH_URL` and `GRAFANA_OAUTH_TOKEN_URL` are all required; an incomplete configuration is logged and ignored. +- The provider must have the device authorization (device code) grant enabled for this client. +- OAuth takes precedence over a static `GRAFANA_SERVICE_ACCOUNT_TOKEN` if both are set (a warning is logged). It does **not** override on-behalf-of Grafana Cloud auth (`X-Access-Token`/`X-Grafana-Id`), which still wins when present. +- `GRAFANA_URL` may point either directly at a Grafana that accepts the provider's tokens (`[auth.jwt]` / `[auth.generic_oauth]`) or at a reverse proxy in front of Grafana that validates the bearer token (e.g. oauth2-proxy with `--skip-jwt-bearer-tokens`). +- OAuth currently authenticates the core Grafana API and datasource-proxy tools. The Incident/IRM and OnCall clients still use the static service account token, so keep one configured if you rely on those tools. + ### Multi-Organization Support You can specify which organization to interact with using either: diff --git a/cmd/mcp-grafana/main.go b/cmd/mcp-grafana/main.go index 86581ad1f..222f00127 100644 --- a/cmd/mcp-grafana/main.go +++ b/cmd/mcp-grafana/main.go @@ -524,9 +524,12 @@ func run(transport, addr, basePath, endpointPath string, logLevel slog.Level, dt cf := mcpgrafana.ComposedStdioContextFunc(gc) srv.SetContextFunc(cf) - // For stdio (single-tenant), initialize proxied tools on the server directly + // For stdio (single-tenant), initialize proxied tools on the server directly. + // This discovery makes authenticated Grafana calls; mark the context so it + // never triggers an interactive OAuth browser login at startup. When OAuth + // is in use the login instead happens lazily on the first real tool request. if !dt.proxied { - stdioCtx := cf(ctx) + stdioCtx := mcpgrafana.WithoutInteractiveOAuth(cf(ctx)) if err := tm.InitializeAndRegisterServerTools(stdioCtx); err != nil { slog.Error("failed to initialize proxied tools for stdio", "error", err) } diff --git a/go.mod b/go.mod index f193bb4a9..ed3f17d68 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( go.opentelemetry.io/otel/sdk/log v0.19.0 go.opentelemetry.io/otel/sdk/metric v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.20.0 golang.org/x/tools v0.44.0 gopkg.in/yaml.v3 v3.0.1 @@ -161,7 +162,6 @@ require ( golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/text v0.37.0 // indirect diff --git a/mcpgrafana.go b/mcpgrafana.go index 700380344..3b0ab2e4a 100644 --- a/mcpgrafana.go +++ b/mcpgrafana.go @@ -255,6 +255,15 @@ type GrafanaConfig struct { // It is used for on-behalf-of auth in Grafana Cloud. IDToken string + // OAuth holds optional OAuth2 Authorization-Code + PKCE configuration. When + // set and enabled, the server logs the user in interactively via the browser + // and uses the resulting short-lived bearer token to authenticate to Grafana + // instead of a static service account token. It is populated from the + // GRAFANA_OAUTH_* environment variables. It is a pointer so the cached token + // (and its refresh state) is shared across the copies of GrafanaConfig that + // flow through the context. + OAuth *OAuthConfig + // TLSConfig holds TLS configuration for all Grafana clients. TLSConfig *TLSConfig @@ -557,13 +566,15 @@ func NewExtraHeadersRoundTripper(rt http.RoundTripper, headers map[string]string } // AuthRoundTripper wraps an http.RoundTripper to add authentication headers. -// It supports on-behalf-of (OBO) auth via access/ID tokens, API key bearer -// auth, and HTTP basic auth, in that priority order. +// It supports on-behalf-of (OBO) auth via access/ID tokens, OAuth2 (browser +// PKCE) bearer auth, API key bearer auth, and HTTP basic auth, in that priority +// order. type AuthRoundTripper struct { accessToken string idToken string apiKey string basicAuth *url.Userinfo + oauth *OAuthConfig underlying http.RoundTripper } @@ -571,6 +582,7 @@ func (rt *AuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) clonedReq := req.Clone(req.Context()) accessToken, idToken, apiKey, basicAuth := rt.accessToken, rt.idToken, rt.apiKey, rt.basicAuth + oauth := rt.oauth cfg := GrafanaConfigFromContext(req.Context()) if cfg.AccessToken != "" { accessToken = cfg.AccessToken @@ -584,13 +596,28 @@ func (rt *AuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) if cfg.BasicAuth != nil { basicAuth = cfg.BasicAuth } + if cfg.OAuth.Enabled() { + oauth = cfg.OAuth + } - if accessToken != "" && idToken != "" { + switch { + case accessToken != "" && idToken != "": clonedReq.Header.Set("X-Access-Token", accessToken) clonedReq.Header.Set("X-Grafana-Id", idToken) - } else if apiKey != "" { + case oauth.Enabled(): + // Fetch (or reuse a cached) OAuth2 access token and send it as a bearer + // token. On a real tool request this may trigger an interactive browser + // login; on startup/background work (marked via WithoutInteractiveOAuth) + // it never opens a browser. This takes precedence over a static API key + // so that enabling OAuth is an explicit, unambiguous opt-in. + token, err := oauth.Token(oauthInteractiveAllowed(req.Context())) + if err != nil { + return nil, fmt.Errorf("failed to obtain Grafana OAuth token: %w", err) + } + token.SetAuthHeader(clonedReq) + case apiKey != "": clonedReq.Header.Set("Authorization", "Bearer "+apiKey) - } else if basicAuth != nil { + case basicAuth != nil: password, _ := basicAuth.Password() clonedReq.SetBasicAuth(basicAuth.Username(), password) } @@ -742,7 +769,9 @@ func BuildTransport(cfg *GrafanaConfig, base http.RoundTripper, opts ...Transpor // Auth (innermost header layer — wins on conflicts with ExtraHeaders) if !options.withoutAuth { - transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth) + authRT := NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth) + authRT.oauth = cfg.OAuth + transport = authRT } // Extra headers (always included so per-request context overrides work) @@ -824,13 +853,18 @@ var ExtractGrafanaInfoFromEnv server.StdioContextFunc = func(ctx context.Context } extraHeaders := extraHeadersFromEnv(logger) + oauth := oauthConfigFromEnv(logger) + if oauth.Enabled() && apiKey != "" { + logger.Warn("Both OAuth and a service account token are configured; OAuth takes precedence and the service account token will be ignored.") + } - logger.Info("Using Grafana configuration", "url", parsedURL.Redacted(), "api_key_set", apiKey != "", "basic_auth_set", basicAuth != nil, "org_id", orgID, "extra_headers_count", len(extraHeaders)) + logger.Info("Using Grafana configuration", "url", parsedURL.Redacted(), "api_key_set", apiKey != "", "basic_auth_set", basicAuth != nil, "oauth_set", oauth.Enabled(), "org_id", orgID, "extra_headers_count", len(extraHeaders)) config.URL = u config.APIKey = apiKey config.BasicAuth = basicAuth config.OrgID = orgID config.ExtraHeaders = extraHeaders + config.OAuth = oauth return WithGrafanaConfig(ctx, config) } @@ -855,6 +889,9 @@ var ExtractGrafanaInfoFromHeaders httpContextFunc = func(ctx context.Context, re config.BasicAuth = basicAuth config.OrgID = orgID config.ExtraHeaders = mergeHeaders(extraHeadersFromEnv(logger), forwardedHeadersFromRequest(req)) + // OAuth is configured process-wide via GRAFANA_OAUTH_* env vars (not per + // request), so it applies as a fallback the same way in header mode. + config.OAuth = oauthConfigFromEnv(logger) return WithGrafanaConfig(ctx, config) } @@ -1201,12 +1238,15 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string, auth *url. } } // Use BuildTransport but skip APIKey/BasicAuth auth - // (handled by the OpenAPI client). OBO tokens still need - // transport-level injection since the OpenAPI client - // doesn't support them natively. + // (handled by the OpenAPI client). OBO tokens and OAuth + // bearer tokens still need transport-level injection since + // the OpenAPI client doesn't support them natively: OAuth + // tokens are short-lived and refreshed per request, so they + // can't be set as the client's static APIKey. oboConfig := GrafanaConfig{ AccessToken: config.AccessToken, IDToken: config.IDToken, + OAuth: config.OAuth, OrgID: config.OrgID, TLSConfig: config.TLSConfig, ExtraHeaders: config.ExtraHeaders, @@ -1229,6 +1269,12 @@ func NewGrafanaClient(ctx context.Context, grafanaURL, apiKey string, auth *url. } // Fetch the public URL from Grafana's frontend settings. + // Note: OAuth is deliberately omitted here. This fetch runs at client + // creation time (server startup), and triggering an interactive browser + // login for a best-effort, non-critical deep-link lookup would be + // surprising. When OAuth is the only credential this fetch is unauthenticated + // and may fail; that's fine (public URL is optional). The interactive login + // happens lazily on the first real Grafana tool request instead. fetchCfg := &GrafanaConfig{ URL: grafanaURL, APIKey: apiKey, diff --git a/oauth.go b/oauth.go new file mode 100644 index 000000000..26f4ce858 --- /dev/null +++ b/oauth.go @@ -0,0 +1,424 @@ +package mcpgrafana + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // grafanaOAuthClientIDEnvVar is the OAuth2 client ID of the public client + // used for the Device Authorization Grant login. + grafanaOAuthClientIDEnvVar = "GRAFANA_OAUTH_CLIENT_ID" + // grafanaOAuthClientSecretEnvVar is an optional client secret. Leave it unset + // for a public client (the recommended, secret-less setup). + grafanaOAuthClientSecretEnvVar = "GRAFANA_OAUTH_CLIENT_SECRET" + // grafanaOAuthDeviceAuthURLEnvVar is the OAuth2 device authorization endpoint + // (RFC 8628), e.g. https://idp/application/o/device/. + grafanaOAuthDeviceAuthURLEnvVar = "GRAFANA_OAUTH_DEVICE_AUTH_URL" + // grafanaOAuthTokenURLEnvVar is the OAuth2 token endpoint. + grafanaOAuthTokenURLEnvVar = "GRAFANA_OAUTH_TOKEN_URL" + // grafanaOAuthScopesEnvVar is a comma- or space-separated list of scopes. + grafanaOAuthScopesEnvVar = "GRAFANA_OAUTH_SCOPES" + // grafanaOAuthAudienceEnvVar is an optional audience parameter for the token. + grafanaOAuthAudienceEnvVar = "GRAFANA_OAUTH_AUDIENCE" + // grafanaOAuthTokenCacheEnvVar overrides the on-disk token cache path. + grafanaOAuthTokenCacheEnvVar = "GRAFANA_OAUTH_TOKEN_CACHE" + // grafanaOAuthAuthTimeoutEnvVar bounds how long to wait for device approval + // (Go duration). + grafanaOAuthAuthTimeoutEnvVar = "GRAFANA_OAUTH_AUTH_TIMEOUT" + + defaultOAuthAuthTimeout = 10 * time.Minute + oauthRefreshTimeout = 30 * time.Second +) + +// defaultOAuthScopes requests an ID token plus a refresh token (offline_access) +// so that expired access tokens can be refreshed silently without another +// device login. Override with GRAFANA_OAUTH_SCOPES. +var defaultOAuthScopes = []string{"openid", "profile", "email", "offline_access"} + +// OAuthConfig holds the OAuth2 Device Authorization Grant (RFC 8628) settings +// used to authenticate to Grafana as the interactive user, in place of a +// long-lived service account token. +// +// Because the device flow has no redirect/callback, it works even when the MCP +// server runs somewhere without a browser (a container, a remote host, over +// SSH): on the first Grafana request the server surfaces a verification URL and +// a short user code, the user approves it in any browser, and the server polls +// the token endpoint in the background. Only short-lived, per-user, revocable +// tokens ever live on the machine running the server — no static shared secret. +// +// The access token and (when issued) refresh token are cached on disk so tokens +// survive restarts; expired access tokens are refreshed automatically. An +// *OAuthConfig is shared by pointer across the copies of GrafanaConfig that flow +// through the request context, so the cached token is reused process-wide. +type OAuthConfig struct { + // ClientID is the public OAuth2 client identifier. + ClientID string + // ClientSecret is optional; leave empty for a public client. + ClientSecret string + // DeviceAuthURL is the OAuth2 device authorization endpoint (RFC 8628). + DeviceAuthURL string + // TokenURL is the OAuth2 token endpoint. + TokenURL string + // Scopes are the OAuth2 scopes to request. + Scopes []string + // Audience, when set, is sent as the `audience` parameter to the device + // authorization endpoint (required by some providers, e.g. Auth0). + Audience string + // CachePath is the file the token is persisted to. When empty a per-config + // path under the user config dir is used. + CachePath string + // AuthTimeout bounds how long a device login may stay pending. + AuthTimeout time.Duration + + logger *slog.Logger + + mu sync.Mutex + cur *oauth2.Token + loaded bool + pending *deviceFlow +} + +// deviceFlow tracks an in-progress device authorization. It is created when a +// tool request first needs a token and cleared once the login completes (so a +// subsequent failure can start a fresh flow). +type deviceFlow struct { + userCode string + verificationURI string + verificationURIComplete string + done bool + err error +} + +// Enabled reports whether the config carries the fields required to perform the +// device login. A nil receiver is not enabled, which lets callers write +// cfg.OAuth.Enabled() without a nil check. +func (c *OAuthConfig) Enabled() bool { + return c != nil && c.ClientID != "" && c.DeviceAuthURL != "" && c.TokenURL != "" +} + +// noInteractiveOAuthKey marks a context in which the OAuth flow must not start +// an interactive device login (e.g. server-startup work such as proxied-tool +// discovery or the public-URL fetch). Such contexts still use a cached or +// refreshable token, but never begin a login that needs a human. +type noInteractiveOAuthKey struct{} + +// WithoutInteractiveOAuth returns a context in which OAuthConfig.Token will not +// start an interactive device login. Use it for background/startup work so the +// login happens lazily on the first real tool request instead. +func WithoutInteractiveOAuth(ctx context.Context) context.Context { + return context.WithValue(ctx, noInteractiveOAuthKey{}, true) +} + +// oauthInteractiveAllowed reports whether interactive login is permitted for the +// context. It defaults to true; WithoutInteractiveOAuth opts out. +func oauthInteractiveAllowed(ctx context.Context) bool { + disabled, _ := ctx.Value(noInteractiveOAuthKey{}).(bool) + return !disabled +} + +// Token returns a valid OAuth2 access token, refreshing a cached token when +// possible. When allowInteractive is true and no valid or refreshable token +// exists, it starts (or continues) a device login and returns an actionable +// error telling the user which URL to open and which code to enter; the token +// is fetched in the background, so a retry after approval succeeds. When +// allowInteractive is false it returns an error instead of starting a login. It +// is safe for concurrent use. +func (c *OAuthConfig) Token(allowInteractive bool) (*oauth2.Token, error) { + if !c.Enabled() { + return nil, fmt.Errorf("grafana OAuth is not configured") + } + c.mu.Lock() + defer c.mu.Unlock() + + if !c.loaded { + c.cur = c.load() + c.loaded = true + } + + // Reuse a still-valid access token. + if c.cur.Valid() { + return c.cur, nil + } + + // Try to refresh using a stored refresh token before falling back to login. + if c.cur != nil && c.cur.RefreshToken != "" { + if tok, err := c.refresh(c.cur); err == nil { + c.storeLocked(tok) + return tok, nil + } else if !allowInteractive { + return nil, fmt.Errorf("failed to refresh Grafana OAuth token: %w", err) + } else { + c.log().Warn("Grafana OAuth token refresh failed, starting a new device login", "error", err) + } + } + + if !allowInteractive { + return nil, fmt.Errorf("no valid Grafana OAuth token is cached; a device login is required and will start on the first Grafana tool request") + } + + // A device login is already in progress. + if p := c.pending; p != nil { + if !p.done { + return nil, p.instructionsErr() + } + // The background poller finished. On success c.cur was populated; + // clear the pending flow either way so a failure can be retried. + perr := p.err + c.pending = nil + if c.cur.Valid() { + return c.cur, nil + } + if perr != nil { + return nil, perr + } + } + + // Start a new device login and return instructions. + p, err := c.startDeviceFlow() + if err != nil { + return nil, err + } + c.pending = p + return nil, p.instructionsErr() +} + +func (c *OAuthConfig) oauth2Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Endpoint: oauth2.Endpoint{ + DeviceAuthURL: c.DeviceAuthURL, + TokenURL: c.TokenURL, + AuthStyle: oauth2.AuthStyleAutoDetect, + }, + Scopes: c.Scopes, + } +} + +func (c *OAuthConfig) authTimeout() time.Duration { + if c.AuthTimeout > 0 { + return c.AuthTimeout + } + return defaultOAuthAuthTimeout +} + +func (c *OAuthConfig) log() *slog.Logger { + if c.logger != nil { + return c.logger + } + return slog.New(slog.DiscardHandler) +} + +func (c *OAuthConfig) audienceOptions() []oauth2.AuthCodeOption { + if c.Audience == "" { + return nil + } + return []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("audience", c.Audience)} +} + +// refresh exchanges a stored refresh token for a fresh access token. +func (c *OAuthConfig) refresh(old *oauth2.Token) (*oauth2.Token, error) { + ctx, cancel := context.WithTimeout(context.Background(), oauthRefreshTimeout) + defer cancel() + return c.oauth2Config().TokenSource(ctx, old).Token() +} + +// startDeviceFlow initiates a device authorization request and kicks off a +// background goroutine that polls the token endpoint until the user approves. +// It must be called with c.mu held. It returns quickly with the verification +// details; it never blocks on the human. +func (c *OAuthConfig) startDeviceFlow() (*deviceFlow, error) { + conf := c.oauth2Config() + ctx, cancel := context.WithTimeout(context.Background(), c.authTimeout()) + + da, err := conf.DeviceAuth(ctx, c.audienceOptions()...) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to start Grafana OAuth device authorization: %w", err) + } + + p := &deviceFlow{ + userCode: da.UserCode, + verificationURI: da.VerificationURI, + verificationURIComplete: da.VerificationURIComplete, + } + + c.log().Info("Grafana OAuth device login required", + "verification_uri", da.VerificationURI, "user_code", da.UserCode) + + go func() { + defer cancel() + tok, err := conf.DeviceAccessToken(ctx, da) + + c.mu.Lock() + defer c.mu.Unlock() + if err != nil { + p.err = fmt.Errorf("device login did not complete: %w", err) + } else { + c.storeLocked(tok) + c.log().Info("Grafana OAuth device login completed") + } + p.done = true + }() + + return p, nil +} + +// instructionsErr returns the actionable error shown to the user for a pending +// device login. +func (p *deviceFlow) instructionsErr() error { + if p.verificationURIComplete != "" { + return fmt.Errorf("sign in to Grafana — open %s in a browser to approve "+ + "(user code %s), then retry your request", p.verificationURIComplete, p.userCode) + } + return fmt.Errorf("sign in to Grafana — open %s in a browser and enter code %s, "+ + "then retry your request", p.verificationURI, p.userCode) +} + +// storeLocked updates the in-memory token and persists it to disk (best effort). +// It must be called with c.mu held. +func (c *OAuthConfig) storeLocked(tok *oauth2.Token) { + c.cur = tok + if err := c.save(tok); err != nil { + c.log().Warn("Failed to persist Grafana OAuth token cache", "error", err) + } +} + +func (c *OAuthConfig) cachePath() string { + if c.CachePath != "" { + return c.CachePath + } + dir, err := os.UserConfigDir() + if err != nil || dir == "" { + return "" + } + sum := sha256.Sum256([]byte(c.ClientID + "|" + c.DeviceAuthURL + "|" + c.TokenURL)) + name := "token-" + hex.EncodeToString(sum[:6]) + ".json" + return filepath.Join(dir, "mcp-grafana", name) +} + +func (c *OAuthConfig) save(tok *oauth2.Token) error { + path := c.cachePath() + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.Marshal(tok) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func (c *OAuthConfig) load() *oauth2.Token { + path := c.cachePath() + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var tok oauth2.Token + if err := json.Unmarshal(data, &tok); err != nil { + return nil + } + if tok.AccessToken == "" && tok.RefreshToken == "" { + return nil + } + return &tok +} + +// oauthConfigFromEnv builds an OAuthConfig from the GRAFANA_OAUTH_* environment +// variables, returning nil when OAuth is not configured. The result is cached: +// the environment is read once for the lifetime of the process (it does not +// change), which keeps the cached token stable across the per-request header +// context funcs used by the HTTP and SSE transports. +func oauthConfigFromEnv(logger *slog.Logger) *OAuthConfig { + envOAuthOnce.Do(func() { + envOAuthConfig = buildOAuthConfigFromEnv(logger) + }) + return envOAuthConfig +} + +var ( + envOAuthOnce sync.Once + envOAuthConfig *OAuthConfig +) + +// buildOAuthConfigFromEnv reads the GRAFANA_OAUTH_* environment variables into +// an OAuthConfig. It returns nil when no OAuth settings are present, or when the +// settings are incomplete (logging a warning), so a nil result always means +// "OAuth disabled". +func buildOAuthConfigFromEnv(logger *slog.Logger) *OAuthConfig { + clientID := strings.TrimSpace(os.Getenv(grafanaOAuthClientIDEnvVar)) + deviceAuthURL := strings.TrimSpace(os.Getenv(grafanaOAuthDeviceAuthURLEnvVar)) + tokenURL := strings.TrimSpace(os.Getenv(grafanaOAuthTokenURLEnvVar)) + clientSecret := os.Getenv(grafanaOAuthClientSecretEnvVar) + + // Nothing configured: OAuth is simply disabled. + if clientID == "" && deviceAuthURL == "" && tokenURL == "" && clientSecret == "" { + return nil + } + + scopes := parseOAuthScopes(os.Getenv(grafanaOAuthScopesEnvVar)) + if scopes == nil { + scopes = append([]string(nil), defaultOAuthScopes...) + } + + cfg := &OAuthConfig{ + ClientID: clientID, + ClientSecret: clientSecret, + DeviceAuthURL: deviceAuthURL, + TokenURL: tokenURL, + Scopes: scopes, + Audience: strings.TrimSpace(os.Getenv(grafanaOAuthAudienceEnvVar)), + CachePath: strings.TrimSpace(os.Getenv(grafanaOAuthTokenCacheEnvVar)), + logger: logger, + } + if raw := strings.TrimSpace(os.Getenv(grafanaOAuthAuthTimeoutEnvVar)); raw != "" { + if d, err := time.ParseDuration(raw); err == nil { + cfg.AuthTimeout = d + } else { + logger.Warn("Invalid GRAFANA_OAUTH_AUTH_TIMEOUT, using default", "value", raw, "error", err) + } + } + + if !cfg.Enabled() { + logger.Warn("Incomplete Grafana OAuth configuration, ignoring it. "+ + grafanaOAuthClientIDEnvVar+", "+grafanaOAuthDeviceAuthURLEnvVar+" and "+ + grafanaOAuthTokenURLEnvVar+" are all required to enable OAuth login.", + "client_id_set", clientID != "", + "device_auth_url_set", deviceAuthURL != "", + "token_url_set", tokenURL != "") + return nil + } + return cfg +} + +// parseOAuthScopes splits a scope string on commas and whitespace, dropping +// empty entries. OAuth providers expect space-delimited scopes; commas are also +// accepted for consistency with other list-style env vars in this project. +func parseOAuthScopes(raw string) []string { + fields := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + if len(fields) == 0 { + return nil + } + return fields +} diff --git a/oauth_test.go b/oauth_test.go new file mode 100644 index 000000000..0fdf16f0f --- /dev/null +++ b/oauth_test.go @@ -0,0 +1,338 @@ +package mcpgrafana + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.DiscardHandler) +} + +// fakeIdP is a minimal OAuth2 identity provider implementing the Device +// Authorization Grant (RFC 8628) and refresh_token grants, for exercising the +// login flow end to end without a real browser or IdP. +type fakeIdP struct { + srv *httptest.Server + mu sync.Mutex + approved bool + deviceCalls int + pollCalls int +} + +func newFakeIdP(t *testing.T) *fakeIdP { + t.Helper() + idp := &fakeIdP{} + mux := http.NewServeMux() + + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.PostFormValue("client_id") == "" { + http.Error(w, "missing client_id", http.StatusBadRequest) + return + } + idp.mu.Lock() + idp.deviceCalls++ + idp.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"device_code":"dev-code-123","user_code":"WXYZ-1234",`+ + `"verification_uri":%q,"verification_uri_complete":%q,"expires_in":600,"interval":1}`, + idp.srv.URL+"/device/verify", idp.srv.URL+"/device/verify?code=WXYZ-1234") + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + switch r.PostFormValue("grant_type") { + case "urn:ietf:params:oauth:grant-type:device_code": + idp.mu.Lock() + idp.pollCalls++ + approved := idp.approved + idp.mu.Unlock() + if !approved { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = fmt.Fprint(w, `{"error":"authorization_pending"}`) + return + } + writeTokenResponse(w, "access-1", "refresh-1", 3600) + case "refresh_token": + if r.PostFormValue("refresh_token") == "" { + http.Error(w, "missing refresh_token", http.StatusBadRequest) + return + } + writeTokenResponse(w, "access-refreshed", "refresh-2", 3600) + default: + http.Error(w, "unsupported grant_type", http.StatusBadRequest) + } + }) + + idp.srv = httptest.NewServer(mux) + t.Cleanup(idp.srv.Close) + return idp +} + +func (idp *fakeIdP) approve() { + idp.mu.Lock() + idp.approved = true + idp.mu.Unlock() +} + +func (idp *fakeIdP) deviceCallCount() int { + idp.mu.Lock() + defer idp.mu.Unlock() + return idp.deviceCalls +} + +func (idp *fakeIdP) deviceAuthURL() string { return idp.srv.URL + "/device" } +func (idp *fakeIdP) tokenURL() string { return idp.srv.URL + "/token" } + +func writeTokenResponse(w http.ResponseWriter, access, refresh string, expiresIn int) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"access_token":%q,"token_type":"Bearer","refresh_token":%q,"expires_in":%d}`, + access, refresh, expiresIn) +} + +func writeTokenFile(t *testing.T, path string, tok *oauth2.Token) { + t.Helper() + data, err := json.Marshal(tok) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) +} + +func TestOAuthConfigEnabled(t *testing.T) { + var nilCfg *OAuthConfig + assert.False(t, nilCfg.Enabled()) + assert.False(t, (&OAuthConfig{}).Enabled()) + assert.False(t, (&OAuthConfig{ClientID: "id"}).Enabled()) + assert.False(t, (&OAuthConfig{ClientID: "id", DeviceAuthURL: "https://x/device"}).Enabled()) + assert.True(t, (&OAuthConfig{ClientID: "id", DeviceAuthURL: "https://x/device", TokenURL: "https://x/token"}).Enabled()) +} + +func TestParseOAuthScopes(t *testing.T) { + assert.Nil(t, parseOAuthScopes("")) + assert.Nil(t, parseOAuthScopes(" ")) + assert.Equal(t, []string{"openid"}, parseOAuthScopes("openid")) + assert.Equal(t, []string{"openid", "profile", "email"}, parseOAuthScopes("openid profile email")) + assert.Equal(t, []string{"openid", "profile", "email"}, parseOAuthScopes("openid,profile,email")) + assert.Equal(t, []string{"openid", "profile"}, parseOAuthScopes(" openid , profile ")) +} + +func TestBuildOAuthConfigFromEnv(t *testing.T) { + logger := discardLogger() + + t.Run("returns nil when nothing configured", func(t *testing.T) { + t.Setenv(grafanaOAuthClientIDEnvVar, "") + t.Setenv(grafanaOAuthDeviceAuthURLEnvVar, "") + t.Setenv(grafanaOAuthTokenURLEnvVar, "") + t.Setenv(grafanaOAuthClientSecretEnvVar, "") + assert.Nil(t, buildOAuthConfigFromEnv(logger)) + }) + + t.Run("returns nil and warns when incomplete", func(t *testing.T) { + t.Setenv(grafanaOAuthClientIDEnvVar, "id") + t.Setenv(grafanaOAuthDeviceAuthURLEnvVar, "") + t.Setenv(grafanaOAuthTokenURLEnvVar, "") + t.Setenv(grafanaOAuthClientSecretEnvVar, "") + assert.Nil(t, buildOAuthConfigFromEnv(logger)) + }) + + t.Run("builds full config with defaults", func(t *testing.T) { + t.Setenv(grafanaOAuthClientIDEnvVar, "grafana") + t.Setenv(grafanaOAuthDeviceAuthURLEnvVar, "https://idp/device") + t.Setenv(grafanaOAuthTokenURLEnvVar, "https://idp/token") + t.Setenv(grafanaOAuthClientSecretEnvVar, "") + t.Setenv(grafanaOAuthScopesEnvVar, "") + t.Setenv(grafanaOAuthAudienceEnvVar, "grafana") + + cfg := buildOAuthConfigFromEnv(logger) + require.NotNil(t, cfg) + assert.True(t, cfg.Enabled()) + assert.Equal(t, "grafana", cfg.ClientID) + assert.Empty(t, cfg.ClientSecret) + assert.Equal(t, "https://idp/device", cfg.DeviceAuthURL) + assert.Equal(t, defaultOAuthScopes, cfg.Scopes) + assert.Equal(t, "grafana", cfg.Audience) + }) +} + +func TestOAuthDeviceFlow(t *testing.T) { + idp := newFakeIdP(t) + cachePath := filepath.Join(t.TempDir(), "token.json") + cfg := &OAuthConfig{ + ClientID: "grafana", + DeviceAuthURL: idp.deviceAuthURL(), + TokenURL: idp.tokenURL(), + Scopes: []string{"openid"}, + CachePath: cachePath, + AuthTimeout: 30 * time.Second, + } + + // First call starts the device flow and returns actionable instructions + // containing the verification URL and user code. + _, err := cfg.Token(true) + require.Error(t, err) + assert.Contains(t, err.Error(), "WXYZ-1234") + assert.Contains(t, err.Error(), "/device/verify") + + // While the user hasn't approved yet, repeated calls keep returning + // instructions and do NOT start a second device authorization. + _, err = cfg.Token(true) + require.Error(t, err) + assert.Equal(t, 1, idp.deviceCallCount()) + + // Simulate the user approving in their browser. + idp.approve() + + // The background poller obtains and caches the token; a retry then succeeds. + require.Eventually(t, func() bool { + tok, err := cfg.Token(true) + return err == nil && tok != nil && tok.AccessToken == "access-1" + }, 10*time.Second, 100*time.Millisecond) + + assert.FileExists(t, cachePath) +} + +func TestOAuthTokenNonInteractive(t *testing.T) { + idp := newFakeIdP(t) + + t.Run("errors without starting a device flow when no token is cached", func(t *testing.T) { + cfg := &OAuthConfig{ + ClientID: "grafana", + DeviceAuthURL: idp.deviceAuthURL(), + TokenURL: idp.tokenURL(), + CachePath: filepath.Join(t.TempDir(), "token.json"), + } + _, err := cfg.Token(false) + require.Error(t, err) + assert.Equal(t, 0, idp.deviceCallCount(), "no device authorization should be started") + assert.Nil(t, cfg.pending) + }) + + t.Run("returns a valid cached token without any network call", func(t *testing.T) { + cachePath := filepath.Join(t.TempDir(), "token.json") + writeTokenFile(t, cachePath, &oauth2.Token{ + AccessToken: "cached", TokenType: "Bearer", Expiry: time.Now().Add(time.Hour), + }) + cfg := &OAuthConfig{ + ClientID: "grafana", + DeviceAuthURL: idp.deviceAuthURL(), + TokenURL: idp.tokenURL(), + CachePath: cachePath, + } + tok, err := cfg.Token(false) + require.NoError(t, err) + assert.Equal(t, "cached", tok.AccessToken) + }) +} + +func TestOAuthLoadsAndRefreshesCachedToken(t *testing.T) { + idp := newFakeIdP(t) + cachePath := filepath.Join(t.TempDir(), "token.json") + // Seed an expired access token that still has a refresh token. + writeTokenFile(t, cachePath, &oauth2.Token{ + AccessToken: "old", + TokenType: "Bearer", + RefreshToken: "refresh-1", + Expiry: time.Now().Add(-time.Hour), + }) + + cfg := &OAuthConfig{ + ClientID: "grafana", + DeviceAuthURL: idp.deviceAuthURL(), + TokenURL: idp.tokenURL(), + CachePath: cachePath, + } + + tok, err := cfg.Token(true) + require.NoError(t, err) + assert.Equal(t, "access-refreshed", tok.AccessToken) + assert.Equal(t, 0, idp.deviceCallCount(), "refresh must not trigger a device login") + + reloaded := cfg.load() + require.NotNil(t, reloaded) + assert.Equal(t, "refresh-2", reloaded.RefreshToken) +} + +func TestOAuthConfigTokenNotConfigured(t *testing.T) { + _, err := (&OAuthConfig{}).Token(true) + require.Error(t, err) +} + +func TestOAuthInteractiveAllowedContext(t *testing.T) { + assert.True(t, oauthInteractiveAllowed(context.Background())) + assert.False(t, oauthInteractiveAllowed(WithoutInteractiveOAuth(context.Background()))) +} + +func TestAuthRoundTripperOAuth(t *testing.T) { + newMock := func(captured **http.Request) *capturingMockRT { + return &capturingMockRT{fn: func(req *http.Request) (*http.Response, error) { + *captured = req + return &http.Response{StatusCode: 200}, nil + }} + } + + // Pre-seed a valid cached token so RoundTrip needs no device login/network. + seedValid := func(t *testing.T) *OAuthConfig { + cachePath := filepath.Join(t.TempDir(), "token.json") + writeTokenFile(t, cachePath, &oauth2.Token{ + AccessToken: "bearer-tok", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + }) + return &OAuthConfig{ + ClientID: "id", + DeviceAuthURL: "https://idp/device", + TokenURL: "https://idp/token", + CachePath: cachePath, + } + } + + t.Run("sets bearer token from oauth", func(t *testing.T) { + var captured *http.Request + rt := NewAuthRoundTripper(newMock(&captured), "", "", "", nil) + rt.oauth = seedValid(t) + + req, _ := http.NewRequest("GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-tok", captured.Header.Get("Authorization")) + }) + + t.Run("oauth takes precedence over static api key", func(t *testing.T) { + var captured *http.Request + rt := NewAuthRoundTripper(newMock(&captured), "", "", "static-key", nil) + rt.oauth = seedValid(t) + + req, _ := http.NewRequest("GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, "Bearer bearer-tok", captured.Header.Get("Authorization")) + }) + + t.Run("obo tokens take precedence over oauth", func(t *testing.T) { + var captured *http.Request + // An OAuth config whose Token() would fail if ever called (no server). + rt := NewAuthRoundTripper(newMock(&captured), "access-tok", "id-tok", "", nil) + rt.oauth = &OAuthConfig{ClientID: "id", DeviceAuthURL: "https://idp/device", TokenURL: "https://idp/token"} + + req, _ := http.NewRequest("GET", "http://example.com", nil) + _, err := rt.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, "access-tok", captured.Header.Get("X-Access-Token")) + assert.Equal(t, "id-tok", captured.Header.Get("X-Grafana-Id")) + assert.Empty(t, captured.Header.Get("Authorization")) + }) +}