Merge pull request #222 from multica-ai/revert/daemon-auth
revert: daemon authentication for API routes (#214)
This commit is contained in:
commit
7b68dd8dda
9 changed files with 16 additions and 306 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
DROP TABLE IF EXISTS daemon_token;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue