173 lines
6.8 KiB
Go
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
|
|
}
|