@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.
- package/README.md +288 -50
- package/dist/commands/auth.js +53 -22
- package/dist/commands/config.js +50 -0
- package/dist/commands/create.js +32 -2
- package/dist/commands/generate.js +25 -2
- package/dist/commands/method.js +22 -7
- package/dist/commands/migration.js +2 -2
- package/dist/commands/observability.js +3 -3
- package/dist/commands/rbac.js +3 -3
- package/dist/commands/undo.js +5 -0
- package/dist/commands/worker.js +1 -1
- package/dist/index.js +186 -59
- package/dist/prompts/auth-wizard.js +40 -6
- package/dist/prompts/create-wizard.js +43 -2
- package/dist/prompts/generate-wizard.js +89 -9
- package/dist/templates/auth-manifest.js +31 -1
- package/dist/templates/create-manifest.js +4 -0
- package/dist/templates/module-manifest.js +37 -1
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/types.js +6 -0
- package/dist/utils/auth-patcher.js +115 -24
- package/dist/utils/config.js +147 -3
- package/dist/utils/main-patcher.js +29 -27
- package/dist/utils/marker-patch.js +7 -1
- package/dist/utils/method-patcher.js +261 -81
- package/dist/utils/module-profile.js +32 -0
- package/dist/utils/observability-patcher.js +2 -2
- package/dist/utils/platform-patcher.js +29 -7
- package/dist/utils/rbac-patcher.js +97 -75
- package/package.json +7 -2
- package/templates/add/auth/cmd/seed/main.go.hbs +13 -3
- package/templates/add/auth/docs/login.yaml.hbs +11 -1
- package/templates/add/auth/docs/mfa-verify.yaml.hbs +19 -0
- package/templates/add/auth/docs/provider-exchange.yaml.hbs +40 -0
- package/templates/add/auth/docs/provider-login.yaml.hbs +31 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +7 -0
- package/templates/add/auth/docs/register.yaml.hbs +7 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +59 -1
- package/templates/add/auth/docs/users-me-mfa-confirm.yaml.hbs +19 -0
- package/templates/add/auth/docs/users-me-mfa-disable.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-mfa-setup.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-mfa.yaml.hbs +12 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +132 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +113 -0
- package/templates/add/auth/internal/app/user/browser_policy.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/composition.go.hbs +165 -0
- package/templates/add/auth/internal/app/user/contracts.go.hbs +88 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +57 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +25 -0
- package/templates/add/auth/internal/app/user/external_login.go.hbs +208 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +60 -203
- package/templates/add/auth/internal/app/user/handler_local.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/handler_mfa.go.hbs +83 -0
- package/templates/add/auth/internal/app/user/handler_oauth.go.hbs +70 -0
- package/templates/add/auth/internal/app/user/handler_recovery.go.hbs +49 -0
- package/templates/add/auth/internal/app/user/handler_test.go.hbs +290 -0
- package/templates/add/auth/internal/app/user/handler_user.go.hbs +41 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +6 -59
- package/templates/add/auth/internal/app/user/local_auth.go.hbs +98 -0
- package/templates/add/auth/internal/app/user/mfa_service.go.hbs +450 -0
- package/templates/add/auth/internal/app/user/mfa_service_test.go.hbs +199 -0
- package/templates/add/auth/internal/app/user/mfa_store.go.hbs +127 -0
- package/templates/add/auth/internal/app/user/mfa_store_test.go.hbs +174 -0
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +8 -2
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +4 -3
- package/templates/add/auth/internal/app/user/model/mfa_challenge.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/mfa_enrollment.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/model/mfa_recovery_code.go.hbs +17 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +3 -2
- package/templates/add/auth/internal/app/user/provider_test.go.hbs +286 -0
- package/templates/add/auth/internal/app/user/recovery_service.go.hbs +114 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +82 -478
- package/templates/add/auth/internal/app/user/service_test.go.hbs +601 -45
- package/templates/add/auth/internal/app/user/session_cookie.go.hbs +33 -0
- package/templates/add/auth/internal/app/user/sessions.go.hbs +99 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +42 -14
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +105 -40
- package/templates/add/auth/internal/app/user/tokenstore_pg_test.go.hbs +96 -0
- package/templates/add/auth/internal/app/user/tokenstore_recovery.go.hbs +58 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +144 -70
- package/templates/add/auth/internal/app/user/tokenstore_redis_test.go.hbs +185 -0
- package/templates/add/auth/internal/app/user/user_query.go.hbs +65 -0
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +389 -0
- package/templates/add/auth/internal/platform/authprovider/google/google_test.go.hbs +312 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +9 -4
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_mfa.down.sql.hbs +3 -0
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +29 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +2 -2
- package/templates/add/rbac/internal/app/role/composition.go.hbs +35 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +12 -12
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +340 -121
- package/templates/create/base/.env.example.hbs +0 -1
- package/templates/create/base/AGENTS.md.hbs +255 -67
- package/templates/create/base/Makefile.hbs +2 -1
- package/templates/create/base/README.md.hbs +45 -17
- package/templates/create/base/cmd/api/wiring.go.hbs +18 -25
- package/templates/create/base/internal/platform/database/database.go.hbs +3 -3
- package/templates/create/base/internal/shared/apperror/apperror.go.hbs +15 -2
- package/templates/create/base/internal/shared/config/config.go.hbs +0 -8
- package/templates/create/base/internal/shared/middleware/cors_test.go.hbs +40 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +15 -5
- package/templates/create/features/docs/architecture.md.hbs +38 -16
- package/templates/create/features/docs/patterns.md.hbs +40 -21
- package/templates/create/features/docs/techstack.md.hbs +3 -3
- package/templates/generate/module/commands.go.hbs +95 -0
- package/templates/generate/module/composition.go.hbs +23 -0
- package/templates/generate/module/cqrs_test.go.hbs +7 -0
- package/templates/generate/module/handler.go.hbs +50 -5
- package/templates/generate/module/minimal/commands.go.hbs +34 -0
- package/templates/generate/module/minimal/handler.go.hbs +34 -0
- package/templates/generate/module/minimal/queries.go.hbs +45 -0
- package/templates/generate/module/minimal/service.go.hbs +27 -1
- package/templates/generate/module/queries.go.hbs +62 -0
- package/templates/generate/module/service.go.hbs +61 -5
- package/templates/add/auth/docs/google-callback.yaml.hbs +0 -22
- 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
|
|
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.
|
|
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(
|
|
42
|
-
ExpiresAt: jwt.NewNumericDate(
|
|
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.
|
|
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:
|