refactor(auth): wrap JWT operations in transactions and migrate to clock.Now()

Wrap JTI revocation, cleanup, refresh token generation, and verification in explicit DB transactions with Begin/defer Rollback/Commit. Replace time.Now() with clock.Now() for testability.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:07 +01:00
co-authored by Sisyphus
parent da0e64c02b
commit e95b1a65af
2 changed files with 65 additions and 11 deletions
+59 -6
View File
@@ -5,9 +5,11 @@ import (
"crypto/rand"
"errors"
"fmt"
"log"
"time"
"crussell/db"
"crussell/clock"
"github.com/jackc/pgx/v5"
"github.com/go-chi/jwtauth/v5"
@@ -39,13 +41,25 @@ func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) {
if db.Conn == nil {
return
}
_, err := db.Conn.Exec(ctx,
tx, err := db.Conn.Begin(ctx)
if err != nil {
fmt.Printf("WARN: Failed to begin transaction for JTI revocation: %v\n", err)
return
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx,
`INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING`,
jti, expiresAt)
if err != nil {
// Log but don't fail - this is best effort
fmt.Printf("WARN: Failed to revoke JTI %s: %v\n", jti, err)
return
}
if err := tx.Commit(ctx); err != nil {
fmt.Printf("WARN: Failed to commit transaction for JTI revocation: %v\n", err)
}
}
@@ -72,10 +86,22 @@ func CleanupRevokedJTIs(ctx context.Context) {
if db.Conn == nil {
return
}
_, err := db.Conn.Exec(ctx,
tx, err := db.Conn.Begin(ctx)
if err != nil {
fmt.Printf("WARN: Failed to begin transaction for JTI cleanup: %v\n", err)
return
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx,
`DELETE FROM revoked_jtis WHERE expires_at < NOW()`)
if err != nil {
fmt.Printf("WARN: Failed to cleanup revoked JTIs: %v\n", err)
return
}
if err := tx.Commit(ctx); err != nil {
fmt.Printf("WARN: Failed to commit transaction for JTI cleanup: %v\n", err)
}
}
@@ -85,7 +111,14 @@ func StartJTICleanup() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for range ticker.C {
CleanupRevokedJTIs(context.Background())
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in JWT cleanup ticker: %v", r)
}
}()
CleanupRevokedJTIs(context.Background())
}()
}
}()
}
@@ -106,7 +139,7 @@ func GenerateToken(userID string, role string) (string, string, error) {
"user_id": userID,
"role": role,
"jti": jti,
"exp": time.Now().Add(1 * time.Hour).Unix(), // 1 hour
"exp": clock.Now().Add(1 * time.Hour).Unix(), // 1 hour
})
return tokenString, jti, err
}
@@ -175,12 +208,22 @@ func GenerateRefreshToken(ctx context.Context, userID string, role string) (stri
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, NOW() + INTERVAL '90 days')
RETURNING id`
tx, err := db.Conn.Begin(ctx)
if err != nil {
return "", fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
var tokenID int64
err = db.Conn.QueryRow(ctx, query, userID, token, role).Scan(&tokenID)
err = tx.QueryRow(ctx, query, userID, token, role).Scan(&tokenID)
if err != nil {
return "", fmt.Errorf("failed to store refresh token: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("failed to commit transaction: %w", err)
}
return token, nil
}
@@ -194,7 +237,13 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
AND NOT revoked
RETURNING user_id, role`
err = db.Conn.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
tx, err := db.Conn.Begin(ctx)
if err != nil {
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("invalid or expired refresh token")
@@ -202,6 +251,10 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
return "", "", fmt.Errorf("failed to verify refresh token: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
}
// Token was consumed (DELETE returned it) — this is rotation
// If a token is used twice, the second DELETE returns no rows = invalid
return userID, role, nil