@nakedev/go-scaffold 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -0,0 +1,33 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "github.com/gin-gonic/gin"
7
+ )
8
+
9
+ func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
10
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
11
+ c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
12
+ }
13
+
14
+ func (h *Handler) clearRefreshCookie(c *gin.Context) {
15
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
16
+ c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
17
+ }
18
+
19
+ // Token responses must not be retained by browsers, shared caches, or
20
+ // intermediary middleware.
21
+ func setNoStoreHeaders(c *gin.Context) {
22
+ c.Header("Cache-Control", "no-store")
23
+ c.Header("Pragma", "no-cache")
24
+ }
25
+
26
+ func (h *Handler) writeAuthResult(c *gin.Context, status int, result *authResult) {
27
+ if result.MFAChallenge != "" {
28
+ c.JSON(http.StatusOK, mfaChallengeResponse{MFARequired: true, Challenge: result.MFAChallenge})
29
+ return
30
+ }
31
+ h.setRefreshCookie(c, result.RefreshToken)
32
+ c.JSON(status, toCookieResponse(result.authResponse))
33
+ }
@@ -0,0 +1,99 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+
8
+ "{{goModule}}/internal/app/user/model"
9
+ "{{goModule}}/internal/shared/apperror"
10
+
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ // Refresh rotates a refresh token. A replayed, already-used token revokes
15
+ // every session for that user; an intentionally logged-out token is rejected
16
+ // without triggering reuse detection.
17
+ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*authResponse, error) {
18
+ hash := hashToken(rawRefreshToken)
19
+ token, ok, err := s.refreshTokens.ConsumeRefreshToken(ctx, hash)
20
+ if err != nil {
21
+ return nil, apperror.NewInternal(fmt.Errorf("consume refresh token: %w", err))
22
+ }
23
+ if !ok {
24
+ reusedBy, used, reuseErr := s.refreshTokens.IsRefreshTokenUsed(ctx, hash)
25
+ if reuseErr != nil {
26
+ return nil, apperror.NewInternal(fmt.Errorf("check refresh token reuse: %w", reuseErr))
27
+ }
28
+ if used {
29
+ if revokeErr := s.refreshTokens.RevokeAllRefreshTokens(ctx, reusedBy); revokeErr != nil {
30
+ return nil, apperror.NewInternal(fmt.Errorf("revoke sessions after refresh token reuse: %w", revokeErr))
31
+ }
32
+ }
33
+ return nil, errInvalidToken()
34
+ }
35
+
36
+ u, err := s.repo.FindByID(ctx, token.UserID)
37
+ if err != nil {
38
+ return nil, errInvalidToken()
39
+ }
40
+ return s.issueTokens(ctx, u, token.AbsoluteExpiresAt)
41
+ }
42
+
43
+ // Logout is intentional, not reuse: a missing or already-gone token still
44
+ // succeeds and leaves no tombstone.
45
+ func (s *Service) Logout(ctx context.Context, rawRefreshToken string) error {
46
+ if rawRefreshToken == "" {
47
+ return nil
48
+ }
49
+ hash := hashToken(rawRefreshToken)
50
+ userID, ok, err := s.refreshTokens.GetRefreshToken(ctx, hash)
51
+ if err != nil || !ok {
52
+ return nil
53
+ }
54
+ return s.refreshTokens.DeleteRefreshToken(ctx, hash, userID)
55
+ }
56
+
57
+ // LogoutAll ends every refresh session for the authenticated user.
58
+ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
59
+ return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
60
+ }
61
+
62
+ func (s *Service) issueTokens(ctx context.Context, u *model.User, absoluteExpiresAt ...time.Time) (*authResponse, error) {
63
+ access, err := s.issueAccessToken(
64
+ u.ID,
65
+ // go-scaffold:issue-access-token-args
66
+ )
67
+ if err != nil {
68
+ return nil, apperror.NewInternal(err)
69
+ }
70
+ refresh, err := randomToken()
71
+ if err != nil {
72
+ return nil, apperror.NewInternal(err)
73
+ }
74
+ clock := s.now
75
+ if clock == nil {
76
+ clock = time.Now
77
+ }
78
+ now := clock()
79
+ abs := now.Add(s.config.JWTRefreshMaxTTL)
80
+ if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
81
+ abs = absoluteExpiresAt[0]
82
+ }
83
+ expiresAt := now.Add(s.config.JWTRefreshTTL)
84
+ if abs.Before(expiresAt) {
85
+ expiresAt = abs
86
+ }
87
+ if !expiresAt.After(now) || !abs.After(now) {
88
+ return nil, apperror.NewInternal(fmt.Errorf("refresh token lifetime is exhausted"))
89
+ }
90
+ if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), refreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
91
+ return nil, apperror.NewInternal(err)
92
+ }
93
+ return &authResponse{
94
+ AccessToken: access,
95
+ RefreshToken: refresh,
96
+ TokenType: "Bearer",
97
+ ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
98
+ }, nil
99
+ }
@@ -4,27 +4,55 @@ import (
4
4
  "context"
5
5
  "time"
6
6
 
7
+ "{{goModule}}/internal/app/user/application"
8
+
7
9
  "github.com/google/uuid"
8
10
  )
9
11
 
10
- // tokenStore is the short-lived-token surface Service needs declared
11
- // consumer-side so it can be faked in tests, and so the backing store is a
12
- // choice rather than a hard dependency. Two implementations ship with the
13
- // scaffold and exactly one is written into a project:
14
- //
15
- // `add auth --store postgres` -> tokenstore_pg.go (default, no extra service)
16
- // `add auth --store redis` -> tokenstore_redis.go (exact across pods)
17
- //
18
- // Service never learns which one it got.
19
- type tokenStore interface {
20
- SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
12
+ // refreshTokenStore is the session surface needed by the refresh/logout use
13
+ // cases. Keeping this port narrow means a store for OAuth state or recovery
14
+ // tokens cannot accidentally become a dependency of session logic.
15
+ type RefreshTokenStore interface {
16
+ SetRefreshToken(ctx context.Context, tokenHash string, token refreshTokenRecord) error
21
17
  GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
18
+ ConsumeRefreshToken(ctx context.Context, tokenHash string) (refreshTokenRecord, bool, error)
22
19
  DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error
23
20
  RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error
24
- MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
25
21
  IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
22
+ }
23
+
24
+ // oauthTransactionStore is the one-time authorization attempt surface. It is
25
+ // intentionally separate from refresh sessions: the two have different TTL,
26
+ // replay, and operational characteristics.
27
+ type OAuthTransactionStore interface {
28
+ SetLoginTransaction(ctx context.Context, stateHash string, transaction loginTransaction) error
29
+ ConsumeLoginTransaction(ctx context.Context, stateHash string) (loginTransaction, bool, error)
30
+ }
31
+
32
+ // recoveryTokenPort is the durable one-time-token surface. Recovery owns the
33
+ // transaction boundary that consumes a token and updates the user, while the
34
+ // outer service also needs the two write methods to issue links.
35
+ type RecoveryTokenStore interface {
36
+ application.RecoveryTokens
26
37
  SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
27
- ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
28
38
  SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error
29
- ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error)
39
+ }
40
+
41
+ // refreshTokenRecord carries both the sliding inactivity expiry and the fixed
42
+ // absolute expiry. A rotation may move ExpiresAt forward, but it must never
43
+ // move AbsoluteExpiresAt forward.
44
+ type refreshTokenRecord struct {
45
+ UserID uuid.UUID `json:"user_id"`
46
+ ExpiresAt time.Time `json:"expires_at"`
47
+ AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
48
+ }
49
+
50
+ // loginTransaction is the server-owned binding for a browser authorization
51
+ // attempt. State itself is only hashed before storage; the provider receives
52
+ // the original value so the frontend callback can compare it unchanged.
53
+ type loginTransaction struct {
54
+ Provider string
55
+ CodeChallenge string
56
+ Nonce string
57
+ ExpiresAt time.Time
30
58
  }
@@ -12,6 +12,8 @@ import (
12
12
  "context"
13
13
  "database/sql"
14
14
  "errors"
15
+ "fmt"
16
+ "strings"
15
17
  "time"
16
18
 
17
19
  "{{goModule}}/internal/app/user/model"
@@ -24,25 +26,16 @@ import (
24
26
  const (
25
27
  kindRefresh = "refresh"
26
28
  kindRefreshUsed = "refresh_used"
27
- kindPasswordSet = "pwreset"
28
- kindEmailVerify = "emailverify"
29
+ kindOAuthState = "oauth_state"
29
30
  )
30
31
 
31
32
  type pgTokenStore struct {
32
- db *gorm.DB
33
+ db *gorm.DB
34
+ recovery *recoveryTokenStore
33
35
  }
34
36
 
35
- func NewPgTokenStore(db *gorm.DB) *pgTokenStore { return &pgTokenStore{db: db} }
36
-
37
- // put writes a token row, replacing any row that already carries the same
38
- // hash — a collision is astronomically unlikely, but "upsert" is the right
39
- // semantic for "this hash now means this" either way.
40
- func (s *pgTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
41
- row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: time.Now().Add(ttl)}
42
- return tx.From(ctx, s.db).WithContext(ctx).
43
- Where("token_hash = ?", hash).
44
- Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": row.ExpiresAt}).
45
- FirstOrCreate(&row).Error
37
+ func NewPgTokenStore(db *gorm.DB) *pgTokenStore {
38
+ return &pgTokenStore{db: db, recovery: newRecoveryTokenStore(db)}
46
39
  }
47
40
 
48
41
  // lookup finds a live token of the given kind. Expiry is enforced here rather
@@ -61,32 +54,59 @@ func (s *pgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID
61
54
  return row.UserID, true, nil
62
55
  }
63
56
 
64
- // consume deletes and returns in one statement, which is what makes a
65
- // one-time token actually one-time: two requests racing on the same token
66
- // cannot both come away with a user id.
67
- func (s *pgTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
68
- var userID uuid.UUID
69
- err := tx.From(ctx, s.db).WithContext(ctx).Raw(
70
- `DELETE FROM user_svc.auth_tokens
71
- WHERE token_hash = ? AND kind = ? AND expires_at > now()
72
- RETURNING user_id`, hash, kind).Row().Scan(&userID)
73
- if errors.Is(err, sql.ErrNoRows) {
74
- return uuid.Nil, false, nil
57
+ func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token refreshTokenRecord) error {
58
+ if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
59
+ return fmt.Errorf("refresh token record expiry is invalid")
75
60
  }
76
- if err != nil {
77
- return uuid.Nil, false, err
61
+ row := model.AuthToken{
62
+ TokenHash: tokenHash,
63
+ UserID: token.UserID,
64
+ Kind: kindRefresh,
65
+ ExpiresAt: token.ExpiresAt,
66
+ AbsoluteExpiresAt: &token.AbsoluteExpiresAt,
78
67
  }
79
- return userID, true, nil
80
- }
81
-
82
- func (s *pgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
83
- return s.put(ctx, tokenHash, kindRefresh, userID, ttl)
68
+ return tx.From(ctx, s.db).WithContext(ctx).
69
+ Where("token_hash = ?", tokenHash).
70
+ Assign(map[string]any{
71
+ "user_id": token.UserID,
72
+ "kind": kindRefresh,
73
+ "expires_at": token.ExpiresAt,
74
+ "absolute_expires_at": token.AbsoluteExpiresAt,
75
+ "provider": "",
76
+ "code_challenge": "",
77
+ "nonce": "",
78
+ }).FirstOrCreate(&row).Error
84
79
  }
85
80
 
86
81
  func (s *pgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
87
82
  return s.lookup(ctx, tokenHash, kindRefresh)
88
83
  }
89
84
 
85
+ // ConsumeRefreshToken removes the active row and writes the reuse-detection
86
+ // tombstone in one statement. A concurrent caller can observe either the
87
+ // active row or the tombstone, never the gap between separate operations.
88
+ func (s *pgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (refreshTokenRecord, bool, error) {
89
+ var token refreshTokenRecord
90
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
91
+ `WITH consumed AS (
92
+ DELETE FROM user_svc.auth_tokens
93
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
94
+ AND (absolute_expires_at IS NULL OR absolute_expires_at > now())
95
+ RETURNING user_id, expires_at, absolute_expires_at
96
+ )
97
+ INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, expires_at, absolute_expires_at)
98
+ SELECT ?, user_id, ?, expires_at, COALESCE(absolute_expires_at, expires_at) FROM consumed
99
+ RETURNING user_id, expires_at, absolute_expires_at`,
100
+ tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(&token.UserID, &token.ExpiresAt, &token.AbsoluteExpiresAt)
101
+ if errors.Is(err, sql.ErrNoRows) {
102
+ return refreshTokenRecord{}, false, nil
103
+ }
104
+ if err != nil {
105
+ return refreshTokenRecord{}, false, err
106
+ }
107
+ return token, true, nil
108
+ }
109
+
90
110
  // DeleteRefreshToken removes the row outright and leaves nothing behind — see
91
111
  // the AuthToken doc comment for why logout must not leave a tombstone.
92
112
  func (s *pgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
@@ -105,28 +125,73 @@ func (s *pgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.U
105
125
  Delete(&model.AuthToken{}).Error
106
126
  }
107
127
 
108
- func (s *pgTokenStore) MarkRefreshTokenUsed(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
109
- return s.put(ctx, tokenHash, kindRefreshUsed, userID, ttl)
110
- }
111
-
112
128
  func (s *pgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
113
129
  return s.lookup(ctx, tokenHash, kindRefreshUsed)
114
130
  }
115
131
 
132
+ func (s *pgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction loginTransaction) error {
133
+ if strings.TrimSpace(stateHash) == "" || strings.TrimSpace(transaction.Provider) == "" || strings.TrimSpace(transaction.CodeChallenge) == "" || strings.TrimSpace(transaction.Nonce) == "" || !transaction.ExpiresAt.After(time.Now()) {
134
+ return fmt.Errorf("oauth login transaction is invalid")
135
+ }
136
+ row := model.AuthToken{
137
+ TokenHash: stateHash,
138
+ Kind: kindOAuthState,
139
+ ExpiresAt: transaction.ExpiresAt,
140
+ Provider: transaction.Provider,
141
+ CodeChallenge: transaction.CodeChallenge,
142
+ Nonce: transaction.Nonce,
143
+ }
144
+ return tx.From(ctx, s.db).WithContext(ctx).
145
+ Where("token_hash = ?", stateHash).
146
+ Assign(map[string]any{
147
+ "user_id": uuid.Nil,
148
+ "kind": kindOAuthState,
149
+ "expires_at": transaction.ExpiresAt,
150
+ "provider": transaction.Provider,
151
+ "code_challenge": transaction.CodeChallenge,
152
+ "nonce": transaction.Nonce,
153
+ }).FirstOrCreate(&row).Error
154
+ }
155
+
156
+ func (s *pgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (loginTransaction, bool, error) {
157
+ var transaction loginTransaction
158
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
159
+ `DELETE FROM user_svc.auth_tokens
160
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
161
+ RETURNING provider, code_challenge, nonce, expires_at`,
162
+ stateHash, kindOAuthState).Row().Scan(
163
+ &transaction.Provider,
164
+ &transaction.CodeChallenge,
165
+ &transaction.Nonce,
166
+ &transaction.ExpiresAt,
167
+ )
168
+ if errors.Is(err, sql.ErrNoRows) {
169
+ return loginTransaction{}, false, nil
170
+ }
171
+ if err != nil {
172
+ return loginTransaction{}, false, err
173
+ }
174
+ return transaction, true, nil
175
+ }
176
+
116
177
  func (s *pgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
117
- return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
178
+ return s.recovery.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
118
179
  }
119
180
 
120
181
  func (s *pgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
121
- return s.consume(ctx, tokenHash, kindPasswordSet)
182
+ return s.recovery.consume(ctx, tokenHash, kindPasswordSet)
122
183
  }
123
184
 
124
185
  func (s *pgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
125
- return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
186
+ return s.recovery.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
126
187
  }
127
188
 
128
189
  func (s *pgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
129
- return s.consume(ctx, tokenHash, kindEmailVerify)
190
+ return s.recovery.consume(ctx, tokenHash, kindEmailVerify)
191
+ }
192
+
193
+ func (s *pgTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
194
+ return s.recovery.withTransaction(ctx, fn)
130
195
  }
131
196
 
132
197
  // DeleteExpired drops rows nothing can use any more. Correctness never depends
@@ -0,0 +1,96 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+
8
+ "github.com/google/uuid"
9
+ "gorm.io/driver/postgres"
10
+ "gorm.io/gorm"
11
+ )
12
+
13
+ // tokenStoreDBForTest opens a real database instead of reusing the repository
14
+ // test transaction: concurrent consumers must use separate PostgreSQL
15
+ // connections to exercise DELETE ... RETURNING's row-level serialization.
16
+ func tokenStoreDBForTest(t *testing.T) *gorm.DB {
17
+ t.Helper()
18
+ dsn := os.Getenv("TEST_DB_DSN")
19
+ if dsn == "" {
20
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
21
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
22
+ }
23
+ t.Skip("token-store integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
24
+ }
25
+
26
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
27
+ if err != nil {
28
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
29
+ t.Fatalf("open required token-store database: %v", err)
30
+ }
31
+ t.Skipf("token-store integration test skipped: %v", err)
32
+ }
33
+ sqlDB, err := db.DB()
34
+ if err != nil {
35
+ t.Fatalf("get token-store SQL handle: %v", err)
36
+ }
37
+ if err := sqlDB.Ping(); err != nil {
38
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
39
+ t.Fatalf("ping required token-store database: %v", err)
40
+ }
41
+ t.Skipf("token-store integration test skipped: %v", err)
42
+ }
43
+ t.Cleanup(func() { _ = sqlDB.Close() })
44
+ return db
45
+ }
46
+
47
+ func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
48
+ db := tokenStoreDBForTest(t)
49
+ store := NewPgTokenStore(db)
50
+ ctx := context.Background()
51
+ userID := uuid.New()
52
+ hash := "real-pg-refresh-" + uuid.NewString()
53
+ if err := store.SetRefreshToken(ctx, hash, testRefreshTokenRecord(userID)); err != nil {
54
+ t.Fatalf("seed refresh token: %v", err)
55
+ }
56
+ t.Cleanup(func() {
57
+ _ = db.Exec("DELETE FROM user_svc.auth_tokens WHERE token_hash = ?", hash).Error
58
+ })
59
+
60
+ const callers = 32
61
+ type result struct {
62
+ token refreshTokenRecord
63
+ ok bool
64
+ err error
65
+ }
66
+ start := make(chan struct{})
67
+ results := make(chan result, callers)
68
+ for i := 0; i < callers; i++ {
69
+ go func() {
70
+ <-start
71
+ token, ok, err := store.ConsumeRefreshToken(ctx, hash)
72
+ results <- result{token: token, ok: ok, err: err}
73
+ }()
74
+ }
75
+ close(start)
76
+
77
+ winners := 0
78
+ for i := 0; i < callers; i++ {
79
+ got := <-results
80
+ if got.err != nil {
81
+ t.Fatalf("concurrent consume: %v", got.err)
82
+ }
83
+ if got.ok {
84
+ winners++
85
+ if got.token.UserID != userID {
86
+ t.Fatalf("winner returned user %s, want %s", got.token.UserID, userID)
87
+ }
88
+ }
89
+ }
90
+ if winners != 1 {
91
+ t.Fatalf("expected exactly one real-database refresh winner, got %d", winners)
92
+ }
93
+ if _, ok, err := store.GetRefreshToken(ctx, hash); err != nil || ok {
94
+ t.Fatalf("consumed refresh token remained active: ok=%t err=%v", ok, err)
95
+ }
96
+ }
@@ -0,0 +1,58 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/app/user/model"
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "github.com/google/uuid"
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ const (
17
+ kindPasswordSet = "pwreset"
18
+ kindEmailVerify = "emailverify"
19
+ )
20
+
21
+ // recoveryTokenStore is deliberately Postgres-backed for both auth store
22
+ // choices. Refresh rotation may live in Redis, but password reset and email
23
+ // verification must share a durable DB transaction with the user update.
24
+ type recoveryTokenStore struct {
25
+ db *gorm.DB
26
+ }
27
+
28
+ func newRecoveryTokenStore(db *gorm.DB) *recoveryTokenStore {
29
+ return &recoveryTokenStore{db: db}
30
+ }
31
+
32
+ func (s *recoveryTokenStore) withTransaction(ctx context.Context, fn func(context.Context) error) error {
33
+ return tx.Do(ctx, s.db, fn)
34
+ }
35
+
36
+ func (s *recoveryTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
37
+ expiresAt := time.Now().Add(ttl)
38
+ row := model.AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: expiresAt}
39
+ return tx.From(ctx, s.db).WithContext(ctx).
40
+ Where("token_hash = ?", hash).
41
+ Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": expiresAt}).
42
+ FirstOrCreate(&row).Error
43
+ }
44
+
45
+ func (s *recoveryTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
46
+ var userID uuid.UUID
47
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
48
+ `DELETE FROM user_svc.auth_tokens
49
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
50
+ RETURNING user_id`, hash, kind).Row().Scan(&userID)
51
+ if errors.Is(err, sql.ErrNoRows) {
52
+ return uuid.Nil, false, nil
53
+ }
54
+ if err != nil {
55
+ return uuid.Nil, false, err
56
+ }
57
+ return userID, true, nil
58
+ }