feat: machine-bound token renewal + refresh-token rotation with reuse detection
Bind /renew to the registered machine so a stolen refresh token can't mint tokens from another host. Rotate the refresh token on every renewal; replaying a consumed token whose successor is also consumed revokes the family. Accept- previous-once grace covers agent crash-before-save. Typed auth errors so the polling loop renews on 401 and treats refresh/machine failures as terminal.
This commit is contained in:
parent
1c18a6b55a
commit
e8afcc7994
7 changed files with 293 additions and 32 deletions
|
|
@ -2,11 +2,11 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
|
||||
|
|
@ -246,18 +246,41 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
// Get commands from server
|
||||
response, err := ctx.APIClient.GetCommands(ctx.Cfg.AgentID, metrics)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "401 Unauthorized") && ctx.Cfg.RefreshToken != "" {
|
||||
if errors.Is(err, client.ErrMachineMismatch) {
|
||||
// Terminal: the server no longer recognizes this host as the one the
|
||||
// agent registered on — config moved or copied. Renewal can't fix it
|
||||
// (renewal is now machine-bound too), so don't even try. Surface loudly;
|
||||
// the client already buffered a critical machine_binding_rejected event.
|
||||
// We keep polling rather than exit, so the agent stays visible and
|
||||
// self-heals the moment an operator rebinds it server-side.
|
||||
log.Printf("[ERROR] [agent] [auth] machine_id_mismatch identity_moved_or_copied re_registration_required agent_id=%s", ctx.Cfg.AgentID)
|
||||
} else if errors.Is(err, client.ErrUnauthorized) && ctx.Cfg.RefreshToken != "" {
|
||||
log.Printf("[INFO] [agent] [auth] jwt_expired attempting_renewal agent_id=%s", ctx.Cfg.AgentID)
|
||||
if renewErr := ctx.APIClient.RenewToken(ctx.Cfg.AgentID, ctx.Cfg.RefreshToken, version.Version); renewErr != nil {
|
||||
log.Printf("[ERROR] [agent] [auth] token_renewal_failed error=%v", renewErr)
|
||||
} else {
|
||||
renewErr := ctx.APIClient.RenewToken(ctx.Cfg.AgentID, ctx.Cfg.RefreshToken, version.Version)
|
||||
switch {
|
||||
case renewErr == nil:
|
||||
ctx.Cfg.Token = ctx.APIClient.GetToken()
|
||||
// The refresh token rotates on each renewal (migration 045) — persist
|
||||
// the new one or the next renewal will look like a replay. If the Save
|
||||
// fails here, the server's accept-previous-once grace recovers us on
|
||||
// the next attempt with the old token still on disk.
|
||||
if rt := ctx.APIClient.GetRefreshToken(); rt != "" {
|
||||
ctx.Cfg.RefreshToken = rt
|
||||
}
|
||||
if saveErr := ctx.Cfg.Save(constants.GetAgentConfigPath()); saveErr != nil {
|
||||
log.Printf("[WARNING] [agent] [auth] token_persist_failed error=%v", saveErr)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [auth] token_renewed_successfully")
|
||||
consecutiveFailures = 0
|
||||
continue
|
||||
case errors.Is(renewErr, client.ErrRefreshTokenInvalid):
|
||||
// Terminal: the refresh token is dead. Backing off won't help —
|
||||
// the agent needs re-registration. Surface it loudly; the client
|
||||
// already buffered a critical refresh_token_invalid event.
|
||||
log.Printf("[ERROR] [agent] [auth] refresh_token_invalid re_registration_required agent_id=%s error=%v", ctx.Cfg.AgentID, renewErr)
|
||||
default:
|
||||
// Transient renewal failure (network, 502). Fall through to backoff and retry.
|
||||
log.Printf("[ERROR] [agent] [auth] token_renewal_failed error=%v", renewErr)
|
||||
}
|
||||
}
|
||||
consecutiveFailures++
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package client
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
|
@ -21,6 +22,26 @@ import (
|
|||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Auth sentinel errors. The polling loop branches on these via errors.Is rather
|
||||
// than matching status text, so a change in error formatting can't silently
|
||||
// disable token renewal.
|
||||
//
|
||||
// - ErrUnauthorized: the access token (JWT) was rejected — expected at the
|
||||
// 24h expiry boundary. Recoverable: renew with the refresh token.
|
||||
// - ErrRefreshTokenInvalid: the refresh token itself was rejected (expired,
|
||||
// revoked, or machine unbound). Terminal — no auto-recovery, the agent
|
||||
// must be re-registered. Distinct so operators can tell it apart from a
|
||||
// transient renewal failure (network, 502).
|
||||
var (
|
||||
ErrUnauthorized = errors.New("unauthorized: access token rejected")
|
||||
ErrRefreshTokenInvalid = errors.New("unauthorized: refresh token rejected")
|
||||
// ErrMachineMismatch: the server rejected us because our machine ID doesn't
|
||||
// match the one this agent registered with (403). Terminal — this identity
|
||||
// has been moved or copied to another host. Renewing won't help; a human must
|
||||
// re-register. Distinct so the loop can alarm instead of silently retrying.
|
||||
ErrMachineMismatch = errors.New("forbidden: machine ID mismatch")
|
||||
)
|
||||
|
||||
// Client handles API communication with the server
|
||||
type Client struct {
|
||||
baseURL string
|
||||
|
|
@ -29,6 +50,7 @@ type Client struct {
|
|||
RapidPollingEnabled bool
|
||||
RapidPollingUntil time.Time
|
||||
machineID string // Cached machine ID for security binding
|
||||
refreshToken string // Most recent refresh token (rotated on each renew, migration 045)
|
||||
eventBuffer *event.Buffer
|
||||
agentID uuid.UUID
|
||||
}
|
||||
|
|
@ -175,6 +197,12 @@ func (c *Client) SetToken(token string) {
|
|||
c.token = token
|
||||
}
|
||||
|
||||
// GetRefreshToken returns the most recent refresh token. Empty unless a renewal
|
||||
// has rotated one this process lifetime; the caller persists it to config.
|
||||
func (c *Client) GetRefreshToken() string {
|
||||
return c.refreshToken
|
||||
}
|
||||
|
||||
// DownloadAuthenticatedToFile fetches a relative or absolute URL using the agent's
|
||||
// JWT + machine binding and streams the body into dstPath. maxBytes caps the size to
|
||||
// guard against a misbehaving server filling the disk. Returns the number of bytes
|
||||
|
|
@ -358,7 +386,8 @@ type TokenRenewalRequest struct {
|
|||
|
||||
// TokenRenewalResponse is returned after successful token renewal
|
||||
type TokenRenewalResponse struct {
|
||||
Token string `json:"token"` // New short-lived access token (24h)
|
||||
Token string `json:"token"` // New short-lived access token (24h)
|
||||
RefreshToken string `json:"refresh_token"` // Rotated refresh token (migration 045) — must be persisted
|
||||
}
|
||||
|
||||
// RenewToken uses refresh token to get a new access token (proper implementation)
|
||||
|
|
@ -395,6 +424,7 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
|
|||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
c.addMachineIDHeader(httpReq) // Renewal is machine-bound: a refresh token only works from the registered host.
|
||||
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
|
|
@ -414,8 +444,20 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
|
|||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
errorMsg := fmt.Sprintf("token renewal failed: %s - %s", resp.Status, string(bodyBytes))
|
||||
|
||||
// Buffer token renewal failure event
|
||||
c.bufferEventInternal("token_renewal_failure", "api_error", "error", "client",
|
||||
// A 401/403 on the renew endpoint means the refresh token itself is no
|
||||
// longer valid (expired, revoked, or machine unbound). That's terminal —
|
||||
// no amount of retrying recovers it, the agent must be re-registered.
|
||||
// Emit a distinct, higher-severity event so it isn't lost among transient
|
||||
// renewal failures, and wrap the terminal sentinel so the loop can react.
|
||||
terminal := resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
|
||||
subtype := "api_error"
|
||||
severity := "error"
|
||||
if terminal {
|
||||
subtype = "refresh_token_invalid"
|
||||
severity = "critical"
|
||||
}
|
||||
|
||||
c.bufferEventInternal("token_renewal_failure", subtype, severity, "client",
|
||||
errorMsg,
|
||||
map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
|
|
@ -423,6 +465,10 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
|
|||
"agent_id": agentID.String(),
|
||||
"server_url": c.baseURL,
|
||||
})
|
||||
|
||||
if terminal {
|
||||
return fmt.Errorf("%w: %s", ErrRefreshTokenInvalid, string(bodyBytes))
|
||||
}
|
||||
return fmt.Errorf("%s", errorMsg)
|
||||
}
|
||||
|
||||
|
|
@ -438,8 +484,14 @@ func (c *Client) RenewToken(agentID uuid.UUID, refreshToken string, agentVersion
|
|||
return err
|
||||
}
|
||||
|
||||
// Update client token
|
||||
// Update client token, and the rotated refresh token if the server sent one.
|
||||
// The caller reads GetRefreshToken() and persists it to config; if persistence
|
||||
// fails or the agent crashes first, the server's accept-previous-once grace
|
||||
// lets the next attempt with the old token recover.
|
||||
c.token = result.Token
|
||||
if result.RefreshToken != "" {
|
||||
c.refreshToken = result.RefreshToken
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -539,6 +591,22 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*Comman
|
|||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, string(bodyBytes))
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
// Machine binding rejected us — this config is being used from a host
|
||||
// it wasn't registered on. Loud, not silent: buffer a critical event so
|
||||
// the operator sees a possible cloned identity, not just a backoff line.
|
||||
c.bufferEventInternal("machine_binding_rejected", "machine_id_mismatch", "critical", "client",
|
||||
fmt.Sprintf("Server rejected check-in with 403 (machine ID mismatch): %s", string(bodyBytes)),
|
||||
map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
"agent_id": agentID.String(),
|
||||
"server_url": c.baseURL,
|
||||
})
|
||||
return nil, fmt.Errorf("%w: %s", ErrMachineMismatch, string(bodyBytes))
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get commands: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -413,8 +413,9 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
}
|
||||
refreshTokenExpiry := time.Now().UTC().Add(90 * 24 * time.Hour)
|
||||
tokenHash := queries.HashRefreshToken(refreshToken)
|
||||
if _, err := tx.Exec("INSERT INTO refresh_tokens (agent_id, token_hash, expires_at) VALUES ($1, $2, $3)",
|
||||
agent.ID, tokenHash, refreshTokenExpiry); err != nil {
|
||||
refreshFamilyID := uuid.New() // root of this agent's rotation family (migration 045)
|
||||
if _, err := tx.Exec("INSERT INTO refresh_tokens (agent_id, token_hash, expires_at, family_id) VALUES ($1, $2, $3, $4)",
|
||||
agent.ID, tokenHash, refreshTokenExpiry, refreshFamilyID); err != nil {
|
||||
log.Printf("[ERROR] [server] [registration] create_refresh_token_failed error=%q", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store refresh token"})
|
||||
return
|
||||
|
|
@ -1276,17 +1277,87 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
|
|||
}
|
||||
defer renewTx.Rollback()
|
||||
|
||||
// Validate refresh token within transaction
|
||||
// Look up the presented token. We deliberately do NOT filter on `revoked` or
|
||||
// `consumed_at` here: reuse detection needs to see a spent token to react to it.
|
||||
tokenHash := queries.HashRefreshToken(req.RefreshToken)
|
||||
var refreshToken queries.RefreshToken
|
||||
validateQuery := `
|
||||
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked
|
||||
SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked,
|
||||
family_id, superseded_by, consumed_at
|
||||
FROM refresh_tokens
|
||||
WHERE agent_id = $1 AND token_hash = $2 AND NOT revoked
|
||||
WHERE agent_id = $1 AND token_hash = $2
|
||||
`
|
||||
if err := renewTx.Get(&refreshToken, validateQuery, req.AgentID, tokenHash); err != nil {
|
||||
log.Printf("[WARNING] [server] [auth] token_renewal_failed agent_id=%s error=%v", req.AgentID, err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Bind renewal to the machine the agent registered on, exactly as the command
|
||||
// endpoints do. The refresh token is a long-lived secret sitting on the agent's
|
||||
// disk; without this check it mints access tokens from *any* machine, so a
|
||||
// stolen config.json works anywhere for 90 days. A mismatch is a copied-identity
|
||||
// signal, not a transient error — we reject and surface it before sliding the
|
||||
// window or minting a token.
|
||||
reportedMachineID := c.GetHeader("X-Machine-ID")
|
||||
if reportedMachineID == "" {
|
||||
log.Printf("[WARNING] [server] [auth] renew_missing_machine_id agent_id=%s", req.AgentID)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "missing machine ID header"})
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
if agent.MachineID == nil {
|
||||
log.Printf("[WARNING] [server] [auth] renew_agent_unbound agent_id=%s", req.AgentID)
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "agent not bound to machine - re-registration required"})
|
||||
return
|
||||
}
|
||||
if *agent.MachineID != reportedMachineID {
|
||||
log.Printf("[WARNING] [server] [auth] renew_machine_id_mismatch agent=%s (%s) db=%s reported=%s",
|
||||
agent.Hostname, req.AgentID, *agent.MachineID, reportedMachineID)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogMachineIDMismatch(req.AgentID, *agent.MachineID, reportedMachineID)
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "machine ID mismatch - refresh token presented from a different machine",
|
||||
"security_note": "renewal is bound to the registered machine; this prevents stolen-token replay",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// --- Refresh-token rotation + reuse detection (migration 045) ---
|
||||
// familyID groups the rotation chain; fall back to the token's own id for
|
||||
// pre-rotation tokens that predate the 045 backfill.
|
||||
familyID := refreshToken.ID
|
||||
if refreshToken.FamilyID != nil {
|
||||
familyID = *refreshToken.FamilyID
|
||||
}
|
||||
|
||||
// revokeFamily kills every token in the chain, commits, and logs a security
|
||||
// event. Used on reuse/theft and on any revoked-token replay against a live
|
||||
// family. Fail-closed and loud — both the legitimate agent and a thief lose
|
||||
// access, forcing a deliberate human re-registration.
|
||||
revokeFamily := func(reason string) {
|
||||
if _, e := renewTx.Exec("UPDATE refresh_tokens SET revoked = true WHERE family_id = $1", familyID); e != nil {
|
||||
log.Printf("[ERROR] [server] [auth] family_revoke_failed family_id=%s error=%q", familyID, e)
|
||||
}
|
||||
if e := renewTx.Commit(); e != nil {
|
||||
log.Printf("[ERROR] [server] [auth] family_revoke_commit_failed family_id=%s error=%q", familyID, e)
|
||||
}
|
||||
log.Printf("[WARNING] [server] [auth] refresh_token_family_revoked agent_id=%s family_id=%s reason=%s", req.AgentID, familyID, reason)
|
||||
if h.securityLogger != nil {
|
||||
h.securityLogger.LogUnauthorizedAccessAttempt(c.ClientIP(), "/api/v1/agents/renew", "refresh_token_reuse: "+reason, req.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
// A revoked token presented while the family is alive is anomalous → reuse.
|
||||
if refreshToken.Revoked {
|
||||
revokeFamily("revoked_token_replayed")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token revoked"})
|
||||
return
|
||||
}
|
||||
if time.Now().UTC().After(refreshToken.ExpiresAt) {
|
||||
|
|
@ -1294,13 +1365,84 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Update expiration within same transaction
|
||||
newExpiry := time.Now().UTC().Add(90 * 24 * time.Hour)
|
||||
if _, err := renewTx.Exec("UPDATE refresh_tokens SET expires_at = $1, last_used_at = NOW() WHERE id = $2",
|
||||
newExpiry, refreshToken.ID); err != nil {
|
||||
log.Printf("[ERROR] [server] [auth] update_expiration_failed error=%q", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
|
||||
return
|
||||
// mintSuccessor inserts a fresh token into the family and returns its plaintext + id.
|
||||
mintSuccessor := func() (string, uuid.UUID, error) {
|
||||
plain, gErr := queries.GenerateRefreshToken()
|
||||
if gErr != nil {
|
||||
return "", uuid.Nil, gErr
|
||||
}
|
||||
exp := time.Now().UTC().Add(90 * 24 * time.Hour)
|
||||
var newID uuid.UUID
|
||||
if sErr := renewTx.QueryRowx(
|
||||
`INSERT INTO refresh_tokens (agent_id, token_hash, expires_at, family_id)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
req.AgentID, queries.HashRefreshToken(plain), exp, familyID,
|
||||
).Scan(&newID); sErr != nil {
|
||||
return "", uuid.Nil, sErr
|
||||
}
|
||||
return plain, newID, nil
|
||||
}
|
||||
|
||||
var newRefreshToken string
|
||||
if refreshToken.ConsumedAt == nil {
|
||||
// First use of this token — normal rotation: mint a successor and mark spent.
|
||||
plain, newID, mErr := mintSuccessor()
|
||||
if mErr != nil {
|
||||
log.Printf("[ERROR] [server] [auth] mint_successor_failed error=%q", mErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
|
||||
return
|
||||
}
|
||||
if _, uErr := renewTx.Exec(
|
||||
"UPDATE refresh_tokens SET consumed_at = NOW(), superseded_by = $1, last_used_at = NOW() WHERE id = $2",
|
||||
newID, refreshToken.ID); uErr != nil {
|
||||
log.Printf("[ERROR] [server] [auth] consume_token_failed error=%q", uErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
|
||||
return
|
||||
}
|
||||
newRefreshToken = plain
|
||||
} else {
|
||||
// Replay of an already-consumed token. Inspect its successor to tell a
|
||||
// benign crash-before-save retry from genuine reuse.
|
||||
if refreshToken.SupersededBy == nil {
|
||||
revokeFamily("consumed_without_successor")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token reuse detected"})
|
||||
return
|
||||
}
|
||||
var successor queries.RefreshToken
|
||||
succErr := renewTx.Get(&successor,
|
||||
`SELECT id, agent_id, token_hash, expires_at, created_at, last_used_at, revoked,
|
||||
family_id, superseded_by, consumed_at
|
||||
FROM refresh_tokens WHERE id = $1`, *refreshToken.SupersededBy)
|
||||
if succErr != nil || successor.Revoked || successor.ConsumedAt != nil {
|
||||
// Successor is missing, revoked, or already consumed → the chain advanced
|
||||
// past this token without us. Reuse of a superseded token: revoke family.
|
||||
revokeFamily("successor_consumed_or_missing")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token reuse detected"})
|
||||
return
|
||||
}
|
||||
// Successor exists and is still unconsumed → the agent rotated but crashed
|
||||
// before persisting the new token (it provably never used the successor).
|
||||
// Accept-previous-once: orphan that unsaved leaf, mint a fresh one, re-point.
|
||||
if _, rErr := renewTx.Exec("UPDATE refresh_tokens SET revoked = true WHERE id = $1", successor.ID); rErr != nil {
|
||||
log.Printf("[ERROR] [server] [auth] grace_orphan_failed error=%q", rErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
|
||||
return
|
||||
}
|
||||
plain, newID, mErr := mintSuccessor()
|
||||
if mErr != nil {
|
||||
log.Printf("[ERROR] [server] [auth] grace_mint_failed error=%q", mErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
|
||||
return
|
||||
}
|
||||
if _, uErr := renewTx.Exec(
|
||||
"UPDATE refresh_tokens SET superseded_by = $1, last_used_at = NOW() WHERE id = $2",
|
||||
newID, refreshToken.ID); uErr != nil {
|
||||
log.Printf("[ERROR] [server] [auth] grace_repoint_failed error=%q", uErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token renewal failed"})
|
||||
return
|
||||
}
|
||||
log.Printf("[WARNING] [server] [auth] refresh_token_grace_reissue agent_id=%s family_id=%s reason=crash_before_save_recovery", req.AgentID, familyID)
|
||||
newRefreshToken = plain
|
||||
}
|
||||
|
||||
if err := renewTx.Commit(); err != nil {
|
||||
|
|
@ -1309,13 +1451,6 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Check if agent still exists (outside transaction)
|
||||
agent, err := h.agentQueries.GetAgentByID(req.AgentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update agent last_seen timestamp
|
||||
if err := h.agentQueries.UpdateAgentLastSeen(req.AgentID); err != nil {
|
||||
log.Printf("[WARNING] [server] [auth] update_last_seen_failed agent_id=%s error=%v", req.AgentID, err)
|
||||
|
|
@ -1337,9 +1472,12 @@ func (h *AgentHandler) RenewToken(c *gin.Context) {
|
|||
|
||||
log.Printf("[INFO] [server] [agents]Token renewed successfully for agent %s (%s)", agent.Hostname, req.AgentID)
|
||||
|
||||
// Return new access token
|
||||
// Return new access token + the rotated refresh token. The agent must persist
|
||||
// the refresh token; if it crashes before doing so, the accept-previous-once
|
||||
// grace above lets it recover on the next attempt.
|
||||
response := models.TokenRenewalResponse{
|
||||
Token: token,
|
||||
Token: token,
|
||||
RefreshToken: newRefreshToken,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS idx_refresh_tokens_family;
|
||||
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS consumed_at;
|
||||
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS superseded_by;
|
||||
ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS family_id;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- Refresh-token rotation + reuse detection.
|
||||
--
|
||||
-- Each refresh token belongs to a family. A renewal mints a successor token in
|
||||
-- the same family and marks the parent "consumed" (consumed_at + superseded_by).
|
||||
-- Replaying a consumed token whose successor is ALSO consumed means the chain
|
||||
-- advanced past it without this caller — i.e. a stolen/duplicated token is being
|
||||
-- replayed. That is reuse: the whole family is revoked (fail-closed), forcing
|
||||
-- re-registration. A consumed token whose successor is still UNCONSUMED is the
|
||||
-- benign crash-before-save case (accept-previous-once grace).
|
||||
--
|
||||
-- See RAF/security/01-trust-boundaries.md for the full state machine.
|
||||
|
||||
ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS family_id UUID;
|
||||
ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS superseded_by UUID;
|
||||
ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ;
|
||||
|
||||
-- Backfill: every pre-rotation token is the root of its own family.
|
||||
UPDATE refresh_tokens SET family_id = id WHERE family_id IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family ON refresh_tokens(family_id);
|
||||
|
|
@ -28,6 +28,13 @@ type RefreshToken struct {
|
|||
CreatedAt time.Time `db:"created_at"`
|
||||
LastUsedAt *time.Time `db:"last_used_at"`
|
||||
Revoked bool `db:"revoked"`
|
||||
// Rotation lineage (migration 045). FamilyID groups a rotation chain;
|
||||
// SupersededBy points at the successor minted when this token was used;
|
||||
// ConsumedAt is set the moment this token mints a successor. A consumed token
|
||||
// replayed after its successor is also consumed = reuse → family revoked.
|
||||
FamilyID *uuid.UUID `db:"family_id"`
|
||||
SupersededBy *uuid.UUID `db:"superseded_by"`
|
||||
ConsumedAt *time.Time `db:"consumed_at"`
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a cryptographically secure random token
|
||||
|
|
|
|||
|
|
@ -108,7 +108,8 @@ type TokenRenewalRequest struct {
|
|||
|
||||
// TokenRenewalResponse is returned after successful token renewal
|
||||
type TokenRenewalResponse struct {
|
||||
Token string `json:"token"` // New short-lived access token (24h)
|
||||
Token string `json:"token"` // New short-lived access token (24h)
|
||||
RefreshToken string `json:"refresh_token"` // Rotated refresh token (migration 045) — agent must persist it
|
||||
}
|
||||
|
||||
// UTCTime is a time.Time that marshals to ISO format with UTC timezone
|
||||
|
|
|
|||
Loading…
Reference in a new issue