194 lines
8.3 KiB
Go
194 lines
8.3 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/krow/krow-backend/go-api/internal/auth"
|
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
|
)
|
|
|
|
// Bearer authentication for the MCP surface.
|
|
//
|
|
// This file is a SEAM, not an authentication system. It defines the one
|
|
// question the MCP transport needs answered — "which KROW user does this token
|
|
// belong to" — and leaves answering it to whatever is plugged in. Phase 3 plugs
|
|
// in OAuth 2.1 token validation. Nothing here mints, stores, refreshes or
|
|
// validates a token's contents, because doing any of that now would be
|
|
// inventing a token format that OAuth then has to replace.
|
|
//
|
|
// WHY MCP AUTHENTICATES SEPARATELY FROM THE REST OF THE API
|
|
//
|
|
// The cookie middleware in httpserver/auth.go is deliberately not reused, and
|
|
// this is the most important decision in this file. Mounting MCP behind that
|
|
// middleware would mean a browser session could authenticate an MCP call: the
|
|
// middleware puts an Identity in the context, and any handler downstream that
|
|
// reads the ambient identity would accept it. That is a real vulnerability
|
|
// rather than a theoretical one — a logged-in user's cookie is sent by the
|
|
// browser on requests the user did not intend, which is what SameSite exists to
|
|
// limit and what an MCP endpoint has no business relying on.
|
|
//
|
|
// So identity here is PASSED, never ambient. The transport authenticates, and
|
|
// hands the result to Handle as a parameter. There is no code path in this
|
|
// package that reads authctx.From on an inbound request, which makes "a cookie
|
|
// silently authenticated MCP" structurally impossible rather than merely
|
|
// unintended. See TestCookieCannotAuthenticateMCP.
|
|
|
|
/* ── Errors ─────────────────────────────────────────────────────────────── */
|
|
|
|
var (
|
|
// ErrNoAuthenticator is returned when the surface is running without a
|
|
// token authenticator. It is a configuration fault, and it fails CLOSED:
|
|
// a deployment that forgot to wire one refuses every call rather than
|
|
// serving them unauthenticated.
|
|
ErrNoAuthenticator = errors.New("mcpserver: no token authenticator configured")
|
|
|
|
// ErrMissingToken covers an absent or empty Authorization header.
|
|
ErrMissingToken = errors.New("mcpserver: no bearer token")
|
|
|
|
// ErrMalformedToken covers a header this server could not parse as a
|
|
// bearer credential — a missing scheme, a wrong scheme, an empty value.
|
|
ErrMalformedToken = errors.New("mcpserver: malformed Authorization header")
|
|
|
|
// ErrInvalidToken covers a well-formed token that does not resolve to a
|
|
// user: unknown, expired, revoked, or issued for something else.
|
|
//
|
|
// ONE error for all of those, deliberately. Telling a caller that a token
|
|
// is "expired" rather than "unknown" confirms it once existed, which is an
|
|
// oracle over the token space. Same reasoning as tools.Denied().
|
|
ErrInvalidToken = errors.New("mcpserver: invalid bearer token")
|
|
)
|
|
|
|
/* ── The seam ───────────────────────────────────────────────────────────── */
|
|
|
|
// TokenAuthenticator resolves a raw bearer token into a KROW identity.
|
|
//
|
|
// Deliberately one method taking a string and returning the SAME
|
|
// authctx.Identity the cookie path produces. Two things follow from that shape,
|
|
// and both are the point:
|
|
//
|
|
// - There is no second identity model. Everything downstream — the policy
|
|
// table, the org pre-filter, tools.Context — consumes authctx.Identity and
|
|
// cannot tell which path produced it, so authorization cannot drift between
|
|
// the two.
|
|
// - An implementation cannot report anything except an identity or a failure.
|
|
// It has no way to return "authenticated, but also here is an org" or any
|
|
// other channel a caller might trust. The org is inside the identity,
|
|
// which comes from the user row.
|
|
//
|
|
// Phase 3's OAuth implementation of this interface will: hash the presented
|
|
// token, look it up, check expiry, revocation and audience, load the user, and
|
|
// build the identity from the USER ROW — never from the token's contents. A
|
|
// token that carried its own org claim would be a token whose bearer chose
|
|
// their own tenant.
|
|
type TokenAuthenticator interface {
|
|
// Authenticate resolves a raw token, or returns an error.
|
|
//
|
|
// Implementations must fail closed and must not distinguish unknown from
|
|
// expired from revoked in the returned error.
|
|
Authenticate(ctx context.Context, rawToken string) (authctx.Identity, error)
|
|
}
|
|
|
|
// UserLookup is the subset of the existing user store this package needs.
|
|
//
|
|
// Narrowed to one method so an implementation of TokenAuthenticator can re-read
|
|
// the user on every call — which is what makes suspension take effect on
|
|
// contact rather than whenever a token happens to lapse. httpserver/auth.go
|
|
// does exactly this for cookies (see its comment on re-reading the user row),
|
|
// and the bearer path must not be weaker.
|
|
//
|
|
// auth.UserStore already satisfies this.
|
|
type UserLookup interface {
|
|
FindByID(ctx context.Context, id string) (auth.User, error)
|
|
}
|
|
|
|
/* ── Header parsing ─────────────────────────────────────────────────────── */
|
|
|
|
// bearerToken extracts the credential from an Authorization header.
|
|
//
|
|
// Only the Authorization header is consulted. Not a query parameter — the MCP
|
|
// spec forbids tokens in the URI, and a URI is logged, cached, and put in a
|
|
// Referer. Not a custom header, not a cookie, not the body. One place, so there
|
|
// is one thing to reason about.
|
|
func bearerToken(r *http.Request) (string, error) {
|
|
header := r.Header.Get("Authorization")
|
|
if strings.TrimSpace(header) == "" {
|
|
return "", ErrMissingToken
|
|
}
|
|
|
|
scheme, value, found := strings.Cut(header, " ")
|
|
if !found {
|
|
return "", ErrMalformedToken
|
|
}
|
|
// Case-insensitive per RFC 7235: "Bearer", "bearer" and "BEARER" are the
|
|
// same scheme, and rejecting the variants would fail against clients that
|
|
// are behaving correctly.
|
|
if !strings.EqualFold(strings.TrimSpace(scheme), "bearer") {
|
|
return "", ErrMalformedToken
|
|
}
|
|
|
|
token := strings.TrimSpace(value)
|
|
if token == "" {
|
|
return "", ErrMalformedToken
|
|
}
|
|
// A second space means a second value — "Bearer a b" is not a token, and
|
|
// accepting the first half would silently authenticate something the
|
|
// client did not send.
|
|
if strings.ContainsAny(token, " \t") {
|
|
return "", ErrMalformedToken
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// authenticate resolves the request's bearer credential into an identity.
|
|
//
|
|
// Every failure returns the same outward answer — 401 with no detail about
|
|
// which stage failed. The reason is recorded in the log, where the operator is.
|
|
func (s *Server) authenticate(r *http.Request) (authctx.Identity, error) {
|
|
token, err := bearerToken(r)
|
|
if err != nil {
|
|
return authctx.Identity{}, err
|
|
}
|
|
if s.tokens == nil {
|
|
return authctx.Identity{}, ErrNoAuthenticator
|
|
}
|
|
|
|
identity, err := s.tokens.Authenticate(r.Context(), token)
|
|
if err != nil {
|
|
return authctx.Identity{}, ErrInvalidToken
|
|
}
|
|
|
|
// Defence in depth against an authenticator that returns a partially
|
|
// populated identity. Everything downstream assumes these two are present:
|
|
// tools/scope.go refuses an empty OrgID, but it should never be asked to,
|
|
// and a missing UserID would produce a query scoped to nobody.
|
|
if identity.UserID == "" || identity.OrgID == "" {
|
|
return authctx.Identity{}, ErrInvalidToken
|
|
}
|
|
// A suspended account must not hold a working token. The authenticator is
|
|
// expected to check this; repeating it here costs nothing and means a
|
|
// mistake in one implementation is not a live account bypass.
|
|
if identity.Status != "" && identity.Status != auth.StatusActive {
|
|
return authctx.Identity{}, ErrInvalidToken
|
|
}
|
|
return identity, nil
|
|
}
|
|
|
|
// authFailureReason names the stage that refused, for the log only.
|
|
func authFailureReason(err error) string {
|
|
switch {
|
|
case errors.Is(err, ErrMissingToken):
|
|
return "missing_token"
|
|
case errors.Is(err, ErrMalformedToken):
|
|
return "malformed_header"
|
|
case errors.Is(err, ErrNoAuthenticator):
|
|
return "no_authenticator_configured"
|
|
case errors.Is(err, ErrInvalidToken):
|
|
return "invalid_token"
|
|
default:
|
|
return "error"
|
|
}
|
|
}
|