@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
@@ -0,0 +1,69 @@
1
+ package application
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+
8
+ "{{goModule}}/internal/app/user/domain"
9
+
10
+ "github.com/google/uuid"
11
+ )
12
+
13
+ func TestService_IdentityLinkBindsOAuthTransactionToCaller(t *testing.T) {
14
+ provider, svc := validFakeProvider(t)
15
+ userID := uuid.New()
16
+ otherUserID := uuid.New()
17
+
18
+ if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
19
+ t.Fatalf("begin identity link: %v", err)
20
+ }
21
+
22
+ _, err := svc.ExchangeIdentityLink(context.Background(), otherUserID, provider.Name(), validLoginExchange())
23
+ if got := oauthErrorCode(t, err); got != OAuthStateInvalid {
24
+ t.Fatalf("oauth code = %q, want %q", got, OAuthStateInvalid)
25
+ }
26
+ if provider.completeCnt != 0 {
27
+ t.Fatal("provider must not receive a transaction bound to another user")
28
+ }
29
+ }
30
+
31
+ func TestService_IdentityLinkIsIdempotentForSameProviderSubject(t *testing.T) {
32
+ provider, svc := validFakeProvider(t)
33
+ userID := uuid.New()
34
+
35
+ if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
36
+ t.Fatalf("begin first identity link: %v", err)
37
+ }
38
+ first, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
39
+ if err != nil {
40
+ t.Fatalf("exchange first identity link: %v", err)
41
+ }
42
+
43
+ if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
44
+ t.Fatalf("begin repeated identity link: %v", err)
45
+ }
46
+ second, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
47
+ if err != nil {
48
+ t.Fatalf("exchange repeated identity link: %v", err)
49
+ }
50
+ if first.ID != second.ID || first.Provider != second.Provider {
51
+ t.Fatalf("repeated link returned a different identity: first=%+v second=%+v", first, second)
52
+ }
53
+ }
54
+
55
+ func TestService_UnlinkIdentityRejectsRemovingLastLoginMethod(t *testing.T) {
56
+ userID := uuid.New()
57
+ providerUID := "local-user"
58
+ repo := &fakeRepo{}
59
+ repo.identities = append(repo.identities, domain.Identity{
60
+ ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, ProviderUID: &providerUID,
61
+ })
62
+ svc := newTestService(repo, newFakeTokenStore())
63
+
64
+ err := svc.UnlinkIdentity(context.Background(), userID, string(domain.ProviderLocal))
65
+ var ruleErr *domain.RuleError
66
+ if !errors.As(err, &ruleErr) || ruleErr.Code != "AUTH_LAST_IDENTITY" {
67
+ t.Fatalf("unlink last identity error = %v, want AUTH_LAST_IDENTITY", err)
68
+ }
69
+ }
@@ -16,8 +16,9 @@ const (
16
16
  // accessClaims — see internal/shared/middleware/auth.go for why this struct
17
17
  // is duplicated there instead of imported.
18
18
  type accessClaims struct {
19
- Typ string `json:"typ"`
20
- Role string `json:"role,omitempty"`
19
+ Typ string `json:"typ"`
20
+ Role string `json:"role,omitempty"`
21
+ SessionID uuid.UUID `json:"sid,omitempty"`
21
22
  // go-scaffold:jwt-claims
22
23
  jwt.RegisteredClaims
23
24
  }
@@ -25,12 +26,14 @@ type accessClaims struct {
25
26
  func (s *Service) issueAccessToken(
26
27
  userID uuid.UUID,
27
28
  role string,
29
+ sessionID uuid.UUID,
28
30
  // go-scaffold:issue-access-token-params
29
31
  ) (string, error) {
30
32
  now := s.clock()
31
33
  claims := accessClaims{
32
34
  Typ: tokenTypeAccess,
33
35
  Role: role,
36
+ SessionID: sessionID,
34
37
  // go-scaffold:jwt-claims-values
35
38
  RegisteredClaims: jwt.RegisteredClaims{
36
39
  Subject: userID.String(),
@@ -55,7 +55,7 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*AuthResult,
55
55
  // Best-effort: a mail failure should not block registration. The user can
56
56
  // request another link through ResendVerificationEmail.
57
57
  s.sendVerificationEmail(ctx, u)
58
- auth, err := s.issueTokens(ctx, u)
58
+ auth, err := s.issueTokens(ctx, u, in.Session)
59
59
  if err != nil {
60
60
  return nil, err
61
61
  }
@@ -92,5 +92,5 @@ func (s *Service) Login(ctx context.Context, in LoginInput) (*AuthResult, error)
92
92
  if err := s.repo.ClearLoginFailures(ctx, key); err != nil {
93
93
  slog.Error("clear login failures", "error", err)
94
94
  }
95
- return s.completeLogin(ctx, u)
95
+ return s.completeLogin(ctx, u, in.Session)
96
96
  }
@@ -86,10 +86,17 @@ func ValidateMFASettings(settings MFASettings) error {
86
86
  return nil
87
87
  }
88
88
 
89
- func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResult, error) {
89
+ func (s *Service) completeLogin(ctx context.Context, u *domain.User, sessions ...SessionContext) (*AuthResult, error) {
90
+ var session SessionContext
91
+ if len(sessions) > 0 {
92
+ session = sessions[0]
93
+ }
94
+ if session.ID == uuid.Nil {
95
+ session.ID = uuid.New()
96
+ }
90
97
  settings := s.mfaSettings()
91
98
  if !settings.Enabled {
92
- auth, err := s.issueTokens(ctx, u)
99
+ auth, err := s.issueTokens(ctx, u, session)
93
100
  if err != nil {
94
101
  return nil, err
95
102
  }
@@ -103,7 +110,7 @@ func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResul
103
110
  return nil, fmt.Errorf("read MFA enrollment: %w", err)
104
111
  }
105
112
  if !found || !enrollment.Enabled {
106
- auth, err := s.issueTokens(ctx, u)
113
+ auth, err := s.issueTokens(ctx, u, session)
107
114
  if err != nil {
108
115
  return nil, err
109
116
  }
@@ -116,6 +123,8 @@ func (s *Service) completeLogin(ctx context.Context, u *domain.User) (*AuthResul
116
123
  }
117
124
  if err := s.mfa.CreateChallenge(ctx, hashToken(rawChallenge), MFAChallenge{
118
125
  UserID: u.ID,
126
+ SessionID: session.ID,
127
+ UserAgent: normalizeUserAgent(session.UserAgent),
119
128
  ExpiresAt: s.clock().Add(settings.ChallengeTTL),
120
129
  }); err != nil {
121
130
  return nil, fmt.Errorf("store MFA challenge: %w", err)
@@ -285,7 +294,7 @@ func (s *Service) VerifyMFA(ctx context.Context, rawChallenge, code string) (*Au
285
294
  if err != nil {
286
295
  return nil, errInvalidCredentials()
287
296
  }
288
- return s.issueTokens(ctx, u)
297
+ return s.issueTokens(ctx, u, SessionContext{ID: challenge.SessionID, UserAgent: challenge.UserAgent})
289
298
  }
290
299
 
291
300
  func (s *Service) requireMFA() (MFASettings, error) {
@@ -118,12 +118,18 @@ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string
118
118
  // ServicePort is the inbound application boundary. HTTP, jobs, and future
119
119
  // transports depend on this capability set instead of the concrete service.
120
120
  type ServicePort interface {
121
+ ListIdentities(context.Context, uuid.UUID) ([]IdentityResponse, error)
122
+ BeginIdentityLink(context.Context, uuid.UUID, string, LoginStartInput) (*Authorization, error)
123
+ ExchangeIdentityLink(context.Context, uuid.UUID, string, LoginExchangeInput) (*IdentityResponse, error)
124
+ UnlinkIdentity(context.Context, uuid.UUID, string) error
121
125
  // go-scaffold:service-interface
122
126
  Register(context.Context, RegisterInput) (*AuthResult, error)
123
127
  Login(context.Context, LoginInput) (*AuthResult, error)
124
128
  Refresh(context.Context, string) (*AuthResponse, error)
125
129
  Logout(context.Context, string) error
126
130
  LogoutAll(context.Context, uuid.UUID) error
131
+ ListSessions(context.Context, uuid.UUID, uuid.UUID) ([]Session, error)
132
+ RevokeSession(context.Context, uuid.UUID, uuid.UUID) error
127
133
  ForgotPassword(context.Context, string) error
128
134
  ResetPassword(context.Context, string, string) error
129
135
  VerifyEmail(context.Context, string) error
@@ -102,6 +102,32 @@ func (f *fakeTokenStore) RevokeAllRefreshTokens(_ context.Context, userID uuid.U
102
102
  delete(f.sessions, userID)
103
103
  return nil
104
104
  }
105
+ func (f *fakeTokenStore) ListRefreshSessions(_ context.Context, userID uuid.UUID) ([]RefreshSession, error) {
106
+ f.mu.Lock()
107
+ defer f.mu.Unlock()
108
+ out := make([]RefreshSession, 0)
109
+ for _, token := range f.active {
110
+ if token.UserID != userID || !token.ExpiresAt.After(time.Now()) {
111
+ continue
112
+ }
113
+ out = append(out, RefreshSession{
114
+ ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
115
+ LastUsedAt: token.LastUsedAt, ExpiresAt: token.ExpiresAt,
116
+ })
117
+ }
118
+ return out, nil
119
+ }
120
+ func (f *fakeTokenStore) RevokeRefreshSession(_ context.Context, userID, sessionID uuid.UUID) error {
121
+ f.mu.Lock()
122
+ defer f.mu.Unlock()
123
+ for hash, token := range f.active {
124
+ if token.UserID == userID && token.SessionID == sessionID {
125
+ delete(f.active, hash)
126
+ delete(f.sessions[userID], hash)
127
+ }
128
+ }
129
+ return nil
130
+ }
105
131
  func (f *fakeTokenStore) IsRefreshTokenUsed(_ context.Context, hash string) (uuid.UUID, bool, error) {
106
132
  f.mu.Lock()
107
133
  defer f.mu.Unlock()
@@ -308,6 +334,7 @@ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID,
308
334
  type fakeRepo struct {
309
335
  user *domain.User
310
336
  identity *domain.Identity
337
+ identities []domain.Identity
311
338
  updateIdentityErr error
312
339
  updateUserErr error
313
340
  failures map[string]int
@@ -361,20 +388,61 @@ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
361
388
  return nil
362
389
  }
363
390
  func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
364
- func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ domain.Provider) (*domain.Identity, error) {
365
- if f.identity != nil && f.identity.UserID == userID {
391
+
392
+ func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
393
+ for i := range f.identities {
394
+ if f.identities[i].UserID == userID && f.identities[i].Provider == provider {
395
+ copy := f.identities[i]
396
+ return &copy, nil
397
+ }
398
+ }
399
+ if f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider {
366
400
  copy := *f.identity
367
401
  return &copy, nil
368
402
  }
369
403
  return nil, domain.ErrNotFound
370
404
  }
371
- func (f *fakeRepo) FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error) {
405
+ func (f *fakeRepo) ListIdentities(_ context.Context, userID uuid.UUID) ([]domain.Identity, error) {
406
+ out := make([]domain.Identity, 0, len(f.identities)+1)
407
+ for _, identity := range f.identities {
408
+ if identity.UserID == userID {
409
+ out = append(out, identity)
410
+ }
411
+ }
412
+ if f.identity != nil && f.identity.UserID == userID {
413
+ found := false
414
+ for _, identity := range out {
415
+ if identity.ID == f.identity.ID {
416
+ found = true
417
+ break
418
+ }
419
+ }
420
+ if !found {
421
+ out = append(out, *f.identity)
422
+ }
423
+ }
424
+ return out, nil
425
+ }
426
+ func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
427
+ for i := range f.identities {
428
+ if f.identities[i].Provider == provider && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
429
+ copy := f.identities[i]
430
+ return &copy, nil
431
+ }
432
+ }
433
+ if f.identity != nil && f.identity.Provider == provider && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
434
+ copy := *f.identity
435
+ return &copy, nil
436
+ }
372
437
  return nil, domain.ErrNotFound
373
438
  }
374
439
  func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
375
440
  return nil
376
441
  }
377
- func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error { return nil }
442
+ func (f *fakeRepo) CreateIdentity(_ context.Context, identity *domain.Identity) error {
443
+ f.identities = append(f.identities, *identity)
444
+ return nil
445
+ }
378
446
  func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
379
447
  if f.updateIdentityErr != nil {
380
448
  return f.updateIdentityErr
@@ -382,6 +450,35 @@ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
382
450
  f.identity = i
383
451
  return nil
384
452
  }
453
+ func (f *fakeRepo) DeleteIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) error {
454
+ count := 0
455
+ target := -1
456
+ for i := range f.identities {
457
+ if f.identities[i].UserID != userID {
458
+ continue
459
+ }
460
+ count++
461
+ if f.identities[i].Provider == provider {
462
+ target = i
463
+ }
464
+ }
465
+ legacyTarget := f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider
466
+ if legacyTarget {
467
+ count++
468
+ }
469
+ if target < 0 && !legacyTarget {
470
+ return domain.ErrNotFound
471
+ }
472
+ if count <= 1 {
473
+ return domain.ErrLastIdentity
474
+ }
475
+ if target >= 0 {
476
+ f.identities = append(f.identities[:target], f.identities[target+1:]...)
477
+ } else {
478
+ f.identity = nil
479
+ }
480
+ return nil
481
+ }
385
482
 
386
483
  // go-scaffold:user-fake-repo-methods
387
484
  // go-scaffold:repository-stub-methods
@@ -552,8 +649,10 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
552
649
  raw := "bounded-refresh-token"
553
650
  issuedAt := time.Now()
554
651
  absoluteExpiry := issuedAt.Add(2 * time.Hour)
652
+ sessionID := uuid.New()
555
653
  if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
556
- UserID: userID,
654
+ UserID: userID, SessionID: sessionID, UserAgent: "test-device",
655
+ CreatedAt: issuedAt.Add(-time.Hour), LastUsedAt: issuedAt.Add(-time.Minute),
557
656
  ExpiresAt: issuedAt.Add(time.Hour),
558
657
  AbsoluteExpiresAt: absoluteExpiry,
559
658
  }); err != nil {
@@ -576,6 +675,9 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
576
675
  if !rotated.ExpiresAt.Equal(absoluteExpiry) {
577
676
  t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
578
677
  }
678
+ if rotated.SessionID != sessionID || rotated.UserAgent != "test-device" {
679
+ t.Fatalf("refresh rotation changed session identity/metadata: got id=%s ua=%q", rotated.SessionID, rotated.UserAgent)
680
+ }
579
681
  }
580
682
 
581
683
  func TestService_Refresh_ReuseRevokeFailureFailsClosed(t *testing.T) {
@@ -3,6 +3,8 @@ package application
3
3
  import (
4
4
  "context"
5
5
  "fmt"
6
+ "sort"
7
+ "strings"
6
8
  "time"
7
9
 
8
10
  "{{goModule}}/internal/app/user/domain"
@@ -36,7 +38,9 @@ func (s *Service) Refresh(ctx context.Context, rawRefreshToken string) (*AuthRes
36
38
  if err != nil {
37
39
  return nil, errInvalidToken()
38
40
  }
39
- return s.issueTokens(ctx, u, token.AbsoluteExpiresAt)
41
+ return s.issueTokens(ctx, u, SessionContext{
42
+ ID: token.SessionID, UserAgent: token.UserAgent, CreatedAt: token.CreatedAt,
43
+ }, token.AbsoluteExpiresAt)
40
44
  }
41
45
 
42
46
  // Logout is intentional, not reuse: a missing or already-gone token still
@@ -58,10 +62,51 @@ func (s *Service) LogoutAll(ctx context.Context, userID uuid.UUID) error {
58
62
  return s.refreshTokens.RevokeAllRefreshTokens(ctx, userID)
59
63
  }
60
64
 
61
- func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
65
+ func (s *Service) ListSessions(ctx context.Context, userID, currentSessionID uuid.UUID) ([]Session, error) {
66
+ items, err := s.refreshTokens.ListRefreshSessions(ctx, userID)
67
+ if err != nil {
68
+ return nil, fmt.Errorf("list refresh sessions: %w", err)
69
+ }
70
+ out := make([]Session, 0, len(items))
71
+ for _, item := range items {
72
+ out = append(out, Session{
73
+ ID: item.ID, UserAgent: item.UserAgent, CreatedAt: item.CreatedAt,
74
+ LastUsedAt: item.LastUsedAt, ExpiresAt: item.ExpiresAt,
75
+ Current: item.ID == currentSessionID,
76
+ })
77
+ }
78
+ sort.SliceStable(out, func(i, j int) bool {
79
+ return out[i].LastUsedAt.After(out[j].LastUsedAt)
80
+ })
81
+ return out, nil
82
+ }
83
+
84
+ func (s *Service) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error {
85
+ if userID == uuid.Nil || sessionID == uuid.Nil {
86
+ return fmt.Errorf("session identity is invalid")
87
+ }
88
+ return s.refreshTokens.RevokeRefreshSession(ctx, userID, sessionID)
89
+ }
90
+
91
+ func (s *Service) issueTokens(ctx context.Context, u *domain.User, session SessionContext, absoluteExpiresAt ...time.Time) (*AuthResponse, error) {
92
+ clock := s.now
93
+ if clock == nil {
94
+ clock = time.Now
95
+ }
96
+ now := clock()
97
+ if session.ID == uuid.Nil {
98
+ session.ID = uuid.New()
99
+ }
100
+ if session.CreatedAt.IsZero() {
101
+ session.CreatedAt = now
102
+ }
103
+ if session.UserAgent = normalizeUserAgent(session.UserAgent); session.UserAgent == "" {
104
+ session.UserAgent = "unknown"
105
+ }
62
106
  access, err := s.issueAccessToken(
63
107
  u.ID,
64
108
  u.Role,
109
+ session.ID,
65
110
  // go-scaffold:issue-access-token-args
66
111
  )
67
112
  if err != nil {
@@ -71,11 +116,6 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
71
116
  if err != nil {
72
117
  return nil, fmt.Errorf("generate refresh token: %w", err)
73
118
  }
74
- clock := s.now
75
- if clock == nil {
76
- clock = time.Now
77
- }
78
- now := clock()
79
119
  abs := now.Add(s.config.JWTRefreshMaxTTL)
80
120
  if len(absoluteExpiresAt) > 0 && !absoluteExpiresAt[0].IsZero() {
81
121
  abs = absoluteExpiresAt[0]
@@ -87,7 +127,11 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
87
127
  if !expiresAt.After(now) || !abs.After(now) {
88
128
  return nil, fmt.Errorf("refresh token lifetime is exhausted")
89
129
  }
90
- if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{UserID: u.ID, ExpiresAt: expiresAt, AbsoluteExpiresAt: abs}); err != nil {
130
+ if err := s.refreshTokens.SetRefreshToken(ctx, hashToken(refresh), RefreshTokenRecord{
131
+ UserID: u.ID, SessionID: session.ID, UserAgent: session.UserAgent,
132
+ CreatedAt: session.CreatedAt, LastUsedAt: now, ExpiresAt: expiresAt,
133
+ AbsoluteExpiresAt: abs,
134
+ }); err != nil {
91
135
  return nil, fmt.Errorf("store refresh token: %w", err)
92
136
  }
93
137
  return &AuthResponse{
@@ -97,3 +141,13 @@ func (s *Service) issueTokens(ctx context.Context, u *domain.User, absoluteExpir
97
141
  ExpiresIn: int(s.config.JWTAccessTTL.Seconds()),
98
142
  }, nil
99
143
  }
144
+
145
+ const maxUserAgentLength = 512
146
+
147
+ func normalizeUserAgent(value string) string {
148
+ value = strings.TrimSpace(value)
149
+ if len(value) > maxUserAgentLength {
150
+ return value[:maxUserAgentLength]
151
+ }
152
+ return value
153
+ }
@@ -8,6 +8,7 @@ type RefreshTokenStore = ports.RefreshTokenStore
8
8
  type OAuthTransactionStore = ports.OAuthTransactionStore
9
9
  type RecoveryTokenStore = ports.RecoveryTokenStore
10
10
  type RefreshTokenRecord = ports.RefreshTokenRecord
11
+ type RefreshSession = ports.RefreshSession
11
12
  type LoginTransaction = ports.LoginTransaction
12
13
  type MFAEnrollment = ports.MFAEnrollment
13
14
  type MFAChallenge = ports.MFAChallenge
@@ -15,7 +15,9 @@ var (
15
15
  ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
16
16
  ErrMFANotEnrolled = errors.New("MFA is not enrolled")
17
17
  ErrMFASetupRequired = errors.New("MFA setup is required")
18
- ErrUnknownRole = errors.New("unknown role")
18
+ ErrUnknownRole = errors.New("unknown role")
19
+ ErrIdentityAlreadyLinked = errors.New("login identity is already linked")
20
+ ErrLastIdentity = errors.New("cannot remove the last login identity")
19
21
  )
20
22
 
21
23
  type RuleError struct {
@@ -16,10 +16,12 @@ type UserRepository interface {
16
16
  UpdateUser(context.Context, *domain.User) error
17
17
  FindAll(context.Context, int, int) ([]domain.User, error)
18
18
  FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
19
+ ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
19
20
  FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
20
21
  CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
21
22
  CreateIdentity(context.Context, *domain.Identity) error
22
23
  UpdateIdentity(context.Context, *domain.Identity) error
24
+ DeleteIdentity(context.Context, uuid.UUID, domain.Provider) error
23
25
  LoginLockedUntil(context.Context, string) (time.Time, error)
24
26
  RecordLoginFailure(context.Context, string, int, time.Duration) error
25
27
  ClearLoginFailures(context.Context, string) error
@@ -36,21 +38,36 @@ type RoleChecker interface {
36
38
 
37
39
  type RefreshTokenRecord struct {
38
40
  UserID uuid.UUID `json:"user_id"`
41
+ SessionID uuid.UUID `json:"session_id"`
42
+ UserAgent string `json:"user_agent"`
43
+ CreatedAt time.Time `json:"created_at"`
44
+ LastUsedAt time.Time `json:"last_used_at"`
39
45
  ExpiresAt time.Time `json:"expires_at"`
40
46
  AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
41
47
  }
42
48
 
49
+ type RefreshSession struct {
50
+ ID uuid.UUID
51
+ UserAgent string
52
+ CreatedAt time.Time
53
+ LastUsedAt time.Time
54
+ ExpiresAt time.Time
55
+ }
56
+
43
57
  type RefreshTokenStore interface {
44
58
  SetRefreshToken(context.Context, string, RefreshTokenRecord) error
45
59
  GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
46
60
  ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
47
61
  DeleteRefreshToken(context.Context, string, uuid.UUID) error
48
62
  RevokeAllRefreshTokens(context.Context, uuid.UUID) error
63
+ ListRefreshSessions(context.Context, uuid.UUID) ([]RefreshSession, error)
64
+ RevokeRefreshSession(context.Context, uuid.UUID, uuid.UUID) error
49
65
  IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
50
66
  }
51
67
 
52
68
  type LoginTransaction struct {
53
69
  Provider string `json:"provider"`
70
+ UserID uuid.UUID `json:"user_id"`
54
71
  CodeChallenge string `json:"code_challenge"`
55
72
  Nonce string `json:"nonce"`
56
73
  ExpiresAt time.Time `json:"expires_at"`
@@ -76,6 +93,8 @@ type MFAEnrollment struct {
76
93
 
77
94
  type MFAChallenge struct {
78
95
  UserID uuid.UUID
96
+ SessionID uuid.UUID
97
+ UserAgent string
79
98
  ExpiresAt time.Time
80
99
  }
81
100
 
@@ -13,14 +13,17 @@ import (
13
13
 
14
14
  const UserIDKey = "user_id"
15
15
 
16
+ const SessionIDKey = "session_id"
17
+
16
18
  // go-scaffold:middleware-auth-keys
17
19
 
18
20
  // accessClaims mirrors internal/app/user's own claims shape — duplicated
19
21
  // rather than imported, since shared/ can never import a domain package.
20
- // The two are kept in sync by convention: sub = user id, typ = "access",
21
- // nothing else is load-bearing here.
22
+ // The two are kept in sync by convention: sub = user id, typ = "access", and
23
+ // sid = the stable refresh-session identity used by session management.
22
24
  type accessClaims struct {
23
- Typ string `json:"typ"`
25
+ Typ string `json:"typ"`
26
+ SessionID uuid.UUID `json:"sid,omitempty"`
24
27
  // go-scaffold:middleware-auth-claims
25
28
  jwt.RegisteredClaims
26
29
  }
@@ -53,6 +56,9 @@ func RequireAuth(secret string) gin.HandlerFunc {
53
56
  }
54
57
 
55
58
  c.Set(UserIDKey, userID)
59
+ if claims.SessionID != uuid.Nil {
60
+ c.Set(SessionIDKey, claims.SessionID)
61
+ }
56
62
  // go-scaffold:middleware-auth-context
57
63
  c.Next()
58
64
  }
@@ -0,0 +1,48 @@
1
+ package middleware
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/gin-gonic/gin"
10
+ "github.com/golang-jwt/jwt/v5"
11
+ "github.com/google/uuid"
12
+ )
13
+
14
+ func TestRequireAuthExposesSessionIDFromAccessToken(t *testing.T) {
15
+ gin.SetMode(gin.TestMode)
16
+ secret := "test-secret"
17
+ userID := uuid.New()
18
+ sessionID := uuid.New()
19
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims{
20
+ Typ: "access",
21
+ SessionID: sessionID,
22
+ RegisteredClaims: jwt.RegisteredClaims{
23
+ Subject: userID.String(),
24
+ IssuedAt: jwt.NewNumericDate(time.Now()),
25
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
26
+ },
27
+ })
28
+ raw, err := token.SignedString([]byte(secret))
29
+ if err != nil {
30
+ t.Fatalf("sign token: %v", err)
31
+ }
32
+
33
+ router := gin.New()
34
+ router.Use(RequireAuth(secret))
35
+ router.GET("/", func(c *gin.Context) {
36
+ if got := c.MustGet(SessionIDKey); got != sessionID {
37
+ t.Fatalf("session id = %v, want %s", got, sessionID)
38
+ }
39
+ c.Status(http.StatusNoContent)
40
+ })
41
+ response := httptest.NewRecorder()
42
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
43
+ request.Header.Set("Authorization", "Bearer "+raw)
44
+ router.ServeHTTP(response, request)
45
+ if response.Code != http.StatusNoContent {
46
+ t.Fatalf("status = %d, want 204", response.Code)
47
+ }
48
+ }
@@ -6,12 +6,15 @@ CREATE TABLE user_svc.auth_tokens (
6
6
  token_hash TEXT PRIMARY KEY,
7
7
  user_id UUID NOT NULL,
8
8
  kind VARCHAR(20) NOT NULL,
9
+ session_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
10
+ user_agent TEXT NOT NULL DEFAULT '',
9
11
  expires_at TIMESTAMPTZ NOT NULL,
10
12
  absolute_expires_at TIMESTAMPTZ,
11
13
  provider VARCHAR(20) NOT NULL DEFAULT '',
12
14
  code_challenge TEXT NOT NULL DEFAULT '',
13
15
  nonce TEXT NOT NULL DEFAULT '',
14
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
16
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
17
+ last_used_at TIMESTAMPTZ NOT NULL DEFAULT now()
15
18
  );
16
19
 
17
20
  -- Named to match model.AuthToken's own `index:` tags exactly, so the
@@ -19,3 +22,4 @@ CREATE TABLE user_svc.auth_tokens (
19
22
  CREATE INDEX idx_auth_tokens_user_kind ON user_svc.auth_tokens (user_id, kind);
20
23
  CREATE INDEX idx_auth_tokens_expires_at ON user_svc.auth_tokens (expires_at);
21
24
  CREATE INDEX idx_auth_tokens_absolute_expires_at ON user_svc.auth_tokens (absolute_expires_at);
25
+ CREATE INDEX idx_auth_tokens_user_session ON user_svc.auth_tokens (user_id, session_id, last_used_at);
@@ -12,6 +12,8 @@ CREATE TABLE user_svc.mfa_enrollments (
12
12
  CREATE TABLE user_svc.mfa_challenges (
13
13
  challenge_hash TEXT PRIMARY KEY,
14
14
  user_id UUID NOT NULL REFERENCES user_svc.users(id) ON DELETE CASCADE,
15
+ session_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000000',
16
+ user_agent TEXT NOT NULL DEFAULT '',
15
17
  expires_at TIMESTAMPTZ NOT NULL,
16
18
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
17
19
  );
@@ -260,6 +260,15 @@ boundary. For browser OAuth, preserve this client-owned callback contract:
260
260
  `Pragma: no-cache`.
261
261
  Never accept a request-supplied redirect destination or place an access token,
262
262
  refresh token, authorization code, or state in a URI.
263
+ - Authenticated identity management is a separate account flow, not another
264
+ login endpoint: list safe metadata at `GET /users/me/identities`, start and
265
+ finish linking at `POST /users/me/identities/:provider/link` and
266
+ `POST /users/me/identities/:provider/link/exchange`, and unlink with
267
+ `DELETE /users/me/identities/:provider`. Bind the server-side transaction to
268
+ the caller's user ID, never issue or replace a session during link exchange,
269
+ keep one identity per provider per user, and reject unlinking the last login
270
+ method. Provider subjects and password hashes must never cross the HTTP
271
+ boundary.
263
272
  - Map provider and validation failures to controlled public codes only:
264
273
  `oauth_denied`, `oauth_state_invalid`, `oauth_provider_unavailable`, and
265
274
  `oauth_failed`. Do not expose raw provider descriptions or technical causes.