@nakedev/go-scaffold 0.5.3 → 0.5.4

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 (43) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/auth.js +19 -12
  3. package/dist/commands/method.js +2 -0
  4. package/dist/utils/auth-patcher.js +10 -1
  5. package/dist/utils/hexagonal-method-patcher.js +12 -1
  6. package/package.json +1 -1
  7. package/templates/add/auth/docs/schemas.yaml.hbs +10 -3
  8. package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
  9. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +7 -3
  10. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
  11. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +27 -0
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +1 -0
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +231 -74
  16. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
  17. package/templates/add/auth/internal/app/user/application/errors.go.hbs +4 -0
  18. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +5 -5
  19. package/templates/add/auth/internal/app/user/application/identities.go.hbs +40 -2
  20. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +33 -0
  21. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
  22. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
  23. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
  24. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
  25. package/templates/add/auth/internal/app/user/application/service.go.hbs +12 -0
  26. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +3 -3
  27. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
  28. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
  29. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +1 -0
  30. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +1 -1
  31. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
  32. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
  33. package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
  34. package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
  35. package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
  36. package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
  37. package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
  38. package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
  39. package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
  40. package/templates/create/base/AGENTS.md.hbs +7 -1
  41. package/templates/create/base/README.md.hbs +3 -1
  42. package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
  43. package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
@@ -3,6 +3,8 @@ package postgres
3
3
  import (
4
4
  "context"
5
5
  "errors"
6
+ "sort"
7
+ "strings"
6
8
  "time"
7
9
 
8
10
  "{{goModule}}/internal/app/user/domain"
@@ -26,24 +28,52 @@ func NewRepository(db *gorm.DB) *Repository {
26
28
  }
27
29
 
28
30
  func (r *Repository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
29
- var row User
30
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "email = ?", email).Error; err != nil {
31
+ db := tx.From(ctx, r.db).WithContext(ctx)
32
+ var emailRow UserEmail
33
+ if err := db.Where("email_normalized = ?", normalizeEmail(email)).First(&emailRow).Error; err != nil {
31
34
  return nil, persistenceError(err)
32
35
  }
33
- return toDomainUser(&row), nil
36
+ return findUserWithEmail(db, emailRow.UserID, &emailRow)
34
37
  }
35
38
 
36
39
  func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
37
- var row User
38
- if err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "id = ?", id).Error; err != nil {
40
+ db := tx.From(ctx, r.db).WithContext(ctx)
41
+ var emailRow UserEmail
42
+ if err := db.Where("user_id = ? AND is_primary = true", id).First(&emailRow).Error; err != nil {
39
43
  return nil, persistenceError(err)
40
44
  }
41
- return toDomainUser(&row), nil
45
+ return findUserWithEmail(db, id, &emailRow)
42
46
  }
43
47
 
44
48
  func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
45
- row := fromDomainUser(user)
46
- return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
49
+ if user == nil || strings.TrimSpace(user.Email) == "" {
50
+ return errors.New("user email is required")
51
+ }
52
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
53
+ row := fromDomainUser(user)
54
+ if err := db.Save(&row).Error; err != nil {
55
+ return err
56
+ }
57
+
58
+ var emailRow UserEmail
59
+ err := db.Where("user_id = ? AND is_primary = true", user.ID).First(&emailRow).Error
60
+ if errors.Is(err, gorm.ErrRecordNotFound) {
61
+ emailRow = UserEmail{ID: uuid.New(), UserID: user.ID, IsPrimary: true}
62
+ } else if err != nil {
63
+ return err
64
+ }
65
+ emailRow.Email = strings.TrimSpace(user.Email)
66
+ emailRow.EmailNormalized = normalizeEmail(user.Email)
67
+ if user.EmailVerified {
68
+ if emailRow.VerifiedAt == nil {
69
+ now := time.Now()
70
+ emailRow.VerifiedAt = &now
71
+ }
72
+ } else {
73
+ emailRow.VerifiedAt = nil
74
+ }
75
+ return db.Save(&emailRow).Error
76
+ }))
47
77
  }
48
78
 
49
79
  func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
@@ -53,81 +83,137 @@ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.U
53
83
  if err != nil {
54
84
  return nil, persistenceError(err)
55
85
  }
86
+ ids := make([]uuid.UUID, len(rows))
87
+ for i := range rows {
88
+ ids[i] = rows[i].ID
89
+ }
90
+ var emailRows []UserEmail
91
+ if len(ids) > 0 {
92
+ if err := tx.From(ctx, r.db).WithContext(ctx).
93
+ Where("user_id IN ? AND is_primary = true", ids).Find(&emailRows).Error; err != nil {
94
+ return nil, persistenceError(err)
95
+ }
96
+ }
97
+ emails := make(map[uuid.UUID]*UserEmail, len(emailRows))
98
+ for i := range emailRows {
99
+ emails[emailRows[i].UserID] = &emailRows[i]
100
+ }
56
101
  items := make([]domain.User, len(rows))
57
102
  for i := range rows {
58
- items[i] = *toDomainUser(&rows[i])
103
+ items[i] = *toDomainUserWithEmail(&rows[i], emails[rows[i].ID])
59
104
  }
60
105
  return items, nil
61
106
  }
62
107
 
63
108
  func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
64
- var row Identity
65
- if err := tx.From(ctx, r.db).WithContext(ctx).
66
- First(&row, "user_id = ? AND provider = ?", userID, string(provider)).Error; err != nil {
109
+ db := tx.From(ctx, r.db).WithContext(ctx)
110
+ if provider == domain.ProviderLocal {
111
+ var row PasswordCredential
112
+ if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
113
+ return nil, persistenceError(err)
114
+ }
115
+ return toDomainPasswordIdentity(&row), nil
116
+ }
117
+ var row ExternalIdentity
118
+ if err := db.Where("user_id = ? AND provider = ?", userID, string(provider)).First(&row).Error; err != nil {
67
119
  return nil, persistenceError(err)
68
120
  }
69
- return toDomainIdentity(&row), nil
121
+ return toDomainExternalIdentity(&row), nil
70
122
  }
71
123
 
72
124
  func (r *Repository) ListIdentities(ctx context.Context, userID uuid.UUID) ([]domain.Identity, error) {
73
- var rows []Identity
74
- if err := tx.From(ctx, r.db).WithContext(ctx).
75
- Where("user_id = ?", userID).
76
- Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
125
+ db := tx.From(ctx, r.db).WithContext(ctx)
126
+ var passwordRows []PasswordCredential
127
+ if err := db.Where("user_id = ?", userID).Find(&passwordRows).Error; err != nil {
77
128
  return nil, persistenceError(err)
78
129
  }
79
- items := make([]domain.Identity, len(rows))
80
- for i := range rows {
81
- items[i] = *toDomainIdentity(&rows[i])
130
+ var externalRows []ExternalIdentity
131
+ if err := db.Where("user_id = ?", userID).Find(&externalRows).Error; err != nil {
132
+ return nil, persistenceError(err)
133
+ }
134
+ items := make([]domain.Identity, 0, len(passwordRows)+len(externalRows))
135
+ for i := range passwordRows {
136
+ items = append(items, *toDomainPasswordIdentity(&passwordRows[i]))
82
137
  }
138
+ for i := range externalRows {
139
+ items = append(items, *toDomainExternalIdentity(&externalRows[i]))
140
+ }
141
+ sort.Slice(items, func(i, j int) bool {
142
+ if items[i].CreatedAt.Equal(items[j].CreatedAt) {
143
+ return items[i].ID.String() < items[j].ID.String()
144
+ }
145
+ return items[i].CreatedAt.Before(items[j].CreatedAt)
146
+ })
83
147
  return items, nil
84
148
  }
85
149
 
86
- func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
87
- var row Identity
150
+ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, issuer, subject string) (*domain.Identity, error) {
151
+ var row ExternalIdentity
88
152
  if err := tx.From(ctx, r.db).WithContext(ctx).
89
- First(&row, "provider = ? AND provider_uid = ?", string(provider), providerUID).Error; err != nil {
153
+ First(&row, "issuer = ? AND subject = ?", issuer, subject).Error; err != nil {
90
154
  return nil, persistenceError(err)
91
155
  }
92
- return toDomainIdentity(&row), nil
156
+ return toDomainExternalIdentity(&row), nil
93
157
  }
94
158
 
95
159
  // CreateIdentity links a new login method onto an existing user.
96
160
  func (r *Repository) CreateIdentity(ctx context.Context, identity *domain.Identity) error {
97
- row := fromDomainIdentity(identity)
98
- return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Create(&row).Error)
161
+ return persistenceError(createIdentity(tx.From(ctx, r.db).WithContext(ctx), identity))
99
162
  }
100
163
 
101
164
  func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identity) error {
102
- row := fromDomainIdentity(identity)
103
- return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
165
+ if identity == nil {
166
+ return errors.New("identity is required")
167
+ }
168
+ db := tx.From(ctx, r.db).WithContext(ctx)
169
+ if identity.Provider == domain.ProviderLocal {
170
+ if identity.PasswordHash == nil {
171
+ return errors.New("local identity password is required")
172
+ }
173
+ row := PasswordCredential{ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash, HashAlgorithm: "bcrypt", PasswordChangedAt: time.Now(), CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt}
174
+ return persistenceError(db.Save(&row).Error)
175
+ }
176
+ row, err := externalIdentityFromDomain(identity)
177
+ if err != nil {
178
+ return err
179
+ }
180
+ return persistenceError(db.Save(&row).Error)
104
181
  }
105
182
 
106
183
  // DeleteIdentity locks the user's identity rows before checking the count so
107
184
  // two concurrent unlink requests cannot both remove the last login method.
108
185
  func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) error {
109
186
  return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
110
- var rows []Identity
111
- if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).
112
- Where("user_id = ?", userID).
113
- Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
187
+ var user User
188
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
114
189
  return err
115
190
  }
116
-
117
- var target *Identity
118
- for i := range rows {
119
- if rows[i].Provider == string(provider) {
120
- target = &rows[i]
121
- break
122
- }
191
+ var passwordCount, externalCount int64
192
+ if err := db.Model(&PasswordCredential{}).Where("user_id = ?", userID).Count(&passwordCount).Error; err != nil {
193
+ return err
123
194
  }
124
- if target == nil {
195
+ if err := db.Model(&ExternalIdentity{}).Where("user_id = ?", userID).Count(&externalCount).Error; err != nil {
196
+ return err
197
+ }
198
+ total := passwordCount + externalCount
199
+ if total == 0 {
125
200
  return domain.ErrNotFound
126
201
  }
127
- if len(rows) <= 1 {
202
+ if total <= 1 {
128
203
  return domain.ErrLastIdentity
129
204
  }
130
- return db.Delete(&Identity{}, "id = ? AND user_id = ?", target.ID, userID).Error
205
+ if provider == domain.ProviderLocal {
206
+ result := db.Where("user_id = ?", userID).Delete(&PasswordCredential{})
207
+ if result.Error == nil && result.RowsAffected == 0 {
208
+ return domain.ErrNotFound
209
+ }
210
+ return result.Error
211
+ }
212
+ result := db.Where("user_id = ? AND provider = ?", userID, string(provider)).Delete(&ExternalIdentity{})
213
+ if result.Error == nil && result.RowsAffected == 0 {
214
+ return domain.ErrNotFound
215
+ }
216
+ return result.Error
131
217
  }))
132
218
  }
133
219
 
@@ -135,13 +221,23 @@ func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provi
135
221
  // one transaction. A user without an identity cannot authenticate.
136
222
  func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
137
223
  userRow := fromDomainUser(user)
138
- identityRow := fromDomainIdentity(identity)
139
224
  return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
140
225
  if err := db.Create(&userRow).Error; err != nil {
141
226
  return persistenceError(err)
142
227
  }
143
- identityRow.UserID = userRow.ID
144
- return persistenceError(db.Create(&identityRow).Error)
228
+ verifiedAt := (*time.Time)(nil)
229
+ if user.EmailVerified {
230
+ now := time.Now()
231
+ verifiedAt = &now
232
+ }
233
+ if err := db.Create(&UserEmail{
234
+ ID: uuid.New(), UserID: userRow.ID, Email: strings.TrimSpace(user.Email),
235
+ EmailNormalized: normalizeEmail(user.Email), IsPrimary: true, VerifiedAt: verifiedAt,
236
+ }).Error; err != nil {
237
+ return persistenceError(err)
238
+ }
239
+ identity.UserID = userRow.ID
240
+ return persistenceError(createIdentity(db, identity))
145
241
  }))
146
242
  }
147
243
 
@@ -194,16 +290,48 @@ func persistenceError(err error) error {
194
290
  return err
195
291
  }
196
292
 
197
- func toDomainUser(row *User) *domain.User {
293
+ func findUserWithEmail(db *gorm.DB, userID uuid.UUID, emailRow *UserEmail) (*domain.User, error) {
294
+ var row User
295
+ if err := db.First(&row, "id = ?", userID).Error; err != nil {
296
+ return nil, persistenceError(err)
297
+ }
298
+ return toDomainUserWithEmail(&row, emailRow), nil
299
+ }
300
+
301
+ func normalizeEmail(email string) string {
302
+ return strings.ToLower(strings.TrimSpace(email))
303
+ }
304
+
305
+ // toDomainUser is the one-row mapping hook used by generic generated method
306
+ // extensions. It hydrates the primary email because auth stores addresses in
307
+ // user_emails rather than on the profile row.
308
+ //nolint:unused // generate method uses this seam when a custom user lookup is added.
309
+ func (r *Repository) toDomainUser(ctx context.Context, row *User) (*domain.User, error) {
310
+ var emailRow UserEmail
311
+ if err := tx.From(ctx, r.db).WithContext(ctx).
312
+ Where("user_id = ? AND is_primary = true", row.ID).
313
+ First(&emailRow).Error; err != nil {
314
+ return nil, persistenceError(err)
315
+ }
316
+ return toDomainUserWithEmail(row, &emailRow), nil
317
+ }
318
+
319
+ func toDomainUserWithEmail(row *User, emailRow *UserEmail) *domain.User {
198
320
  if row == nil {
199
321
  return nil
200
322
  }
323
+ var email string
324
+ var verified bool
325
+ if emailRow != nil {
326
+ email = emailRow.Email
327
+ verified = emailRow.VerifiedAt != nil
328
+ }
201
329
  return &domain.User{
202
330
  ID: row.ID,
203
- Email: row.Email,
331
+ Email: email,
204
332
  Name: row.Name,
205
333
  AvatarURL: row.AvatarURL,
206
- EmailVerified: row.EmailVerified,
334
+ EmailVerified: verified,
207
335
  Role: row.Role,
208
336
  CreatedAt: row.CreatedAt,
209
337
  UpdatedAt: row.UpdatedAt,
@@ -212,42 +340,71 @@ func toDomainUser(row *User) *domain.User {
212
340
 
213
341
  func fromDomainUser(user *domain.User) User {
214
342
  return User{
215
- ID: user.ID,
216
- Email: user.Email,
217
- Name: user.Name,
218
- AvatarURL: user.AvatarURL,
219
- EmailVerified: user.EmailVerified,
220
- Role: user.Role,
343
+ ID: user.ID,
344
+ Name: user.Name,
345
+ AvatarURL: user.AvatarURL,
346
+ Role: user.Role,
221
347
  CreatedAt: user.CreatedAt,
222
348
  UpdatedAt: user.UpdatedAt,
223
349
  }
224
350
  }
225
351
 
226
- func toDomainIdentity(row *Identity) *domain.Identity {
352
+ func toDomainPasswordIdentity(row *PasswordCredential) *domain.Identity {
227
353
  if row == nil {
228
354
  return nil
229
355
  }
356
+ hash := row.PasswordHash
230
357
  return &domain.Identity{
231
- ID: row.ID,
232
- UserID: row.UserID,
233
- Provider: domain.Provider(row.Provider),
234
- PasswordHash: row.PasswordHash,
235
- ProviderUID: row.ProviderUID,
236
- CreatedAt: row.CreatedAt,
237
- UpdatedAt: row.UpdatedAt,
238
- }
239
- }
240
-
241
- func fromDomainIdentity(identity *domain.Identity) Identity {
242
- return Identity{
243
- ID: identity.ID,
244
- UserID: identity.UserID,
245
- Provider: string(identity.Provider),
246
- PasswordHash: identity.PasswordHash,
247
- ProviderUID: identity.ProviderUID,
248
- CreatedAt: identity.CreatedAt,
249
- UpdatedAt: identity.UpdatedAt,
358
+ ID: row.ID, UserID: row.UserID, Provider: domain.ProviderLocal,
359
+ PasswordHash: &hash, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
360
+ }
361
+ }
362
+
363
+
364
+ func toDomainExternalIdentity(row *ExternalIdentity) *domain.Identity {
365
+ if row == nil {
366
+ return nil
367
+ }
368
+ subject := row.Subject
369
+ return &domain.Identity{
370
+ ID: row.ID, UserID: row.UserID, Provider: domain.Provider(row.Provider),
371
+ Issuer: row.Issuer, ProviderUID: &subject, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
372
+ }
373
+ }
374
+
375
+ func createIdentity(db *gorm.DB, identity *domain.Identity) error {
376
+ if identity == nil || identity.UserID == uuid.Nil || identity.ID == uuid.Nil {
377
+ return errors.New("identity id and user id are required")
378
+ }
379
+ if identity.Provider == domain.ProviderLocal {
380
+ if identity.PasswordHash == nil {
381
+ return errors.New("local identity password is required")
382
+ }
383
+ return db.Create(&PasswordCredential{
384
+ ID: identity.ID, UserID: identity.UserID, PasswordHash: *identity.PasswordHash,
385
+ HashAlgorithm: "bcrypt", PasswordChangedAt: time.Now(),
386
+ CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt,
387
+ }).Error
388
+ }
389
+ row, err := externalIdentityFromDomain(identity)
390
+ if err != nil {
391
+ return err
392
+ }
393
+ return db.Create(&row).Error
394
+ }
395
+
396
+ func externalIdentityFromDomain(identity *domain.Identity) (ExternalIdentity, error) {
397
+ if identity == nil || identity.ID == uuid.Nil || identity.UserID == uuid.Nil {
398
+ return ExternalIdentity{}, errors.New("identity id and user id are required")
399
+ }
400
+ if strings.TrimSpace(identity.Issuer) == "" || identity.ProviderUID == nil || strings.TrimSpace(*identity.ProviderUID) == "" {
401
+ return ExternalIdentity{}, errors.New("external identity issuer and subject are required")
250
402
  }
403
+ return ExternalIdentity{
404
+ ID: identity.ID, UserID: identity.UserID, Provider: string(identity.Provider),
405
+ Issuer: identity.Issuer, Subject: *identity.ProviderUID,
406
+ CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt,
407
+ }, nil
251
408
  }
252
409
 
253
410
  // go-scaffold:repository-methods
@@ -60,31 +60,34 @@ func repositoryDBForTest(t *testing.T) *gorm.DB {
60
60
  return tx
61
61
  }
62
62
 
63
+ func stringPtr(value string) *string { return &value }
64
+
63
65
  // CreateUserWithIdentity's whole reason to be a transaction: a user that
64
66
  // exists with no way to log in is unreachable. If the identity insert fails,
65
67
  // the user it was meant to arrive with must vanish too — a function-backed
66
68
  // stub can't prove this, since it never runs a real insert against a real
67
69
  // constraint. This forces exactly that: the user insert succeeds (it's a
68
- // brand new row), but the identity insert collides with an existing Google
69
- // identity's provider_uid.
70
+ // brand new row), but the identity insert collides with an existing external
71
+ // identity's issuer/subject.
70
72
  func TestRepository_CreateUserWithIdentity_RollsBackBothOnIdentityConflict(t *testing.T) {
71
73
  repo := NewRepository(repositoryDBForTest(t))
72
74
  ctx := context.Background()
73
75
 
74
- providerUID := "conflicting-provider-uid"
76
+ issuer := "https://accounts.google.com"
77
+ providerUID := "conflicting-provider-subject"
75
78
  existing := &domain.User{ID: uuid.New(), Email: "first@example.com"}
76
79
  if err := repo.CreateUserWithIdentity(ctx, existing, &domain.Identity{
77
- ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
80
+ ID: uuid.New(), Provider: domain.ProviderGoogle, Issuer: issuer, ProviderUID: &providerUID,
78
81
  }); err != nil {
79
82
  t.Fatalf("seed existing user+identity: %v", err)
80
83
  }
81
84
 
82
85
  blocked := &domain.User{ID: uuid.New(), Email: "second@example.com"}
83
86
  err := repo.CreateUserWithIdentity(ctx, blocked, &domain.Identity{
84
- ID: uuid.New(), Provider: domain.ProviderGoogle, ProviderUID: &providerUID,
87
+ ID: uuid.New(), Provider: domain.ProviderGoogle, Issuer: issuer, ProviderUID: &providerUID,
85
88
  })
86
89
  if !errors.Is(err, domain.ErrConflict) {
87
- t.Fatalf("want a mapped user conflict from the conflicting provider_uid, got %v", err)
90
+ t.Fatalf("want a mapped user conflict from the conflicting issuer/subject, got %v", err)
88
91
  }
89
92
 
90
93
  if _, findErr := repo.FindByID(ctx, blocked.ID); !errors.Is(findErr, domain.ErrNotFound) {
@@ -103,13 +106,13 @@ func TestRepository_CreateUserWithIdentity_DuplicateEmailIsDetectable(t *testing
103
106
 
104
107
  email := "dup@example.com"
105
108
  if err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
106
- ID: uuid.New(), Provider: domain.ProviderLocal,
109
+ ID: uuid.New(), Provider: domain.ProviderLocal, PasswordHash: stringPtr("hash-one"),
107
110
  }); err != nil {
108
111
  t.Fatalf("seed first user: %v", err)
109
112
  }
110
113
 
111
114
  err := repo.CreateUserWithIdentity(ctx, &domain.User{ID: uuid.New(), Email: email}, &domain.Identity{
112
- ID: uuid.New(), Provider: domain.ProviderLocal,
115
+ ID: uuid.New(), Provider: domain.ProviderLocal, PasswordHash: stringPtr("hash-two"),
113
116
  })
114
117
  if !errors.Is(err, domain.ErrConflict) {
115
118
  t.Fatalf("want a mapped user conflict for the reused email, got %v", err)
@@ -25,6 +25,10 @@ func errInvalidCredentials() error {
25
25
  return domain.Rule("AUTH_INVALID_CREDENTIALS", "invalid email or password", domain.ErrInvalidCredential)
26
26
  }
27
27
 
28
+ func errInvalidPassword() error {
29
+ return domain.Rule("AUTH_INVALID_PASSWORD", "password must be between 8 and 72 bytes", domain.ErrInvalidPassword)
30
+ }
31
+
28
32
  func errInvalidToken() error {
29
33
  return domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)
30
34
  }
@@ -183,15 +183,15 @@ func mapProviderError(err error) error {
183
183
  // provider subject wins; only a verified external email may link to an
184
184
  // existing account.
185
185
  func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIdentity) (*domain.User, error) {
186
- if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
187
- return nil, fmt.Errorf("external identity is missing provider, subject, or email")
186
+ if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Issuer) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
187
+ return nil, fmt.Errorf("external identity is missing provider, issuer, subject, or email")
188
188
  }
189
189
  if len(info.Provider) > 20 {
190
190
  return nil, fmt.Errorf("external identity provider name is too long")
191
191
  }
192
192
 
193
193
  provider := domain.Provider(info.Provider)
194
- if ident, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
194
+ if ident, err := s.repo.FindIdentityByProviderUID(ctx, info.Issuer, info.Subject); err == nil {
195
195
  user, findErr := s.repo.FindByID(ctx, ident.UserID)
196
196
  if findErr != nil {
197
197
  return nil, fmt.Errorf("find user for existing identity: %w", findErr)
@@ -205,7 +205,7 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIde
205
205
  email := normalizeEmail(info.Email)
206
206
  if info.EmailVerified {
207
207
  if u, err := s.repo.FindByEmail(ctx, email); err == nil {
208
- ident := &domain.Identity{ID: id.New(), UserID: u.ID, Provider: provider, ProviderUID: &providerUID}
208
+ ident := &domain.Identity{ID: id.New(), UserID: u.ID, Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID}
209
209
  if err := s.repo.CreateIdentity(ctx, ident); err != nil {
210
210
  return nil, fmt.Errorf("link external identity: %w", err)
211
211
  }
@@ -216,7 +216,7 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIde
216
216
  }
217
217
 
218
218
  u := &domain.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.AvatarURL, EmailVerified: info.EmailVerified, Role: domain.DefaultRole}
219
- ident := &domain.Identity{ID: id.New(), Provider: provider, ProviderUID: &providerUID}
219
+ ident := &domain.Identity{ID: id.New(), Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID}
220
220
  if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
221
221
  if errors.Is(err, domain.ErrConflict) {
222
222
  return nil, errEmailTaken()
@@ -24,6 +24,41 @@ func (s *Service) ListIdentities(ctx context.Context, userID uuid.UUID) ([]Ident
24
24
  return out, nil
25
25
  }
26
26
 
27
+ // LinkLocalIdentity adds a password credential to an already authenticated
28
+ // account. This is the supported Google/OIDC -> email/password path: it
29
+ // creates a credential for the existing user instead of creating a second
30
+ // account keyed by the same email address.
31
+ func (s *Service) LinkLocalIdentity(ctx context.Context, userID uuid.UUID, password string) error {
32
+ if userID == uuid.Nil || strings.TrimSpace(password) == "" {
33
+ return fmt.Errorf("authenticated user and password are required")
34
+ }
35
+ if err := validatePassword(password); err != nil {
36
+ return err
37
+ }
38
+ if _, err := s.repo.FindByID(ctx, userID); err != nil {
39
+ return wrapFindErr(err)
40
+ }
41
+ if _, err := s.repo.FindIdentity(ctx, userID, domain.ProviderLocal); err == nil {
42
+ return errIdentityAlreadyLinked()
43
+ } else if !errors.Is(err, domain.ErrNotFound) {
44
+ return fmt.Errorf("check local identity: %w", err)
45
+ }
46
+
47
+ hash, err := s.passwords.Hash(password)
48
+ if err != nil {
49
+ return fmt.Errorf("hash password: %w", err)
50
+ }
51
+ hashStr := string(hash)
52
+ identity := &domain.Identity{ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &hashStr}
53
+ if err := s.repo.CreateIdentity(ctx, identity); err != nil {
54
+ if errors.Is(err, domain.ErrConflict) {
55
+ return errIdentityAlreadyLinked()
56
+ }
57
+ return fmt.Errorf("link local identity: %w", err)
58
+ }
59
+ return nil
60
+ }
61
+
27
62
  func (s *Service) ExchangeIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginExchangeInput) (*IdentityResponse, error) {
28
63
  if userID == uuid.Nil {
29
64
  return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
@@ -45,7 +80,10 @@ func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, in
45
80
  // Replaying the same provider account from the same user is idempotent.
46
81
  // A provider subject already owned by another user is a conflict and must
47
82
  // never be silently moved between accounts.
48
- if existing, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
83
+ if strings.TrimSpace(info.Issuer) == "" {
84
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("external identity issuer is missing"))
85
+ }
86
+ if existing, err := s.repo.FindIdentityByProviderUID(ctx, info.Issuer, info.Subject); err == nil {
49
87
  if existing.UserID != userID {
50
88
  return nil, errIdentityConflict()
51
89
  }
@@ -65,7 +103,7 @@ func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, in
65
103
 
66
104
  providerUID := info.Subject
67
105
  identity := &domain.Identity{
68
- ID: uuid.New(), UserID: userID, Provider: provider, ProviderUID: &providerUID,
106
+ ID: uuid.New(), UserID: userID, Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID,
69
107
  }
70
108
  if err := s.repo.CreateIdentity(ctx, identity); err != nil {
71
109
  if errors.Is(err, domain.ErrConflict) {
@@ -28,6 +28,39 @@ func TestService_IdentityLinkBindsOAuthTransactionToCaller(t *testing.T) {
28
28
  }
29
29
  }
30
30
 
31
+ func TestService_LinkLocalIdentityAddsPasswordToExistingAccount(t *testing.T) {
32
+ userID := uuid.New()
33
+ repo := &fakeRepo{user: &domain.User{ID: userID, Email: "google@example.com"}}
34
+ svc := newTestService(repo, newFakeTokenStore())
35
+
36
+ if err := svc.LinkLocalIdentity(context.Background(), userID, "new-password"); err != nil {
37
+ t.Fatalf("link local identity: %v", err)
38
+ }
39
+ identity, err := repo.FindIdentity(context.Background(), userID, domain.ProviderLocal)
40
+ if err != nil {
41
+ t.Fatalf("find linked local identity: %v", err)
42
+ }
43
+ if identity.PasswordHash == nil || *identity.PasswordHash != "hashed:new-password" {
44
+ t.Fatalf("password was not hashed through the password port: %+v", identity)
45
+ }
46
+ }
47
+
48
+ func TestService_LinkLocalIdentityRejectsSecondPasswordCredential(t *testing.T) {
49
+ userID := uuid.New()
50
+ hash := "already-hashed"
51
+ repo := &fakeRepo{user: &domain.User{ID: userID, Email: "user@example.com"}}
52
+ repo.identities = append(repo.identities, domain.Identity{
53
+ ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, PasswordHash: &hash,
54
+ })
55
+ svc := newTestService(repo, newFakeTokenStore())
56
+
57
+ err := svc.LinkLocalIdentity(context.Background(), userID, "another-password")
58
+ var ruleErr *domain.RuleError
59
+ if !errors.As(err, &ruleErr) || ruleErr.Code != "AUTH_IDENTITY_ALREADY_LINKED" {
60
+ t.Fatalf("link duplicate local identity error = %v, want AUTH_IDENTITY_ALREADY_LINKED", err)
61
+ }
62
+ }
63
+
31
64
  func TestService_IdentityLinkIsIdempotentForSameProviderSubject(t *testing.T) {
32
65
  provider, svc := validFakeProvider(t)
33
66
  userID := uuid.New()
@@ -38,6 +38,9 @@ func (s *Service) throttled(ctx context.Context, key string) bool {
38
38
  }
39
39
 
40
40
  func (s *Service) Register(ctx context.Context, in RegisterInput) (*AuthResult, error) {
41
+ if err := validatePassword(in.Password); err != nil {
42
+ return nil, err
43
+ }
41
44
  hash, err := s.passwords.Hash(in.Password)
42
45
  if err != nil {
43
46
  return nil, fmt.Errorf("hash password: %w", err)
@@ -40,6 +40,7 @@ type LoginCompleteInput struct {
40
40
  // provider's stable subject identifier, never an email address.
41
41
  type ExternalIdentity struct {
42
42
  Provider string
43
+ Issuer string
43
44
  Subject string
44
45
  Email string
45
46
  EmailVerified bool
@@ -90,6 +90,7 @@ func validFakeProvider(t *testing.T) (*fakeLoginProvider, *Service) {
90
90
  }
91
91
  return ExternalIdentity{
92
92
  Provider: "fake",
93
+ Issuer: "https://provider.example.test",
93
94
  Subject: "subject-1",
94
95
  Email: "user@example.com",
95
96
  EmailVerified: true,
@@ -165,6 +166,7 @@ func TestService_ExchangeLoginRejectsIdentityFromAnotherProvider(t *testing.T) {
165
166
  provider.completeFn = func(LoginCompleteInput) (ExternalIdentity, error) {
166
167
  return ExternalIdentity{
167
168
  Provider: "another-provider",
169
+ Issuer: "https://provider.example.test",
168
170
  Subject: "subject-1",
169
171
  Email: "user@example.com",
170
172
  EmailVerified: true,