@nakedev/go-scaffold 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/commands/auth.js +11 -2
  2. package/dist/index.js +1 -1
  3. package/dist/templates/auth-manifest.js +4 -0
  4. package/dist/utils/rbac-patcher.js +12 -4
  5. package/package.json +1 -1
  6. package/templates/add/auth/docs/schemas.yaml.hbs +54 -0
  7. package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
  8. package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
  9. package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
  10. package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
  11. package/templates/add/auth/docs/users-me-session.yaml.hbs +14 -0
  12. package/templates/add/auth/docs/users-me-sessions.yaml.hbs +13 -0
  13. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +62 -0
  14. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
  15. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +80 -0
  16. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +6 -2
  17. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +9 -0
  18. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +3 -1
  19. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +34 -0
  20. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +30 -0
  21. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +3 -3
  22. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +6 -1
  23. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +43 -0
  24. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +46 -7
  25. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +36 -0
  26. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +75 -0
  27. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +36 -0
  28. package/templates/add/auth/internal/app/user/application/dto.go.hbs +32 -0
  29. package/templates/add/auth/internal/app/user/application/errors.go.hbs +16 -0
  30. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +42 -13
  31. package/templates/add/auth/internal/app/user/application/identities.go.hbs +97 -0
  32. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +69 -0
  33. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +5 -2
  34. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +2 -2
  35. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +13 -4
  36. package/templates/add/auth/internal/app/user/application/service.go.hbs +6 -0
  37. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +107 -5
  38. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +62 -8
  39. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +1 -0
  40. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +3 -1
  41. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +19 -0
  42. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +9 -3
  43. package/templates/add/auth/internal/shared/middleware/auth_test.go.hbs +48 -0
  44. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +5 -1
  45. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +2 -0
  46. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
  47. package/templates/create/base/AGENTS.md.hbs +14 -0
  48. package/templates/create/base/README.md.hbs +9 -0
  49. package/templates/create/features/docs/architecture.md.hbs +5 -5
  50. package/templates/create/features/docs/techstack.md.hbs +1 -1
@@ -92,7 +92,7 @@ func (s *PostgresMFAStore) CreateChallenge(ctx context.Context, hash string, cha
92
92
  if hash == "" || challenge.UserID == uuid.Nil || !challenge.ExpiresAt.After(time.Now()) {
93
93
  return fmt.Errorf("MFA challenge is invalid")
94
94
  }
95
- row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, ExpiresAt: challenge.ExpiresAt}
95
+ row := MFAChallenge{ChallengeHash: hash, UserID: challenge.UserID, SessionID: challenge.SessionID, UserAgent: challenge.UserAgent, ExpiresAt: challenge.ExpiresAt}
96
96
  return tx.From(ctx, s.db).WithContext(ctx).Create(&row).Error
97
97
  }
98
98
 
@@ -101,8 +101,8 @@ func (s *PostgresMFAStore) ConsumeChallenge(ctx context.Context, hash string) (p
101
101
  err := tx.From(ctx, s.db).WithContext(ctx).Raw(
102
102
  `DELETE FROM user_svc.mfa_challenges
103
103
  WHERE challenge_hash = ? AND expires_at > now()
104
- RETURNING user_id, expires_at`, hash,
105
- ).Row().Scan(&challenge.UserID, &challenge.ExpiresAt)
104
+ RETURNING user_id, session_id, user_agent, expires_at`, hash,
105
+ ).Row().Scan(&challenge.UserID, &challenge.SessionID, &challenge.UserAgent, &challenge.ExpiresAt)
106
106
  if errors.Is(err, sql.ErrNoRows) {
107
107
  return ports.MFAChallenge{}, false, nil
108
108
  }
@@ -37,12 +37,15 @@ type AuthToken struct {
37
37
  TokenHash string `gorm:"primaryKey;type:text"`
38
38
  UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_kind,priority:1"`
39
39
  Kind string `gorm:"type:varchar(20);not null;index:idx_auth_tokens_user_kind,priority:2"`
40
+ SessionID uuid.UUID `gorm:"type:uuid;not null;index:idx_auth_tokens_user_session,priority:1"`
41
+ UserAgent string `gorm:"type:text;not null;default:''"`
40
42
  ExpiresAt time.Time `gorm:"not null;index:idx_auth_tokens_expires_at"`
41
43
  AbsoluteExpiresAt *time.Time `gorm:"index:idx_auth_tokens_absolute_expires_at"`
42
44
  Provider string `gorm:"type:varchar(20);not null;default:''"`
43
45
  CodeChallenge string `gorm:"type:text;not null;default:''"`
44
46
  Nonce string `gorm:"type:text;not null;default:''"`
45
- CreatedAt time.Time
47
+ CreatedAt time.Time `gorm:"not null"`
48
+ LastUsedAt time.Time `gorm:"not null;index:idx_auth_tokens_user_session,priority:3"`
46
49
  }
47
50
 
48
51
  func (AuthToken) TableName() string { return "user_svc.auth_tokens" }
@@ -69,6 +72,8 @@ func (MFAEnrollment) TableName() string { return "user_svc.mfa_enrollments" }
69
72
  type MFAChallenge struct {
70
73
  ChallengeHash string `gorm:"type:text;primaryKey"`
71
74
  UserID uuid.UUID `gorm:"type:uuid;not null;index:idx_mfa_challenges_user"`
75
+ SessionID uuid.UUID `gorm:"type:uuid;not null;default:'00000000-0000-0000-0000-000000000000'"`
76
+ UserAgent string `gorm:"type:text;not null;default:''"`
72
77
  ExpiresAt time.Time `gorm:"not null;index:idx_mfa_challenges_expires_at"`
73
78
  CreatedAt time.Time
74
79
  }
@@ -12,6 +12,7 @@ import (
12
12
 
13
13
  "github.com/google/uuid"
14
14
  "gorm.io/gorm"
15
+ "gorm.io/gorm/clause"
15
16
  )
16
17
 
17
18
  type Repository struct {
@@ -68,6 +69,20 @@ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provide
68
69
  return toDomainIdentity(&row), nil
69
70
  }
70
71
 
72
+ func (r *Repository) ListIdentities(ctx context.Context, userID uuid.UUID) ([]domain.Identity, error) {
73
+ var rows []Identity
74
+ if err := tx.From(ctx, r.db).WithContext(ctx).
75
+ Where("user_id = ?", userID).
76
+ Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
77
+ return nil, persistenceError(err)
78
+ }
79
+ items := make([]domain.Identity, len(rows))
80
+ for i := range rows {
81
+ items[i] = *toDomainIdentity(&rows[i])
82
+ }
83
+ return items, nil
84
+ }
85
+
71
86
  func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
72
87
  var row Identity
73
88
  if err := tx.From(ctx, r.db).WithContext(ctx).
@@ -88,6 +103,34 @@ func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identi
88
103
  return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
89
104
  }
90
105
 
106
+ // DeleteIdentity locks the user's identity rows before checking the count so
107
+ // two concurrent unlink requests cannot both remove the last login method.
108
+ func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) error {
109
+ return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
110
+ var rows []Identity
111
+ if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).
112
+ Where("user_id = ?", userID).
113
+ Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
114
+ return err
115
+ }
116
+
117
+ var target *Identity
118
+ for i := range rows {
119
+ if rows[i].Provider == string(provider) {
120
+ target = &rows[i]
121
+ break
122
+ }
123
+ }
124
+ if target == nil {
125
+ return domain.ErrNotFound
126
+ }
127
+ if len(rows) <= 1 {
128
+ return domain.ErrLastIdentity
129
+ }
130
+ return db.Delete(&Identity{}, "id = ? AND user_id = ?", target.ID, userID).Error
131
+ }))
132
+ }
133
+
91
134
  // CreateUserWithIdentity inserts the profile and its first login method in
92
135
  // one transaction. A user without an identity cannot authenticate.
93
136
  func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
@@ -66,16 +66,23 @@ func (s *PgTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, to
66
66
  TokenHash: tokenHash,
67
67
  UserID: token.UserID,
68
68
  Kind: kindRefresh,
69
+ SessionID: token.SessionID,
70
+ UserAgent: token.UserAgent,
69
71
  ExpiresAt: token.ExpiresAt,
70
72
  AbsoluteExpiresAt: &token.AbsoluteExpiresAt,
73
+ CreatedAt: token.CreatedAt,
74
+ LastUsedAt: token.LastUsedAt,
71
75
  }
72
76
  return tx.From(ctx, s.db).WithContext(ctx).
73
77
  Where("token_hash = ?", tokenHash).
74
78
  Assign(map[string]any{
75
79
  "user_id": token.UserID,
76
80
  "kind": kindRefresh,
81
+ "session_id": token.SessionID,
82
+ "user_agent": token.UserAgent,
77
83
  "expires_at": token.ExpiresAt,
78
84
  "absolute_expires_at": token.AbsoluteExpiresAt,
85
+ "last_used_at": token.LastUsedAt,
79
86
  "provider": "",
80
87
  "code_challenge": "",
81
88
  "nonce": "",
@@ -96,12 +103,15 @@ func (s *PgTokenStore) ConsumeRefreshToken(ctx context.Context, tokenHash string
96
103
  DELETE FROM user_svc.auth_tokens
97
104
  WHERE token_hash = ? AND kind = ? AND expires_at > now()
98
105
  AND (absolute_expires_at IS NULL OR absolute_expires_at > now())
99
- RETURNING user_id, expires_at, absolute_expires_at
106
+ RETURNING user_id, session_id, user_agent, created_at, last_used_at, expires_at, absolute_expires_at
100
107
  )
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)
108
+ INSERT INTO user_svc.auth_tokens (token_hash, user_id, kind, session_id, user_agent, expires_at, absolute_expires_at, created_at, last_used_at)
109
+ SELECT ?, user_id, ?, session_id, user_agent, expires_at, COALESCE(absolute_expires_at, expires_at), created_at, last_used_at FROM consumed
110
+ RETURNING user_id, session_id, user_agent, created_at, last_used_at, expires_at, absolute_expires_at`,
111
+ tokenHash, kindRefresh, tokenHash, kindRefreshUsed).Row().Scan(
112
+ &token.UserID, &token.SessionID, &token.UserAgent, &token.CreatedAt, &token.LastUsedAt,
113
+ &token.ExpiresAt, &token.AbsoluteExpiresAt,
114
+ )
105
115
  if errors.Is(err, sql.ErrNoRows) {
106
116
  return ports.RefreshTokenRecord{}, false, nil
107
117
  }
@@ -129,6 +139,33 @@ func (s *PgTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uuid.U
129
139
  Delete(&AuthToken{}).Error
130
140
  }
131
141
 
142
+ func (s *PgTokenStore) ListRefreshSessions(ctx context.Context, userID uuid.UUID) ([]ports.RefreshSession, error) {
143
+ var rows []AuthToken
144
+ err := tx.From(ctx, s.db).WithContext(ctx).
145
+ Where("user_id = ? AND kind = ? AND expires_at > now() AND (absolute_expires_at IS NULL OR absolute_expires_at > now())", userID, kindRefresh).
146
+ Order("last_used_at DESC, created_at DESC").Find(&rows).Error
147
+ if err != nil {
148
+ return nil, err
149
+ }
150
+ out := make([]ports.RefreshSession, 0, len(rows))
151
+ for _, row := range rows {
152
+ out = append(out, ports.RefreshSession{
153
+ ID: row.SessionID, UserAgent: row.UserAgent, CreatedAt: row.CreatedAt,
154
+ LastUsedAt: row.LastUsedAt, ExpiresAt: row.ExpiresAt,
155
+ })
156
+ }
157
+ return out, nil
158
+ }
159
+
160
+ func (s *PgTokenStore) RevokeRefreshSession(ctx context.Context, userID, sessionID uuid.UUID) error {
161
+ if userID == uuid.Nil || sessionID == uuid.Nil {
162
+ return fmt.Errorf("session identity is invalid")
163
+ }
164
+ return tx.From(ctx, s.db).WithContext(ctx).
165
+ Where("user_id = ? AND kind = ? AND session_id = ?", userID, kindRefresh, sessionID).
166
+ Delete(&AuthToken{}).Error
167
+ }
168
+
132
169
  func (s *PgTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
133
170
  return s.lookup(ctx, tokenHash, kindRefreshUsed)
134
171
  }
@@ -139,6 +176,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
139
176
  }
140
177
  row := AuthToken{
141
178
  TokenHash: stateHash,
179
+ UserID: transaction.UserID,
142
180
  Kind: kindOAuthState,
143
181
  ExpiresAt: transaction.ExpiresAt,
144
182
  Provider: transaction.Provider,
@@ -148,7 +186,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
148
186
  return tx.From(ctx, s.db).WithContext(ctx).
149
187
  Where("token_hash = ?", stateHash).
150
188
  Assign(map[string]any{
151
- "user_id": uuid.Nil,
189
+ "user_id": transaction.UserID,
152
190
  "kind": kindOAuthState,
153
191
  "expires_at": transaction.ExpiresAt,
154
192
  "provider": transaction.Provider,
@@ -162,8 +200,9 @@ func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash st
162
200
  err := tx.From(ctx, s.db).WithContext(ctx).Raw(
163
201
  `DELETE FROM user_svc.auth_tokens
164
202
  WHERE token_hash = ? AND kind = ? AND expires_at > now()
165
- RETURNING provider, code_challenge, nonce, expires_at`,
203
+ RETURNING user_id, provider, code_challenge, nonce, expires_at`,
166
204
  stateHash, kindOAuthState).Row().Scan(
205
+ &transaction.UserID,
167
206
  &transaction.Provider,
168
207
  &transaction.CodeChallenge,
169
208
  &transaction.Nonce,
@@ -101,3 +101,39 @@ func refreshTokenRecordForTest(userID uuid.UUID) ports.RefreshTokenRecord {
101
101
  now := time.Now()
102
102
  return ports.RefreshTokenRecord{UserID: userID, ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour)}
103
103
  }
104
+
105
+ func TestPgTokenStore_ListAndRevokeRefreshSessions(t *testing.T) {
106
+ db := tokenStoreDBForTest(t)
107
+ store := NewPgTokenStore(db)
108
+ ctx := context.Background()
109
+ userID := uuid.New()
110
+ currentID := uuid.New()
111
+ remoteID := uuid.New()
112
+ now := time.Now()
113
+ hashes := []string{"real-pg-session-current-" + uuid.NewString(), "real-pg-session-remote-" + uuid.NewString()}
114
+ for i, sessionID := range []uuid.UUID{currentID, remoteID} {
115
+ if err := store.SetRefreshToken(ctx, hashes[i], ports.RefreshTokenRecord{
116
+ UserID: userID, SessionID: sessionID, UserAgent: "test-device",
117
+ CreatedAt: now.Add(-time.Hour), LastUsedAt: now.Add(-time.Duration(i) * time.Minute),
118
+ ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(24 * time.Hour),
119
+ }); err != nil {
120
+ t.Fatalf("seed session %d: %v", i, err)
121
+ }
122
+ }
123
+ t.Cleanup(func() { _ = db.Exec("DELETE FROM user_svc.auth_tokens WHERE token_hash IN ?", hashes).Error })
124
+
125
+ sessions, err := store.ListRefreshSessions(ctx, userID)
126
+ if err != nil || len(sessions) != 2 {
127
+ t.Fatalf("list sessions = %+v, err=%v", sessions, err)
128
+ }
129
+ if sessions[0].ID != currentID || sessions[0].UserAgent != "test-device" {
130
+ t.Fatalf("sessions were not ordered/decoded correctly: %+v", sessions)
131
+ }
132
+ if err := store.RevokeRefreshSession(ctx, userID, remoteID); err != nil {
133
+ t.Fatalf("revoke session: %v", err)
134
+ }
135
+ sessions, err = store.ListRefreshSessions(ctx, userID)
136
+ if err != nil || len(sessions) != 1 || sessions[0].ID != currentID {
137
+ t.Fatalf("sessions after revoke = %+v, err=%v", sessions, err)
138
+ }
139
+ }
@@ -76,6 +76,48 @@ redis.call("DEL", KEYS[1])
76
76
  return #hashes
77
77
  `)
78
78
 
79
+ var listRefreshSessionsScript = redis.NewScript(`
80
+ local sessions = {}
81
+ local hashes = redis.call("SMEMBERS", KEYS[1])
82
+ for _, hash in ipairs(hashes) do
83
+ local key = KEYS[2] .. hash
84
+ local raw = redis.call("GET", key)
85
+ local ttl = redis.call("PTTL", key)
86
+ if not raw or ttl < 1 then
87
+ redis.call("SREM", KEYS[1], hash)
88
+ else
89
+ local decoded, token_data = pcall(cjson.decode, raw)
90
+ if decoded and type(token_data) == "table" and token_data.user_id == ARGV[1] and
91
+ type(token_data.session_id) == "string" and token_data.session_id ~= "" then
92
+ table.insert(sessions, token_data)
93
+ elseif not decoded or type(token_data) ~= "table" or token_data.user_id ~= ARGV[1] then
94
+ redis.call("SREM", KEYS[1], hash)
95
+ end
96
+ end
97
+ end
98
+ return cjson.encode(sessions)
99
+ `)
100
+
101
+ var revokeRefreshSessionScript = redis.NewScript(`
102
+ local hashes = redis.call("SMEMBERS", KEYS[1])
103
+ local revoked = 0
104
+ for _, hash in ipairs(hashes) do
105
+ local key = KEYS[2] .. hash
106
+ local raw = redis.call("GET", key)
107
+ if raw then
108
+ local decoded, token_data = pcall(cjson.decode, raw)
109
+ if decoded and type(token_data) == "table" and token_data.user_id == ARGV[1] and token_data.session_id == ARGV[2] then
110
+ redis.call("DEL", key)
111
+ redis.call("SREM", KEYS[1], hash)
112
+ revoked = revoked + 1
113
+ end
114
+ else
115
+ redis.call("SREM", KEYS[1], hash)
116
+ end
117
+ end
118
+ return revoked
119
+ `)
120
+
79
121
  func (s *RedisTokenStore) SetRefreshToken(ctx context.Context, tokenHash string, token ports.RefreshTokenRecord) error {
80
122
  ttl := time.Until(token.ExpiresAt)
81
123
  if token.UserID == uuid.Nil || ttl <= 0 || token.AbsoluteExpiresAt.IsZero() || token.AbsoluteExpiresAt.Before(token.ExpiresAt) {
@@ -151,6 +193,39 @@ func (s *RedisTokenStore) RevokeAllRefreshTokens(ctx context.Context, userID uui
151
193
  return err
152
194
  }
153
195
 
196
+ func (s *RedisTokenStore) ListRefreshSessions(ctx context.Context, userID uuid.UUID) ([]ports.RefreshSession, error) {
197
+ result, err := listRefreshSessionsScript.Run(ctx, s.rdb,
198
+ []string{refreshUserKeyPrefix + userID.String(), refreshKeyPrefix}, userID.String()).Result()
199
+ if err != nil {
200
+ return nil, err
201
+ }
202
+ raw, ok := result.(string)
203
+ if !ok || raw == "" {
204
+ return []ports.RefreshSession{}, nil
205
+ }
206
+ var tokens []ports.RefreshTokenRecord
207
+ if err := json.Unmarshal([]byte(raw), &tokens); err != nil {
208
+ return nil, fmt.Errorf("decode refresh sessions: %w", err)
209
+ }
210
+ out := make([]ports.RefreshSession, 0, len(tokens))
211
+ for _, token := range tokens {
212
+ out = append(out, ports.RefreshSession{
213
+ ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
214
+ LastUsedAt: token.LastUsedAt, ExpiresAt: token.ExpiresAt,
215
+ })
216
+ }
217
+ return out, nil
218
+ }
219
+
220
+ func (s *RedisTokenStore) RevokeRefreshSession(ctx context.Context, userID, sessionID uuid.UUID) error {
221
+ if userID == uuid.Nil || sessionID == uuid.Nil {
222
+ return fmt.Errorf("session identity is invalid")
223
+ }
224
+ _, err := revokeRefreshSessionScript.Run(ctx, s.rdb,
225
+ []string{refreshUserKeyPrefix + userID.String(), refreshKeyPrefix}, userID.String(), sessionID.String()).Result()
226
+ return err
227
+ }
228
+
154
229
  func (s *RedisTokenStore) IsRefreshTokenUsed(ctx context.Context, tokenHash string) (uuid.UUID, bool, error) {
155
230
  raw, err := s.rdb.Get(ctx, refreshUsedKeyPrefix+tokenHash).Result()
156
231
  if err == redis.Nil {
@@ -70,6 +70,42 @@ func TestRefreshTokenRecord_JSONUsesLuaFieldNames(t *testing.T) {
70
70
  }
71
71
  }
72
72
 
73
+ func TestRedisTokenStore_ListAndRevokeRefreshSessions(t *testing.T) {
74
+ client := redisClientForTest(t)
75
+ store := NewRedisTokenStore(client, nil)
76
+ ctx := context.Background()
77
+ userID := uuid.New()
78
+ currentID := uuid.New()
79
+ remoteID := uuid.New()
80
+ current := testRefreshTokenRecord(userID)
81
+ current.SessionID = currentID
82
+ current.UserAgent = "current-device"
83
+ remote := testRefreshTokenRecord(userID)
84
+ remote.SessionID = remoteID
85
+ remote.UserAgent = "remote-device"
86
+ currentHash := "real-redis-session-current-" + uuid.NewString()
87
+ remoteHash := "real-redis-session-remote-" + uuid.NewString()
88
+ if err := store.SetRefreshToken(ctx, currentHash, current); err != nil {
89
+ t.Fatalf("seed current session: %v", err)
90
+ }
91
+ if err := store.SetRefreshToken(ctx, remoteHash, remote); err != nil {
92
+ t.Fatalf("seed remote session: %v", err)
93
+ }
94
+ t.Cleanup(func() { _ = store.RevokeAllRefreshTokens(ctx, userID) })
95
+
96
+ sessions, err := store.ListRefreshSessions(ctx, userID)
97
+ if err != nil || len(sessions) != 2 {
98
+ t.Fatalf("list sessions = %+v, err=%v", sessions, err)
99
+ }
100
+ if err := store.RevokeRefreshSession(ctx, userID, remoteID); err != nil {
101
+ t.Fatalf("revoke session: %v", err)
102
+ }
103
+ sessions, err = store.ListRefreshSessions(ctx, userID)
104
+ if err != nil || len(sessions) != 1 || sessions[0].ID != currentID {
105
+ t.Fatalf("sessions after revoke = %+v, err=%v", sessions, err)
106
+ }
107
+ }
108
+
73
109
  func TestRedisTokenStore_ConsumeRefreshToken_ConcurrentRealRedis(t *testing.T) {
74
110
  client := redisClientForTest(t)
75
111
  store := NewRedisTokenStore(client, nil)
@@ -12,17 +12,49 @@ type RegisterInput struct {
12
12
  Email string
13
13
  Password string
14
14
  Name string
15
+ Session SessionContext
15
16
  }
16
17
 
17
18
  type LoginInput struct {
18
19
  Email string
19
20
  Password string
21
+ Session SessionContext
20
22
  }
21
23
 
22
24
  type LoginExchangeInput struct {
23
25
  Code string
24
26
  State string
25
27
  CodeVerifier string
28
+ Session SessionContext
29
+ }
30
+
31
+ type SessionContext struct {
32
+ ID uuid.UUID
33
+ UserAgent string
34
+ CreatedAt time.Time
35
+ }
36
+
37
+ type Session struct {
38
+ ID uuid.UUID
39
+ UserAgent string
40
+ CreatedAt time.Time
41
+ LastUsedAt time.Time
42
+ ExpiresAt time.Time
43
+ Current bool
44
+ }
45
+
46
+ // IdentityResponse is the safe public view of a login identity. Provider
47
+ // subjects and password hashes never cross the application/HTTP boundary.
48
+ type IdentityResponse struct {
49
+ ID uuid.UUID
50
+ Provider string
51
+ CreatedAt time.Time
52
+ }
53
+
54
+ func ToIdentityResponse(identity domain.Identity) IdentityResponse {
55
+ return IdentityResponse{
56
+ ID: identity.ID, Provider: string(identity.Provider), CreatedAt: identity.CreatedAt,
57
+ }
26
58
  }
27
59
 
28
60
  type AuthResponse struct {
@@ -60,3 +60,19 @@ func errMFAConfig() error {
60
60
  func errUnknownRole() error {
61
61
  return domain.Rule("USER_UNKNOWN_ROLE", "unknown role code", domain.ErrUnknownRole)
62
62
  }
63
+
64
+ func errIdentityAlreadyLinked() error {
65
+ return domain.Rule("AUTH_IDENTITY_ALREADY_LINKED", "this login method is already linked", domain.ErrIdentityAlreadyLinked)
66
+ }
67
+
68
+ func errIdentityConflict() error {
69
+ return domain.Rule("AUTH_IDENTITY_CONFLICT", "this provider account is linked to another user", domain.ErrConflict)
70
+ }
71
+
72
+ func errIdentityNotFound() error {
73
+ return domain.Rule("AUTH_IDENTITY_NOT_FOUND", "login method not found", domain.ErrNotFound)
74
+ }
75
+
76
+ func errLastIdentity() error {
77
+ return domain.Rule("AUTH_LAST_IDENTITY", "you cannot remove your last login method", domain.ErrLastIdentity)
78
+ }
@@ -12,6 +12,8 @@ import (
12
12
 
13
13
  "{{goModule}}/internal/app/user/domain"
14
14
  "{{goModule}}/internal/shared/id"
15
+
16
+ "github.com/google/uuid"
15
17
  )
16
18
 
17
19
  // BeginLogin accepts the browser client's state and S256 PKCE challenge and
@@ -19,6 +21,21 @@ import (
19
21
  // The server persists a hashed state transaction binding provider, challenge,
20
22
  // and OIDC nonce before the callback reaches ExchangeLogin.
21
23
  func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginStartInput) (*Authorization, error) {
24
+ return s.beginProviderLogin(ctx, providerName, in, uuid.Nil)
25
+ }
26
+
27
+ // BeginIdentityLink starts an OAuth transaction that is bound to the
28
+ // authenticated user. It is deliberately separate from BeginLogin: the
29
+ // public login flow may resolve any user, while a link flow must never be
30
+ // allowed to attach an identity to whichever account the provider returns.
31
+ func (s *Service) BeginIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginStartInput) (*Authorization, error) {
32
+ if userID == uuid.Nil {
33
+ return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
34
+ }
35
+ return s.beginProviderLogin(ctx, providerName, in, userID)
36
+ }
37
+
38
+ func (s *Service) beginProviderLogin(ctx context.Context, providerName string, in LoginStartInput, userID uuid.UUID) (*Authorization, error) {
22
39
  provider, ok := s.providers.Lookup(providerName)
23
40
  if !ok {
24
41
  return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
@@ -46,6 +63,7 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginS
46
63
  }
47
64
  if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State), LoginTransaction{
48
65
  Provider: providerName,
66
+ UserID: userID,
49
67
  CodeChallenge: in.CodeChallenge,
50
68
  Nonce: nonce,
51
69
  ExpiresAt: clock().Add(s.config.OAuthStateTTL),
@@ -56,37 +74,48 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginS
56
74
  }
57
75
 
58
76
  func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in LoginExchangeInput) (*AuthResult, error) {
77
+ identity, err := s.exchangeExternalIdentity(ctx, providerName, in, uuid.Nil)
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ u, err := s.findOrCreateExternalUser(ctx, identity)
82
+ if err != nil {
83
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
84
+ }
85
+ return s.completeLogin(ctx, u, in.Session)
86
+ }
87
+
88
+ // exchangeExternalIdentity consumes the one-time transaction before asking
89
+ // the provider to complete the code exchange. The transaction's UserID is
90
+ // zero for public login and is the authenticated caller for identity linking.
91
+ func (s *Service) exchangeExternalIdentity(ctx context.Context, providerName string, in LoginExchangeInput, userID uuid.UUID) (ExternalIdentity, error) {
59
92
  provider, ok := s.providers.Lookup(providerName)
60
93
  if !ok {
61
- return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
94
+ return ExternalIdentity{}, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
62
95
  }
63
96
  if !validOAuthValue(in.State) || !validPKCEValue(in.CodeVerifier) {
64
- return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
97
+ return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
65
98
  }
66
99
  if !validOAuthValue(in.Code) {
67
- return nil, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
100
+ return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
68
101
  }
69
102
 
70
103
  transaction, ok, err := s.oauthTransactions.ConsumeLoginTransaction(ctx, hashToken(in.State))
71
104
  if err != nil {
72
- return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
105
+ return ExternalIdentity{}, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
73
106
  }
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"))
107
+ if !ok || transaction.Provider != providerName || transaction.UserID != userID || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
108
+ return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
76
109
  }
77
110
 
78
111
  identity, err := provider.Complete(ctx, LoginCompleteInput{Code: in.Code, CodeVerifier: in.CodeVerifier, Nonce: transaction.Nonce})
79
112
  if err != nil {
80
- return nil, mapProviderError(err)
113
+ return ExternalIdentity{}, mapProviderError(err)
81
114
  }
82
115
  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))
116
+ return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
88
117
  }
89
- return s.completeLogin(ctx, u)
118
+ return identity, nil
90
119
  }
91
120
 
92
121
  func validPKCEVerifier(verifier, challenge string) bool {
@@ -0,0 +1,97 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "strings"
8
+
9
+ "{{goModule}}/internal/app/user/domain"
10
+
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ func (s *Service) ListIdentities(ctx context.Context, userID uuid.UUID) ([]IdentityResponse, error) {
15
+ items, err := s.repo.ListIdentities(ctx, userID)
16
+ if err != nil {
17
+ return nil, fmt.Errorf("list login identities: %w", err)
18
+ }
19
+
20
+ out := make([]IdentityResponse, len(items))
21
+ for i := range items {
22
+ out[i] = ToIdentityResponse(items[i])
23
+ }
24
+ return out, nil
25
+ }
26
+
27
+ func (s *Service) ExchangeIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginExchangeInput) (*IdentityResponse, error) {
28
+ if userID == uuid.Nil {
29
+ return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
30
+ }
31
+
32
+ identity, err := s.exchangeExternalIdentity(ctx, providerName, in, userID)
33
+ if err != nil {
34
+ return nil, err
35
+ }
36
+ return s.linkExternalIdentity(ctx, userID, identity)
37
+ }
38
+
39
+ func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, info ExternalIdentity) (*IdentityResponse, error) {
40
+ provider := domain.Provider(strings.TrimSpace(info.Provider))
41
+ if userID == uuid.Nil || provider == "" || strings.TrimSpace(info.Subject) == "" {
42
+ return nil, NewOAuthError(OAuthFailed, fmt.Errorf("external identity is incomplete"))
43
+ }
44
+
45
+ // Replaying the same provider account from the same user is idempotent.
46
+ // A provider subject already owned by another user is a conflict and must
47
+ // never be silently moved between accounts.
48
+ if existing, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
49
+ if existing.UserID != userID {
50
+ return nil, errIdentityConflict()
51
+ }
52
+ out := ToIdentityResponse(*existing)
53
+ return &out, nil
54
+ } else if !errors.Is(err, domain.ErrNotFound) {
55
+ return nil, fmt.Errorf("find linked identity: %w", err)
56
+ }
57
+
58
+ // The database permits one identity per provider for each user. Return a
59
+ // controlled conflict instead of exposing a persistence duplicate error.
60
+ if _, err := s.repo.FindIdentity(ctx, userID, provider); err == nil {
61
+ return nil, errIdentityAlreadyLinked()
62
+ } else if !errors.Is(err, domain.ErrNotFound) {
63
+ return nil, fmt.Errorf("check existing provider identity: %w", err)
64
+ }
65
+
66
+ providerUID := info.Subject
67
+ identity := &domain.Identity{
68
+ ID: uuid.New(), UserID: userID, Provider: provider, ProviderUID: &providerUID,
69
+ }
70
+ if err := s.repo.CreateIdentity(ctx, identity); err != nil {
71
+ if errors.Is(err, domain.ErrConflict) {
72
+ return nil, errIdentityAlreadyLinked()
73
+ }
74
+ return nil, fmt.Errorf("link identity: %w", err)
75
+ }
76
+
77
+ out := ToIdentityResponse(*identity)
78
+ return &out, nil
79
+ }
80
+
81
+ func (s *Service) UnlinkIdentity(ctx context.Context, userID uuid.UUID, providerName string) error {
82
+ provider := domain.Provider(strings.TrimSpace(providerName))
83
+ if userID == uuid.Nil || provider == "" {
84
+ return errIdentityNotFound()
85
+ }
86
+ if err := s.repo.DeleteIdentity(ctx, userID, provider); err != nil {
87
+ switch {
88
+ case errors.Is(err, domain.ErrLastIdentity):
89
+ return errLastIdentity()
90
+ case errors.Is(err, domain.ErrNotFound):
91
+ return errIdentityNotFound()
92
+ default:
93
+ return fmt.Errorf("unlink identity: %w", err)
94
+ }
95
+ }
96
+ return nil
97
+ }