196 lines
8.6 KiB
Go
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)
|