Merge pull request #222 from multica-ai/revert/daemon-auth

revert: daemon authentication for API routes (#214)
This commit is contained in:
Jiayuan Zhang 2026-03-31 15:33:02 +08:00 committed by GitHub
commit 7b68dd8dda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 16 additions and 306 deletions

View file

@ -79,34 +79,28 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/auth/send-code", h.SendCode)
r.Post("/auth/verify-code", h.VerifyCode)
// Daemon API routes
// Daemon API routes (no user auth; daemon auth deferred to later)
r.Route("/api/daemon", func(r chi.Router) {
// Pairing routes — no auth required (daemon doesn't have a token yet).
r.Post("/pairing-sessions", h.CreateDaemonPairingSession)
r.Get("/pairing-sessions/{token}", h.GetDaemonPairingSession)
r.Post("/pairing-sessions/{token}/claim", h.ClaimDaemonPairingSession)
// Authenticated daemon routes — require daemon token (mdt_) or user JWT/PAT.
r.Group(func(r chi.Router) {
r.Use(middleware.DaemonAuth(queries))
r.Post("/register", h.DaemonRegister)
r.Post("/deregister", h.DaemonDeregister)
r.Post("/heartbeat", h.DaemonHeartbeat)
r.Post("/register", h.DaemonRegister)
r.Post("/deregister", h.DaemonDeregister)
r.Post("/heartbeat", h.DaemonHeartbeat)
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage)
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
r.Post("/runtimes/{runtimeId}/tasks/claim", h.ClaimTaskByRuntime)
r.Get("/runtimes/{runtimeId}/tasks/pending", h.ListPendingTasksByRuntime)
r.Post("/runtimes/{runtimeId}/usage", h.ReportRuntimeUsage)
r.Post("/runtimes/{runtimeId}/ping/{pingId}/result", h.ReportPingResult)
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
r.Post("/tasks/{taskId}/start", h.StartTask)
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
r.Post("/tasks/{taskId}/fail", h.FailTask)
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
})
r.Get("/tasks/{taskId}/status", h.GetTaskStatus)
r.Post("/tasks/{taskId}/start", h.StartTask)
r.Post("/tasks/{taskId}/progress", h.ReportTaskProgress)
r.Post("/tasks/{taskId}/complete", h.CompleteTask)
r.Post("/tasks/{taskId}/fail", h.FailTask)
r.Post("/tasks/{taskId}/messages", h.ReportTaskMessages)
r.Get("/tasks/{taskId}/messages", h.ListTaskMessages)
})
// Protected API routes

View file

@ -37,15 +37,6 @@ func GeneratePATToken() (string, error) {
return "mul_" + hex.EncodeToString(b), nil
}
// GenerateDaemonToken creates a new daemon auth token: "mdt_" + 40 random hex chars.
func GenerateDaemonToken() (string, error) {
b := make([]byte, 20) // 20 bytes = 40 hex chars
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate daemon token: %w", err)
}
return "mdt_" + 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))

View file

@ -6,7 +6,6 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
@ -15,8 +14,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/auth"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
const daemonPairingTTL = 10 * time.Minute
@ -53,7 +50,6 @@ type DaemonPairingSessionResponse struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
LinkURL *string `json:"link_url,omitempty"`
DaemonToken *string `json:"daemon_token,omitempty"`
}
type CreateDaemonPairingSessionRequest struct {
@ -386,39 +382,5 @@ func (h *Handler) ClaimDaemonPairingSession(w http.ResponseWriter, r *http.Reque
return
}
resp := daemonPairingSessionToResponse(rec, true)
// Issue a daemon auth token bound to the workspace and daemon.
if rec.WorkspaceID.Valid {
plainToken, err := auth.GenerateDaemonToken()
if err != nil {
slog.Error("failed to generate daemon token", "error", err)
writeError(w, http.StatusInternalServerError, "failed to generate daemon token")
return
}
hash := auth.HashToken(plainToken)
// Revoke any existing tokens for this workspace+daemon pair.
_ = h.Queries.DeleteDaemonTokensByWorkspaceAndDaemon(r.Context(), db.DeleteDaemonTokensByWorkspaceAndDaemonParams{
WorkspaceID: rec.WorkspaceID,
DaemonID: rec.DaemonID,
})
_, err = h.Queries.CreateDaemonToken(r.Context(), db.CreateDaemonTokenParams{
TokenHash: hash,
WorkspaceID: rec.WorkspaceID,
DaemonID: rec.DaemonID,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(365 * 24 * time.Hour), Valid: true},
})
if err != nil {
slog.Error("failed to store daemon token", "error", err)
writeError(w, http.StatusInternalServerError, "failed to store daemon token")
return
}
resp.DaemonToken = &plainToken
slog.Info("daemon token issued", "daemon_id", rec.DaemonID, "workspace_id", uuidToPtr(rec.WorkspaceID))
}
writeJSON(w, http.StatusOK, resp)
writeJSON(w, http.StatusOK, daemonPairingSessionToResponse(rec, true))
}

View file

@ -1,112 +0,0 @@
package middleware
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
"github.com/multica-ai/multica/server/internal/auth"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Daemon context keys.
type daemonContextKey int
const (
ctxKeyDaemonWorkspaceID daemonContextKey = iota
ctxKeyDaemonID
)
// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware.
func DaemonWorkspaceIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyDaemonWorkspaceID).(string)
return id
}
// DaemonIDFromContext returns the daemon ID set by DaemonAuth middleware.
func DaemonIDFromContext(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyDaemonID).(string)
return id
}
// DaemonAuth validates daemon auth tokens (mdt_ prefix) or falls back to
// JWT/PAT validation for backward compatibility with daemons that
// authenticate via user tokens.
func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
slog.Debug("daemon_auth: missing authorization header", "path", r.URL.Path)
writeError(w, http.StatusUnauthorized, "missing authorization header")
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
slog.Debug("daemon_auth: invalid format", "path", r.URL.Path)
writeError(w, http.StatusUnauthorized, "invalid authorization format")
return
}
// Daemon token: "mdt_" prefix.
if strings.HasPrefix(tokenString, "mdt_") {
hash := auth.HashToken(tokenString)
dt, err := queries.GetDaemonTokenByHash(r.Context(), hash)
if err != nil {
slog.Warn("daemon_auth: invalid daemon token", "path", r.URL.Path, "error", err)
writeError(w, http.StatusUnauthorized, "invalid daemon token")
return
}
ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID))
ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Fallback: PAT tokens ("mul_" prefix).
if strings.HasPrefix(tokenString, "mul_") {
hash := auth.HashToken(tokenString)
pat, err := queries.GetPersonalAccessTokenByHash(r.Context(), hash)
if err != nil {
slog.Warn("daemon_auth: invalid PAT", "path", r.URL.Path, "error", err)
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
r.Header.Set("X-User-ID", uuidToString(pat.UserID))
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
next.ServeHTTP(w, r)
return
}
// Fallback: JWT tokens.
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return auth.JWTSecret(), nil
})
if err != nil || !token.Valid {
slog.Warn("daemon_auth: invalid token", "path", r.URL.Path, "error", err)
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
writeError(w, http.StatusUnauthorized, "invalid claims")
return
}
sub, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(sub) == "" {
writeError(w, http.StatusUnauthorized, "invalid claims")
return
}
r.Header.Set("X-User-ID", sub)
next.ServeHTTP(w, r)
})
}
}

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS daemon_token;

View file

@ -1,11 +0,0 @@
CREATE TABLE daemon_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,
daemon_id TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_daemon_token_hash ON daemon_token(token_hash);
CREATE INDEX idx_daemon_token_workspace_daemon ON daemon_token(workspace_id, daemon_id);

View file

@ -1,88 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: daemon_token.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
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
`
type CreateDaemonTokenParams struct {
TokenHash string `json:"token_hash"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonID string `json:"daemon_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
}
func (q *Queries) CreateDaemonToken(ctx context.Context, arg CreateDaemonTokenParams) (DaemonToken, error) {
row := q.db.QueryRow(ctx, createDaemonToken,
arg.TokenHash,
arg.WorkspaceID,
arg.DaemonID,
arg.ExpiresAt,
)
var i DaemonToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.DaemonID,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}
const deleteDaemonTokensByWorkspaceAndDaemon = `-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec
DELETE FROM daemon_token
WHERE workspace_id = $1 AND daemon_id = $2
`
type DeleteDaemonTokensByWorkspaceAndDaemonParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonID string `json:"daemon_id"`
}
func (q *Queries) DeleteDaemonTokensByWorkspaceAndDaemon(ctx context.Context, arg DeleteDaemonTokensByWorkspaceAndDaemonParams) error {
_, err := q.db.Exec(ctx, deleteDaemonTokensByWorkspaceAndDaemon, arg.WorkspaceID, arg.DaemonID)
return err
}
const deleteExpiredDaemonTokens = `-- name: DeleteExpiredDaemonTokens :exec
DELETE FROM daemon_token
WHERE expires_at <= now()
`
func (q *Queries) DeleteExpiredDaemonTokens(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteExpiredDaemonTokens)
return err
}
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()
`
func (q *Queries) GetDaemonTokenByHash(ctx context.Context, tokenHash string) (DaemonToken, error) {
row := q.db.QueryRow(ctx, getDaemonTokenByHash, tokenHash)
var i DaemonToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.WorkspaceID,
&i.DaemonID,
&i.ExpiresAt,
&i.CreatedAt,
)
return i, err
}

View file

@ -131,15 +131,6 @@ type DaemonPairingSession struct {
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type DaemonToken struct {
ID pgtype.UUID `json:"id"`
TokenHash string `json:"token_hash"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
DaemonID string `json:"daemon_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type InboxItem struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`

View file

@ -1,16 +0,0 @@
-- name: CreateDaemonToken :one
INSERT INTO daemon_token (token_hash, workspace_id, daemon_id, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetDaemonTokenByHash :one
SELECT * FROM daemon_token
WHERE token_hash = $1 AND expires_at > now();
-- name: DeleteDaemonTokensByWorkspaceAndDaemon :exec
DELETE FROM daemon_token
WHERE workspace_id = $1 AND daemon_id = $2;
-- name: DeleteExpiredDaemonTokens :exec
DELETE FROM daemon_token
WHERE expires_at <= now();