Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions example/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
Expand Down
7 changes: 7 additions & 0 deletions example/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,12 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
Expand Down Expand Up @@ -499,6 +505,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ require (
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/tliron/commonlog v0.2.8 // indirect
github.com/tliron/kutil v0.3.11 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ=
github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
Expand Down Expand Up @@ -606,6 +612,7 @@ golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
Expand Down
163 changes: 163 additions & 0 deletions module/auth_token_blacklist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package module

import (
"context"
"fmt"
"sync"
"time"

"github.com/CrisisTextLine/modular"
"github.com/redis/go-redis/v9"
)

// TokenBlacklist is the interface for checking and adding revoked JWT IDs.
type TokenBlacklist interface {
Add(jti string, expiresAt time.Time)
IsBlacklisted(jti string) bool
}

// TokenBlacklistModule maintains a set of revoked JWT IDs (JTIs).
// It supports two backends: "memory" (default) and "redis".
type TokenBlacklistModule struct {
name string
backend string
redisURL string
cleanupInterval time.Duration

// memory backend
entries sync.Map // jti (string) -> expiry (time.Time)

// redis backend
redisClient *redis.Client

logger modular.Logger
stopCh chan struct{}
}

// NewTokenBlacklistModule creates a new TokenBlacklistModule.
func NewTokenBlacklistModule(name, backend, redisURL string, cleanupInterval time.Duration) *TokenBlacklistModule {
if backend == "" {
backend = "memory"
}
if cleanupInterval <= 0 {
cleanupInterval = 5 * time.Minute
}
return &TokenBlacklistModule{
name: name,
backend: backend,
redisURL: redisURL,
cleanupInterval: cleanupInterval,
stopCh: make(chan struct{}),
}
}

// Name returns the module name.
func (m *TokenBlacklistModule) Name() string { return m.name }

// Init initializes the module.
func (m *TokenBlacklistModule) Init(app modular.Application) error {
m.logger = app.Logger()
return nil
}

// Start connects to Redis (if configured) and starts the cleanup goroutine.
func (m *TokenBlacklistModule) Start(ctx context.Context) error {
if m.backend == "redis" {
if m.redisURL == "" {
return fmt.Errorf("auth.token-blacklist %q: redis_url is required for redis backend", m.name)
}
opts, err := redis.ParseURL(m.redisURL)
if err != nil {
return fmt.Errorf("auth.token-blacklist %q: invalid redis_url: %w", m.name, err)
}
m.redisClient = redis.NewClient(opts)
if err := m.redisClient.Ping(ctx).Err(); err != nil {
_ = m.redisClient.Close()
m.redisClient = nil
return fmt.Errorf("auth.token-blacklist %q: redis ping failed: %w", m.name, err)
}
m.logger.Info("token blacklist started", "name", m.name, "backend", "redis")
return nil
}

// memory backend: start cleanup goroutine
go m.runCleanup()
m.logger.Info("token blacklist started", "name", m.name, "backend", "memory")
return nil
}

// Stop shuts down the module.
func (m *TokenBlacklistModule) Stop(_ context.Context) error {
select {
case <-m.stopCh:
// already closed
default:
close(m.stopCh)
}
if m.redisClient != nil {
return m.redisClient.Close()
}
return nil
}

// Add marks a JTI as revoked until expiresAt.
func (m *TokenBlacklistModule) Add(jti string, expiresAt time.Time) {
if m.backend == "redis" && m.redisClient != nil {
ttl := time.Until(expiresAt)
if ttl <= 0 {
return // already expired, nothing to blacklist
}
_ = m.redisClient.Set(context.Background(), m.redisKey(jti), "1", ttl).Err()
return
}
Comment on lines +104 to +112
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redis backend operations ignore errors (e.g., Set in Add). If Redis is unavailable, tokens may not actually be blacklisted and there’s no signal to the operator. At minimum, log errors via the module logger; ideally consider whether the interface should allow propagating errors for security-critical revocation paths.

Copilot uses AI. Check for mistakes.
m.entries.Store(jti, expiresAt)
}

// IsBlacklisted returns true if the JTI is revoked and has not yet expired.
func (m *TokenBlacklistModule) IsBlacklisted(jti string) bool {
if m.backend == "redis" && m.redisClient != nil {
n, err := m.redisClient.Exists(context.Background(), m.redisKey(jti)).Result()
return err == nil && n > 0
}
val, ok := m.entries.Load(jti)
if !ok {
return false
}
expiry, ok := val.(time.Time)
return ok && time.Now().Before(expiry)
}

func (m *TokenBlacklistModule) redisKey(jti string) string {
return "blacklist:" + jti
}

func (m *TokenBlacklistModule) runCleanup() {
ticker := time.NewTicker(m.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
now := time.Now()
m.entries.Range(func(key, value any) bool {
if expiry, ok := value.(time.Time); ok && now.After(expiry) {
m.entries.Delete(key)
}
return true
})
}
}
}

// ProvidesServices registers this module as a service.
func (m *TokenBlacklistModule) ProvidesServices() []modular.ServiceProvider {
return []modular.ServiceProvider{
{Name: m.name, Description: "JWT token blacklist", Instance: m},
}
}

// RequiresServices returns service dependencies (none).
func (m *TokenBlacklistModule) RequiresServices() []modular.ServiceDependency {
return nil
}
89 changes: 89 additions & 0 deletions module/auth_token_blacklist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package module

import (
"context"
"testing"
"time"
)

func TestTokenBlacklistMemory_AddAndCheck(t *testing.T) {
bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute)

if bl.IsBlacklisted("jti-1") {
t.Fatal("expected jti-1 to not be blacklisted initially")
}

bl.Add("jti-1", time.Now().Add(time.Hour))
if !bl.IsBlacklisted("jti-1") {
t.Fatal("expected jti-1 to be blacklisted after Add")
}

if bl.IsBlacklisted("jti-unknown") {
t.Fatal("expected jti-unknown to not be blacklisted")
}
}

func TestTokenBlacklistMemory_ExpiredEntry(t *testing.T) {
bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute)

// Add a JTI that has already expired.
bl.Add("jti-expired", time.Now().Add(-time.Second))
if bl.IsBlacklisted("jti-expired") {
t.Fatal("expected already-expired JTI to not be blacklisted")
}
}

func TestTokenBlacklistMemory_Cleanup(t *testing.T) {
bl := NewTokenBlacklistModule("test-bl", "memory", "", 50*time.Millisecond)

app := NewMockApplication()
if err := bl.Init(app); err != nil {
t.Fatalf("Init: %v", err)
}
if err := bl.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer func() { _ = bl.Stop(context.Background()) }()

// Add a JTI that expires in 10ms.
bl.Add("jti-cleanup", time.Now().Add(10*time.Millisecond))

// Give it time to expire and be cleaned up by the cleanup goroutine.
time.Sleep(300 * time.Millisecond)

if bl.IsBlacklisted("jti-cleanup") {
t.Fatal("expected cleaned-up JTI to no longer be blacklisted")
}
}

func TestTokenBlacklistModule_StopIdempotent(t *testing.T) {
bl := NewTokenBlacklistModule("test-bl", "memory", "", time.Minute)
app := NewMockApplication()
if err := bl.Init(app); err != nil {
t.Fatalf("Init: %v", err)
}
if err := bl.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
// Calling Stop twice must not panic.
if err := bl.Stop(context.Background()); err != nil {
t.Fatalf("first Stop: %v", err)
}
if err := bl.Stop(context.Background()); err != nil {
t.Fatalf("second Stop: %v", err)
}
}

func TestTokenBlacklistModule_ProvidesServices(t *testing.T) {
bl := NewTokenBlacklistModule("my-bl", "memory", "", time.Minute)
svcs := bl.ProvidesServices()
if len(svcs) != 1 {
t.Fatalf("expected 1 service, got %d", len(svcs))
}
if svcs[0].Name != "my-bl" {
t.Fatalf("expected service name 'my-bl', got %q", svcs[0].Name)
}
if _, ok := svcs[0].Instance.(TokenBlacklist); !ok {
t.Fatal("expected service instance to implement TokenBlacklist")
}
}
12 changes: 11 additions & 1 deletion module/cache_redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/CrisisTextLine/modular"
"github.com/GoCodeAlone/workflow/pkg/tlsutil"
"github.com/redis/go-redis/v9"
)

Expand All @@ -30,10 +31,11 @@ type RedisClient interface {
// RedisCacheConfig holds configuration for the cache.redis module.
type RedisCacheConfig struct {
Address string
Password string //nolint:gosec // G117: config struct field, not a hardcoded secret
Password string //nolint:gosec // G117: config struct field, not a hardcoded secret
DB int
Prefix string
DefaultTTL time.Duration
TLS tlsutil.TLSConfig `yaml:"tls" json:"tls"`
}

// RedisCache is a module that connects to a Redis instance and exposes
Expand Down Expand Up @@ -87,6 +89,14 @@ func (r *RedisCache) Start(ctx context.Context) error {
opts.Password = r.cfg.Password
}

if r.cfg.TLS.Enabled {
tlsCfg, err := tlsutil.LoadTLSConfig(r.cfg.TLS)
if err != nil {
return fmt.Errorf("cache.redis %q: TLS config: %w", r.name, err)
}
opts.TLSConfig = tlsCfg
}

r.client = redis.NewClient(opts)

if err := r.client.Ping(ctx).Err(); err != nil {
Expand Down
Loading
Loading