@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
@@ -12,6 +12,8 @@ import (
12
12
 
13
13
  "{{goModule}}/internal/app/user/domain"
14
14
  "{{goModule}}/internal/shared/id"
15
+
16
+ "github.com/google/uuid"
15
17
  )
16
18
 
17
19
  // BeginLogin accepts the browser client's state and S256 PKCE challenge and
@@ -19,6 +21,21 @@ import (
19
21
  // The server persists a hashed state transaction binding provider, challenge,
20
22
  // and OIDC nonce before the callback reaches ExchangeLogin.
21
23
  func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginStartInput) (*Authorization, error) {
24
+ return s.beginProviderLogin(ctx, providerName, in, uuid.Nil)
25
+ }
26
+
27
+ // BeginIdentityLink starts an OAuth transaction that is bound to the
28
+ // authenticated user. It is deliberately separate from BeginLogin: the
29
+ // public login flow may resolve any user, while a link flow must never be
30
+ // allowed to attach an identity to whichever account the provider returns.
31
+ func (s *Service) BeginIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginStartInput) (*Authorization, error) {
32
+ if userID == uuid.Nil {
33
+ return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
34
+ }
35
+ return s.beginProviderLogin(ctx, providerName, in, userID)
36
+ }
37
+
38
+ func (s *Service) beginProviderLogin(ctx context.Context, providerName string, in LoginStartInput, userID uuid.UUID) (*Authorization, error) {
22
39
  provider, ok := s.providers.Lookup(providerName)
23
40
  if !ok {
24
41
  return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
@@ -46,6 +63,7 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginS
46
63
  }
47
64
  if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State), LoginTransaction{
48
65
  Provider: providerName,
66
+ UserID: userID,
49
67
  CodeChallenge: in.CodeChallenge,
50
68
  Nonce: nonce,
51
69
  ExpiresAt: clock().Add(s.config.OAuthStateTTL),
@@ -56,37 +74,48 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginS
56
74
  }
57
75
 
58
76
  func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in LoginExchangeInput) (*AuthResult, error) {
77
+ identity, err := s.exchangeExternalIdentity(ctx, providerName, in, uuid.Nil)
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ u, err := s.findOrCreateExternalUser(ctx, identity)
82
+ if err != nil {
83
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
84
+ }
85
+ return s.completeLogin(ctx, u, in.Session)
86
+ }
87
+
88
+ // exchangeExternalIdentity consumes the one-time transaction before asking
89
+ // the provider to complete the code exchange. The transaction's UserID is
90
+ // zero for public login and is the authenticated caller for identity linking.
91
+ func (s *Service) exchangeExternalIdentity(ctx context.Context, providerName string, in LoginExchangeInput, userID uuid.UUID) (ExternalIdentity, error) {
59
92
  provider, ok := s.providers.Lookup(providerName)
60
93
  if !ok {
61
- return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
94
+ return ExternalIdentity{}, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
62
95
  }
63
96
  if !validOAuthValue(in.State) || !validPKCEValue(in.CodeVerifier) {
64
- return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
97
+ return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
65
98
  }
66
99
  if !validOAuthValue(in.Code) {
67
- return nil, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
100
+ return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
68
101
  }
69
102
 
70
103
  transaction, ok, err := s.oauthTransactions.ConsumeLoginTransaction(ctx, hashToken(in.State))
71
104
  if err != nil {
72
- return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
105
+ return ExternalIdentity{}, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
73
106
  }
74
- if !ok || transaction.Provider != providerName || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
75
- return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
107
+ if !ok || transaction.Provider != providerName || transaction.UserID != userID || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
108
+ return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
76
109
  }
77
110
 
78
111
  identity, err := provider.Complete(ctx, LoginCompleteInput{Code: in.Code, CodeVerifier: in.CodeVerifier, Nonce: transaction.Nonce})
79
112
  if err != nil {
80
- return nil, mapProviderError(err)
113
+ return ExternalIdentity{}, mapProviderError(err)
81
114
  }
82
115
  if identity.Provider != providerName {
83
- return nil, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
116
+ return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
84
117
  }
85
- u, err := s.findOrCreateExternalUser(ctx, identity)
86
- if err != nil {
87
- return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
88
- }
89
- return s.completeLogin(ctx, u, in.Session)
118
+ return identity, nil
90
119
  }
91
120
 
92
121
  func validPKCEVerifier(verifier, challenge string) bool {
@@ -154,15 +183,15 @@ func mapProviderError(err error) error {
154
183
  // provider subject wins; only a verified external email may link to an
155
184
  // existing account.
156
185
  func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIdentity) (*domain.User, error) {
157
- if strings.TrimSpace(info.Provider) == "" || strings.TrimSpace(info.Subject) == "" || strings.TrimSpace(info.Email) == "" {
158
- 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")
159
188
  }
160
189
  if len(info.Provider) > 20 {
161
190
  return nil, fmt.Errorf("external identity provider name is too long")
162
191
  }
163
192
 
164
193
  provider := domain.Provider(info.Provider)
165
- 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 {
166
195
  user, findErr := s.repo.FindByID(ctx, ident.UserID)
167
196
  if findErr != nil {
168
197
  return nil, fmt.Errorf("find user for existing identity: %w", findErr)
@@ -176,7 +205,7 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIde
176
205
  email := normalizeEmail(info.Email)
177
206
  if info.EmailVerified {
178
207
  if u, err := s.repo.FindByEmail(ctx, email); err == nil {
179
- 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}
180
209
  if err := s.repo.CreateIdentity(ctx, ident); err != nil {
181
210
  return nil, fmt.Errorf("link external identity: %w", err)
182
211
  }
@@ -187,7 +216,7 @@ func (s *Service) findOrCreateExternalUser(ctx context.Context, info ExternalIde
187
216
  }
188
217
 
189
218
  u := &domain.User{ID: id.New(), Email: email, Name: info.Name, AvatarURL: info.AvatarURL, EmailVerified: info.EmailVerified, Role: domain.DefaultRole}
190
- ident := &domain.Identity{ID: id.New(), Provider: provider, ProviderUID: &providerUID}
219
+ ident := &domain.Identity{ID: id.New(), Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID}
191
220
  if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
192
221
  if errors.Is(err, domain.ErrConflict) {
193
222
  return nil, errEmailTaken()
@@ -0,0 +1,135 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "strings"
8
+
9
+ "{{goModule}}/internal/app/user/domain"
10
+
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ func (s *Service) ListIdentities(ctx context.Context, userID uuid.UUID) ([]IdentityResponse, error) {
15
+ items, err := s.repo.ListIdentities(ctx, userID)
16
+ if err != nil {
17
+ return nil, fmt.Errorf("list login identities: %w", err)
18
+ }
19
+
20
+ out := make([]IdentityResponse, len(items))
21
+ for i := range items {
22
+ out[i] = ToIdentityResponse(items[i])
23
+ }
24
+ return out, nil
25
+ }
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
+
62
+ func (s *Service) ExchangeIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginExchangeInput) (*IdentityResponse, error) {
63
+ if userID == uuid.Nil {
64
+ return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
65
+ }
66
+
67
+ identity, err := s.exchangeExternalIdentity(ctx, providerName, in, userID)
68
+ if err != nil {
69
+ return nil, err
70
+ }
71
+ return s.linkExternalIdentity(ctx, userID, identity)
72
+ }
73
+
74
+ func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, info ExternalIdentity) (*IdentityResponse, error) {
75
+ provider := domain.Provider(strings.TrimSpace(info.Provider))
76
+ if userID == uuid.Nil || provider == "" || strings.TrimSpace(info.Subject) == "" {
77
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("external identity is incomplete"))
78
+ }
79
+
80
+ // Replaying the same provider account from the same user is idempotent.
81
+ // A provider subject already owned by another user is a conflict and must
82
+ // never be silently moved between accounts.
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 {
87
+ if existing.UserID != userID {
88
+ return nil, errIdentityConflict()
89
+ }
90
+ out := ToIdentityResponse(*existing)
91
+ return &out, nil
92
+ } else if !errors.Is(err, domain.ErrNotFound) {
93
+ return nil, fmt.Errorf("find linked identity: %w", err)
94
+ }
95
+
96
+ // The database permits one identity per provider for each user. Return a
97
+ // controlled conflict instead of exposing a persistence duplicate error.
98
+ if _, err := s.repo.FindIdentity(ctx, userID, provider); err == nil {
99
+ return nil, errIdentityAlreadyLinked()
100
+ } else if !errors.Is(err, domain.ErrNotFound) {
101
+ return nil, fmt.Errorf("check existing provider identity: %w", err)
102
+ }
103
+
104
+ providerUID := info.Subject
105
+ identity := &domain.Identity{
106
+ ID: uuid.New(), UserID: userID, Provider: provider, Issuer: info.Issuer, ProviderUID: &providerUID,
107
+ }
108
+ if err := s.repo.CreateIdentity(ctx, identity); err != nil {
109
+ if errors.Is(err, domain.ErrConflict) {
110
+ return nil, errIdentityAlreadyLinked()
111
+ }
112
+ return nil, fmt.Errorf("link identity: %w", err)
113
+ }
114
+
115
+ out := ToIdentityResponse(*identity)
116
+ return &out, nil
117
+ }
118
+
119
+ func (s *Service) UnlinkIdentity(ctx context.Context, userID uuid.UUID, providerName string) error {
120
+ provider := domain.Provider(strings.TrimSpace(providerName))
121
+ if userID == uuid.Nil || provider == "" {
122
+ return errIdentityNotFound()
123
+ }
124
+ if err := s.repo.DeleteIdentity(ctx, userID, provider); err != nil {
125
+ switch {
126
+ case errors.Is(err, domain.ErrLastIdentity):
127
+ return errLastIdentity()
128
+ case errors.Is(err, domain.ErrNotFound):
129
+ return errIdentityNotFound()
130
+ default:
131
+ return fmt.Errorf("unlink identity: %w", err)
132
+ }
133
+ }
134
+ return nil
135
+ }
@@ -0,0 +1,102 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+
8
+ "{{goModule}}/internal/app/user/domain"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ func TestService_IdentityLinkBindsOAuthTransactionToCaller(t *testing.T) {
14
+ provider, svc := validFakeProvider(t)
15
+ userID := uuid.New()
16
+ otherUserID := uuid.New()
17
+
18
+ if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
19
+ t.Fatalf("begin identity link: %v", err)
20
+ }
21
+
22
+ _, err := svc.ExchangeIdentityLink(context.Background(), otherUserID, provider.Name(), validLoginExchange())
23
+ if got := oauthErrorCode(t, err); got != OAuthStateInvalid {
24
+ t.Fatalf("oauth code = %q, want %q", got, OAuthStateInvalid)
25
+ }
26
+ if provider.completeCnt != 0 {
27
+ t.Fatal("provider must not receive a transaction bound to another user")
28
+ }
29
+ }
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
+
64
+ func TestService_IdentityLinkIsIdempotentForSameProviderSubject(t *testing.T) {
65
+ provider, svc := validFakeProvider(t)
66
+ userID := uuid.New()
67
+
68
+ if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
69
+ t.Fatalf("begin first identity link: %v", err)
70
+ }
71
+ first, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
72
+ if err != nil {
73
+ t.Fatalf("exchange first identity link: %v", err)
74
+ }
75
+
76
+ if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
77
+ t.Fatalf("begin repeated identity link: %v", err)
78
+ }
79
+ second, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
80
+ if err != nil {
81
+ t.Fatalf("exchange repeated identity link: %v", err)
82
+ }
83
+ if first.ID != second.ID || first.Provider != second.Provider {
84
+ t.Fatalf("repeated link returned a different identity: first=%+v second=%+v", first, second)
85
+ }
86
+ }
87
+
88
+ func TestService_UnlinkIdentityRejectsRemovingLastLoginMethod(t *testing.T) {
89
+ userID := uuid.New()
90
+ providerUID := "local-user"
91
+ repo := &fakeRepo{}
92
+ repo.identities = append(repo.identities, domain.Identity{
93
+ ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, ProviderUID: &providerUID,
94
+ })
95
+ svc := newTestService(repo, newFakeTokenStore())
96
+
97
+ err := svc.UnlinkIdentity(context.Background(), userID, string(domain.ProviderLocal))
98
+ var ruleErr *domain.RuleError
99
+ if !errors.As(err, &ruleErr) || ruleErr.Code != "AUTH_LAST_IDENTITY" {
100
+ t.Fatalf("unlink last identity error = %v, want AUTH_LAST_IDENTITY", err)
101
+ }
102
+ }
@@ -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,
@@ -24,6 +24,9 @@ func NewRecovery(repo ports.UserRepository, tokens ports.RecoveryTokenStore, has
24
24
  // ResetPassword consumes the token and updates the local identity in one
25
25
  // unit of work. The token adapter owns the transaction boundary.
26
26
  func (r *Recovery) ResetPassword(ctx context.Context, tokenHash, newPassword string) (uuid.UUID, error) {
27
+ if err := validatePassword(newPassword); err != nil {
28
+ return uuid.Nil, err
29
+ }
27
30
  hash, err := r.hasher.Hash(newPassword)
28
31
  if err != nil {
29
32
  return uuid.Nil, fmt.Errorf("hash password: %w", err)
@@ -92,6 +92,17 @@ func normalizeEmail(email string) string {
92
92
  return strings.ToLower(strings.TrimSpace(email))
93
93
  }
94
94
 
95
+ // bcrypt accepts at most 72 bytes. Enforce the same bound in the application
96
+ // layer so every caller (HTTP, seed, jobs, and future transports) gets the
97
+ // same safe contract instead of a provider-specific hashing failure.
98
+ func validatePassword(password string) error {
99
+ length := len([]byte(password))
100
+ if length < 8 || length > 72 {
101
+ return errInvalidPassword()
102
+ }
103
+ return nil
104
+ }
105
+
95
106
  // SetRole validates the role against the role catalog before persisting it.
96
107
  func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string) (*domain.User, error) {
97
108
  u, err := s.repo.FindByID(ctx, userID)
@@ -118,6 +129,11 @@ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string
118
129
  // ServicePort is the inbound application boundary. HTTP, jobs, and future
119
130
  // transports depend on this capability set instead of the concrete service.
120
131
  type ServicePort interface {
132
+ ListIdentities(context.Context, uuid.UUID) ([]IdentityResponse, error)
133
+ LinkLocalIdentity(context.Context, uuid.UUID, string) error
134
+ BeginIdentityLink(context.Context, uuid.UUID, string, LoginStartInput) (*Authorization, error)
135
+ ExchangeIdentityLink(context.Context, uuid.UUID, string, LoginExchangeInput) (*IdentityResponse, error)
136
+ UnlinkIdentity(context.Context, uuid.UUID, string) error
121
137
  // go-scaffold:service-interface
122
138
  Register(context.Context, RegisterInput) (*AuthResult, error)
123
139
  Login(context.Context, LoginInput) (*AuthResult, error)
@@ -334,6 +334,7 @@ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID,
334
334
  type fakeRepo struct {
335
335
  user *domain.User
336
336
  identity *domain.Identity
337
+ identities []domain.Identity
337
338
  updateIdentityErr error
338
339
  updateUserErr error
339
340
  failures map[string]int
@@ -387,20 +388,61 @@ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
387
388
  return nil
388
389
  }
389
390
  func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
390
- func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ domain.Provider) (*domain.Identity, error) {
391
- if f.identity != nil && f.identity.UserID == userID {
391
+
392
+ func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
393
+ for i := range f.identities {
394
+ if f.identities[i].UserID == userID && f.identities[i].Provider == provider {
395
+ copy := f.identities[i]
396
+ return &copy, nil
397
+ }
398
+ }
399
+ if f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider {
392
400
  copy := *f.identity
393
401
  return &copy, nil
394
402
  }
395
403
  return nil, domain.ErrNotFound
396
404
  }
397
- func (f *fakeRepo) FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error) {
405
+ func (f *fakeRepo) ListIdentities(_ context.Context, userID uuid.UUID) ([]domain.Identity, error) {
406
+ out := make([]domain.Identity, 0, len(f.identities)+1)
407
+ for _, identity := range f.identities {
408
+ if identity.UserID == userID {
409
+ out = append(out, identity)
410
+ }
411
+ }
412
+ if f.identity != nil && f.identity.UserID == userID {
413
+ found := false
414
+ for _, identity := range out {
415
+ if identity.ID == f.identity.ID {
416
+ found = true
417
+ break
418
+ }
419
+ }
420
+ if !found {
421
+ out = append(out, *f.identity)
422
+ }
423
+ }
424
+ return out, nil
425
+ }
426
+ func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, issuer, providerUID string) (*domain.Identity, error) {
427
+ for i := range f.identities {
428
+ if f.identities[i].Issuer == issuer && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
429
+ copy := f.identities[i]
430
+ return &copy, nil
431
+ }
432
+ }
433
+ if f.identity != nil && f.identity.Issuer == issuer && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
434
+ copy := *f.identity
435
+ return &copy, nil
436
+ }
398
437
  return nil, domain.ErrNotFound
399
438
  }
400
439
  func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
401
440
  return nil
402
441
  }
403
- func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error { return nil }
442
+ func (f *fakeRepo) CreateIdentity(_ context.Context, identity *domain.Identity) error {
443
+ f.identities = append(f.identities, *identity)
444
+ return nil
445
+ }
404
446
  func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
405
447
  if f.updateIdentityErr != nil {
406
448
  return f.updateIdentityErr
@@ -408,6 +450,35 @@ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
408
450
  f.identity = i
409
451
  return nil
410
452
  }
453
+ func (f *fakeRepo) DeleteIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) error {
454
+ count := 0
455
+ target := -1
456
+ for i := range f.identities {
457
+ if f.identities[i].UserID != userID {
458
+ continue
459
+ }
460
+ count++
461
+ if f.identities[i].Provider == provider {
462
+ target = i
463
+ }
464
+ }
465
+ legacyTarget := f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider
466
+ if legacyTarget {
467
+ count++
468
+ }
469
+ if target < 0 && !legacyTarget {
470
+ return domain.ErrNotFound
471
+ }
472
+ if count <= 1 {
473
+ return domain.ErrLastIdentity
474
+ }
475
+ if target >= 0 {
476
+ f.identities = append(f.identities[:target], f.identities[target+1:]...)
477
+ } else {
478
+ f.identity = nil
479
+ }
480
+ return nil
481
+ }
411
482
 
412
483
  // go-scaffold:user-fake-repo-methods
413
484
  // go-scaffold:repository-stub-methods
@@ -49,6 +49,9 @@ func (s *Service) EnsureUser(ctx context.Context, email, password, name string)
49
49
  } else if !errors.Is(err, domain.ErrNotFound) {
50
50
  return nil, fmt.Errorf("find existing user: %w", err)
51
51
  }
52
+ if err := validatePassword(password); err != nil {
53
+ return nil, err
54
+ }
52
55
 
53
56
  hash, err := s.passwords.Hash(password)
54
57
  if err != nil {
@@ -34,6 +34,7 @@ type Identity struct {
34
34
  ID uuid.UUID
35
35
  UserID uuid.UUID
36
36
  Provider Provider
37
+ Issuer string
37
38
  PasswordHash *string
38
39
  ProviderUID *string
39
40
  CreatedAt time.Time
@@ -6,6 +6,7 @@ var (
6
6
  ErrNotFound = errors.New("user not found")
7
7
  ErrConflict = errors.New("user conflict")
8
8
  ErrInvalidCredential = errors.New("invalid credentials")
9
+ ErrInvalidPassword = errors.New("password must be between 8 and 72 bytes")
9
10
  ErrInvalidToken = errors.New("invalid or expired token")
10
11
  ErrEmailTaken = errors.New("email already registered")
11
12
  ErrTooManyAttempts = errors.New("too many failed attempts")
@@ -15,7 +16,9 @@ var (
15
16
  ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
16
17
  ErrMFANotEnrolled = errors.New("MFA is not enrolled")
17
18
  ErrMFASetupRequired = errors.New("MFA setup is required")
18
- ErrUnknownRole = errors.New("unknown role")
19
+ ErrUnknownRole = errors.New("unknown role")
20
+ ErrIdentityAlreadyLinked = errors.New("login identity is already linked")
21
+ ErrLastIdentity = errors.New("cannot remove the last login identity")
19
22
  )
20
23
 
21
24
  type RuleError struct {
@@ -16,10 +16,12 @@ type UserRepository interface {
16
16
  UpdateUser(context.Context, *domain.User) error
17
17
  FindAll(context.Context, int, int) ([]domain.User, error)
18
18
  FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
19
- FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
19
+ ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
20
+ FindIdentityByProviderUID(context.Context, string, string) (*domain.Identity, error)
20
21
  CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
21
22
  CreateIdentity(context.Context, *domain.Identity) error
22
23
  UpdateIdentity(context.Context, *domain.Identity) error
24
+ DeleteIdentity(context.Context, uuid.UUID, domain.Provider) error
23
25
  LoginLockedUntil(context.Context, string) (time.Time, error)
24
26
  RecordLoginFailure(context.Context, string, int, time.Duration) error
25
27
  ClearLoginFailures(context.Context, string) error
@@ -65,6 +67,7 @@ type RefreshTokenStore interface {
65
67
 
66
68
  type LoginTransaction struct {
67
69
  Provider string `json:"provider"`
70
+ UserID uuid.UUID `json:"user_id"`
68
71
  CodeChallenge string `json:"code_challenge"`
69
72
  Nonce string `json:"nonce"`
70
73
  ExpiresAt time.Time `json:"expires_at"`
@@ -217,6 +217,7 @@ func (p *Provider) Complete(ctx context.Context, in application.LoginCompleteInp
217
217
  }
218
218
  return application.ExternalIdentity{
219
219
  Provider: p.Name(),
220
+ Issuer: p.issuer,
220
221
  Subject: claims.Subject,
221
222
  Email: email,
222
223
  EmailVerified: info.EmailVerified || claims.EmailVerified,
@@ -26,7 +26,13 @@ func (r *RedisLimiter) Allow(ctx context.Context, key string, limit int, window
26
26
  return true
27
27
  }
28
28
  if count == 1 {
29
- r.rdb.Expire(ctx, redisKey, window)
29
+ if err := r.rdb.Expire(ctx, redisKey, window).Err(); err != nil {
30
+ // Never leave a counter without a TTL: an expired window must not
31
+ // become a permanent lockout after a transient Redis failure. The
32
+ // limiter is intentionally fail-open, so discard this best-effort
33
+ // counter and let the endpoint's own controls handle the request.
34
+ _ = r.rdb.Del(ctx, redisKey).Err()
35
+ }
30
36
  }
31
37
  return count <= int64(limit)
32
38
  }
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.external_identities;
@@ -0,0 +1,12 @@
1
+ CREATE TABLE user_svc.external_identities (
2
+ id UUID PRIMARY KEY,
3
+ user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
4
+ provider VARCHAR(50) NOT NULL,
5
+ issuer TEXT NOT NULL,
6
+ subject TEXT NOT NULL,
7
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9
+ );
10
+
11
+ CREATE UNIQUE INDEX idx_external_identities_issuer_subject ON user_svc.external_identities (issuer, subject);
12
+ CREATE UNIQUE INDEX idx_external_identities_user_provider ON user_svc.external_identities (user_id, provider);
@@ -0,0 +1 @@
1
+ DROP TABLE IF EXISTS user_svc.password_credentials;