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)