@nakedev/go-scaffold 0.5.0 → 0.5.2

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 (37) hide show
  1. package/dist/commands/auth.js +5 -1
  2. package/dist/index.js +1 -1
  3. package/dist/templates/auth-manifest.js +1 -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 +19 -0
  7. package/templates/add/auth/docs/users-me-session.yaml.hbs +14 -0
  8. package/templates/add/auth/docs/users-me-sessions.yaml.hbs +13 -0
  9. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +24 -0
  10. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +2 -0
  11. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +6 -2
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +9 -0
  13. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +3 -1
  14. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +14 -0
  15. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +30 -0
  16. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +3 -3
  17. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +6 -1
  18. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +42 -5
  19. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +36 -0
  20. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +75 -0
  21. package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +36 -0
  22. package/templates/add/auth/internal/app/user/application/dto.go.hbs +18 -0
  23. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +1 -1
  24. package/templates/add/auth/internal/app/user/application/jwt.go.hbs +5 -2
  25. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +2 -2
  26. package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +13 -4
  27. package/templates/add/auth/internal/app/user/application/service.go.hbs +2 -0
  28. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +32 -1
  29. package/templates/add/auth/internal/app/user/application/sessions.go.hbs +62 -8
  30. package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +1 -0
  31. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +16 -0
  32. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +9 -3
  33. package/templates/add/auth/internal/shared/middleware/auth_test.go.hbs +48 -0
  34. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +5 -1
  35. package/templates/add/auth/migrations/create_mfa.up.sql.hbs +2 -0
  36. package/templates/create/base/AGENTS.md.hbs +6 -0
  37. package/templates/create/base/README.md.hbs +4 -0
@@ -36,6 +36,8 @@ const AUTH_OPENAPI_PATHS = [
36
36
  { urlPath: "/users/me", file: "./auth/users-me.yaml" },
37
37
  { urlPath: "/users/me/resend-verification", file: "./auth/users-me-resend-verification.yaml" },
38
38
  { urlPath: "/users/me/logout-all", file: "./auth/users-me-logout-all.yaml" },
39
+ { urlPath: "/users/me/sessions", file: "./auth/users-me-sessions.yaml" },
40
+ { urlPath: "/users/me/sessions/{id}", file: "./auth/users-me-session.yaml" },
39
41
  { urlPath: "/users/me/mfa", file: "./auth/users-me-mfa.yaml" },
40
42
  { urlPath: "/users/me/mfa/setup", file: "./auth/users-me-mfa-setup.yaml" },
41
43
  { urlPath: "/users/me/mfa/confirm", file: "./auth/users-me-mfa-confirm.yaml" },
@@ -44,7 +46,8 @@ const AUTH_OPENAPI_PATHS = [
44
46
  ];
45
47
  // addAuth scaffolds email/password authentication: a users+identities model
46
48
  // pair, JWT access tokens, a selectable refresh token store with
47
- // rotation + reuse detection, and register/login/refresh/logout/me. No RBAC
49
+ // rotation + reuse detection, register/login/refresh/logout/me, and
50
+ // per-user session listing/revocation. No RBAC
48
51
  // (no roles/permissions) — that's a separate opt-in on top of this, since
49
52
  // most projects need "is this caller logged in" long before they need "can
50
53
  // this caller do X".
@@ -178,6 +181,7 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
178
181
  console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
179
182
  "GET /auth/{provider}/login, POST /auth/{provider}/exchange, GET /users/me, and " +
180
183
  "POST /users/me/{resend-verification,logout-all,mfa/setup,mfa/confirm,mfa/disable}, " +
184
+ "GET /users/me/sessions, DELETE /users/me/sessions/{id}, " +
181
185
  "GET /users/me/mfa, and POST /auth/mfa/verify in cmd/api/wiring.go" +
182
186
  docsMessage);
183
187
  console.log(picocolors_1.default.dim("\nnext: go mod tidy, then apply the new migrations with `make migrate-up` before production\n" +
package/dist/index.js CHANGED
@@ -494,7 +494,7 @@ async function resolveQueueBackend(opts) {
494
494
  }
495
495
  add
496
496
  .command("auth")
497
- .description("add email/password auth: JWT access tokens, refresh token rotation, register/login/refresh/logout/me (no prerequisites — without `add worker` the verification/reset mail is sent inline)")
497
+ .description("add email/password auth: JWT access tokens, refresh rotation, device-session listing/revocation, register/login/refresh/logout/me (no prerequisites — without `add worker` the verification/reset mail is sent inline)")
498
498
  .option("--store <store>", 'where tokens and rate-limit counters live: "postgres" (default, no extra service) or "redis" (exact across replicas)')
499
499
  .option("--browser-topology <topology>", "browser deployment topology for cookie/CORS policy: same-origin, same-site (different origin), or cross-site (requires HTTPS deployment)")
500
500
  .option("--defaults", "skip store, browser-topology, and confirmation prompts; use Postgres plus local same-site defaults")
@@ -7,6 +7,7 @@ exports.authFiles = authFiles;
7
7
  // must create one canonical implementation tree.
8
8
  const SHARED = [
9
9
  { template: "add/auth/internal/shared/middleware/auth.go.hbs", output: "internal/shared/middleware/auth.go" },
10
+ { template: "add/auth/internal/shared/middleware/auth_test.go.hbs", output: "internal/shared/middleware/auth_test.go" },
10
11
  { template: "add/auth/internal/shared/middleware/ratelimit.go.hbs", output: "internal/shared/middleware/ratelimit.go" },
11
12
  { template: "add/auth/internal/app/user/domain/entity.go.hbs", output: "internal/app/user/domain/entity.go" },
12
13
  { template: "add/auth/internal/app/user/domain/errors.go.hbs", output: "internal/app/user/domain/errors.go" },
@@ -61,7 +61,7 @@ function patchUserModelForRbac(userModelPath) {
61
61
  function patchMiddlewareAuthForRbac(middlewareAuthPath) {
62
62
  let content = fs_extra_1.default.readFileSync(middlewareAuthPath, "utf8");
63
63
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-keys", 'const RoleKey = "role"', 'RoleKey = "role"');
64
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-claims", 'Role string `json:"role,omitempty"`', "Role string");
64
+ content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:middleware-auth-claims", 'Role string `json:"role,omitempty"`', /^\s*Role\s+string\s+`json:"role,omitempty"`/m);
65
65
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:middleware-auth-context", "c.Set(RoleKey, claims.Role)", "c.Set(RoleKey");
66
66
  fs_extra_1.default.writeFileSync(middlewareAuthPath, content);
67
67
  }
@@ -72,11 +72,19 @@ function patchMiddlewareAuthForRbac(middlewareAuthPath) {
72
72
  // specifically so a marker patch can extend either without reformatting.
73
73
  function patchUserJWTForRbac(jwtGoPath) {
74
74
  let content = fs_extra_1.default.readFileSync(jwtGoPath, "utf8");
75
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims", 'Role string `json:"role,omitempty"`', "Role string");
76
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:issue-access-token-params", "role string,", "role string,");
77
- content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, "// go-scaffold:jwt-claims-values", "Role: role,", "Role: role,");
75
+ content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:jwt-claims", 'Role string `json:"role,omitempty"`', /^\s*Role\s+string\s+`json:"role,omitempty"`/m);
76
+ content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:issue-access-token-params", "role string,", /^\s*role\s+string\s*,/m);
77
+ content = insertGoLineBeforeMarkerOnce(content, "// go-scaffold:jwt-claims-values", "Role: role,", /^\s*Role\s*:\s*role\s*,/m);
78
78
  fs_extra_1.default.writeFileSync(jwtGoPath, content);
79
79
  }
80
+ // Go fmt aligns struct fields and can turn a literal sentinel such as
81
+ // `Role string` into `Role string`. Match the Go line semantically so a
82
+ // subsequent `add rbac` cannot append a duplicate field after formatting.
83
+ function insertGoLineBeforeMarkerOnce(content, marker, block, line) {
84
+ if (line.test(content))
85
+ return content;
86
+ return (0, marker_patch_1.insertBeforeMarker)(content, marker, block);
87
+ }
80
88
  // patchUserServiceForRbac verifies the auth application already exposes the
81
89
  // optional role capability. The canonical auth template owns this shared user
82
90
  // behavior; `add rbac` only supplies the role catalog and authorizer.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -114,3 +114,22 @@ MeResponse:
114
114
  email_verified: { type: boolean }
115
115
  # go-scaffold:me-response-fields
116
116
  created_at: { type: string, format: date-time }
117
+
118
+ SessionResponse:
119
+ type: object
120
+ required: [id, user_agent, created_at, last_used_at, expires_at, current]
121
+ properties:
122
+ id: { type: string, format: uuid }
123
+ user_agent: { type: string, description: browser or client User-Agent captured at sign-in }
124
+ created_at: { type: string, format: date-time }
125
+ last_used_at: { type: string, format: date-time }
126
+ expires_at: { type: string, format: date-time }
127
+ current: { type: boolean, description: true when this session issued the current access token }
128
+
129
+ SessionsResponse:
130
+ type: object
131
+ required: [sessions]
132
+ properties:
133
+ sessions:
134
+ type: array
135
+ items: { $ref: '#/SessionResponse' }
@@ -0,0 +1,14 @@
1
+ delete:
2
+ summary: Revoke one session for the current user
3
+ operationId: revokeMySession
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ parameters:
7
+ - name: id
8
+ in: path
9
+ required: true
10
+ schema: { type: string, format: uuid }
11
+ responses:
12
+ "204": { description: session revoked }
13
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
14
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,13 @@
1
+ get:
2
+ summary: List active sessions for the current user
3
+ description: Returns active refresh-token sessions with safe device metadata. Raw refresh tokens and token hashes are never returned.
4
+ operationId: listMySessions
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ responses:
8
+ "200":
9
+ description: active sessions
10
+ content:
11
+ application/json:
12
+ schema: { $ref: './schemas.yaml#/SessionsResponse' }
13
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -52,6 +52,10 @@ func toLoginExchangeInput(in loginExchangeInput) userapp.LoginExchangeInput {
52
52
  return userapp.LoginExchangeInput{Code: in.Code, State: in.State, CodeVerifier: in.CodeVerifier}
53
53
  }
54
54
 
55
+ func sessionContext(userAgent string) userapp.SessionContext {
56
+ return userapp.SessionContext{UserAgent: userAgent}
57
+ }
58
+
55
59
  type forgotPasswordInput struct {
56
60
  Email string `json:"email" binding:"required,email"`
57
61
  }
@@ -99,6 +103,26 @@ type mfaConfirmResponse struct {
99
103
  RecoveryCodes []string `json:"recovery_codes"`
100
104
  }
101
105
 
106
+ type sessionResponse struct {
107
+ ID uuid.UUID `json:"id"`
108
+ UserAgent string `json:"user_agent"`
109
+ CreatedAt time.Time `json:"created_at"`
110
+ LastUsedAt time.Time `json:"last_used_at"`
111
+ ExpiresAt time.Time `json:"expires_at"`
112
+ Current bool `json:"current"`
113
+ }
114
+
115
+ type sessionsResponse struct {
116
+ Sessions []sessionResponse `json:"sessions"`
117
+ }
118
+
119
+ func toSessionResponse(session userapp.Session) sessionResponse {
120
+ return sessionResponse{
121
+ ID: session.ID, UserAgent: session.UserAgent, CreatedAt: session.CreatedAt,
122
+ LastUsedAt: session.LastUsedAt, ExpiresAt: session.ExpiresAt, Current: session.Current,
123
+ }
124
+ }
125
+
102
126
  func toCookieResponse(auth *userapp.AuthResponse) authCookieResponse {
103
127
  return authCookieResponse{AccessToken: auth.AccessToken, TokenType: auth.TokenType, ExpiresIn: auth.ExpiresIn}
104
128
  }
@@ -132,6 +132,8 @@ func (h *Handler) Register(rg gin.IRouter) {
132
132
  usersGroup.GET("/me", h.me)
133
133
  usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
134
134
  usersGroup.POST("/me/logout-all", h.logoutAll)
135
+ usersGroup.GET("/me/sessions", h.sessions)
136
+ usersGroup.DELETE("/me/sessions/:id", h.revokeSession)
135
137
  usersGroup.GET("/me/mfa", h.mfaStatus)
136
138
  usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
137
139
  usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
@@ -19,7 +19,9 @@ func (h *Handler) register(c *gin.Context) {
19
19
  c.Error(httpx.BindErr(err))
20
20
  return
21
21
  }
22
- auth, err := h.svc.Register(c.Request.Context(), toRegisterInput(in))
22
+ input := toRegisterInput(in)
23
+ input.Session = sessionContext(c.GetHeader("User-Agent"))
24
+ auth, err := h.svc.Register(c.Request.Context(), input)
23
25
  if err != nil {
24
26
  c.Error(toHTTPError(err))
25
27
  return
@@ -37,7 +39,9 @@ func (h *Handler) login(c *gin.Context) {
37
39
  c.Error(httpx.BindErr(err))
38
40
  return
39
41
  }
40
- auth, err := h.svc.Login(c.Request.Context(), toLoginInput(in))
42
+ input := toLoginInput(in)
43
+ input.Session = sessionContext(c.GetHeader("User-Agent"))
44
+ auth, err := h.svc.Login(c.Request.Context(), input)
41
45
  if err != nil {
42
46
  c.Error(toHTTPError(err))
43
47
  return
@@ -81,3 +81,12 @@ func (h *Handler) disableMFA(c *gin.Context) {
81
81
  func currentUserID(c *gin.Context) uuid.UUID {
82
82
  return c.MustGet(middleware.UserIDKey).(uuid.UUID)
83
83
  }
84
+
85
+ func currentSessionID(c *gin.Context) uuid.UUID {
86
+ if value, ok := c.Get(middleware.SessionIDKey); ok {
87
+ if sessionID, ok := value.(uuid.UUID); ok {
88
+ return sessionID
89
+ }
90
+ }
91
+ return uuid.Nil
92
+ }
@@ -34,7 +34,9 @@ func (h *Handler) providerExchange(c *gin.Context) {
34
34
  c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
35
35
  return
36
36
  }
37
- auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), toLoginExchangeInput(in))
37
+ input := toLoginExchangeInput(in)
38
+ input.Session = sessionContext(c.GetHeader("User-Agent"))
39
+ auth, err := h.svc.ExchangeLogin(c.Request.Context(), c.Param("provider"), input)
38
40
  if err != nil {
39
41
  c.Error(h.oauthAppError(c.Param("provider"), err))
40
42
  return
@@ -151,6 +151,20 @@ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
151
151
  }
152
152
  }
153
153
 
154
+ func TestHandler_SessionRoutesAreProtected(t *testing.T) {
155
+ router := newOAuthTestRouter(t)
156
+ for _, route := range []struct{ method, path string }{
157
+ {http.MethodGet, "/users/me/sessions"},
158
+ {http.MethodDelete, "/users/me/sessions/00000000-0000-0000-0000-000000000001"},
159
+ } {
160
+ response := httptest.NewRecorder()
161
+ router.ServeHTTP(response, httptest.NewRequest(route.method, route.path, nil))
162
+ if response.Code != http.StatusUnauthorized {
163
+ t.Fatalf("%s %s status = %d, want 401", route.method, route.path, response.Code)
164
+ }
165
+ }
166
+ }
167
+
154
168
  func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
155
169
  router := newOAuthTestRouter(t)
156
170
  callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
@@ -3,6 +3,7 @@ package httpadapter
3
3
  import (
4
4
  "net/http"
5
5
 
6
+ "{{goModule}}/internal/shared/httpx"
6
7
  "{{goModule}}/internal/shared/middleware"
7
8
 
8
9
  "github.com/gin-gonic/gin"
@@ -30,6 +31,35 @@ func (h *Handler) logoutAll(c *gin.Context) {
30
31
  c.Status(http.StatusNoContent)
31
32
  }
32
33
 
34
+ func (h *Handler) sessions(c *gin.Context) {
35
+ setNoStoreHeaders(c)
36
+ items, err := h.svc.ListSessions(c.Request.Context(), currentUserID(c), currentSessionID(c))
37
+ if err != nil {
38
+ c.Error(toHTTPError(err))
39
+ return
40
+ }
41
+ out := make([]sessionResponse, len(items))
42
+ for i := range items {
43
+ out[i] = toSessionResponse(items[i])
44
+ }
45
+ c.JSON(http.StatusOK, sessionsResponse{Sessions: out})
46
+ }
47
+
48
+ func (h *Handler) revokeSession(c *gin.Context) {
49
+ sessionID, ok := httpx.ParseID(c)
50
+ if !ok {
51
+ return
52
+ }
53
+ if err := h.svc.RevokeSession(c.Request.Context(), currentUserID(c), sessionID); err != nil {
54
+ c.Error(toHTTPError(err))
55
+ return
56
+ }
57
+ if sessionID == currentSessionID(c) {
58
+ h.clearRefreshCookie(c)
59
+ }
60
+ c.Status(http.StatusNoContent)
61
+ }
62
+
33
63
  func (h *Handler) me(c *gin.Context) {
34
64
  userID := c.MustGet(middleware.UserIDKey).(uuid.UUID)
35
65
  u, err := h.svc.Get(c.Request.Context(), userID)
@@ -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
  }
@@ -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
  }
@@ -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,35 @@ 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
26
44
  }
27
45
 
28
46
  type AuthResponse struct {
@@ -86,7 +86,7 @@ func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in Log
86
86
  if err != nil {
87
87
  return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
88
88
  }
89
- return s.completeLogin(ctx, u)
89
+ return s.completeLogin(ctx, u, in.Session)
90
90
  }
91
91
 
92
92
  func validPKCEVerifier(verifier, challenge string) bool {
@@ -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) {
@@ -124,6 +124,8 @@ type ServicePort interface {
124
124
  Refresh(context.Context, string) (*AuthResponse, error)
125
125
  Logout(context.Context, string) error
126
126
  LogoutAll(context.Context, uuid.UUID) error
127
+ ListSessions(context.Context, uuid.UUID, uuid.UUID) ([]Session, error)
128
+ RevokeSession(context.Context, uuid.UUID, uuid.UUID) error
127
129
  ForgotPassword(context.Context, string) error
128
130
  ResetPassword(context.Context, string, string) error
129
131
  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()
@@ -552,8 +578,10 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
552
578
  raw := "bounded-refresh-token"
553
579
  issuedAt := time.Now()
554
580
  absoluteExpiry := issuedAt.Add(2 * time.Hour)
581
+ sessionID := uuid.New()
555
582
  if err := tokens.SetRefreshToken(ctx, hashToken(raw), RefreshTokenRecord{
556
- UserID: userID,
583
+ UserID: userID, SessionID: sessionID, UserAgent: "test-device",
584
+ CreatedAt: issuedAt.Add(-time.Hour), LastUsedAt: issuedAt.Add(-time.Minute),
557
585
  ExpiresAt: issuedAt.Add(time.Hour),
558
586
  AbsoluteExpiresAt: absoluteExpiry,
559
587
  }); err != nil {
@@ -576,6 +604,9 @@ func TestService_Refresh_DoesNotExtendTheAbsoluteLifetime(t *testing.T) {
576
604
  if !rotated.ExpiresAt.Equal(absoluteExpiry) {
577
605
  t.Fatalf("rotated inactivity expiry was not capped at the absolute expiry: got %s want %s", rotated.ExpiresAt, absoluteExpiry)
578
606
  }
607
+ if rotated.SessionID != sessionID || rotated.UserAgent != "test-device" {
608
+ t.Fatalf("refresh rotation changed session identity/metadata: got id=%s ua=%q", rotated.SessionID, rotated.UserAgent)
609
+ }
579
610
  }
580
611
 
581
612
  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
@@ -36,16 +36,30 @@ type RoleChecker interface {
36
36
 
37
37
  type RefreshTokenRecord struct {
38
38
  UserID uuid.UUID `json:"user_id"`
39
+ SessionID uuid.UUID `json:"session_id"`
40
+ UserAgent string `json:"user_agent"`
41
+ CreatedAt time.Time `json:"created_at"`
42
+ LastUsedAt time.Time `json:"last_used_at"`
39
43
  ExpiresAt time.Time `json:"expires_at"`
40
44
  AbsoluteExpiresAt time.Time `json:"absolute_expires_at"`
41
45
  }
42
46
 
47
+ type RefreshSession struct {
48
+ ID uuid.UUID
49
+ UserAgent string
50
+ CreatedAt time.Time
51
+ LastUsedAt time.Time
52
+ ExpiresAt time.Time
53
+ }
54
+
43
55
  type RefreshTokenStore interface {
44
56
  SetRefreshToken(context.Context, string, RefreshTokenRecord) error
45
57
  GetRefreshToken(context.Context, string) (uuid.UUID, bool, error)
46
58
  ConsumeRefreshToken(context.Context, string) (RefreshTokenRecord, bool, error)
47
59
  DeleteRefreshToken(context.Context, string, uuid.UUID) error
48
60
  RevokeAllRefreshTokens(context.Context, uuid.UUID) error
61
+ ListRefreshSessions(context.Context, uuid.UUID) ([]RefreshSession, error)
62
+ RevokeRefreshSession(context.Context, uuid.UUID, uuid.UUID) error
49
63
  IsRefreshTokenUsed(context.Context, string) (uuid.UUID, bool, error)
50
64
  }
51
65
 
@@ -76,6 +90,8 @@ type MFAEnrollment struct {
76
90
 
77
91
  type MFAChallenge struct {
78
92
  UserID uuid.UUID
93
+ SessionID uuid.UUID
94
+ UserAgent string
79
95
  ExpiresAt time.Time
80
96
  }
81
97
 
@@ -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
  );
@@ -180,6 +180,12 @@ approval before changing their contract.
180
180
  tombstone. Its inactivity expiry may move on rotation, but its persisted
181
181
  absolute expiry never moves. Never replace it with a read-then-delete
182
182
  sequence.
183
+ - Authenticated users can inspect active refresh sessions through
184
+ `GET /users/me/sessions` and revoke one with
185
+ `DELETE /users/me/sessions/:id`. Session IDs remain stable through refresh
186
+ rotation; responses expose only User-Agent and lifecycle timestamps, never
187
+ refresh tokens or token hashes. The current access token carries the session
188
+ ID as `sid` so the adapter can mark the current device.
183
189
  - Password reset and email verification consume a one-time token in the same
184
190
  retry-safe transaction as the user/identity update. A failed post-commit
185
191
  session revocation must not make a successful reset impossible to retry.
@@ -194,6 +194,10 @@ nonce; the frontend still owns the callback route and sends the code to the
194
194
  exchange endpoint. The provider redirect URI is separate from
195
195
  `CORS_ALLOWED_ORIGINS`; the latter remains an exact-origin credentialed API
196
196
  policy. Token responses use `Cache-Control: no-store` and `Pragma: no-cache`.
197
+ Authenticated users can list active device sessions with
198
+ `GET /users/me/sessions` and revoke an individual session with
199
+ `DELETE /users/me/sessions/:id`; only User-Agent and session lifecycle
200
+ timestamps are returned.
197
201
  For `cross-site`, configure HTTPS origins, `COOKIE_SAMESITE=none`,
198
202
  `COOKIE_SECURE=true`, and an exact allowed Origin; the API applies an Origin
199
203
  guard independently of CORS.