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