@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.
- package/dist/commands/auth.js +11 -2
- package/dist/index.js +1 -1
- package/dist/templates/auth-manifest.js +4 -0
- package/dist/utils/rbac-patcher.js +12 -4
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +54 -0
- package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
- package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
- package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
- package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-session.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-sessions.yaml.hbs +13 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +62 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +80 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +6 -2
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +34 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +30 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +6 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +43 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +46 -7
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +16 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +42 -13
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +97 -0
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +69 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +5 -2
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +2 -2
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +13 -4
- package/templates/add/auth/internal/app/user/application/service.go.hbs +6 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +107 -5
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +62 -8
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +19 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +9 -3
- package/templates/add/auth/internal/shared/middleware/auth_test.go.hbs +48 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +5 -1
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +2 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +14 -0
- package/templates/create/base/README.md.hbs +9 -0
- package/templates/create/features/docs/architecture.md.hbs +5 -5
- 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 {
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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":
|
|
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,
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs
CHANGED
|
@@ -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
|
|
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
|
|
97
|
+
return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
|
|
65
98
|
}
|
|
66
99
|
if !validOAuthValue(in.Code) {
|
|
67
|
-
return
|
|
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
|
|
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
|
|
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
|
|
113
|
+
return ExternalIdentity{}, mapProviderError(err)
|
|
81
114
|
}
|
|
82
115
|
if identity.Provider != providerName {
|
|
83
|
-
return
|
|
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
|
|
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
|
+
}
|