Fix SEC-03: require token proximity for renewal
- Add 50% lifetime elapsed check to REST handleRenew and gRPC RenewToken - Reject renewal attempts before 50% of token lifetime has elapsed - Update existing renewal tests to use short-lived tokens with sleep - Add TestRenewTokenTooEarly tests for both REST and gRPC Security: Tokens can only be renewed after 50% of their lifetime has elapsed, preventing indefinite renewal of stolen tokens. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/peer"
|
"google.golang.org/grpc/peer"
|
||||||
@@ -153,6 +154,14 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest
|
|||||||
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
|
func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) {
|
||||||
claims := claimsFromContext(ctx)
|
claims := claimsFromContext(ctx)
|
||||||
|
|
||||||
|
// Security: only allow renewal when the token has consumed at least 50% of
|
||||||
|
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
|
||||||
|
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
|
||||||
|
elapsed := time.Since(claims.IssuedAt)
|
||||||
|
if elapsed < totalLifetime/2 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "token is not yet eligible for renewal")
|
||||||
|
}
|
||||||
|
|
||||||
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
acct, err := a.s.db.GetAccountByUUID(claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Unauthenticated, "account not found")
|
return nil, status.Error(codes.Unauthenticated, "account not found")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -143,7 +144,12 @@ func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model
|
|||||||
// issueUserToken issues a regular (non-admin) token for an account.
|
// issueUserToken issues a regular (non-admin) token for an account.
|
||||||
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
|
func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, time.Hour)
|
return e.issueShortToken(t, acct, time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *testEnv) issueShortToken(t *testing.T, acct *model.Account, expiry time.Duration) string {
|
||||||
|
t.Helper()
|
||||||
|
tokenStr, claims, err := token.IssueToken(e.priv, testIssuer, acct.UUID, []string{}, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("issue token: %v", err)
|
t.Fatalf("issue token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -357,11 +363,17 @@ func TestLogout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenewToken verifies that a valid token can be renewed.
|
// TestRenewToken verifies that a valid token can be renewed after 50% of its
|
||||||
|
// lifetime has elapsed (SEC-03).
|
||||||
func TestRenewToken(t *testing.T) {
|
func TestRenewToken(t *testing.T) {
|
||||||
e := newTestEnv(t)
|
e := newTestEnv(t)
|
||||||
acct := e.createHumanAccount(t, "renewuser")
|
acct := e.createHumanAccount(t, "renewuser")
|
||||||
tok := e.issueUserToken(t, acct)
|
|
||||||
|
// Issue a short-lived token (2s) so we can wait past the 50% threshold.
|
||||||
|
tok := e.issueShortToken(t, acct, 2*time.Second)
|
||||||
|
|
||||||
|
// Wait for >50% of lifetime to elapse.
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
|
||||||
cl := mciasv1.NewAuthServiceClient(e.conn)
|
cl := mciasv1.NewAuthServiceClient(e.conn)
|
||||||
ctx := authCtx(tok)
|
ctx := authCtx(tok)
|
||||||
@@ -377,6 +389,28 @@ func TestRenewToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
|
||||||
|
// of its lifetime has elapsed (SEC-03).
|
||||||
|
func TestRenewTokenTooEarly(t *testing.T) {
|
||||||
|
e := newTestEnv(t)
|
||||||
|
acct := e.createHumanAccount(t, "renewearlyuser")
|
||||||
|
tok := e.issueUserToken(t, acct)
|
||||||
|
|
||||||
|
cl := mciasv1.NewAuthServiceClient(e.conn)
|
||||||
|
ctx := authCtx(tok)
|
||||||
|
_, err := cl.RenewToken(ctx, &mciasv1.RenewTokenRequest{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("RenewToken: expected error for early renewal, got nil")
|
||||||
|
}
|
||||||
|
st, ok := status.FromError(err)
|
||||||
|
if !ok || st.Code() != codes.InvalidArgument {
|
||||||
|
t.Fatalf("RenewToken: expected InvalidArgument, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(st.Message(), "not yet eligible for renewal") {
|
||||||
|
t.Errorf("RenewToken: expected eligibility message, got: %s", st.Message())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- TokenService tests ----
|
// ---- TokenService tests ----
|
||||||
|
|
||||||
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
|
// TestValidateToken verifies the public ValidateToken RPC returns valid=true for
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
"git.wntrmute.dev/kyle/mcias/internal/auth"
|
||||||
"git.wntrmute.dev/kyle/mcias/internal/config"
|
"git.wntrmute.dev/kyle/mcias/internal/config"
|
||||||
@@ -337,6 +338,15 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) {
|
||||||
claims := middleware.ClaimsFromContext(r.Context())
|
claims := middleware.ClaimsFromContext(r.Context())
|
||||||
|
|
||||||
|
// Security: only allow renewal when the token has consumed at least 50% of
|
||||||
|
// its lifetime. This prevents indefinite renewal of stolen tokens (SEC-03).
|
||||||
|
totalLifetime := claims.ExpiresAt.Sub(claims.IssuedAt)
|
||||||
|
elapsed := time.Since(claims.IssuedAt)
|
||||||
|
if elapsed < totalLifetime/2 {
|
||||||
|
middleware.WriteError(w, http.StatusBadRequest, "token is not yet eligible for renewal", "renewal_too_early")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Load account to get current roles (they may have changed since token issuance).
|
// Load account to get current roles (they may have changed since token issuance).
|
||||||
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
acct, err := s.db.GetAccountByUUID(claims.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -563,7 +563,8 @@ func TestRenewToken(t *testing.T) {
|
|||||||
acct := createTestHumanAccount(t, srv, "renew-user")
|
acct := createTestHumanAccount(t, srv, "renew-user")
|
||||||
handler := srv.Handler()
|
handler := srv.Handler()
|
||||||
|
|
||||||
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
// Issue a short-lived token (2s) so we can wait past the 50% threshold.
|
||||||
|
oldTokenStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, 2*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("IssueToken: %v", err)
|
t.Fatalf("IssueToken: %v", err)
|
||||||
}
|
}
|
||||||
@@ -572,6 +573,9 @@ func TestRenewToken(t *testing.T) {
|
|||||||
t.Fatalf("TrackToken: %v", err)
|
t.Fatalf("TrackToken: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for >50% of the 2s lifetime to elapse.
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
|
||||||
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
|
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, oldTokenStr)
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String())
|
||||||
@@ -594,3 +598,29 @@ func TestRenewToken(t *testing.T) {
|
|||||||
t.Error("old token should be revoked after renewal")
|
t.Error("old token should be revoked after renewal")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRenewTokenTooEarly verifies that a token cannot be renewed before 50%
|
||||||
|
// of its lifetime has elapsed (SEC-03).
|
||||||
|
func TestRenewTokenTooEarly(t *testing.T) {
|
||||||
|
srv, _, priv, _ := newTestServer(t)
|
||||||
|
acct := createTestHumanAccount(t, srv, "renew-early-user")
|
||||||
|
handler := srv.Handler()
|
||||||
|
|
||||||
|
// Issue a long-lived token so 50% is far in the future.
|
||||||
|
tokStr, claims, err := token.IssueToken(priv, testIssuer, acct.UUID, nil, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueToken: %v", err)
|
||||||
|
}
|
||||||
|
if err := srv.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately try to renew — should be rejected.
|
||||||
|
rr := doRequest(t, handler, "POST", "/v1/auth/renew", nil, tokStr)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("renew status = %d, want 400; body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "not yet eligible for renewal") {
|
||||||
|
t.Errorf("expected eligibility message, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -223,19 +223,20 @@ func TestE2ELoginLogoutFlow(t *testing.T) {
|
|||||||
// TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one.
|
// TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one.
|
||||||
func TestE2ETokenRenewal(t *testing.T) {
|
func TestE2ETokenRenewal(t *testing.T) {
|
||||||
e := newTestEnv(t)
|
e := newTestEnv(t)
|
||||||
e.createAccount(t, "bob")
|
acct := e.createAccount(t, "bob")
|
||||||
|
|
||||||
// Login.
|
// Issue a short-lived token (2s) directly so we can wait past the 50%
|
||||||
resp := e.do(t, "POST", "/v1/auth/login", map[string]string{
|
// renewal threshold (SEC-03) without blocking the test for minutes.
|
||||||
"username": "bob",
|
oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 2*time.Second)
|
||||||
"password": "testpass123",
|
if err != nil {
|
||||||
}, "")
|
t.Fatalf("IssueToken: %v", err)
|
||||||
mustStatus(t, resp, http.StatusOK)
|
|
||||||
var lr struct {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
}
|
||||||
decodeJSON(t, resp, &lr)
|
if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
|
||||||
oldToken := lr.Token
|
t.Fatalf("TrackToken: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for >50% of the 2s lifetime to elapse.
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
|
||||||
// Renew.
|
// Renew.
|
||||||
resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)
|
resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)
|
||||||
|
|||||||
Reference in New Issue
Block a user