Merge SEC-03: require token proximity for renewal
# Conflicts: # internal/server/server_test.go
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user