@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.
- package/README.md +4 -0
- package/dist/commands/auth.js +25 -13
- package/dist/commands/method.js +2 -0
- package/dist/templates/auth-manifest.js +3 -0
- package/dist/utils/auth-patcher.js +10 -1
- package/dist/utils/hexagonal-method-patcher.js +12 -1
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +45 -3
- package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
- package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
- package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
- package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
- package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +45 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +107 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +21 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +253 -53
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +4 -2
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +47 -18
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +135 -0
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +102 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +16 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +4 -1
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
- package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
- package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +14 -0
- package/templates/create/base/README.md.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +5 -5
- package/templates/create/features/docs/techstack.md.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
- 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
|
|
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
|
|
97
|
+
return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
|
|
65
98
|
}
|
|
66
99
|
if !validOAuthValue(in.Code) {
|
|
67
|
-
return
|
|
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
|
|
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
|
|
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
|
|
113
|
+
return ExternalIdentity{}, mapProviderError(err)
|
|
81
114
|
}
|
|
82
115
|
if identity.Provider != providerName {
|
|
83
|
-
return
|
|
116
|
+
return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
|
|
84
117
|
}
|
|
85
|
-
|
|
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,
|
|
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)
|
|
@@ -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
|
-
|
|
391
|
-
|
|
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 ©, nil
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider {
|
|
392
400
|
copy := *f.identity
|
|
393
401
|
return ©, nil
|
|
394
402
|
}
|
|
395
403
|
return nil, domain.ErrNotFound
|
|
396
404
|
}
|
|
397
|
-
func (f *fakeRepo)
|
|
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 ©, 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 ©, 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 {
|
|
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 {
|
|
@@ -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
|
|
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
|
-
|
|
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;
|