diff --git a/internal/api/oauth.go b/internal/api/oauth.go index 845665ec..102769ac 100644 --- a/internal/api/oauth.go +++ b/internal/api/oauth.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "crypto/sha256" "crypto/subtle" @@ -27,10 +28,14 @@ import ( mcpgoauth "github.com/modelcontextprotocol/go-sdk/auth" ) -const mcpReadScope = "fanout:read" +const mcpReadScope = appauth.MCPScopeTelemetryRead const browserMCPSessionBearer = "fanout-browser-session" +const maxMCPAuthorizationBodyBytes = 4 << 20 + +var errMCPAuthorizationBodyTooLarge = errors.New("MCP request body exceeds authorization limit") + type browserMCPUserContextKey struct{} var mcpSupportedScopes = []string{mcpReadScope, dashboard.OAuthScope} @@ -82,10 +87,41 @@ func (h *MCPAuthorization) Register(e *echo.Echo) { } func (h *MCPAuthorization) ProtectMCP(next http.Handler) http.Handler { + scopeChecked := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := mcpgoauth.TokenInfoFromContext(r.Context()) + if info != nil && slices.Contains(info.Scopes, dashboard.OAuthScope) { + next.ServeHTTP(w, r) + return + } + + requiredScope, err := requiredMCPToolScope(r) + if err != nil { + status := http.StatusBadRequest + message := "invalid MCP request body" + if errors.Is(err, errMCPAuthorizationBodyTooLarge) { + status = http.StatusRequestEntityTooLarge + message = "MCP request body is too large" + } + http.Error(w, message, status) + return + } + if requiredScope != "" { + if info == nil || !slices.Contains(info.Scopes, requiredScope) { + w.Header().Set("WWW-Authenticate", fmt.Sprintf( + `Bearer error="insufficient_scope", scope=%q, resource_metadata=%q`, + requiredScope, + h.metadataURL, + )) + http.Error(w, "insufficient scope", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) protected := mcpgoauth.RequireBearerToken(h.verifyMCPToken, &mcpgoauth.RequireBearerTokenOptions{ ResourceMetadataURL: h.metadataURL, Scopes: []string{mcpReadScope}, - })(next) + })(scopeChecked) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.EqualFold(strings.TrimSpace(r.Host), h.allowedHost) { http.Error(w, "MCP request host does not match the configured public URL", http.StatusMisdirectedRequest) @@ -95,6 +131,72 @@ func (h *MCPAuthorization) ProtectMCP(next http.Handler) http.Handler { }) } +func requiredMCPToolScope(r *http.Request) (string, error) { + if r.Method != http.MethodPost || r.Body == nil { + return "", nil + } + original := r.Body + body, err := io.ReadAll(io.LimitReader(original, maxMCPAuthorizationBodyBytes+1)) + r.Body = struct { + io.Reader + io.Closer + }{ + Reader: io.MultiReader(bytes.NewReader(body), original), + Closer: original, + } + if err != nil { + return "", fmt.Errorf("read MCP request body: %w", err) + } + if len(body) > maxMCPAuthorizationBodyBytes { + return "", errMCPAuthorizationBodyTooLarge + } + + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return "", errors.New("invalid MCP request body") + } + switch trimmed[0] { + case '{': + var single mcpAuthorizationRequest + if json.Unmarshal(trimmed, &single) != nil { + return "", errors.New("invalid MCP request body") + } + return single.requiredScope(), nil + case '[': + var batch []mcpAuthorizationRequest + if json.Unmarshal(trimmed, &batch) != nil || len(batch) == 0 { + return "", errors.New("invalid MCP request body") + } + for _, request := range batch { + if scope := request.requiredScope(); scope != "" { + return scope, nil + } + } + return "", nil + default: + return "", errors.New("invalid MCP request body") + } +} + +type mcpAuthorizationRequest struct { + Method string `json:"method"` + Params struct { + Name string `json:"name"` + } `json:"params"` +} + +func (r mcpAuthorizationRequest) requiredScope() string { + if r.Method != "tools/call" { + return "" + } + switch r.Params.Name { + case "dashboard_list", "dashboard_get", "dashboard_create", "dashboard_update": + return dashboard.OAuthScope + default: + return "" + } +} + // ProtectBrowserMCP adapts an already-authenticated browser session to the // standard MCP transport identity consumed by the SDK. The public /mcp route // remains OAuth bearer-only; this adapter is used only by the same-origin @@ -117,7 +219,7 @@ func ProtectBrowserMCP(sessions *appauth.BrowserSessions, next http.Handler) ech Scopes: scopes, Expiration: sessions.Deadline(ctx), UserID: user.ID, - Extra: map[string]any{"role": user.Role, "credential": "browser_session"}, + Extra: map[string]any{"credential": "browser_session"}, }, nil }, nil)(next) @@ -156,13 +258,15 @@ func (h *MCPAuthorization) verifyMCPToken(ctx context.Context, raw string, _ *ht if !user.Active { return nil, mcpgoauth.ErrInvalidToken } + if !userCanUseMCPScopes(user, record.Scope) { + return nil, mcpgoauth.ErrInvalidToken + } return &mcpgoauth.TokenInfo{ Scopes: strings.Fields(record.Scope), Expiration: record.ExpiresAt, UserID: record.UserID, Extra: map[string]any{ "client_id": record.ClientID, - "role": user.Role, }, }, nil } @@ -171,6 +275,7 @@ func (h *MCPAuthorization) ProtectedResourceMetadata(c *echo.Context) error { setDiscoveryHeaders(c) return c.JSON(http.StatusOK, map[string]any{ "resource": h.resource, + "resource_name": "Fanout Observability", "authorization_servers": []string{h.issuer}, "scopes_supported": mcpSupportedScopes, "bearer_methods_supported": []string{"header"}, @@ -180,17 +285,18 @@ func (h *MCPAuthorization) ProtectedResourceMetadata(c *echo.Context) error { func (h *MCPAuthorization) AuthorizationServerMetadata(c *echo.Context) error { setDiscoveryHeaders(c) return c.JSON(http.StatusOK, map[string]any{ - "issuer": h.issuer, - "authorization_endpoint": h.issuer + "/api/auth/oauth/authorize", - "token_endpoint": h.issuer + "/oauth/token", - "registration_endpoint": h.issuer + "/oauth/register", - "scopes_supported": mcpSupportedScopes, - "response_types_supported": []string{"code"}, - "response_modes_supported": []string{"query"}, - "grant_types_supported": []string{"authorization_code", "refresh_token"}, - "token_endpoint_auth_methods_supported": []string{"none"}, - "code_challenge_methods_supported": []string{"S256"}, - "client_id_metadata_document_supported": false, + "issuer": h.issuer, + "authorization_endpoint": h.issuer + "/api/auth/oauth/authorize", + "token_endpoint": h.issuer + "/oauth/token", + "registration_endpoint": h.issuer + "/oauth/register", + "scopes_supported": mcpSupportedScopes, + "response_types_supported": []string{"code"}, + "response_modes_supported": []string{"query"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + "code_challenge_methods_supported": []string{"S256"}, + "authorization_response_iss_parameter_supported": true, + "client_id_metadata_document_supported": false, }) } @@ -290,7 +396,7 @@ func (h *MCPAuthorization) Authorize(c *echo.Context) error { client, errorCode, description := h.validateAuthorizationRequest(c.Request().Context(), req) if errorCode != "" { if client.ClientID != "" { - return redirectOAuthError(c, req.RedirectURI, req.State, errorCode, description) + return h.redirectOAuthError(c, req.RedirectURI, req.State, errorCode, description) } status := http.StatusBadRequest if errorCode == "server_error" { @@ -304,13 +410,16 @@ func (h *MCPAuthorization) Authorize(c *echo.Context) error { slog.Error("oauth consent reached handler without an authenticated browser user") return oauthJSONError(c, http.StatusUnauthorized, "access_denied", "browser authentication is required") } + grantedScope := authorizationScope(req.Scope) + if !userCanUseMCPScopes(user, grantedScope) { + return h.redirectOAuthError(c, req.RedirectURI, req.State, "invalid_scope", "requested scope is not available to this account") + } if c.Request().Method == http.MethodGet { redirectOrigin, formActionSource, err := redirectURIOrigin(req.RedirectURI) if err != nil { slog.Error("registered OAuth redirect URI is invalid", "client_id", req.ClientID, "err", err) return oauthJSONError(c, http.StatusInternalServerError, "server_error", "authorization failed") } - grantedScope := authorizationScope(req.Scope) c.Response().Header().Set("Cache-Control", "no-store") // Chromium applies form-action across the redirect after this // same-origin form POST. Permit the exact validated callback origin @@ -334,13 +443,13 @@ func (h *MCPAuthorization) Authorize(c *echo.Context) error { } if c.Request().Form.Get("decision") != "approve" { - return redirectOAuthError(c, req.RedirectURI, req.State, "access_denied", "authorization was denied") + return h.redirectOAuthError(c, req.RedirectURI, req.State, "access_denied", "authorization was denied") } code, err := h.store.CreateAuthorizationCode(c.Request().Context(), appauth.OAuthAuthorizationCode{ ClientID: req.ClientID, UserID: user.ID, RedirectURI: req.RedirectURI, - Scope: authorizationScope(req.Scope), + Scope: grantedScope, Resource: h.resource, CodeChallenge: req.CodeChallenge, }) @@ -348,8 +457,8 @@ func (h *MCPAuthorization) Authorize(c *echo.Context) error { slog.Error("oauth authorization code creation failed", "client_id", req.ClientID, "user_id", user.ID, "err", err) return oauthJSONError(c, http.StatusInternalServerError, "server_error", "authorization failed") } - slog.Info("oauth authorization approved", "client_id", req.ClientID, "user_id", user.ID, "scope", authorizationScope(req.Scope)) - return redirectOAuthSuccess(c, req.RedirectURI, req.State, code) + slog.Info("oauth authorization approved", "client_id", req.ClientID, "user_id", user.ID, "scope", grantedScope) + return h.redirectOAuthSuccess(c, req.RedirectURI, req.State, code) } func (h *MCPAuthorization) validateAuthorizationRequest(ctx context.Context, req authorizationRequest) (appauth.OAuthClient, string, string) { @@ -387,7 +496,8 @@ func authorizationScope(requested string) string { if strings.TrimSpace(requested) == "" { return mcpReadScope } - return strings.Join(strings.Fields(requested), " ") + canonical, _ := appauth.CanonicalMCPOAuthScope(requested) + return canonical } type consentGrant struct { @@ -413,11 +523,26 @@ func consentGrants(scope string) []consentGrant { } func validMCPScopes(scopes []string) bool { - if !slices.Contains(scopes, mcpReadScope) { + _, ok := appauth.CanonicalMCPOAuthScope(strings.Join(scopes, " ")) + return ok +} + +func userCanUseMCPScopes(user appauth.User, raw string) bool { + canonical, ok := appauth.CanonicalMCPOAuthScope(raw) + if !ok { return false } - for _, scope := range scopes { - if !slices.Contains(mcpSupportedScopes, scope) { + for _, scope := range strings.Fields(canonical) { + switch scope { + case mcpReadScope: + if !HasCapability(user, ReadTelemetry) { + return false + } + case dashboard.OAuthScope: + if !HasCapability(user, ManageOwnDashboards) { + return false + } + default: return false } } @@ -456,6 +581,9 @@ func (h *MCPAuthorization) Token(c *echo.Context) error { var pair appauth.OAuthTokenPair switch grantType { case "authorization_code": + if _, present := c.Request().PostForm["scope"]; present { + return oauthJSONError(c, http.StatusBadRequest, "invalid_request", "scope is not allowed for an authorization_code grant") + } pair, err = h.exchangeAuthorizationCode(c, clientID) case "refresh_token": resource := c.Request().PostForm.Get("resource") @@ -465,11 +593,20 @@ func (h *MCPAuthorization) Token(c *echo.Context) error { if resource != h.resource { return oauthJSONError(c, http.StatusBadRequest, "invalid_target", "resource must identify this MCP server") } - pair, err = h.store.RotateRefreshToken(c.Request().Context(), clientID, c.Request().PostForm.Get("refresh_token"), resource) + pair, err = h.store.RotateRefreshToken( + c.Request().Context(), + clientID, + c.Request().PostForm.Get("refresh_token"), + resource, + c.Request().PostForm.Get("scope"), + ) default: return oauthJSONError(c, http.StatusBadRequest, "unsupported_grant_type", "unsupported grant type") } if err != nil { + if errors.Is(err, appauth.ErrInvalidOAuthScope) { + return oauthJSONError(c, http.StatusBadRequest, "invalid_scope", "requested scope is invalid or exceeds the original grant") + } if errors.Is(err, appauth.ErrInvalidOAuthGrant) || errors.Is(err, appauth.ErrOAuthRefreshReuse) { return oauthJSONError(c, http.StatusBadRequest, "invalid_grant", "grant is invalid or expired") } @@ -592,10 +729,11 @@ func sameStringSet(got, want []string) bool { return true } -func redirectOAuthSuccess(c *echo.Context, redirectURI, state, code string) error { +func (h *MCPAuthorization) redirectOAuthSuccess(c *echo.Context, redirectURI, state, code string) error { u, _ := url.Parse(redirectURI) query := u.Query() query.Set("code", code) + query.Set("iss", h.issuer) if state != "" { query.Set("state", state) } @@ -603,11 +741,12 @@ func redirectOAuthSuccess(c *echo.Context, redirectURI, state, code string) erro return c.Redirect(http.StatusFound, u.String()) } -func redirectOAuthError(c *echo.Context, redirectURI, state, code, description string) error { +func (h *MCPAuthorization) redirectOAuthError(c *echo.Context, redirectURI, state, code, description string) error { u, _ := url.Parse(redirectURI) query := u.Query() query.Set("error", code) query.Set("error_description", description) + query.Set("iss", h.issuer) if state != "" { query.Set("state", state) } diff --git a/internal/api/oauth_test.go b/internal/api/oauth_test.go index 6c09839f..1165b010 100644 --- a/internal/api/oauth_test.go +++ b/internal/api/oauth_test.go @@ -4,6 +4,8 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -22,6 +24,8 @@ import ( const testMCPResource = "https://fanout.example.com/mcp" +const testReadMCPCall = `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"observability_overview","arguments":{}}}` + func newOAuthTestServer(t *testing.T) (*echo.Echo, *auth.UserStore, *auth.BrowserSessions) { return newOAuthTestServerWithConfig(t, config.Config{}) } @@ -55,7 +59,12 @@ func newOAuthTestServerWithConfig(t *testing.T, cfg config.Config) (*echo.Echo, return c.NoContent(http.StatusNoContent) }) handler.Register(e) - e.Any("/mcp", echo.WrapHandler(handler.ProtectMCP(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + e.Any("/mcp", echo.WrapHandler(handler.ProtectMCP(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if info := mcpgoauth.TokenInfoFromContext(r.Context()); info != nil { + if role, ok := info.Extra["role"]; ok { + w.Header().Set("X-Test-MCP-Role", fmt.Sprint(role)) + } + } w.WriteHeader(http.StatusNoContent) })))) e.Any("/api/mcp", ProtectBrowserMCP(sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -66,6 +75,9 @@ func newOAuthTestServerWithConfig(t *testing.T, cfg config.Config) (*echo.Echo, } w.Header().Set("X-Test-MCP-User", info.UserID) w.Header().Set("X-Test-MCP-Scopes", strings.Join(info.Scopes, " ")) + if role, ok := info.Extra["role"]; ok { + w.Header().Set("X-Test-MCP-Role", fmt.Sprint(role)) + } w.WriteHeader(http.StatusNoContent) }))) e.GET("/api/auth/me", func(c *echo.Context) error { return c.NoContent(http.StatusNoContent) }) @@ -101,11 +113,17 @@ func TestMCPOAuthDiscoveryAndAuthorizationCodeFlow(t *testing.T) { } metadata := serve(t, e, http.MethodGet, "/.well-known/oauth-protected-resource/mcp", "", nil) - if metadata.Code != http.StatusOK || !strings.Contains(metadata.Body.String(), testMCPResource) { + if metadata.Code != http.StatusOK || + !strings.Contains(metadata.Body.String(), testMCPResource) || + !strings.Contains(metadata.Body.String(), `"resource_name":"Fanout Observability"`) || + !strings.Contains(metadata.Body.String(), `"scopes_supported":["telemetry:read","dashboard:manage"]`) { t.Fatalf("protected resource metadata = %d %s", metadata.Code, metadata.Body.String()) } authorizationMetadata := serve(t, e, http.MethodGet, "/.well-known/oauth-authorization-server", "", nil) - if authorizationMetadata.Code != http.StatusOK || !strings.Contains(authorizationMetadata.Body.String(), `"code_challenge_methods_supported":["S256"]`) { + if authorizationMetadata.Code != http.StatusOK || + !strings.Contains(authorizationMetadata.Body.String(), `"code_challenge_methods_supported":["S256"]`) || + !strings.Contains(authorizationMetadata.Body.String(), `"scopes_supported":["telemetry:read","dashboard:manage"]`) || + !strings.Contains(authorizationMetadata.Body.String(), `"authorization_response_iss_parameter_supported":true`) { t.Fatalf("authorization metadata = %d %s", authorizationMetadata.Code, authorizationMetadata.Body.String()) } @@ -172,7 +190,7 @@ func TestMCPOAuthDiscoveryAndAuthorizationCodeFlow(t *testing.T) { if err != nil { t.Fatalf("parse callback: %v", err) } - if callback.Query().Get("state") != "state-123" || callback.Query().Get("code") == "" { + if callback.Query().Get("state") != "state-123" || callback.Query().Get("code") == "" || callback.Query().Get("iss") != "https://fanout.example.com" { t.Fatalf("callback = %s", callback) } @@ -197,14 +215,17 @@ func TestMCPOAuthDiscoveryAndAuthorizationCodeFlow(t *testing.T) { t.Fatalf("unexpected token response: %#v", tokenBody) } - mcp := serve(t, e, http.MethodPost, "/mcp", "", map[string]string{"Authorization": "Bearer " + access}) + mcp := serve(t, e, http.MethodPost, "/mcp", testReadMCPCall, map[string]string{"Authorization": "Bearer " + access}) if mcp.Code != http.StatusNoContent { t.Fatalf("MCP with OAuth token = %d %s", mcp.Code, mcp.Body.String()) } + if role := mcp.Header().Get("X-Test-MCP-Role"); role != "" { + t.Fatalf("delegated MCP context exposed account role %q", role) + } if err := users.RevokeAllSessions(user.ID); err != nil { t.Fatalf("logout everywhere: %v", err) } - replayed := serve(t, e, http.MethodPost, "/mcp", "", map[string]string{"Authorization": "Bearer " + access}) + replayed := serve(t, e, http.MethodPost, "/mcp", testReadMCPCall, map[string]string{"Authorization": "Bearer " + access}) if replayed.Code != http.StatusUnauthorized { t.Fatalf("MCP token survived logout everywhere: %d %s", replayed.Code, replayed.Body.String()) } @@ -258,9 +279,12 @@ func TestBrowserMCPUsesSessionWithoutWeakeningRemoteMCP(t *testing.T) { t.Fatalf("browser MCP user = %q, want %q", got, user.ID) } scopes := strings.Fields(browser.Header().Get("X-Test-MCP-Scopes")) - if !slices.Contains(scopes, mcpReadScope) || !slices.Contains(scopes, "fanout:dashboard") { + if !slices.Contains(scopes, mcpReadScope) || !slices.Contains(scopes, auth.MCPScopeDashboardManage) { t.Fatalf("browser MCP scopes = %v, want read and dashboard access", scopes) } + if role := browser.Header().Get("X-Test-MCP-Role"); role != "" { + t.Fatalf("browser MCP context exposed account role %q", role) + } remoteWithSessionOnly := serve(t, e, http.MethodPost, "/mcp", "", map[string]string{"Fanout-Request": "1"}, cookie) if remoteWithSessionOnly.Code != http.StatusUnauthorized { @@ -467,6 +491,93 @@ func decodeTokens(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { // --- HTTP-layer negative-path and scope tests --------------------------------- +func TestMCPOAuthScopePolicyCanonicalizesLegacyNames(t *testing.T) { + legacy := "fanout:dashboard fanout:read fanout:dashboard" + if !validMCPScopes(strings.Fields(legacy)) { + t.Fatal("legacy scope aliases were rejected") + } + want := mcpReadScope + " " + auth.MCPScopeDashboardManage + if got := authorizationScope(legacy); got != want { + t.Fatalf("canonical scope = %q, want %q", got, want) + } + if !userCanUseMCPScopes(auth.User{Role: auth.RoleViewer}, want) { + t.Fatal("viewer lost its configured MCP capabilities") + } + if userCanUseMCPScopes(auth.User{Role: auth.Role("retired")}, want) { + t.Fatal("unknown role received MCP capabilities") + } +} + +func TestRequiredMCPToolScopeUsesPayloadAndReplaysBody(t *testing.T) { + body := `[{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"observability_overview"}},{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"dashboard_get"}}]` + req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(body)) + req.Header.Set("Mcp-Method", "tools/call") + req.Header.Set("Mcp-Name", "observability_overview") + + got, err := requiredMCPToolScope(req) + if err != nil { + t.Fatal(err) + } + if got != auth.MCPScopeDashboardManage { + t.Fatalf("required scope = %q, want %q", got, auth.MCPScopeDashboardManage) + } + replayed, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if string(replayed) != body { + t.Fatalf("replayed body = %q, want original payload", replayed) + } +} + +func TestMCPOAuthTokenEndpointEnforcesScopeByGrantType(t *testing.T) { + e, users, _ := newOAuthTestServer(t) + cookie := oauthSessionCookie(t, e, users, "token-scope@example.com") + client := registerOAuthClient(t, e) + verifier, challenge := pkcePair() + fullScope := mcpReadScope + " " + auth.MCPScopeDashboardManage + params := authorizeParams(client.ClientID, fullScope, challenge) + code := approveAndGetCode(t, e, params, cookie) + + codeExchange := url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {client.ClientID}, + "code": {code}, + "redirect_uri": {testRedirectURI}, + "code_verifier": {verifier}, + "resource": {testMCPResource}, + "scope": {mcpReadScope}, + } + rejectedCodeExchange := serve(t, e, http.MethodPost, "/oauth/token", codeExchange.Encode(), formHeaders) + if rejectedCodeExchange.Code != http.StatusBadRequest || !strings.Contains(rejectedCodeExchange.Body.String(), `"error":"invalid_request"`) { + t.Fatalf("code exchange scope = %d %s, want 400 invalid_request", rejectedCodeExchange.Code, rejectedCodeExchange.Body.String()) + } + + // Rejecting the extra parameter must not consume the authorization code. + tokens := decodeTokens(t, exchangeCode(t, e, client.ClientID, code, testRedirectURI, verifier)) + refresh := url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {client.ClientID}, + "refresh_token": {tokens["refresh_token"].(string)}, + "scope": {mcpReadScope}, + } + narrowed := decodeTokens(t, serve(t, e, http.MethodPost, "/oauth/token", refresh.Encode(), formHeaders)) + if narrowed["scope"] != mcpReadScope { + t.Fatalf("narrowed refresh scope = %v, want %q", narrowed["scope"], mcpReadScope) + } + + refresh.Set("refresh_token", narrowed["refresh_token"].(string)) + refresh.Set("scope", fullScope) + expanded := serve(t, e, http.MethodPost, "/oauth/token", refresh.Encode(), formHeaders) + if expanded.Code != http.StatusBadRequest || !strings.Contains(expanded.Body.String(), `"error":"invalid_scope"`) { + t.Fatalf("expanded refresh scope = %d %s, want 400 invalid_scope", expanded.Code, expanded.Body.String()) + } + + // An invalid scope request must not consume the refresh token. + refresh.Set("scope", mcpReadScope) + decodeTokens(t, serve(t, e, http.MethodPost, "/oauth/token", refresh.Encode(), formHeaders)) +} + func TestMCPOAuthTokenExchangeRejectsWrongPKCEVerifier(t *testing.T) { e, users, _ := newOAuthTestServer(t) cookie := oauthSessionCookie(t, e, users, "pkce@example.com") @@ -502,6 +613,9 @@ func TestMCPOAuthConsentDenyRedirectsAccessDenied(t *testing.T) { if callback.Query().Get("state") != "state-xyz" { t.Fatalf("deny callback dropped state: %s", callback) } + if callback.Query().Get("iss") != "https://fanout.example.com" { + t.Fatalf("deny callback issuer = %q", callback.Query().Get("iss")) + } } func TestMCPOAuthTokenExchangeRejectsRedirectURIMismatch(t *testing.T) { @@ -551,7 +665,7 @@ func TestMCPOAuthRefreshGrantOverHTTP(t *testing.T) { if rotated["scope"] != mcpReadScope { t.Fatalf("rotated scope = %v, want %q", rotated["scope"], mcpReadScope) } - mcp := serve(t, e, http.MethodPost, "/mcp", "", map[string]string{"Authorization": "Bearer " + rotated["access_token"].(string)}) + mcp := serve(t, e, http.MethodPost, "/mcp", testReadMCPCall, map[string]string{"Authorization": "Bearer " + rotated["access_token"].(string)}) if mcp.Code != http.StatusNoContent { t.Fatalf("MCP with rotated token = %d %s", mcp.Code, mcp.Body.String()) } @@ -580,7 +694,7 @@ func TestMCPOAuthOmittedScopeGrantsReadOnly(t *testing.T) { if !strings.Contains(body, "read-only") || !strings.Contains(body, mcpReadScope) { t.Fatalf("consent for omitted scope must advertise read-only %s, got: %s", mcpReadScope, body) } - if strings.Contains(body, "fanout:dashboard") || strings.Contains(body, "dashboards and overwrite") { + if strings.Contains(body, auth.MCPScopeDashboardManage) || strings.Contains(body, "dashboards and overwrite") { t.Fatalf("consent for omitted scope leaked dashboard write access: %s", body) } @@ -589,6 +703,45 @@ func TestMCPOAuthOmittedScopeGrantsReadOnly(t *testing.T) { if tokens["scope"] != mcpReadScope { t.Fatalf("granted scope = %v, want %q", tokens["scope"], mcpReadScope) } + dashboardCall := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"dashboard_list","arguments":{}}}` + challenged := serve(t, e, http.MethodPost, "/mcp", dashboardCall, map[string]string{ + "Authorization": "Bearer " + tokens["access_token"].(string), + "Content-Type": "application/json", + }) + if challenged.Code != http.StatusForbidden { + t.Fatalf("dashboard step-up = %d %s, want 403", challenged.Code, challenged.Body.String()) + } + wwwAuthenticate := challenged.Header().Get("WWW-Authenticate") + if !strings.Contains(wwwAuthenticate, `error="insufficient_scope"`) || + !strings.Contains(wwwAuthenticate, `scope="dashboard:manage"`) || + !strings.Contains(wwwAuthenticate, `resource_metadata="https://fanout.example.com/.well-known/oauth-protected-resource/mcp"`) { + t.Fatalf("dashboard step-up challenge = %q", wwwAuthenticate) + } + spoofed := serve(t, e, http.MethodPost, "/mcp", dashboardCall, map[string]string{ + "Authorization": "Bearer " + tokens["access_token"].(string), + "Content-Type": "application/json", + "Mcp-Method": "tools/call", + "Mcp-Name": "observability_overview", + }) + if spoofed.Code != http.StatusForbidden { + t.Fatalf("spoofed dashboard step-up = %d %s, want 403", spoofed.Code, spoofed.Body.String()) + } + for _, body := range []string{"{", "null", "[]"} { + malformed := serve(t, e, http.MethodPost, "/mcp", body, map[string]string{ + "Authorization": "Bearer " + tokens["access_token"].(string), + "Content-Type": "application/json", + }) + if malformed.Code != http.StatusBadRequest { + t.Fatalf("malformed MCP body %q = %d %s, want 400", body, malformed.Code, malformed.Body.String()) + } + } + oversized := serve(t, e, http.MethodPost, "/mcp", strings.Repeat(" ", maxMCPAuthorizationBodyBytes+1), map[string]string{ + "Authorization": "Bearer " + tokens["access_token"].(string), + "Content-Type": "application/json", + }) + if oversized.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized MCP body = %d %s, want 413", oversized.Code, oversized.Body.String()) + } } // When dashboard write access is requested, the consent card must say so and @@ -598,7 +751,7 @@ func TestMCPOAuthConsentShowsDashboardWriteGrant(t *testing.T) { cookie := oauthSessionCookie(t, e, users, "dashboard-scope@example.com") client := registerOAuthClient(t, e) verifier, challenge := pkcePair() - scope := mcpReadScope + " fanout:dashboard" + scope := mcpReadScope + " " + auth.MCPScopeDashboardManage params := authorizeParams(client.ClientID, scope, challenge) consent := serve(t, e, http.MethodGet, "/api/auth/oauth/authorize?"+params.Encode(), "", nil, cookie) @@ -606,7 +759,7 @@ func TestMCPOAuthConsentShowsDashboardWriteGrant(t *testing.T) { t.Fatalf("consent = %d %s", consent.Code, consent.Body.String()) } body := consent.Body.String() - if !strings.Contains(body, "Create and replace dashboards") || !strings.Contains(body, "fanout:dashboard") { + if !strings.Contains(body, "Create and replace dashboards") || !strings.Contains(body, auth.MCPScopeDashboardManage) { t.Fatalf("consent must disclose dashboard write access, got: %s", body) } if strings.Contains(body, "read-only") { @@ -618,6 +771,22 @@ func TestMCPOAuthConsentShowsDashboardWriteGrant(t *testing.T) { if tokens["scope"] != scope { t.Fatalf("granted scope = %v, want %q", tokens["scope"], scope) } + allowed := serve(t, e, http.MethodPost, "/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"dashboard_list","arguments":{}}}`, map[string]string{ + "Authorization": "Bearer " + tokens["access_token"].(string), + "Content-Type": "application/json", + }) + if allowed.Code != http.StatusNoContent { + t.Fatalf("dashboard scope was rejected: %d %s", allowed.Code, allowed.Body.String()) + } + // Fully scoped tokens bypass authorization-body buffering; payload validity + // remains the MCP protocol handler's responsibility. + unparsed := serve(t, e, http.MethodPost, "/mcp", `{`, map[string]string{ + "Authorization": "Bearer " + tokens["access_token"].(string), + "Content-Type": "application/json", + }) + if unparsed.Code != http.StatusNoContent { + t.Fatalf("fully scoped request was parsed by authorization gate: %d %s", unparsed.Code, unparsed.Body.String()) + } } func serve(t *testing.T, e *echo.Echo, method, target, body string, headers map[string]string, cookies ...*http.Cookie) *httptest.ResponseRecorder { diff --git a/internal/auth/oauth_scope.go b/internal/auth/oauth_scope.go new file mode 100644 index 00000000..b29103df --- /dev/null +++ b/internal/auth/oauth_scope.go @@ -0,0 +1,57 @@ +package auth + +import "strings" + +const ( + // MCPScopeTelemetryRead grants read access to Fanout observability data. + MCPScopeTelemetryRead = "telemetry:read" + // MCPScopeDashboardManage grants access to manage the authenticated user's dashboards. + MCPScopeDashboardManage = "dashboard:manage" + + legacyMCPScopeRead = "fanout:read" + legacyMCPScopeDashboard = "fanout:dashboard" +) + +// CanonicalMCPOAuthScope validates an MCP OAuth scope string and returns its +// deduplicated canonical representation. The retired fanout:* names remain +// accepted so existing codes and tokens keep their original authority. +func CanonicalMCPOAuthScope(raw string) (string, bool) { + var read, dashboards bool + for _, scope := range strings.Fields(raw) { + switch scope { + case MCPScopeTelemetryRead, legacyMCPScopeRead: + read = true + case MCPScopeDashboardManage, legacyMCPScopeDashboard: + dashboards = true + default: + return "", false + } + } + if !read { + return "", false + } + if dashboards { + return MCPScopeTelemetryRead + " " + MCPScopeDashboardManage, true + } + return MCPScopeTelemetryRead, true +} + +// ResolveMCPRefreshScope applies the optional scope from a refresh request. +// Refreshes may retain or reduce the original grant, but never expand it. +func ResolveMCPRefreshScope(granted, requested string) (string, bool) { + canonicalGranted, ok := CanonicalMCPOAuthScope(granted) + if !ok { + return "", false + } + if strings.TrimSpace(requested) == "" { + return canonicalGranted, true + } + canonicalRequested, ok := CanonicalMCPOAuthScope(requested) + if !ok { + return "", false + } + if canonicalRequested == MCPScopeTelemetryRead+" "+MCPScopeDashboardManage && canonicalGranted != canonicalRequested { + return "", false + } + return canonicalRequested, true +} diff --git a/internal/auth/oauth_scope_test.go b/internal/auth/oauth_scope_test.go new file mode 100644 index 00000000..f118bd18 --- /dev/null +++ b/internal/auth/oauth_scope_test.go @@ -0,0 +1,57 @@ +package auth + +import "testing" + +func TestCanonicalMCPOAuthScope(t *testing.T) { + tests := []struct { + name string + raw string + want string + ok bool + }{ + {name: "read", raw: MCPScopeTelemetryRead, want: MCPScopeTelemetryRead, ok: true}, + {name: "dashboard", raw: MCPScopeTelemetryRead + " " + MCPScopeDashboardManage, want: MCPScopeTelemetryRead + " " + MCPScopeDashboardManage, ok: true}, + {name: "legacy", raw: legacyMCPScopeDashboard + " " + legacyMCPScopeRead, want: MCPScopeTelemetryRead + " " + MCPScopeDashboardManage, ok: true}, + {name: "mixed and duplicated", raw: MCPScopeDashboardManage + " " + legacyMCPScopeRead + " " + MCPScopeDashboardManage, want: MCPScopeTelemetryRead + " " + MCPScopeDashboardManage, ok: true}, + {name: "missing read", raw: MCPScopeDashboardManage, ok: false}, + {name: "empty", raw: "", ok: false}, + {name: "unknown", raw: MCPScopeTelemetryRead + " users:manage", ok: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := CanonicalMCPOAuthScope(test.raw) + if ok != test.ok || got != test.want { + t.Fatalf("CanonicalMCPOAuthScope(%q) = %q, %v; want %q, %v", test.raw, got, ok, test.want, test.ok) + } + }) + } +} + +func TestResolveMCPRefreshScope(t *testing.T) { + full := MCPScopeTelemetryRead + " " + MCPScopeDashboardManage + tests := []struct { + name string + granted string + requested string + want string + ok bool + }{ + {name: "omitted retains grant", granted: full, want: full, ok: true}, + {name: "same grant", granted: full, requested: full, want: full, ok: true}, + {name: "narrowed to read", granted: full, requested: MCPScopeTelemetryRead, want: MCPScopeTelemetryRead, ok: true}, + {name: "legacy names canonicalized", granted: legacyMCPScopeRead + " " + legacyMCPScopeDashboard, requested: legacyMCPScopeRead, want: MCPScopeTelemetryRead, ok: true}, + {name: "cannot expand", granted: MCPScopeTelemetryRead, requested: full, ok: false}, + {name: "unknown requested scope", granted: full, requested: "users:manage", ok: false}, + {name: "invalid stored grant", granted: "users:manage", requested: MCPScopeTelemetryRead, ok: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, ok := ResolveMCPRefreshScope(test.granted, test.requested) + if ok != test.ok || got != test.want { + t.Fatalf("ResolveMCPRefreshScope(%q, %q) = %q, %v; want %q, %v", test.granted, test.requested, got, ok, test.want, test.ok) + } + }) + } +} diff --git a/internal/auth/oauth_store.go b/internal/auth/oauth_store.go index 1858b35f..7fca000c 100644 --- a/internal/auth/oauth_store.go +++ b/internal/auth/oauth_store.go @@ -24,6 +24,7 @@ const ( var ( ErrOAuthClientNotFound = errors.New("oauth client not found") ErrInvalidOAuthGrant = errors.New("invalid oauth grant") + ErrInvalidOAuthScope = errors.New("invalid oauth scope") ErrOAuthRefreshReuse = errors.New("oauth refresh token reuse detected") ErrInvalidOAuthToken = errors.New("invalid oauth token") ) @@ -156,6 +157,11 @@ func (s *OAuthStore) GetClient(ctx context.Context, clientID string) (OAuthClien } func (s *OAuthStore) CreateAuthorizationCode(ctx context.Context, code OAuthAuthorizationCode) (string, error) { + canonicalScope, ok := CanonicalMCPOAuthScope(code.Scope) + if !ok { + return "", ErrInvalidOAuthGrant + } + code.Scope = canonicalScope raw, err := randomOAuthValue("foc_") if err != nil { return "", err @@ -218,18 +224,27 @@ func (s *OAuthStore) ConsumeAuthorizationCode(ctx context.Context, raw string) ( if !code.ExpiresAt.After(s.now()) { return OAuthAuthorizationCode{}, ErrInvalidOAuthGrant } + canonicalScope, ok := CanonicalMCPOAuthScope(code.Scope) + if !ok { + return OAuthAuthorizationCode{}, ErrInvalidOAuthGrant + } + code.Scope = canonicalScope return code, nil } func (s *OAuthStore) IssueTokenPair(ctx context.Context, clientID, userID, scope, resource string) (OAuthTokenPair, error) { + canonicalScope, ok := CanonicalMCPOAuthScope(scope) + if !ok { + return OAuthTokenPair{}, ErrInvalidOAuthGrant + } family, err := appid.New() if err != nil { return OAuthTokenPair{}, fmt.Errorf("oauth: generate token family: %w", err) } - return s.insertTokenPair(ctx, s.db, family, clientID, userID, scope, resource) + return s.insertTokenPair(ctx, s.db, family, clientID, userID, canonicalScope, resource) } -func (s *OAuthStore) RotateRefreshToken(ctx context.Context, clientID, raw, resource string) (OAuthTokenPair, error) { +func (s *OAuthStore) RotateRefreshToken(ctx context.Context, clientID, raw, resource, requestedScope string) (OAuthTokenPair, error) { conn, err := s.db.Conn(ctx) if err != nil { return OAuthTokenPair{}, fmt.Errorf("oauth: open refresh transaction: %w", err) @@ -297,10 +312,14 @@ func (s *OAuthStore) RotateRefreshToken(ctx context.Context, clientID, raw, reso committed = true return OAuthTokenPair{}, ErrInvalidOAuthGrant } + rotationScope, ok := ResolveMCPRefreshScope(record.Scope, requestedScope) + if !ok { + return OAuthTokenPair{}, ErrInvalidOAuthScope + } if _, err := conn.ExecContext(ctx, `UPDATE oauth_tokens SET revoked_at = ? WHERE token_hash = ?`, now, oauthHash(raw)); err != nil { return OAuthTokenPair{}, fmt.Errorf("oauth: rotate refresh token: %w", err) } - pair, err := s.insertTokenPair(ctx, conn, record.FamilyID, record.ClientID, record.UserID, record.Scope, record.Resource) + pair, err := s.insertTokenPair(ctx, conn, record.FamilyID, record.ClientID, record.UserID, rotationScope, record.Resource) if err != nil { return OAuthTokenPair{}, err } @@ -326,6 +345,11 @@ func (s *OAuthStore) VerifyAccessToken(ctx context.Context, raw, resource string if record.Kind != TokenKindAccess || record.Resource != resource || record.RevokedAt.Valid || !record.ExpiresAt.After(s.now()) { return OAuthTokenRecord{}, ErrInvalidOAuthToken } + canonicalScope, ok := CanonicalMCPOAuthScope(record.Scope) + if !ok { + return OAuthTokenRecord{}, ErrInvalidOAuthToken + } + record.Scope = canonicalScope return record, nil } diff --git a/internal/auth/oauth_store_test.go b/internal/auth/oauth_store_test.go index 3e05397d..49a44bdd 100644 --- a/internal/auth/oauth_store_test.go +++ b/internal/auth/oauth_store_test.go @@ -22,7 +22,7 @@ func TestOAuthStoreAuthorizationCodeIsSingleUse(t *testing.T) { } raw, err := store.CreateAuthorizationCode(t.Context(), OAuthAuthorizationCode{ ClientID: client.ClientID, UserID: user.ID, - RedirectURI: "http://localhost:4321/callback", Scope: "fanout:read", + RedirectURI: "http://localhost:4321/callback", Scope: MCPScopeTelemetryRead, Resource: "https://fanout.example.com/mcp", CodeChallenge: "challenge", }) if err != nil { @@ -40,6 +40,45 @@ func TestOAuthStoreAuthorizationCodeIsSingleUse(t *testing.T) { } } +func TestOAuthStoreCanonicalizesLegacyScopesWithoutInvalidatingGrants(t *testing.T) { + sqlite := newTestSQLite(t) + users := NewUserStore(sqlite.DB) + user, err := users.Create("legacy-oauth@example.com", "Legacy OAuth", "viewer") + if err != nil { + t.Fatal(err) + } + store := NewOAuthStore(sqlite.DB) + client, err := store.RegisterClient(t.Context(), "Legacy client", "", []string{"http://localhost:4321/callback"}) + if err != nil { + t.Fatal(err) + } + resource := "https://fanout.example.com/mcp" + pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead+" "+MCPScopeDashboardManage, resource) + if err != nil { + t.Fatal(err) + } + legacy := legacyMCPScopeRead + " " + legacyMCPScopeDashboard + if _, err := sqlite.DB.Exec(`UPDATE oauth_tokens SET scope = ? WHERE family_id IN (SELECT family_id FROM oauth_tokens WHERE token_hash = ?)`, legacy, oauthHash(pair.AccessToken)); err != nil { + t.Fatal(err) + } + + record, err := store.VerifyAccessToken(t.Context(), pair.AccessToken, resource) + if err != nil { + t.Fatalf("VerifyAccessToken legacy scope: %v", err) + } + want := MCPScopeTelemetryRead + " " + MCPScopeDashboardManage + if record.Scope != want { + t.Fatalf("verified scope = %q, want %q", record.Scope, want) + } + rotated, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource, "") + if err != nil { + t.Fatalf("RotateRefreshToken legacy scope: %v", err) + } + if rotated.Scope != want { + t.Fatalf("rotated scope = %q, want %q", rotated.Scope, want) + } +} + func TestOAuthStoreRefreshRotationAndReuseRevokesFamily(t *testing.T) { sqlite := newTestSQLite(t) users := NewUserStore(sqlite.DB) @@ -53,18 +92,18 @@ func TestOAuthStoreRefreshRotationAndReuseRevokesFamily(t *testing.T) { t.Fatalf("RegisterClient: %v", err) } resource := "https://fanout.example.com/mcp" - first, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + first, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } - second, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource) + second, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource, "") if err != nil { t.Fatalf("RotateRefreshToken: %v", err) } if second.RefreshToken == first.RefreshToken || second.AccessToken == first.AccessToken { t.Fatal("rotation must issue fresh credentials") } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource); !errors.Is(err, ErrOAuthRefreshReuse) { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource, ""); !errors.Is(err, ErrOAuthRefreshReuse) { t.Fatalf("reused refresh = %v, want reuse detection", err) } if _, err := store.VerifyAccessToken(t.Context(), second.AccessToken, resource); !errors.Is(err, ErrInvalidOAuthToken) { @@ -85,7 +124,7 @@ func TestRevokeAllSessionsAlsoRevokesOAuthCredentials(t *testing.T) { t.Fatal(err) } resource := "https://fanout.example.com/mcp" - pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatal(err) } @@ -95,7 +134,7 @@ func TestRevokeAllSessionsAlsoRevokesOAuthCredentials(t *testing.T) { if _, err := store.VerifyAccessToken(t.Context(), pair.AccessToken, resource); !errors.Is(err, ErrInvalidOAuthToken) { t.Fatalf("replayed access token = %v, want invalid token", err) } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource); !errors.Is(err, ErrOAuthRefreshReuse) { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource, ""); !errors.Is(err, ErrOAuthRefreshReuse) { t.Fatalf("replayed refresh token = %v, want reuse detection", err) } } @@ -113,7 +152,7 @@ func TestOAuthStoreRejectsWrongAudienceAndInactiveRefresh(t *testing.T) { t.Fatalf("RegisterClient: %v", err) } resource := "https://fanout.example.com/mcp" - pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } @@ -124,7 +163,7 @@ func TestOAuthStoreRejectsWrongAudienceAndInactiveRefresh(t *testing.T) { if _, err := users.Update(user.ID, nil, nil, nil, &active); err != nil { t.Fatalf("deactivate user: %v", err) } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource); !errors.Is(err, ErrInvalidOAuthGrant) { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource, ""); !errors.Is(err, ErrInvalidOAuthGrant) { t.Fatalf("inactive user refresh = %v, want invalid grant", err) } } @@ -147,16 +186,16 @@ func TestOAuthStoreReuseDetectionRunsBeforeExpiryCheck(t *testing.T) { t.Fatalf("RegisterClient: %v", err) } resource := "https://fanout.example.com/mcp" - first, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + first, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource); err != nil { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource, ""); err != nil { t.Fatalf("RotateRefreshToken: %v", err) } store.now = func() time.Time { return base.Add(OAuthRefreshTTL + time.Hour) } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource); !errors.Is(err, ErrOAuthRefreshReuse) { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource, ""); !errors.Is(err, ErrOAuthRefreshReuse) { t.Fatalf("expired reused refresh = %v, want reuse detection", err) } var live int @@ -183,7 +222,7 @@ func TestOAuthStoreRotateDBErrorDoesNotRevokeFamily(t *testing.T) { t.Fatalf("RegisterClient: %v", err) } resource := "https://fanout.example.com/mcp" - pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } @@ -192,7 +231,7 @@ func TestOAuthStoreRotateDBErrorDoesNotRevokeFamily(t *testing.T) { if _, err := sqlite.DB.Exec(`ALTER TABLE users RENAME TO users_offline`); err != nil { t.Fatalf("hide users table: %v", err) } - _, err = store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource) + _, err = store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource, "") if err == nil || errors.Is(err, ErrInvalidOAuthGrant) || errors.Is(err, ErrOAuthRefreshReuse) { t.Fatalf("rotate during DB failure = %v, want wrapped infrastructure error", err) } @@ -202,7 +241,7 @@ func TestOAuthStoreRotateDBErrorDoesNotRevokeFamily(t *testing.T) { if _, err := sqlite.DB.Exec(`ALTER TABLE users_offline RENAME TO users`); err != nil { t.Fatalf("restore users table: %v", err) } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource); err != nil { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource, ""); err != nil { t.Fatalf("rotate after DB recovery = %v, want success", err) } } @@ -222,7 +261,7 @@ func TestOAuthStoreVerifyAccessTokenDBErrorIsNotInvalidToken(t *testing.T) { t.Fatalf("RegisterClient: %v", err) } resource := "https://fanout.example.com/mcp" - pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } @@ -258,15 +297,15 @@ func TestOAuthStoreCleanupExpired(t *testing.T) { resource := "https://fanout.example.com/mcp" if _, err := store.CreateAuthorizationCode(t.Context(), OAuthAuthorizationCode{ ClientID: client.ClientID, UserID: user.ID, RedirectURI: "http://localhost:5555/callback", - Scope: "fanout:read", Resource: resource, CodeChallenge: "challenge", + Scope: MCPScopeTelemetryRead, Resource: resource, CodeChallenge: "challenge", }); err != nil { t.Fatalf("CreateAuthorizationCode: %v", err) } - first, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + first, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource); err != nil { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource, ""); err != nil { t.Fatalf("RotateRefreshToken: %v", err) } @@ -281,7 +320,7 @@ func TestOAuthStoreCleanupExpired(t *testing.T) { t.Fatalf("early cleanup deleted %d rows, want 0", deleted) } // The revoked row survived, so reuse detection still fires. - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource); !errors.Is(err, ErrOAuthRefreshReuse) { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, first.RefreshToken, resource, ""); !errors.Is(err, ErrOAuthRefreshReuse) { t.Fatalf("reuse after cleanup = %v, want reuse detection", err) } @@ -321,7 +360,7 @@ func TestOAuthStoreCleanupKeepsLiveFamiliesAndActiveClients(t *testing.T) { t.Fatalf("RegisterClient: %v", err) } resource := "https://fanout.example.com/mcp" - pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, "fanout:read", resource) + pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, MCPScopeTelemetryRead, resource) if err != nil { t.Fatalf("IssueTokenPair: %v", err) } @@ -341,13 +380,13 @@ func TestOAuthStoreCleanupKeepsLiveFamiliesAndActiveClients(t *testing.T) { t.Fatalf("client with live tokens was collected: %v", err) } store.now = func() time.Time { return at } - if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource); err != nil { + if _, err := store.RotateRefreshToken(t.Context(), client.ClientID, pair.RefreshToken, resource, ""); err != nil { t.Fatalf("rotate after cleanup = %v, want success", err) } } func TestOAuthTokenPairStringRedactsSecrets(t *testing.T) { - pair := OAuthTokenPair{AccessToken: "foa_secret", RefreshToken: "for_secret", ExpiresIn: 900, Scope: "fanout:read"} + pair := OAuthTokenPair{AccessToken: "foa_secret", RefreshToken: "for_secret", ExpiresIn: 900, Scope: MCPScopeTelemetryRead} got := fmt.Sprintf("%v", pair) if strings.Contains(got, "foa_secret") || strings.Contains(got, "for_secret") { t.Fatalf("String() leaked token material: %s", got) @@ -367,7 +406,7 @@ func TestOAuthStoreExpiredCodeFails(t *testing.T) { client, _ := store.RegisterClient(t.Context(), "Client", "", []string{"http://localhost:1111/callback"}) raw, err := store.CreateAuthorizationCode(t.Context(), OAuthAuthorizationCode{ ClientID: client.ClientID, UserID: user.ID, RedirectURI: "http://localhost:1111/callback", - Scope: "fanout:read", Resource: "https://fanout.example.com/mcp", CodeChallenge: "challenge", + Scope: MCPScopeTelemetryRead, Resource: "https://fanout.example.com/mcp", CodeChallenge: "challenge", }) if err != nil { t.Fatalf("CreateAuthorizationCode: %v", err) diff --git a/internal/dashboard/identity.go b/internal/dashboard/identity.go index e8713965..70d76bc6 100644 --- a/internal/dashboard/identity.go +++ b/internal/dashboard/identity.go @@ -1,6 +1,10 @@ package dashboard -import "context" +import ( + "context" + + "github.com/labstack/fanout/internal/auth" +) // OwnerMetaKey carries the dashboard owner's user ID in MCP request _meta. // @@ -12,7 +16,7 @@ import "context" // leaving the _meta fallback reachable only via the in-process transport, // where internal/agent/tools.go injects the already-authenticated user. const OwnerMetaKey = "io.fanout/owner-id" -const OAuthScope = "fanout:dashboard" +const OAuthScope = auth.MCPScopeDashboardManage type ownerContextKey struct{} diff --git a/site/src/content/docs/guides/connect-over-mcp.mdx b/site/src/content/docs/guides/connect-over-mcp.mdx index b9053ece..287f1851 100644 --- a/site/src/content/docs/guides/connect-over-mcp.mdx +++ b/site/src/content/docs/guides/connect-over-mcp.mdx @@ -46,23 +46,24 @@ needs only the URL: nothing is pre-provisioned by hand. 3. Obtains a token from `/oauth/token`, bound to the resource URI above. -You authorize it in the browser as yourself, which is what ties the agent to -your account and your role. +You authorize it in the browser as yourself, which ties the agent to your +account without delegating your first-party account role. ## Scopes | Scope | Grants | |---|---| -| `fanout:read` | The read-only observability tools | -| `fanout:dashboard` | The dashboard tools | +| `telemetry:read` | The read-only observability tools | +| `dashboard:manage` | List, read, create, and replace your dashboards | -An agent holding only `fanout:read` can investigate but cannot create or replace +An agent holding only `telemetry:read` can investigate but cannot create or replace a dashboard. ## What the agent can and cannot do -It acts as **you**. Its reach is your role's capabilities and no more — see -[roles](/reference/roles). Two consequences: +It acts on your behalf, but its delegated authority comes from the scopes you +approve rather than your account role. Fanout also checks that your current +account capabilities still permit those scopes. Two consequences: - Dashboard tools are owner-scoped. An agent can manage your dashboards and cannot touch anyone else's.