Files
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

196 lines
8.6 KiB
Go

package httpserver
import (
"net/http"
"github.com/krow/krow-backend/go-api/internal/auth"
"github.com/krow/krow-backend/go-api/internal/authctx"
"github.com/krow/krow-backend/go-api/internal/mcpserver"
"github.com/krow/krow-backend/go-api/internal/oauth"
"github.com/krow/krow-backend/go-api/internal/ratelimit"
"github.com/krow/krow-backend/go-api/internal/runtime"
)
// Mounting the MCP surface and the OAuth authorization server behind it.
//
// This file is the seam between the existing HTTP server and two packages that
// know nothing about it. It is deliberately thin: no validation, no policy and
// no business logic live here, because every one of those already lives in the
// package being mounted. What this file decides is only WHERE things are served
// and WHAT AUTHENTICATES them, and those two decisions are the ones that have
// to be right.
//
// OFF UNLESS CONFIGURED. Without OAUTH_ISSUER and MCP_RESOURCE, none of these
// routes are registered at all. That follows routeRuns' precedent exactly: a
// deployment that does not serve agents answers 404 rather than registering
// routes that fail, and the same is true of one that does not serve MCP. An
// existing deployment that upgrades to this build gains nothing it did not ask
// for.
// routeOAuth registers the authorization server and its discovery documents.
//
// WHICH OF THESE ARE PUBLIC, AND WHY — this is the part worth reading twice.
// Four paths bypass the cookie middleware, and each has a specific reason:
//
// /.well-known/oauth-protected-resource RFC 9728. A client that has no
// /.well-known/oauth-authorization-server RFC 8414. token cannot read a
// document that requires one, and
// these are how it learns where to
// get a token. They contain only
// public endpoint URLs.
//
// /oauth/register RFC 7591. A client that has never registered has no
// credential to present — that is the entire point of
// dynamic registration.
//
// /oauth/token The client authenticates with an authorization code or a
// refresh token IN THE BODY. A cookie would be meaningless:
// this is a back-channel call from Claude's servers, where
// no browser and no cookie exist.
//
// /oauth/authorize is deliberately NOT public. It runs in a browser, as a
// person, and it requires the existing KROW session — that is how the consent
// screen knows whose organisation is being granted. An unauthenticated visitor
// is redirected to the existing login and comes back.
//
// /mcp is deliberately NOT public either, and also does not use the cookie. See
// routeMCP.
func (s *Server) routeOAuth(mux *http.ServeMux) int {
if !s.cfg.OAuth.Enabled() {
return 0
}
cfg := oauth.Config{
Issuer: s.cfg.OAuth.Issuer,
Resource: s.cfg.OAuth.Resource,
}
store := oauth.NewStore(s.db.Pool)
as := oauth.NewServer(cfg, store, sessionResolver{s}, s.cfg.OAuth.LoginPath, s.log)
mux.Handle("GET /.well-known/oauth-protected-resource", cfg.ProtectedResourceHandler())
mux.Handle("GET /.well-known/oauth-authorization-server", cfg.AuthorizationServerHandler())
// Registration is the only endpoint that writes for a caller with no
// credential at all, so it carries the tightest limit on the surface.
mux.Handle("POST /oauth/register",
s.limited(ratelimit.OAuthRegister, s.byClientAddr, as.RegisterHandler()))
// GET renders consent; POST carries the decision. One handler, because the
// POST re-validates every parameter the GET validated rather than trusting
// the form it rendered.
mux.Handle("GET /oauth/authorize",
s.limited(ratelimit.OAuthAuthorize, s.byAddrAndUser, as.AuthorizeHandler()))
mux.Handle("POST /oauth/authorize",
s.limited(ratelimit.OAuthAuthorize, s.byAddrAndUser, as.AuthorizeHandler()))
// The token endpoint carries two limits on two different subjects, because
// its two grant types are abused differently: a code exchange is bounded
// per client, and a refresh is bounded per token so a loop on one
// connection cannot spend another's budget. Which applies is decided per
// request by the grant_type, inside tokenLimited.
mux.Handle("POST /oauth/token", s.tokenLimited(as.TokenHandler()))
// Revocation is deliberately unlimited — see ratelimit/rules.go. It is the
// emergency brake, and an attacker gains nothing by pulling it.
mux.Handle("POST /oauth/revoke", as.RevokeHandler())
return 7
}
// routeMCP registers the MCP endpoint.
//
// AUTHENTICATION HERE IS THE BEARER PATH AND ONLY THE BEARER PATH.
//
// The handler authenticates its own callers from the Authorization header and
// ignores whatever the cookie middleware put in the context. That is a property
// of mcpserver, not of this file — see its auth.go.
//
// /mcp IS on the publicPaths allowlist, and that is deliberate rather than an
// oversight. The cookie middleware has to step aside here: an MCP client
// discovers how to authenticate by calling this endpoint without a token and
// reading the WWW-Authenticate header of the 401, and the middleware's own 401
// carries no such header. Guarding the path here would refuse the client with
// nowhere to go, and the connection could never be made at all.
//
// The credential requirement is not weakened by that, because it was never
// this middleware enforcing it: mcpserver refuses every method but the
// handshake without a bearer token, and it takes its identity as a parameter
// rather than from the request context, so a cookie cannot supply one.
//
// There is no second authorization layer. A tool call goes straight into the
// registry the agent runtime already uses, under the policy table it already
// consults.
func (s *Server) routeMCP(mux *http.ServeMux) int {
if !s.cfg.OAuth.Enabled() {
return 0
}
// The SAME registry the runtime builds. Not a copy, not a second
// construction: a tool added once is available to Owliver and to MCP
// together, and neither can drift from the other.
registry := runtime.DefaultTools(
s.db.Pool,
nil, // knowledge_search is not exposed over MCP — see mcpserver/tools.go
)
authenticator := oauth.NewAuthenticator(
oauth.NewStore(s.db.Pool),
s.users,
// The audience an access token must carry. From configuration, never
// from a request: a resource value supplied by a caller would let the
// caller choose their own audience.
s.cfg.OAuth.Resource,
s.log,
)
server := mcpserver.New(registry, authenticator, s.log).
WithResourceMetadataURL(s.cfg.OAuth.Issuer + "/.well-known/oauth-protected-resource").
// The per-organisation ceiling is installed INSIDE the MCP server
// rather than as middleware, because the organisation is only known
// after the token has been resolved. See orgLimiter in mcplimit.go.
WithOrgLimiter(orgLimiter{s})
mux.Handle("POST /mcp", s.mcpLimited(server.Handler()))
// GET is what the Streamable HTTP binding uses for a server-initiated
// stream, which this server does not open. Registered so the answer is 405
// with an Allow header rather than a 404 that suggests the endpoint is
// absent.
mux.Handle("GET /mcp", server.Handler())
return 2
}
// sessionResolver adapts the existing cookie session to oauth.SessionResolver.
//
// This is the ONLY place the OAuth package learns who is signed in, and it does
// so through the existing session manager — the same lookup every other
// authenticated route performs. No second password store, no second session
// table, no second notion of identity.
type sessionResolver struct{ s *Server }
// CurrentUser resolves the session cookie into an identity.
//
// Re-reads the user row rather than trusting the session's own copy, exactly as
// authenticate() does, so a suspended account cannot approve an authorization
// in the window before its session lapses.
func (r sessionResolver) CurrentUser(req *http.Request) (authctx.Identity, bool) {
token := sessionToken(req)
if token == "" {
return authctx.Identity{}, false
}
sess, err := r.s.sessions.Authenticate(req.Context(), token)
if err != nil {
return authctx.Identity{}, false
}
user, err := r.s.users.FindByID(req.Context(), sess.UserID)
if err != nil || !user.IsActive() {
return authctx.Identity{}, false
}
return authctx.Identity{
UserID: user.ID, OrgID: user.OrgID, Email: user.Email,
FullName: user.FullName, Role: user.Role, AccountType: user.AccountType,
Status: user.Status, SessionID: sess.ID, ExpiresAt: sess.ExpiresAt,
}, true
}
// compile-time proof that the existing user store satisfies what OAuth needs.
var _ oauth.UserLookup = (auth.UserStore)(nil)