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 }