Files
multica/server/internal/service/plugin_token.go
Jiayuan Zhangandmultica-agent 8ade96e671 MUL-6584: establish Public API v1 foundation (#7450)
* feat(api): establish public v1 contract foundation

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): close public API contract gaps

Co-authored-by: multica-agent <[email protected]>

* fix(issues): enforce revision guard during update

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 22:47:37 +08:00

246 lines
9.2 KiB
Go

package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Two credentials, moving in opposite directions.
//
// Neither one ever enters an iframe: a surface still holds nothing and still
// reaches Multica only by asking the host page over postMessage. What changes
// with hooks is that a plugin now has a SERVER, and that server needs a way to
// be recognised. So the honest statement about the system is no longer "there
// are no plugin credentials" but "plugin credentials only move between
// servers".
//
// install token (mpi_…) plugin -> host, long-lived, rotatable.
// The host only ever verifies it, so it is stored
// hashed and cannot be recovered from the database.
// callback token host -> plugin, minutes, one INVOCATION.
// Handed to a hook handler so it can answer using the
// Action API without being given standing access.
const (
installTokenPrefix = "mpi_"
callbackTokenPrefix = "mpc_"
callbackTokenTTL = 5 * time.Minute
callbackTokenEntropy = 32
)
// IssueInstallToken mints a new install token and stores only its hash.
//
// Returned in plaintext exactly once. There is no endpoint that reads it back —
// an admin who loses it rotates rather than recovers, which is the same trade
// every other bearer credential in the product makes.
func (s *PluginService) IssueInstallToken(ctx context.Context, installationID pgtype.UUID) (string, error) {
raw := make([]byte, callbackTokenEntropy)
if _, err := rand.Read(raw); err != nil {
return "", &PluginError{Kind: PluginErrorUnavailable, Message: "generate install token", Err: err}
}
token := installTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
if err := s.Queries.SetPluginInstallationToken(ctx, db.SetPluginInstallationTokenParams{
ID: installationID,
TokenHash: pgtype.Text{String: hashToken(token), Valid: true},
}); err != nil {
return "", &PluginError{Kind: PluginErrorUnavailable, Message: "store install token", Err: err}
}
return token, nil
}
// InstallCredentials is returned exactly once when an admin rotates a Plugin's
// standing credential. SigningSecret is empty when hook signing is disabled;
// the install token remains usable for the Public API in that deployment.
type InstallCredentials struct {
Token string
SigningSecret string
}
// RotateInstallCredentials prepares every response value before replacing the
// stored token hash. This keeps an optional hook-signing configuration failure
// from invalidating the previous token after the response has already become
// impossible to complete.
func (s *PluginService) RotateInstallCredentials(ctx context.Context, installationID pgtype.UUID) (InstallCredentials, error) {
var signingSecret string
if len(s.DeploymentKey) > 0 {
secret, err := s.HookSigningSecret(installationID)
if err != nil {
return InstallCredentials{}, err
}
signingSecret = secret
}
token, err := s.IssueInstallToken(ctx, installationID)
if err != nil {
return InstallCredentials{}, err
}
return InstallCredentials{Token: token, SigningSecret: signingSecret}, nil
}
// RevokeInstallToken drops the stored hash, so nothing presented afterwards
// matches. Rotation is IssueInstallToken, which overwrites in place.
func (s *PluginService) RevokeInstallToken(ctx context.Context, installationID pgtype.UUID) error {
if err := s.Queries.SetPluginInstallationToken(ctx, db.SetPluginInstallationTokenParams{
ID: installationID,
TokenHash: pgtype.Text{},
}); err != nil {
return &PluginError{Kind: PluginErrorUnavailable, Message: "revoke install token", Err: err}
}
return nil
}
// AuthenticateInstallToken resolves a presented token to its installation.
func (s *PluginService) AuthenticateInstallToken(ctx context.Context, token string) (db.PluginInstallation, error) {
token = strings.TrimSpace(token)
if !strings.HasPrefix(token, installTokenPrefix) {
return db.PluginInstallation{}, pluginErrf(PluginErrorForbidden, "invalid plugin token")
}
installation, err := s.Queries.GetPluginInstallationByTokenHash(ctx, pgtype.Text{String: hashToken(token), Valid: true})
if errors.Is(err, pgx.ErrNoRows) {
return db.PluginInstallation{}, pluginErrf(PluginErrorForbidden, "invalid plugin token")
}
if err != nil {
return db.PluginInstallation{}, &PluginError{Kind: PluginErrorUnavailable, Message: "load plugin installation", Err: err}
}
if !installation.Enabled {
return db.PluginInstallation{}, pluginErrf(PluginErrorForbidden, "this Plugin is disabled")
}
return installation, nil
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// CallbackGrant is what a redeemed callback token proves.
//
// Its scopes are the installation's, never wider: the callback exists so a hook
// handler can finish the job it was called for, not so an out-of-band request
// can do more than the surface could.
type CallbackGrant struct {
InstallationID pgtype.UUID
WorkspaceID pgtype.UUID
HookKey string
Trigger string
// Actor is who the resulting writes belong to, decided when the hook was
// dispatched. A handler cannot choose to write as somebody else.
Actor HookActor
// IssueID narrows an event callback to the issue that produced it. Zero
// when the invocation had no issue.
IssueID pgtype.UUID
ExpiresAt time.Time
}
// CallbackTokens issues and resolves the per-invocation callback tokens.
//
// Scoped to one INVOCATION, not one request — and that distinction was found by
// running a real handler rather than by reasoning about it. A single-use token
// looked stricter and was: the reference handler reads the issue, decides, then
// posts a comment, and the second call died on an already-spent token. Two calls
// is the floor for any handler that does something with what it read.
//
// The trade that actually matters is not one-call versus several. It is this
// token versus the installation's standing token: a handler that cannot finish
// its job with the callback will simply be written against the install token
// instead, which never expires and is not scoped to an invocation. A control
// that pushes authors toward the stronger credential is worse than the slightly
// looser one they will use.
//
// What still bounds it: minutes, this installation's granted scopes, the actor
// decided at dispatch, and the issue the invocation was about.
//
// Held in memory. Across several instances or after a restart a token stops
// resolving early rather than late, so the failure mode is a handler seeing 403
// — visible and retriable — not a grant outliving its window.
type CallbackTokens struct {
mu sync.Mutex
issued map[string]CallbackGrant
}
func NewCallbackTokens() *CallbackTokens {
return &CallbackTokens{issued: make(map[string]CallbackGrant)}
}
// Issue mints a token for one hook invocation.
func (c *CallbackTokens) Issue(ctx context.Context, invocation HookInvocation) (string, error) {
raw := make([]byte, callbackTokenEntropy)
if _, err := rand.Read(raw); err != nil {
return "", &PluginError{Kind: PluginErrorUnavailable, Message: "generate callback token", Err: err}
}
token := callbackTokenPrefix + base64.RawURLEncoding.EncodeToString(raw)
grant := CallbackGrant{
InstallationID: invocation.Installation.ID,
WorkspaceID: invocation.Installation.WorkspaceID,
HookKey: invocation.Hook.Key,
Trigger: invocation.Trigger,
Actor: invocation.Actor,
IssueID: invocation.IssueID,
ExpiresAt: time.Now().Add(callbackTokenTTL),
}
c.mu.Lock()
defer c.mu.Unlock()
c.sweepLocked()
c.issued[hashToken(token)] = grant
_ = ctx
return token, nil
}
// Resolve looks a token up. Valid for as many calls as the handler needs until
// it expires; see the type comment for why that is the right bound.
func (c *CallbackTokens) Resolve(token string) (CallbackGrant, error) {
token = strings.TrimSpace(token)
if !strings.HasPrefix(token, callbackTokenPrefix) {
return CallbackGrant{}, pluginErrf(PluginErrorForbidden, "invalid callback token")
}
key := hashToken(token)
c.mu.Lock()
defer c.mu.Unlock()
c.sweepLocked()
grant, ok := c.issued[key]
if !ok {
return CallbackGrant{}, pluginErrf(PluginErrorForbidden, "callback token is expired or unknown")
}
if time.Now().After(grant.ExpiresAt) {
delete(c.issued, key)
return CallbackGrant{}, pluginErrf(PluginErrorForbidden, "callback token is expired or unknown")
}
return grant, nil
}
// Revoke drops a grant before its expiry. Called when a hook invocation has
// finished, so a token stops working the moment the work it was issued for is
// over rather than lingering for the rest of its window.
func (c *CallbackTokens) Revoke(token string) {
if token == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
delete(c.issued, hashToken(strings.TrimSpace(token)))
}
// sweepLocked drops expired grants so the map cannot grow without bound on a
// long-running server. Callers hold the mutex.
func (c *CallbackTokens) sweepLocked() {
now := time.Now()
for key, grant := range c.issued {
if now.After(grant.ExpiresAt) {
delete(c.issued, key)
}
}
}