@nakedev/go-scaffold 0.3.3 → 0.4.3

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 (119) hide show
  1. package/README.md +288 -50
  2. package/dist/commands/auth.js +53 -22
  3. package/dist/commands/config.js +50 -0
  4. package/dist/commands/create.js +32 -2
  5. package/dist/commands/generate.js +25 -2
  6. package/dist/commands/method.js +22 -7
  7. package/dist/commands/migration.js +2 -2
  8. package/dist/commands/observability.js +3 -3
  9. package/dist/commands/rbac.js +3 -3
  10. package/dist/commands/undo.js +5 -0
  11. package/dist/commands/worker.js +1 -1
  12. package/dist/index.js +186 -59
  13. package/dist/prompts/auth-wizard.js +40 -6
  14. package/dist/prompts/create-wizard.js +43 -2
  15. package/dist/prompts/generate-wizard.js +89 -9
  16. package/dist/templates/auth-manifest.js +31 -1
  17. package/dist/templates/create-manifest.js +4 -0
  18. package/dist/templates/module-manifest.js +37 -1
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/types.js +6 -0
  21. package/dist/utils/auth-patcher.js +115 -24
  22. package/dist/utils/config.js +147 -3
  23. package/dist/utils/main-patcher.js +29 -27
  24. package/dist/utils/marker-patch.js +7 -1
  25. package/dist/utils/method-patcher.js +261 -81
  26. package/dist/utils/module-profile.js +32 -0
  27. package/dist/utils/observability-patcher.js +2 -2
  28. package/dist/utils/platform-patcher.js +29 -7
  29. package/dist/utils/rbac-patcher.js +97 -75
  30. package/package.json +7 -2
  31. package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
  32. package/templates/add/auth/docs/login.yaml.hbs +11 -1
  33. package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
  35. package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
  36. package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
  37. package/templates/add/auth/docs/register.yaml.hbs +7 -0
  38. package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
  39. package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
  40. package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
  41. package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
  42. package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
  43. package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
  44. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
  45. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
  46. package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
  47. package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
  48. package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
  49. package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
  50. package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
  51. package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
  52. package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
  53. package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
  54. package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
  55. package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
  56. package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
  57. package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
  58. package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
  59. package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
  60. package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
  61. package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
  62. package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
  63. package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
  64. package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
  65. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
  66. package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
  67. package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
  68. package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
  69. package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
  70. package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
  71. package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
  72. package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
  73. package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
  74. package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
  75. package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
  76. package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
  77. package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
  78. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
  79. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
  80. package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
  81. package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
  82. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
  83. package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
  84. package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
  85. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
  86. package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
  87. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
  88. package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
  89. package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
  90. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
  91. package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
  92. package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
  93. package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
  94. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
  95. package/templates/create/base/.env.example.hbs +0 -1
  96. package/templates/create/base/AGENTS.md.hbs +255 -67
  97. package/templates/create/base/Makefile.hbs +2 -1
  98. package/templates/create/base/README.md.hbs +45 -17
  99. package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
  100. package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
  101. package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
  102. package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
  103. package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
  104. package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
  105. package/templates/create/features/docs/architecture.md.hbs +38 -16
  106. package/templates/create/features/docs/patterns.md.hbs +40 -21
  107. package/templates/create/features/docs/techstack.md.hbs +3 -3
  108. package/templates/generate/module/commands.go.hbs +95 -0
  109. package/templates/generate/module/composition.go.hbs +23 -0
  110. package/templates/generate/module/cqrs_test.go.hbs +7 -0
  111. package/templates/generate/module/handler.go.hbs +50 -5
  112. package/templates/generate/module/minimal/commands.go.hbs +34 -0
  113. package/templates/generate/module/minimal/handler.go.hbs +34 -0
  114. package/templates/generate/module/minimal/queries.go.hbs +45 -0
  115. package/templates/generate/module/minimal/service.go.hbs +27 -1
  116. package/templates/generate/module/queries.go.hbs +62 -0
  117. package/templates/generate/module/service.go.hbs +61 -5
  118. package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
  119. package/templates/add/auth/docs/google-login.yaml.hbs +0 -7
@@ -0,0 +1,83 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+ "{{goModule}}/internal/shared/middleware"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ func (h *Handler) verifyMFA(c *gin.Context) {
14
+ setNoStoreHeaders(c)
15
+ if !h.requireBrowserOrigin(c) {
16
+ return
17
+ }
18
+ var in mfaChallengeInput
19
+ if err := c.ShouldBindJSON(&in); err != nil {
20
+ c.Error(httpx.BindErr(err))
21
+ return
22
+ }
23
+ auth, err := h.svc.VerifyMFA(c.Request.Context(), in.Challenge, in.Code)
24
+ if err != nil {
25
+ c.Error(err)
26
+ return
27
+ }
28
+ h.setRefreshCookie(c, auth.RefreshToken)
29
+ c.JSON(http.StatusOK, toCookieResponse(auth))
30
+ }
31
+
32
+ func (h *Handler) mfaStatus(c *gin.Context) {
33
+ setNoStoreHeaders(c)
34
+ status, err := h.svc.MFAStatus(c.Request.Context(), currentUserID(c))
35
+ if err != nil {
36
+ c.Error(err)
37
+ return
38
+ }
39
+ c.JSON(http.StatusOK, mfaStatusResponse(status))
40
+ }
41
+
42
+ func (h *Handler) setupMFA(c *gin.Context) {
43
+ setNoStoreHeaders(c)
44
+ setup, err := h.svc.SetupMFA(c.Request.Context(), currentUserID(c))
45
+ if err != nil {
46
+ c.Error(err)
47
+ return
48
+ }
49
+ c.JSON(http.StatusOK, mfaSetupResponse{Secret: setup.Secret, OTPAuthURI: setup.OTPAuthURI})
50
+ }
51
+
52
+ func (h *Handler) confirmMFA(c *gin.Context) {
53
+ setNoStoreHeaders(c)
54
+ var in mfaCodeInput
55
+ if err := c.ShouldBindJSON(&in); err != nil {
56
+ c.Error(httpx.BindErr(err))
57
+ return
58
+ }
59
+ codes, err := h.svc.ConfirmMFA(c.Request.Context(), currentUserID(c), in.Code)
60
+ if err != nil {
61
+ c.Error(err)
62
+ return
63
+ }
64
+ c.JSON(http.StatusOK, mfaConfirmResponse{RecoveryCodes: codes})
65
+ }
66
+
67
+ func (h *Handler) disableMFA(c *gin.Context) {
68
+ setNoStoreHeaders(c)
69
+ var in mfaCodeInput
70
+ if err := c.ShouldBindJSON(&in); err != nil {
71
+ c.Error(httpx.BindErr(err))
72
+ return
73
+ }
74
+ if err := h.svc.DisableMFA(c.Request.Context(), currentUserID(c), in.Code); err != nil {
75
+ c.Error(err)
76
+ return
77
+ }
78
+ c.Status(http.StatusNoContent)
79
+ }
80
+
81
+ func currentUserID(c *gin.Context) uuid.UUID {
82
+ return c.MustGet(middleware.UserIDKey).(uuid.UUID)
83
+ }
@@ -0,0 +1,70 @@
1
+ package user
2
+
3
+ import (
4
+ "errors"
5
+ "log/slog"
6
+ "net/http"
7
+
8
+ "{{goModule}}/internal/app/user/application"
9
+ "{{goModule}}/internal/shared/apperror"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ func (h *Handler) providerLogin(c *gin.Context) {
15
+ start, err := h.svc.BeginLogin(c.Request.Context(), c.Param("provider"), application.LoginStartInput{
16
+ State: c.Query("state"),
17
+ CodeChallenge: c.Query("code_challenge"),
18
+ CodeChallengeMethod: c.Query("code_challenge_method"),
19
+ })
20
+ if err != nil {
21
+ c.Error(h.oauthAppError(c.Param("provider"), err))
22
+ return
23
+ }
24
+ c.Redirect(http.StatusFound, start.URL)
25
+ }
26
+
27
+ func (h *Handler) providerExchange(c *gin.Context) {
28
+ setNoStoreHeaders(c)
29
+ if !h.requireBrowserOrigin(c) {
30
+ return
31
+ }
32
+ var in loginExchangeInput
33
+ if err := c.ShouldBindJSON(&in); err != nil {
34
+ c.Error(h.oauthAppError(c.Param("provider"), application.NewOAuthError(application.OAuthFailed, err)))
35
+ return
36
+ }
37
+ auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), in)
38
+ if err != nil {
39
+ c.Error(h.oauthAppError(c.Param("provider"), err))
40
+ return
41
+ }
42
+ h.writeAuthResult(c, http.StatusOK, auth)
43
+ }
44
+
45
+ func (h *Handler) oauthAppError(provider string, err error) *apperror.AppError {
46
+ code := application.OAuthFailed
47
+ status := http.StatusBadRequest
48
+ message := "external login failed"
49
+ var oauthErr *application.OAuthError
50
+ if errors.As(err, &oauthErr) {
51
+ switch oauthErr.Code {
52
+ case application.OAuthDenied:
53
+ code = application.OAuthDenied
54
+ message = "external login was denied"
55
+ case application.OAuthStateInvalid:
56
+ code = application.OAuthStateInvalid
57
+ message = "oauth state or PKCE verifier is invalid"
58
+ case application.OAuthProviderUnavailable:
59
+ code = application.OAuthProviderUnavailable
60
+ status = http.StatusServiceUnavailable
61
+ message = "external login provider is unavailable"
62
+ case application.OAuthFailed:
63
+ code = application.OAuthFailed
64
+ default:
65
+ code = application.OAuthFailed
66
+ }
67
+ }
68
+ slog.Warn("oauth request failed", "provider", provider, "code", code, "error", err)
69
+ return apperror.New(status, string(code), message)
70
+ }
@@ -0,0 +1,49 @@
1
+ package user
2
+
3
+ import (
4
+ "net/http"
5
+
6
+ "{{goModule}}/internal/shared/httpx"
7
+
8
+ "github.com/gin-gonic/gin"
9
+ )
10
+
11
+ func (h *Handler) forgotPassword(c *gin.Context) {
12
+ var in forgotPasswordInput
13
+ if err := c.ShouldBindJSON(&in); err != nil {
14
+ c.Error(httpx.BindErr(err))
15
+ return
16
+ }
17
+ if err := h.svc.ForgotPassword(c.Request.Context(), in.Email); err != nil {
18
+ c.Error(err)
19
+ return
20
+ }
21
+ // Always the same response, whether or not the email exists.
22
+ c.JSON(http.StatusOK, gin.H{"message": "if that email exists, a reset link has been sent"})
23
+ }
24
+
25
+ func (h *Handler) resetPassword(c *gin.Context) {
26
+ var in resetPasswordInput
27
+ if err := c.ShouldBindJSON(&in); err != nil {
28
+ c.Error(httpx.BindErr(err))
29
+ return
30
+ }
31
+ if err := h.svc.ResetPassword(c.Request.Context(), in.Token, in.NewPassword); err != nil {
32
+ c.Error(err)
33
+ return
34
+ }
35
+ c.Status(http.StatusNoContent)
36
+ }
37
+
38
+ func (h *Handler) verifyEmail(c *gin.Context) {
39
+ var in verifyEmailInput
40
+ if err := c.ShouldBindJSON(&in); err != nil {
41
+ c.Error(httpx.BindErr(err))
42
+ return
43
+ }
44
+ if err := h.svc.VerifyEmail(c.Request.Context(), in.Token); err != nil {
45
+ c.Error(err)
46
+ return
47
+ }
48
+ c.Status(http.StatusNoContent)
49
+ }
@@ -0,0 +1,290 @@
1
+ package user
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "net/url"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "{{goModule}}/internal/app/user/application"
13
+ "{{goModule}}/internal/shared/middleware"
14
+
15
+ "github.com/gin-gonic/gin"
16
+ )
17
+
18
+ type allowAllLimiter struct{}
19
+
20
+ func (allowAllLimiter) Allow(_ context.Context, _ string, _ int, _ time.Duration) bool { return true }
21
+
22
+ type allowAllAuthorizer struct{}
23
+
24
+ func (allowAllAuthorizer) Require(_ string) gin.HandlerFunc {
25
+ return func(c *gin.Context) { c.Next() }
26
+ }
27
+
28
+ const handlerOAuthVerifier = "client-code-verifier-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
29
+
30
+ func newOAuthTestRouter(t *testing.T) (*gin.Engine, *fakeLoginProvider) {
31
+ t.Helper()
32
+ provider := &fakeLoginProvider{name: "fake"}
33
+ provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
34
+ return application.Authorization{URL: "https://provider.example.test/authorize?" + url.Values{
35
+ "state": {in.State},
36
+ "code_challenge": {in.CodeChallenge},
37
+ "code_challenge_method": {in.CodeChallengeMethod},
38
+ }.Encode()}, nil
39
+ }
40
+ provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
41
+ return application.ExternalIdentity{Provider: "fake", Subject: "subject", Email: "user@example.com", EmailVerified: true, Name: "User"}, nil
42
+ }
43
+ svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
44
+ h := NewHandler(svc, "test-secret", time.Hour, true, "strict", allowAllLimiter{}, allowAllAuthorizer{})
45
+ gin.SetMode(gin.TestMode)
46
+ router := gin.New()
47
+ router.Use(middleware.Error(false))
48
+ h.Register(router)
49
+ return router, provider
50
+ }
51
+
52
+ func newCrossSiteTestRouter(t *testing.T) *gin.Engine {
53
+ t.Helper()
54
+ provider := &fakeLoginProvider{name: "fake"}
55
+ provider.beginFn = func(in application.LoginStartInput) (application.Authorization, error) {
56
+ return application.Authorization{URL: "https://provider.example.test/authorize?state=" + url.QueryEscape(in.State)}, nil
57
+ }
58
+ provider.completeFn = func(application.LoginCompleteInput) (application.ExternalIdentity, error) {
59
+ return application.ExternalIdentity{Provider: "fake", Subject: "subject", Email: "user@example.com", EmailVerified: true}, nil
60
+ }
61
+ svc := newTestServiceWithProviders(&fakeRepo{}, newFakeTokenStore(), provider)
62
+ h := NewHandlerWithOrigins(svc, "test-secret", time.Hour, true, "none", []string{"https://app.example.test"}, allowAllLimiter{}, allowAllAuthorizer{})
63
+ router := gin.New()
64
+ router.Use(middleware.Error(false))
65
+ h.Register(router)
66
+ return router
67
+ }
68
+
69
+ func beginOAuthFlow(t *testing.T, router *gin.Engine) (string, string) {
70
+ t.Helper()
71
+ const state = "client-state"
72
+ const verifier = handlerOAuthVerifier
73
+ request := httptest.NewRequest(http.MethodGet, "/auth/fake/login?"+url.Values{
74
+ "state": {state},
75
+ "code_challenge": {pkceChallenge(verifier)},
76
+ "code_challenge_method": {"S256"},
77
+ }.Encode(), nil)
78
+ response := httptest.NewRecorder()
79
+ router.ServeHTTP(response, request)
80
+ if response.Code != http.StatusFound {
81
+ t.Fatalf("login status = %d, want 302; body=%s", response.Code, response.Body.String())
82
+ }
83
+ if cookies := response.Result().Cookies(); len(cookies) != 0 {
84
+ t.Fatalf("client-owned callback must not set a server OAuth state cookie: %#v", cookies)
85
+ }
86
+ location, err := url.Parse(response.Header().Get("Location"))
87
+ if err != nil {
88
+ t.Fatalf("parse provider location: %v", err)
89
+ }
90
+ if location.Query().Get("state") != state || location.Query().Get("code_challenge") != pkceChallenge(verifier) || location.Query().Get("code_challenge_method") != "S256" {
91
+ t.Fatalf("provider location did not preserve state/PKCE: %s", location)
92
+ }
93
+ return state, verifier
94
+ }
95
+
96
+ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
97
+ router, _ := newOAuthTestRouter(t)
98
+ state, verifier := beginOAuthFlow(t, router)
99
+ request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(
100
+ "{\"code\":\"code\",\"state\":\"" + state + "\",\"code_verifier\":\"" + verifier + "\"}",
101
+ ))
102
+ request.Header.Set("Content-Type", "application/json")
103
+ response := httptest.NewRecorder()
104
+ router.ServeHTTP(response, request)
105
+
106
+ if response.Code != http.StatusOK {
107
+ t.Fatalf("exchange status = %d, want 200; body=%s", response.Code, response.Body.String())
108
+ }
109
+ if response.Header().Get("Location") != "" {
110
+ t.Fatalf("exchange must not redirect: %q", response.Header().Get("Location"))
111
+ }
112
+ if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" {
113
+ t.Fatalf("token response must disable caching: Cache-Control=%q Pragma=%q", response.Header().Get("Cache-Control"), response.Header().Get("Pragma"))
114
+ }
115
+ var refreshCookie *http.Cookie
116
+ for _, cookie := range response.Result().Cookies() {
117
+ if cookie.Name == refreshCookieName && cookie.Value != "" {
118
+ refreshCookie = cookie
119
+ break
120
+ }
121
+ }
122
+ if refreshCookie == nil || !refreshCookie.HttpOnly || !refreshCookie.Secure || refreshCookie.Path != "/" {
123
+ t.Fatalf("expected secure HttpOnly refresh cookie, got %#v", refreshCookie)
124
+ }
125
+ if !strings.Contains(response.Body.String(), "\"access_token\"") || strings.Contains(response.Body.String(), "\"refresh_token\"") {
126
+ t.Fatalf("response must expose only the access token, got %s", response.Body.String())
127
+ }
128
+ }
129
+
130
+ func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
131
+ router, provider := newOAuthTestRouter(t)
132
+ callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
133
+ callbackResponse := httptest.NewRecorder()
134
+ router.ServeHTTP(callbackResponse, callback)
135
+ if callbackResponse.Code != http.StatusNotFound {
136
+ t.Fatalf("API callback status = %d, want 404 for frontend-owned callback", callbackResponse.Code)
137
+ }
138
+ if provider.completeCnt != 0 {
139
+ t.Fatal("provider must not exchange a frontend callback error")
140
+ }
141
+
142
+ invalid := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange?return_to=https%3A%2F%2Fevil.example", strings.NewReader(
143
+ "{\"code\":\"code\",\"state\":\"\",\"code_verifier\":\""+handlerOAuthVerifier+"\",\"error_description\":\"do-not-leak\"}",
144
+ ))
145
+ invalid.Header.Set("Content-Type", "application/json")
146
+ invalidResponse := httptest.NewRecorder()
147
+ router.ServeHTTP(invalidResponse, invalid)
148
+ if invalidResponse.Code != http.StatusBadRequest || !strings.Contains(invalidResponse.Body.String(), "oauth_state_invalid") {
149
+ t.Fatalf("invalid exchange response = %d %s", invalidResponse.Code, invalidResponse.Body.String())
150
+ }
151
+ if invalidResponse.Header().Get("Location") != "" || strings.Contains(invalidResponse.Body.String(), "do-not-leak") || strings.Contains(invalidResponse.Body.String(), "evil.example") {
152
+ t.Fatalf("exchange exposed redirect or provider details: headers=%v body=%s", invalidResponse.Header(), invalidResponse.Body.String())
153
+ }
154
+ }
155
+
156
+ func TestHandler_ProviderLoginAndExchangeUnconfiguredProviderUseControlledJSON(t *testing.T) {
157
+ router, _ := newOAuthTestRouter(t)
158
+ login := httptest.NewRequest(http.MethodGet, "/auth/google/login?"+url.Values{
159
+ "state": {"client-state"},
160
+ "code_challenge": {"client-code-challenge"},
161
+ "code_challenge_method": {"S256"},
162
+ }.Encode(), nil)
163
+ loginResponse := httptest.NewRecorder()
164
+ router.ServeHTTP(loginResponse, login)
165
+ if loginResponse.Code != http.StatusServiceUnavailable || !strings.Contains(loginResponse.Body.String(), "oauth_provider_unavailable") {
166
+ t.Fatalf("unconfigured provider login = %d %s", loginResponse.Code, loginResponse.Body.String())
167
+ }
168
+ if loginResponse.Header().Get("Location") != "" {
169
+ t.Fatalf("unconfigured provider must not redirect: %q", loginResponse.Header().Get("Location"))
170
+ }
171
+
172
+ exchange := httptest.NewRequest(http.MethodPost, "/auth/google/exchange", strings.NewReader(
173
+ "{\"code\":\"code\",\"state\":\"state\",\"code_verifier\":\"verifier\"}",
174
+ ))
175
+ exchange.Header.Set("Content-Type", "application/json")
176
+ exchangeResponse := httptest.NewRecorder()
177
+ router.ServeHTTP(exchangeResponse, exchange)
178
+ if exchangeResponse.Code != http.StatusServiceUnavailable || !strings.Contains(exchangeResponse.Body.String(), "oauth_provider_unavailable") {
179
+ t.Fatalf("unconfigured provider exchange = %d %s", exchangeResponse.Code, exchangeResponse.Body.String())
180
+ }
181
+ if exchangeResponse.Header().Get("Location") != "" {
182
+ t.Fatalf("unconfigured provider exchange must not redirect: %q", exchangeResponse.Header().Get("Location"))
183
+ }
184
+ }
185
+
186
+ func TestHandler_ProviderExchangeRejectsMissingStatePKCEAndCode(t *testing.T) {
187
+ router, _ := newOAuthTestRouter(t)
188
+ tests := []struct {
189
+ name string
190
+ body string
191
+ code string
192
+ }{
193
+ {name: "malformed JSON", body: "{", code: "oauth_failed"},
194
+ {name: "missing state", body: "{\"code\":\"code\",\"code_verifier\":\"" + handlerOAuthVerifier + "\"}", code: "oauth_state_invalid"},
195
+ {name: "missing verifier", body: "{\"code\":\"code\",\"state\":\"state\"}", code: "oauth_state_invalid"},
196
+ {name: "missing code", body: "{\"state\":\"state\",\"code_verifier\":\"" + handlerOAuthVerifier + "\"}", code: "oauth_failed"},
197
+ }
198
+ for _, tt := range tests {
199
+ t.Run(tt.name, func(t *testing.T) {
200
+ request := httptest.NewRequest(http.MethodPost, "/auth/fake/exchange", strings.NewReader(tt.body))
201
+ request.Header.Set("Content-Type", "application/json")
202
+ response := httptest.NewRecorder()
203
+ router.ServeHTTP(response, request)
204
+ if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), tt.code) {
205
+ t.Fatalf("exchange response = %d %s, want %s", response.Code, response.Body.String(), tt.code)
206
+ }
207
+ if response.Header().Get("Location") != "" {
208
+ t.Fatalf("exchange must not redirect: %q", response.Header().Get("Location"))
209
+ }
210
+ })
211
+ }
212
+ }
213
+
214
+ func TestHandler_ValidateBrowserCookiePolicy(t *testing.T) {
215
+ tests := []struct {
216
+ name string
217
+ topology string
218
+ sameSite string
219
+ secure bool
220
+ production bool
221
+ wantErr bool
222
+ }{
223
+ {name: "same-site default", topology: "same-site", sameSite: "strict", secure: false},
224
+ {name: "same-origin strict", topology: "same-origin", sameSite: "strict", secure: true},
225
+ {name: "same-site production without secure", topology: "same-site", sameSite: "strict", secure: false, production: true, wantErr: true},
226
+ {name: "same-origin production secure", topology: "same-origin", sameSite: "strict", secure: true, production: true},
227
+ {name: "cross-site secure none", topology: "cross-site", sameSite: "none", secure: true, production: true},
228
+ {name: "cross-site without secure", topology: "cross-site", sameSite: "none", secure: false, wantErr: true},
229
+ {name: "cross-site lax", topology: "cross-site", sameSite: "lax", secure: true, wantErr: true},
230
+ {name: "unknown topology", topology: "mobile", sameSite: "strict", secure: true, wantErr: true},
231
+ }
232
+ for _, tt := range tests {
233
+ t.Run(tt.name, func(t *testing.T) {
234
+ err := validateBrowserCookiePolicy(tt.topology, tt.sameSite, tt.secure, tt.production)
235
+ if (err != nil) != tt.wantErr {
236
+ t.Fatalf("validateBrowserCookiePolicy() error = %v, wantErr %v", err, tt.wantErr)
237
+ }
238
+ })
239
+ }
240
+ }
241
+
242
+ func TestHandler_SetRefreshCookie_CrossSiteUsesNoneAndSecure(t *testing.T) {
243
+ h := &Handler{refreshTTL: time.Hour, cookieSecure: true, cookieSameSite: "none"}
244
+ response := httptest.NewRecorder()
245
+ c, _ := gin.CreateTestContext(response)
246
+ h.setRefreshCookie(c, "refresh-token")
247
+
248
+ cookies := response.Result().Cookies()
249
+ if len(cookies) != 1 || cookies[0].Name != refreshCookieName || cookies[0].SameSite != http.SameSiteNoneMode || !cookies[0].Secure || !cookies[0].HttpOnly {
250
+ t.Fatalf("expected a cross-site None + Secure HttpOnly refresh cookie, got %#v", cookies)
251
+ }
252
+ }
253
+
254
+ func TestHandler_CrossSiteStateChangingRequestsRequireAnAllowedOrigin(t *testing.T) {
255
+ router := newCrossSiteTestRouter(t)
256
+ tests := []struct {
257
+ name string
258
+ origin string
259
+ wantStatus int
260
+ }{
261
+ {name: "missing origin", wantStatus: http.StatusForbidden},
262
+ {name: "untrusted origin", origin: "https://evil.example.test", wantStatus: http.StatusForbidden},
263
+ {name: "allowed exact origin", origin: "https://app.example.test", wantStatus: http.StatusNoContent},
264
+ }
265
+ for _, tt := range tests {
266
+ t.Run(tt.name, func(t *testing.T) {
267
+ request := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
268
+ if tt.origin != "" {
269
+ request.Header.Set("Origin", tt.origin)
270
+ }
271
+ response := httptest.NewRecorder()
272
+ router.ServeHTTP(response, request)
273
+ if response.Code != tt.wantStatus {
274
+ t.Fatalf("logout status = %d, want %d; body=%s", response.Code, tt.wantStatus, response.Body.String())
275
+ }
276
+ if tt.wantStatus == http.StatusForbidden && !strings.Contains(response.Body.String(), "CSRF_ORIGIN_INVALID") {
277
+ t.Fatalf("missing controlled CSRF error: %s", response.Body.String())
278
+ }
279
+ })
280
+ }
281
+ }
282
+
283
+ func TestHandler_SetNoStoreHeaders(t *testing.T) {
284
+ response := httptest.NewRecorder()
285
+ c, _ := gin.CreateTestContext(response)
286
+ setNoStoreHeaders(c)
287
+ if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Pragma") != "no-cache" {
288
+ t.Fatalf("unexpected no-store headers: %v", response.Header())
289
+ }
290
+ }
@@ -0,0 +1,41 @@
1
+ package user
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(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(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(err)
38
+ return
39
+ }
40
+ c.JSON(http.StatusOK, toMeResponse(u))
41
+ }
@@ -3,28 +3,20 @@ package user
3
3
  import (
4
4
  "crypto/rand"
5
5
  "crypto/sha256"
6
- "crypto/subtle"
7
6
  "encoding/hex"
8
- "time"
9
7
 
10
8
  "github.com/golang-jwt/jwt/v5"
11
9
  "github.com/google/uuid"
12
10
  )
13
11
 
14
12
  const (
15
- tokenTypeAccess = "access"
16
- tokenTypeOAuthState = "oauth_state"
17
- oauthStateTTL = 10 * time.Minute
13
+ tokenTypeAccess = "access"
18
14
  )
19
15
 
20
16
  // accessClaims — see internal/shared/middleware/auth.go for why this struct
21
- // is duplicated there instead of imported. Also reused (with no Subject) for
22
- // the Google OAuth CSRF state token below — Typ is what stops one kind being
23
- // presented where the other is expected.
17
+ // is duplicated there instead of imported.
24
18
  type accessClaims struct {
25
19
  Typ string `json:"typ"`
26
- // Nonce is only set on the OAuth state token — see issueOAuthState.
27
- Nonce string `json:"nonce,omitempty"`
28
20
  // go-scaffold:jwt-claims
29
21
  jwt.RegisteredClaims
30
22
  }
@@ -33,62 +25,17 @@ func (s *Service) issueAccessToken(
33
25
  userID uuid.UUID,
34
26
  // go-scaffold:issue-access-token-params
35
27
  ) (string, error) {
28
+ now := s.clock()
36
29
  claims := accessClaims{
37
30
  Typ: tokenTypeAccess,
38
31
  // go-scaffold:jwt-claims-values
39
32
  RegisteredClaims: jwt.RegisteredClaims{
40
33
  Subject: userID.String(),
41
- IssuedAt: jwt.NewNumericDate(time.Now()),
42
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTTL)),
34
+ IssuedAt: jwt.NewNumericDate(now),
35
+ ExpiresAt: jwt.NewNumericDate(now.Add(s.config.JWTAccessTTL)),
43
36
  },
44
37
  }
45
- return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
46
- }
47
-
48
- // issueOAuthState / verifyOAuthState round-trip a short-lived CSRF token
49
- // through Google's own redirect. The signature and TTL are not the whole
50
- // protection: a signed-but-unbound state is one anyone can fetch from
51
- // GET /auth/google/login and then replay in a victim's browser, which is
52
- // exactly the login-CSRF the state parameter exists to stop. So the token
53
- // carries a nonce that the handler also drops in a short-lived httpOnly
54
- // cookie, and the callback only proceeds when the two agree — the state is
55
- // then usable in one browser only, the one that started the flow.
56
- //
57
- // Returns the state and the nonce to put in that cookie.
58
- func (s *Service) issueOAuthState() (string, string, error) {
59
- nonce, err := randomToken()
60
- if err != nil {
61
- return "", "", err
62
- }
63
- claims := accessClaims{
64
- Typ: tokenTypeOAuthState,
65
- Nonce: nonce,
66
- RegisteredClaims: jwt.RegisteredClaims{
67
- IssuedAt: jwt.NewNumericDate(time.Now()),
68
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(oauthStateTTL)),
69
- },
70
- }
71
- state, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.jwtSecret))
72
- if err != nil {
73
- return "", "", err
74
- }
75
- return state, nonce, nil
76
- }
77
-
78
- func (s *Service) verifyOAuthState(raw, nonce string) error {
79
- var claims accessClaims
80
- _, err := jwt.ParseWithClaims(raw, &claims, func(*jwt.Token) (any, error) {
81
- return []byte(s.jwtSecret), nil
82
- }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}))
83
- if err != nil || claims.Typ != tokenTypeOAuthState {
84
- return errInvalidToken()
85
- }
86
- // A missing cookie is a mismatch, not a pass — otherwise stripping the
87
- // cookie is all it takes to get the old, unbound behavior back.
88
- if nonce == "" || subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(nonce)) != 1 {
89
- return errInvalidToken()
90
- }
91
- return nil
38
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(s.config.JWTSecret))
92
39
  }
93
40
 
94
41
  // randomToken generates an opaque refresh token — deliberately not a JWT: