-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): OAuth2 Device Authorization Grant login to Grafana #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,20 +566,23 @@ 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 | ||
| } | ||
|
|
||
| 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) | ||
|
Comment on lines
+892
to
+894
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# OAuth가 HTTP/SSE(헤더) 전송 모드와 함께 사용될 수 있는지, 관련 가드/문서 확인
rg -nP -C3 'oauthConfigFromEnv|WithoutInteractiveOAuth|ExtractGrafanaInfoFromHeaders' --type go
rg -nP -C3 'ComposedHTTPContextFunc|ComposedSSEContextFunc' --type goRepository: Buzzvil/mcp-grafana Length of output: 157 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== files ==\n'
git ls-files | rg '(^|/)(mcpgrafana\.go|oauth\.go|.*grafana.*\.go|.*oauth.*\.go|.*transport.*\.go|.*context.*\.go)$' || true
printf '\n== locate target file ==\n'
fd -a 'mcpgrafana.go' . || true
printf '\n== outline target if found ==\n'
if [ -f mcpgrafana.go ]; then
ast-grep outline mcpgrafana.go --view expanded || true
fi
printf '\n== search relevant symbols ==\n'
rg -n -C3 'oauthConfigFromEnv|WithoutInteractiveOAuth|ExtractGrafanaInfoFromHeaders|ComposedHTTPContextFunc|ComposedSSEContextFunc|envOAuthOnce|envOAuthConfig|OAuthConfig' . --glob '*.go' || trueRepository: Buzzvil/mcp-grafana Length of output: 43403 헤더 모드에서 OAuth를 요청별로 분리하세요. 🤖 Prompt for AI Agents |
||
| 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
주석의 인증 방식 설명이 실제 구현과 불일치합니다.
주석은 "OAuth2 Authorization-Code + PKCE ... 브라우저 리다이렉트/콜백"을 전제로 설명하지만,
oauth.go의 실제 구현은 리다이렉트/콜백이 없는 OAuth2 Device Authorization Grant(RFC 8628)입니다(oauth.goLine 50-59 참조). Line 569-571의AuthRoundTripper주석(“OAuth2 (browser PKCE)”)에도 동일한 불일치가 있습니다. 향후 유지보수 혼란을 줄이도록 device flow로 통일해 주세요.🤖 Prompt for AI Agents