From b6a6a2b3d7e6079c5d1103bef42a5aa1df6936f0 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 5 May 2026 13:07:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(user):=20magic-link=20expired-?= =?UTF-8?q?token=20cleanup=20loop=20(ADR-0028=20Phase=20A=20consequence)?= =?UTF-8?q?=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gabriel Radureau Co-committed-by: Gabriel Radureau --- pkg/config/config.go | 15 ++++++- pkg/server/server.go | 6 +++ pkg/user/magic_link_cleanup.go | 56 +++++++++++++++++++++++++ pkg/user/magic_link_cleanup_test.go | 64 +++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 pkg/user/magic_link_cleanup.go create mode 100644 pkg/user/magic_link_cleanup_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 185b5b2..847f500 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -114,8 +114,9 @@ type AuthConfig struct { // MagicLinkConfig holds passwordless-auth magic-link parameters (ADR-0028 Phase A). type MagicLinkConfig struct { - TTL time.Duration `mapstructure:"ttl"` - BaseURL string `mapstructure:"base_url"` + TTL time.Duration `mapstructure:"ttl"` + BaseURL string `mapstructure:"base_url"` + CleanupInterval time.Duration `mapstructure:"cleanup_interval"` } // OIDCConfig holds OpenID Connect provider configuration (ADR-0028 Phase B). @@ -300,6 +301,7 @@ func LoadConfig() (*Config, error) { // Magic-link defaults (ADR-0028 Phase A). v.SetDefault("auth.magic_link.ttl", 15*time.Minute) v.SetDefault("auth.magic_link.base_url", "http://localhost:8080") + v.SetDefault("auth.magic_link.cleanup_interval", 1*time.Hour) // OIDC defaults (ADR-0028 Phase B). Providers map is empty by default; // configured per environment via config file or env vars. @@ -361,6 +363,7 @@ func LoadConfig() (*Config, error) { // Magic-link environment variables (ADR-0028 Phase A). v.BindEnv("auth.magic_link.ttl", "DLC_AUTH_MAGIC_LINK_TTL") v.BindEnv("auth.magic_link.base_url", "DLC_AUTH_MAGIC_LINK_BASE_URL") + v.BindEnv("auth.magic_link.cleanup_interval", "DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL") // OIDC environment variables (ADR-0028 Phase B). One canonical "default" // provider is bindable via env; additional providers must be defined in config.yaml. @@ -528,6 +531,14 @@ func (c *Config) GetOIDCProviders() map[string]OIDCProvider { return c.Auth.OIDC.Providers } +// GetMagicLinkCleanupInterval returns the magic-link cleanup interval (ADR-0028 Phase A consequence). +func (c *Config) GetMagicLinkCleanupInterval() time.Duration { + if c.Auth.MagicLink.CleanupInterval <= 0 { + return 1 * time.Hour + } + return c.Auth.MagicLink.CleanupInterval +} + // GetJWTTTL returns the JWT TTL func (c *Config) GetJWTTTL() time.Duration { if c.Auth.JWT.TTL == 0 { diff --git a/pkg/server/server.go b/pkg/server/server.go index b54eb6b..49666ee 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -764,6 +764,12 @@ func (s *Server) Run() error { s.userService.StartJWTSecretCleanupLoop(rootCtx, s.config.GetJWTSecretCleanupInterval()) } + // Start the magic-link expired-token cleanup loop (ADR-0028 Phase A consequence). + if mlRepo, ok := s.userRepo.(user.MagicLinkRepository); ok { + runner := user.NewMagicLinkCleanupRunner(mlRepo) + runner.StartCleanupLoop(rootCtx, s.config.GetMagicLinkCleanupInterval()) + } + // Wire the sampler hot-reload callback (ADR-0023 Phase 3, sub-phase 3.3). // telemetrySetup is non-nil only when telemetry was successfully initialized // at startup — hot-reloading telemetry-on is out of scope (see ADR-0023). diff --git a/pkg/user/magic_link_cleanup.go b/pkg/user/magic_link_cleanup.go new file mode 100644 index 0000000..292a5ab --- /dev/null +++ b/pkg/user/magic_link_cleanup.go @@ -0,0 +1,56 @@ +package user + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" +) + +// MagicLinkCleanupRunner periodically deletes expired magic-link tokens +// (ADR-0028 Phase A consequence — the rows accumulate without cleanup +// otherwise, and stale rows are pure overhead since the token plaintext +// is never stored). +type MagicLinkCleanupRunner struct { + repo MagicLinkRepository +} + +// NewMagicLinkCleanupRunner creates a new cleanup runner. +func NewMagicLinkCleanupRunner(repo MagicLinkRepository) *MagicLinkCleanupRunner { + return &MagicLinkCleanupRunner{repo: repo} +} + +// StartCleanupLoop runs the cleanup pass every `interval`. Stops when ctx +// is cancelled. interval <= 0 disables the loop. +func (r *MagicLinkCleanupRunner) StartCleanupLoop(ctx context.Context, interval time.Duration) { + if interval <= 0 { + return + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = r.runOnce(ctx) + } + } + }() +} + +// runOnce performs a single cleanup pass. Returns the count of deleted rows. +// Exposed for testing — tests drive runOnce directly instead of waiting on +// the ticker. +func (r *MagicLinkCleanupRunner) runOnce(ctx context.Context) (int64, error) { + n, err := r.repo.DeleteExpiredMagicLinkTokens(ctx, time.Now()) + if err != nil { + log.Error().Ctx(ctx).Err(err).Msg("magic-link cleanup: delete failed") + return 0, err + } + if n > 0 { + log.Trace().Ctx(ctx).Int64("deleted", n).Msg("magic-link cleanup: removed expired tokens") + } + return n, nil +} diff --git a/pkg/user/magic_link_cleanup_test.go b/pkg/user/magic_link_cleanup_test.go new file mode 100644 index 0000000..49900c8 --- /dev/null +++ b/pkg/user/magic_link_cleanup_test.go @@ -0,0 +1,64 @@ +package user + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeMLRepo struct { + deleteN int64 + deleteErr error + cutoffSeen time.Time +} + +func (r *fakeMLRepo) CreateMagicLinkToken(_ context.Context, _ *MagicLinkToken) error { return nil } +func (r *fakeMLRepo) GetMagicLinkTokenByHash(_ context.Context, _ string) (*MagicLinkToken, error) { + return nil, nil +} +func (r *fakeMLRepo) MarkMagicLinkTokenConsumed(_ context.Context, _ uint, _ time.Time) error { + return nil +} +func (r *fakeMLRepo) DeleteExpiredMagicLinkTokens(_ context.Context, before time.Time) (int64, error) { + r.cutoffSeen = before + return r.deleteN, r.deleteErr +} + +func TestRunOnce_ReturnsCount(t *testing.T) { + repo := &fakeMLRepo{deleteN: 7} + r := NewMagicLinkCleanupRunner(repo) + n, err := r.runOnce(context.Background()) + require.NoError(t, err) + assert.EqualValues(t, 7, n) + assert.WithinDuration(t, time.Now(), repo.cutoffSeen, time.Second) +} + +func TestRunOnce_PropagatesError(t *testing.T) { + repo := &fakeMLRepo{deleteErr: errors.New("simulated")} + r := NewMagicLinkCleanupRunner(repo) + _, err := r.runOnce(context.Background()) + require.Error(t, err) +} + +func TestStartCleanupLoop_StopsOnContextCancel(t *testing.T) { + repo := &fakeMLRepo{} + r := NewMagicLinkCleanupRunner(repo) + ctx, cancel := context.WithCancel(context.Background()) + r.StartCleanupLoop(ctx, 10*time.Millisecond) + time.Sleep(25 * time.Millisecond) // 2 ticks + cancel() + time.Sleep(15 * time.Millisecond) // give the goroutine time to exit + // Implicit assertion: no goroutine leak (test would hang in -race mode otherwise). +} + +func TestStartCleanupLoop_NoOpWhenIntervalZero(t *testing.T) { + repo := &fakeMLRepo{} + r := NewMagicLinkCleanupRunner(repo) + r.StartCleanupLoop(context.Background(), 0) + // Just make sure no goroutine is started ; nothing observable to assert + // beyond "no panic, returns immediately". +}