Skip to content
Open
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
35 changes: 35 additions & 0 deletions backend/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,41 @@ func main() {
teams.PATCH(":id/slack/status", measure.UpdateTeamSlackStatus)
}

mcp := r.Group("/mcp")
{
mcp.GET("teams/:id/keys", measure.ValidateAccessToken(), measure.GetMcpKeys)
mcp.POST("teams/:id/keys", measure.ValidateAccessToken(), measure.CreateMcpKey)
mcp.PATCH("teams/:id/keys/:keyId/revoke", measure.ValidateAccessToken(), measure.RevokeMcpKey)
mcp.GET("keys/teamId", measure.ValidateMcpKey(), measure.GetUserAndTeamIdForMcpKey)
mcp.POST("teams/:id/usage/ai", measure.ValidateAccessTokenOrMcpKey(), measure.ReportAiUsage)
mcp.GET("user", measure.ValidateAccessTokenOrMcpKey(), measure.GetUser)
mcp.GET("teams/:id/apps", measure.ValidateAccessTokenOrMcpKey(), measure.GetTeamApps)
mcp.GET("apps/:id/journey", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppJourney)
mcp.GET("apps/:id/metrics", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppMetrics)
mcp.GET("apps/:id/filters", measure.ValidateAccessTokenOrMcpKey(), measure.GetAppFilters)
mcp.GET("apps/:id/crashGroups", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashOverview)
mcp.GET("apps/:id/crashGroups/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashOverviewPlotInstances)
mcp.GET("apps/:id/crashGroups/:crashGroupId/crashes", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailCrashes)
mcp.GET("apps/:id/crashGroups/:crashGroupId/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailPlotInstances)
mcp.GET("apps/:id/crashGroups/:crashGroupId/plots/distribution", measure.ValidateAccessTokenOrMcpKey(), measure.GetCrashDetailAttributeDistribution)
mcp.GET("apps/:id/anrGroups", measure.ValidateAccessTokenOrMcpKey(), measure.GetANROverview)
mcp.GET("apps/:id/anrGroups/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetANROverviewPlotInstances)
mcp.GET("apps/:id/anrGroups/:anrGroupId/anrs", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailANRs)
mcp.GET("apps/:id/anrGroups/:anrGroupId/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailPlotInstances)
mcp.GET("apps/:id/anrGroups/:anrGroupId/plots/distribution", measure.ValidateAccessTokenOrMcpKey(), measure.GetANRDetailAttributeDistribution)
mcp.GET("apps/:id/sessions", measure.ValidateAccessTokenOrMcpKey(), measure.GetSessionsOverview)
mcp.GET("apps/:id/sessions/:sessionId", measure.ValidateAccessTokenOrMcpKey(), measure.GetSession)
mcp.GET("apps/:id/sessions/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetSessionsOverviewPlotInstances)
mcp.GET("apps/:id/spans/roots/names", measure.ValidateAccessTokenOrMcpKey(), measure.GetRootSpanNames)
mcp.GET("apps/:id/spans", measure.ValidateAccessTokenOrMcpKey(), measure.GetSpansForSpanName)
mcp.GET("apps/:id/spans/plots/metrics", measure.ValidateAccessTokenOrMcpKey(), measure.GetMetricsPlotForSpanName)
mcp.GET("apps/:id/traces/:traceId", measure.ValidateAccessTokenOrMcpKey(), measure.GetTrace)
mcp.GET("apps/:id/bugReports", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReportsOverview)
mcp.GET("apps/:id/bugReports/plots/instances", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReportsInstancesPlot)
mcp.GET("apps/:id/bugReports/:bugReportId", measure.ValidateAccessTokenOrMcpKey(), measure.GetBugReport)
mcp.GET("apps/:id/alerts", measure.ValidateAccessTokenOrMcpKey(), measure.GetAlertsOverview)
}

slack := r.Group("/slack")
{
slack.POST("/connect", measure.ConnectTeamSlack)
Expand Down
167 changes: 167 additions & 0 deletions backend/api/measure/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ func extractToken(c *gin.Context) (token string) {
return
}

// extractMcpKey extracts the MCP key
// from the Authorization header.
func extractMcpKey(c *gin.Context) (token string) {
authHeader := c.GetHeader("Authorization")
splitToken := strings.Split(authHeader, "Bearer ")

if len(splitToken) != 2 {
c.AbortWithStatus(http.StatusUnauthorized)
return
}

token = strings.TrimSpace(splitToken[1])

if token == "" {
c.AbortWithStatus((http.StatusUnauthorized))
return
}

return
}

// extractRefreshToken extracts the refresh token
// from the cookie or Authorization header
func extractRefreshToken(c *gin.Context) (token string) {
Expand Down Expand Up @@ -145,6 +166,100 @@ func ValidateAccessToken() gin.HandlerFunc {
}
}

// ValidateMcpKey validates the Measure MCP key.
func ValidateMcpKey() gin.HandlerFunc {
return func(c *gin.Context) {
key := extractMcpKey(c)

userId, teamId, err := DecodeMcpKey(c, key)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid MCP key",
})
return
}

if userId == nil {
msg := "no user found for this MCP key"
fmt.Println(msg)
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
return
}

if teamId == nil {
msg := "no team found for this MCP key"
fmt.Println(msg)
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
return
}

c.Set("userId", userId.String())
c.Set("teamId", teamId.String())
c.Next()
}
}

// ValidateAccessTokenOrMcpKey validates the Measure access token or MCP key. If either one succeeds, the request is allowed.
func ValidateAccessTokenOrMcpKey() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)

// Try access token validation first
accessToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
err := fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
return nil, err
}

return server.Server.Config.AccessTokenSecret, nil
})

// If access token is valid, use it
if err == nil {
if claims, ok := accessToken.Claims.(jwt.MapClaims); ok {
sessionId := claims["jti"]
c.Set("sessionId", sessionId)

userId := claims["sub"]
c.Set("userId", userId)

c.Next()
return
}
}

// Access token failed, try MCP key
key := extractMcpKey(c)
userId, teamId, err := DecodeMcpKey(c, key)
if err != nil {
// Both validations failed
fmt.Println("both access token and MCP key validation failed")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid access token or MCP key",
})
return
}

if userId == nil {
msg := "no user found for this MCP key"
fmt.Println(msg)
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
return
}

if teamId == nil {
msg := "no team found for this MCP key"
fmt.Println(msg)
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": msg})
return
}

c.Set("userId", userId.String())
c.Set("teamId", teamId.String())
c.Next()
}
}

// ValidateRefreshToken validates the Measure refresh token.
func ValidateRefreshToken() gin.HandlerFunc {
return func(c *gin.Context) {
Expand Down Expand Up @@ -967,6 +1082,58 @@ func RefreshToken(c *gin.Context) {
})
}

// GetUser returns the current user information
func GetUser(c *gin.Context) {
userId := c.GetString("userId")

ctx := c.Request.Context()

if userId == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Not authenticated",
})
return
}

user := &User{
ID: &userId,
}

ownTeam, err := user.getOwnTeam(ctx)
if err != nil {
msg := "Unable to get user's team"
fmt.Println(msg, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": msg,
})
return
}

err = user.getUserDetails(ctx)

if err != nil {
msg := "Unable to get user details"
fmt.Println(msg, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": msg,
})
return
}

c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": userId,
"own_team_id": ownTeam.ID,
"name": user.Name,
"email": user.Email,
"confirmed_at": user.ConfirmedAt,
"last_sign_in_at": user.LastSignInAt,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
},
})
}

// GetSession returns the current session information
func GetAuthSession(c *gin.Context) {
userId := c.GetString("userId")
Expand Down
Loading
Loading