@nakedev/go-scaffold 0.5.2 → 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 (53) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/auth.js +25 -13
  3. package/dist/commands/method.js +2 -0
  4. package/dist/templates/auth-manifest.js +3 -0
  5. package/dist/utils/auth-patcher.js +10 -1
  6. package/dist/utils/hexagonal-method-patcher.js +12 -1
  7. package/package.json +1 -1
  8. package/templates/add/auth/docs/schemas.yaml.hbs +45 -3
  9. package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
  10. package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
  11. package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
  12. package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
  13. package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
  14. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +45 -3
  15. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
  16. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +107 -0
  17. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +21 -0
  18. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
  19. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
  20. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +253 -53
  21. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
  22. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +4 -2
  23. package/templates/add/auth/internal/app/user/application/dto.go.hbs +14 -0
  24. package/templates/add/auth/internal/app/user/application/errors.go.hbs +20 -0
  25. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +47 -18
  26. package/templates/add/auth/internal/app/user/application/identities.go.hbs +135 -0
  27. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +102 -0
  28. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
  29. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
  30. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
  31. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
  32. package/templates/add/auth/internal/app/user/application/service.go.hbs +16 -0
  33. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
  34. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
  35. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
  36. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -1
  37. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +4 -1
  38. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
  39. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
  40. package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
  41. package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
  42. package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
  43. package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
  44. package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
  45. package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
  46. package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
  47. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
  48. package/templates/create/base/AGENTS.md.hbs +14 -0
  49. package/templates/create/base/README.md.hbs +7 -0
  50. package/templates/create/features/docs/architecture.md.hbs +5 -5
  51. package/templates/create/features/docs/techstack.md.hbs +1 -1
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
@@ -9,29 +9,52 @@ import (
9
9
  )
10
10
 
11
11
  type User struct {
12
- ID uuid.UUID `gorm:"type:uuid;primaryKey"`
13
- Email string `gorm:"uniqueIndex:idx_users_email;not null"`
14
- Name string
15
- AvatarURL string
16
- EmailVerified bool
17
- Role string `gorm:"type:varchar(20);not null;default:'staff'"`
18
- CreatedAt time.Time
19
- UpdatedAt time.Time
12
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
13
+ Name string
14
+ AvatarURL string
15
+ Role string `gorm:"type:varchar(20);not null;default:'staff'"`
16
+ CreatedAt time.Time
17
+ UpdatedAt time.Time
20
18
  }
21
19
 
22
20
  func (User) TableName() string { return "user_svc.users" }
23
21
 
24
- type Identity struct {
25
- ID uuid.UUID `gorm:"type:uuid;primaryKey"`
26
- UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_identities_user_provider,priority:1"`
27
- Provider string `gorm:"type:varchar(20);not null;uniqueIndex:idx_identities_user_provider,priority:2;uniqueIndex:idx_identities_provider_uid,priority:1"`
28
- PasswordHash *string
29
- ProviderUID *string `gorm:"column:provider_uid;uniqueIndex:idx_identities_provider_uid,priority:2"`
30
- CreatedAt time.Time
31
- UpdatedAt time.Time
22
+ type UserEmail struct {
23
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
24
+ UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_user_emails_user;uniqueIndex:idx_user_emails_primary,where:is_primary"`
25
+ Email string `gorm:"type:varchar(255);not null"`
26
+ EmailNormalized string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_emails_normalized"`
27
+ IsPrimary bool `gorm:"not null;default:true"`
28
+ VerifiedAt *time.Time
29
+ CreatedAt time.Time
30
+ UpdatedAt time.Time
31
+ }
32
+
33
+ func (UserEmail) TableName() string { return "user_svc.user_emails" }
34
+
35
+ type PasswordCredential struct {
36
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
37
+ UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_password_credentials_user"`
38
+ PasswordHash string `gorm:"column:password_hash;type:text;not null"`
39
+ HashAlgorithm string `gorm:"type:varchar(32);not null;default:'bcrypt'"`
40
+ PasswordChangedAt time.Time `gorm:"not null"`
41
+ CreatedAt time.Time
42
+ UpdatedAt time.Time
43
+ }
44
+
45
+ func (PasswordCredential) TableName() string { return "user_svc.password_credentials" }
46
+
47
+ type ExternalIdentity struct {
48
+ ID uuid.UUID `gorm:"type:uuid;primaryKey"`
49
+ UserID uuid.UUID `gorm:"type:uuid;not null"`
50
+ Provider string `gorm:"type:varchar(50);not null;uniqueIndex:idx_external_identities_user_provider,priority:2"`
51
+ Issuer string `gorm:"type:text;not null;uniqueIndex:idx_external_identities_issuer_subject,priority:1"`
52
+ Subject string `gorm:"type:text;not null;uniqueIndex:idx_external_identities_issuer_subject,priority:2"`
53
+ CreatedAt time.Time
54
+ UpdatedAt time.Time
32
55
  }
33
56
 
34
- func (Identity) TableName() string { return "user_svc.identities" }
57
+ func (ExternalIdentity) TableName() string { return "user_svc.external_identities" }
35
58
 
36
59
  type AuthToken struct {
37
60
  TokenHash string `gorm:"primaryKey;type:text"`
@@ -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"
@@ -12,6 +14,7 @@ import (
12
14
 
13
15
  "github.com/google/uuid"
14
16
  "gorm.io/gorm"
17
+ "gorm.io/gorm/clause"
15
18
  )
16
19
 
17
20
  type Repository struct {
@@ -25,24 +28,52 @@ func NewRepository(db *gorm.DB) *Repository {
25
28
  }
26
29
 
27
30
  func (r *Repository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
28
- var row User
29
- 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 {
30
34
  return nil, persistenceError(err)
31
35
  }
32
- return toDomainUser(&row), nil
36
+ return findUserWithEmail(db, emailRow.UserID, &emailRow)
33
37
  }
34
38
 
35
39
  func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
36
- var row User
37
- 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 {
38
43
  return nil, persistenceError(err)
39
44
  }
40
- return toDomainUser(&row), nil
45
+ return findUserWithEmail(db, id, &emailRow)
41
46
  }
42
47
 
43
48
  func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
44
- row := fromDomainUser(user)
45
- 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
+ }))
46
77
  }
47
78
 
48
79
  func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.User, error) {
@@ -52,53 +83,161 @@ func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.U
52
83
  if err != nil {
53
84
  return nil, persistenceError(err)
54
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
+ }
55
101
  items := make([]domain.User, len(rows))
56
102
  for i := range rows {
57
- items[i] = *toDomainUser(&rows[i])
103
+ items[i] = *toDomainUserWithEmail(&rows[i], emails[rows[i].ID])
58
104
  }
59
105
  return items, nil
60
106
  }
61
107
 
62
108
  func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
63
- var row Identity
64
- if err := tx.From(ctx, r.db).WithContext(ctx).
65
- 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 {
66
119
  return nil, persistenceError(err)
67
120
  }
68
- return toDomainIdentity(&row), nil
121
+ return toDomainExternalIdentity(&row), nil
69
122
  }
70
123
 
71
- func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
72
- var row Identity
124
+ func (r *Repository) ListIdentities(ctx context.Context, userID uuid.UUID) ([]domain.Identity, error) {
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 {
128
+ return nil, persistenceError(err)
129
+ }
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]))
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
+ })
147
+ return items, nil
148
+ }
149
+
150
+ func (r *Repository) FindIdentityByProviderUID(ctx context.Context, issuer, subject string) (*domain.Identity, error) {
151
+ var row ExternalIdentity
73
152
  if err := tx.From(ctx, r.db).WithContext(ctx).
74
- First(&row, "provider = ? AND provider_uid = ?", string(provider), providerUID).Error; err != nil {
153
+ First(&row, "issuer = ? AND subject = ?", issuer, subject).Error; err != nil {
75
154
  return nil, persistenceError(err)
76
155
  }
77
- return toDomainIdentity(&row), nil
156
+ return toDomainExternalIdentity(&row), nil
78
157
  }
79
158
 
80
159
  // CreateIdentity links a new login method onto an existing user.
81
160
  func (r *Repository) CreateIdentity(ctx context.Context, identity *domain.Identity) error {
82
- row := fromDomainIdentity(identity)
83
- return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Create(&row).Error)
161
+ return persistenceError(createIdentity(tx.From(ctx, r.db).WithContext(ctx), identity))
84
162
  }
85
163
 
86
164
  func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identity) error {
87
- row := fromDomainIdentity(identity)
88
- 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)
181
+ }
182
+
183
+ // DeleteIdentity locks the user's identity rows before checking the count so
184
+ // two concurrent unlink requests cannot both remove the last login method.
185
+ func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) error {
186
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
187
+ var user User
188
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, "id = ?", userID).Error; err != nil {
189
+ return err
190
+ }
191
+ var passwordCount, externalCount int64
192
+ if err := db.Model(&PasswordCredential{}).Where("user_id = ?", userID).Count(&passwordCount).Error; err != nil {
193
+ return err
194
+ }
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 {
200
+ return domain.ErrNotFound
201
+ }
202
+ if total <= 1 {
203
+ return domain.ErrLastIdentity
204
+ }
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
217
+ }))
89
218
  }
90
219
 
91
220
  // CreateUserWithIdentity inserts the profile and its first login method in
92
221
  // one transaction. A user without an identity cannot authenticate.
93
222
  func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
94
223
  userRow := fromDomainUser(user)
95
- identityRow := fromDomainIdentity(identity)
96
224
  return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
97
225
  if err := db.Create(&userRow).Error; err != nil {
98
226
  return persistenceError(err)
99
227
  }
100
- identityRow.UserID = userRow.ID
101
- 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))
102
241
  }))
103
242
  }
104
243
 
@@ -151,16 +290,48 @@ func persistenceError(err error) error {
151
290
  return err
152
291
  }
153
292
 
154
- 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 {
155
320
  if row == nil {
156
321
  return nil
157
322
  }
323
+ var email string
324
+ var verified bool
325
+ if emailRow != nil {
326
+ email = emailRow.Email
327
+ verified = emailRow.VerifiedAt != nil
328
+ }
158
329
  return &domain.User{
159
330
  ID: row.ID,
160
- Email: row.Email,
331
+ Email: email,
161
332
  Name: row.Name,
162
333
  AvatarURL: row.AvatarURL,
163
- EmailVerified: row.EmailVerified,
334
+ EmailVerified: verified,
164
335
  Role: row.Role,
165
336
  CreatedAt: row.CreatedAt,
166
337
  UpdatedAt: row.UpdatedAt,
@@ -169,42 +340,71 @@ func toDomainUser(row *User) *domain.User {
169
340
 
170
341
  func fromDomainUser(user *domain.User) User {
171
342
  return User{
172
- ID: user.ID,
173
- Email: user.Email,
174
- Name: user.Name,
175
- AvatarURL: user.AvatarURL,
176
- EmailVerified: user.EmailVerified,
177
- Role: user.Role,
343
+ ID: user.ID,
344
+ Name: user.Name,
345
+ AvatarURL: user.AvatarURL,
346
+ Role: user.Role,
178
347
  CreatedAt: user.CreatedAt,
179
348
  UpdatedAt: user.UpdatedAt,
180
349
  }
181
350
  }
182
351
 
183
- func toDomainIdentity(row *Identity) *domain.Identity {
352
+ func toDomainPasswordIdentity(row *PasswordCredential) *domain.Identity {
184
353
  if row == nil {
185
354
  return nil
186
355
  }
356
+ hash := row.PasswordHash
187
357
  return &domain.Identity{
188
- ID: row.ID,
189
- UserID: row.UserID,
190
- Provider: domain.Provider(row.Provider),
191
- PasswordHash: row.PasswordHash,
192
- ProviderUID: row.ProviderUID,
193
- CreatedAt: row.CreatedAt,
194
- UpdatedAt: row.UpdatedAt,
195
- }
196
- }
197
-
198
- func fromDomainIdentity(identity *domain.Identity) Identity {
199
- return Identity{
200
- ID: identity.ID,
201
- UserID: identity.UserID,
202
- Provider: string(identity.Provider),
203
- PasswordHash: identity.PasswordHash,
204
- ProviderUID: identity.ProviderUID,
205
- CreatedAt: identity.CreatedAt,
206
- 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")
207
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
208
408
  }
209
409
 
210
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)
@@ -176,6 +176,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
176
176
  }
177
177
  row := AuthToken{
178
178
  TokenHash: stateHash,
179
+ UserID: transaction.UserID,
179
180
  Kind: kindOAuthState,
180
181
  ExpiresAt: transaction.ExpiresAt,
181
182
  Provider: transaction.Provider,
@@ -185,7 +186,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
185
186
  return tx.From(ctx, s.db).WithContext(ctx).
186
187
  Where("token_hash = ?", stateHash).
187
188
  Assign(map[string]any{
188
- "user_id": uuid.Nil,
189
+ "user_id": transaction.UserID,
189
190
  "kind": kindOAuthState,
190
191
  "expires_at": transaction.ExpiresAt,
191
192
  "provider": transaction.Provider,
@@ -199,8 +200,9 @@ func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash st
199
200
  err := tx.From(ctx, s.db).WithContext(ctx).Raw(
200
201
  `DELETE FROM user_svc.auth_tokens
201
202
  WHERE token_hash = ? AND kind = ? AND expires_at > now()
202
- RETURNING provider, code_challenge, nonce, expires_at`,
203
+ RETURNING user_id, provider, code_challenge, nonce, expires_at`,
203
204
  stateHash, kindOAuthState).Row().Scan(
205
+ &transaction.UserID,
204
206
  &transaction.Provider,
205
207
  &transaction.CodeChallenge,
206
208
  &transaction.Nonce,
@@ -43,6 +43,20 @@ type Session struct {
43
43
  Current bool
44
44
  }
45
45
 
46
+ // IdentityResponse is the safe public view of a login identity. Provider
47
+ // subjects and password hashes never cross the application/HTTP boundary.
48
+ type IdentityResponse struct {
49
+ ID uuid.UUID
50
+ Provider string
51
+ CreatedAt time.Time
52
+ }
53
+
54
+ func ToIdentityResponse(identity domain.Identity) IdentityResponse {
55
+ return IdentityResponse{
56
+ ID: identity.ID, Provider: string(identity.Provider), CreatedAt: identity.CreatedAt,
57
+ }
58
+ }
59
+
46
60
  type AuthResponse struct {
47
61
  AccessToken string
48
62
  RefreshToken string
@@ -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
  }
@@ -60,3 +64,19 @@ func errMFAConfig() error {
60
64
  func errUnknownRole() error {
61
65
  return domain.Rule("USER_UNKNOWN_ROLE", "unknown role code", domain.ErrUnknownRole)
62
66
  }
67
+
68
+ func errIdentityAlreadyLinked() error {
69
+ return domain.Rule("AUTH_IDENTITY_ALREADY_LINKED", "this login method is already linked", domain.ErrIdentityAlreadyLinked)
70
+ }
71
+
72
+ func errIdentityConflict() error {
73
+ return domain.Rule("AUTH_IDENTITY_CONFLICT", "this provider account is linked to another user", domain.ErrConflict)
74
+ }
75
+
76
+ func errIdentityNotFound() error {
77
+ return domain.Rule("AUTH_IDENTITY_NOT_FOUND", "login method not found", domain.ErrNotFound)
78
+ }
79
+
80
+ func errLastIdentity() error {
81
+ return domain.Rule("AUTH_LAST_IDENTITY", "you cannot remove your last login method", domain.ErrLastIdentity)
82
+ }