@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,311 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "context"
5
+ "crypto/sha256"
6
+ "encoding/base64"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "net/url"
10
+ "strings"
11
+ "testing"
12
+ "time"
13
+
14
+ "{{goModule}}/internal/app/user/application"
15
+ "{{goModule}}/internal/shared/middleware"
16
+
17
+ "github.com/gin-gonic/gin"
18
+ )
19
+
20
+ type allowAllLimiter struct{}
21
+
22
+ func (allowAllLimiter) Allow(_ context.Context, _ string, _ int, _ time.Duration) bool { return true }
23
+
24
+ type allowAllAuthorizer struct{}
25
+
26
+ func (allowAllAuthorizer) Require(_ string) gin.HandlerFunc {
27
+ return func(c *gin.Context) { c.Next() }
28
+ }
29
+
30
+ const handlerOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
31
+
32
+ type stubService struct {
33
+ application.ServicePort
34
+ }
35
+
36
+ func (stubService) BeginLogin(_ context.Context, provider string, in application.LoginStartInput) (*application.Authorization, error) {
37
+ if provider != "fake" {
38
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, nil)
39
+ }
40
+ return &application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
41
+ "state": {in.State},
42
+ "code_challenge": {in.CodeChallenge},
43
+ "code_challenge_method": {in.CodeChallengeMethod},
44
+ }.Encode()}, nil
45
+ }
46
+
47
+ func (stubService) ExchangeLogin(_ context.Context, provider string, in application.LoginExchangeInput) (*application.AuthResult, error) {
48
+ if provider != "fake" {
49
+ return nil, application.NewOAuthError(application.OAuthProviderUnavailable, nil)
50
+ }
51
+ if in.State == "" || in.CodeVerifier == "" {
52
+ return nil, application.NewOAuthError(application.OAuthStateInvalid, nil)
53
+ }
54
+ if in.Code == "" {
55
+ return nil, application.NewOAuthError(application.OAuthFailed, nil)
56
+ }
57
+ return application.TokenResult(&application.AuthResponse{
58
+ AccessToken: "access-token",
59
+ RefreshToken: "refresh-token",
60
+ TokenType: "Bearer",
61
+ ExpiresIn: 900,
62
+ }), nil
63
+ }
64
+
65
+ func (stubService) Logout(context.Context, string) error { return nil }
66
+
67
+ func pkceChallenge(verifier string) string {
68
+ sum := sha256.Sum256([]byte(verifier))
69
+ return base64.RawURLEncoding.EncodeToString(sum[:])
70
+ }
71
+
72
+ func newOAuthTestRouter(t *testing.T) *gin.Engine {
73
+ t.Helper()
74
+ svc := stubService{}
75
+ h := NewHandler(svc, "test-secret", time.Hour, true, "strict", allowAllLimiter{}, allowAllAuthorizer{})
76
+ gin.SetMode(gin.TestMode)
77
+ router := gin.New()
78
+ router.Use(middleware.Error(false))
79
+ h.Register(router)
80
+ return router
81
+ }
82
+
83
+ func newCrossSiteTestRouter(t *testing.T) *gin.Engine {
84
+ t.Helper()
85
+ svc := stubService{}
86
+ h := NewHandlerWithOrigins(svc, "test-secret", time.Hour, true, "none", []string{"https://app.example.test"}, allowAllLimiter{}, allowAllAuthorizer{})
87
+ router := gin.New()
88
+ router.Use(middleware.Error(false))
89
+ h.Register(router)
90
+ return router
91
+ }
92
+
93
+ func beginOAuthFlow(t *testing.T, router *gin.Engine) (string, string) {
94
+ t.Helper()
95
+ const state = "client-state"
96
+ const verifier = handlerOAuthVerifier
97
+ request := httptest.NewRequest(http.MethodGet, "/auth/fake/login?"+url.Values{
98
+ "state": {state},
99
+ "code_challenge": {pkceChallenge(verifier)},
100
+ "code_challenge_method": {"S256"},
101
+ }.Encode(), nil)
102
+ response := httptest.NewRecorder()
103
+ router.ServeHTTP(response, request)
104
+ if response.Code != http.StatusFound {
105
+ t.Fatalf("login status = %d, want 302; body=%s", response.Code, response.Body.String())
106
+ }
107
+ if cookies := response.Result().Cookies(); len(cookies) != 0 {
108
+ t.Fatalf("client-owned callback must not set a server OAuth state cookie: %#v", cookies)
109
+ }
110
+ location, err := url.Parse(response.Header().Get("Location"))
111
+ if err != nil {
112
+ t.Fatalf("parse provider location: %v", err)
113
+ }
114
+ if location.Query().Get("state") != state || location.Query().Get("code_challenge") != pkceChallenge(verifier) || location.Query().Get("code_challenge_method") != "S256" {
115
+ t.Fatalf("provider location did not preserve state/PKCE: %s", location)
116
+ }
117
+ return state, verifier
118
+ }
119
+
120
+ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
121
+ router := newOAuthTestRouter(t)
122
+ state, verifier := beginOAuthFlow(t, router)
123
+ request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(
124
+ "{\"code\":\"code\",\"state\":\""+state+"\",\"code_verifier\":\""+verifier+"\"}",
125
+ ))
126
+ request.Header.Set("Content-Type", "application/json")
127
+ response := httptest.NewRecorder()
128
+ router.ServeHTTP(response, request)
129
+
130
+ if response.Code != http.StatusOK {
131
+ t.Fatalf("exchange status = %d, want 200; body=%s", response.Code, response.Body.String())
132
+ }
133
+ if response.Header().Get("Location") != "" {
134
+ t.Fatalf("exchange must not redirect: %q", response.Header().Get("Location"))
135
+ }
136
+ if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" {
137
+ t.Fatalf("token response must disable caching: Cache-Control=%q Pragma=%q", response.Header().Get("Cache-Control"), response.Header().Get("Pragma"))
138
+ }
139
+ var refreshCookie *http.Cookie
140
+ for _, cookie := range response.Result().Cookies() {
141
+ if cookie.Name == refreshCookieName && cookie.Value != "" {
142
+ refreshCookie = cookie
143
+ break
144
+ }
145
+ }
146
+ if refreshCookie == nil || !refreshCookie.HttpOnly || !refreshCookie.Secure || refreshCookie.Path != "/" {
147
+ t.Fatalf("expected secure HttpOnly refresh cookie, got %#v", refreshCookie)
148
+ }
149
+ if !strings.Contains(response.Body.String(), "\"access_token\"") || strings.Contains(response.Body.String(), "\"refresh_token\"") {
150
+ t.Fatalf("response must expose only the access token, got %s", response.Body.String())
151
+ }
152
+ }
153
+
154
+ func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
155
+ router := newOAuthTestRouter(t)
156
+ callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
157
+ callbackResponse := httptest.NewRecorder()
158
+ router.ServeHTTP(callbackResponse, callback)
159
+ if callbackResponse.Code != http.StatusNotFound {
160
+ t.Fatalf("API callback status = %d, want 404 for frontend-owned callback", callbackResponse.Code)
161
+ }
162
+
163
+ invalid := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange?return_to=https%3A%2F%2Fevil.example", strings.NewReader(
164
+ "{\"code\":\"code\",\"state\":\"\",\"code_verifier\":\""+handlerOAuthVerifier+"\",\"error_description\":\"do-not-leak\"}",
165
+ ))
166
+ invalid.Header.Set("Content-Type", "application/json")
167
+ invalidResponse := httptest.NewRecorder()
168
+ router.ServeHTTP(invalidResponse, invalid)
169
+ if invalidResponse.Code != http.StatusBadRequest || !strings.Contains(invalidResponse.Body.String(), "oauth_state_invalid") {
170
+ t.Fatalf("invalid exchange response = %d %s", invalidResponse.Code, invalidResponse.Body.String())
171
+ }
172
+ if invalidResponse.Header().Get("Location") != "" || strings.Contains(invalidResponse.Body.String(), "do-not-leak") || strings.Contains(invalidResponse.Body.String(), "evil.example") {
173
+ t.Fatalf("exchange exposed redirect or provider details: headers=%v body=%s", invalidResponse.Header(), invalidResponse.Body.String())
174
+ }
175
+ }
176
+
177
+ func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t *testing.T) {
178
+ router := newOAuthTestRouter(t)
179
+ login := httptest.NewRequest(http.MethodGet, "/auth/google/login?"+url.Values{
180
+ "state": {"client-state"},
181
+ "code_challenge": {"client-code-challenge"},
182
+ "code_challenge_method": {"S256"},
183
+ }.Encode(), nil)
184
+ loginResponse := httptest.NewRecorder()
185
+ router.ServeHTTP(loginResponse, login)
186
+ if loginResponse.Code != http.StatusServiceUnavailable || !strings.Contains(loginResponse.Body.String(), "oauth_provider_unavailable") {
187
+ t.Fatalf("unconfigured provider login = %d %s", loginResponse.Code, loginResponse.Body.String())
188
+ }
189
+ if loginResponse.Header().Get("Location") != "" {
190
+ t.Fatalf("unconfigured provider must not redirect: %q", loginResponse.Header().Get("Location"))
191
+ }
192
+
193
+ exchange := httptest.NewRequest(http.MethodPost, "/auth/google/exchange", strings.NewReader(
194
+ "{\"code\":\"code\",\"state\":\"state\",\"code_verifier\":\"verifier\"}",
195
+ ))
196
+ exchange.Header.Set("Content-Type", "application/json")
197
+ exchangeResponse := httptest.NewRecorder()
198
+ router.ServeHTTP(exchangeResponse, exchange)
199
+ if exchangeResponse.Code != http.StatusServiceUnavailable || !strings.Contains(exchangeResponse.Body.String(), "oauth_provider_unavailable") {
200
+ t.Fatalf("unconfigured provider exchange = %d %s", exchangeResponse.Code, exchangeResponse.Body.String())
201
+ }
202
+ if exchangeResponse.Header().Get("Location") != "" {
203
+ t.Fatalf("unconfigured provider exchange must not redirect: %q", exchangeResponse.Header().Get("Location"))
204
+ }
205
+ }
206
+
207
+ func TestHandler_ProviderExchangeRejectsMissingStatePKCEAndCode(t *testing.T) {
208
+ router := newOAuthTestRouter(t)
209
+ tests := []struct {
210
+ name string
211
+ body string
212
+ code string
213
+ }{
214
+ {name: "malformed JSON", body: "{", code: "oauth_failed"},
215
+ {name: "missing state", body: "{\"code\":\"code\",\"code_verifier\":\"" + handlerOAuthVerifier + "\"}", code: "oauth_state_invalid"},
216
+ {name: "missing verifier", body: "{\"code\":\"code\",\"state\":\"state\"}", code: "oauth_state_invalid"},
217
+ {name: "missing code", body: "{\"state\":\"state\",\"code_verifier\":\"" + handlerOAuthVerifier + "\"}", code: "oauth_failed"},
218
+ }
219
+ for _, tt := range tests {
220
+ t.Run(tt.name, func(t *testing.T) {
221
+ request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(tt.body))
222
+ request.Header.Set("Content-Type", "application/json")
223
+ response := httptest.NewRecorder()
224
+ router.ServeHTTP(response, request)
225
+ if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), tt.code) {
226
+ t.Fatalf("exchange response = %d %s, want %s", response.Code, response.Body.String(), tt.code)
227
+ }
228
+ if response.Header().Get("Location") != "" {
229
+ t.Fatalf("exchange must not redirect: %q", response.Header().Get("Location"))
230
+ }
231
+ })
232
+ }
233
+ }
234
+
235
+ func TestHandler_ValidateBrowserCookiePolicy(t *testing.T) {
236
+ tests := []struct {
237
+ name string
238
+ topology string
239
+ sameSite string
240
+ secure bool
241
+ production bool
242
+ wantErr bool
243
+ }{
244
+ {name: "same-site default", topology: "same-site", sameSite: "strict", secure: false},
245
+ {name: "same-origin strict", topology: "same-origin", sameSite: "strict", secure: true},
246
+ {name: "same-site production without secure", topology: "same-site", sameSite: "strict", secure: false, production: true, wantErr: true},
247
+ {name: "same-origin production secure", topology: "same-origin", sameSite: "strict", secure: true, production: true},
248
+ {name: "cross-site secure none", topology: "cross-site", sameSite: "none", secure: true, production: true},
249
+ {name: "cross-site without secure", topology: "cross-site", sameSite: "none", secure: false, wantErr: true},
250
+ {name: "cross-site lax", topology: "cross-site", sameSite: "lax", secure: true, wantErr: true},
251
+ {name: "unknown topology", topology: "mobile", sameSite: "strict", secure: true, wantErr: true},
252
+ }
253
+ for _, tt := range tests {
254
+ t.Run(tt.name, func(t *testing.T) {
255
+ err := ValidateBrowserCookiePolicy(tt.topology, tt.sameSite, tt.secure, tt.production)
256
+ if (err != nil) != tt.wantErr {
257
+ t.Fatalf("validateBrowserCookiePolicy() error = %v, wantErr %v", err, tt.wantErr)
258
+ }
259
+ })
260
+ }
261
+ }
262
+
263
+ func TestHandler_SetRefreshCookie_CrossSiteUsesNoneAndSecure(t *testing.T) {
264
+ h := &Handler{refreshTTL: time.Hour, cookieSecure: true, cookieSameSite: "none"}
265
+ response := httptest.NewRecorder()
266
+ c, _ := gin.CreateTestContext(response)
267
+ h.setRefreshCookie(c, "refresh-token")
268
+
269
+ cookies := response.Result().Cookies()
270
+ if len(cookies) != 1 || cookies[0].Name != refreshCookieName || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly {
271
+ t.Fatalf("expected a cross-site None + Secure HttpOnly refresh cookie, got %#v", cookies)
272
+ }
273
+ }
274
+
275
+ func TestHandler_CrossSiteStateChangingRequestsRequireAnAllowedOrigin(t *testing.T) {
276
+ router := newCrossSiteTestRouter(t)
277
+ tests := []struct {
278
+ name string
279
+ origin string
280
+ wantStatus int
281
+ }{
282
+ {name: "missing origin", wantStatus: http.StatusForbidden},
283
+ {name: "untrusted origin", origin: "https://evil.example.test", wantStatus: http.StatusForbidden},
284
+ {name: "allowed exact origin", origin: "https://app.example.test", wantStatus: http.StatusNoContent},
285
+ }
286
+ for _, tt := range tests {
287
+ t.Run(tt.name, func(t *testing.T) {
288
+ request := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
289
+ if tt.origin != "" {
290
+ request.Header.Set("Origin", tt.origin)
291
+ }
292
+ response := httptest.NewRecorder()
293
+ router.ServeHTTP(response, request)
294
+ if response.Code != tt.wantStatus {
295
+ t.Fatalf("logout status = %d, want %d; body=%s", response.Code, tt.wantStatus, response.Body.String())
296
+ }
297
+ if tt.wantStatus == http.StatusForbidden && !strings.Contains(response.Body.String(), "CSRF_ORIGIN_INVALID") {
298
+ t.Fatalf("missing controlled CSRF error: %s", response.Body.String())
299
+ }
300
+ })
301
+ }
302
+ }
303
+
304
+ func TestHandler_SetNoStoreHeaders(t *testing.T) {
305
+ response := httptest.NewRecorder()
306
+ c, _ := gin.CreateTestContext(response)
307
+ setNoStoreHeaders(c)
308
+ if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" {
309
+ t.Fatalf("unexpected no-store headers: %v", response.Header())
310
+ }
311
+ }
@@ -0,0 +1,41 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/middleware"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ "github.com/google/uuid"
10
+ )
11
+
12
+ func (h *Handler) resendVerification(c *gin.Context) {
13
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
14
+ if err := h.svc.ResendVerificationEmail(c.Request.Context(), userID); err != nil {
15
+ c.Error(toHTTPError(err))
16
+ return
17
+ }
18
+ c.Status(http.StatusNoContent)
19
+ }
20
+
21
+ // logoutAll ends every session for the caller, including the cookie used by
22
+ // this request.
23
+ func (h *Handler) logoutAll(c *gin.Context) {
24
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
25
+ if err := h.svc.LogoutAll(c.Request.Context(), userID); err != nil {
26
+ c.Error(toHTTPError(err))
27
+ return
28
+ }
29
+ h.clearRefreshCookie(c)
30
+ c.Status(http.StatusNoContent)
31
+ }
32
+
33
+ func (h *Handler) me(c *gin.Context) {
34
+ userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
35
+ u, err := h.svc.Get(c.Request.Context(), userID)
36
+ if err != nil {
37
+ c.Error(toHTTPError(err))
38
+ return
39
+ }
40
+ c.JSON(http.StatusOK, toMeResponse(u))
41
+ }
@@ -0,0 +1,35 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/app/user/application"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ func (h *Handler) setRefreshCookie(c *gin.Context, token string) {
12
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
13
+ c.SetCookie(refreshCookieName, token, int(h.refreshTTL.Seconds()), "/", "", h.cookieSecure, true)
14
+ }
15
+
16
+ func (h *Handler) clearRefreshCookie(c *gin.Context) {
17
+ c.SetSameSite(sameSiteFrom(h.cookieSameSite))
18
+ c.SetCookie(refreshCookieName, "", -1, "/", "", h.cookieSecure, true)
19
+ }
20
+
21
+ // Token responses must not be retained by browsers, shared caches, or
22
+ // intermediary middleware.
23
+ func setNoStoreHeaders(c *gin.Context) {
24
+ c.Header("Cache-Control", "no-store")
25
+ c.Header("Pragma", "no-cache")
26
+ }
27
+
28
+ func (h *Handler) writeAuthResult(c *gin.Context, status int, result *application.AuthResult) {
29
+ if result.MFAChallenge != "" {
30
+ c.JSON(http.StatusOK, mfaChallengeResponse{MFARequired: true, Challenge: result.MFAChallenge})
31
+ return
32
+ }
33
+ h.setRefreshCookie(c, result.RefreshToken)
34
+ c.JSON(status, toCookieResponse(result.AuthResponse))
35
+ }
@@ -0,0 +1,35 @@
1
+ package password
2
+
3
+ import (
4
+ userports "{{goModule}}/internal/app/user/ports"
5
+
6
+ "golang.org/x/crypto/bcrypt"
7
+ )
8
+
9
+ // BCryptHasher is the production password adapter. Keeping bcrypt here means
10
+ // register, login, reset, and seed all share one algorithm boundary.
11
+ type BCryptHasher struct {
12
+ cost int
13
+ }
14
+
15
+ func NewBCryptHasher() *BCryptHasher {
16
+ return &BCryptHasher{cost: bcrypt.DefaultCost}
17
+ }
18
+
19
+ func (h *BCryptHasher) Hash(password string) (string, error) {
20
+ cost := h.cost
21
+ if cost <= 0 {
22
+ cost = bcrypt.DefaultCost
23
+ }
24
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
25
+ if err != nil {
26
+ return "", err
27
+ }
28
+ return string(hash), nil
29
+ }
30
+
31
+ func (*BCryptHasher) Compare(hash, password string) error {
32
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
33
+ }
34
+
35
+ var _ userports.PasswordHasher = (*BCryptHasher)(nil)
@@ -0,0 +1,20 @@
1
+ package password
2
+
3
+ import "testing"
4
+
5
+ func TestBCryptHasherRoundTrip(t *testing.T) {
6
+ hasher := NewBCryptHasher()
7
+ hash, err := hasher.Hash("correct horse battery staple")
8
+ if err != nil {
9
+ t.Fatalf("hash password: %v", err)
10
+ }
11
+ if hash == "" {
12
+ t.Fatal("expected a password hash")
13
+ }
14
+ if err := hasher.Compare(hash, "correct horse battery staple"); err != nil {
15
+ t.Fatalf("compare matching password: %v", err)
16
+ }
17
+ if err := hasher.Compare(hash, "wrong password"); err == nil {
18
+ t.Fatal("expected a mismatched password to be rejected")
19
+ }
20
+ }
@@ -0,0 +1,129 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "time"
9
+
10
+ "{{goModule}}/internal/app/user/ports"
11
+ "{{goModule}}/internal/shared/tx"
12
+
13
+ "github.com/google/uuid"
14
+ "gorm.io/gorm"
15
+ "gorm.io/gorm/clause"
16
+ )
17
+
18
+ // PostgresMFAStore is intentionally independent of the selected refresh-token
19
+ // adapter. MFA challenges and recovery codes need durable, atomic semantics in
20
+ // every deployment, including Redis-backed auth behind multiple replicas.
21
+ type PostgresMFAStore struct {
22
+ db *gorm.DB
23
+ }
24
+
25
+ var _ ports.MFAStore = (*PostgresMFAStore)(nil)
26
+
27
+ func NewPostgresMFAStore(db *gorm.DB) *PostgresMFAStore { return &PostgresMFAStore{db: db} }
28
+
29
+ func (s *PostgresMFAStore) GetEnrollment(ctx context.Context, userID uuid.UUID) (ports.MFAEnrollment, bool, error) {
30
+ var row MFAEnrollment
31
+ err := tx.From(ctx, s.db).WithContext(ctx).First(&row, "user_id = ?", userID).Error
32
+ if errors.Is(err, gorm.ErrRecordNotFound) {
33
+ return ports.MFAEnrollment{}, false, nil
34
+ }
35
+ if err != nil {
36
+ return ports.MFAEnrollment{}, false, err
37
+ }
38
+ return ports.MFAEnrollment{EncryptedSecret: row.EncryptedSecret, Enabled: row.Enabled}, true, nil
39
+ }
40
+
41
+ func (s *PostgresMFAStore) PutPendingEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string) error {
42
+ if userID == uuid.Nil || encryptedSecret == "" {
43
+ return errors.New("MFA enrollment is incomplete")
44
+ }
45
+ row := MFAEnrollment{UserID: userID, EncryptedSecret: encryptedSecret, Enabled: false}
46
+ return tx.From(ctx, s.db).WithContext(ctx).
47
+ Where("user_id = ? AND enabled = ?", userID, false).
48
+ Assign(map[string]any{"encrypted_secret": encryptedSecret, "enabled": false, "updated_at": time.Now()}).
49
+ FirstOrCreate(&row).Error
50
+ }
51
+
52
+ func (s *PostgresMFAStore) ConfirmEnrollment(ctx context.Context, userID uuid.UUID, encryptedSecret string, recoveryCodeHashes []string) error {
53
+ if userID == uuid.Nil || encryptedSecret == "" || len(recoveryCodeHashes) == 0 {
54
+ return errors.New("MFA enrollment confirmation is incomplete")
55
+ }
56
+ return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
57
+ var enrollment MFAEnrollment
58
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ?", userID).First(&enrollment).Error; err != nil {
59
+ return err
60
+ }
61
+ if enrollment.Enabled {
62
+ return errors.New("MFA enrollment is already enabled")
63
+ }
64
+ if err := db.Model(&enrollment).Updates(map[string]any{
65
+ "encrypted_secret": encryptedSecret,
66
+ "enabled": true,
67
+ "updated_at": time.Now(),
68
+ }).Error; err != nil {
69
+ return err
70
+ }
71
+ if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
72
+ return err
73
+ }
74
+ rows := make([]MFARecoveryCode, len(recoveryCodeHashes))
75
+ for i, hash := range recoveryCodeHashes {
76
+ rows[i] = MFARecoveryCode{UserID: userID, CodeHash: hash}
77
+ }
78
+ return db.Create(&rows).Error
79
+ })
80
+ }
81
+
82
+ func (s *PostgresMFAStore) Disable(ctx context.Context, userID uuid.UUID) error {
83
+ return tx.From(ctx, s.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
84
+ if err := db.Where("user_id = ?", userID).Delete(&MFARecoveryCode{}).Error; err != nil {
85
+ return err
86
+ }
87
+ return db.Where("user_id = ?", userID).Delete(&MFAEnrollment{}).Error
88
+ })
89
+ }
90
+
91
+ func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, challenge ports.MFAChallenge) error {
92
+ if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
93
+ return fmt.Errorf("MFA challenge is invalid")
94
+ }
95
+ row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
96
+ return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
97
+ }
98
+
99
+ func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (ports.MFAChallenge, bool, error) {
100
+ var challenge ports.MFAChallenge
101
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
102
+ `DELETE FROM user_svc.mfa_challenges
103
+ WHERE challenge_hash = ? AND expires_at > now()
104
+ RETURNING user_id, expires_at`, hash,
105
+ ).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
106
+ if errors.Is(err, sql.ErrNoRows) {
107
+ return ports.MFAChallenge{}, false, nil
108
+ }
109
+ if err != nil {
110
+ return ports.MFAChallenge{}, false, err
111
+ }
112
+ return challenge, true, nil
113
+ }
114
+
115
+ func (s *PostgresMFAStore) ConsumeRecoveryCode(ctx context.Context, userID uuid.UUID, hash string) (bool, error) {
116
+ var consumed string
117
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
118
+ `DELETE FROM user_svc.mfa_recovery_codes
119
+ WHERE user_id = ? AND code_hash = ?
120
+ RETURNING code_hash`, userID, hash,
121
+ ).Row().Scan(&consumed)
122
+ if errors.Is(err, sql.ErrNoRows) {
123
+ return false, nil
124
+ }
125
+ if err != nil {
126
+ return false, err
127
+ }
128
+ return consumed != "", nil
129
+ }