mcp connection
This commit is contained in:
193
go-api/internal/mcpserver/auth.go
Normal file
193
go-api/internal/mcpserver/auth.go
Normal file
@@ -0,0 +1,193 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user