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