Files
krow_backend/go-api/internal/oauth/authenticator.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

173 lines
6.8 KiB
Go

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
}