diff --git a/internal/grpcserver/auth.go b/internal/grpcserver/auth.go index c542f82..775c770 100644 --- a/internal/grpcserver/auth.go +++ b/internal/grpcserver/auth.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "net" + "time" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" @@ -156,6 +157,14 @@ func (a *authServiceServer) Logout(ctx context.Context, _ *mciasv1.LogoutRequest func (a *authServiceServer) RenewToken(ctx context.Context, _ *mciasv1.RenewTokenRequest) (*mciasv1.RenewTokenResponse, error) { 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) if err != nil { return nil, status.Error(codes.Unauthenticated, "account not found") diff --git a/internal/grpcserver/grpcserver_test.go b/internal/grpcserver/grpcserver_test.go index 9a173ea..13f8ae1 100644 --- a/internal/grpcserver/grpcserver_test.go +++ b/internal/grpcserver/grpcserver_test.go @@ -12,6 +12,7 @@ import ( "io" "log/slog" "net" + "strings" "testing" "time" @@ -144,7 +145,12 @@ func (e *testEnv) issueAdminToken(t *testing.T, username string) (string, *model // issueUserToken issues a regular (non-admin) token for an account. func (e *testEnv) issueUserToken(t *testing.T, acct *model.Account) string { 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 { t.Fatalf("issue token: %v", err) } @@ -358,11 +364,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) { e := newTestEnv(t) 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) ctx := authCtx(tok) @@ -378,6 +390,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 ---- // TestValidateToken verifies the public ValidateToken RPC returns valid=true for diff --git a/internal/server/server.go b/internal/server/server.go index 361c830..468f38c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,6 +18,7 @@ import ( "log/slog" "net" "net/http" + "time" "git.wntrmute.dev/kyle/mcias/internal/audit" "git.wntrmute.dev/kyle/mcias/internal/auth" @@ -350,6 +351,15 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRenew(w http.ResponseWriter, r *http.Request) { 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). acct, err := s.db.GetAccountByUUID(claims.Subject) if err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e34fe59..a8e2be9 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -563,7 +563,8 @@ func TestRenewToken(t *testing.T) { acct := createTestHumanAccount(t, srv, "renew-user") 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 { t.Fatalf("IssueToken: %v", err) } @@ -572,6 +573,9 @@ func TestRenewToken(t *testing.T) { 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) if rr.Code != http.StatusOK { t.Fatalf("renew status = %d, want 200; body: %s", rr.Code, rr.Body.String()) @@ -729,3 +733,29 @@ func TestLoginLockedAccountReturns401(t *testing.T) { t.Errorf("locked response error = %q, want %q", lockedBody.Error, "invalid credentials") } } + +// 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()) + } +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 3bd2f87..a0679e6 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -223,19 +223,20 @@ func TestE2ELoginLogoutFlow(t *testing.T) { // TestE2ETokenRenewal verifies that renewal returns a new token and revokes the old one. func TestE2ETokenRenewal(t *testing.T) { e := newTestEnv(t) - e.createAccount(t, "bob") + acct := e.createAccount(t, "bob") - // Login. - resp := e.do(t, "POST", "/v1/auth/login", map[string]string{ - "username": "bob", - "password": "testpass123", - }, "") - mustStatus(t, resp, http.StatusOK) - var lr struct { - Token string `json:"token"` + // Issue a short-lived token (2s) directly so we can wait past the 50% + // renewal threshold (SEC-03) without blocking the test for minutes. + oldToken, claims, err := token.IssueToken(e.privKey, e2eIssuer, acct.UUID, nil, 2*time.Second) + if err != nil { + t.Fatalf("IssueToken: %v", err) } - decodeJSON(t, resp, &lr) - oldToken := lr.Token + if err := e.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { + t.Fatalf("TrackToken: %v", err) + } + + // Wait for >50% of the 2s lifetime to elapse. + time.Sleep(1100 * time.Millisecond) // Renew. resp2 := e.do(t, "POST", "/v1/auth/renew", nil, oldToken)