Skip to content

Commit e47d093

Browse files
committed
feat: add browser-based SSO authentication
Add chromedp-based browser login flow that opens a real browser window for SSO authentication, captures the grafana_session cookie, and persists it to disk. Includes automatic re-authentication on 401 responses via a custom http.RoundTripper middleware. New files: - auth/browser.go: chromedp browser login with persistent Chrome profile - auth/session_store.go: cookie persistence to ~/.config/mcp-grafana/ - auth/transport.go: SessionAuthTransport with auto 401 re-login Modified: - cmd/mcp-grafana/main.go: --browser-auth CLI flag - mcpgrafana.go: wire browser auth transport into Grafana client
1 parent ad6ef92 commit e47d093

File tree

8 files changed

+481
-4
lines changed

8 files changed

+481
-4
lines changed

auth/browser.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"net/url"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/chromedp/cdproto/network"
14+
"github.com/chromedp/chromedp"
15+
)
16+
17+
const (
18+
loginTimeout = 5 * time.Minute
19+
cookiePollInterval = 500 * time.Millisecond
20+
grafanaSessionName = "grafana_session"
21+
)
22+
23+
// BrowserLogin opens a real browser window to the Grafana login page and waits
24+
// for the user to complete SSO authentication. Once authenticated, it captures
25+
// the grafana_session cookie and returns it.
26+
//
27+
// A persistent Chrome profile is used so that IdP sessions (Okta, Google, etc.)
28+
// are remembered. If the profile is locked by another Chrome instance, a
29+
// temporary profile is used as fallback.
30+
func BrowserLogin(grafanaURL string) (string, error) {
31+
parsedURL, err := url.Parse(grafanaURL)
32+
if err != nil {
33+
return "", fmt.Errorf("invalid Grafana URL: %w", err)
34+
}
35+
domain := parsedURL.Hostname()
36+
loginURL := strings.TrimRight(grafanaURL, "/") + "/login"
37+
38+
sendNotification(
39+
"Grafana MCP - Login Required",
40+
"A browser window will open for Grafana SSO login. Please complete authentication.",
41+
)
42+
43+
cookie, err := tryBrowserLogin(loginURL, domain, chromeProfileDir())
44+
if err != nil {
45+
slog.Warn("Browser login with persistent profile failed, retrying with temp profile", "error", err)
46+
cookie, err = tryBrowserLogin(loginURL, domain, "")
47+
}
48+
if err != nil {
49+
sendNotification(
50+
"Grafana MCP - Login Failed",
51+
fmt.Sprintf("Browser login timed out after %s. Please try again.", loginTimeout),
52+
)
53+
return "", fmt.Errorf("browser login failed: %w", err)
54+
}
55+
56+
return cookie, nil
57+
}
58+
59+
func tryBrowserLogin(loginURL, domain, profileDir string) (string, error) {
60+
opts := append(chromedp.DefaultExecAllocatorOptions[:],
61+
chromedp.Flag("headless", false),
62+
chromedp.Flag("disable-gpu", false),
63+
chromedp.WindowSize(1024, 768),
64+
)
65+
66+
if profileDir != "" {
67+
if err := os.MkdirAll(profileDir, 0o700); err != nil {
68+
return "", fmt.Errorf("create chrome profile dir: %w", err)
69+
}
70+
opts = append(opts, chromedp.UserDataDir(profileDir))
71+
slog.Info("Starting browser login", "url", loginURL, "profile", profileDir)
72+
} else {
73+
slog.Info("Starting browser login with temporary profile", "url", loginURL)
74+
}
75+
76+
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
77+
defer allocCancel()
78+
79+
ctx, cancel := chromedp.NewContext(allocCtx)
80+
defer cancel()
81+
82+
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, loginTimeout)
83+
defer timeoutCancel()
84+
85+
var sessionCookie string
86+
87+
err := chromedp.Run(timeoutCtx,
88+
chromedp.Navigate(loginURL),
89+
chromedp.ActionFunc(func(ctx context.Context) error {
90+
slog.Info("Browser opened, waiting for SSO login to complete...")
91+
for {
92+
select {
93+
case <-ctx.Done():
94+
return fmt.Errorf(
95+
"login timed out after %s — please complete SSO in the browser window that opened",
96+
loginTimeout,
97+
)
98+
default:
99+
}
100+
101+
cookies, err := network.GetCookies().Do(ctx)
102+
if err != nil {
103+
return fmt.Errorf("failed to get cookies: %w", err)
104+
}
105+
106+
for _, c := range cookies {
107+
if c.Name == grafanaSessionName && matchesDomain(c.Domain, domain) {
108+
sessionCookie = c.Value
109+
slog.Info("Session cookie captured", "domain", c.Domain)
110+
return nil
111+
}
112+
}
113+
114+
time.Sleep(cookiePollInterval)
115+
}
116+
}),
117+
)
118+
if err != nil {
119+
return "", err
120+
}
121+
122+
if sessionCookie == "" {
123+
return "", fmt.Errorf("no grafana_session cookie found after login")
124+
}
125+
126+
return sessionCookie, nil
127+
}
128+
129+
func chromeProfileDir() string {
130+
return filepath.Join(configDir(), "chrome-profile")
131+
}
132+
133+
// matchesDomain checks if a cookie domain matches the target domain.
134+
// Cookie domains may have a leading dot (e.g., ".jfrog.io").
135+
func matchesDomain(cookieDomain, targetDomain string) bool {
136+
cookieDomain = strings.TrimPrefix(cookieDomain, ".")
137+
return strings.EqualFold(cookieDomain, targetDomain) ||
138+
strings.HasSuffix(targetDomain, "."+cookieDomain)
139+
}

auth/notify.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package auth
2+
3+
import (
4+
"log/slog"
5+
"os/exec"
6+
"runtime"
7+
)
8+
9+
// sendNotification sends a desktop notification so the user knows
10+
// a browser window needs attention. Best-effort: failures are logged
11+
// but never block the caller.
12+
func sendNotification(title, message string) {
13+
var cmd *exec.Cmd
14+
15+
switch runtime.GOOS {
16+
case "darwin":
17+
script := `display notification "` + escapeAppleScript(message) + `" with title "` + escapeAppleScript(title) + `"`
18+
cmd = exec.Command("osascript", "-e", script)
19+
case "linux":
20+
cmd = exec.Command("notify-send", "--urgency=critical", title, message)
21+
case "windows":
22+
ps := `[void][System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms');` +
23+
`$n=New-Object System.Windows.Forms.NotifyIcon;` +
24+
`$n.Icon=[System.Drawing.SystemIcons]::Information;` +
25+
`$n.Visible=$true;` +
26+
`$n.ShowBalloonTip(10000,'` + title + `','` + message + `',[System.Windows.Forms.ToolTipIcon]::Info)`
27+
cmd = exec.Command("powershell", "-NoProfile", "-Command", ps)
28+
default:
29+
slog.Debug("Desktop notifications not supported on this platform", "os", runtime.GOOS)
30+
return
31+
}
32+
33+
if err := cmd.Start(); err != nil {
34+
slog.Debug("Failed to send desktop notification", "error", err)
35+
return
36+
}
37+
38+
// Don't block on the notification process.
39+
go func() {
40+
_ = cmd.Wait()
41+
}()
42+
}
43+
44+
func escapeAppleScript(s string) string {
45+
result := make([]byte, 0, len(s))
46+
for i := 0; i < len(s); i++ {
47+
switch s[i] {
48+
case '"':
49+
result = append(result, '\\', '"')
50+
case '\\':
51+
result = append(result, '\\', '\\')
52+
default:
53+
result = append(result, s[i])
54+
}
55+
}
56+
return string(result)
57+
}

auth/session_store.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log/slog"
7+
"net/url"
8+
"os"
9+
"path/filepath"
10+
"sync"
11+
"time"
12+
)
13+
14+
type sessionEntry struct {
15+
Cookie string `json:"cookie"`
16+
SavedAt time.Time `json:"saved_at"`
17+
}
18+
19+
// SessionStore persists Grafana session cookies to disk, keyed by host.
20+
type SessionStore struct {
21+
mu sync.Mutex
22+
path string
23+
}
24+
25+
// NewSessionStore creates a store backed by ~/.config/mcp-grafana/sessions.json.
26+
func NewSessionStore() *SessionStore {
27+
dir := configDir()
28+
return &SessionStore{path: filepath.Join(dir, "sessions.json")}
29+
}
30+
31+
// Load returns the saved session cookie for a Grafana URL, or "" if none.
32+
func (s *SessionStore) Load(grafanaURL string) string {
33+
s.mu.Lock()
34+
defer s.mu.Unlock()
35+
36+
host := hostFromURL(grafanaURL)
37+
entries := s.readAll()
38+
if e, ok := entries[host]; ok {
39+
slog.Debug("Loaded session from disk", "host", host, "saved_at", e.SavedAt)
40+
return e.Cookie
41+
}
42+
return ""
43+
}
44+
45+
// Save persists the session cookie for a Grafana URL.
46+
func (s *SessionStore) Save(grafanaURL, cookie string) error {
47+
s.mu.Lock()
48+
defer s.mu.Unlock()
49+
50+
host := hostFromURL(grafanaURL)
51+
entries := s.readAll()
52+
entries[host] = sessionEntry{
53+
Cookie: cookie,
54+
SavedAt: time.Now().UTC(),
55+
}
56+
57+
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
58+
return fmt.Errorf("create config dir: %w", err)
59+
}
60+
61+
data, err := json.MarshalIndent(entries, "", " ")
62+
if err != nil {
63+
return fmt.Errorf("marshal sessions: %w", err)
64+
}
65+
66+
if err := os.WriteFile(s.path, data, 0o600); err != nil {
67+
return fmt.Errorf("write sessions file: %w", err)
68+
}
69+
70+
slog.Debug("Session saved to disk", "host", host, "path", s.path)
71+
return nil
72+
}
73+
74+
func (s *SessionStore) readAll() map[string]sessionEntry {
75+
data, err := os.ReadFile(s.path)
76+
if err != nil {
77+
return make(map[string]sessionEntry)
78+
}
79+
var entries map[string]sessionEntry
80+
if err := json.Unmarshal(data, &entries); err != nil {
81+
slog.Warn("Corrupt sessions file, starting fresh", "path", s.path, "error", err)
82+
return make(map[string]sessionEntry)
83+
}
84+
return entries
85+
}
86+
87+
func hostFromURL(rawURL string) string {
88+
u, err := url.Parse(rawURL)
89+
if err != nil {
90+
return rawURL
91+
}
92+
return u.Host
93+
}
94+
95+
func configDir() string {
96+
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
97+
return filepath.Join(xdg, "mcp-grafana")
98+
}
99+
home, err := os.UserHomeDir()
100+
if err != nil {
101+
home = "."
102+
}
103+
return filepath.Join(home, ".config", "mcp-grafana")
104+
}

0 commit comments

Comments
 (0)