Skip to content

Commit 0e39694

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 66189f0 commit 0e39694

9 files changed

Lines changed: 373 additions & 24 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,22 @@ 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. **Multi-instance deployments behind a load balancer must supply a shared backend** — otherwise an attacker can land on a different instance from the legitimate user and replay the token there. Plug in Redis (or any shared KV) by setting `Opts.VerifConfirmationStore` to a value implementing `provider.VerifConfirmationStore`:
304+
305+
```go
306+
type VerifConfirmationStore interface {
307+
// MarkUsed records key as consumed and returns true if it was already
308+
// recorded. ttl is an upper bound on how long the entry needs to live.
309+
MarkUsed(key string, ttl time.Duration) bool
310+
}
311+
```
312+
313+
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.
314+
299315
#### Email-as-identity caveat
300316

301317
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: 36 additions & 16 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
@@ -74,6 +77,15 @@ type Opts struct {
7477
AudSecrets bool // allow multiple secrets (secret per aud)
7578
Logger logger.L // logger interface, default is no logging at all
7679
RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens
80+
81+
// VerifConfirmationStore enforces one-shot consumption of email
82+
// confirmation tokens issued by the verify provider. The default
83+
// (nil) installs an in-memory store on first use of AddVerifProvider —
84+
// fine for single-instance deployments. Multi-instance deployments
85+
// MUST supply a shared backend (e.g. Redis) implementing
86+
// provider.VerifConfirmationStore, otherwise replay rejection works
87+
// only on the instance that consumed the token.
88+
VerifConfirmationStore provider.VerifConfirmationStore
7789
}
7890

7991
// NewService initializes everything
@@ -419,15 +431,23 @@ func (s *Service) AddDirectProviderWithUserIDFunc(name string, credChecker provi
419431

420432
// AddVerifProvider adds provider user's verification sent by sender
421433
func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender) {
434+
s.verifConfirmStoreO.Do(func() {
435+
if s.opts.VerifConfirmationStore != nil {
436+
s.verifConfirmStore = s.opts.VerifConfirmationStore
437+
return
438+
}
439+
s.verifConfirmStore = provider.NewInMemoryVerifStore()
440+
})
422441
dh := provider.VerifyHandler{
423-
L: s.logger,
424-
ProviderName: name,
425-
Issuer: s.issuer,
426-
TokenService: s.jwtService,
427-
AvatarSaver: s.avatarProxy,
428-
Sender: sender,
429-
Template: msgTmpl,
430-
UseGravatar: s.useGravatar,
442+
L: s.logger,
443+
ProviderName: name,
444+
Issuer: s.issuer,
445+
TokenService: s.jwtService,
446+
AvatarSaver: s.avatarProxy,
447+
Sender: sender,
448+
Template: msgTmpl,
449+
UseGravatar: s.useGravatar,
450+
ConfirmationStore: s.verifConfirmStore,
431451
}
432452
s.addProvider(dh)
433453
}

auth_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,36 @@ 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 { s.calls++; return false }
149+
120150
func TestService_AddAppleProvider(t *testing.T) {
121151

122152
options := Opts{

provider/verify.go

Lines changed: 64 additions & 0 deletions
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"
@@ -40,6 +43,56 @@ type VerifyHandler struct {
4043
Sender Sender
4144
Template string
4245
UseGravatar bool
46+
47+
// ConfirmationStore enforces one-shot consumption of confirmation tokens.
48+
// When non-nil, a token cannot be redeemed twice within its TTL window.
49+
// Leave nil to keep the legacy behavior (token replayable until expiry).
50+
ConfirmationStore VerifConfirmationStore
51+
}
52+
53+
// VerifConfirmationStore tracks consumed confirmation tokens to prevent replay.
54+
// Implementations must be safe for concurrent use.
55+
type VerifConfirmationStore interface {
56+
// MarkUsed records key as consumed and returns true if it was already
57+
// recorded. ttl is an upper bound on how long the entry needs to live;
58+
// the implementation may evict earlier under memory pressure.
59+
MarkUsed(key string, ttl time.Duration) bool
60+
}
61+
62+
// NewInMemoryVerifStore returns a process-local default VerifConfirmationStore.
63+
// Suitable for single-instance deployments. Multi-instance deployments should
64+
// supply a shared backend (e.g. Redis) implementing VerifConfirmationStore.
65+
func NewInMemoryVerifStore() VerifConfirmationStore {
66+
return &inMemoryVerifStore{used: make(map[string]time.Time)}
67+
}
68+
69+
type inMemoryVerifStore struct {
70+
mu sync.Mutex
71+
used map[string]time.Time // key -> expiry
72+
}
73+
74+
func (s *inMemoryVerifStore) MarkUsed(key string, ttl time.Duration) bool {
75+
s.mu.Lock()
76+
defer s.mu.Unlock()
77+
now := time.Now()
78+
for k, exp := range s.used {
79+
if !exp.After(now) {
80+
delete(s.used, k)
81+
}
82+
}
83+
if exp, ok := s.used[key]; ok && exp.After(now) {
84+
return true
85+
}
86+
s.used[key] = now.Add(ttl)
87+
return false
88+
}
89+
90+
// confirmationKey hashes the raw token so the store key length is bounded
91+
// regardless of token size, and so the in-memory map doesn't retain the
92+
// signed token itself.
93+
func confirmationKey(rawToken string) string {
94+
sum := sha256.Sum256([]byte(rawToken))
95+
return hex.EncodeToString(sum[:])
4396
}
4497

4598
// Sender defines interface to send emails
@@ -91,6 +144,17 @@ func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
91144
return
92145
}
93146

147+
if e.ConfirmationStore != nil {
148+
ttl := time.Until(time.Unix(confClaims.ExpiresAt, 0))
149+
if ttl <= 0 {
150+
ttl = time.Minute
151+
}
152+
if e.ConfirmationStore.MarkUsed(confirmationKey(tkn), ttl) {
153+
rest.SendErrorJSON(w, r, e.L, http.StatusForbidden, fmt.Errorf("token already used"), "confirmation token already consumed")
154+
return
155+
}
156+
}
157+
94158
elems := strings.Split(confClaims.Handshake.ID, "::")
95159
if len(elems) != 2 {
96160
rest.SendErrorJSON(w, r, e.L, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token")

provider/verify_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,58 @@ func TestVerifyHandler_LoginAcceptConfirm(t *testing.T) {
132132
assert.Equal(t, true, claims.SessionOnly)
133133
}
134134

135+
func TestInMemoryVerifStore(t *testing.T) {
136+
t.Run("first MarkUsed returns false, second returns true", func(t *testing.T) {
137+
s := NewInMemoryVerifStore()
138+
assert.False(t, s.MarkUsed("k1", time.Hour), "first call must mark and return not-used")
139+
assert.True(t, s.MarkUsed("k1", time.Hour), "second call must report already-used")
140+
})
141+
t.Run("expired entry is not considered used", func(t *testing.T) {
142+
s := NewInMemoryVerifStore()
143+
assert.False(t, s.MarkUsed("k1", time.Nanosecond))
144+
time.Sleep(2 * time.Millisecond)
145+
assert.False(t, s.MarkUsed("k1", time.Hour), "expired entry should be reusable")
146+
})
147+
t.Run("distinct keys are independent", func(t *testing.T) {
148+
s := NewInMemoryVerifStore()
149+
assert.False(t, s.MarkUsed("k1", time.Hour))
150+
assert.False(t, s.MarkUsed("k2", time.Hour))
151+
assert.True(t, s.MarkUsed("k1", time.Hour))
152+
assert.True(t, s.MarkUsed("k2", time.Hour))
153+
})
154+
}
155+
156+
func TestVerifyHandler_LoginAcceptConfirm_RejectsReplay(t *testing.T) {
157+
e := VerifyHandler{
158+
ProviderName: "test",
159+
TokenService: token.NewService(token.Opts{
160+
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
161+
TokenDuration: time.Hour,
162+
CookieDuration: time.Hour * 24 * 31,
163+
}),
164+
Issuer: "iss-test",
165+
L: logger.Std,
166+
ConfirmationStore: NewInMemoryVerifStore(),
167+
}
168+
169+
handler := http.HandlerFunc(e.LoginHandler)
170+
171+
// first use: success
172+
rr1 := httptest.NewRecorder()
173+
req1, err := http.NewRequest("GET", "/login?token="+testConfirmedToken, http.NoBody)
174+
require.NoError(t, err)
175+
handler.ServeHTTP(rr1, req1)
176+
require.Equal(t, 200, rr1.Code, "first consumption must succeed")
177+
178+
// second use: must be rejected
179+
rr2 := httptest.NewRecorder()
180+
req2, err := http.NewRequest("GET", "/login?token="+testConfirmedToken, http.NoBody)
181+
require.NoError(t, err)
182+
handler.ServeHTTP(rr2, req2)
183+
assert.Equal(t, 403, rr2.Code, "replay must be rejected")
184+
assert.Contains(t, rr2.Body.String(), "already")
185+
}
186+
135187
func TestVerifyHandler_LoginAcceptConfirmWithAvatar(t *testing.T) {
136188
e := VerifyHandler{
137189
ProviderName: "test",

v2/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
@@ -84,6 +87,15 @@ type Opts struct {
8487
Logger logger.L // logger interface, default is no logging at all
8588
RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens
8689
ErrorHandler middleware.ErrorHandlerFunc // custom error handler for auth failures
90+
91+
// VerifConfirmationStore enforces one-shot consumption of email
92+
// confirmation tokens issued by the verify provider. The default
93+
// (nil) installs an in-memory store on first use of AddVerifProvider —
94+
// fine for single-instance deployments. Multi-instance deployments
95+
// MUST supply a shared backend (e.g. Redis) implementing
96+
// provider.VerifConfirmationStore, otherwise replay rejection works
97+
// only on the instance that consumed the token.
98+
VerifConfirmationStore provider.VerifConfirmationStore
8799
}
88100

89101
// NewService initializes everything
@@ -435,6 +447,13 @@ func (s *Service) AddDirectProviderWithUserIDFunc(name string, credChecker provi
435447

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

v2/auth_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ 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+
first := svc.verifConfirmStore
141+
svc.AddVerifProvider("email2", "{{.Token}}", provider.SenderFunc(func(string, string) error { return nil }))
142+
assert.Same(t, first, svc.verifConfirmStore, "subsequent AddVerifProvider calls must reuse the same store")
143+
}
144+
145+
type countingVerifStore struct{ calls int }
146+
147+
func (s *countingVerifStore) MarkUsed(string, time.Duration) bool { s.calls++; return false }
148+
120149
func TestService_AddAppleProvider(t *testing.T) {
121150

122151
options := Opts{

0 commit comments

Comments
 (0)