mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
fix/cloud-
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f52db9e96c |
@@ -219,6 +219,13 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Post("/api/webhooks/github", h.HandleGitHubWebhook)
|
||||
r.Get("/api/github/setup", h.GitHubSetupCallback)
|
||||
|
||||
// Install-token exchange is public — the `mit_` token in the request
|
||||
// body IS the credential. A daemon installer hasn't been issued an
|
||||
// `mdt_` yet, and we don't want to require a user PAT for the
|
||||
// install-time hand-off. See handler.ExchangeInstallToken for the
|
||||
// single-use semantics. RFC MUL-2297 Phase 1.
|
||||
r.Post("/api/install-tokens/exchange", h.ExchangeInstallToken)
|
||||
|
||||
// Daemon API routes (require daemon token or valid user token)
|
||||
r.Route("/api/daemon", func(r chi.Router) {
|
||||
r.Use(middleware.DaemonAuth(queries, patCache, daemonTokenCache))
|
||||
@@ -294,6 +301,11 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
|
||||
r.Delete("/", h.DeleteMember)
|
||||
})
|
||||
r.Delete("/invitations/{invitationId}", h.RevokeInvitation)
|
||||
|
||||
// Install-token mint — workspace admin presses "Add
|
||||
// computer", server hands back a one-shot `mit_` to paste
|
||||
// into a daemon installer. RFC MUL-2297 Phase 1.
|
||||
r.Post("/install-tokens", h.CreateInstallToken)
|
||||
})
|
||||
// Owner-only access
|
||||
r.With(middleware.RequireWorkspaceRoleFromURL(queries, "id", "owner")).Delete("/", h.DeleteWorkspace)
|
||||
|
||||
@@ -46,6 +46,18 @@ func GenerateDaemonToken() (string, error) {
|
||||
return "mdt_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GenerateInstallToken creates a new single-use install token: "mit_" + 40
|
||||
// random hex chars. The raw token is shown to the user once at mint time
|
||||
// and exchanged by the daemon installer for a long-lived daemon_token.
|
||||
// See RFC MUL-2297.
|
||||
func GenerateInstallToken() (string, error) {
|
||||
b := make([]byte, 20) // 20 bytes = 40 hex chars
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate install token: %w", err)
|
||||
}
|
||||
return "mit_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashToken returns the hex-encoded SHA-256 hash of a token string.
|
||||
func HashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
|
||||
184
server/internal/handler/install_token.go
Normal file
184
server/internal/handler/install_token.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
// installTokenTTL bounds how long an install_token stays valid between mint
|
||||
// and exchange. The RFC (MUL-2297 Phase 1) suggests 15 minutes — long enough
|
||||
// for a user to copy the snippet, switch to their machine, paste it into an
|
||||
// installer, and complete a network round-trip; short enough that a token
|
||||
// leaked onto a chat log or screenshot doesn't stay armed for long.
|
||||
const installTokenTTL = 15 * time.Minute
|
||||
|
||||
// daemonTokenLongLivedExpiry is how far in the future an mdt_ token issued
|
||||
// via the install_token exchange is dated. The point is "no automatic
|
||||
// expiry" — explicit revoke via daemon_token.revoked_at is the only way to
|
||||
// retire one. We keep expires_at NOT NULL (legacy schema constraint) and
|
||||
// the DeleteExpiredDaemonTokens cleanup query intact by picking a stamp so
|
||||
// far out that no rational deployment will reach it. See RFC MUL-2297.
|
||||
const daemonTokenLongLivedExpiry = 100 * 365 * 24 * time.Hour
|
||||
|
||||
// CreateInstallTokenResponse is the workspace-admin-facing mint response.
|
||||
// Token is shown once — the server only retains the hash.
|
||||
type CreateInstallTokenResponse struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateInstallToken mints a short-lived single-use install token for the
|
||||
// authenticated admin's workspace. Routed under
|
||||
// /api/workspaces/{id}/install-tokens so workspace membership/role is
|
||||
// enforced by the RequireWorkspaceRoleFromURL middleware before this
|
||||
// handler runs.
|
||||
func (h *Handler) CreateInstallToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := middleware.WorkspaceIDFromContext(r.Context())
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return
|
||||
}
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rawToken, err := auth.GenerateInstallToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
expiresAt := pgtype.Timestamptz{Time: time.Now().Add(installTokenTTL), Valid: true}
|
||||
|
||||
row, err := h.Queries.CreateInstallToken(r.Context(), db.CreateInstallTokenParams{
|
||||
TokenHash: auth.HashToken(rawToken),
|
||||
WorkspaceID: wsUUID,
|
||||
CreatedBy: parseUUID(userID),
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create install token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, CreateInstallTokenResponse{
|
||||
ID: uuidToString(row.ID),
|
||||
Token: rawToken,
|
||||
WorkspaceID: uuidToString(row.WorkspaceID),
|
||||
ExpiresAt: timestampToString(row.ExpiresAt),
|
||||
CreatedAt: timestampToString(row.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// ExchangeInstallTokenRequest is the public unauthenticated body posted by a
|
||||
// daemon installer. The mit_ token itself is the credential.
|
||||
type ExchangeInstallTokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
}
|
||||
|
||||
// ExchangeInstallTokenResponse returns the long-lived daemon token. The
|
||||
// caller stores `token` and authenticates subsequent /api/daemon/* requests
|
||||
// with it.
|
||||
type ExchangeInstallTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
DaemonID string `json:"daemon_id"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
// ExchangeInstallToken validates a `mit_` install token, atomically burns
|
||||
// it, and returns a fresh long-lived `mdt_` daemon token bound to the
|
||||
// caller-supplied daemon_id. Public (no Authorization header) — the install
|
||||
// token IS the credential.
|
||||
//
|
||||
// Error contract (Phase 2 daemon depends on these):
|
||||
// - 400 `invalid_install_token` — malformed body or missing fields
|
||||
// - 401 `invalid_install_token` — unknown hash OR expired
|
||||
// - 401 `install_token_already_used` — hash exists but used_at IS NOT NULL
|
||||
// - 500 any DB / token-gen failure
|
||||
func (h *Handler) ExchangeInstallToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req ExchangeInstallTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_install_token")
|
||||
return
|
||||
}
|
||||
req.Token = strings.TrimSpace(req.Token)
|
||||
req.DaemonID = strings.TrimSpace(req.DaemonID)
|
||||
if req.Token == "" || !strings.HasPrefix(req.Token, "mit_") {
|
||||
writeError(w, http.StatusBadRequest, "invalid_install_token")
|
||||
return
|
||||
}
|
||||
if req.DaemonID == "" {
|
||||
writeError(w, http.StatusBadRequest, "daemon_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
hash := auth.HashToken(req.Token)
|
||||
|
||||
// Try to consume the row atomically (single UPDATE flips used_at). A
|
||||
// miss here means one of: unknown hash, expired, or already used. We
|
||||
// disambiguate with a follow-up read so the "already used" path can
|
||||
// return the dedicated error code the daemon installer surfaces to
|
||||
// the user — that copy explicitly tells them to mint a fresh mit_.
|
||||
consumed, err := h.Queries.ConsumeInstallToken(r.Context(), hash)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to exchange install token")
|
||||
return
|
||||
}
|
||||
existing, lookupErr := h.Queries.GetInstallTokenByHash(r.Context(), hash)
|
||||
if lookupErr == nil && existing.UsedAt.Valid {
|
||||
writeError(w, http.StatusUnauthorized, "install_token_already_used")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusUnauthorized, "invalid_install_token")
|
||||
return
|
||||
}
|
||||
|
||||
daemonToken, err := auth.GenerateDaemonToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate daemon token")
|
||||
return
|
||||
}
|
||||
expiresAt := pgtype.Timestamptz{
|
||||
Time: time.Now().Add(daemonTokenLongLivedExpiry),
|
||||
Valid: true,
|
||||
}
|
||||
|
||||
created, err := h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{
|
||||
TokenHash: auth.HashToken(daemonToken),
|
||||
WorkspaceID: consumed.WorkspaceID,
|
||||
DaemonID: req.DaemonID,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to issue daemon token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, ExchangeInstallTokenResponse{
|
||||
Token: daemonToken,
|
||||
WorkspaceID: uuidToString(created.WorkspaceID),
|
||||
DaemonID: created.DaemonID,
|
||||
ExpiresAt: timestampToString(created.ExpiresAt),
|
||||
})
|
||||
}
|
||||
337
server/internal/handler/install_token_test.go
Normal file
337
server/internal/handler/install_token_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
"github.com/multica-ai/multica/server/internal/middleware"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
)
|
||||
|
||||
// withWorkspaceContext primes the workspace ID into the request context the
|
||||
// same way the RequireWorkspaceMember* middleware does in production. The
|
||||
// handler tests bypass the real middleware chain.
|
||||
func withWorkspaceContext(t *testing.T, req *http.Request, workspaceID string) *http.Request {
|
||||
t.Helper()
|
||||
memberRow, err := testHandler.Queries.GetMemberByUserAndWorkspace(context.Background(), db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: util.MustParseUUID(testUserID),
|
||||
WorkspaceID: util.MustParseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load member row: %v", err)
|
||||
}
|
||||
return req.WithContext(middleware.SetMemberContext(req.Context(), workspaceID, memberRow))
|
||||
}
|
||||
|
||||
func TestCreateInstallToken_ReturnsMitTokenAndPersistsHash(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/install-tokens", nil)
|
||||
req = withWorkspaceContext(t, req, testWorkspaceID)
|
||||
testHandler.CreateInstallToken(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("CreateInstallToken: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp CreateInstallTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Token, "mit_") {
|
||||
t.Fatalf("expected mit_ prefix, got %q", resp.Token)
|
||||
}
|
||||
if resp.ID == "" || resp.WorkspaceID != testWorkspaceID {
|
||||
t.Fatalf("expected workspace_id %q with non-empty id, got %+v", testWorkspaceID, resp)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, resp.ID)
|
||||
})
|
||||
|
||||
// Server must store hash, not raw token. The raw token must not appear
|
||||
// in any column.
|
||||
sum := sha256.Sum256([]byte(resp.Token))
|
||||
wantHash := hex.EncodeToString(sum[:])
|
||||
var gotHash string
|
||||
var usedAt *time.Time
|
||||
var expiresAt time.Time
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT token_hash, used_at, expires_at FROM install_token WHERE id = $1`, resp.ID,
|
||||
).Scan(&gotHash, &usedAt, &expiresAt); err != nil {
|
||||
t.Fatalf("read install_token row: %v", err)
|
||||
}
|
||||
if gotHash != wantHash {
|
||||
t.Fatalf("expected token_hash %q, got %q", wantHash, gotHash)
|
||||
}
|
||||
if usedAt != nil {
|
||||
t.Fatalf("expected used_at NULL on fresh token, got %v", usedAt)
|
||||
}
|
||||
// Expiry must land in (now, now + 30m]: TTL is 15m, but we allow slop
|
||||
// for slow CI runs and clock drift between Go and Postgres.
|
||||
if expiresAt.Before(time.Now()) || expiresAt.After(time.Now().Add(30*time.Minute)) {
|
||||
t.Fatalf("expires_at %v not within (now, now+30m]", expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeInstallToken_HappyPath(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
rawInstall, installID := seedInstallToken(t, testWorkspaceID, 5*time.Minute)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, installID)
|
||||
})
|
||||
|
||||
const daemonID = "test-daemon-exchange-happy"
|
||||
body := map[string]any{"token": rawInstall, "daemon_id": daemonID}
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("POST", "/api/install-tokens/exchange", body)
|
||||
testHandler.ExchangeInstallToken(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ExchangeInstallToken: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp ExchangeInstallTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(resp.Token, "mdt_") {
|
||||
t.Fatalf("expected mdt_ prefix on returned daemon token, got %q", resp.Token)
|
||||
}
|
||||
if resp.WorkspaceID != testWorkspaceID || resp.DaemonID != daemonID {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM daemon_token WHERE token_hash = $1`, auth.HashToken(resp.Token))
|
||||
})
|
||||
|
||||
// install_token row must be burned (used_at populated).
|
||||
var usedAt *time.Time
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT used_at FROM install_token WHERE id = $1`, installID,
|
||||
).Scan(&usedAt); err != nil {
|
||||
t.Fatalf("read install_token after exchange: %v", err)
|
||||
}
|
||||
if usedAt == nil {
|
||||
t.Fatal("expected install_token.used_at to be set after exchange")
|
||||
}
|
||||
|
||||
// daemon_token row must exist, unrevoked, with the ~100y long-lived
|
||||
// expiry (well past anything a TTL-style mint would produce).
|
||||
var dtExpiresAt time.Time
|
||||
var revokedAt *time.Time
|
||||
if err := testPool.QueryRow(context.Background(),
|
||||
`SELECT expires_at, revoked_at FROM daemon_token WHERE token_hash = $1`,
|
||||
auth.HashToken(resp.Token),
|
||||
).Scan(&dtExpiresAt, &revokedAt); err != nil {
|
||||
t.Fatalf("read daemon_token: %v", err)
|
||||
}
|
||||
if revokedAt != nil {
|
||||
t.Fatalf("expected daemon_token.revoked_at NULL, got %v", revokedAt)
|
||||
}
|
||||
if dtExpiresAt.Before(time.Now().Add(50 * 365 * 24 * time.Hour)) {
|
||||
t.Fatalf("expected long-lived expiry (>50y), got %v", dtExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeInstallToken_DoubleExchangeRejected(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
rawInstall, installID := seedInstallToken(t, testWorkspaceID, 5*time.Minute)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, installID)
|
||||
})
|
||||
|
||||
const daemonID = "test-daemon-double-exchange"
|
||||
body := map[string]any{"token": rawInstall, "daemon_id": daemonID}
|
||||
|
||||
// First exchange must succeed.
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("first exchange: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var first ExchangeInstallTokenResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &first); err != nil {
|
||||
t.Fatalf("decode first response: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM daemon_token WHERE token_hash = $1`, auth.HashToken(first.Token))
|
||||
})
|
||||
|
||||
// Second exchange must fail with the dedicated error code so the daemon
|
||||
// installer can surface the "请重新生成 mit_" guidance from the RFC.
|
||||
w = httptest.NewRecorder()
|
||||
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second exchange: expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var errBody map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if errBody["error"] != "install_token_already_used" {
|
||||
t.Fatalf("expected install_token_already_used, got %q", errBody["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeInstallToken_UnknownTokenRejected(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := map[string]any{
|
||||
"token": "mit_unknown_never_minted_token_hex_padding_4",
|
||||
"daemon_id": "test-daemon-unknown-mit",
|
||||
}
|
||||
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var errBody map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if errBody["error"] != "invalid_install_token" {
|
||||
t.Fatalf("expected invalid_install_token, got %q", errBody["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeInstallToken_ExpiredTokenRejected(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
rawInstall, installID := seedInstallToken(t, testWorkspaceID, -1*time.Minute)
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM install_token WHERE id = $1`, installID)
|
||||
})
|
||||
|
||||
body := map[string]any{"token": rawInstall, "daemon_id": "test-daemon-expired-mit"}
|
||||
w := httptest.NewRecorder()
|
||||
testHandler.ExchangeInstallToken(w, newRequest("POST", "/api/install-tokens/exchange", body))
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var errBody map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
// An expired-but-never-used token reports invalid_install_token, not
|
||||
// install_token_already_used — the daemon installer's recovery copy
|
||||
// is "mint a fresh token", which is correct for both cases.
|
||||
if errBody["error"] != "invalid_install_token" {
|
||||
t.Fatalf("expected invalid_install_token for expired row, got %q", errBody["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDaemonTokenByHash_FiltersRevoked guards the D4 schema change: once a
|
||||
// daemon_token row has revoked_at set, GetDaemonTokenByHash (which feeds the
|
||||
// DaemonAuth middleware) must refuse to resolve it even though expires_at is
|
||||
// still in the future. The bug we're preventing is a revoked daemon
|
||||
// continuing to authenticate until the original short-TTL expiry.
|
||||
func TestGetDaemonTokenByHash_FiltersRevoked(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
|
||||
const daemonID = "test-daemon-revoked-filter"
|
||||
rawToken, hash := seedDaemonToken(t, testWorkspaceID, daemonID, 100*365*24*time.Hour)
|
||||
|
||||
// Before revoke, the row resolves.
|
||||
if _, err := testHandler.Queries.GetDaemonTokenByHash(context.Background(), hash); err != nil {
|
||||
t.Fatalf("pre-revoke lookup: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testPool.Exec(context.Background(),
|
||||
`UPDATE daemon_token SET revoked_at = now() WHERE token_hash = $1`, hash,
|
||||
); err != nil {
|
||||
t.Fatalf("revoke row: %v", err)
|
||||
}
|
||||
|
||||
// After revoke, the row must vanish from the auth path even though
|
||||
// expires_at is decades out.
|
||||
_, err := testHandler.Queries.GetDaemonTokenByHash(context.Background(), hash)
|
||||
if err == nil {
|
||||
t.Fatal("expected GetDaemonTokenByHash to fail for revoked row")
|
||||
}
|
||||
|
||||
// Tidy up: rawToken isn't an authentication concern (it was never
|
||||
// returned to a client), but leaving the row around taints later runs
|
||||
// of the same fixture.
|
||||
_ = rawToken
|
||||
testPool.Exec(context.Background(), `DELETE FROM daemon_token WHERE token_hash = $1`, hash)
|
||||
}
|
||||
|
||||
// seedInstallToken inserts an install_token row directly so tests don't have
|
||||
// to run the mint endpoint just to get a known-good raw token. ttl<=0 mints
|
||||
// an already-expired row (ExpiresAt set in the past).
|
||||
func seedInstallToken(t *testing.T, workspaceID string, ttl time.Duration) (string, string) {
|
||||
t.Helper()
|
||||
raw, err := auth.GenerateInstallToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generate install token: %v", err)
|
||||
}
|
||||
hash := auth.HashToken(raw)
|
||||
var id string
|
||||
if err := testPool.QueryRow(context.Background(), `
|
||||
INSERT INTO install_token (token_hash, workspace_id, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, now() + $4::interval)
|
||||
RETURNING id
|
||||
`, hash, workspaceID, testUserID, formatInterval(ttl)).Scan(&id); err != nil {
|
||||
t.Fatalf("seed install_token: %v", err)
|
||||
}
|
||||
return raw, id
|
||||
}
|
||||
|
||||
func seedDaemonToken(t *testing.T, workspaceID, daemonID string, ttl time.Duration) (string, string) {
|
||||
t.Helper()
|
||||
raw, err := auth.GenerateDaemonToken()
|
||||
if err != nil {
|
||||
t.Fatalf("generate daemon token: %v", err)
|
||||
}
|
||||
hash := auth.HashToken(raw)
|
||||
if _, err := testPool.Exec(context.Background(), `
|
||||
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
|
||||
VALUES ($1, $2, $3, now() + $4::interval)
|
||||
`, hash, workspaceID, daemonID, formatInterval(ttl)); err != nil {
|
||||
t.Fatalf("seed daemon_token: %v", err)
|
||||
}
|
||||
return raw, hash
|
||||
}
|
||||
|
||||
// formatInterval renders a Go duration as a Postgres interval literal.
|
||||
// Sub-second precision isn't needed for these tests; we clamp to whole
|
||||
// seconds (with sign) so negative TTLs still produce a valid interval like
|
||||
// "-60 seconds" for the expired-token case.
|
||||
func formatInterval(d time.Duration) string {
|
||||
secs := int64(d / time.Second)
|
||||
if secs == 0 && d != 0 {
|
||||
if d > 0 {
|
||||
secs = 1
|
||||
} else {
|
||||
secs = -1
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d seconds", secs)
|
||||
}
|
||||
2
server/migrations/091_daemon_token_revoked_at.down.sql
Normal file
2
server/migrations/091_daemon_token_revoked_at.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE daemon_token
|
||||
DROP COLUMN revoked_at;
|
||||
10
server/migrations/091_daemon_token_revoked_at.up.sql
Normal file
10
server/migrations/091_daemon_token_revoked_at.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- daemon_token.revoked_at flips the credential from "short-lived auto-expiry"
|
||||
-- to "long-lived until explicitly revoked". Phase 1 of the runtime UX
|
||||
-- refactor (RFC MUL-2297): once the install_token / mdt_ flow is wired up
|
||||
-- (Phase 2+), an issued daemon token sticks until the workspace owner
|
||||
-- revokes its runtime — so users no longer need to re-paste a token on a
|
||||
-- schedule. expires_at stays NOT NULL and is set to ~now()+100y at mint
|
||||
-- time, which is functionally equivalent to "no expiry" while keeping the
|
||||
-- DeleteExpiredDaemonTokens cleanup path intact.
|
||||
ALTER TABLE daemon_token
|
||||
ADD COLUMN revoked_at TIMESTAMPTZ NULL;
|
||||
1
server/migrations/091_install_token.down.sql
Normal file
1
server/migrations/091_install_token.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS install_token;
|
||||
26
server/migrations/091_install_token.up.sql
Normal file
26
server/migrations/091_install_token.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- install_token is a single-use, short-lived bearer credential that an
|
||||
-- authorized workspace member (or autopilot, eventually) mints and pastes
|
||||
-- into a daemon installer. The installer POSTs the raw token to the
|
||||
-- exchange endpoint, which atomically marks it used and returns a
|
||||
-- long-lived daemon_token (mdt_). Phase 1 of RFC MUL-2297 — DB +
|
||||
-- credential contract only; Phase 2 wires the daemon/CLI side.
|
||||
--
|
||||
-- Why a dedicated table instead of reusing daemon_token / PAT:
|
||||
-- * single-use semantics — used_at + UPDATE ... WHERE used_at IS NULL
|
||||
-- atomically rejects replay. daemon_token has no such gate.
|
||||
-- * short TTL — install tokens live ~15 minutes; daemon tokens live
|
||||
-- 100 years. Sharing one row would conflate the two lifecycles.
|
||||
-- * different prefix (mit_ vs mdt_) so DaemonAuth never accidentally
|
||||
-- accepts a not-yet-exchanged install token on /api/daemon/*.
|
||||
CREATE TABLE install_token (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token_hash TEXT NOT NULL,
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_install_token_hash ON install_token(token_hash);
|
||||
CREATE INDEX idx_install_token_workspace ON install_token(workspace_id);
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const createDaemonToken = `-- name: CreateDaemonToken :one
|
||||
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at
|
||||
RETURNING id, token_hash, workspace_id, daemon_id, expires_at, created_at, revoked_at
|
||||
`
|
||||
|
||||
type CreateDaemonTokenParams struct {
|
||||
@@ -39,6 +39,7 @@ func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenPa
|
||||
&i.DaemonID,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -93,10 +94,16 @@ func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error {
|
||||
}
|
||||
|
||||
const getDaemonTokenByHash = `-- name: GetDaemonTokenByHash :one
|
||||
SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at FROM daemon_token
|
||||
WHERE token_hash = $1 AND expires_at > now()
|
||||
SELECT id, token_hash, workspace_id, daemon_id, expires_at, created_at, revoked_at FROM daemon_token
|
||||
WHERE token_hash = $1
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > now()
|
||||
`
|
||||
|
||||
// revoked_at IS NULL filters out tokens explicitly revoked via the runtime
|
||||
// UX flow (RFC MUL-2297). expires_at > now() stays for defense in depth even
|
||||
// though Phase 1 mints tokens with a ~100-year expiry — the legacy short-TTL
|
||||
// behavior is still legal and the cleanup query depends on it.
|
||||
func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) {
|
||||
row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash)
|
||||
var i DaemonToken
|
||||
@@ -107,6 +114,31 @@ func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (D
|
||||
&i.DaemonID,
|
||||
&i.ExpiresAt,
|
||||
&i.CreatedAt,
|
||||
&i.RevokedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const revokeDaemonTokenByID = `-- name: RevokeDaemonTokenByID :one
|
||||
UPDATE daemon_token
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND revoked_at IS NULL
|
||||
RETURNING token_hash
|
||||
`
|
||||
|
||||
type RevokeDaemonTokenByIDParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// Marks a single daemon_token revoked. Returns token_hash so the caller can
|
||||
// invalidate auth.DaemonTokenCache before the 10-minute TTL would otherwise
|
||||
// let a revoked token keep authenticating on a cached lookup.
|
||||
func (q *Queries) RevokeDaemonTokenByID(ctx context.Context, arg RevokeDaemonTokenByIDParams) (string, error) {
|
||||
row := q.db.QueryRow(ctx, revokeDaemonTokenByID, arg.ID, arg.WorkspaceID)
|
||||
var token_hash string
|
||||
err := row.Scan(&token_hash)
|
||||
return token_hash, err
|
||||
}
|
||||
|
||||
109
server/pkg/db/generated/install_token.sql.go
Normal file
109
server/pkg/db/generated/install_token.sql.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: install_token.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const consumeInstallToken = `-- name: ConsumeInstallToken :one
|
||||
UPDATE install_token
|
||||
SET used_at = now()
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
RETURNING id, token_hash, workspace_id, created_by, created_at, expires_at, used_at
|
||||
`
|
||||
|
||||
// Atomically validates and burns an install_token in a single statement. The
|
||||
// caller passes the SHA-256 hash of the raw mit_ token; the row is selected
|
||||
// only when it is unexpired AND not yet consumed, and the same UPDATE sets
|
||||
// used_at = now() so a concurrent second exchange returns zero rows. Callers
|
||||
// distinguish "wrong/expired" from "already used" by re-querying after a
|
||||
// miss (see handler.ExchangeInstallToken).
|
||||
func (q *Queries) ConsumeInstallToken(ctx context.Context, tokenHash string) (InstallToken, error) {
|
||||
row := q.db.QueryRow(ctx, consumeInstallToken, tokenHash)
|
||||
var i InstallToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.WorkspaceID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.UsedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createInstallToken = `-- name: CreateInstallToken :one
|
||||
INSERT INTO install_token (token_hash, workspace_id, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, token_hash, workspace_id, created_by, created_at, expires_at, used_at
|
||||
`
|
||||
|
||||
type CreateInstallTokenParams struct {
|
||||
TokenHash string `json:"token_hash"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateInstallToken(ctx context.Context, arg CreateInstallTokenParams) (InstallToken, error) {
|
||||
row := q.db.QueryRow(ctx, createInstallToken,
|
||||
arg.TokenHash,
|
||||
arg.WorkspaceID,
|
||||
arg.CreatedBy,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
var i InstallToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.WorkspaceID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.UsedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteExpiredInstallTokens = `-- name: DeleteExpiredInstallTokens :exec
|
||||
DELETE FROM install_token
|
||||
WHERE expires_at <= now()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredInstallTokens(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, deleteExpiredInstallTokens)
|
||||
return err
|
||||
}
|
||||
|
||||
const getInstallTokenByHash = `-- name: GetInstallTokenByHash :one
|
||||
SELECT id, token_hash, workspace_id, created_by, created_at, expires_at, used_at FROM install_token
|
||||
WHERE token_hash = $1
|
||||
`
|
||||
|
||||
// Read-only lookup used after ConsumeInstallToken returns zero rows, so the
|
||||
// handler can tell "no such token / expired" apart from "already consumed"
|
||||
// and return the install_token_already_used error code that Phase 2's
|
||||
// daemon installer expects.
|
||||
func (q *Queries) GetInstallTokenByHash(ctx context.Context, tokenHash string) (InstallToken, error) {
|
||||
row := q.db.QueryRow(ctx, getInstallTokenByHash, tokenHash)
|
||||
var i InstallToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TokenHash,
|
||||
&i.WorkspaceID,
|
||||
&i.CreatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.UsedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -230,6 +230,7 @@ type DaemonToken struct {
|
||||
DaemonID string `json:"daemon_id"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type Feedback struct {
|
||||
@@ -292,6 +293,16 @@ type InboxItem struct {
|
||||
Details []byte `json:"details"`
|
||||
}
|
||||
|
||||
type InstallToken struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
UsedAt pgtype.Timestamptz `json:"used_at"`
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
||||
@@ -4,8 +4,25 @@ VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetDaemonTokenByHash :one
|
||||
-- revoked_at IS NULL filters out tokens explicitly revoked via the runtime
|
||||
-- UX flow (RFC MUL-2297). expires_at > now() stays for defense in depth even
|
||||
-- though Phase 1 mints tokens with a ~100-year expiry — the legacy short-TTL
|
||||
-- behavior is still legal and the cleanup query depends on it.
|
||||
SELECT * FROM daemon_token
|
||||
WHERE token_hash = $1 AND expires_at > now();
|
||||
WHERE token_hash = $1
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > now();
|
||||
|
||||
-- name: RevokeDaemonTokenByID :one
|
||||
-- Marks a single daemon_token revoked. Returns token_hash so the caller can
|
||||
-- invalidate auth.DaemonTokenCache before the 10-minute TTL would otherwise
|
||||
-- let a revoked token keep authenticating on a cached lookup.
|
||||
UPDATE daemon_token
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1
|
||||
AND workspace_id = $2
|
||||
AND revoked_at IS NULL
|
||||
RETURNING token_hash;
|
||||
|
||||
-- name: DeleteDaemonTokensByWorkspaceAndDaemons :many
|
||||
-- Deletes every daemon_token row matching the (workspace_id, daemon_id)
|
||||
|
||||
30
server/pkg/db/queries/install_token.sql
Normal file
30
server/pkg/db/queries/install_token.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- name: CreateInstallToken :one
|
||||
INSERT INTO install_token (token_hash, workspace_id, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: ConsumeInstallToken :one
|
||||
-- Atomically validates and burns an install_token in a single statement. The
|
||||
-- caller passes the SHA-256 hash of the raw mit_ token; the row is selected
|
||||
-- only when it is unexpired AND not yet consumed, and the same UPDATE sets
|
||||
-- used_at = now() so a concurrent second exchange returns zero rows. Callers
|
||||
-- distinguish "wrong/expired" from "already used" by re-querying after a
|
||||
-- miss (see handler.ExchangeInstallToken).
|
||||
UPDATE install_token
|
||||
SET used_at = now()
|
||||
WHERE token_hash = $1
|
||||
AND used_at IS NULL
|
||||
AND expires_at > now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetInstallTokenByHash :one
|
||||
-- Read-only lookup used after ConsumeInstallToken returns zero rows, so the
|
||||
-- handler can tell "no such token / expired" apart from "already consumed"
|
||||
-- and return the install_token_already_used error code that Phase 2's
|
||||
-- daemon installer expects.
|
||||
SELECT * FROM install_token
|
||||
WHERE token_hash = $1;
|
||||
|
||||
-- name: DeleteExpiredInstallTokens :exec
|
||||
DELETE FROM install_token
|
||||
WHERE expires_at <= now();
|
||||
Reference in New Issue
Block a user