Skip to content

Commit e3489bf

Browse files
feat(auth): add DPoP jti replay protection and address review comments
Add a server-side replay cache that rejects reused DPoP proof jti values within the acceptance window (RFC 9449 §11.1). The check runs as the last step in validateDPoP so only otherwise-valid proofs populate the cache, and the cache TTL tracks DPoPSkew since older proofs are already rejected by the iat freshness check. Also addresses review nits: move the dpopNonceManager doc comment onto the correct type and use WarnContext in checkToken. Signed-off-by: Dave Mihalcik <dmihalcik@virtru.com>
1 parent c9f0014 commit e3489bf

4 files changed

Lines changed: 195 additions & 9 deletions

File tree

service/internal/auth/authn.go

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,22 @@ type Authentication struct {
104104
logger *logger.Logger
105105
// DPoP nonce management
106106
dpopNonceManager *dpopNonceManager
107+
// dpopReplayCache rejects replayed DPoP proofs by `jti`
108+
dpopReplayCache *dpopReplayCache
107109

108110
// Used for testing
109111
_testCheckTokenFunc func(ctx context.Context, authHeader []string, dpopInfo receiverInfo, dpopHeader []string) (jwt.Token, context.Context, error)
110112
}
111113

112-
// dpopNonceManager manages server-issued DPoP nonces per RFC 9449 §8
114+
// nonceState is an immutable snapshot of the active and previous nonces,
115+
// swapped atomically on rotation.
113116
type nonceState struct {
114117
currentNonce string
115118
previousNonce string
116119
rotatedAt time.Time
117120
}
118121

122+
// dpopNonceManager manages server-issued DPoP nonces per RFC 9449 §8
119123
type dpopNonceManager struct {
120124
state atomic.Pointer[nonceState]
121125
mu sync.Mutex // serializes rotation only
@@ -180,11 +184,20 @@ func (nm *dpopNonceManager) validateNonce(nonce string) bool {
180184

181185
// Creates new authN which is used to verify tokens for a set of given issuers
182186
func NewAuthenticator(ctx context.Context, cfg Config, logger *logger.Logger, wellknownRegistration func(namespace string, config any) error) (*Authentication, error) {
187+
// Remember DPoP `jti` values for the acceptance window so a captured proof
188+
// cannot be replayed; a proof older than DPoPSkew is rejected by the `iat`
189+
// check, so the cache TTL tracks that window.
190+
replayTTL := cfg.DPoPSkew
191+
if replayTTL <= 0 {
192+
replayTTL = time.Hour
193+
}
194+
183195
a := &Authentication{
184196
enforceDPoP: cfg.EnforceDPoP,
185197
strictDPoPHTU: cfg.DPoP.StrictHTU,
186198
logger: logger,
187199
dpopNonceManager: newDPoPNonceManager(cfg.DPoP.RequireNonce, cfg.DPoP.NonceExpiration),
200+
dpopReplayCache: newDPoPReplayCache(replayTTL),
188201
}
189202

190203
tokenVerifier, oidcConfig, err := newTokenVerifier(ctx, cfg.AuthNConfig, a.logger)
@@ -384,7 +397,8 @@ func (a Authentication) MuxHandler(handler http.Handler) http.Handler {
384397
http.Error(w, "unauthenticated", http.StatusUnauthorized)
385398
return
386399
}
387-
log.WarnContext(ctxWithAuthX,
400+
log.WarnContext(
401+
ctxWithAuthX,
388402
"unauthenticated",
389403
slog.Any("error", err),
390404
slog.Any("dpop", dp),
@@ -623,7 +637,8 @@ func permissionDeniedLogAttrs(token jwt.Token, casbinAuthz CasbinAuthzLog, err e
623637
attrs := []any{slog.String("azp", token.Subject())}
624638

625639
if casbinAuthz.ConfiguredGroupsClaim != "" || casbinAuthz.SubjectGroups != nil {
626-
attrs = append(attrs, slog.Group("casbin_authz",
640+
attrs = append(attrs, slog.Group(
641+
"casbin_authz",
627642
slog.String("configured_groups_claim", casbinAuthz.ConfiguredGroupsClaim),
628643
slog.Any("subject_groups", casbinAuthz.SubjectGroups),
629644
))
@@ -799,7 +814,7 @@ func (a *Authentication) checkToken(ctx context.Context, authHeader []string, dp
799814
if errors.As(err, &nonceErr) {
800815
a.logger.DebugContext(ctx, "dpop nonce challenge issued", slog.String("reason", nonceErr.Message))
801816
} else {
802-
a.logger.Warn("failed to validate dpop", slog.Any("err", err))
817+
a.logger.WarnContext(ctx, "failed to validate dpop", slog.Any("err", err))
803818
}
804819
return nil, nil, err
805820
}
@@ -948,21 +963,38 @@ func (a Authentication) validateDPoP(accessToken jwt.Token, acessTokenRaw string
948963
}
949964
}
950965

966+
// Replay protection (RFC 9449 §11.1): reject a proof whose `jti` has already
967+
// been seen within the acceptance window. Checked last so only otherwise-valid
968+
// proofs populate the cache, preventing an attacker from poisoning it.
969+
jtiI, ok := dpopToken.Get("jti")
970+
if !ok {
971+
return nil, errors.New("missing `jti` claim in DPoP JWT")
972+
}
973+
jti, ok := jtiI.(string)
974+
if !ok {
975+
return nil, errors.New("`jti` claim invalid format in DPoP JWT")
976+
}
977+
if a.dpopReplayCache.observe(jti, time.Now()) {
978+
return nil, errors.New("DPoP proof replay detected; `jti` already used")
979+
}
980+
951981
return dpopKey, nil
952982
}
953983

954984
func (a Authentication) isPublicRoute(path string) func(string) bool {
955985
return func(route string) bool {
956986
matched, err := doublestar.Match(route, path)
957987
if err != nil {
958-
a.logger.Warn("error matching route",
988+
a.logger.Warn(
989+
"error matching route",
959990
slog.String("route", route),
960991
slog.String("path", path),
961992
slog.Any("error", err),
962993
)
963994
return false
964995
}
965-
a.logger.Trace("matching route",
996+
a.logger.Trace(
997+
"matching route",
966998
slog.String("route", route),
967999
slog.String("path", path),
9681000
slog.Bool("matched", matched),

service/internal/auth/authn_test.go

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,51 @@ func (s *AuthSuite) Test_CheckToken_AcceptsDPoP_GET() {
809809
s.Require().NoError(err)
810810
}
811811

812+
// Test_CheckToken_RejectsReplayedDPoP verifies that reusing the same DPoP proof
813+
// (identical `jti`) within the acceptance window is rejected per RFC 9449 §11.1,
814+
// even though the first presentation succeeds.
815+
func (s *AuthSuite) Test_CheckToken_RejectsReplayedDPoP() {
816+
dpopRaw, err := rsa.GenerateKey(rand.Reader, 2048)
817+
s.Require().NoError(err)
818+
dpopKey, err := jwk.FromRaw(dpopRaw)
819+
s.Require().NoError(err)
820+
s.Require().NoError(dpopKey.Set(jwk.AlgorithmKey, jwa.RS256))
821+
dpopPublic, err := dpopKey.PublicKey()
822+
s.Require().NoError(err)
823+
824+
tok := jwt.New()
825+
s.Require().NoError(tok.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)))
826+
s.Require().NoError(tok.Set("iss", s.server.URL))
827+
s.Require().NoError(tok.Set("aud", "test"))
828+
s.Require().NoError(tok.Set("cid", "client-replay"))
829+
thumbprint, err := dpopKey.Thumbprint(crypto.SHA256)
830+
s.Require().NoError(err)
831+
cnf := map[string]string{"jkt": base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(thumbprint)}
832+
s.Require().NoError(tok.Set("cnf", cnf))
833+
signedTok, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, s.key))
834+
s.Require().NoError(err)
835+
836+
dpopToken := makeDPoPToken(s.T(), dpopTestCase{
837+
key: dpopPublic,
838+
actualSigningKey: dpopKey,
839+
accessToken: signedTok,
840+
alg: jwa.RS256,
841+
typ: "dpop+jwt",
842+
htm: http.MethodPost,
843+
htu: "/a/path",
844+
iat: time.Now(),
845+
})
846+
847+
ri := receiverInfo{u: []string{"/a/path"}, m: []string{http.MethodPost}}
848+
849+
_, _, err = s.auth.checkToken(context.Background(), []string{"DPoP " + string(signedTok)}, ri, []string{dpopToken})
850+
s.Require().NoError(err, "first presentation of the proof should succeed")
851+
852+
_, _, err = s.auth.checkToken(context.Background(), []string{"DPoP " + string(signedTok)}, ri, []string{dpopToken})
853+
s.Require().Error(err, "replaying the same proof should be rejected")
854+
s.Contains(err.Error(), "replay")
855+
}
856+
812857
// Test_ConnectAuthNInterceptor_PropagatesHTTPMethod verifies that the Connect
813858
// interceptor forwards the actual HTTP method (from req.HTTPMethod()) into
814859
// receiverInfo.m, instead of hardcoding POST. This is what makes DPoP
@@ -1027,9 +1072,10 @@ func (s *AuthSuite) Test_Allowing_Auth_With_No_DPoP() {
10271072
}
10281073
config := Config{}
10291074
config.AuthNConfig = authnConfig
1030-
auth, err := NewAuthenticator(context.Background(), config, &logger.Logger{
1031-
Logger: slog.New(slog.Default().Handler()),
1032-
},
1075+
auth, err := NewAuthenticator(
1076+
context.Background(), config, &logger.Logger{
1077+
Logger: slog.New(slog.Default().Handler()),
1078+
},
10331079
func(_ string, _ any) error { return nil },
10341080
)
10351081

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package auth
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// dpopReplayCache rejects replayed DPoP proofs by their `jti` value within the
9+
// acceptance window, per RFC 9449 §11.1. Entries expire after ttl (set to the
10+
// DPoP `iat` skew): a proof older than that window is already rejected by the
11+
// `iat` freshness check, so the `jti` need not be remembered any longer.
12+
type dpopReplayCache struct {
13+
mu sync.Mutex
14+
seen map[string]time.Time // jti -> expiry
15+
ttl time.Duration
16+
lastGC time.Time
17+
}
18+
19+
func newDPoPReplayCache(ttl time.Duration) *dpopReplayCache {
20+
return &dpopReplayCache{
21+
seen: make(map[string]time.Time),
22+
ttl: ttl,
23+
}
24+
}
25+
26+
// observe records jti and reports whether it is a replay (already seen and not
27+
// yet expired). The first time a jti is seen it returns false and is remembered
28+
// until now+ttl; any subsequent call within that window returns true.
29+
func (c *dpopReplayCache) observe(jti string, now time.Time) bool {
30+
c.mu.Lock()
31+
defer c.mu.Unlock()
32+
33+
c.gc(now)
34+
35+
if expiry, ok := c.seen[jti]; ok && expiry.After(now) {
36+
return true
37+
}
38+
c.seen[jti] = now.Add(c.ttl)
39+
return false
40+
}
41+
42+
// gc prunes expired entries to keep the map bounded. It is throttled to run at
43+
// most once per ttl so the common (cache-hot) path stays O(1). Callers must hold
44+
// c.mu.
45+
func (c *dpopReplayCache) gc(now time.Time) {
46+
if c.ttl <= 0 || now.Sub(c.lastGC) < c.ttl {
47+
return
48+
}
49+
for jti, expiry := range c.seen {
50+
if !expiry.After(now) {
51+
delete(c.seen, jti)
52+
}
53+
}
54+
c.lastGC = now
55+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package auth
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestDPoPReplayCache_FirstUseThenReplay(t *testing.T) {
12+
c := newDPoPReplayCache(time.Hour)
13+
now := time.Now()
14+
15+
require.False(t, c.observe("jti-1", now), "first use of a jti is not a replay")
16+
require.True(t, c.observe("jti-1", now), "second use of the same jti within the window is a replay")
17+
require.False(t, c.observe("jti-2", now), "a different jti is not a replay")
18+
}
19+
20+
func TestDPoPReplayCache_ExpiredEntryIsReusable(t *testing.T) {
21+
ttl := time.Minute
22+
c := newDPoPReplayCache(ttl)
23+
now := time.Now()
24+
25+
require.False(t, c.observe("jti", now))
26+
// After the TTL elapses the proof would already be rejected by the iat check,
27+
// so the jti is allowed to be re-recorded rather than reported as a replay.
28+
require.False(t, c.observe("jti", now.Add(ttl+time.Second)), "entry past its TTL should not count as a replay")
29+
}
30+
31+
func TestDPoPReplayCache_GCPrunesExpiredEntries(t *testing.T) {
32+
ttl := time.Minute
33+
c := newDPoPReplayCache(ttl)
34+
now := time.Now()
35+
36+
for _, jti := range []string{"a", "b", "c"} {
37+
require.False(t, c.observe(jti, now))
38+
}
39+
assert.Len(t, c.seen, 3)
40+
41+
// A later observe past the GC interval prunes the expired entries, leaving
42+
// only the freshly recorded one.
43+
require.False(t, c.observe("d", now.Add(ttl+time.Second)))
44+
assert.Len(t, c.seen, 1, "expired entries should be pruned, leaving only the new jti")
45+
}
46+
47+
func TestDPoPReplayCache_ZeroTTLDoesNotPanic(t *testing.T) {
48+
c := newDPoPReplayCache(0)
49+
now := time.Now()
50+
// With a non-positive TTL entries expire immediately, so nothing is ever a replay.
51+
require.False(t, c.observe("jti", now))
52+
require.False(t, c.observe("jti", now))
53+
}

0 commit comments

Comments
 (0)