Files
krow_backend/go-api/internal/mcpserver/auth.go
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

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"
}
}