feat(auth): OAuth2 Device Authorization Grant login to Grafana - #2
Conversation
WalkthroughGrafana 인증에 OAuth2 디바이스 승인 그랜트(RFC 8628)를 신규 도입했습니다. oauth.go에 OAuthConfig 타입, 토큰 캐시/자동 갱신, 인터랙티브 디바이스 로그인 흐름, 컨텍스트 기반 인터랙티브 회피 옵션을 추가했습니다. GrafanaConfig와 AuthRoundTripper가 OAuth를 인식하도록 확장되어 정적 서비스 계정 토큰보다 우선 적용되며, 환경/헤더 기반 설정 추출에서도 OAuth 구성을 로드합니다. main.go의 stdio 초기화 경로는 인터랙티브 로그인을 지연시키도록 컨텍스트를 조정했습니다. go.mod 의존성, README 문서, 관련 테스트가 함께 추가/갱신되었습니다. Estimated code review effort: 4 (Complex) | ~60 minutes Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant AuthRoundTripper
participant OAuthConfig
participant IdP
participant GrafanaAPI
Client->>AuthRoundTripper: RoundTrip(request)
AuthRoundTripper->>AuthRoundTripper: access/id 토큰 헤더 설정
AuthRoundTripper->>OAuthConfig: OAuth.Enabled() 확인
alt OAuth 활성화
AuthRoundTripper->>OAuthConfig: Token(allowInteractive)
OAuthConfig->>OAuthConfig: 캐시/리프레시 확인
alt 캐시/리프레시 성공
OAuthConfig-->>AuthRoundTripper: 토큰 반환
else 인터랙티브 필요
OAuthConfig->>IdP: startDeviceFlow()
IdP-->>OAuthConfig: 검증 URL/유저 코드
OAuthConfig-->>AuthRoundTripper: 안내 에러
end
AuthRoundTripper->>AuthRoundTripper: SetAuthHeader(Authorization)
else OAuth 비활성화
AuthRoundTripper->>AuthRoundTripper: API key/basic auth 적용
end
AuthRoundTripper->>GrafanaAPI: 요청 전송
Related issues: 명시된 이슈 없음 Related PRs: 명시된 관련 PR 없음 Suggested labels: enhancement, authentication, documentation Suggested reviewers: 저장소 메인테이너 🐰 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
1cd5e92 to
76dd754
Compare
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) <noreply@anthropic.com>
76dd754 to
cb68377
Compare
요약이 PR은 정적 서비스 계정 토큰 대신 OAuth2 Device Authorization Grant(RFC 8628)로 Grafana에 로그인하는 기능을 추가합니다. 발견된 이슈🔴 심각 — HTTP/SSE 멀티유저 모드에서 OAuth 토큰이 프로세스 전역으로 공유됨
🟡 주의 —
|
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@mcpgrafana.go`:
- Around line 258-265: Update the comments on OAuthConfig and AuthRoundTripper
to match the actual authentication flow implemented in oauth.go: this is OAuth2
Device Authorization Grant (RFC 8628), not Authorization-Code + PKCE or browser
redirect/callback. Keep the wording consistent wherever these symbols are
documented so the comments describe device flow behavior and token acquisition
accurately.
- Around line 892-894: The header-mode fallback currently reuses the
process-wide OAuthConfig from oauthConfigFromEnv(logger), which can leak cached
tokens and login state across requests. Update the header-mode path in
mcpgrafana.go so OAuth is either explicitly disabled there or constructed per
request with isolation, and ensure WithoutInteractiveOAuth is applied
consistently before assigning config.OAuth. Refer to the OAuthConfig setup in
the header-mode handling logic to make the separation clear.
In `@oauth.go`:
- Around line 242-277: `startDeviceFlow()` is holding `c.mu` while calling
`conf.DeviceAuth()`, which blocks other `Token()` calls for the duration of the
IdP request. Move the `DeviceAuth()` call outside the locked section, and keep
`c.mu` only around shared-state updates like `c.pending` and `c.cur` in the
`OAuthConfig` flow. Use the `startDeviceFlow`, `Token`, and `c.mu` symbols to
refactor the lock scope so the slow network call does not serialize unrelated
token requests.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bce29b79-4296-4cf1-ad19-3b989800a441
📒 Files selected for processing (6)
README.mdcmd/mcp-grafana/main.gogo.modmcpgrafana.gooauth.gooauth_test.go
| // 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 |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
주석의 인증 방식 설명이 실제 구현과 불일치합니다.
주석은 "OAuth2 Authorization-Code + PKCE ... 브라우저 리다이렉트/콜백"을 전제로 설명하지만, oauth.go의 실제 구현은 리다이렉트/콜백이 없는 OAuth2 Device Authorization Grant(RFC 8628)입니다(oauth.go Line 50-59 참조). Line 569-571의 AuthRoundTripper 주석(“OAuth2 (browser PKCE)”)에도 동일한 불일치가 있습니다. 향후 유지보수 혼란을 줄이도록 device flow로 통일해 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@mcpgrafana.go` around lines 258 - 265, Update the comments on OAuthConfig and
AuthRoundTripper to match the actual authentication flow implemented in
oauth.go: this is OAuth2 Device Authorization Grant (RFC 8628), not
Authorization-Code + PKCE or browser redirect/callback. Keep the wording
consistent wherever these symbols are documented so the comments describe device
flow behavior and token acquisition accurately.
| // 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) |
There was a problem hiding this comment.
🔒 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를 요청별로 분리하세요. mcpgrafana.go:892-894에서 oauthConfigFromEnv(logger)가 OAuthConfig 포인터와 캐시된 토큰을 프로세스 전역으로 재사용합니다. 이 경로는 HTTP/SSE 헤더 모드에도 그대로 들어가고 WithoutInteractiveOAuth도 적용되지 않으므로, 멀티테넌트 환경에서는 한 요청의 로그인 상태가 다른 요청에 영향을 줄 수 있습니다. 헤더 모드에서 OAuth를 허용할 의도라면 요청별 격리 또는 비활성화 기준을 분리해 주세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@mcpgrafana.go` around lines 892 - 894, The header-mode fallback currently
reuses the process-wide OAuthConfig from oauthConfigFromEnv(logger), which can
leak cached tokens and login state across requests. Update the header-mode path
in mcpgrafana.go so OAuth is either explicitly disabled there or constructed per
request with isolation, and ensure WithoutInteractiveOAuth is applied
consistently before assigning config.OAuth. Refer to the OAuthConfig setup in
the header-mode handling logic to make the separation clear.
| 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 | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🌐 Web query:
golang.org/x/oauth2 Config DeviceAuth context timeout behavior
💡 Result:
In the golang.org/x/oauth2 package, the DeviceAuth and DeviceAccessToken methods respect the provided context.Context through the following behaviors: DeviceAuth: This method uses the provided context to perform the initial device authorization request [1][2]. The context is utilized by the internal HTTP client to manage the request's lifecycle, meaning that if the context is canceled or reaches its deadline, the request will be terminated and return an error [1][3]. DeviceAccessToken: This method manages the polling process to exchange a device code for an access token [1][4]. Its behavior regarding the context includes: 1. Lifecycle Management: The method monitors the provided context via a select statement [1][2]. If the context is canceled or times out, the polling loop terminates immediately, and the function returns the context's error [1][3]. 2. Expiry Deadline: If the DeviceAuthResponse contains an Expiry time, the method derives a new context using context.WithDeadline set to that expiry time [1][2]. This ensures that if the device code itself expires, the polling loop will stop even if the user-provided context has a longer timeout or no deadline [1][4]. 3. Polling Intervals: The method respects the RFC 8628 polling interval [1][2]. It specifically handles the authorization_pending and slow_down error codes by either continuing the loop or increasing the polling interval, respectively, all while remaining sensitive to context cancellation at each step of the loop [1][3]. In summary, both methods actively respect the provided context to ensure that requests and polling operations can be cancelled or timed out appropriately [1][2].
Citations:
- 1: https://go.googlesource.com/oauth2/+/fd15e0fe894866ebff17ca3503d3706a967b061a/deviceauth.go
- 2: https://go.googlesource.com/oauth2/+/3a6776ada799d857cee190d6f517c4afee7c3170/deviceauth.go
- 3: https://go.googlesource.com/oauth2/+/e11eea88a89331c1fae3d38a37f484363a8d597d/deviceauth.go
- 4: https://go.googlesource.com/oauth2/+/84cb9f7f5c5a639955cd501bfdd54f0e63997e61/deviceauth.go
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate oauth.go and print focused regions around Token() and startDeviceFlow()
file=$(git ls-files | rg '(^|/)oauth\.go$' | head -n 1)
echo "FILE=$file"
wc -l "$file"
echo '--- Token() region ---'
sed -n '120,180p' "$file"
echo '--- startDeviceFlow() region ---'
sed -n '220,310p' "$file"Repository: Buzzvil/mcp-grafana
Length of output: 5338
startDeviceFlow()에서 c.mu를 잡은 채 DeviceAuth()를 호출합니다.
Token()이 이미 이 락을 보유한 상태로 들어오므로, IdP 응답이 지연되면 디바이스 인가 요청이 끝날 때까지 다른 Token() 호출이 모두 대기합니다. 디바이스 인증 요청은 락 밖으로 빼고, c.pending/c.cur 갱신만 락으로 보호하세요.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@oauth.go` around lines 242 - 277, `startDeviceFlow()` is holding `c.mu` while
calling `conf.DeviceAuth()`, which blocks other `Token()` calls for the duration
of the IdP request. Move the `DeviceAuth()` call outside the locked section, and
keep `c.mu` only around shared-state updates like `c.pending` and `c.cur` in the
`OAuthConfig` flow. Use the `startDeviceFlow`, `Token`, and `c.mu` symbols to
refactor the lock scope so the slow network call does not serialize unrelated
token requests.
|
( 문제 있으면 그때 수정해도 될꺼라는 판단입니다. ) |
요약이 fork 에는 CI 가 없습니다. head sha 이미 올라온 두 지적(HTTP 모드 토큰 공유 · 락 하 네트워크 호출)과 겹치지 않는 것만 적습니다. 확인 부탁드리는 것
어디까지 봤나PR head 를 로컬에 받아 빌드·vet·테스트 실행, GitHub API 로 check-run 과 workflow 등록 상태 조회, Hugo 요청으로 Claude Code 가 조사했습니다. 승인 판단은 Hugo 가 합니다. |
목적
로컬(및 컨테이너/원격) 환경에서 장기 수명 토큰(Grafana service account token 등)을 걷어내기 위해, mcp-grafana가 SA 토큰 대신 SSO(OAuth2 Device Authorization Grant, RFC 8628)로 로그인해서 단기·사용자별·폐기가능 토큰만 로컬에 두도록 합니다.
Authorization: Bearer로 전송.왜 device flow인가 (PKCE에서 전환)
처음엔 Authorization Code + PKCE(loopback 콜백)로 구현했으나, devcontainer/원격에서 MCP가 도는 경우 브라우저(호스트)와 콜백 서버(컨테이너)가 다른 네트워크라 loopback 콜백이 닿지 않아 로그인이 완료되지 않았습니다. Device flow는 콜백이 없어 이 문제가 원천적으로 사라집니다.
환경변수
GRAFANA_OAUTH_CLIENT_IDGRAFANA_OAUTH_DEVICE_AUTH_URLGRAFANA_OAUTH_TOKEN_URLGRAFANA_OAUTH_CLIENT_SECRETGRAFANA_OAUTH_SCOPESopenid profile email offline_access(offline_access로 refresh token)GRAFANA_OAUTH_AUDIENCEaudience파라미터GRAFANA_OAUTH_TOKEN_CACHEGRAFANA_OAUTH_AUTH_TIMEOUT10m)구현 노트
AuthRoundTripper인증 우선순위: OBO > OAuth > service account token > basic auth (OAuth·SA 동시 설정 시 OAuth 우선 + 경고).BuildTransport)와 OpenAPI 클라이언트(OBO와 동일한 transport-level 주입) 양쪽 적용.WithoutInteractiveOAuth로 감싸 MCP 핸드셰이크를 막지 않음. device 폴링은 백그라운드 goroutine → tool 호출은 즉시 instructions 반환.0600)에 캐시, 만료 시 자동 refresh. OAuth 설정은 env에서 1회만 읽어 pointer 공유.golang.org/x/oauth2를 direct dependency로 승격(pkg/browser 의존 제거).범위 (scope)
핵심 Grafana API + datasource-proxy 계열 도구를 OAuth로 인증합니다. Incident/IRM · OnCall 클라이언트는 여전히 정적 SA 토큰 경로라, 그 도구를 쓰면 SA 토큰이 별도로 필요합니다.
테스트 (Go 1.26, docker)
go build ./...✅ /go vet✅ /gofmt -lno diff ✅golangci-lint v2.11.4 run ./ ./cmd/...✅ 0 issuesgo test -count=1 ./ ./cmd/...✅ — 기존 인증 테스트 회귀 없음oauth_test.go: config 파싱, fake IdP 대상 device flow end-to-end(pending→approve→token), 캐시 토큰 silent refresh, non-interactive startup 경로, RoundTripper 우선순위https://authentik.buzzvil.com/device?code=...+ user code를 담은 instructions 즉시 반환(행 없음), 실제 device code 발급 확인🤖 Generated with Claude Code