2 Commits

Author SHA1 Message Date
8041a8c04f feat(user): magic-link expired-token cleanup loop (ADR-0028 Phase A consequence)
Periodically delete magic-link tokens past their expires_at — the rows
accumulate without cleanup since the consume endpoint only marks them
consumed but never removes them, and DeleteExpiredMagicLinkTokens (added
in Phase A.3, PR #61) was never wired up.

Mirrors the JWT secret cleanup pattern (ADR-0021).

- pkg/user/magic_link_cleanup.go: MagicLinkCleanupRunner with
  StartCleanupLoop(ctx, interval) and an inner runOnce(ctx) for testability
- pkg/user/magic_link_cleanup_test.go: unit tests cover happy path,
  error propagation, ctx-cancel termination, zero-interval no-op
- pkg/server/server.go: start the loop right after the JWT cleanup loop
- pkg/config/config.go: auth.magic_link.cleanup_interval (default 1h),
  env DLC_AUTH_MAGIC_LINK_CLEANUP_INTERVAL, getter GetMagicLinkCleanupInterval

Mostly authored by Mistral Vibe in batch1-task-b worktree (parallel with
batch1-task-a OIDC config). Mistral hit --max-price 1.50 right before the
final commit step, Claude finished the ship via trainer takeover (Q-045
pattern, 2nd recurrence in two batches — to be addressed in Phase 1bis).
2026-05-05 13:06:43 +02:00
6ed95165d3 feat(config): OIDC provider config skeleton (ADR-0028 Phase B.1 prep) (#64)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 9s
CI/CD Pipeline / Trigger Docker Push (push) Has been cancelled
CI/CD Pipeline / CI Pipeline (push) Has been cancelled
Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
2026-05-05 13:04:14 +02:00
5 changed files with 173 additions and 26 deletions

View File

@@ -1,24 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
-`GET /api/v1/uptime` endpoint (PR #67) — returns server start_time and uptime_seconds
- 📝 mkcert local HTTPS setup + Makefile cert target (PR #68) — prep for ADR-0028 Phase B OIDC callbacks
-`pkg/auth/` skeleton for OpenID Connect (PR #69) — types + client surface, handlers come later
## [0.1.0] - 2026-05-05
### Added
- Magic-link passwordless authentication (ADR-0028 Phases A.1 through A.5, PRs #59-#63)
- OIDC provider config skeleton (ADR-0028 Phase B.1 prep, PR #64)
- Magic-link expired-token cleanup loop (PR #65)
- Mailpit local SMTP infrastructure (ADR-0029)
- BDD parallel email assertion strategy (ADR-0030)

View File

@@ -109,12 +109,27 @@ type AuthConfig struct {
JWT JWTConfig `mapstructure:"jwt"`
Email EmailConfig `mapstructure:"email"`
MagicLink MagicLinkConfig `mapstructure:"magic_link"`
OIDC OIDCConfig `mapstructure:"oidc"`
}
// 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).
// Multiple providers are supported via a map keyed by provider name (e.g. "arcodange-sso", "google").
type OIDCConfig struct {
Providers map[string]OIDCProvider `mapstructure:"providers"`
}
// OIDCProvider describes a single OIDC provider's discovery + client config.
type OIDCProvider struct {
IssuerURL string `mapstructure:"issuer_url"`
ClientID string `mapstructure:"client_id"`
ClientSecret string `mapstructure:"client_secret"`
}
// EmailConfig holds outgoing email transport configuration.
@@ -286,6 +301,11 @@ 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.
v.SetDefault("auth.oidc.providers", map[string]interface{}{})
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
@@ -343,6 +363,14 @@ 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.
v.BindEnv("auth.oidc.providers.default.issuer_url", "DLC_AUTH_OIDC_ISSUER_URL")
v.BindEnv("auth.oidc.providers.default.client_id", "DLC_AUTH_OIDC_CLIENT_ID")
v.BindEnv("auth.oidc.providers.default.client_secret", "DLC_AUTH_OIDC_CLIENT_SECRET")
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
@@ -494,6 +522,23 @@ func (c *Config) GetMagicLinkConfig() MagicLinkConfig {
return out
}
// GetOIDCProviders returns the configured OIDC providers, keyed by provider name.
// Empty map (not nil) is returned when no providers are configured.
func (c *Config) GetOIDCProviders() map[string]OIDCProvider {
if c.Auth.OIDC.Providers == nil {
return 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 {

View File

@@ -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).

View File

@@ -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
}

View File

@@ -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".
}