package pier import ( "context" "errors" "slices" "strconv" "strings" "sync" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" ) // OAuthTokenPrefix is the prefix of the OAuth access tokens the published // pact.MCPTokenVerifier verifies; every other bearer is a personal token. const OAuthTokenPrefix = "oas_at_" // Token kinds of an Identity. const ( TokenKindOAuth = "oauth" TokenKindPersonal = "pat" ) // LinkCacheTTL bounds how long a resolved backend link is reused on one // instance before it is read again (P-07); a link event drops it at once. const LinkCacheTTL = 5 * time.Second // ErrUnauthorized is what identity resolution answers for a missing, // unknown, expired or revoked bearer; the endpoint turns it into a 401 with // the protected resource metadata challenge. var ErrUnauthorized = errors.New("pier: unauthorized") // Identity is who one MCP request acts as: the frontend subject (the OAuth // grant's user or a frontend personal token), the backend subject (the // linked admin when the grant carries the backend scope, or an admin // personal token), the scopes of the credential, the session binding key // (D-35: oauth::, pat:user: or // pat:backend:), the credential's expiry and its kind. Every // request resolves its own Identity from its bearer; a session never // replaces it. type Identity struct { Frontend *pact.MCPSubject Backend *pact.MCPSubject Scopes []string Binding string ExpiresAt time.Time TokenKind string // ceiling says the read/write ceiling (D-20) applies: OAuth grants and // frontend personal tokens carry read and write; an admin personal // token is capped through its cabana scope limit instead. ceiling bool // credential is the admin personal token row stored next to the // backend principal on a dispatched call. credential any } // subjectFor returns the subject acting under guard, nil when the identity // has none for it. func (id Identity) subjectFor(guard string) *pact.MCPSubject { if id.Backend != nil && id.Backend.Guard == guard { return id.Backend } if id.Frontend != nil && id.Frontend.Guard == guard { return id.Frontend } return nil } // writes reports whether the identity may call tools that are not // read-only: no ceiling, or the write scope granted. func (id Identity) writes() bool { return !id.ceiling || slices.Contains(id.Scopes, "write") } // fingerprint encodes the authorization-relevant parts of the identity // (P-26): the sorted scopes, both subjects (guard, user, client, token and // their sorted scopes), the ceiling and the token kind. Two requests of one // binding with different fingerprints may see different tools, so a cached // server is re-filtered when it changes (A-WR-04). func (id Identity) fingerprint() string { var b strings.Builder sorted := func(in []string) string { out := slices.Clone(in) slices.Sort(out) return strings.Join(out, ",") } subject := func(s *pact.MCPSubject) { if s == nil { b.WriteString("-|") return } b.WriteString(s.Guard) b.WriteByte(':') b.WriteString(strconv.FormatUint(uint64(s.UserID), 10)) b.WriteByte(':') b.WriteString(s.ClientID) b.WriteByte(':') b.WriteString(strconv.FormatUint(uint64(s.TokenID), 10)) b.WriteByte(':') b.WriteString(sorted(s.Scopes)) b.WriteByte('|') } b.WriteString(sorted(id.Scopes)) b.WriteByte('|') subject(id.Frontend) subject(id.Backend) b.WriteString(strconv.FormatBool(id.ceiling)) b.WriteByte('|') b.WriteString(id.TokenKind) return b.String() } // subjects lists the identity's subjects (frontend first). func (id Identity) subjects() []pact.MCPSubject { var out []pact.MCPSubject if id.Frontend != nil { out = append(out, *id.Frontend) } if id.Backend != nil { out = append(out, *id.Backend) } return out } // has reports whether the identity includes the subject (guard and user id). func (id Identity) has(guard string, userID uint) bool { for _, s := range id.subjects() { if s.Guard == guard && s.UserID == userID { return true } } return false } // backendTokenFunc resolves an admin personal token; tests inject one in // place of cabana.ResolveBackendToken, which needs a database. type backendTokenFunc func(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error) // identityResolver turns a bearer into an Identity through the published // verifier and resolvers, with the per-instance link cache. type identityResolver struct { app *backpack.App cfg Config frontendGuard string backendTokens backendTokenFunc mu sync.Mutex links map[uint]linkEntry now func() time.Time } type linkEntry struct { link pact.LinkedBackendUser ok bool at time.Time } func newIdentityResolver(app *backpack.App, cfg Config, frontendGuard string) *identityResolver { return &identityResolver{ app: app, cfg: cfg, frontendGuard: frontendGuard, backendTokens: cabana.ResolveBackendToken, links: map[uint]linkEntry{}, now: time.Now, } } // resolve verifies raw afresh: an OAuth access token through the published // pact.MCPTokenVerifier (missing verifier: unauthorized); with // mcp.allow_personal_tokens true, an admin g15_ token through cabana and // any other prefix through the published pact.MCPPersonalTokenResolver; // with mcp.allow_personal_tokens false (the default) any bearer that is // not an OAuth token is unauthorized without a lookup (D-19). A // backend subject for an OAuth grant exists only with the backend scope and // a link the published pact.MCPLinkedBackendResolver confirms (D-08). func (r *identityResolver) resolve(ctx context.Context, raw string) (Identity, error) { raw = strings.TrimSpace(raw) if raw == "" { return Identity{}, ErrUnauthorized } if strings.HasPrefix(raw, OAuthTokenPrefix) { return r.resolveOAuth(ctx, raw) } if !r.cfg.AllowPersonalTokens { return Identity{}, ErrUnauthorized } if strings.HasPrefix(raw, cabana.APITokenPrefix) { return r.resolveBackendToken(ctx, raw) } return r.resolvePersonalToken(ctx, raw) } func (r *identityResolver) resolveOAuth(ctx context.Context, raw string) (Identity, error) { verifier, ok := r.app.Lookup[pact.MCPTokenVerifier]() if !ok || verifier == nil { return Identity{}, ErrUnauthorized } grant, err := verifier.MCPVerifyToken(ctx, raw) if err != nil || grant.UserID == 0 { return Identity{}, ErrUnauthorized } scopes := append([]string(nil), grant.Scopes...) id := Identity{ Scopes: scopes, Binding: "oauth:" + grant.ClientID + ":" + strconv.FormatUint(uint64(grant.UserID), 10), ExpiresAt: grant.ExpiresAt, TokenKind: TokenKindOAuth, ceiling: true, } if r.frontendGuard != "" { id.Frontend = &pact.MCPSubject{ Guard: r.frontendGuard, UserID: grant.UserID, Scopes: scopes, ClientID: grant.ClientID, TokenID: grant.TokenID, } } if slices.Contains(scopes, "backend") { if link, ok := r.linkedBackend(ctx, grant.UserID); ok { id.Backend = &pact.MCPSubject{ Guard: BackendGuard, UserID: link.ID, Scopes: scopes, ClientID: grant.ClientID, TokenID: grant.TokenID, } } } return id, nil } func (r *identityResolver) resolveBackendToken(ctx context.Context, raw string) (Identity, error) { principal, row, scopes, err := r.backendTokens(ctx, r.app, raw) if err != nil || principal == nil || row == nil { return Identity{}, ErrUnauthorized } id := Identity{ Scopes: append([]string(nil), scopes...), Binding: "pat:backend:" + strconv.FormatUint(uint64(row.ID), 10), TokenKind: TokenKindPersonal, Backend: &pact.MCPSubject{ Guard: BackendGuard, UserID: principal.ID, Scopes: append([]string(nil), scopes...), TokenID: row.ID, }, credential: row, } if row.ExpiresAt != nil { id.ExpiresAt = *row.ExpiresAt } return id, nil } func (r *identityResolver) resolvePersonalToken(ctx context.Context, raw string) (Identity, error) { resolver, ok := r.app.Lookup[pact.MCPPersonalTokenResolver]() if !ok || resolver == nil { return Identity{}, ErrUnauthorized } subject, ok, err := resolver.MCPResolvePersonalToken(ctx, raw) // TokenID keys the binding (pat:user:): a subject without one // would put every user of the resolver on one binding (A-WR-09). if err != nil || !ok || subject.UserID == 0 || subject.TokenID == 0 { return Identity{}, ErrUnauthorized } if subject.Guard == "" { subject.Guard = r.frontendGuard } if subject.Guard == "" || subject.Guard == BackendGuard { return Identity{}, ErrUnauthorized } return Identity{ Frontend: &subject, Scopes: append([]string(nil), subject.Scopes...), Binding: "pat:user:" + strconv.FormatUint(uint64(subject.TokenID), 10), TokenKind: TokenKindPersonal, ceiling: true, }, nil } // linkedBackend answers the backend account linked to a frontend user // through the published resolver, reusing an answer younger than // LinkCacheTTL on this instance (P-07). func (r *identityResolver) linkedBackend(ctx context.Context, userID uint) (pact.LinkedBackendUser, bool) { resolver, ok := r.app.Lookup[pact.MCPLinkedBackendResolver]() if !ok || resolver == nil { return pact.LinkedBackendUser{}, false } now := r.now() r.mu.Lock() if e, ok := r.links[userID]; ok && now.Sub(e.at) < LinkCacheTTL { r.mu.Unlock() return e.link, e.ok } r.mu.Unlock() link, found, err := resolver.LinkedBackendUser(ctx, userID) if err != nil { return pact.LinkedBackendUser{}, false } if link.ID == 0 { found = false } r.mu.Lock() r.links[userID] = linkEntry{link: link, ok: found, at: now} r.mu.Unlock() return link, found } // forgetLink drops the cached link of one frontend user (a link event). func (r *identityResolver) forgetLink(userID uint) { r.mu.Lock() delete(r.links, userID) r.mu.Unlock() }