@nakedev/go-scaffold 0.4.0 → 0.5.0

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 (192) hide show
  1. package/README.md +598 -306
  2. package/dist/commands/auth.js +65 -23
  3. package/dist/commands/check.js +281 -0
  4. package/dist/commands/config.js +50 -0
  5. package/dist/commands/create.js +33 -2
  6. package/dist/commands/generate.js +29 -3
  7. package/dist/commands/method.js +74 -63
  8. package/dist/commands/migration.js +2 -2
  9. package/dist/commands/observability.js +4 -53
  10. package/dist/commands/rbac.js +21 -10
  11. package/dist/commands/undo.js +11 -3
  12. package/dist/commands/worker.js +15 -5
  13. package/dist/index.js +198 -59
  14. package/dist/prompts/auth-wizard.js +40 -6
  15. package/dist/prompts/create-wizard.js +42 -1
  16. package/dist/prompts/generate-wizard.js +89 -9
  17. package/dist/templates/auth-manifest.js +50 -19
  18. package/dist/templates/create-manifest.js +8 -0
  19. package/dist/templates/module-manifest.js +84 -26
  20. package/dist/templates/rbac-manifest.js +16 -11
  21. package/dist/templates/worker-manifest.js +4 -1
  22. package/dist/types.js +8 -0
  23. package/dist/utils/auth-patcher.js +124 -33
  24. package/dist/utils/config.js +167 -4
  25. package/dist/utils/docs-patcher.js +68 -0
  26. package/dist/utils/hexagonal-method-patcher.js +334 -0
  27. package/dist/utils/main-patcher.js +32 -30
  28. package/dist/utils/marker-patch.js +7 -1
  29. package/dist/utils/module-location.js +17 -11
  30. package/dist/utils/module-profile.js +32 -0
  31. package/dist/utils/platform-patcher.js +56 -7
  32. package/dist/utils/rbac-patcher.js +89 -210
  33. package/package.json +7 -2
  34. package/templates/add/auth/cmd/seed/main.go.hbs +15 -3
  35. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  36. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  38. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  39. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  40. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  41. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  42. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  43. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  44. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  45. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  46. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  47. package/templates/add/auth/internal/app/user/adapters/inbound/http/browser_policy.go.hbs +98 -0
  48. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +159 -0
  49. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +228 -0
  50. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +76 -0
  51. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +83 -0
  52. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +70 -0
  53. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs +49 -0
  54. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +311 -0
  55. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +41 -0
  56. package/templates/add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs +35 -0
  57. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs +35 -0
  58. package/templates/add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs +20 -0
  59. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +129 -0
  60. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +174 -0
  61. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +84 -0
  62. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +211 -0
  63. package/templates/add/auth/internal/app/user/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +18 -19
  64. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +213 -0
  65. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +103 -0
  66. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_recovery.go.hbs +84 -0
  67. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +228 -0
  68. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +196 -0
  69. package/templates/add/auth/internal/app/user/application/contracts.go.hbs +52 -0
  70. package/templates/add/auth/internal/app/user/application/dto.go.hbs +75 -0
  71. package/templates/add/auth/internal/app/user/application/errors.go.hbs +62 -0
  72. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +198 -0
  73. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +58 -0
  74. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +96 -0
  75. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +449 -0
  76. package/templates/add/auth/internal/app/user/application/mfa_service_test.go.hbs +200 -0
  77. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  78. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +285 -0
  79. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +82 -0
  80. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +112 -0
  81. package/templates/add/auth/internal/app/user/application/service.go.hbs +145 -0
  82. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +891 -0
  83. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +99 -0
  84. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +14 -0
  85. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +65 -0
  86. package/templates/add/auth/internal/app/user/composition.go.hbs +168 -0
  87. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +41 -0
  88. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +32 -0
  89. package/templates/add/auth/internal/app/user/ports/password.go.hbs +9 -0
  90. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +90 -0
  91. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  92. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  93. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +10 -5
  94. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  95. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +1 -1
  96. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  97. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  98. package/templates/add/auth/migrations/create_users.up.sql.hbs +4 -3
  99. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +142 -0
  100. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler_test.go.hbs +19 -0
  101. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/model.go.hbs +48 -0
  102. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +127 -0
  103. package/templates/add/rbac/internal/app/role/{repository_test.go.hbs → adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  104. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +47 -0
  105. package/templates/add/rbac/internal/app/role/application/errors.go.hbs +19 -0
  106. package/templates/add/rbac/internal/app/role/application/service.go.hbs +157 -0
  107. package/templates/add/rbac/internal/app/role/{service_test.go.hbs → application/service_test.go.hbs} +26 -19
  108. package/templates/add/rbac/internal/app/role/composition.go.hbs +48 -0
  109. package/templates/add/rbac/internal/app/role/domain/entity.go.hbs +23 -0
  110. package/templates/add/rbac/internal/app/role/domain/errors.go.hbs +26 -0
  111. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +25 -0
  112. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +3 -11
  113. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -6
  114. package/templates/add/worker/internal/platform/queue/river_test.go.hbs +84 -0
  115. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +358 -121
  116. package/templates/create/base/.env.example.hbs +0 -1
  117. package/templates/create/base/.golangci.yml.hbs +2 -2
  118. package/templates/create/base/AGENTS.md.hbs +279 -67
  119. package/templates/create/base/Makefile.hbs +2 -1
  120. package/templates/create/base/README.md.hbs +115 -32
  121. package/templates/create/base/cmd/api/wiring.go.hbs +13 -9
  122. package/templates/create/base/internal/composition/doc.go.hbs +7 -0
  123. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  124. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  125. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  126. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  127. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  128. package/templates/create/features/docs/architecture.md.hbs +92 -32
  129. package/templates/create/features/docs/patterns.md.hbs +137 -91
  130. package/templates/create/features/docs/techstack.md.hbs +18 -3
  131. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.go.hbs +45 -0
  132. package/templates/generate/module/hexagonal/adapters/inbound/http/dto.minimal.go.hbs +28 -0
  133. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +182 -0
  134. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.minimal.go.hbs +83 -0
  135. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_crud_test.go.hbs +18 -0
  136. package/templates/generate/module/hexagonal/adapters/inbound/http/handler_test.go.hbs +30 -0
  137. package/templates/generate/module/hexagonal/adapters/outbound/postgres/model.go.hbs +37 -0
  138. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +95 -0
  139. package/templates/generate/module/{repository_test.go.hbs → hexagonal/adapters/outbound/postgres/repository_test.go.hbs} +8 -8
  140. package/templates/generate/module/hexagonal/application/commands.crud.go.hbs +54 -0
  141. package/templates/generate/module/hexagonal/application/commands.go.hbs +25 -0
  142. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +66 -0
  143. package/templates/generate/module/hexagonal/application/dto.go.hbs +35 -0
  144. package/templates/generate/module/hexagonal/application/dto.minimal.go.hbs +25 -0
  145. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +33 -0
  146. package/templates/generate/module/hexagonal/application/queries.go.hbs +25 -0
  147. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +73 -0
  148. package/templates/generate/module/hexagonal/application/service.go.hbs +29 -0
  149. package/templates/generate/module/hexagonal/application/service_test.go.hbs +62 -0
  150. package/templates/generate/module/hexagonal/composition.go.hbs +27 -0
  151. package/templates/generate/module/hexagonal/domain/entity.go.hbs +20 -0
  152. package/templates/generate/module/hexagonal/domain/errors.go.hbs +11 -0
  153. package/templates/generate/module/hexagonal/ports/repository.go.hbs +38 -0
  154. package/templates/generate/module/migration.up.sql.hbs +1 -1
  155. package/dist/utils/method-patcher.js +0 -357
  156. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  157. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
  158. package/templates/add/auth/internal/app/user/dto.go.hbs +0 -77
  159. package/templates/add/auth/internal/app/user/errors.go.hbs +0 -43
  160. package/templates/add/auth/internal/app/user/handler.go.hbs +0 -276
  161. package/templates/add/auth/internal/app/user/jwt.go.hbs +0 -108
  162. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +0 -39
  163. package/templates/add/auth/internal/app/user/model/identity.go.hbs +0 -31
  164. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +0 -26
  165. package/templates/add/auth/internal/app/user/model/user.go.hbs +0 -30
  166. package/templates/add/auth/internal/app/user/repository.go.hbs +0 -137
  167. package/templates/add/auth/internal/app/user/service.go.hbs +0 -531
  168. package/templates/add/auth/internal/app/user/service_test.go.hbs +0 -316
  169. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +0 -30
  170. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +0 -144
  171. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +0 -147
  172. package/templates/add/rbac/internal/app/role/dto.go.hbs +0 -45
  173. package/templates/add/rbac/internal/app/role/errors.go.hbs +0 -39
  174. package/templates/add/rbac/internal/app/role/handler.go.hbs +0 -104
  175. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +0 -12
  176. package/templates/add/rbac/internal/app/role/model/role.go.hbs +0 -22
  177. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +0 -11
  178. package/templates/add/rbac/internal/app/role/repository.go.hbs +0 -97
  179. package/templates/add/rbac/internal/app/role/service.go.hbs +0 -217
  180. package/templates/generate/module/dto.go.hbs +0 -36
  181. package/templates/generate/module/errors.go.hbs +0 -33
  182. package/templates/generate/module/handler.go.hbs +0 -134
  183. package/templates/generate/module/handler_test.go.hbs +0 -174
  184. package/templates/generate/module/minimal/dto.go.hbs +0 -28
  185. package/templates/generate/module/minimal/handler.go.hbs +0 -48
  186. package/templates/generate/module/minimal/handler_test.go.hbs +0 -10
  187. package/templates/generate/module/minimal/service.go.hbs +0 -45
  188. package/templates/generate/module/minimal/service_test.go.hbs +0 -77
  189. package/templates/generate/module/model/model.go.hbs +0 -36
  190. package/templates/generate/module/repository.go.hbs +0 -103
  191. package/templates/generate/module/service.go.hbs +0 -108
  192. package/templates/generate/module/service_test.go.hbs +0 -161
@@ -0,0 +1,196 @@
1
+ package redisadapter
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "os"
8
+ "testing"
9
+ "time"
10
+
11
+ "{{goModule}}/internal/app/user/ports"
12
+
13
+ "github.com/google/uuid"
14
+ "github.com/redis/go-redis/v9"
15
+ )
16
+
17
+ func testRefreshTokenRecord(userID uuid.UUID) ports.RefreshTokenRecord {
18
+ now := time.Now()
19
+ return ports.RefreshTokenRecord{
20
+ UserID: userID,
21
+ ExpiresAt: now.Add(time.Hour),
22
+ AbsoluteExpiresAt: now.Add(24 * time.Hour),
23
+ }
24
+ }
25
+
26
+ func redisClientForTest(t *testing.T) *redis.Client {
27
+ t.Helper()
28
+ url := os.Getenv("TEST_REDIS_URL")
29
+ if url == "" {
30
+ if os.Getenv("REQUIRE_TEST_REDIS") == "true" {
31
+ t.Fatal("TEST_REDIS_URL is required when REQUIRE_TEST_REDIS=true")
32
+ }
33
+ t.Skip("token-store integration test skipped: set TEST_REDIS_URL to a Redis instance")
34
+ }
35
+
36
+ options, err := redis.ParseURL(url)
37
+ if err != nil {
38
+ if os.Getenv("REQUIRE_TEST_REDIS") == "true" {
39
+ t.Fatalf("parse required Redis URL: %v", err)
40
+ }
41
+ t.Skipf("token-store integration test skipped: %v", err)
42
+ }
43
+ client := redis.NewClient(options)
44
+ if err := client.Ping(context.Background()).Err(); err != nil {
45
+ _ = client.Close()
46
+ if os.Getenv("REQUIRE_TEST_REDIS") == "true" {
47
+ t.Fatalf("ping required Redis: %v", err)
48
+ }
49
+ t.Skipf("token-store integration test skipped: %v", err)
50
+ }
51
+ t.Cleanup(func() { _ = client.Close() })
52
+ return client
53
+ }
54
+
55
+ func TestRefreshTokenRecord_JSONUsesLuaFieldNames(t *testing.T) {
56
+ raw, err := json.Marshal(testRefreshTokenRecord(uuid.New()))
57
+ if err != nil {
58
+ t.Fatalf("marshal refresh token record: %v", err)
59
+ }
60
+
61
+ var payload map[string]json.RawMessage
62
+ if err := json.Unmarshal(raw, &payload); err != nil {
63
+ t.Fatalf("decode refresh token record: %v", err)
64
+ }
65
+ if _, ok := payload["user_id"]; !ok {
66
+ t.Fatalf("Redis refresh payload must contain user_id, got %s", raw)
67
+ }
68
+ if _, ok := payload["UserID"]; ok {
69
+ t.Fatalf("Redis refresh payload must not contain Go field name UserID: %s", raw)
70
+ }
71
+ }
72
+
73
+ func TestRedisTokenStore_ConsumeRefreshToken_ConcurrentRealRedis(t *testing.T) {
74
+ client := redisClientForTest(t)
75
+ store := NewRedisTokenStore(client, nil)
76
+ ctx := context.Background()
77
+ userID := uuid.New()
78
+ hash := "real-redis-refresh-" + uuid.NewString()
79
+ if err := store.SetRefreshToken(ctx, hash, testRefreshTokenRecord(userID)); err != nil {
80
+ t.Fatalf("seed refresh token: %v", err)
81
+ }
82
+ t.Cleanup(func() { _ = store.RevokeAllRefreshTokens(ctx, userID) })
83
+
84
+ const callers = 32
85
+ start := make(chan struct{})
86
+ results := make(chan struct {
87
+ token ports.RefreshTokenRecord
88
+ ok bool
89
+ err error
90
+ }, callers)
91
+ for i := 0; i < callers; i++ {
92
+ go func() {
93
+ <-start
94
+ token, ok, err := store.ConsumeRefreshToken(ctx, hash)
95
+ results <- struct {
96
+ token ports.RefreshTokenRecord
97
+ ok bool
98
+ err error
99
+ }{token: token, ok: ok, err: err}
100
+ }()
101
+ }
102
+ close(start)
103
+
104
+ winners := 0
105
+ for i := 0; i < callers; i++ {
106
+ got := <-results
107
+ if got.err != nil {
108
+ t.Fatalf("concurrent consume: %v", got.err)
109
+ }
110
+ if got.ok {
111
+ winners++
112
+ if got.token.UserID != userID {
113
+ t.Fatalf("winner returned user %s, want %s", got.token.UserID, userID)
114
+ }
115
+ }
116
+ }
117
+ if winners != 1 {
118
+ t.Fatalf("expected exactly one real-Redis refresh winner, got %d", winners)
119
+ }
120
+ }
121
+
122
+ func TestRedisTokenStore_ConsumeRefreshToken_MalformedRecordDoesNotDelete(t *testing.T) {
123
+ client := redisClientForTest(t)
124
+ store := NewRedisTokenStore(client, nil)
125
+ ctx := context.Background()
126
+ hash := "real-redis-malformed-" + uuid.NewString()
127
+ key := refreshKeyPrefix + hash
128
+ if err := client.Set(ctx, key, "not-json", time.Minute).Err(); err != nil {
129
+ t.Fatalf("seed malformed refresh token: %v", err)
130
+ }
131
+ t.Cleanup(func() { _ = client.Del(ctx, key).Err() })
132
+
133
+ if _, ok, err := store.ConsumeRefreshToken(ctx, hash); err == nil || ok {
134
+ t.Fatalf("malformed refresh token must fail without being consumed, ok=%t err=%v", ok, err)
135
+ }
136
+ exists, err := client.Exists(ctx, key).Result()
137
+ if err != nil {
138
+ t.Fatalf("check malformed refresh token: %v", err)
139
+ }
140
+ if exists != 1 {
141
+ t.Fatal("malformed refresh token was deleted before validation")
142
+ }
143
+ }
144
+
145
+ func TestRedisTokenStore_RevokeAllRefreshTokens_IsAtomicWithSet(t *testing.T) {
146
+ client := redisClientForTest(t)
147
+ store := NewRedisTokenStore(client, nil)
148
+ ctx := context.Background()
149
+ userID := uuid.New()
150
+ setKey := refreshUserKeyPrefix + userID.String()
151
+ t.Cleanup(func() { _ = store.RevokeAllRefreshTokens(ctx, userID) })
152
+
153
+ // An invariant is stronger than checking the final session count: an
154
+ // active token must always have a member in the user's set. The old
155
+ // SMEMBERS -> pipeline implementation could leave exactly that split when
156
+ // SetRefreshToken landed between the two phases.
157
+ for i := 0; i < 128; i++ {
158
+ existingHash := fmt.Sprintf("real-redis-existing-%s-%d", userID, i)
159
+ newHash := fmt.Sprintf("real-redis-racing-%s-%d", userID, i)
160
+ if err := store.SetRefreshToken(ctx, existingHash, testRefreshTokenRecord(userID)); err != nil {
161
+ t.Fatalf("seed existing token: %v", err)
162
+ }
163
+
164
+ start := make(chan struct{})
165
+ errs := make(chan error, 2)
166
+ go func() {
167
+ <-start
168
+ errs <- store.RevokeAllRefreshTokens(ctx, userID)
169
+ }()
170
+ go func() {
171
+ <-start
172
+ errs <- store.SetRefreshToken(ctx, newHash, testRefreshTokenRecord(userID))
173
+ }()
174
+ close(start)
175
+ for j := 0; j < 2; j++ {
176
+ if err := <-errs; err != nil {
177
+ t.Fatalf("concurrent revoke/set: %v", err)
178
+ }
179
+ }
180
+
181
+ _, active, err := store.GetRefreshToken(ctx, newHash)
182
+ if err != nil {
183
+ t.Fatalf("read racing token: %v", err)
184
+ }
185
+ member, err := client.SIsMember(ctx, setKey, newHash).Result()
186
+ if err != nil {
187
+ t.Fatalf("read session set: %v", err)
188
+ }
189
+ if active != member {
190
+ t.Fatalf("inconsistent Redis session state at round %d: active=%t set_member=%t", i, active, member)
191
+ }
192
+ if err := store.RevokeAllRefreshTokens(ctx, userID); err != nil {
193
+ t.Fatalf("cleanup round %d: %v", i, err)
194
+ }
195
+ }
196
+ }
@@ -0,0 +1,52 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/user/ports"
7
+ )
8
+
9
+ // Dependencies is the explicit application composition contract for auth.
10
+ // Each port represents one lifecycle or capability so an adapter does not
11
+ // have to implement unrelated token operations.
12
+ type Dependencies struct {
13
+ Repository ports.UserRepository
14
+ Passwords ports.PasswordHasher
15
+ RefreshTokens ports.RefreshTokenStore
16
+ OAuthTransactions ports.OAuthTransactionStore
17
+ RecoveryTokens ports.RecoveryTokenStore
18
+ MFA ports.MFAStore
19
+ Mailer ports.AuthMailer
20
+ Providers ProviderRegistry
21
+ Roles ports.RoleChecker
22
+ Clock func() time.Time
23
+ }
24
+
25
+ type MFASettings struct {
26
+ Enabled bool
27
+ Issuer string
28
+ EncryptionKey string
29
+ ChallengeTTL time.Duration
30
+ TOTPWindow int
31
+ RecoveryCodeCount int
32
+ }
33
+
34
+ type AuthConfig struct {
35
+ JWTSecret string
36
+ JWTAccessTTL time.Duration
37
+ JWTRefreshTTL time.Duration
38
+ JWTRefreshMaxTTL time.Duration
39
+ OAuthStateTTL time.Duration
40
+ PasswordResetTTL time.Duration
41
+ PasswordResetURL string
42
+ EmailVerifyTTL time.Duration
43
+ EmailVerifyURL string
44
+ MFA MFASettings
45
+ }
46
+
47
+ // These aliases keep use-case code concise while the contracts remain owned
48
+ // by the module's ports package. Adapters import ports directly.
49
+ type UserRepository = ports.UserRepository
50
+ type AuthMailer = ports.AuthMailer
51
+ type RoleChecker = ports.RoleChecker
52
+ type PasswordHasher = ports.PasswordHasher
@@ -0,0 +1,75 @@
1
+ package application
2
+
3
+ import (
4
+ "time"
5
+
6
+ "{{goModule}}/internal/app/user/domain"
7
+
8
+ "github.com/google/uuid"
9
+ )
10
+
11
+ type RegisterInput struct {
12
+ Email string
13
+ Password string
14
+ Name string
15
+ }
16
+
17
+ type LoginInput struct {
18
+ Email string
19
+ Password string
20
+ }
21
+
22
+ type LoginExchangeInput struct {
23
+ Code string
24
+ State string
25
+ CodeVerifier string
26
+ }
27
+
28
+ type AuthResponse struct {
29
+ AccessToken string
30
+ RefreshToken string
31
+ TokenType string
32
+ ExpiresIn int
33
+ }
34
+
35
+ type AuthResult struct {
36
+ *AuthResponse
37
+ MFAChallenge string
38
+ }
39
+
40
+ func TokenResult(auth *AuthResponse) *AuthResult {
41
+ return &AuthResult{AuthResponse: auth}
42
+ }
43
+
44
+ func ChallengeResult(challenge string) *AuthResult {
45
+ return &AuthResult{MFAChallenge: challenge}
46
+ }
47
+
48
+ // Response is the generic application response used by generated method
49
+ // extensions. Auth's HTTP adapter may expose a richer meResponse, but method
50
+ // generation still has one stable transport-neutral mapping seam.
51
+ type Response struct {
52
+ ID uuid.UUID
53
+ Email string
54
+ Name string
55
+ AvatarURL string
56
+ EmailVerified bool
57
+ Role string
58
+ CreatedAt time.Time
59
+ UpdatedAt time.Time
60
+ }
61
+
62
+ func ToResponse(user *domain.User) Response {
63
+ return Response{
64
+ ID: user.ID,
65
+ Email: user.Email,
66
+ Name: user.Name,
67
+ AvatarURL: user.AvatarURL,
68
+ EmailVerified: user.EmailVerified,
69
+ Role: user.Role,
70
+ CreatedAt: user.CreatedAt,
71
+ UpdatedAt: user.UpdatedAt,
72
+ }
73
+ }
74
+
75
+ // go-scaffold:dto
@@ -0,0 +1,62 @@
1
+ package application
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "{{goModule}}/internal/app/user/domain"
7
+ )
8
+
9
+ // Use-case errors carry a stable business code and a domain sentinel. The
10
+ // inbound adapter decides how those errors map to HTTP; application code does
11
+ // not know status codes or response envelopes.
12
+ func errNotFound() error {
13
+ return domain.Rule("USER_NOT_FOUND", "user not found", domain.ErrNotFound)
14
+ }
15
+
16
+ func errEmailTaken() error {
17
+ return domain.Rule("USER_EMAIL_TAKEN", "email already registered", domain.ErrEmailTaken)
18
+ }
19
+
20
+ func errTooManyAttempts() error {
21
+ return domain.Rule("AUTH_TOO_MANY_ATTEMPTS", "too many failed attempts - try again later", domain.ErrTooManyAttempts)
22
+ }
23
+
24
+ func errInvalidCredentials() error {
25
+ return domain.Rule("AUTH_INVALID_CREDENTIALS", "invalid email or password", domain.ErrInvalidCredential)
26
+ }
27
+
28
+ func errInvalidToken() error {
29
+ return domain.Rule("AUTH_INVALID_TOKEN", "invalid or expired token", domain.ErrInvalidToken)
30
+ }
31
+
32
+ func errAlreadyVerified() error {
33
+ return domain.Rule("AUTH_ALREADY_VERIFIED", "email is already verified", domain.ErrAlreadyVerified)
34
+ }
35
+
36
+ func errMFAUnavailable() error {
37
+ return domain.Rule("AUTH_MFA_UNAVAILABLE", "multi-factor authentication is not enabled", domain.ErrMFAUnavailable)
38
+ }
39
+
40
+ func errMFAInvalid() error {
41
+ return domain.Rule("AUTH_MFA_INVALID", "invalid or expired multi-factor authentication code", domain.ErrMFAInvalid)
42
+ }
43
+
44
+ func errMFAAlreadyEnabled() error {
45
+ return domain.Rule("AUTH_MFA_ALREADY_ENABLED", "multi-factor authentication is already enabled", domain.ErrMFAAlreadyEnabled)
46
+ }
47
+
48
+ func errMFANotEnrolled() error {
49
+ return domain.Rule("AUTH_MFA_NOT_ENROLLED", "multi-factor authentication is not enabled for this user", domain.ErrMFANotEnrolled)
50
+ }
51
+
52
+ func errMFASetupRequired() error {
53
+ return domain.Rule("AUTH_MFA_SETUP_REQUIRED", "complete multi-factor authentication setup first", domain.ErrMFASetupRequired)
54
+ }
55
+
56
+ func errMFAConfig() error {
57
+ return fmt.Errorf("multi-factor authentication encryption key is invalid")
58
+ }
59
+
60
+ func errUnknownRole() error {
61
+ return domain.Rule("USER_UNKNOWN_ROLE", "unknown role code", domain.ErrUnknownRole)
62
+ }
@@ -0,0 +1,198 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "crypto/subtle"
7
+ "encoding/base64"
8
+ "errors"
9
+ "fmt"
10
+ "strings"
11
+ "time"
12
+
13
+ "{{goModule}}/internal/app/user/domain"
14
+ "{{goModule}}/internal/shared/id"
15
+ )
16
+
17
+ // BeginLogin accepts the browser client's state and S256 PKCE challenge and
18
+ // asks the registered provider adapter to construct its authorization URL.
19
+ // The server persists a hashed state transaction binding provider, challenge,
20
+ // and OIDC nonce before the callback reaches ExchangeLogin.
21
+ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginStartInput) (*Authorization, error) {
22
+ provider, ok := s.providers.Lookup(providerName)
23
+ if !ok {
24
+ return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
25
+ }
26
+ // OAuth values are opaque: validate their wire-safe shape without trimming
27
+ // or rewriting what the frontend and provider saw.
28
+ if !validOAuthValue(in.State) || !validPKCEChallenge(in.CodeChallenge) || in.CodeChallengeMethod != "S256" {
29
+ return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and S256 code challenge are required"))
30
+ }
31
+ nonce, err := randomToken()
32
+ if err != nil {
33
+ return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("create oauth transaction: %w", err))
34
+ }
35
+ in.Nonce = nonce
36
+ authorization, err := provider.Begin(ctx, in)
37
+ if err != nil {
38
+ return nil, mapProviderError(err)
39
+ }
40
+ if strings.TrimSpace(authorization.URL) == "" {
41
+ return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider returned an empty authorization URL"))
42
+ }
43
+ clock := s.now
44
+ if clock == nil {
45
+ clock = time.Now
46
+ }
47
+ if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State), LoginTransaction{
48
+ Provider: providerName,
49
+ CodeChallenge: in.CodeChallenge,
50
+ Nonce: nonce,
51
+ ExpiresAt: clock().Add(s.config.OAuthStateTTL),
52
+ }); err != nil {
53
+ return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("store oauth transaction: %w", err))
54
+ }
55
+ return &authorization, nil
56
+ }
57
+
58
+ func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in LoginExchangeInput) (*AuthResult, error) {
59
+ provider, ok := s.providers.Lookup(providerName)
60
+ if !ok {
61
+ return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
62
+ }
63
+ if !validOAuthValue(in.State) || !validPKCEValue(in.CodeVerifier) {
64
+ return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
65
+ }
66
+ if !validOAuthValue(in.Code) {
67
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
68
+ }
69
+
70
+ transaction, ok, err := s.oauthTransactions.ConsumeLoginTransaction(ctx, hashToken(in.State))
71
+ if err != nil {
72
+ return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
73
+ }
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"))
76
+ }
77
+
78
+ identity, err := provider.Complete(ctx, LoginCompleteInput{Code: in.Code, CodeVerifier: in.CodeVerifier, Nonce: transaction.Nonce})
79
+ if err != nil {
80
+ return nil, mapProviderError(err)
81
+ }
82
+ if identity.Provider != providerName {
83
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
84
+ }
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)
90
+ }
91
+
92
+ func validPKCEVerifier(verifier, challenge string) bool {
93
+ if !validPKCEValue(verifier) || !validPKCEValue(challenge) {
94
+ return false
95
+ }
96
+ computed := pkceChallenge(verifier)
97
+ return subtle.ConstantTimeCompare([]byte(computed), []byte(challenge)) == 1
98
+ }
99
+
100
+ // OAuth state and authorization codes use the RFC 6749 VSCHAR shape.
101
+ func validOAuthValue(value string) bool {
102
+ if value == "" {
103
+ return false
104
+ }
105
+ for i := 0; i < len(value); i++ {
106
+ if value[i] < 0x20 || value[i] > 0x7e {
107
+ return false
108
+ }
109
+ }
110
+ return true
111
+ }
112
+
113
+ // RFC 7636 defines code_verifier/code_challenge as 43–128 unreserved ASCII
114
+ // characters. S256 is the only method accepted by this scaffold.
115
+ func validPKCEValue(value string) bool {
116
+ if len(value) < 43 || len(value) > 128 {
117
+ return false
118
+ }
119
+ for i := 0; i < len(value); i++ {
120
+ c := value[i]
121
+ switch {
122
+ case c >= 'A' && c <= 'Z':
123
+ case c >= 'a' && c <= 'z':
124
+ case c >= '0' && c <= '9':
125
+ case c == '-' || c == '.' || c == '_' || c == '~':
126
+ default:
127
+ return false
128
+ }
129
+ }
130
+ return true
131
+ }
132
+
133
+ func validPKCEChallenge(challenge string) bool {
134
+ return validPKCEValue(challenge)
135
+ }
136
+
137
+ func pkceChallenge(verifier string) string {
138
+ sum := sha256.Sum256([]byte(verifier))
139
+ return base64.RawURLEncoding.EncodeToString(sum[:])
140
+ }
141
+
142
+ func mapProviderError(err error) error {
143
+ var oauthErr *OAuthError
144
+ if errors.As(err, &oauthErr) {
145
+ return oauthErr
146
+ }
147
+ if IsProviderUnavailable(err) {
148
+ return NewOAuthError(OAuthProviderUnavailable, err)
149
+ }
150
+ return NewOAuthError(OAuthFailed, err)
151
+ }
152
+
153
+ // findOrCreateExternalUser is provider-neutral identity resolution. A stable
154
+ // provider subject wins; only a verified external email may link to an
155
+ // existing account.
156
+ 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")
159
+ }
160
+ if len(info.Provider) > 20 {
161
+ return nil, fmt.Errorf("external identity provider name is too long")
162
+ }
163
+
164
+ provider := domain.Provider(info.Provider)
165
+ if ident, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
166
+ user, findErr := s.repo.FindByID(ctx, ident.UserID)
167
+ if findErr != nil {
168
+ return nil, fmt.Errorf("find user for existing identity: %w", findErr)
169
+ }
170
+ return user, nil
171
+ } else if !errors.Is(err, domain.ErrNotFound) {
172
+ return nil, fmt.Errorf("find external identity: %w", err)
173
+ }
174
+
175
+ providerUID := info.Subject
176
+ email := normalizeEmail(info.Email)
177
+ if info.EmailVerified {
178
+ if u, err := s.repo.FindByEmail(ctx, email); err == nil {
179
+ ident := &domain.Identity{ID: id.New(), UserID: u.ID, Provider: provider, ProviderUID: &providerUID}
180
+ if err := s.repo.CreateIdentity(ctx, ident); err != nil {
181
+ return nil, fmt.Errorf("link external identity: %w", err)
182
+ }
183
+ return u, nil
184
+ } else if !errors.Is(err, domain.ErrNotFound) {
185
+ return nil, fmt.Errorf("find user by verified external email: %w", err)
186
+ }
187
+ }
188
+
189
+ 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}
191
+ if err := s.repo.CreateUserWithIdentity(ctx, u, ident); err != nil {
192
+ if errors.Is(err, domain.ErrConflict) {
193
+ return nil, errEmailTaken()
194
+ }
195
+ return nil, fmt.Errorf("create external user: %w", err)
196
+ }
197
+ return u, nil
198
+ }
@@ -0,0 +1,58 @@
1
+ package application
2
+
3
+ import (
4
+ "crypto/rand"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+
8
+ "github.com/golang-jwt/jwt/v5"
9
+ "github.com/google/uuid"
10
+ )
11
+
12
+ const (
13
+ tokenTypeAccess = "access"
14
+ )
15
+
16
+ // accessClaims — see internal/shared/middleware/auth.go for why this struct
17
+ // is duplicated there instead of imported.
18
+ type accessClaims struct {
19
+ Typ string `json:"typ"`
20
+ Role string `json:"role,omitempty"`
21
+ // go-scaffold:jwt-claims
22
+ jwt.RegisteredClaims
23
+ }
24
+
25
+ func (s *Service) issueAccessToken(
26
+ userID uuid.UUID,
27
+ role string,
28
+ // go-scaffold:issue-access-token-params
29
+ ) (string, error) {
30
+ now := s.clock()
31
+ claims := accessClaims{
32
+ Typ: tokenTypeAccess,
33
+ Role: role,
34
+ // go-scaffold:jwt-claims-values
35
+ RegisteredClaims: jwt.RegisteredClaims{
36
+ Subject: userID.String(),
37
+ IssuedAt: jwt.NewNumericDate(now),
38
+ ExpiresAt: jwt.NewNumericDate(now.Add(s.config.JWTAccessTTL)),
39
+ },
40
+ }
41
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.config.JWTSecret))
42
+ }
43
+
44
+ // randomToken generates an opaque refresh token — deliberately not a JWT:
45
+ // nothing needs to read claims out of it, and only its hash (see hashToken)
46
+ // is ever stored, so a leaked server-side store never exposes usable tokens.
47
+ func randomToken() (string, error) {
48
+ b := make([]byte, 32)
49
+ if _, err := rand.Read(b); err != nil {
50
+ return "", err
51
+ }
52
+ return hex.EncodeToString(b), nil
53
+ }
54
+
55
+ func hashToken(raw string) string {
56
+ sum := sha256.Sum256([]byte(raw))
57
+ return hex.EncodeToString(sum[:])
58
+ }