Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/fanout-docgen/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ func renderRoutes(routes []api.RouteDoc) []byte {

b.WriteString("- `/debug/pprof/*` — only when `FANOUT_PPROF_ENABLED` is true, which is\n")
b.WriteString(" **not** the default. Enabling it also turns on mutex and block sampling.\n")
b.WriteString("- `/mcp`, `/api/mcp`, `/oauth/*` and `/.well-known/*` — only when\n")
b.WriteString("- `/mcp`, `/api/mcp`, `/api/auth/oauth/*`, `/oauth/*` and `/.well-known/*` — only when\n")
b.WriteString(" `FANOUT_MCP_ENABLED` is true, which **is** the default.\n")
b.WriteString("- `/api/agent/*` — only when `FANOUT_AI_PROVIDER` selects `anthropic` or\n")
b.WriteString(" `openai`, with that provider's required key. An empty provider disables chat.\n")
Expand Down
15 changes: 14 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,19 @@ The `issuer` value relaxes that check and is accepted only when an allow policy
is configured, on the basis that the issuer itself vouches for the address. Use
it only with a provider you administer.

## MCP OAuth connections

Signed-in users can review their connected agents in Settings and disconnect
one agent at a time. Disconnecting immediately revokes that client's access and
refresh tokens for the current user. Other agents and browser sessions stay
connected. To reconnect, authorize the agent again.

`GET /api/auth/oauth/connections` lists the current user's live connections;
`POST /api/auth/oauth/connections/{id}/revoke` disconnects one client. Both
require a browser session. Administrators can still revoke all of a user's
access, including browser sessions and OAuth tokens, through
`POST /api/users/{id}/access/revoke`.

## Credentials at a glance

| Credential | Purpose | Rotation |
Expand All @@ -247,7 +260,7 @@ it only with a provider you administer.
| Browser session | Interactive access | Idle and absolute TTL; revoked on role, email, or status change |
| Ingest token | OTLP ingest | Settings page |
| Metrics token | `/metrics` scrape | `FANOUT_METRICS_TOKEN` |
| MCP OAuth tokens | MCP clients | One-hour access tokens; refresh rotation with replay-family revocation, admitting the same client's just-rotated token for one minute so processes sharing a credential can refresh together |
| MCP OAuth tokens | MCP clients | 30-day access tokens, checked on every request; 90-day refresh tokens renewed on each use. Refresh rotation with replay-family revocation, admitting the same client's rotated token for one access-token lifetime so processes sharing a credential can refresh from stale copies. Users disconnect individual agents in Settings; admins can still revoke all of a user's access |

Local email codes allow three attempts per code and expire after five minutes.
Attempts are reserved atomically, so concurrent guesses cannot exceed the limit.
5 changes: 5 additions & 0 deletions internal/api/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ func classifyRoute(method, path string) (routePolicy, bool) {
return routePolicy{kind: routePolicyAuthenticated}, method == http.MethodPost
case path == "/api/auth/oauth/authorize":
return routePolicy{kind: routePolicyAuthenticated}, read || method == http.MethodPost
case path == "/api/auth/oauth/connections":
return routePolicy{kind: routePolicyAuthenticated}, read
case strings.HasPrefix(path, "/api/auth/oauth/connections/"):
id, action, _ := strings.Cut(strings.TrimPrefix(path, "/api/auth/oauth/connections/"), "/")
return routePolicy{kind: routePolicyAuthenticated}, id != "" && action == "revoke" && method == http.MethodPost
case strings.HasPrefix(path, "/.well-known/"):
return routePolicy{kind: routePolicyProtocol}, read
case path == "/oauth/register" || path == "/oauth/token":
Expand Down
2 changes: 2 additions & 0 deletions internal/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ func TestRoutePolicyClassification(t *testing.T) {
{http.MethodPost, "/api/auth/logout", routePolicyAuthenticated, ""},
{http.MethodGet, "/api/auth/oauth/authorize", routePolicyAuthenticated, ""},
{http.MethodPost, "/api/auth/oauth/authorize", routePolicyAuthenticated, ""},
{http.MethodGet, "/api/auth/oauth/connections", routePolicyAuthenticated, ""},
{http.MethodPost, "/api/auth/oauth/connections/client-1/revoke", routePolicyAuthenticated, ""},
{http.MethodGet, "/api/traces/abc", routePolicyCapability, ReadTelemetry},
{http.MethodHead, "/api/traces/abc", routePolicyCapability, ReadTelemetry},
{http.MethodGet, "/api/intelligence", routePolicyCapability, ReadTelemetry},
Expand Down
43 changes: 43 additions & 0 deletions internal/api/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,49 @@ func (h *MCPAuthorization) Register(e *echo.Echo) {
e.POST("/oauth/token", h.Token)
e.GET("/api/auth/oauth/authorize", h.Authorize)
e.POST("/api/auth/oauth/authorize", h.Authorize)
e.GET("/api/auth/oauth/connections", h.ListConnections, browserOAuthConnection)
e.POST("/api/auth/oauth/connections/:id/revoke", h.RevokeConnection, browserOAuthConnection)
}

func browserOAuthConnection(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
c.Response().Header().Set("Cache-Control", "no-store")
if fields := strings.Fields(c.Request().Header.Get("Authorization")); len(fields) > 0 && strings.EqualFold(fields[0], "Bearer") {
return echo.NewHTTPError(http.StatusUnauthorized, "browser session required")
}
return next(c)
}
}

func (h *MCPAuthorization) ListConnections(c *echo.Context) error {
userID, err := RequestOwner(c)
if err != nil {
return err
}
connections, err := h.store.ListConnections(c.Request().Context(), userID)
if err != nil {
slog.Error("list OAuth connections failed", "err", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list connections")
}
return c.JSON(http.StatusOK, map[string]any{"connections": connections})
}

func (h *MCPAuthorization) RevokeConnection(c *echo.Context) error {
userID, err := RequestOwner(c)
if err != nil {
return err
}
err = h.store.RevokeConnection(c.Request().Context(), userID, c.Param("id"), appauth.AuditEvent{
RemoteIP: c.RealIP(), UserAgent: c.Request().UserAgent(),
})
if errors.Is(err, appauth.ErrOAuthConnectionNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "connection not found")
}
if err != nil {
slog.Error("revoke OAuth connection failed", "err", err)
return echo.NewHTTPError(http.StatusInternalServerError, "failed to revoke connection")
}
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
}

func (h *MCPAuthorization) ProtectMCP(next http.Handler) http.Handler {
Expand Down
123 changes: 123 additions & 0 deletions internal/api/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -861,3 +862,125 @@ func TestMCPOAuthConsentMarksDynamicClientUnverified(t *testing.T) {
t.Fatalf("consent screen presents an attacker-chosen client name with no provenance warning: %s", page)
}
}

func TestOAuthConnectionsRequireBrowserSession(t *testing.T) {
s := newTestAuthServer(t)
store := auth.NewOAuthStore(s.db.DB)
handler, err := NewMCPAuthorization(store, s.users, testMCPResource)
if err != nil {
t.Fatal(err)
}
handler.Register(s.e)
user, err := s.users.CreateWithAudit("connections-auth@example.com", "", "admin", auth.AuditEvent{EventType: "user.created", Outcome: "success"})
if err != nil {
t.Fatal(err)
}
client := registerOAuthClient(t, s.e)
pair, err := store.IssueTokenPair(t.Context(), client.ClientID, user.ID, mcpReadScope, testMCPResource)
if err != nil {
t.Fatal(err)
}
cookie := s.login(t, user)
for _, route := range []struct{ method, path string }{
{http.MethodGet, "/api/auth/oauth/connections"},
{http.MethodPost, "/api/auth/oauth/connections/" + client.ClientID + "/revoke"},
} {
for _, credential := range []string{"anonymous", "bearer", "bearer with cookie"} {
t.Run(route.method+" "+credential, func(t *testing.T) {
req := httptest.NewRequest(route.method, route.path, nil)
req.Header.Set("Fanout-Request", "1")
if credential != "anonymous" {
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
}
if credential == "bearer with cookie" {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
s.e.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response = %d %v %s", rec.Code, rec.Header(), rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["code"] != "unauthorized" || body["message"] == nil {
t.Fatalf("error body = %s, %v", rec.Body.String(), err)
}
})
}
}
}

func TestOAuthConnectionsAreOwnAccountOnlyForEveryRole(t *testing.T) {
for _, role := range []auth.Role{auth.RoleAdmin, auth.RoleOperator, auth.RoleViewer} {
t.Run(string(role), func(t *testing.T) {
s := newTestAuthServer(t)
store := auth.NewOAuthStore(s.db.DB)
handler, err := NewMCPAuthorization(store, s.users, testMCPResource)
if err != nil {
t.Fatal(err)
}
handler.Register(s.e)
user, err := s.users.CreateWithAudit("connections-owner@example.com", "", role, auth.AuditEvent{EventType: "user.created", Outcome: "success"})
if err != nil {
t.Fatal(err)
}
other, err := s.users.CreateWithAudit("connections-other@example.com", "", "viewer", auth.AuditEvent{EventType: "user.created", Outcome: "success"})
if err != nil {
t.Fatal(err)
}
ownClient := registerOAuthClient(t, s.e)
otherClient := registerOAuthClient(t, s.e)
issue := func(clientID, userID string) auth.OAuthTokenPair {
t.Helper()
pair, err := store.IssueTokenPair(t.Context(), clientID, userID, mcpReadScope, testMCPResource)
if err != nil {
t.Fatal(err)
}
return pair
}
ownToken := issue(ownClient.ClientID, user.ID)
otherToken := issue(otherClient.ClientID, other.ID)
cookie := s.login(t, user)
list := serve(t, s.e, http.MethodGet, "/api/auth/oauth/connections?user_id="+other.ID, "", nil, cookie)
var body struct {
Connections []auth.OAuthConnection `json:"connections"`
}
if err := json.Unmarshal(list.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if list.Code != http.StatusOK || list.Header().Get("Cache-Control") != "no-store" || len(body.Connections) != 1 || body.Connections[0].ClientID != ownClient.ClientID || body.Connections[0].ClientName != ownClient.ClientName || body.Connections[0].ConnectedAt.IsZero() || body.Connections[0].RefreshedAt.IsZero() || body.Connections[0].Scope != mcpReadScope {
t.Fatalf("list = %d %s", list.Code, list.Body.String())
}
for _, clientID := range []string{otherClient.ClientID, "unknown"} {
rec := serve(t, s.e, http.MethodPost, "/api/auth/oauth/connections/"+clientID+"/revoke", "", map[string]string{"Fanout-Request": "1"}, cookie)
if rec.Code != http.StatusNotFound || rec.Header().Get("Cache-Control") != "no-store" || !strings.Contains(rec.Body.String(), `"code":"not_found"`) {
t.Fatalf("other or unknown revoke = %d %s", rec.Code, rec.Body.String())
}
}
request := httptest.NewRequest(http.MethodPost, "/api/auth/oauth/connections/"+ownClient.ClientID+"/revoke", nil)
request.AddCookie(cookie)
blocked := httptest.NewRecorder()
s.e.ServeHTTP(blocked, request)
if blocked.Code != http.StatusForbidden {
t.Fatalf("revoke without CSRF header = %d", blocked.Code)
}
revoked := serve(t, s.e, http.MethodPost, "/api/auth/oauth/connections/"+ownClient.ClientID+"/revoke", "", map[string]string{"Fanout-Request": "1", "User-Agent": "connection-test"}, cookie)
if revoked.Code != http.StatusOK || revoked.Header().Get("Cache-Control") != "no-store" || !strings.Contains(revoked.Body.String(), `"ok":true`) {
t.Fatalf("revoke = %d %s", revoked.Code, revoked.Body.String())
}
if _, err := store.VerifyAccessToken(t.Context(), ownToken.AccessToken, testMCPResource); !errors.Is(err, auth.ErrInvalidOAuthToken) {
t.Fatalf("revoked token = %v", err)
}
if _, err := store.VerifyAccessToken(t.Context(), otherToken.AccessToken, testMCPResource); err != nil {
t.Fatalf("other token = %v", err)
}
list = serve(t, s.e, http.MethodGet, "/api/auth/oauth/connections", "", nil, cookie)
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"connections":[]`) {
t.Fatalf("browser session after revoke = %d %s", list.Code, list.Body.String())
}
var events int
if err := s.db.DB.QueryRow(`SELECT COUNT(*) FROM auth_audit_events WHERE event_type = 'oauth_connection.revoked' AND actor_user_id = ? AND target_type = 'oauth_client' AND target_id = ? AND user_agent = 'connection-test' AND json_extract(metadata_json, '$.client_name') = ?`, user.ID, ownClient.ClientID, ownClient.ClientName).Scan(&events); err != nil || events != 1 {
t.Fatalf("revocation audits = %d, %v", events, err)
}
})
}
}
4 changes: 2 additions & 2 deletions internal/auth/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func recordAudit(ctx context.Context, executor auditExecutor, event AuditEvent)
if !validAuditValue(event.EventType, map[AuditEventType]struct{}{
"authorization.denied": {}, "identity.linked": {}, "ingest_key.rotated": {},
"login.failed": {}, "login.requested": {}, "login.succeeded": {}, "logout": {},
"login_link.issued": {}, "login_link.redeemed": {},
"login_link.issued": {}, "login_link.redeemed": {}, "oauth_connection.revoked": {},
"oidc.denied": {}, "role.changed": {}, "session.revoked": {}, "setup.completed": {},
"user.created": {}, "user.suspended": {}, "user.deleted": {}, "user.provisioned": {}, "user.updated": {},
}) {
Expand All @@ -65,7 +65,7 @@ func recordAudit(ctx context.Context, executor auditExecutor, event AuditEvent)
appmetrics.AuthAuditWriteFailures.Inc()
return fmt.Errorf("auth: invalid audit outcome %q", event.Outcome)
}
if event.TargetType != "" && !validAuditValue(event.TargetType, map[AuditTargetType]struct{}{"email": {}, "identity": {}, "ingest": {}, "route": {}, "user": {}}) {
if event.TargetType != "" && !validAuditValue(event.TargetType, map[AuditTargetType]struct{}{"email": {}, "identity": {}, "ingest": {}, "oauth_client": {}, "route": {}, "user": {}}) {
appmetrics.AuthAuditWriteFailures.Inc()
return fmt.Errorf("auth: invalid audit target type %q", event.TargetType)
}
Expand Down
Loading
Loading