@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,213 @@
1
+ // Postgres-backed tokenStore — installed by `add auth --store postgres` (the
2
+ // default). The Redis-backed alternative lives in tokenstore_redis.go; only
3
+ // one of the two is ever written into a project.
4
+ //
5
+ // Everything Redis needed two keys for collapses into one table here: the
6
+ // per-user session set is just a WHERE clause, and expiry is a column every
7
+ // read filters on rather than a TTL the store enforces. What that costs is a
8
+ // sweep to keep the table from growing — see DeleteExpired.
9
+ package postgres
10
+
11
+ import (
12
+ "context"
13
+ "database/sql"
14
+ "errors"
15
+ "fmt"
16
+ "strings"
17
+ "time"
18
+
19
+ "{{goModule}}/internal/app/user/ports"
20
+ "{{goModule}}/internal/shared/tx"
21
+
22
+ "github.com/google/uuid"
23
+ "gorm.io/gorm"
24
+ )
25
+
26
+ const (
27
+ kindRefresh = "refresh"
28
+ kindRefreshUsed = "refresh_used"
29
+ kindOAuthState = "oauth_state"
30
+ )
31
+
32
+ type PgTokenStore struct {
33
+ db *gorm.DB
34
+ recovery *recoveryTokenStore
35
+ }
36
+
37
+ func NewPgTokenStore(db *gorm.DB) *PgTokenStore {
38
+ return &PgTokenStore{db: db, recovery: newRecoveryTokenStore(db)}
39
+ }
40
+
41
+ var _ ports.RefreshTokenStore = (*PgTokenStore)(nil)
42
+ var _ ports.OAuthTransactionStore = (*PgTokenStore)(nil)
43
+ var _ ports.RecoveryTokenStore = (*PgTokenStore)(nil)
44
+
45
+ // lookup finds a live token of the given kind. Expiry is enforced here rather
46
+ // than by a sweep, so a row that outlived its TTL is already invisible.
47
+ func (s *PgTokenStore) lookup(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
48
+ var row AuthToken
49
+ err := tx.From(ctx, s.db).WithContext(ctx).
50
+ Where("token_hash = ? AND kind = ? AND expires_at > now()", hash, kind).
51
+ Take(&row).Error
52
+ if errors.Is(err, gorm.ErrRecordNotFound) {
53
+ return uuid.Nil, false, nil
54
+ }
55
+ if err != nil {
56
+ return uuid.Nil, false, err
57
+ }
58
+ return row.UserID, true, nil
59
+ }
60
+
61
+ func (s *PgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
62
+ if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
63
+ return fmt.Errorf("refresh token record expiry is invalid")
64
+ }
65
+ row := AuthToken{
66
+ TokenHash: tokenHash,
67
+ UserID: token.UserID,
68
+ Kind: kindRefresh,
69
+ ExpiresAt: token.ExpiresAt,
70
+ AbsoluteExpiresAt: &token.AbsoluteExpiresAt,
71
+ }
72
+ return tx.From(ctx, s.db).WithContext(ctx).
73
+ Where("token_hash = ?", tokenHash).
74
+ Assign(map[string]any{
75
+ "user_id": token.UserID,
76
+ "kind": kindRefresh,
77
+ "expires_at": token.ExpiresAt,
78
+ "absolute_expires_at": token.AbsoluteExpiresAt,
79
+ "provider": "",
80
+ "code_challenge": "",
81
+ "nonce": "",
82
+ }).FirstOrCreate(&row).Error
83
+ }
84
+
85
+ func (s *PgTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
86
+ return s.lookup(ctx, tokenHash, kindRefresh)
87
+ }
88
+
89
+ // ConsumeRefreshToken removes the active row and writes the reuse-detection
90
+ // tombstone in one statement. A concurrent caller can observe either the
91
+ // active row or the tombstone, never the gap between separate operations.
92
+ func (s *PgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (ports.RefreshTokenRecord, bool, error) {
93
+ var token ports.RefreshTokenRecord
94
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
95
+ `WITH consumed AS (
96
+ DELETE FROM user_svc.auth_tokens
97
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
98
+ AND (absolute_expires_at IS NULL OR absolute_expires_at > now())
99
+ RETURNING user_id, expires_at, absolute_expires_at
100
+ )
101
+ INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, expires_at, absolute_expires_at)
102
+ SELECT ?, user_id, ?, expires_at, COALESCE(absolute_expires_at, expires_at) FROM consumed
103
+ RETURNING user_id, expires_at, absolute_expires_at`,
104
+ tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(&token.UserID, &token.ExpiresAt, &token.AbsoluteExpiresAt)
105
+ if errors.Is(err, sql.ErrNoRows) {
106
+ return ports.RefreshTokenRecord{}, false, nil
107
+ }
108
+ if err != nil {
109
+ return ports.RefreshTokenRecord{}, false, err
110
+ }
111
+ return token, true, nil
112
+ }
113
+
114
+ // DeleteRefreshToken removes the row outright and leaves nothing behind — see
115
+ // the AuthToken doc comment for why logout must not leave a tombstone.
116
+ func (s *PgTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, _ uuid.UUID) error {
117
+ return tx.From(ctx, s.db).WithContext(ctx).
118
+ Where("token_hash = ? AND kind = ?", tokenHash, kindRefresh).
119
+ Delete(&AuthToken{}).Error
120
+ }
121
+
122
+ // RevokeAllRefreshTokens is one statement here because Postgres can query by
123
+ // value — the Redis store has to keep a separate per-user set to answer this,
124
+ // and that set accumulates stale members its own comment calls out.
125
+ // Tombstones are left in place so reuse detection still works afterwards.
126
+ func (s *PgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
127
+ return tx.From(ctx, s.db).WithContext(ctx).
128
+ Where("user_id = ? AND kind = ?", userID, kindRefresh).
129
+ Delete(&AuthToken{}).Error
130
+ }
131
+
132
+ func (s *PgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
133
+ return s.lookup(ctx, tokenHash, kindRefreshUsed)
134
+ }
135
+
136
+ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction ports.LoginTransaction) error {
137
+ if strings.TrimSpace(stateHash) == "" || strings.TrimSpace(transaction.Provider) == "" || strings.TrimSpace(transaction.CodeChallenge) == "" || strings.TrimSpace(transaction.Nonce) == "" || !transaction.ExpiresAt.After(time.Now()) {
138
+ return fmt.Errorf("oauth login transaction is invalid")
139
+ }
140
+ row := AuthToken{
141
+ TokenHash: stateHash,
142
+ Kind: kindOAuthState,
143
+ ExpiresAt: transaction.ExpiresAt,
144
+ Provider: transaction.Provider,
145
+ CodeChallenge: transaction.CodeChallenge,
146
+ Nonce: transaction.Nonce,
147
+ }
148
+ return tx.From(ctx, s.db).WithContext(ctx).
149
+ Where("token_hash = ?", stateHash).
150
+ Assign(map[string]any{
151
+ "user_id": uuid.Nil,
152
+ "kind": kindOAuthState,
153
+ "expires_at": transaction.ExpiresAt,
154
+ "provider": transaction.Provider,
155
+ "code_challenge": transaction.CodeChallenge,
156
+ "nonce": transaction.Nonce,
157
+ }).FirstOrCreate(&row).Error
158
+ }
159
+
160
+ func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (ports.LoginTransaction, bool, error) {
161
+ var transaction ports.LoginTransaction
162
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
163
+ `DELETE FROM user_svc.auth_tokens
164
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
165
+ RETURNING provider, code_challenge, nonce, expires_at`,
166
+ stateHash, kindOAuthState).Row().Scan(
167
+ &transaction.Provider,
168
+ &transaction.CodeChallenge,
169
+ &transaction.Nonce,
170
+ &transaction.ExpiresAt,
171
+ )
172
+ if errors.Is(err, sql.ErrNoRows) {
173
+ return ports.LoginTransaction{}, false, nil
174
+ }
175
+ if err != nil {
176
+ return ports.LoginTransaction{}, false, err
177
+ }
178
+ return transaction, true, nil
179
+ }
180
+
181
+ func (s *PgTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
182
+ return s.recovery.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
183
+ }
184
+
185
+ func (s *PgTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
186
+ return s.recovery.consume(ctx, tokenHash, kindPasswordSet)
187
+ }
188
+
189
+ func (s *PgTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
190
+ return s.recovery.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
191
+ }
192
+
193
+ func (s *PgTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
194
+ return s.recovery.consume(ctx, tokenHash, kindEmailVerify)
195
+ }
196
+
197
+ func (s *PgTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
198
+ return s.recovery.withTransaction(ctx, fn)
199
+ }
200
+
201
+ // DeleteExpired drops rows nothing can use any more. Correctness never depends
202
+ // on it — every read already filters on expires_at — so it is purely about
203
+ // table size, and calling it on a timer from cmd/worker (or a cron) is enough.
204
+ //
205
+ // ponytail: not scheduled for you. Wire it up the day the table is big enough
206
+ // to notice; a scaffold that silently runs background sweeps nobody asked for
207
+ // is harder to reason about than one line you add when it matters.
208
+ func (s *PgTokenStore) DeleteExpired(ctx context.Context) (int64, error) {
209
+ res := tx.From(ctx, s.db).WithContext(ctx).
210
+ Where("expires_at <= now()").
211
+ Delete(&AuthToken{})
212
+ return res.RowsAffected, res.Error
213
+ }
@@ -0,0 +1,103 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/app/user/ports"
10
+ "github.com/google/uuid"
11
+ "gorm.io/driver/postgres"
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ // tokenStoreDBForTest opens a real database instead of reusing the repository
16
+ // test transaction: concurrent consumers must use separate PostgreSQL
17
+ // connections to exercise DELETE ... RETURNING's row-level serialization.
18
+ func tokenStoreDBForTest(t *testing.T) *gorm.DB {
19
+ t.Helper()
20
+ dsn := os.Getenv("TEST_DB_DSN")
21
+ if dsn == "" {
22
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
23
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
24
+ }
25
+ t.Skip("token-store integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
26
+ }
27
+
28
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
29
+ if err != nil {
30
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
31
+ t.Fatalf("open required token-store database: %v", err)
32
+ }
33
+ t.Skipf("token-store integration test skipped: %v", err)
34
+ }
35
+ sqlDB, err := db.DB()
36
+ if err != nil {
37
+ t.Fatalf("get token-store SQL handle: %v", err)
38
+ }
39
+ if err := sqlDB.Ping(); err != nil {
40
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
41
+ t.Fatalf("ping required token-store database: %v", err)
42
+ }
43
+ t.Skipf("token-store integration test skipped: %v", err)
44
+ }
45
+ t.Cleanup(func() { _ = sqlDB.Close() })
46
+ return db
47
+ }
48
+
49
+ func TestPgTokenStore_ConsumeRefreshToken_ConcurrentRealDatabase(t *testing.T) {
50
+ db := tokenStoreDBForTest(t)
51
+ store := NewPgTokenStore(db)
52
+ ctx := context.Background()
53
+ userID := uuid.New()
54
+ hash := "real-pg-refresh-" + uuid.NewString()
55
+ if err := store.SetRefreshToken(ctx, hash, refreshTokenRecordForTest(userID)); err != nil {
56
+ t.Fatalf("seed refresh token: %v", err)
57
+ }
58
+ t.Cleanup(func() {
59
+ _ = db.Exec("DELETE FROM user_svc.auth_tokens WHERE token_hash = ?", hash).Error
60
+ })
61
+
62
+ const callers = 32
63
+ type result struct {
64
+ token ports.RefreshTokenRecord
65
+ ok bool
66
+ err error
67
+ }
68
+ start := make(chan struct{})
69
+ results := make(chan result, callers)
70
+ for i := 0; i < callers; i++ {
71
+ go func() {
72
+ <-start
73
+ token, ok, err := store.ConsumeRefreshToken(ctx, hash)
74
+ results <- result{token: token, ok: ok, err: err}
75
+ }()
76
+ }
77
+ close(start)
78
+
79
+ winners := 0
80
+ for i := 0; i < callers; i++ {
81
+ got := <-results
82
+ if got.err != nil {
83
+ t.Fatalf("concurrent consume: %v", got.err)
84
+ }
85
+ if got.ok {
86
+ winners++
87
+ if got.token.UserID != userID {
88
+ t.Fatalf("winner returned user %s, want %s", got.token.UserID, userID)
89
+ }
90
+ }
91
+ }
92
+ if winners != 1 {
93
+ t.Fatalf("expected exactly one real-database refresh winner, got %d", winners)
94
+ }
95
+ if _, ok, err := store.GetRefreshToken(ctx, hash); err != nil || ok {
96
+ t.Fatalf("consumed refresh token remained active: ok=%t err=%v", ok, err)
97
+ }
98
+ }
99
+
100
+ func refreshTokenRecordForTest(userID uuid.UUID) ports.RefreshTokenRecord {
101
+ now := time.Now()
102
+ return ports.RefreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
103
+ }
@@ -0,0 +1,84 @@
1
+ package postgres
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "time"
8
+
9
+ "{{goModule}}/internal/app/user/ports"
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "github.com/google/uuid"
13
+ "gorm.io/gorm"
14
+ )
15
+
16
+ const (
17
+ kindPasswordSet = "pwreset"
18
+ kindEmailVerify = "emailverify"
19
+ )
20
+
21
+ // recoveryTokenStore is deliberately Postgres-backed for both auth store
22
+ // choices. Refresh rotation may live in Redis, but password reset and email
23
+ // verification must share a durable DB transaction with the user update.
24
+ type recoveryTokenStore struct {
25
+ db *gorm.DB
26
+ }
27
+
28
+ var _ ports.RecoveryTokenStore = (*recoveryTokenStore)(nil)
29
+
30
+ func NewRecoveryTokenStore(db *gorm.DB) ports.RecoveryTokenStore {
31
+ return newRecoveryTokenStore(db)
32
+ }
33
+
34
+ func newRecoveryTokenStore(db *gorm.DB) *recoveryTokenStore {
35
+ return &recoveryTokenStore{db: db}
36
+ }
37
+
38
+ func (s *recoveryTokenStore) withTransaction(ctx context.Context, fn func(context.Context) error) error {
39
+ return tx.Do(ctx, s.db, fn)
40
+ }
41
+
42
+ func (s *recoveryTokenStore) put(ctx context.Context, hash, kind string, userID uuid.UUID, ttl time.Duration) error {
43
+ expiresAt := time.Now().Add(ttl)
44
+ row := AuthToken{TokenHash: hash, UserID: userID, Kind: kind, ExpiresAt: expiresAt}
45
+ return tx.From(ctx, s.db).WithContext(ctx).
46
+ Where("token_hash = ?", hash).
47
+ Assign(map[string]any{"user_id": userID, "kind": kind, "expires_at": expiresAt}).
48
+ FirstOrCreate(&row).Error
49
+ }
50
+
51
+ func (s *recoveryTokenStore) consume(ctx context.Context, hash, kind string) (uuid.UUID, bool, error) {
52
+ var userID uuid.UUID
53
+ err := tx.From(ctx, s.db).WithContext(ctx).Raw(
54
+ `DELETE FROM user_svc.auth_tokens
55
+ WHERE token_hash = ? AND kind = ? AND expires_at > now()
56
+ RETURNING user_id`, hash, kind).Row().Scan(&userID)
57
+ if errors.Is(err, sql.ErrNoRows) {
58
+ return uuid.Nil, false, nil
59
+ }
60
+ if err != nil {
61
+ return uuid.Nil, false, err
62
+ }
63
+ return userID, true, nil
64
+ }
65
+
66
+ func (s *recoveryTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
67
+ return s.put(ctx, tokenHash, kindPasswordSet, userID, ttl)
68
+ }
69
+
70
+ func (s *recoveryTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
71
+ return s.consume(ctx, tokenHash, kindPasswordSet)
72
+ }
73
+
74
+ func (s *recoveryTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
75
+ return s.put(ctx, tokenHash, kindEmailVerify, userID, ttl)
76
+ }
77
+
78
+ func (s *recoveryTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
79
+ return s.consume(ctx, tokenHash, kindEmailVerify)
80
+ }
81
+
82
+ func (s *recoveryTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
83
+ return s.withTransaction(ctx, fn)
84
+ }
@@ -0,0 +1,228 @@
1
+ // Redis-backed tokenStore — installed by `add auth --store redis`.
2
+ // The Postgres-backed alternative lives in tokenstore_pg.go; only one of the
3
+ // two is ever written into a project, and Service never learns which.
4
+ package redisadapter
5
+
6
+ import (
7
+ "context"
8
+ "encoding/json"
9
+ "fmt"
10
+ "time"
11
+
12
+ "{{goModule}}/internal/app/user/adapters/outbound/postgres"
13
+ "{{goModule}}/internal/app/user/ports"
14
+
15
+ "github.com/google/uuid"
16
+ "github.com/redis/go-redis/v9"
17
+ "gorm.io/gorm"
18
+ )
19
+
20
+ const (
21
+ // The shared {refresh} hash tag keeps all keys touched by the Lua rotation
22
+ // script on one Redis Cluster slot as well as making the operation atomic.
23
+ refreshKeyPrefix = "user:{refresh}:" // +hash -> ports.RefreshTokenRecord, TTL = ExpiresAt
24
+ refreshUserKeyPrefix = "user:{refresh}:user:" // +userID -> SET of active token hashes
25
+ refreshUsedKeyPrefix = "user:{refresh}:used:" // +hash -> ports.RefreshTokenRecord, TTL = source token expiry
26
+ oauthStateKeyPrefix = "user:{oauth}:state:"
27
+ )
28
+
29
+ type RedisTokenStore struct {
30
+ rdb *redis.Client
31
+ recovery ports.RecoveryTokenStore
32
+ }
33
+
34
+ func NewRedisTokenStore(rdb *redis.Client, db *gorm.DB) *RedisTokenStore {
35
+ return &RedisTokenStore{rdb: rdb, recovery: postgres.NewRecoveryTokenStore(db)}
36
+ }
37
+
38
+ var _ ports.RefreshTokenStore = (*RedisTokenStore)(nil)
39
+ var _ ports.OAuthTransactionStore = (*RedisTokenStore)(nil)
40
+ var _ ports.RecoveryTokenStore = (*RedisTokenStore)(nil)
41
+
42
+ var consumeRefreshScript = redis.NewScript(`
43
+ local token = redis.call("GET", KEYS[1])
44
+ local ttl = redis.call("PTTL", KEYS[1])
45
+ if not token or ttl < 1 then
46
+ -- An empty bulk string maps to a normal "not found" result in go-redis;
47
+ -- Lua false would be surfaced as redis.Nil and look like an infrastructure
48
+ -- failure to Service.Refresh.
49
+ return ""
50
+ end
51
+
52
+ -- Decode and validate the fields used by this script before mutating anything.
53
+ -- Redis Lua does not roll back earlier writes when a later command errors; the
54
+ -- old order could therefore delete a corrupt active token and then fail while
55
+ -- decoding it, losing the only evidence needed to investigate or retry safely.
56
+ local decoded, token_data = pcall(cjson.decode, token)
57
+ if not decoded or type(token_data) ~= "table" or
58
+ type(token_data.user_id) ~= "string" or token_data.user_id == "" or
59
+ type(token_data.expires_at) ~= "string" or token_data.expires_at == "" or
60
+ type(token_data.absolute_expires_at) ~= "string" or token_data.absolute_expires_at == "" then
61
+ return redis.error_reply("invalid refresh token record")
62
+ end
63
+
64
+ redis.call("DEL", KEYS[1])
65
+ redis.call("SREM", KEYS[3] .. token_data.user_id, ARGV[1])
66
+ redis.call("SET", KEYS[2], token, "PX", ttl)
67
+ return token
68
+ `)
69
+
70
+ var revokeAllRefreshScript = redis.NewScript(`
71
+ local hashes = redis.call("SMEMBERS", KEYS[1])
72
+ for _, hash in ipairs(hashes) do
73
+ redis.call("DEL", KEYS[2] .. hash)
74
+ end
75
+ redis.call("DEL", KEYS[1])
76
+ return #hashes
77
+ `)
78
+
79
+ func (s *RedisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
80
+ ttl := time.Until(token.ExpiresAt)
81
+ if token.UserID == uuid.Nil || ttl <= 0 || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
82
+ return fmt.Errorf("refresh token expiry is invalid")
83
+ }
84
+ payload, err := json.Marshal(token)
85
+ if err != nil {
86
+ return fmt.Errorf("encode refresh token record: %w", err)
87
+ }
88
+ pipe := s.rdb.TxPipeline()
89
+ pipe.Set(ctx, refreshKeyPrefix+tokenHash, payload, ttl)
90
+ pipe.SAdd(ctx, refreshUserKeyPrefix+token.UserID.String(), tokenHash)
91
+ _, err = pipe.Exec(ctx)
92
+ return err
93
+ }
94
+
95
+ func (s *RedisTokenStore) GetRefreshToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
96
+ raw, err := s.rdb.Get(ctx, refreshKeyPrefix+tokenHash).Result()
97
+ if err == redis.Nil {
98
+ return uuid.Nil, false, nil
99
+ }
100
+ if err != nil {
101
+ return uuid.Nil, false, err
102
+ }
103
+ token, err := decodeRefreshTokenRecord(raw)
104
+ if err != nil {
105
+ return uuid.Nil, false, fmt.Errorf("decode refresh token record: %w", err)
106
+ }
107
+ return token.UserID, true, nil
108
+ }
109
+
110
+ // ConsumeRefreshToken uses one Lua script for active-token deletion,
111
+ // per-user-session cleanup, and the reuse tombstone. A concurrent caller
112
+ // cannot observe the active value after another caller has won the rotation.
113
+ func (s *RedisTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string) (ports.RefreshTokenRecord, bool, error) {
114
+ result, err := consumeRefreshScript.Run(ctx, s.rdb,
115
+ []string{refreshKeyPrefix + tokenHash, refreshUsedKeyPrefix + tokenHash, refreshUserKeyPrefix},
116
+ tokenHash,
117
+ ).Result()
118
+ if err != nil {
119
+ return ports.RefreshTokenRecord{}, false, err
120
+ }
121
+ raw, ok := result.(string)
122
+ if !ok || raw == "" {
123
+ return ports.RefreshTokenRecord{}, false, nil
124
+ }
125
+ token, err := decodeRefreshTokenRecord(raw)
126
+ if err != nil {
127
+ return ports.RefreshTokenRecord{}, false, fmt.Errorf("decode consumed refresh token record: %w", err)
128
+ }
129
+ return token, true, nil
130
+ }
131
+
132
+ func (s *RedisTokenStore) DeleteRefreshToken(ctx context.Context, tokenHash string, userID uuid.UUID) error {
133
+ pipe := s.rdb.TxPipeline()
134
+ pipe.Del(ctx, refreshKeyPrefix+tokenHash)
135
+ pipe.SRem(ctx, refreshUserKeyPrefix+userID.String(), tokenHash)
136
+ _, err := pipe.Exec(ctx)
137
+ return err
138
+ }
139
+
140
+ // RevokeAllRefreshTokens snapshots the per-user session set, deletes every
141
+ // active refresh token, and removes the set in one Lua invocation. This is
142
+ // important because SetRefreshToken updates two keys: a SMEMBERS followed by
143
+ // a separate pipeline could miss a token added between those operations and
144
+ // leave an active token with no session-set member.
145
+ func (s *RedisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.UUID) error {
146
+ setKey := refreshUserKeyPrefix + userID.String()
147
+ _, err := revokeAllRefreshScript.Run(ctx, s.rdb,
148
+ []string{setKey, refreshKeyPrefix},
149
+ userID.String(),
150
+ ).Result()
151
+ return err
152
+ }
153
+
154
+ func (s *RedisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
155
+ raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
156
+ if err == redis.Nil {
157
+ return uuid.Nil, false, nil
158
+ }
159
+ if err != nil {
160
+ return uuid.Nil, false, err
161
+ }
162
+ token, err := decodeRefreshTokenRecord(raw)
163
+ if err != nil {
164
+ return uuid.Nil, false, fmt.Errorf("decode used refresh token record: %w", err)
165
+ }
166
+ return token.UserID, true, nil
167
+ }
168
+
169
+ func (s *RedisTokenStore) SetLoginTransaction(ctx context.Context, stateHash string, transaction ports.LoginTransaction) error {
170
+ if !transaction.ExpiresAt.After(time.Now()) {
171
+ return fmt.Errorf("oauth login transaction expiry is invalid")
172
+ }
173
+ payload, err := json.Marshal(transaction)
174
+ if err != nil {
175
+ return fmt.Errorf("encode oauth login transaction: %w", err)
176
+ }
177
+ return s.rdb.Set(ctx, oauthStateKeyPrefix+stateHash, payload, time.Until(transaction.ExpiresAt)).Err()
178
+ }
179
+
180
+ func (s *RedisTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash string) (ports.LoginTransaction, bool, error) {
181
+ raw, err := s.rdb.GetDel(ctx, oauthStateKeyPrefix+stateHash).Result()
182
+ if err == redis.Nil {
183
+ return ports.LoginTransaction{}, false, nil
184
+ }
185
+ if err != nil {
186
+ return ports.LoginTransaction{}, false, err
187
+ }
188
+ var transaction ports.LoginTransaction
189
+ if err := json.Unmarshal([]byte(raw), &transaction); err != nil {
190
+ return ports.LoginTransaction{}, false, fmt.Errorf("decode oauth login transaction: %w", err)
191
+ }
192
+ return transaction, true, nil
193
+ }
194
+
195
+ func decodeRefreshTokenRecord(raw string) (ports.RefreshTokenRecord, error) {
196
+ var token ports.RefreshTokenRecord
197
+ if err := json.Unmarshal([]byte(raw), &token); err != nil {
198
+ return ports.RefreshTokenRecord{}, err
199
+ }
200
+ if token.UserID == uuid.Nil || token.ExpiresAt.IsZero() || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
201
+ return ports.RefreshTokenRecord{}, fmt.Errorf("refresh token record is incomplete")
202
+ }
203
+ return token, nil
204
+ }
205
+
206
+ func (s *RedisTokenStore) SetPasswordResetToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
207
+ return s.recovery.SetPasswordResetToken(ctx, tokenHash, userID, ttl)
208
+ }
209
+
210
+ // ConsumePasswordResetToken is one-time-use by construction: the SQL DELETE
211
+ // runs inside the same transaction as the user update.
212
+ func (s *RedisTokenStore) ConsumePasswordResetToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
213
+ return s.recovery.ConsumePasswordResetToken(ctx, tokenHash)
214
+ }
215
+
216
+ func (s *RedisTokenStore) SetEmailVerifyToken(ctx context.Context, tokenHash string, userID uuid.UUID, ttl time.Duration) error {
217
+ return s.recovery.SetEmailVerifyToken(ctx, tokenHash, userID, ttl)
218
+ }
219
+
220
+ // ConsumeEmailVerifyToken is one-time-use by construction, same as
221
+ // ConsumePasswordResetToken — the SQL delete participates in the transaction.
222
+ func (s *RedisTokenStore) ConsumeEmailVerifyToken(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
223
+ return s.recovery.ConsumeEmailVerifyToken(ctx, tokenHash)
224
+ }
225
+
226
+ func (s *RedisTokenStore) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
227
+ return s.recovery.WithTransaction(ctx, fn)
228
+ }