@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
@@ -1,90 +1,320 @@
1
1
  package user
2
2
 
3
3
  import (
4
- "fmt"
5
4
  "context"
6
5
  "errors"
6
+ "fmt"
7
7
  "net/http"
8
+ "sync"
8
9
  "testing"
9
10
  "time"
10
11
 
11
12
  "{{goModule}}/internal/app/user/model"
13
+ "{{goModule}}/internal/app/user/application"
12
14
  "{{goModule}}/internal/shared/apperror"
13
- "{{goModule}}/internal/shared/config"
14
15
 
15
16
  "github.com/google/uuid"
16
17
  "gorm.io/gorm"
17
18
  )
18
19
 
19
- // fakeTokenStore = in-memory mock of tokenStore, mirroring the real store's
20
+ // testTokenStore is the deliberately broad test-only composition used to
21
+ // populate the three independent token ports in Dependencies.
22
+ type testTokenStore interface {
23
+ RefreshTokenStore
24
+ OAuthTransactionStore
25
+ RecoveryTokenStore
26
+ }
27
+
28
+ // fakeTokenStore = in-memory mock of the token ports, mirroring the real store's
20
29
  // three-map shape (active / used-tombstone / per-user session set) closely
21
30
  // enough to exercise rotation + reuse-detection without a real Redis.
22
31
  type fakeTokenStore struct {
23
- active map[string]uuid.UUID
24
- used map[string]uuid.UUID
25
- sessions map[uuid.UUID]map[string]struct{}
32
+ mu sync.Mutex
33
+ txMu sync.Mutex
34
+ active map[string]refreshTokenRecord
35
+ used map[string]uuid.UUID
36
+ sessions map[uuid.UUID]map[string]struct{}
37
+ transactions map[string]loginTransaction
38
+ reset map[string]uuid.UUID
39
+ verify map[string]uuid.UUID
40
+ revokeAllErr error
26
41
  }
27
42
 
28
43
  func newFakeTokenStore() *fakeTokenStore {
29
44
  return &fakeTokenStore{
30
- active: map[string]uuid.UUID{},
31
- used: map[string]uuid.UUID{},
32
- sessions: map[uuid.UUID]map[string]struct{}{},
45
+ active: map[string]refreshTokenRecord{},
46
+ used: map[string]uuid.UUID{},
47
+ sessions: map[uuid.UUID]map[string]struct{}{},
48
+ transactions: map[string]loginTransaction{},
49
+ reset: map[string]uuid.UUID{},
50
+ verify: map[string]uuid.UUID{},
33
51
  }
34
52
  }
35
53
 
36
- func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
37
- f.active[hash] = userID
38
- if f.sessions[userID] == nil {
39
- f.sessions[userID] = map[string]struct{}{}
54
+ func (f *fakeTokenStore) SetRefreshToken(_ context.Context, hash string, token refreshTokenRecord) error {
55
+ f.mu.Lock()
56
+ defer f.mu.Unlock()
57
+ f.active[hash] = token
58
+ if f.sessions[token.UserID] == nil {
59
+ f.sessions[token.UserID] = map[string]struct{}{}
40
60
  }
41
- f.sessions[userID][hash] = struct{}{}
61
+ f.sessions[token.UserID][hash] = struct{}{}
42
62
  return nil
43
63
  }
44
64
  func (f *fakeTokenStore) GetRefreshToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
45
- id, ok := f.active[hash]
46
- return id, ok, nil
65
+ f.mu.Lock()
66
+ defer f.mu.Unlock()
67
+ token, ok := f.active[hash]
68
+ if !ok || !token.ExpiresAt.After(time.Now()) {
69
+ return uuid.Nil, false, nil
70
+ }
71
+ return token.UserID, true, nil
72
+ }
73
+
74
+ func (f *fakeTokenStore) ConsumeRefreshToken(_ context.Context, hash string) (refreshTokenRecord, bool, error) {
75
+ f.mu.Lock()
76
+ defer f.mu.Unlock()
77
+ token, ok := f.active[hash]
78
+ if !ok || !token.ExpiresAt.After(time.Now()) {
79
+ return refreshTokenRecord{}, false, nil
80
+ }
81
+ delete(f.active, hash)
82
+ delete(f.sessions[token.UserID], hash)
83
+ f.used[hash] = token.UserID
84
+ return token, true, nil
47
85
  }
86
+
48
87
  func (f *fakeTokenStore) DeleteRefreshToken(_ context.Context, hash string, userID uuid.UUID) error {
88
+ f.mu.Lock()
89
+ defer f.mu.Unlock()
49
90
  delete(f.active, hash)
50
91
  delete(f.sessions[userID], hash)
51
92
  return nil
52
93
  }
53
94
  func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.UUID) error {
95
+ f.mu.Lock()
96
+ defer f.mu.Unlock()
97
+ if f.revokeAllErr != nil {
98
+ err := f.revokeAllErr
99
+ f.revokeAllErr = nil
100
+ return err
101
+ }
54
102
  for h := range f.sessions[userID] {
55
103
  delete(f.active, h)
56
104
  }
57
105
  delete(f.sessions, userID)
58
106
  return nil
59
107
  }
60
- func (f *fakeTokenStore) MarkRefreshTokenUsed(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
61
- f.used[hash] = userID
62
- return nil
63
- }
64
108
  func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
109
+ f.mu.Lock()
110
+ defer f.mu.Unlock()
65
111
  id, ok := f.used[hash]
66
112
  return id, ok, nil
67
113
  }
68
- func (f *fakeTokenStore) SetPasswordResetToken(context.Context, string, uuid.UUID, time.Duration) error {
114
+
115
+ func (f *fakeTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
116
+ // A real DB transaction serializes the single-token DELETE against another
117
+ // consumer. Keep the fake's rollback snapshot isolated the same way so a
118
+ // losing concurrent transaction cannot restore the winner's consume.
119
+ f.txMu.Lock()
120
+ defer f.txMu.Unlock()
121
+
122
+ f.mu.Lock()
123
+ active := cloneRefreshTokenMap(f.active)
124
+ used := cloneTokenMap(f.used)
125
+ transactions := cloneLoginTransactions(f.transactions)
126
+ reset := cloneTokenMap(f.reset)
127
+ verify := cloneTokenMap(f.verify)
128
+ sessions := cloneSessions(f.sessions)
129
+ f.mu.Unlock()
130
+
131
+ err := fn(ctx)
132
+ if err == nil {
133
+ return nil
134
+ }
135
+ f.mu.Lock()
136
+ f.active, f.used, f.transactions, f.reset, f.verify, f.sessions = active, used, transactions, reset, verify, sessions
137
+ f.mu.Unlock()
138
+ return err
139
+ }
140
+
141
+ func cloneRefreshTokenMap(in map[string]refreshTokenRecord) map[string]refreshTokenRecord {
142
+ out := make(map[string]refreshTokenRecord, len(in))
143
+ for key, value := range in {
144
+ out[key] = value
145
+ }
146
+ return out
147
+ }
148
+
149
+ func cloneTokenMap(in map[string]uuid.UUID) map[string]uuid.UUID {
150
+ out := make(map[string]uuid.UUID, len(in))
151
+ for key, value := range in {
152
+ out[key] = value
153
+ }
154
+ return out
155
+ }
156
+
157
+ func cloneLoginTransactions(in map[string]loginTransaction) map[string]loginTransaction {
158
+ out := make(map[string]loginTransaction, len(in))
159
+ for key, value := range in {
160
+ out[key] = value
161
+ }
162
+ return out
163
+ }
164
+
165
+ func (f *fakeTokenStore) SetLoginTransaction(_ context.Context, hash string, transaction loginTransaction) error {
166
+ f.mu.Lock()
167
+ defer f.mu.Unlock()
168
+ f.transactions[hash] = transaction
69
169
  return nil
70
170
  }
71
- func (f *fakeTokenStore) ConsumePasswordResetToken(context.Context, string) (uuid.UUID, bool, error) {
72
- return uuid.Nil, false, nil
171
+
172
+ func (f *fakeTokenStore) ConsumeLoginTransaction(_ context.Context, hash string) (loginTransaction, bool, error) {
173
+ f.mu.Lock()
174
+ defer f.mu.Unlock()
175
+ transaction, ok := f.transactions[hash]
176
+ if !ok || !transaction.ExpiresAt.After(time.Now()) {
177
+ return loginTransaction{}, false, nil
178
+ }
179
+ delete(f.transactions, hash)
180
+ return transaction, true, nil
181
+ }
182
+
183
+ func cloneSessions(in map[uuid.UUID]map[string]struct{}) map[uuid.UUID]map[string]struct{} {
184
+ out := make(map[uuid.UUID]map[string]struct{}, len(in))
185
+ for userID, hashes := range in {
186
+ out[userID] = map[string]struct{}{}
187
+ for hash := range hashes {
188
+ out[userID][hash] = struct{}{}
189
+ }
190
+ }
191
+ return out
192
+ }
193
+
194
+ func (f *fakeTokenStore) SetPasswordResetToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
195
+ f.mu.Lock()
196
+ defer f.mu.Unlock()
197
+ f.reset[hash] = userID
198
+ return nil
199
+ }
200
+ func (f *fakeTokenStore) ConsumePasswordResetToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
201
+ f.mu.Lock()
202
+ defer f.mu.Unlock()
203
+ id, ok := f.reset[hash]
204
+ if ok {
205
+ delete(f.reset, hash)
206
+ }
207
+ return id, ok, nil
73
208
  }
74
- func (f *fakeTokenStore) SetEmailVerifyToken(context.Context, string, uuid.UUID, time.Duration) error {
209
+ func (f *fakeTokenStore) SetEmailVerifyToken(_ context.Context, hash string, userID uuid.UUID, _ time.Duration) error {
210
+ f.mu.Lock()
211
+ defer f.mu.Unlock()
212
+ f.verify[hash] = userID
75
213
  return nil
76
214
  }
77
- func (f *fakeTokenStore) ConsumeEmailVerifyToken(context.Context, string) (uuid.UUID, bool, error) {
78
- return uuid.Nil, false, nil
215
+ func (f *fakeTokenStore) ConsumeEmailVerifyToken(_ context.Context, hash string) (uuid.UUID, bool, error) {
216
+ f.mu.Lock()
217
+ defer f.mu.Unlock()
218
+ id, ok := f.verify[hash]
219
+ if ok {
220
+ delete(f.verify, hash)
221
+ }
222
+ return id, ok, nil
223
+ }
224
+
225
+ type fakeMFAStore struct {
226
+ mu sync.Mutex
227
+ enrollments map[uuid.UUID]MFAEnrollment
228
+ challenges map[string]MFAChallenge
229
+ recovery map[uuid.UUID]map[string]struct{}
230
+ }
231
+
232
+ func newFakeMFAStore() *fakeMFAStore {
233
+ return &fakeMFAStore{
234
+ enrollments: map[uuid.UUID]MFAEnrollment{},
235
+ challenges: map[string]MFAChallenge{},
236
+ recovery: map[uuid.UUID]map[string]struct{}{},
237
+ }
238
+ }
239
+
240
+ func (f *fakeMFAStore) GetEnrollment(_ context.Context, userID uuid.UUID) (MFAEnrollment, bool, error) {
241
+ f.mu.Lock()
242
+ defer f.mu.Unlock()
243
+ enrollment, ok := f.enrollments[userID]
244
+ return enrollment, ok, nil
245
+ }
246
+
247
+ func (f *fakeMFAStore) PutPendingEnrollment(_ context.Context, userID uuid.UUID, secret string) error {
248
+ f.mu.Lock()
249
+ defer f.mu.Unlock()
250
+ if current, ok := f.enrollments[userID]; ok && current.Enabled {
251
+ return errors.New("MFA already enabled")
252
+ }
253
+ f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret}
254
+ return nil
255
+ }
256
+
257
+ func (f *fakeMFAStore) ConfirmEnrollment(_ context.Context, userID uuid.UUID, secret string, hashes []string) error {
258
+ f.mu.Lock()
259
+ defer f.mu.Unlock()
260
+ if len(hashes) == 0 {
261
+ return errors.New("MFA recovery codes are required")
262
+ }
263
+ f.enrollments[userID] = MFAEnrollment{EncryptedSecret: secret, Enabled: true}
264
+ f.recovery[userID] = map[string]struct{}{}
265
+ for _, hash := range hashes {
266
+ f.recovery[userID][hash] = struct{}{}
267
+ }
268
+ return nil
269
+ }
270
+
271
+ func (f *fakeMFAStore) Disable(_ context.Context, userID uuid.UUID) error {
272
+ f.mu.Lock()
273
+ defer f.mu.Unlock()
274
+ delete(f.enrollments, userID)
275
+ delete(f.recovery, userID)
276
+ return nil
277
+ }
278
+
279
+ func (f *fakeMFAStore) CreateChallenge(_ context.Context, hash string, challenge MFAChallenge) error {
280
+ f.mu.Lock()
281
+ defer f.mu.Unlock()
282
+ f.challenges[hash] = challenge
283
+ return nil
284
+ }
285
+
286
+ func (f *fakeMFAStore) ConsumeChallenge(_ context.Context, hash string) (MFAChallenge, bool, error) {
287
+ f.mu.Lock()
288
+ defer f.mu.Unlock()
289
+ challenge, ok := f.challenges[hash]
290
+ if !ok || !challenge.ExpiresAt.After(time.Now()) {
291
+ return MFAChallenge{}, false, nil
292
+ }
293
+ delete(f.challenges, hash)
294
+ return challenge, true, nil
295
+ }
296
+
297
+ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID, hash string) (bool, error) {
298
+ f.mu.Lock()
299
+ defer f.mu.Unlock()
300
+ codes := f.recovery[userID]
301
+ if _, ok := codes[hash]; !ok {
302
+ return false, nil
303
+ }
304
+ delete(codes, hash)
305
+ return true, nil
79
306
  }
80
307
 
81
308
  // fakeRepo = mock of the repository interface — only FindByID is exercised
82
309
  // by the tests below (Refresh looks the user up after validating the
83
310
  // token), the rest just satisfy the interface.
84
311
  type fakeRepo struct {
85
- user *model.User
86
- failures map[string]int
87
- lockedUntil map[string]time.Time
312
+ user *model.User
313
+ identity *model.Identity
314
+ updateIdentityErr error
315
+ updateUserErr error
316
+ failures map[string]int
317
+ lockedUntil map[string]time.Time
88
318
  }
89
319
 
90
320
  // throttle: the fake keeps the counter in memory so the lockout path can be
@@ -125,9 +355,19 @@ func (f *fakeRepo) FindByID(_ context.Context, id uuid.UUID) (*model.User, error
125
355
  }
126
356
  return nil, gorm.ErrRecordNotFound
127
357
  }
128
- func (f *fakeRepo) UpdateUser(context.Context, *model.User) error { return nil }
358
+ func (f *fakeRepo) UpdateUser(_ context.Context, u *model.User) error {
359
+ if f.updateUserErr != nil {
360
+ return f.updateUserErr
361
+ }
362
+ f.user = u
363
+ return nil
364
+ }
129
365
  func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.User, error) { return nil, nil }
130
- func (f *fakeRepo) FindIdentity(context.Context, uuid.UUID, model.Provider) (*model.Identity, error) {
366
+ func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ model.Provider) (*model.Identity, error) {
367
+ if f.identity != nil && f.identity.UserID == userID {
368
+ copy := *f.identity
369
+ return &copy, nil
370
+ }
131
371
  return nil, gorm.ErrRecordNotFound
132
372
  }
133
373
  func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, string) (*model.Identity, error) {
@@ -135,7 +375,15 @@ func (f *fakeRepo) FindIdentityByProviderUID(context.Context, model.Provider, st
135
375
  }
136
376
  func (f *fakeRepo) CreateUserWithIdentity(context.Context, *model.User, *model.Identity) error { return nil }
137
377
  func (f *fakeRepo) CreateIdentity(context.Context, *model.Identity) error { return nil }
138
- func (f *fakeRepo) UpdateIdentity(context.Context, *model.Identity) error { return nil }
378
+ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *model.Identity) error {
379
+ if f.updateIdentityErr != nil {
380
+ return f.updateIdentityErr
381
+ }
382
+ f.identity = i
383
+ return nil
384
+ }
385
+
386
+ // go-scaffold:user-fake-repo-methods
139
387
 
140
388
  type fakeMailer struct{}
141
389
 
@@ -147,14 +395,37 @@ func (fakeMailer) Send(context.Context, string, string, string) error { return n
147
395
  // keeping it singular means `add rbac`'s marker patch only has one place to
148
396
  // add the roleChecker arg it introduces (patching every call site
149
397
  // individually would need a marker-per-callsite, easy to miss one).
150
- func newTestService(repo repository, tokens tokenStore) *Service {
151
- return NewService(
152
- repo,
153
- tokens,
154
- fakeMailer{},
155
- config.Config{JWTSecret: "test-secret", JWTAccessTTL: time.Minute, JWTRefreshTTL: time.Hour},
156
- // go-scaffold:user-service-test-args
157
- )
398
+ func newTestService(repo repository, tokens testTokenStore) *Service {
399
+ return NewService(Dependencies{
400
+ Repository: repo,
401
+ RefreshTokens: tokens,
402
+ OAuthTransactions: tokens,
403
+ RecoveryTokens: tokens,
404
+ MFA: newFakeMFAStore(),
405
+ Mailer: fakeMailer{},
406
+ Providers: application.NewProviderRegistry(),
407
+ // go-scaffold:user-service-test-deps
408
+ }, AuthConfig{
409
+ JWTSecret: "test-secret",
410
+ JWTAccessTTL: time.Minute,
411
+ JWTRefreshTTL: time.Hour,
412
+ JWTRefreshMaxTTL: 24 * time.Hour,
413
+ OAuthStateTTL: 10 * time.Minute,
414
+ })
415
+ }
416
+
417
+ func seedRefreshToken(ctx context.Context, tokens *fakeTokenStore, raw string, userID uuid.UUID, ttl time.Duration) error {
418
+ now := time.Now()
419
+ return tokens.SetRefreshToken(ctx, hashToken(raw), refreshTokenRecord{
420
+ UserID: userID,
421
+ ExpiresAt: now.Add(ttl),
422
+ AbsoluteExpiresAt: now.Add(24 * time.Hour),
423
+ })
424
+ }
425
+
426
+ func testRefreshTokenRecord(userID uuid.UUID) refreshTokenRecord {
427
+ now := time.Now()
428
+ return refreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
158
429
  }
159
430
 
160
431
  func status(t *testing.T, err error) int {
@@ -171,7 +442,7 @@ func TestService_Refresh_RotatesTheToken(t *testing.T) {
171
442
  userID := uuid.New()
172
443
  tokens := newFakeTokenStore()
173
444
  rawOld := "old-refresh-token"
174
- if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
445
+ if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
175
446
  t.Fatalf("seed: %v", err)
176
447
  }
177
448
  svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
@@ -191,16 +462,51 @@ func TestService_Refresh_RotatesTheToken(t *testing.T) {
191
462
  }
192
463
  }
193
464
 
465
+ func TestService_Refresh_ConcurrentPresentationHasOneWinner(t *testing.T) {
466
+ ctx := context.Background()
467
+ userID := uuid.New()
468
+ tokens := newFakeTokenStore()
469
+ raw := "one-refresh-token"
470
+ if err := seedRefreshToken(ctx, tokens, raw, userID, time.Hour); err != nil {
471
+ t.Fatalf("seed: %v", err)
472
+ }
473
+ svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
474
+
475
+ const callers = 32
476
+ start := make(chan struct{})
477
+ results := make(chan error, callers)
478
+ for i := 0; i < callers; i++ {
479
+ go func() {
480
+ <-start
481
+ _, err := svc.Refresh(ctx, raw)
482
+ results <- err
483
+ }()
484
+ }
485
+ close(start)
486
+
487
+ winners := 0
488
+ for i := 0; i < callers; i++ {
489
+ if err := <-results; err == nil {
490
+ winners++
491
+ } else if code(err) != "AUTH_INVALID_TOKEN" {
492
+ t.Fatalf("unexpected loser error: %v", err)
493
+ }
494
+ }
495
+ if winners != 1 {
496
+ t.Fatalf("expected exactly one refresh winner, got %d", winners)
497
+ }
498
+ }
499
+
194
500
  func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing.T) {
195
501
  ctx := context.Background()
196
502
  userID := uuid.New()
197
503
  tokens := newFakeTokenStore()
198
504
  rawOld := "old-refresh-token"
199
505
  rawOtherSession := "another-devices-refresh-token"
200
- if err := tokens.SetRefreshToken(ctx, hashToken(rawOld), userID, time.Hour); err != nil {
506
+ if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
201
507
  t.Fatalf("seed: %v", err)
202
508
  }
203
- if err := tokens.SetRefreshToken(ctx, hashToken(rawOtherSession), userID, time.Hour); err != nil {
509
+ if err := seedRefreshToken(ctx, tokens, rawOtherSession, userID, time.Hour); err != nil {
204
510
  t.Fatalf("seed: %v", err)
205
511
  }
206
512
  svc := newTestService(&fakeRepo{user: &model.User{ID: userID, Email: "a@example.com"}}, tokens)
@@ -220,12 +526,70 @@ func TestService_Refresh_ReplayingARotatedOutTokenRevokesEverySession(t *testing
220
526
  }
221
527
  }
222
528
 
529
+ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
530
+ ctx := context.Background()
531
+ userID := uuid.New()
532
+ tokens := newFakeTokenStore()
533
+ raw := "bounded-refresh-token"
534
+ issuedAt := time.Now()
535
+ absoluteExpiry := issuedAt.Add(2 * time.Hour)
536
+ if err := tokens.SetRefreshToken(ctx, hashToken(raw), refreshTokenRecord{
537
+ UserID: userID,
538
+ ExpiresAt: issuedAt.Add(time.Hour),
539
+ AbsoluteExpiresAt: absoluteExpiry,
540
+ }); err != nil {
541
+ t.Fatalf("seed: %v", err)
542
+ }
543
+ svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
544
+ rotationTime := issuedAt.Add(90 * time.Minute)
545
+ svc.now = func() time.Time { return rotationTime }
546
+
547
+ auth, err := svc.Refresh(ctx, raw)
548
+ if err != nil {
549
+ t.Fatalf("refresh: %v", err)
550
+ }
551
+ tokens.mu.Lock()
552
+ rotated := tokens.active[hashToken(auth.RefreshToken)]
553
+ tokens.mu.Unlock()
554
+ if !rotated.AbsoluteExpiresAt.Equal(absoluteExpiry) {
555
+ t.Fatalf("absolute expiry moved during rotation: got %s want %s", rotated.AbsoluteExpiresAt, absoluteExpiry)
556
+ }
557
+ if !rotated.ExpiresAt.Equal(absoluteExpiry) {
558
+ t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
559
+ }
560
+ }
561
+
562
+ func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
563
+ ctx := context.Background()
564
+ userID := uuid.New()
565
+ tokens := newFakeTokenStore()
566
+ rawOld := "replayed-refresh-token"
567
+ rawOther := "still-active-refresh-token"
568
+ if err := seedRefreshToken(ctx, tokens, rawOld, userID, time.Hour); err != nil {
569
+ t.Fatalf("seed old token: %v", err)
570
+ }
571
+ if err := seedRefreshToken(ctx, tokens, rawOther, userID, time.Hour); err != nil {
572
+ t.Fatalf("seed other token: %v", err)
573
+ }
574
+ svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
575
+ if _, err := svc.Refresh(ctx, rawOld); err != nil {
576
+ t.Fatalf("initial rotation: %v", err)
577
+ }
578
+ tokens.revokeAllErr = errors.New("session store unavailable")
579
+ if _, err := svc.Refresh(ctx, rawOld); status(t, err) != http.StatusInternalServerError {
580
+ t.Fatalf("reuse with failed revoke must fail closed with 500, got %v", err)
581
+ }
582
+ if _, ok, _ := tokens.GetRefreshToken(ctx, hashToken(rawOther)); !ok {
583
+ t.Fatal("failed revoke must not be reported as a completed session revocation")
584
+ }
585
+ }
586
+
223
587
  func TestService_Refresh_UnknownTokenIsRejectedWithoutRevoking(t *testing.T) {
224
588
  ctx := context.Background()
225
589
  userID := uuid.New()
226
590
  tokens := newFakeTokenStore()
227
591
  rawLegit := "a-legit-session-token"
228
- if err := tokens.SetRefreshToken(ctx, hashToken(rawLegit), userID, time.Hour); err != nil {
592
+ if err := seedRefreshToken(ctx, tokens, rawLegit, userID, time.Hour); err != nil {
229
593
  t.Fatalf("seed: %v", err)
230
594
  }
231
595
  svc := newTestService(&fakeRepo{user: &model.User{ID: userID}}, tokens)
@@ -248,7 +612,7 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
248
612
  rawB := "user-session-b"
249
613
  rawOther := "other-users-session"
250
614
  for raw, id := range map[string]uuid.UUID{rawA: userID, rawB: userID, rawOther: otherUserID} {
251
- if err := tokens.SetRefreshToken(ctx, hashToken(raw), id, time.Hour); err != nil {
615
+ if err := seedRefreshToken(ctx, tokens, raw, id, time.Hour); err != nil {
252
616
  t.Fatalf("seed: %v", err)
253
617
  }
254
618
  }
@@ -268,6 +632,198 @@ func TestService_LogoutAll_RevokesEverySessionButLeavesOthersAlone(t *testing.T)
268
632
  }
269
633
  }
270
634
 
635
+ func TestService_ResetPassword_DBFailureAllowsRetry(t *testing.T) {
636
+ ctx := context.Background()
637
+ userID := uuid.New()
638
+ passwordHash := "old-hash"
639
+ repo := &fakeRepo{
640
+ user: &model.User{ID: userID},
641
+ identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
642
+ updateIdentityErr: errors.New("database unavailable"),
643
+ }
644
+ tokens := newFakeTokenStore()
645
+ raw := "reset-token"
646
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
647
+ t.Fatalf("seed: %v", err)
648
+ }
649
+ svc := newTestService(repo, tokens)
650
+
651
+ if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
652
+ t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
653
+ }
654
+ repo.updateIdentityErr = nil
655
+ if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
656
+ t.Fatalf("retry should succeed after the transaction rollback: %v", err)
657
+ }
658
+ }
659
+
660
+ func TestService_ResetPassword_SessionRevokeFailureRestoresTokenForRetry(t *testing.T) {
661
+ ctx := context.Background()
662
+ userID := uuid.New()
663
+ passwordHash := "old-hash"
664
+ repo := &fakeRepo{
665
+ user: &model.User{ID: userID},
666
+ identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
667
+ }
668
+ tokens := newFakeTokenStore()
669
+ raw := "reset-token-session-store-outage"
670
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
671
+ t.Fatalf("seed: %v", err)
672
+ }
673
+ tokens.revokeAllErr = errors.New("redis unavailable")
674
+ svc := newTestService(repo, tokens)
675
+
676
+ if err := svc.ResetPassword(ctx, raw, "new-password"); status(t, err) != http.StatusInternalServerError {
677
+ t.Fatalf("expected a 500 while the session store is unavailable, got %v", err)
678
+ }
679
+ if _, ok := tokens.reset[hashToken(raw)]; !ok {
680
+ t.Fatal("expected the reset token to be restored so the caller can retry")
681
+ }
682
+ if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
683
+ t.Fatalf("retry should complete the password reset and session revoke: %v", err)
684
+ }
685
+ if _, ok := tokens.reset[hashToken(raw)]; ok {
686
+ t.Fatal("expected the reset token to be consumed after the retry succeeds")
687
+ }
688
+ }
689
+
690
+ func TestService_VerifyEmail_DBFailureAllowsRetry(t *testing.T) {
691
+ ctx := context.Background()
692
+ userID := uuid.New()
693
+ repo := &fakeRepo{
694
+ user: &model.User{ID: userID},
695
+ updateUserErr: errors.New("database unavailable"),
696
+ }
697
+ tokens := newFakeTokenStore()
698
+ raw := "verify-token"
699
+ if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
700
+ t.Fatalf("seed: %v", err)
701
+ }
702
+ svc := newTestService(repo, tokens)
703
+
704
+ if err := svc.VerifyEmail(ctx, raw); status(t, err) != http.StatusInternalServerError {
705
+ t.Fatalf("expected a 500 on the simulated DB failure, got %v", err)
706
+ }
707
+ repo.updateUserErr = nil
708
+ if err := svc.VerifyEmail(ctx, raw); err != nil {
709
+ t.Fatalf("retry should succeed after the transaction rollback: %v", err)
710
+ }
711
+ }
712
+
713
+ func TestService_ResetPassword_DuplicateTokenIsRejected(t *testing.T) {
714
+ ctx := context.Background()
715
+ userID := uuid.New()
716
+ passwordHash := "old-hash"
717
+ repo := &fakeRepo{
718
+ user: &model.User{ID: userID},
719
+ identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
720
+ }
721
+ tokens := newFakeTokenStore()
722
+ raw := "one-time-reset-token"
723
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
724
+ t.Fatalf("seed: %v", err)
725
+ }
726
+ svc := newTestService(repo, tokens)
727
+ if err := svc.ResetPassword(ctx, raw, "new-password"); err != nil {
728
+ t.Fatalf("first use: %v", err)
729
+ }
730
+ if err := svc.ResetPassword(ctx, raw, "another-password"); code(err) != "AUTH_INVALID_TOKEN" {
731
+ t.Fatalf("second use should be rejected as invalid, got %v", err)
732
+ }
733
+ }
734
+
735
+ func TestService_ResetPassword_ConcurrentPresentationHasOneWinner(t *testing.T) {
736
+ ctx := context.Background()
737
+ userID := uuid.New()
738
+ passwordHash := "old-hash"
739
+ repo := &fakeRepo{
740
+ user: &model.User{ID: userID},
741
+ identity: &model.Identity{ID: uuid.New(), UserID: userID, Provider: model.ProviderLocal, PasswordHash: &passwordHash},
742
+ }
743
+ tokens := newFakeTokenStore()
744
+ raw := "concurrent-reset-token"
745
+ if err := tokens.SetPasswordResetToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
746
+ t.Fatalf("seed: %v", err)
747
+ }
748
+ svc := newTestService(repo, tokens)
749
+
750
+ const callers = 8
751
+ start := make(chan struct{})
752
+ results := make(chan error, callers)
753
+ for i := 0; i < callers; i++ {
754
+ go func() {
755
+ <-start
756
+ results <- svc.ResetPassword(ctx, raw, "new-password")
757
+ }()
758
+ }
759
+ close(start)
760
+
761
+ winners := 0
762
+ for i := 0; i < callers; i++ {
763
+ if err := <-results; err == nil {
764
+ winners++
765
+ } else if code(err) != "AUTH_INVALID_TOKEN" {
766
+ t.Fatalf("unexpected loser error: %v", err)
767
+ }
768
+ }
769
+ if winners != 1 {
770
+ t.Fatalf("expected exactly one reset winner, got %d", winners)
771
+ }
772
+ }
773
+
774
+ func TestService_VerifyEmail_DuplicateTokenIsRejected(t *testing.T) {
775
+ ctx := context.Background()
776
+ userID := uuid.New()
777
+ repo := &fakeRepo{user: &model.User{ID: userID}}
778
+ tokens := newFakeTokenStore()
779
+ raw := "one-time-verification-token"
780
+ if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
781
+ t.Fatalf("seed: %v", err)
782
+ }
783
+ svc := newTestService(repo, tokens)
784
+ if err := svc.VerifyEmail(ctx, raw); err != nil {
785
+ t.Fatalf("first use: %v", err)
786
+ }
787
+ if err := svc.VerifyEmail(ctx, raw); code(err) != "AUTH_INVALID_TOKEN" {
788
+ t.Fatalf("second use should be rejected as invalid, got %v", err)
789
+ }
790
+ }
791
+
792
+ func TestService_VerifyEmail_ConcurrentPresentationHasOneWinner(t *testing.T) {
793
+ ctx := context.Background()
794
+ userID := uuid.New()
795
+ repo := &fakeRepo{user: &model.User{ID: userID}}
796
+ tokens := newFakeTokenStore()
797
+ raw := "concurrent-verification-token"
798
+ if err := tokens.SetEmailVerifyToken(ctx, hashToken(raw), userID, time.Hour); err != nil {
799
+ t.Fatalf("seed: %v", err)
800
+ }
801
+ svc := newTestService(repo, tokens)
802
+
803
+ const callers = 32
804
+ start := make(chan struct{})
805
+ results := make(chan error, callers)
806
+ for i := 0; i < callers; i++ {
807
+ go func() {
808
+ <-start
809
+ results <- svc.VerifyEmail(ctx, raw)
810
+ }()
811
+ }
812
+ close(start)
813
+
814
+ winners := 0
815
+ for i := 0; i < callers; i++ {
816
+ if err := <-results; err == nil {
817
+ winners++
818
+ } else if code(err) != "AUTH_INVALID_TOKEN" {
819
+ t.Fatalf("unexpected loser error: %v", err)
820
+ }
821
+ }
822
+ if winners != 1 {
823
+ t.Fatalf("expected exactly one verification winner, got %d", winners)
824
+ }
825
+ }
826
+
271
827
  // The control that actually stops credential stuffing: the counter follows the
272
828
  // account, so spreading attempts across a proxy pool doesn't help.
273
829
  func TestService_Login_LocksTheAccountAfterRepeatedFailures(t *testing.T) {