fix: review round — B1 clock-skew tolerance + re-poll escalation, refresh-token access-token revocation, shared 2FA composable, per-package-DB test alignment
Three fresh reviews (money/security/dup-mod) cross-validated findings: - MEDIUM: B1 'new charge' discrimination adds a lower-bound tolerance (replayRescueLowerBoundSkew) so a retained-key replay of the ORIGINAL charge (DB clock ahead of Square) is never auto-refunded; ambiguous margins leave PENDING + CRITICAL - MEDIUM: B1 re-poll escalates after stalePendingB1RefundAge (48h) — FAILED/REJECTED refunds go terminal (fail parent, claw back till-sale funding, CRITICAL notification); no more unbounded re-polling / stranded parents without webhooks - DRIFT-REAL: processManualPaymentGroup now checks PENDING/FAILED/REJECTED on the synchronous refund response (mirrors processChargeGroup/manual handler) — no more premature 'completed' - HIGH: refresh-token family kill now also invalidates the attacker's freshly-minted ACCESS token — access tokens carry a family_id claim and VerifyToken rejects tokens whose family was deleted (GenerateTokenForFamily + family-alive check); 30s grace window for concurrent two-tab refresh (no false theft alert) - LOW: 2FA mint endpoint returns remaining_seconds; in-memory 2FA counters documented; 90-day refresh expiry single-sourced (RefreshTokenLifetime + make_interval) - Dup/mod: NEW shared useTwoFactorCodeForSavedCard Svelte composable replaces 6 surface copies of the 2FA gate logic (Request-a-new-code added to BookingFlow + TillPurchases); account page adopts generateUUID - Test architecture: removed t.Parallel() from 8 global-SquareClient-swapping tests per Testing Architecture doc line 89 (B1 flaky-test lesson) — fixes within-package race - SQL alias pence rename (total_cents/paid_cents -> total_pence/paid_pence) 26/26 backend packages; 72/72 frontend tests + build; env-docs 41/41.
This commit is contained in:
@@ -445,7 +445,7 @@ func TestRefreshToken_Success(t *testing.T) {
|
||||
|
||||
// B5: the endpoint REQUIRES the opaque refresh token in the Authorization
|
||||
// header — the access token alone can no longer self-renew.
|
||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
@@ -1432,7 +1432,7 @@ func TestRefreshToken_RotatesRefreshToken(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
@@ -1763,7 +1763,7 @@ func TestRefreshToken_Generation(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
@@ -1920,7 +1920,7 @@ func TestRefreshToken_RotatesRefreshToken_DBBacked(t *testing.T) {
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
refreshToken, _, err := auth.GenerateRefreshToken(ctx, userID, "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate refresh token: %v", err)
|
||||
}
|
||||
|
||||
@@ -451,25 +451,30 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
tokenString, jti, err := auth.GenerateToken(userID, role)
|
||||
if err != nil {
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Issue an opaque refresh token (B5): the 90-day credential is stored
|
||||
// hashed in refresh_tokens and rotated on every use. A stolen ACCESS token
|
||||
// can no longer self-renew — only a valid, unexpired, unrevoked refresh
|
||||
// token can mint a new pair. The refresh token is returned in the body so
|
||||
// the SPA can persist it and present it to POST /api/refresh-token.
|
||||
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
||||
// the SPA can persist it and present it to POST /api/refresh-token. The
|
||||
// access token is bound to the new rotation family (HIGH 1) so that if the
|
||||
// login refresh token is ever replayed the whole family — access token
|
||||
// included — is killed.
|
||||
refreshToken, familyID, err := auth.GenerateRefreshToken(r.Context(), userID, role)
|
||||
if err != nil {
|
||||
log.Printf("failed to issue refresh token for user %s: %v", userID, err)
|
||||
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the access token AFTER the refresh token so it can be bound to
|
||||
// the same rotation family.
|
||||
tokenString, jti, err := auth.GenerateTokenForFamily(userID, role, familyID)
|
||||
if err != nil {
|
||||
log.Printf("failed to generate access token for user %s: %v", userID, err)
|
||||
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
Token: tokenString,
|
||||
JTI: jti,
|
||||
@@ -528,11 +533,9 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Issue a fresh access token + refresh token pair. The rotated refresh
|
||||
// token is minted in the SAME family (familyID from VerifyRefreshToken) so
|
||||
// a replayed ancestor can revoke the whole lineage, descendants included.
|
||||
newToken, jti, err := auth.GenerateToken(userID, currentRole)
|
||||
if err != nil {
|
||||
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
|
||||
return
|
||||
}
|
||||
// The access token is bound to that same family (HIGH 1): when reuse
|
||||
// detection kills the family, the freshly-minted access token handed to the
|
||||
// attacker at rotation dies with it instead of staying valid for 1 hour.
|
||||
newRefreshToken, err := auth.GenerateRefreshTokenInFamily(r.Context(), userID, currentRole, familyID)
|
||||
if err != nil {
|
||||
log.Printf("failed to issue rotated refresh token for user %s: %v", userID, err)
|
||||
@@ -540,6 +543,14 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mint the access token AFTER the descendant refresh token so it can be
|
||||
// bound to the same rotation family.
|
||||
newToken, jti, err := auth.GenerateTokenForFamily(userID, currentRole, familyID)
|
||||
if err != nil {
|
||||
mw.RespondError(w, http.StatusInternalServerError, "could not generate token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(auth.AuthResponse{
|
||||
Token: newToken,
|
||||
JTI: jti,
|
||||
|
||||
Reference in New Issue
Block a user