Files
multica/server/internal/auth/jwt.go
Jiayuan Zhang f52db9e96c feat(server): install_token mint+exchange + daemon_token revoke (MUL-2305)
Phase 1 of RFC MUL-2297 — DB and credential contract for the new runtime
install flow. CLI/daemon/UI plumbing lands in later phases.

Schema (migration 091):
- daemon_token.revoked_at — explicit revoke replaces TTL-based expiry; the
  exchange path now mints daemon_token rows with a ~100y expires_at so the
  cleanup query stays intact while the credential is effectively long-lived
  until revoked.
- install_token — short-lived (15m) single-use credential. used_at IS NULL
  is the atomic gate enforced inside the UPDATE so a concurrent second
  exchange returns zero rows.

API:
- POST /api/workspaces/{id}/install-tokens — admin-only mint, returns mit_
  once; only the hash is stored.
- POST /api/install-tokens/exchange — public (the mit_ is the credential);
  atomically consumes the install_token and returns a fresh mdt_.

Error contract for Phase 2 daemon installer:
- 401 invalid_install_token — unknown hash OR expired
- 401 install_token_already_used — hash exists but used_at IS NOT NULL

Co-authored-by: multica-agent <github@multica.ai>
2026-05-17 00:09:48 +08:00

66 lines
1.7 KiB
Go

package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"sync"
)
const defaultJWTSecret = "multica-dev-secret-change-in-production"
var (
jwtSecret []byte
jwtSecretOnce sync.Once
)
func JWTSecret() []byte {
jwtSecretOnce.Do(func() {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = defaultJWTSecret
}
jwtSecret = []byte(secret)
})
return jwtSecret
}
// GeneratePATToken creates a new personal access token: "mul_" + 40 random hex chars.
func GeneratePATToken() (string, error) {
b := make([]byte, 20) // 20 bytes = 40 hex chars
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate PAT token: %w", err)
}
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
}
// 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))
return hex.EncodeToString(h[:])
}