578 lines
22 KiB
Go
578 lines
22 KiB
Go
// 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
|