Skip to content

Commit 2e0f421

Browse files
committed
fix(verify): one-shot consumption of email confirmation tokens
The email confirmation JWT issued by VerifyHandler.sendConfirmation was only protected by its 30-minute expiry: any party who could read the confirmation link (forwarded email, mail-gateway logs, mailbox archive) could redeem it independently within the window, creating a separate authenticated session for the same address. Add a VerifConfirmationStore interface (MarkUsed key, ttl -> bool) and a default in-memory implementation (sync.Mutex-protected map keyed by SHA-256 of the raw token). When VerifyHandler.ConfirmationStore is non-nil, LoginHandler records the token as consumed on first redemption and rejects replays with 403 "confirmation token already consumed". The store key is the SHA-256 of the raw token rather than a jti claim, so existing token fixtures (which carry no jti) are still de-dup'd correctly without changing the wire format. Wired through Service.AddVerifProvider in both auth.go and v2/auth.go. The store is selected once via sync.Once, with this precedence: 1. Opts.VerifConfirmationStore if non-nil (caller-supplied; required for multi-instance deployments — Redis or any shared KV). 2. provider.NewInMemoryVerifStore() default — fine for single instance, broken for LB-fronted multi-instance deployments where instance A's "used" set is unknown to instance B. Documented in README under "Confirmation token replay protection". Tests: * TestInMemoryVerifStore — store-level coverage (replay, expiry, key independence). * TestVerifyHandler_LoginAcceptConfirm_RejectsReplay — integration: same token redeemed twice -> 200 then 403. * TestService_AddVerifProvider_UsesCustomConfirmationStore — Opts injection works. * TestService_AddVerifProvider_DefaultsToInMemory — default install + sync.Once reuse on subsequent AddVerifProvider calls.
1 parent e5f47f5 commit 2e0f421

9 files changed

Lines changed: 572 additions & 18 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,25 @@ The API for this provider:
296296

297297
The provider acts like any other, i.e. will be registered as `/auth/email/login`.
298298

299+
#### Confirmation token replay protection
300+
301+
Confirmation tokens are one-shot: a token redeemed once cannot be redeemed again within its TTL. This stops anyone with read access to the email link (forwarded mail, mail-gateway logs, mailbox archive) from independently consuming it after the user has.
302+
303+
By default the library installs an in-memory store on first call to `AddVerifProvider`. This is correct for **single-instance** deployments.
304+
305+
**Multi-instance deployments behind a load balancer MUST supply a shared backend.** With the default in-memory store, replay protection works only on the instance that originally consumed the token; an attacker who hits any other instance can still replay within the TTL. **This is silent: the request succeeds and the auth flow completes normally, with no log indicating the protection was bypassed.** Plug in Redis or any shared KV by setting `Opts.VerifConfirmationStore` to a value implementing `provider.VerifConfirmationStore`:
306+
307+
```go
308+
type VerifConfirmationStore interface {
309+
// MarkUsed records key as consumed and returns alreadyUsed=true if it was
310+
// already recorded. err signals a backend failure -- callers fail-closed
311+
// (reject the redemption) on non-nil err to avoid replay during outages.
312+
MarkUsed(key string, ttl time.Duration) (alreadyUsed bool, err error)
313+
}
314+
```
315+
316+
The store key is the SHA-256 of the raw confirmation token, so existing tokens issued before this protection landed are de-dup'd correctly without changing the wire format. Consumption is final: a transient downstream failure after the mark burns the token; the user must request a new confirmation email rather than retry the same link.
317+
299318
#### Email-as-identity caveat
300319

301320
The verify provider returns a local user id of the form `<provider>_<sha1(address)>`. The confirmation round-trip proves current control of the address at the moment of login; it does **not** prove stable+unique identity over time. The owner of an address can change without the address changing:

auth.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/url"
88
"regexp"
99
"strings"
10+
"sync"
1011
"time"
1112

1213
"github.com/go-pkgz/rest"
@@ -26,14 +27,16 @@ type Client struct {
2627

2728
// Service provides higher level wrapper allowing to construct everything and get back token middleware
2829
type Service struct {
29-
logger logger.L
30-
opts Opts
31-
jwtService *token.Service
32-
providers []provider.Service
33-
authMiddleware middleware.Authenticator
34-
avatarProxy *avatar.Proxy
35-
issuer string
36-
useGravatar bool
30+
logger logger.L
31+
opts Opts
32+
jwtService *token.Service
33+
providers []provider.Service
34+
authMiddleware middleware.Authenticator
35+
avatarProxy *avatar.Proxy
36+
issuer string
37+
useGravatar bool
38+
verifConfirmStore provider.VerifConfirmationStore
39+
verifConfirmStoreO sync.Once
3740
}
3841

3942
// Opts is a full set of all parameters to initialize Service
@@ -83,6 +86,15 @@ type Opts struct {
8386
AudSecrets bool // allow multiple secrets (secret per aud)
8487
Logger logger.L // logger interface, default is no logging at all
8588
RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens
89+
90+
// VerifConfirmationStore enforces one-shot consumption of email
91+
// confirmation tokens issued by the verify provider. The default
92+
// (nil) installs an in-memory store on first use of AddVerifProvider —
93+
// fine for single-instance deployments. Multi-instance deployments
94+
// MUST supply a shared backend (e.g. Redis) implementing
95+
// provider.VerifConfirmationStore, otherwise replay rejection works
96+
// only on the instance that consumed the token.
97+
VerifConfirmationStore provider.VerifConfirmationStore
8698
}
8799

88100
// NewService initializes everything
@@ -434,6 +446,13 @@ func (s *Service) AddDirectProviderWithUserIDFunc(name string, credChecker provi
434446

435447
// AddVerifProvider adds provider user's verification sent by sender
436448
func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender) {
449+
s.verifConfirmStoreO.Do(func() {
450+
if s.opts.VerifConfirmationStore != nil {
451+
s.verifConfirmStore = s.opts.VerifConfirmationStore
452+
return
453+
}
454+
s.verifConfirmStore = provider.NewInMemoryVerifStore()
455+
})
437456
dh := provider.VerifyHandler{
438457
L: s.logger,
439458
ProviderName: name,
@@ -445,6 +464,7 @@ func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender)
445464
UseGravatar: s.useGravatar,
446465
URL: s.opts.URL,
447466
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
467+
ConfirmationStore: s.verifConfirmStore,
448468
}
449469
s.addProvider(dh)
450470
}

auth_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ func TestService_AddMicrosoftProvider(t *testing.T) {
117117
})
118118
}
119119

120+
func TestService_AddVerifProvider_UsesCustomConfirmationStore(t *testing.T) {
121+
custom := &countingVerifStore{}
122+
svc := NewService(Opts{
123+
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
124+
URL: "http://127.0.0.1",
125+
Logger: logger.Std,
126+
VerifConfirmationStore: custom,
127+
})
128+
svc.AddVerifProvider("email", "{{.Token}}", provider.SenderFunc(func(string, string) error { return nil }))
129+
assert.Same(t, custom, svc.verifConfirmStore, "custom store from Opts must be used, not the in-memory default")
130+
}
131+
132+
func TestService_AddVerifProvider_DefaultsToInMemory(t *testing.T) {
133+
svc := NewService(Opts{
134+
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
135+
URL: "http://127.0.0.1",
136+
Logger: logger.Std,
137+
})
138+
svc.AddVerifProvider("email", "{{.Token}}", provider.SenderFunc(func(string, string) error { return nil }))
139+
require.NotNil(t, svc.verifConfirmStore, "default store must be installed when Opts.VerifConfirmationStore is nil")
140+
// second call must reuse the same store (sync.Once guards against re-init)
141+
first := svc.verifConfirmStore
142+
svc.AddVerifProvider("email2", "{{.Token}}", provider.SenderFunc(func(string, string) error { return nil }))
143+
assert.Same(t, first, svc.verifConfirmStore, "subsequent AddVerifProvider calls must reuse the same store")
144+
}
145+
146+
type countingVerifStore struct{ calls int }
147+
148+
func (s *countingVerifStore) MarkUsed(string, time.Duration) (bool, error) {
149+
s.calls++
150+
return false, nil
151+
}
152+
120153
func TestService_AddAppleProvider(t *testing.T) {
121154

122155
options := Opts{

provider/verify.go

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package provider
33
import (
44
"bytes"
55
"crypto/sha1"
6+
"crypto/sha256"
7+
"encoding/hex"
68
"fmt"
79
"html/template"
810
"net/http"
911
"strings"
12+
"sync"
1013
"time"
1114

1215
"github.com/go-pkgz/rest"
@@ -50,6 +53,87 @@ type VerifyHandler struct {
5053
// here. Nil disables validation and preserves legacy permissive
5154
// behavior — any non-empty "from" value is honored.
5255
AllowedRedirectHosts token.AllowedHosts
56+
57+
// ConfirmationStore enforces one-shot consumption of confirmation tokens.
58+
// When non-nil, a token cannot be redeemed twice within its TTL window.
59+
// Leave nil to keep the legacy behavior (token replayable until expiry).
60+
ConfirmationStore VerifConfirmationStore
61+
}
62+
63+
// VerifConfirmationStore tracks consumed confirmation tokens to prevent replay.
64+
// Implementations must be safe for concurrent use.
65+
type VerifConfirmationStore interface {
66+
// MarkUsed records key as consumed and returns alreadyUsed=true if it was
67+
// already recorded. ttl is an upper bound on how long the entry needs to
68+
// live; the implementation may evict earlier under memory pressure. err
69+
// signals a backend failure (network, disk, etc.) -- callers MUST treat a
70+
// non-nil err as fail-closed (reject the redemption) to avoid replay
71+
// during outages.
72+
MarkUsed(key string, ttl time.Duration) (alreadyUsed bool, err error)
73+
}
74+
75+
// VerifConfirmationStoreFunc is an adapter to use ordinary functions as
76+
// VerifConfirmationStore, mirroring the SenderFunc / token.AllowedHostsFunc
77+
// house pattern for closure-based config.
78+
type VerifConfirmationStoreFunc func(key string, ttl time.Duration) (alreadyUsed bool, err error)
79+
80+
// MarkUsed calls f(key, ttl) to implement VerifConfirmationStore.
81+
func (f VerifConfirmationStoreFunc) MarkUsed(key string, ttl time.Duration) (bool, error) {
82+
return f(key, ttl)
83+
}
84+
85+
// NewInMemoryVerifStore returns a process-local default VerifConfirmationStore.
86+
// Suitable for single-instance deployments. Multi-instance deployments behind
87+
// a load balancer MUST supply a shared backend (e.g. Redis) -- otherwise an
88+
// attacker who lands on a different instance from the legitimate user can
89+
// replay the token there. The default's failure is silent: the request
90+
// completes normally and no log indicates the protection was bypassed.
91+
func NewInMemoryVerifStore() VerifConfirmationStore {
92+
return &inMemoryVerifStore{used: make(map[string]time.Time)}
93+
}
94+
95+
type inMemoryVerifStore struct {
96+
mu sync.Mutex
97+
used map[string]time.Time // key -> expiry
98+
insertCount int
99+
}
100+
101+
// inMemoryVerifStoreSweepEvery is the in-memory store's amortization cadence.
102+
// Walking the whole map on every redemption is O(n) under a single mutex,
103+
// which serializes the hot path. Sweeping every N inserts keeps the map size
104+
// bounded by ~N + (concurrent redemptions during the gap) without holding
105+
// the lock through a full walk on most calls.
106+
const inMemoryVerifStoreSweepEvery = 256
107+
108+
func (s *inMemoryVerifStore) MarkUsed(key string, ttl time.Duration) (bool, error) {
109+
s.mu.Lock()
110+
defer s.mu.Unlock()
111+
now := time.Now()
112+
if exp, ok := s.used[key]; ok && exp.After(now) {
113+
return true, nil
114+
}
115+
// amortized eviction: walk the map only every Nth insert, not on every
116+
// hot-path call. The lookup above already rejects unexpired duplicates,
117+
// so worst-case staleness is bounded by N inserts between sweeps.
118+
s.insertCount++
119+
if s.insertCount >= inMemoryVerifStoreSweepEvery {
120+
s.insertCount = 0
121+
for k, exp := range s.used {
122+
if !exp.After(now) {
123+
delete(s.used, k)
124+
}
125+
}
126+
}
127+
s.used[key] = now.Add(ttl)
128+
return false, nil
129+
}
130+
131+
// confirmationKey hashes the raw token so the store key length is bounded
132+
// regardless of token size, and so the in-memory map doesn't retain the
133+
// signed token itself.
134+
func confirmationKey(rawToken string) string {
135+
sum := sha256.Sum256([]byte(rawToken))
136+
return hex.EncodeToString(sum[:])
53137
}
54138

55139
// Sender defines interface to send emails
@@ -78,7 +162,13 @@ type VerifTokenService interface {
78162
func (e VerifyHandler) Name() string { return e.ProviderName }
79163

80164
// LoginHandler gets name and address from query, makes confirmation token and sends it to user.
81-
// In case if confirmation token presented in the query uses it to create auth token
165+
// In case if confirmation token presented in the query uses it to create auth token.
166+
//
167+
// Consumption is final when ConfirmationStore is configured: the token is
168+
// marked used before any further side effects (avatar fetch, token issuance),
169+
// so a transient downstream failure burns the token and the user must request
170+
// a new confirmation email rather than retry the same link. This trade-off
171+
// keeps the replay check atomic with the security boundary.
82172
func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
83173

84174
// GET /login?site=site&user=name&address=someone@example.com
@@ -101,6 +191,24 @@ func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
101191
return
102192
}
103193

194+
if e.ConfirmationStore != nil {
195+
ttl := time.Until(time.Unix(confClaims.ExpiresAt, 0))
196+
if ttl <= 0 {
197+
ttl = time.Minute
198+
}
199+
alreadyUsed, markErr := e.ConfirmationStore.MarkUsed(confirmationKey(tkn), ttl)
200+
if markErr != nil {
201+
// fail-closed: a backend outage must not let attackers replay
202+
// tokens. Log and reject; the user can request a fresh email.
203+
rest.SendErrorJSON(w, r, e.L, http.StatusForbidden, markErr, "confirmation token store unavailable")
204+
return
205+
}
206+
if alreadyUsed {
207+
rest.SendErrorJSON(w, r, e.L, http.StatusForbidden, fmt.Errorf("token already used"), "confirmation token already consumed")
208+
return
209+
}
210+
}
211+
104212
elems := strings.Split(confClaims.Handshake.ID, "::")
105213
if len(elems) != 2 {
106214
rest.SendErrorJSON(w, r, e.L, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token")

provider/verify_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"net/http/httptest"
77
"net/url"
88
"strings"
9+
"sync"
10+
"sync/atomic"
911
"testing"
1012
"time"
1113

@@ -259,6 +261,110 @@ func TestVerifyHandler_LoginAcceptConfirmFromAllowsAllowlistedHost(t *testing.T)
259261
assert.Equal(t, "https://trusted.example.com/back", rr.Header().Get("Location"))
260262
}
261263

264+
func TestInMemoryVerifStore(t *testing.T) {
265+
mark := func(s VerifConfirmationStore, k string, ttl time.Duration) bool {
266+
used, err := s.MarkUsed(k, ttl)
267+
require.NoError(t, err)
268+
return used
269+
}
270+
271+
t.Run("first MarkUsed returns false, second returns true", func(t *testing.T) {
272+
s := NewInMemoryVerifStore()
273+
assert.False(t, mark(s, "k1", time.Hour), "first call must mark and return not-used")
274+
assert.True(t, mark(s, "k1", time.Hour), "second call must report already-used")
275+
})
276+
t.Run("expired entry is not considered used", func(t *testing.T) {
277+
s := NewInMemoryVerifStore()
278+
assert.False(t, mark(s, "k1", time.Nanosecond))
279+
time.Sleep(2 * time.Millisecond)
280+
assert.False(t, mark(s, "k1", time.Hour), "expired entry should be reusable")
281+
})
282+
t.Run("distinct keys are independent", func(t *testing.T) {
283+
s := NewInMemoryVerifStore()
284+
assert.False(t, mark(s, "k1", time.Hour))
285+
assert.False(t, mark(s, "k2", time.Hour))
286+
assert.True(t, mark(s, "k1", time.Hour))
287+
assert.True(t, mark(s, "k2", time.Hour))
288+
})
289+
t.Run("concurrent same-key redemption: exactly one succeeds", func(t *testing.T) {
290+
s := NewInMemoryVerifStore()
291+
const goroutines = 50
292+
var wg sync.WaitGroup
293+
successes := int32(0)
294+
errs := int32(0)
295+
wg.Add(goroutines)
296+
for i := 0; i < goroutines; i++ {
297+
go func() {
298+
defer wg.Done()
299+
used, err := s.MarkUsed("hot-key", time.Hour)
300+
if err != nil {
301+
atomic.AddInt32(&errs, 1)
302+
return
303+
}
304+
if !used {
305+
atomic.AddInt32(&successes, 1)
306+
}
307+
}()
308+
}
309+
wg.Wait()
310+
assert.EqualValues(t, 0, errs)
311+
assert.EqualValues(t, 1, successes, "exactly one redemption must observe alreadyUsed=false")
312+
})
313+
}
314+
315+
func TestVerifConfirmationStoreFunc(t *testing.T) {
316+
calls := 0
317+
var lastKey string
318+
var lastTTL time.Duration
319+
var s VerifConfirmationStore = VerifConfirmationStoreFunc(func(key string, ttl time.Duration) (bool, error) {
320+
calls++
321+
lastKey, lastTTL = key, ttl
322+
return calls > 1, nil
323+
})
324+
325+
used, err := s.MarkUsed("k1", 5*time.Minute)
326+
require.NoError(t, err)
327+
assert.False(t, used)
328+
assert.Equal(t, "k1", lastKey)
329+
assert.Equal(t, 5*time.Minute, lastTTL)
330+
331+
used, err = s.MarkUsed("k1", 5*time.Minute)
332+
require.NoError(t, err)
333+
assert.True(t, used)
334+
assert.Equal(t, 2, calls)
335+
}
336+
337+
func TestVerifyHandler_LoginAcceptConfirm_RejectsReplay(t *testing.T) {
338+
e := VerifyHandler{
339+
ProviderName: "test",
340+
TokenService: token.NewService(token.Opts{
341+
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
342+
TokenDuration: time.Hour,
343+
CookieDuration: time.Hour * 24 * 31,
344+
}),
345+
Issuer: "iss-test",
346+
L: logger.Std,
347+
ConfirmationStore: NewInMemoryVerifStore(),
348+
}
349+
350+
handler := http.HandlerFunc(e.LoginHandler)
351+
352+
// first use: success
353+
rr1 := httptest.NewRecorder()
354+
req1, err := http.NewRequest("GET", "/login?token="+testConfirmedToken, http.NoBody)
355+
require.NoError(t, err)
356+
handler.ServeHTTP(rr1, req1)
357+
require.Equal(t, 200, rr1.Code, "first consumption must succeed")
358+
359+
// second use: must be rejected
360+
rr2 := httptest.NewRecorder()
361+
req2, err := http.NewRequest("GET", "/login?token="+testConfirmedToken, http.NoBody)
362+
require.NoError(t, err)
363+
handler.ServeHTTP(rr2, req2)
364+
assert.Equal(t, 403, rr2.Code, "replay must be rejected")
365+
assert.Contains(t, rr2.Body.String(), "already")
366+
}
367+
262368
func TestVerifyHandler_LoginAcceptConfirmWithAvatar(t *testing.T) {
263369
e := VerifyHandler{
264370
ProviderName: "test",

0 commit comments

Comments
 (0)