first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,577 @@
// Package handlers provides HTTP request handlers for the Bifrost HTTP transport.
// This file implements Bifrost's OAuth 2.1 Authorization Server for per-user MCP
// authentication. It provides Dynamic Client Registration (RFC 7591), Authorization
// Code flow with PKCE, and token issuance. MCP clients (Claude Code, IDEs) use
// these endpoints to authenticate users before accessing Bifrost's /mcp endpoint.
package handlers
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"net/url"
"strings"
"time"
"github.com/fasthttp/router"
"github.com/google/uuid"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
"github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
"github.com/valyala/fasthttp"
)
// PerUserOAuthHandler implements Bifrost's OAuth 2.1 Authorization Server.
// It handles dynamic client registration, authorization code issuance with PKCE,
// and token exchange for MCP per-user authentication.
type PerUserOAuthHandler struct {
store *lib.Config
}
// NewPerUserOAuthHandler creates a new per-user OAuth handler instance.
func NewPerUserOAuthHandler(store *lib.Config) *PerUserOAuthHandler {
return &PerUserOAuthHandler{store: store}
}
// RegisterRoutes registers the per-user OAuth authorization server routes.
// These routes do NOT go through auth middleware since they are part of the
// OAuth flow that unauthenticated clients use to obtain tokens.
func (h *PerUserOAuthHandler) RegisterRoutes(r *router.Router, middlewares ...schemas.BifrostHTTPMiddleware) {
r.POST("/api/oauth/per-user/register", lib.ChainMiddlewares(h.handleDynamicClientRegistration, middlewares...))
r.GET("/api/oauth/per-user/authorize", lib.ChainMiddlewares(h.handleAuthorize, middlewares...))
r.POST("/api/oauth/per-user/token", lib.ChainMiddlewares(h.handleToken, middlewares...))
r.GET("/api/oauth/per-user/upstream/authorize", lib.ChainMiddlewares(h.handleUpstreamAuthorize, middlewares...))
}
// handleDynamicClientRegistration handles OAuth 2.0 Dynamic Client Registration
// per RFC 7591. MCP clients register themselves to obtain a client_id.
//
// POST /api/oauth/per-user/register
func (h *PerUserOAuthHandler) handleDynamicClientRegistration(ctx *fasthttp.RequestCtx) {
if h.store.ConfigStore == nil {
SendError(ctx, fasthttp.StatusServiceUnavailable, "OAuth registration unavailable: config store is disabled")
return
}
if len(h.store.GetPerUserOAuthMCPClients()) == 0 {
sendStringError(ctx, fasthttp.StatusNotFound, "Not found")
return
}
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"`
Scope string `json:"scope"`
}
if err := json.Unmarshal(ctx.PostBody(), &req); err != nil {
SendError(ctx, fasthttp.StatusBadRequest, fmt.Sprintf("Invalid registration request: %v", err))
return
}
if len(req.RedirectURIs) == 0 {
SendError(ctx, fasthttp.StatusBadRequest, "redirect_uris is required")
return
}
// Generate client_id
clientID := uuid.New().String()
// Serialize arrays
redirectURIsJSON, _ := json.Marshal(req.RedirectURIs)
grantTypes := req.GrantTypes
if len(grantTypes) == 0 {
grantTypes = []string{"authorization_code"}
}
grantTypesJSON, _ := json.Marshal(grantTypes)
client := &tables.TablePerUserOAuthClient{
ID: uuid.New().String(),
ClientID: clientID,
ClientName: req.ClientName,
RedirectURIs: string(redirectURIsJSON),
GrantTypes: string(grantTypesJSON),
}
if err := h.store.ConfigStore.CreatePerUserOAuthClient(ctx, client); err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to register client: %v", err))
return
}
// Return RFC 7591 response
ctx.SetStatusCode(fasthttp.StatusCreated)
SendJSON(ctx, map[string]interface{}{
"client_id": clientID,
"client_name": req.ClientName,
"redirect_uris": req.RedirectURIs,
"grant_types": grantTypes,
"response_types": req.ResponseTypes,
"token_endpoint_auth_method": "none",
})
}
// handleAuthorize handles the OAuth 2.1 authorization endpoint.
// Instead of issuing a code immediately, it validates the request parameters,
// creates a PendingFlow record, and redirects the user to the consent screen.
// The code is only issued after the user completes the consent flow (VK + MCP auths).
//
// GET /api/oauth/per-user/authorize?response_type=code&client_id=xxx&redirect_uri=xxx&code_challenge=xxx&code_challenge_method=S256[&state=xxx]
func (h *PerUserOAuthHandler) handleAuthorize(ctx *fasthttp.RequestCtx) {
if h.store.ConfigStore == nil {
SendError(ctx, fasthttp.StatusServiceUnavailable, "OAuth authorization unavailable: config store is disabled")
return
}
if len(h.store.GetPerUserOAuthMCPClients()) == 0 {
sendStringError(ctx, fasthttp.StatusNotFound, "Not found")
return
}
// Extract parameters
responseType := string(ctx.QueryArgs().Peek("response_type"))
clientID := string(ctx.QueryArgs().Peek("client_id"))
redirectURI := string(ctx.QueryArgs().Peek("redirect_uri"))
state := string(ctx.QueryArgs().Peek("state"))
codeChallenge := string(ctx.QueryArgs().Peek("code_challenge"))
codeChallengeMethod := string(ctx.QueryArgs().Peek("code_challenge_method"))
// Validate required parameters
if responseType != "code" {
SendError(ctx, fasthttp.StatusBadRequest, "response_type must be 'code'")
return
}
if clientID == "" || redirectURI == "" {
SendError(ctx, fasthttp.StatusBadRequest, "client_id and redirect_uri are required")
return
}
if codeChallenge == "" || codeChallengeMethod != "S256" {
SendError(ctx, fasthttp.StatusBadRequest, "PKCE is required: code_challenge and code_challenge_method=S256")
return
}
// Validate client exists and redirect_uri is registered
client, err := h.store.ConfigStore.GetPerUserOAuthClientByClientID(ctx, clientID)
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to validate client: %v", err))
return
}
if client == nil {
SendError(ctx, fasthttp.StatusBadRequest, "Unknown client_id")
return
}
var allowedURIs []string
json.Unmarshal([]byte(client.RedirectURIs), &allowedURIs)
uriAllowed := false
for _, allowed := range allowedURIs {
if allowed == redirectURI {
uriAllowed = true
break
}
}
if !uriAllowed {
SendError(ctx, fasthttp.StatusBadRequest, "redirect_uri not registered for this client")
return
}
// Generate a browser-binding secret so only the initiating browser can resume this flow.
browserSecret, err := generateOpaqueToken(32)
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, "Failed to generate browser secret")
return
}
browserSecretHash := fmt.Sprintf("%x", sha256.Sum256([]byte(browserSecret)))
// Create a PendingFlow to carry OAuth params through the consent screen.
flow := &tables.TablePerUserOAuthPendingFlow{
ID: uuid.New().String(),
ClientID: clientID,
RedirectURI: redirectURI,
CodeChallenge: codeChallenge,
State: state,
BrowserSecretHash: browserSecretHash,
ExpiresAt: time.Now().Add(15 * time.Minute),
}
if err := h.store.ConfigStore.CreatePerUserOAuthPendingFlow(ctx, flow); err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to create pending flow: %v", err))
return
}
logger.Debug("[oauth/authorize] PendingFlow created: flow_id=%s client_id=%s", flow.ID, clientID)
// Set HttpOnly cookie binding this flow to the current browser.
var cookie fasthttp.Cookie
cookie.SetKey("__bifrost_flow_secret")
cookie.SetValue(browserSecret)
cookie.SetPath("/")
cookie.SetHTTPOnly(true)
cookie.SetSameSite(fasthttp.CookieSameSiteLaxMode)
isSecure := ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https"
cookie.SetSecure(isSecure)
cookie.SetMaxAge(15 * 60) // 15 minutes, matching flow TTL
ctx.Response.Header.SetCookie(&cookie)
// Redirect to consent screen with flow_id (relative path — stays on current origin).
consentURL := fmt.Sprintf("/oauth/consent?flow_id=%s", url.QueryEscape(flow.ID))
ctx.Redirect(consentURL, fasthttp.StatusFound)
}
// handleToken handles the OAuth 2.1 token endpoint.
// It validates the authorization code + PKCE verifier and issues access/refresh tokens.
//
// POST /api/oauth/per-user/token
func (h *PerUserOAuthHandler) handleToken(ctx *fasthttp.RequestCtx) {
if h.store.ConfigStore == nil {
SendError(ctx, fasthttp.StatusServiceUnavailable, "OAuth token endpoint unavailable: config store is disabled")
return
}
if len(h.store.GetPerUserOAuthMCPClients()) == 0 {
sendStringError(ctx, fasthttp.StatusNotFound, "Not found")
return
}
// Parse form-encoded body
grantType := string(ctx.FormValue("grant_type"))
code := string(ctx.FormValue("code"))
redirectURI := string(ctx.FormValue("redirect_uri"))
clientID := string(ctx.FormValue("client_id"))
codeVerifier := string(ctx.FormValue("code_verifier"))
if grantType != "authorization_code" {
sendOAuthError(ctx, fasthttp.StatusBadRequest, "unsupported_grant_type", "Only authorization_code grant is supported")
return
}
if code == "" || codeVerifier == "" {
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_request", "code and code_verifier are required")
return
}
// Atomically claim authorization code (prevents concurrent redemption)
codeRecord, err := h.store.ConfigStore.ClaimPerUserOAuthCode(ctx, code)
if err != nil {
sendOAuthError(ctx, fasthttp.StatusInternalServerError, "server_error", "Failed to validate code")
return
}
if codeRecord == nil {
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_grant", "Invalid or already used authorization code")
return
}
// Validate code is not expired
if time.Now().After(codeRecord.ExpiresAt) {
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_grant", "Authorization code expired")
return
}
// Validate client_id if provided — some public clients omit it (RFC 6749 §4.1.3 allows
// omitting client_id when the client is not authenticating with the server).
// The code record already binds the code to the correct client, so this is safe.
if clientID != "" && codeRecord.ClientID != clientID {
logger.Debug("[oauth/token] client_id mismatch: code_client=%s request_client=%s", codeRecord.ClientID, clientID)
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_grant", "client_id mismatch")
return
}
// Use the client_id from the code record as the authoritative value.
clientID = codeRecord.ClientID
// Validate redirect_uri matches
if redirectURI != "" && codeRecord.RedirectURI != redirectURI {
logger.Debug("[oauth/token] redirect_uri mismatch: code=%s request=%s", codeRecord.RedirectURI, redirectURI)
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_grant", "redirect_uri mismatch")
return
}
// Validate PKCE: SHA256(code_verifier) must match code_challenge
verifierHash := sha256.Sum256([]byte(codeVerifier))
computedChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:])
if computedChallenge != codeRecord.CodeChallenge {
logger.Debug("[oauth/token] PKCE verification failed")
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_grant", "PKCE verification failed")
return
}
// If the code was issued by the consent flow (handleSubmit), the session already exists
// with the upstream tokens transferred to it. Reuse that session's access token so the
// client receives the token that the upstream (Notion, GitHub, etc.) tokens are linked to.
var accessToken string
var expiresAt time.Time
if codeRecord.SessionID != "" {
existingSession, err := h.store.ConfigStore.GetPerUserOAuthSessionByID(ctx, codeRecord.SessionID)
if err != nil {
logger.Info("[oauth/token] Failed to load existing session: session_id=%s err=%v", codeRecord.SessionID, err)
sendOAuthError(ctx, fasthttp.StatusInternalServerError, "server_error", "Failed to load session")
return
}
if existingSession == nil {
logger.Info("[oauth/token] Existing session not found: session_id=%s", codeRecord.SessionID)
sendOAuthError(ctx, fasthttp.StatusInternalServerError, "server_error", "Session not found")
return
}
if !existingSession.ExpiresAt.After(time.Now()) {
sendOAuthError(ctx, fasthttp.StatusBadRequest, "invalid_grant", "Session expired")
return
}
accessToken = existingSession.AccessToken
expiresAt = existingSession.ExpiresAt
logger.Debug("[oauth/token] reusing consent session: session_id=%s", existingSession.ID)
} else {
// Fallback: no linked session (legacy path) — create a new one.
var newAccessToken, newRefreshToken string
newAccessToken, err = generateOpaqueToken(32)
if err != nil {
sendOAuthError(ctx, fasthttp.StatusInternalServerError, "server_error", "Failed to generate access token")
return
}
newRefreshToken, err = generateOpaqueToken(32)
if err != nil {
sendOAuthError(ctx, fasthttp.StatusInternalServerError, "server_error", "Failed to generate refresh token")
return
}
expiresAt = time.Now().Add(24 * time.Hour)
newSession := &tables.TablePerUserOAuthSession{
ID: uuid.New().String(),
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
ClientID: clientID,
ExpiresAt: expiresAt,
}
if err := h.store.ConfigStore.CreatePerUserOAuthSession(ctx, newSession); err != nil {
sendOAuthError(ctx, fasthttp.StatusInternalServerError, "server_error", "Failed to create session")
return
}
accessToken = newAccessToken
logger.Debug("[oauth/token] created new session (legacy path): session_id=%s", newSession.ID)
}
// Return OAuth token response
ctx.SetContentType("application/json")
ctx.SetStatusCode(fasthttp.StatusOK)
SendJSON(ctx, map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()),
"scope": codeRecord.Scopes,
})
}
// sendOAuthError sends an OAuth 2.0 error response per RFC 6749 Section 5.2.
func sendOAuthError(ctx *fasthttp.RequestCtx, statusCode int, errorCode, description string) {
ctx.SetContentType("application/json")
ctx.SetStatusCode(statusCode)
resp, _ := json.Marshal(map[string]string{
"error": errorCode,
"error_description": description,
})
ctx.SetBody(resp)
}
func sendStringError(ctx *fasthttp.RequestCtx, statusCode int, message string) {
ctx.SetContentType("text/plain")
ctx.SetStatusCode(statusCode)
ctx.SetBodyString(message)
}
// generateOpaqueToken generates a cryptographically secure random token.
// validateFlowBrowserSecret checks that the request carries the __bifrost_flow_secret
// cookie matching the hash stored on the pending flow. Returns true if valid.
func validateFlowBrowserSecret(ctx *fasthttp.RequestCtx, flow *tables.TablePerUserOAuthPendingFlow) bool {
if flow.BrowserSecretHash == "" {
// Legacy flow without browser binding — allow for backwards compatibility.
return true
}
secret := ctx.Request.Header.Cookie("__bifrost_flow_secret")
if len(secret) == 0 {
return false
}
hash := fmt.Sprintf("%x", sha256.Sum256(secret))
return hash == flow.BrowserSecretHash
}
func generateOpaqueToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// handleUpstreamAuthorize handles the upstream OAuth proxy for per-user OAuth.
// When a user needs to authenticate with an upstream MCP server (e.g., Notion),
// this endpoint redirects them to the upstream provider's OAuth authorize URL.
// After the user authenticates, the callback stores their upstream token linked
// to either their Bifrost session (runtime flow) or a PendingFlow (consent flow).
//
// Runtime flow: GET /api/oauth/per-user/upstream/authorize?mcp_client_id=xxx&session=xxx
// Consent flow: GET /api/oauth/per-user/upstream/authorize?mcp_client_id=xxx&flow_id=xxx
func (h *PerUserOAuthHandler) handleUpstreamAuthorize(ctx *fasthttp.RequestCtx) {
if h.store.ConfigStore == nil {
SendError(ctx, fasthttp.StatusServiceUnavailable, "OAuth upstream authorization unavailable: config store is disabled")
return
}
mcpClientID := string(ctx.QueryArgs().Peek("mcp_client_id"))
sessionID := string(ctx.QueryArgs().Peek("session"))
flowID := string(ctx.QueryArgs().Peek("flow_id"))
if mcpClientID == "" || (sessionID == "" && flowID == "") {
SendError(ctx, fasthttp.StatusBadRequest, "mcp_client_id and either session or flow_id are required")
return
}
// Resolve identity depending on whether this is a runtime session or a consent flow.
var virtualKeyID, userID, proxySessionToken, gatewaySessionID string
if flowID != "" {
// Consent flow: use the pending flow for identity and proxy token.
flow, err := h.store.ConfigStore.GetPerUserOAuthPendingFlow(ctx, flowID)
if err != nil || flow == nil || time.Now().After(flow.ExpiresAt) {
SendError(ctx, fasthttp.StatusUnauthorized, "Invalid or expired consent flow")
return
}
if !validateFlowBrowserSecret(ctx, flow) {
SendError(ctx, fasthttp.StatusForbidden, "Flow does not belong to this browser session")
return
}
if strVal(flow.VirtualKeyID) != "" {
virtualKeyID = *flow.VirtualKeyID
}
if strVal(flow.UserID) != "" {
userID = *flow.UserID
}
// Use a prefixed flow token so the callback can detect the consent path.
// Include mcpClientID to avoid unique constraint violations when multiple
// MCP services are connected in the same consent flow.
proxySessionToken = "flow:" + flowID + ":" + mcpClientID
gatewaySessionID = flowID
} else {
// Runtime flow: validate the existing Bifrost session.
bifrostSession, err := h.store.ConfigStore.GetPerUserOAuthSessionByID(ctx, sessionID)
if err != nil || bifrostSession == nil {
SendError(ctx, fasthttp.StatusUnauthorized, "Invalid or expired session")
return
}
if !bifrostSession.ExpiresAt.After(time.Now()) {
SendError(ctx, fasthttp.StatusUnauthorized, "Invalid or expired session")
return
}
virtualKeyID = strVal(bifrostSession.VirtualKeyID)
userID = strVal(bifrostSession.UserID)
proxySessionToken = "runtime:" + sessionID + ":" + mcpClientID
gatewaySessionID = sessionID
}
// Look up the MCP client config to get the template OAuth config.
mcpClient, err := h.store.ConfigStore.GetMCPClientByID(ctx, mcpClientID)
if err != nil || mcpClient == nil {
SendError(ctx, fasthttp.StatusNotFound, "MCP client not found")
return
}
if mcpClient.AuthType != string(schemas.MCPAuthTypePerUserOauth) {
SendError(ctx, fasthttp.StatusBadRequest, "MCP client does not use per-user OAuth")
return
}
if mcpClient.OauthConfigID == nil || *mcpClient.OauthConfigID == "" {
SendError(ctx, fasthttp.StatusBadRequest, "MCP client has no OAuth configuration")
return
}
// Load template OAuth config (has upstream authorize_url, client_id, etc.)
templateConfig, err := h.store.ConfigStore.GetOauthConfigByID(ctx, *mcpClient.OauthConfigID)
if err != nil || templateConfig == nil {
SendError(ctx, fasthttp.StatusInternalServerError, "Failed to load OAuth template config")
return
}
// Generate PKCE challenge for upstream.
codeVerifier, err := generateOpaqueToken(32)
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, "Failed to generate PKCE verifier")
return
}
verifierHash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:])
// Generate state for upstream.
state, err := generateOpaqueToken(32)
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, "Failed to generate state token")
return
}
// Build redirect URI (Bifrost's callback endpoint).
scheme := "http"
if ctx.IsTLS() || string(ctx.Request.Header.Peek("X-Forwarded-Proto")) == "https" {
scheme = "https"
}
host := string(ctx.Host())
redirectURI := fmt.Sprintf("%s://%s/api/oauth/callback", scheme, host)
var vkId *string
if virtualKeyID != "" {
vkId = &virtualKeyID
}
var uid *string
if userID != "" {
uid = &userID
}
// Store upstream OAuth session linking state → MCP client + identity.
upstreamSession := &tables.TableOauthUserSession{
ID: uuid.New().String(),
MCPClientID: mcpClientID,
OauthConfigID: *mcpClient.OauthConfigID,
State: state,
CodeVerifier: codeVerifier,
SessionToken: proxySessionToken, // "runtime:xxx" for runtime flow; "flow:xxx" for consent flow
GatewaySessionID: gatewaySessionID,
VirtualKeyID: vkId,
UserID: uid,
Status: "pending",
ExpiresAt: time.Now().Add(15 * time.Minute),
}
logger.Debug("[oauth/upstream-authorize] creating upstream session: mcp_client=%s flow=%s", mcpClientID, proxySessionToken)
if err := h.store.ConfigStore.CreateOauthUserSession(ctx, upstreamSession); err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("Failed to create upstream OAuth session: %v", err))
return
}
// Parse scopes from template config.
var scopes []string
if templateConfig.Scopes != "" {
json.Unmarshal([]byte(templateConfig.Scopes), &scopes)
}
// Build upstream authorize URL with PKCE.
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", templateConfig.ClientID)
params.Set("redirect_uri", redirectURI)
params.Set("state", state)
params.Set("code_challenge", codeChallenge)
params.Set("code_challenge_method", "S256")
if len(scopes) > 0 {
params.Set("scope", strings.Join(scopes, " "))
}
baseURL, err := url.Parse(templateConfig.AuthorizeURL)
if err != nil {
SendError(ctx, fasthttp.StatusInternalServerError, "Invalid upstream authorize URL")
return
}
existing := baseURL.Query()
for k, vals := range params {
for _, v := range vals {
existing.Set(k, v)
}
}
baseURL.RawQuery = existing.Encode()
ctx.Redirect(baseURL.String(), fasthttp.StatusFound)
}
// Ensure unused imports are referenced.
var _ = html.EscapeString
var _ configstore.ConfigStore