mcp connection
This commit is contained in:
172
go-api/internal/oauth/authenticator.go
Normal file
172
go-api/internal/oauth/authenticator.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/auth"
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
)
|
||||
|
||||
// Authenticator is the production implementation of
|
||||
// mcpserver.TokenAuthenticator.
|
||||
//
|
||||
// This is where Phase 2's seam is filled in, and the shape of it is the whole
|
||||
// argument for having defined the interface first: one method, taking a raw
|
||||
// token, returning the same authctx.Identity a cookie produces. Nothing
|
||||
// downstream — not tools.Context, not the policy table, not a single handler —
|
||||
// can tell which path built the identity, so authorization cannot drift between
|
||||
// them.
|
||||
//
|
||||
// THE IDENTITY IS BUILT FROM THE USER ROW, NOT FROM THE TOKEN.
|
||||
//
|
||||
// oauth_tokens carries org_id, and it would be cheaper to read it from there.
|
||||
// It is deliberately not: the token row records the tenant AT ISSUE TIME, and a
|
||||
// token can outlive the fact. A user moved to another organisation, or
|
||||
// suspended, would keep working against a stale claim until the token expired.
|
||||
// Re-reading the user costs one indexed lookup and makes suspension take effect
|
||||
// on the next call — which is exactly what httpserver/auth.go already does for
|
||||
// cookies, and the bearer path must not be weaker than the cookie path.
|
||||
type Authenticator struct {
|
||||
store *Store
|
||||
users UserLookup
|
||||
log *slog.Logger
|
||||
|
||||
// audience is this deployment's canonical MCP resource URI. A token whose
|
||||
// audience is anything else is refused — see the note in Authenticate.
|
||||
audience string
|
||||
}
|
||||
|
||||
// UserLookup is the subset of the existing user store this needs. auth.UserStore
|
||||
// satisfies it; nothing here builds a second user table or password store.
|
||||
type UserLookup interface {
|
||||
FindByID(ctx context.Context, id string) (auth.User, error)
|
||||
}
|
||||
|
||||
// NewAuthenticator builds the production token authenticator.
|
||||
func NewAuthenticator(store *Store, users UserLookup, audience string, log *slog.Logger) *Authenticator {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Authenticator{store: store, users: users, audience: audience, log: log}
|
||||
}
|
||||
|
||||
// ErrAudienceMismatch is internal. It never reaches a client — see the single
|
||||
// return below — but it is distinct so the log can say what happened.
|
||||
var ErrAudienceMismatch = errors.New("oauth: token audience does not match this resource")
|
||||
|
||||
// Authenticate resolves a bearer token into a KROW identity.
|
||||
//
|
||||
// EVERY failure returns the same error. Unknown, expired, revoked, wrong
|
||||
// audience, suspended user, deleted user — one answer, because a caller who can
|
||||
// tell them apart learns things they should not: that a token once existed,
|
||||
// that an account was suspended rather than deleted, that this server is not
|
||||
// the intended audience for a token they hold. Same discipline as
|
||||
// tools.Denied() and the session path's identical answer to "not found" and
|
||||
// "expired".
|
||||
//
|
||||
// The reason goes to the log, at warn, where the operator is.
|
||||
func (a *Authenticator) Authenticate(ctx context.Context, rawToken string) (authctx.Identity, error) {
|
||||
if strings.TrimSpace(rawToken) == "" {
|
||||
return authctx.Identity{}, ErrTokenUnusable
|
||||
}
|
||||
|
||||
// 1. The token must exist, be an access token, be unexpired and unrevoked.
|
||||
// All four are in the query's predicate.
|
||||
token, err := a.store.FindAccessToken(ctx, rawToken)
|
||||
if err != nil {
|
||||
a.log.Warn("mcp bearer refused", "reason", "token_unusable")
|
||||
return authctx.Identity{}, ErrTokenUnusable
|
||||
}
|
||||
|
||||
// 2. Audience. RFC 8707 and the MCP spec both require a server to verify
|
||||
// that a token was issued FOR IT. Without this check, a token minted by
|
||||
// this authorization server for some other resource would be spendable
|
||||
// here — the confused-deputy problem the spec calls out explicitly. The
|
||||
// comparison is against configuration, never against anything in the
|
||||
// request: a resource value supplied by the caller would let the caller
|
||||
// choose their own audience.
|
||||
if token.Audience != a.audience {
|
||||
a.log.Warn("mcp bearer refused",
|
||||
"reason", "audience_mismatch",
|
||||
"token_id", token.ID,
|
||||
"expected", a.audience,
|
||||
"presented", token.Audience)
|
||||
return authctx.Identity{}, ErrTokenUnusable
|
||||
}
|
||||
|
||||
// 3. Scope. krow.read is the only scope this phase issues, and the MCP
|
||||
// surface is read-only, so a token without it has no business here. The
|
||||
// check is present rather than implied so that adding krow.write later
|
||||
// is a change in one place.
|
||||
if !hasScope(token.Scopes, ScopeRead) {
|
||||
a.log.Warn("mcp bearer refused", "reason", "missing_scope", "token_id", token.ID)
|
||||
return authctx.Identity{}, ErrTokenUnusable
|
||||
}
|
||||
|
||||
// 4. The user, re-read live. See the type comment for why this is not taken
|
||||
// from the token row.
|
||||
user, err := a.users.FindByID(ctx, token.UserID)
|
||||
if err != nil {
|
||||
// The FK cascades, so a missing user should be unreachable. If it
|
||||
// happens the token is orphaned and worth killing.
|
||||
a.log.Warn("mcp bearer refused", "reason", "user_missing", "token_id", token.ID)
|
||||
_ = a.store.RevokeFamily(ctx, token.FamilyID, "user_missing")
|
||||
return authctx.Identity{}, ErrTokenUnusable
|
||||
}
|
||||
|
||||
// 5. Suspension revokes on contact, exactly as the cookie path does. Not
|
||||
// "the token stops working at expiry" — a suspended account must lose
|
||||
// access on its next request, and leaving the family alive would mean it
|
||||
// kept a working credential for up to thirty days.
|
||||
if !user.IsActive() {
|
||||
a.log.Warn("mcp bearer refused",
|
||||
"reason", "user_inactive", "user_id", user.ID, "status", user.Status)
|
||||
_ = a.store.RevokeFamily(ctx, token.FamilyID, "user_suspended")
|
||||
return authctx.Identity{}, ErrTokenUnusable
|
||||
}
|
||||
|
||||
// The same construction httpserver/auth.go performs for a cookie. SessionID
|
||||
// and ExpiresAt are deliberately left zero: there is no session row behind
|
||||
// this identity, and inventing one would make a token look like something
|
||||
// logout could end.
|
||||
return authctx.Identity{
|
||||
UserID: user.ID,
|
||||
OrgID: user.OrgID,
|
||||
Email: user.Email,
|
||||
FullName: user.FullName,
|
||||
Role: user.Role,
|
||||
AccountType: user.AccountType,
|
||||
Status: user.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hasScope reports whether a scope was granted.
|
||||
func hasScope(granted []string, want string) bool {
|
||||
for _, s := range granted {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// newUUID returns a random UUID v4 string, for family ids.
|
||||
//
|
||||
// Hand-rolled rather than adding a dependency: the module is stdlib plus pgx,
|
||||
// and one 16-byte read with two bits set is not worth a third-party package.
|
||||
func newUUID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("oauth: generate uuid: %w", err)
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
h := hex.EncodeToString(b[:])
|
||||
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32], nil
|
||||
}
|
||||
Reference in New Issue
Block a user