Stage 7 · Master
Phase 4 — Authentication Service
Session Management
Give users visibility into every device holding a live refresh-token family, and build the Redis-backed jti denylist that makes an access token revocable in seconds instead of waiting out its 15-minute expiry.
A Session Is a Refresh Token Family, Not a Server-Side Object
This platform never adopted server-side sessions in the traditional sense — there is no sessions table holding arbitrary server state per login. What a user experiences as 'my session on my laptop' maps directly onto the refresh_tokens.family_id introduced in the refresh-tokens lesson: one login produces one family, and every rotation within that family is still the same session from the user's point of view.
GET /api/v1/auth/sessions returns one row per distinct family_id with a non-revoked, non-expired token — the most recent created_at within each family approximates 'last active.' No new storage was needed; the refresh-tokens schema already carries everything this feature needs.
package httpapi
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/hoa-platform/backend/pkg/httpmw"
"github.com/hoa-platform/backend/pkg/respond"
)
type SessionSummary struct {
FamilyID string `json:"family_id"`
LastUsed string `json:"last_used_at"`
ExpiresAt string `json:"expires_at"`
IsCurrent bool `json:"is_current"`
}
func (h *AuthHandler) ListSessions(c *gin.Context) {
claims, _ := httpmw.ClaimsFromContext(c.Request.Context())
currentFamily := c.GetHeader("X-Refresh-Family") // set by client from its own stored refresh context, purely for UI highlighting
rows, err := h.sessions.ListActiveFamilies(c.Request.Context(), claims.Subject)
if err != nil {
respond.Error(c, http.StatusInternalServerError, "session_lookup_failed", "could not list sessions")
return
}
out := make([]SessionSummary, 0, len(rows))
for _, row := range rows {
out = append(out, SessionSummary{
FamilyID: row.FamilyID, LastUsed: row.LastUsed, ExpiresAt: row.ExpiresAt,
IsCurrent: row.FamilyID == currentFamily,
})
}
respond.JSON(c, http.StatusOK, out)
}
// RevokeSession implements "log out this device" — it revokes exactly one
// family, leaving every other active session untouched.
func (h *AuthHandler) RevokeSession(c *gin.Context) {
claims, _ := httpmw.ClaimsFromContext(c.Request.Context())
familyID := c.Param("familyID")
owns, err := h.sessions.FamilyBelongsToUser(c.Request.Context(), familyID, claims.Subject)
if err != nil || !owns {
respond.Error(c, http.StatusForbidden, "not_your_session", "cannot revoke a session you do not own")
return
}
if err := h.sessions.RevokeFamily(c.Request.Context(), familyID); err != nil {
respond.Error(c, http.StatusInternalServerError, "revoke_failed", "could not revoke session")
return
}
c.Status(http.StatusNoContent)
}FamilyBelongsToUser exists as an explicit ownership check before RevokeFamily runs — without it, any authenticated user could revoke an arbitrary family_id they discovered or guessed, logging another user out entirely. This is the same 'verify ownership before mutating' discipline the User module's CRUD lesson applied to every resident-owned resource.
Why Revoking a Refresh Family Isn't Instant Logout
Revoking a refresh family stops future token renewal, but any access token already issued under that family remains valid — and independently verifiable by every service — until its own 15-minute expiry. For 'log out everywhere' as a user-facing feature, that gap is acceptable. For 'this account was just compromised, kill it now,' it is not, and that is precisely the gap a Redis denylist closes.
package token
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
type Denylist struct {
client *redis.Client
}
func NewDenylist(client *redis.Client) *Denylist {
return &Denylist{client: client}
}
// Revoke marks jti as denied until ttl elapses. ttl should be set to the
// remaining lifetime of the token being revoked — there is no reason to
// remember a denylist entry longer than the token itself could ever be valid.
func (d *Denylist) Revoke(ctx context.Context, jti string, ttl time.Duration) error {
return d.client.Set(ctx, "denylist:"+jti, "1", ttl).Err()
}
func (d *Denylist) IsRevoked(ctx context.Context, jti string) (bool, error) {
n, err := d.client.Exists(ctx, "denylist:"+jti).Result()
if err != nil {
return false, err
}
return n > 0, nil
}Redis's own key expiry (the ttl argument to SET) means a revoked entry disappears on its own the moment the underlying token would have expired anyway — there is no cleanup job, and the denylist can never grow unbounded from accumulated old entries.
Checking Redis on every request re-adds a dependency the whole point of stateless JWTs was to avoid — but only for the denylist path, which is empty for the overwhelming majority of tokens. RequireAuth should check the denylist after signature verification succeeds, so an already-invalid token never even reaches a Redis round trip, and a Redis outage should fail open only if the platform's threat model tolerates it (this platform chooses to fail closed for is_platform_admin tokens and open for ordinary ones, an explicit tradeoff documented in the runbook).
Testing Immediate Revocation End to End
Applied exercise
Decide the fail-open vs fail-closed policy for a Redis outage
Redis, which backs the jti denylist, becomes unreachable for several minutes during a deploy.
- State what should happen to ordinary user requests during the outage: should IsRevoked errors cause verification to fail (fail closed) or be treated as not-revoked (fail open)?
- State whether your answer changes for is_platform_admin tokens specifically, and why.
- Propose one monitoring signal (tie to the monitoring lesson's metric families) that would alert on-call staff to this exact failure mode.
Deliverable
A short written fail-open/fail-closed policy with an explicit platform-admin exception if applicable.
Completion checks
- The policy is stated explicitly for both ordinary and platform-admin tokens, not left ambiguous.
- The proposed metric would actually distinguish 'Redis is down' from 'a token is legitimately revoked'.
Why is a Redis-backed jti denylist needed even though access tokens are self-verifying JWTs?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.