Ctrl K
ComunidadGuías…

api/mcp_oauth.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package api

import (
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"path"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/coroot/coroot/db"
	"github.com/coroot/coroot/utils"
	"github.com/golang-jwt/jwt/v5"
	"k8s.io/klog"
)

const (
	mcpOAuthCodeTTL          = 30 * time.Second
	mcpOAuthAccessTokenTTL   = time.Hour
	mcpOAuthRefreshTokenTTL  = 30 * 24 * time.Hour
	mcpOAuthScope            = "mcp"
	mcpBearerPrefix          = "Bearer "
	mcpOAuthIssuerPath       = "/"
	mcpOAuthAuthorizePath    = "/oauth/authorize"
	mcpOAuthTokenPath        = "/oauth/token"
	mcpOAuthRegisterPath     = "/oauth/register"
	mcpOAuthRevokePath       = "/oauth/revoke"
	mcpOAuthResourcePath     = "/mcp"
	mcpOAuthMaxClientNameLen = 200
	mcpOAuthMaxRedirectURIs  = 5
)

const (
	mcpAudClient  = "mcp:client"
	mcpAudCode    = "mcp:code"
	mcpAudAccess  = "mcp:access"
	mcpAudRefresh = "mcp:refresh"
)

const (
	oauthErrInvalidRequest          = "invalid_request"
	oauthErrInvalidGrant            = "invalid_grant"
	oauthErrUnauthorizedClient      = "unauthorized_client"
	oauthErrUnsupportedGrantType    = "unsupported_grant_type"
	oauthErrUnsupportedResponseType = "unsupported_response_type"
	oauthErrAccessDenied            = "access_denied"
	oauthErrInvalidClientMetadata   = "invalid_client_metadata"
	oauthErrInvalidRedirectURI      = "invalid_redirect_uri"
)

type mcpClientClaims struct {
	Name         string   `json:"client_name"`
	RedirectURIs []string `json:"redirect_uris"`
	jwt.RegisteredClaims
}

type mcpCodeClaims struct {
	ClientId      string `json:"client_id"`
	RedirectURI   string `json:"redirect_uri"`
	CodeChallenge string `json:"code_challenge"`
	jwt.RegisteredClaims
}

type mcpTokenClaims struct {
	ClientId string `json:"client_id"`
	jwt.RegisteredClaims
}

func (api *Api) mcpAuthSecret() ([]byte, error) {
	var secret string
	if err := api.db.GetSetting(AuthSecretSettingName, &secret); err != nil {
		return nil, fmt.Errorf("failed to get auth secret: %w", err)
	}
	return []byte(secret), nil
}

func (api *Api) mcpSign(claims jwt.Claims) (string, error) {
	secret, err := api.mcpAuthSecret()
	if err != nil {
		return "", err
	}
	return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

func (api *Api) mcpVerify(audience string, token string, out jwt.Claims) error {
	secret, err := api.mcpAuthSecret()
	if err != nil {
		return err
	}
	_, err = jwt.ParseWithClaims(token, out, func(*jwt.Token) (any, error) { return secret, nil },
		jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
		jwt.WithAudience(audience),
	)
	return err
}

func (api *Api) mcpIssueToken(audience string, userId int, clientId string, ttl time.Duration) (string, error) {
	return api.mcpSign(mcpTokenClaims{
		ClientId: clientId,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   strconv.Itoa(userId),
			Audience:  jwt.ClaimStrings{audience},
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
		},
	})
}

func (api *Api) MCPUserFromBearer(r *http.Request) *db.User {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, mcpBearerPrefix) {
		return nil
	}
	token := strings.TrimPrefix(auth, mcpBearerPrefix)
	var claims mcpTokenClaims
	if err := api.mcpVerify(mcpAudAccess, token, &claims); err != nil {
		return api.GetUserByApiKey(r)
	}
	userId, err := strconv.Atoi(claims.Subject)
	if err != nil {
		return nil
	}
	user, err := api.db.GetUser(userId)
	if err != nil || user == nil {
		return nil
	}
	return user
}

func (api *Api) MCPOAuthProtectedResource(w http.ResponseWriter, r *http.Request) {
	issuer := api.mcpIssuerURL(r)
	resp := map[string]any{
		"resource":                 api.GetAbsoluteUrl(r, mcpOAuthResourcePath).String(),
		"authorization_servers":    []string{issuer},
		"scopes_supported":         []string{mcpOAuthScope},
		"bearer_methods_supported": []string{"header"},
	}
	utils.WriteJson(w, resp)
}

func (api *Api) MCPOAuthAuthorizationServer(w http.ResponseWriter, r *http.Request) {
	issuer := api.mcpIssuerURL(r)
	resp := map[string]any{
		"issuer":                                issuer,
		"authorization_endpoint":                api.GetAbsoluteUrl(r, mcpOAuthAuthorizePath).String(),
		"token_endpoint":                        api.GetAbsoluteUrl(r, mcpOAuthTokenPath).String(),
		"registration_endpoint":                 api.GetAbsoluteUrl(r, mcpOAuthRegisterPath).String(),
		"revocation_endpoint":                   api.GetAbsoluteUrl(r, mcpOAuthRevokePath).String(),
		"response_types_supported":              []string{"code"},
		"grant_types_supported":                 []string{"authorization_code", "refresh_token"},
		"token_endpoint_auth_methods_supported": []string{"none"},
		"code_challenge_methods_supported":      []string{"S256"},
		"scopes_supported":                      []string{mcpOAuthScope},
	}
	utils.WriteJson(w, resp)
}

func (api *Api) mcpIssuerURL(r *http.Request) string {
	u := api.GetAbsoluteUrl(r, mcpOAuthIssuerPath)
	s := u.String()
	return strings.TrimRight(s, "/")
}

func (api *Api) MCPOAuthRegister(w http.ResponseWriter, r *http.Request) {
	var req struct {
		ClientName              string   `json:"client_name"`
		RedirectURIs            []string `json:"redirect_uris"`
		GrantTypes              []string `json:"grant_types"`
		ResponseTypes           []string `json:"response_types"`
		TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidClientMetadata, "invalid request body")
		return
	}
	if len(req.RedirectURIs) == 0 {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidRedirectURI, "redirect_uris required")
		return
	}
	if len(req.RedirectURIs) > mcpOAuthMaxRedirectURIs {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidRedirectURI, "too many redirect_uris")
		return
	}
	for _, ru := range req.RedirectURIs {
		if _, err := url.Parse(ru); err != nil {
			mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidRedirectURI, "invalid redirect_uri")
			return
		}
	}
	if len(req.ClientName) > mcpOAuthMaxClientNameLen {
		req.ClientName = req.ClientName[:mcpOAuthMaxClientNameLen]
	}
	clientId, err := api.mcpSign(mcpClientClaims{
		Name:         req.ClientName,
		RedirectURIs: req.RedirectURIs,
		RegisteredClaims: jwt.RegisteredClaims{
			Audience: jwt.ClaimStrings{mcpAudClient},
			IssuedAt: jwt.NewNumericDate(time.Now()),
		},
	})
	if err != nil {
		klog.Errorln("mcp:", err)
		http.Error(w, "", http.StatusInternalServerError)
		return
	}
	utils.WriteJson(w, map[string]any{
		"client_id":                  clientId,
		"client_id_issued_at":        time.Now().Unix(),
		"client_name":                req.ClientName,
		"redirect_uris":              req.RedirectURIs,
		"grant_types":                []string{"authorization_code", "refresh_token"},
		"response_types":             []string{"code"},
		"token_endpoint_auth_method": "none",
	})
}

type authorizeRequest struct {
	ClientId            string
	RedirectURI         string
	ResponseType        string
	CodeChallenge       string
	CodeChallengeMethod string
	State               string
	Scope               string
}

func parseAuthorizeRequest(r *http.Request) authorizeRequest {
	return authorizeRequest{
		ClientId:            r.Form.Get("client_id"),
		RedirectURI:         r.Form.Get("redirect_uri"),
		ResponseType:        r.Form.Get("response_type"),
		CodeChallenge:       r.Form.Get("code_challenge"),
		CodeChallengeMethod: r.Form.Get("code_challenge_method"),
		State:               r.Form.Get("state"),
		Scope:               r.Form.Get("scope"),
	}
}

func (api *Api) MCPOAuthAuthorize(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidRequest, "cannot parse params")
		return
	}
	req := parseAuthorizeRequest(r)

	var client mcpClientClaims
	if err := api.mcpVerify(mcpAudClient, req.ClientId, &client); err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrUnauthorizedClient, "unknown client")
		return
	}
	if !mcpRedirectURIAllowed(client.RedirectURIs, req.RedirectURI) {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidRequest, "redirect_uri mismatch")
		return
	}
	if req.ResponseType != "code" {
		mcpRedirectErr(w, r, req.RedirectURI, req.State, oauthErrUnsupportedResponseType, "")
		return
	}
	if req.CodeChallenge == "" || req.CodeChallengeMethod != "S256" {
		mcpRedirectErr(w, r, req.RedirectURI, req.State, oauthErrInvalidRequest, "PKCE S256 required")
		return
	}

	user := api.GetUser(r)
	switch r.Method {
	case http.MethodGet:
		api.mcpAuthorizeShowConsent(w, r, client.Name, user)
	case http.MethodPost:
		api.mcpAuthorizeIssueCode(w, r, req, user)
	}
}

func (api *Api) mcpAuthorizeShowConsent(w http.ResponseWriter, r *http.Request, clientName string, user *db.User) {
	if user == nil {
		http.Redirect(w, r, api.mcpLoginRedirect(r), http.StatusFound)
		return
	}
	http.Redirect(w, r, api.mcpConsentRedirect(r, clientName, user), http.StatusFound)
}

func (api *Api) mcpAuthorizeIssueCode(w http.ResponseWriter, r *http.Request, req authorizeRequest, user *db.User) {
	if user == nil {
		mcpOAuthError(w, http.StatusUnauthorized, oauthErrAccessDenied, "not authenticated")
		return
	}
	if r.Form.Get("decision") != "allow" {
		mcpRedirectErr(w, r, req.RedirectURI, req.State, oauthErrAccessDenied, "")
		return
	}

	code, err := api.mcpSign(mcpCodeClaims{
		ClientId:      req.ClientId,
		RedirectURI:   req.RedirectURI,
		CodeChallenge: req.CodeChallenge,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   strconv.Itoa(user.Id),
			Audience:  jwt.ClaimStrings{mcpAudCode},
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(mcpOAuthCodeTTL)),
		},
	})
	if err != nil {
		klog.Errorln("mcp:", err)
		http.Error(w, "", http.StatusInternalServerError)
		return
	}

	u, _ := url.Parse(req.RedirectURI)
	q := u.Query()
	q.Set("code", code)
	if req.State != "" {
		q.Set("state", req.State)
	}
	if req.Scope != "" {
		q.Set("scope", req.Scope)
	}
	u.RawQuery = q.Encode()
	http.Redirect(w, r, u.String(), http.StatusFound)
}

func (api *Api) mcpLoginRedirect(r *http.Request) string {
	u := url.URL{Path: path.Join(api.cfg.UrlBasePath, "login")}
	q := url.Values{}
	q.Set("next", r.URL.RequestURI())
	u.RawQuery = q.Encode()
	return u.String()
}

func (api *Api) mcpConsentRedirect(r *http.Request, clientName string, user *db.User) string {
	u := url.URL{Path: path.Join(api.cfg.UrlBasePath, "auth/mcp-consent")}
	q := url.Values{}
	for _, k := range []string{"client_id", "redirect_uri", "response_type", "code_challenge", "code_challenge_method", "state", "scope"} {
		if v := r.Form.Get(k); v != "" {
			q.Set(k, v)
		}
	}
	if clientName == "" {
		clientName = "An MCP client"
	}
	q.Set("client_name", clientName)
	q.Set("user_name", mcpDisplayUserName(user))
	u.RawQuery = q.Encode()
	return u.String()
}

func mcpDisplayUserName(u *db.User) string {
	if u.Name != "" {
		return u.Name
	}
	if u.Email != "" {
		return u.Email
	}
	return fmt.Sprintf("user #%d", u.Id)
}

func (api *Api) MCPOAuthToken(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidRequest, "cannot parse params")
		return
	}

	switch r.Form.Get("grant_type") {
	case "authorization_code":
		api.mcpExchangeCode(w, r)
	case "refresh_token":
		api.mcpExchangeRefresh(w, r)
	default:
		mcpOAuthError(w, http.StatusBadRequest, oauthErrUnsupportedGrantType, "")
	}
}

func (api *Api) mcpExchangeCode(w http.ResponseWriter, r *http.Request) {
	codeStr := r.Form.Get("code")
	clientId := r.Form.Get("client_id")
	redirectURI := r.Form.Get("redirect_uri")
	verifier := r.Form.Get("code_verifier")

	var code mcpCodeClaims
	if err := api.mcpVerify(mcpAudCode, codeStr, &code); err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "invalid code")
		return
	}
	if code.ClientId != clientId {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "client_id mismatch")
		return
	}
	if code.RedirectURI != redirectURI {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "redirect_uri mismatch")
		return
	}
	if !mcpVerifyPKCE(verifier, code.CodeChallenge) {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "PKCE verification failed")
		return
	}
	userId, err := strconv.Atoi(code.Subject)
	if err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "invalid subject")
		return
	}
	api.mcpIssueTokenPair(w, userId, clientId)
}

func (api *Api) mcpExchangeRefresh(w http.ResponseWriter, r *http.Request) {
	refresh := r.Form.Get("refresh_token")
	clientId := r.Form.Get("client_id")

	var claims mcpTokenClaims
	if err := api.mcpVerify(mcpAudRefresh, refresh, &claims); err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "invalid refresh_token")
		return
	}
	if clientId != "" && claims.ClientId != clientId {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "client_id mismatch")
		return
	}
	userId, err := strconv.Atoi(claims.Subject)
	if err != nil {
		mcpOAuthError(w, http.StatusBadRequest, oauthErrInvalidGrant, "invalid subject")
		return
	}
	api.mcpIssueTokenPair(w, userId, claims.ClientId)
}

func (api *Api) mcpIssueTokenPair(w http.ResponseWriter, userId int, clientId string) {
	access, err := api.mcpIssueToken(mcpAudAccess, userId, clientId, mcpOAuthAccessTokenTTL)
	if err != nil {
		klog.Errorln("mcp:", err)
		http.Error(w, "", http.StatusInternalServerError)
		return
	}
	refresh, err := api.mcpIssueToken(mcpAudRefresh, userId, clientId, mcpOAuthRefreshTokenTTL)
	if err != nil {
		klog.Errorln("mcp:", err)
		http.Error(w, "", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Cache-Control", "no-store")
	utils.WriteJson(w, map[string]any{
		"access_token":  access,
		"token_type":    "Bearer",
		"expires_in":    int(mcpOAuthAccessTokenTTL.Seconds()),
		"refresh_token": refresh,
		"scope":         mcpOAuthScope,
	})
}

func (api *Api) MCPOAuthRevoke(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}

func mcpVerifyPKCE(verifier, challenge string) bool {
	if verifier == "" || challenge == "" {
		return false
	}
	h := sha256.Sum256([]byte(verifier))
	got := base64.RawURLEncoding.EncodeToString(h[:])
	return subtle.ConstantTimeCompare([]byte(got), []byte(challenge)) == 1
}

func mcpRedirectURIAllowed(allowed []string, given string) bool {
	return slices.Contains(allowed, given)
}

func mcpOAuthError(w http.ResponseWriter, status int, code, desc string) {
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	body := map[string]string{"error": code}
	if desc != "" {
		body["error_description"] = desc
	}
	if err := json.NewEncoder(w).Encode(body); err != nil {
		klog.Errorln("failed to encode response:", err)
	}
}

func mcpRedirectErr(w http.ResponseWriter, r *http.Request, redirectURI, state, code, desc string) {
	if redirectURI == "" {
		mcpOAuthError(w, http.StatusBadRequest, code, desc)
		return
	}
	u, err := url.Parse(redirectURI)
	if err != nil {
		mcpOAuthError(w, http.StatusBadRequest, code, desc)
		return
	}
	q := u.Query()
	q.Set("error", code)
	if desc != "" {
		q.Set("error_description", desc)
	}
	if state != "" {
		q.Set("state", state)
	}
	u.RawQuery = q.Encode()
	http.Redirect(w, r, u.String(), http.StatusFound)
}