@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
@@ -34,8 +34,14 @@ const AUTH_OPENAPI_PATHS = [
34
34
  { urlPath: "/auth/{provider}/login", file: "./auth/provider-login.yaml" },
35
35
  { urlPath: "/auth/{provider}/exchange", file: "./auth/provider-exchange.yaml" },
36
36
  { urlPath: "/users/me", file: "./auth/users-me.yaml" },
37
+ { urlPath: "/users/me/identities", file: "./auth/users-me-identities.yaml" },
38
+ { urlPath: "/users/me/identities/{provider}/link", file: "./auth/users-me-identity-link.yaml" },
39
+ { urlPath: "/users/me/identities/{provider}/link/exchange", file: "./auth/users-me-identity-link-exchange.yaml" },
40
+ { urlPath: "/users/me/identities/{provider}", file: "./auth/users-me-identity.yaml" },
37
41
  { urlPath: "/users/me/resend-verification", file: "./auth/users-me-resend-verification.yaml" },
38
42
  { urlPath: "/users/me/logout-all", file: "./auth/users-me-logout-all.yaml" },
43
+ { urlPath: "/users/me/sessions", file: "./auth/users-me-sessions.yaml" },
44
+ { urlPath: "/users/me/sessions/{id}", file: "./auth/users-me-session.yaml" },
39
45
  { urlPath: "/users/me/mfa", file: "./auth/users-me-mfa.yaml" },
40
46
  { urlPath: "/users/me/mfa/setup", file: "./auth/users-me-mfa-setup.yaml" },
41
47
  { urlPath: "/users/me/mfa/confirm", file: "./auth/users-me-mfa-confirm.yaml" },
@@ -44,7 +50,8 @@ const AUTH_OPENAPI_PATHS = [
44
50
  ];
45
51
  // addAuth scaffolds email/password authentication: a users+identities model
46
52
  // pair, JWT access tokens, a selectable refresh token store with
47
- // rotation + reuse detection, and register/login/refresh/logout/me. No RBAC
53
+ // rotation + reuse detection, register/login/refresh/logout/me, and
54
+ // per-user session listing/revocation. No RBAC
48
55
  // (no roles/permissions) — that's a separate opt-in on top of this, since
49
56
  // most projects need "is this caller logged in" long before they need "can
50
57
  // this caller do X".
@@ -178,7 +185,9 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
178
185
  console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
179
186
  "GET /auth/{provider}/login, POST /auth/{provider}/exchange, GET /users/me, and " +
180
187
  "POST /users/me/{resend-verification,logout-all,mfa/setup,mfa/confirm,mfa/disable}, " +
181
- "GET /users/me/mfa, and POST /auth/mfa/verify in cmd/api/wiring.go" +
188
+ "GET /users/me/{identities,sessions,mfa}, POST /users/me/identities/{provider}/{link,link/exchange}, " +
189
+ "DELETE /users/me/identities/{provider}, DELETE /users/me/sessions/{id}, " +
190
+ "POST /auth/mfa/verify in cmd/api/wiring.go" +
182
191
  docsMessage);
183
192
  console.log(picocolors_1.default.dim("\nnext: go mod tidy, then apply the new migrations with `make migrate-up` before production\n" +
184
193
  "seed an admin: SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed"));
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" },
@@ -16,12 +17,14 @@ const SHARED = [
16
17
  { template: "add/auth/internal/app/user/application/dto.go.hbs", output: "internal/app/user/application/dto.go" },
17
18
  { template: "add/auth/internal/app/user/application/errors.go.hbs", output: "internal/app/user/application/errors.go" },
18
19
  { template: "add/auth/internal/app/user/application/external_login.go.hbs", output: "internal/app/user/application/external_login.go" },
20
+ { template: "add/auth/internal/app/user/application/identities.go.hbs", output: "internal/app/user/application/identities.go" },
19
21
  { template: "add/auth/internal/app/user/application/jwt.go.hbs", output: "internal/app/user/application/jwt.go" },
20
22
  { template: "add/auth/internal/app/user/application/local_auth.go.hbs", output: "internal/app/user/application/local_auth.go" },
21
23
  { template: "add/auth/internal/app/user/application/mfa_service.go.hbs", output: "internal/app/user/application/mfa_service.go" },
22
24
  { template: "add/auth/internal/app/user/application/mfa_service_test.go.hbs", output: "internal/app/user/application/mfa_service_test.go" },
23
25
  { template: "add/auth/internal/app/user/application/oauth.go.hbs", output: "internal/app/user/application/oauth.go" },
24
26
  { template: "add/auth/internal/app/user/application/provider_test.go.hbs", output: "internal/app/user/application/provider_test.go" },
27
+ { template: "add/auth/internal/app/user/application/identities_test.go.hbs", output: "internal/app/user/application/identities_test.go" },
25
28
  { template: "add/auth/internal/app/user/application/recovery.go.hbs", output: "internal/app/user/application/recovery.go" },
26
29
  { template: "add/auth/internal/app/user/application/recovery_service.go.hbs", output: "internal/app/user/application/recovery_service.go" },
27
30
  { template: "add/auth/internal/app/user/application/service.go.hbs", output: "internal/app/user/application/service.go" },
@@ -38,6 +41,7 @@ const SHARED = [
38
41
  { template: "add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs", output: "internal/app/user/adapters/inbound/http/handler_recovery.go" },
39
42
  { template: "add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs", output: "internal/app/user/adapters/inbound/http/handler_test.go" },
40
43
  { template: "add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs", output: "internal/app/user/adapters/inbound/http/handler_user.go" },
44
+ { template: "add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs", output: "internal/app/user/adapters/inbound/http/handler_identity.go" },
41
45
  { template: "add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs", output: "internal/app/user/adapters/inbound/http/session_cookie.go" },
42
46
  { template: "add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs", output: "internal/app/user/adapters/outbound/password/bcrypt.go" },
43
47
  { template: "add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs", output: "internal/app/user/adapters/outbound/password/bcrypt_test.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.1",
3
+ "version": "0.5.3",
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,57 @@ 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' }
136
+
137
+ IdentityResponse:
138
+ type: object
139
+ required: [id, provider, created_at]
140
+ properties:
141
+ id: { type: string, format: uuid }
142
+ provider: { type: string, example: google }
143
+ created_at: { type: string, format: date-time }
144
+
145
+ IdentitiesResponse:
146
+ type: object
147
+ required: [identities]
148
+ properties:
149
+ identities:
150
+ type: array
151
+ items: { $ref: '#/IdentityResponse' }
152
+
153
+ IdentityLinkStartInput:
154
+ type: object
155
+ required: [state, code_challenge, code_challenge_method]
156
+ additionalProperties: false
157
+ properties:
158
+ state: { type: string, minLength: 1 }
159
+ code_challenge:
160
+ type: string
161
+ minLength: 43
162
+ maxLength: 128
163
+ pattern: '^[A-Za-z0-9._~-]+$'
164
+ code_challenge_method: { type: string, enum: [S256] }
165
+
166
+ IdentityLinkStartResponse:
167
+ type: object
168
+ required: [authorization_url]
169
+ properties:
170
+ authorization_url: { type: string, format: uri }
@@ -0,0 +1,13 @@
1
+ get:
2
+ summary: List login identities for the current user
3
+ description: Returns safe provider metadata only; provider subjects and password hashes are never exposed.
4
+ operationId: listMyIdentities
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ responses:
8
+ "200":
9
+ description: linked login identities
10
+ content:
11
+ application/json:
12
+ schema: { $ref: './schemas.yaml#/IdentitiesResponse' }
13
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
@@ -0,0 +1,26 @@
1
+ post:
2
+ summary: Complete linking a provider identity to the current user
3
+ description: Consumes the one-time OAuth transaction and links the provider subject to the caller. It never creates or switches a session.
4
+ operationId: exchangeIdentityLink
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ parameters:
8
+ - name: provider
9
+ in: path
10
+ required: true
11
+ schema: { type: string, example: google }
12
+ requestBody:
13
+ required: true
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/OAuthExchangeInput' }
17
+ responses:
18
+ "200":
19
+ description: linked identity
20
+ content:
21
+ application/json:
22
+ schema: { $ref: './schemas.yaml#/IdentityResponse' }
23
+ "400": { description: invalid OAuth state or provider response }
24
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
25
+ "409": { description: provider identity is already linked or owned by another user }
26
+ "503": { description: provider unavailable }
@@ -0,0 +1,25 @@
1
+ post:
2
+ summary: Start linking a provider identity to the current user
3
+ description: Creates a one-time OAuth transaction bound to the authenticated user and returns the provider authorization URL.
4
+ operationId: startIdentityLink
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ parameters:
8
+ - name: provider
9
+ in: path
10
+ required: true
11
+ schema: { type: string, example: google }
12
+ requestBody:
13
+ required: true
14
+ content:
15
+ application/json:
16
+ schema: { $ref: './schemas.yaml#/IdentityLinkStartInput' }
17
+ responses:
18
+ "200":
19
+ description: provider authorization URL
20
+ content:
21
+ application/json:
22
+ schema: { $ref: './schemas.yaml#/IdentityLinkStartResponse' }
23
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
24
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
25
+ "503": { description: provider unavailable }
@@ -0,0 +1,15 @@
1
+ delete:
2
+ summary: Unlink a login identity from the current user
3
+ operationId: unlinkMyIdentity
4
+ tags: [users]
5
+ security: [{ bearerAuth: [] }]
6
+ parameters:
7
+ - name: provider
8
+ in: path
9
+ required: true
10
+ schema: { type: string, example: google }
11
+ responses:
12
+ "204": { description: login identity unlinked }
13
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
14
+ "404": { description: login identity not found }
15
+ "409": { description: cannot remove the last login identity }
@@ -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,64 @@ 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
+ type identityLinkStartInput struct {
120
+ State string `json:"state" binding:"required"`
121
+ CodeChallenge string `json:"code_challenge" binding:"required"`
122
+ CodeChallengeMethod string `json:"code_challenge_method" binding:"required"`
123
+ }
124
+
125
+ type identityLinkExchangeInput struct {
126
+ Code string `json:"code" binding:"required"`
127
+ State string `json:"state" binding:"required"`
128
+ CodeVerifier string `json:"code_verifier" binding:"required"`
129
+ }
130
+
131
+ type identityLinkStartResponse struct {
132
+ AuthorizationURL string `json:"authorization_url"`
133
+ }
134
+
135
+ type identityResponse struct {
136
+ ID uuid.UUID `json:"id"`
137
+ Provider string `json:"provider"`
138
+ CreatedAt time.Time `json:"created_at"`
139
+ }
140
+
141
+ type identitiesResponse struct {
142
+ Identities []identityResponse `json:"identities"`
143
+ }
144
+
145
+ func toSessionResponse(session userapp.Session) sessionResponse {
146
+ return sessionResponse{
147
+ ID: session.ID, UserAgent: session.UserAgent, CreatedAt: session.CreatedAt,
148
+ LastUsedAt: session.LastUsedAt, ExpiresAt: session.ExpiresAt, Current: session.Current,
149
+ }
150
+ }
151
+
152
+ func toIdentityLinkStartInput(in identityLinkStartInput) userapp.LoginStartInput {
153
+ return userapp.LoginStartInput{State: in.State, CodeChallenge: in.CodeChallenge, CodeChallengeMethod: in.CodeChallengeMethod}
154
+ }
155
+
156
+ func toIdentityLinkExchangeInput(in identityLinkExchangeInput) userapp.LoginExchangeInput {
157
+ return userapp.LoginExchangeInput{Code: in.Code, State: in.State, CodeVerifier: in.CodeVerifier}
158
+ }
159
+
160
+ func toIdentityResponse(identity userapp.IdentityResponse) identityResponse {
161
+ return identityResponse{ID: identity.ID, Provider: identity.Provider, CreatedAt: identity.CreatedAt}
162
+ }
163
+
102
164
  func toCookieResponse(auth *userapp.AuthResponse) authCookieResponse {
103
165
  return authCookieResponse{AccessToken: auth.AccessToken, TokenType: auth.TokenType, ExpiresIn: auth.ExpiresIn}
104
166
  }
@@ -115,6 +115,7 @@ func (h *Handler) Register(rg gin.IRouter) {
115
115
  mfaSetupLimit := middleware.RateLimit(h.limiter, "mfa-setup", 5, time.Minute)
116
116
  mfaConfirmLimit := middleware.RateLimit(h.limiter, "mfa-confirm", 5, time.Minute)
117
117
  mfaDisableLimit := middleware.RateLimit(h.limiter, "mfa-disable", 5, time.Minute)
118
+ identityLinkLimit := middleware.RateLimit(h.limiter, "identity-link", 10, time.Minute)
118
119
 
119
120
  authGroup := rg.Group("/auth")
120
121
  authGroup.POST("/register", registerLimit, h.register)
@@ -132,10 +133,13 @@ func (h *Handler) Register(rg gin.IRouter) {
132
133
  usersGroup.GET("/me", h.me)
133
134
  usersGroup.POST("/me/resend-verification", resendVerificationLimit, h.resendVerification)
134
135
  usersGroup.POST("/me/logout-all", h.logoutAll)
136
+ usersGroup.GET("/me/sessions", h.sessions)
137
+ usersGroup.DELETE("/me/sessions/:id", h.revokeSession)
135
138
  usersGroup.GET("/me/mfa", h.mfaStatus)
136
139
  usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
137
140
  usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
138
141
  usersGroup.POST("/me/mfa/disable", mfaDisableLimit, h.disableMFA)
142
+ h.registerIdentityRoutes(usersGroup, identityLinkLimit)
139
143
  // Admin user routes are enabled only when the optional RBAC authorizer is
140
144
  // supplied by the composition root. Auth-only projects keep the endpoint
141
145
  // surface limited to authentication and the current-user operations.
@@ -205,8 +209,10 @@ func toHTTPError(err error) error {
205
209
  switch ruleErr.Code {
206
210
  case "USER_NOT_FOUND":
207
211
  status = http.StatusNotFound
208
- case "USER_EMAIL_TAKEN", "AUTH_ALREADY_VERIFIED", "AUTH_MFA_ALREADY_ENABLED", "AUTH_MFA_NOT_ENROLLED", "AUTH_MFA_SETUP_REQUIRED":
212
+ case "USER_EMAIL_TAKEN", "AUTH_ALREADY_VERIFIED", "AUTH_MFA_ALREADY_ENABLED", "AUTH_MFA_NOT_ENROLLED", "AUTH_MFA_SETUP_REQUIRED", "AUTH_IDENTITY_ALREADY_LINKED", "AUTH_IDENTITY_CONFLICT", "AUTH_LAST_IDENTITY":
209
213
  status = http.StatusConflict
214
+ case "AUTH_IDENTITY_NOT_FOUND":
215
+ status = http.StatusNotFound
210
216
  case "AUTH_INVALID_CREDENTIALS", "AUTH_INVALID_TOKEN", "AUTH_MFA_INVALID":
211
217
  status = http.StatusUnauthorized
212
218
  case "AUTH_MFA_UNAVAILABLE":
@@ -0,0 +1,80 @@
1
+ package httpadapter
2
+
3
+ import (
4
+ "errors"
5
+ "net/http"
6
+
7
+ userapp "{{goModule}}/internal/app/user/application"
8
+ "{{goModule}}/internal/shared/httpx"
9
+
10
+ "github.com/gin-gonic/gin"
11
+ )
12
+
13
+ // registerIdentityRoutes keeps account-linking routes out of the main auth
14
+ // route file while the handler remains in the same inbound adapter package.
15
+ func (h *Handler) registerIdentityRoutes(usersGroup gin.IRouter, linkLimiter gin.HandlerFunc) {
16
+ usersGroup.GET("/me/identities", h.listIdentities)
17
+ usersGroup.POST("/me/identities/:provider/link", linkLimiter, h.beginIdentityLink)
18
+ usersGroup.POST("/me/identities/:provider/link/exchange", linkLimiter, h.exchangeIdentityLink)
19
+ usersGroup.DELETE("/me/identities/:provider", h.unlinkIdentity)
20
+ }
21
+
22
+ func (h *Handler) listIdentities(c *gin.Context) {
23
+ setNoStoreHeaders(c)
24
+ items, err := h.svc.ListIdentities(c.Request.Context(), currentUserID(c))
25
+ if err != nil {
26
+ c.Error(toHTTPError(err))
27
+ return
28
+ }
29
+ out := make([]identityResponse, len(items))
30
+ for i := range items {
31
+ out[i] = toIdentityResponse(items[i])
32
+ }
33
+ c.JSON(http.StatusOK, identitiesResponse{Identities: out})
34
+ }
35
+
36
+ func (h *Handler) beginIdentityLink(c *gin.Context) {
37
+ setNoStoreHeaders(c)
38
+ var in identityLinkStartInput
39
+ if err := c.ShouldBindJSON(&in); err != nil {
40
+ c.Error(httpx.BindErr(err))
41
+ return
42
+ }
43
+ authorization, err := h.svc.BeginIdentityLink(c.Request.Context(), currentUserID(c), c.Param("provider"), toIdentityLinkStartInput(in))
44
+ if err != nil {
45
+ c.Error(h.oauthAppError(c.Param("provider"), err))
46
+ return
47
+ }
48
+ c.JSON(http.StatusOK, identityLinkStartResponse{AuthorizationURL: authorization.URL})
49
+ }
50
+
51
+ func (h *Handler) exchangeIdentityLink(c *gin.Context) {
52
+ setNoStoreHeaders(c)
53
+ var in identityLinkExchangeInput
54
+ if err := c.ShouldBindJSON(&in); err != nil {
55
+ c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
56
+ return
57
+ }
58
+ identity, err := h.svc.ExchangeIdentityLink(c.Request.Context(), currentUserID(c), c.Param("provider"), toIdentityLinkExchangeInput(in))
59
+ if err != nil {
60
+ c.Error(h.identityLinkError(c.Param("provider"), err))
61
+ return
62
+ }
63
+ c.JSON(http.StatusOK, toIdentityResponse(*identity))
64
+ }
65
+
66
+ func (h *Handler) identityLinkError(provider string, err error) error {
67
+ var oauthErr *userapp.OAuthError
68
+ if errors.As(err, &oauthErr) {
69
+ return h.oauthAppError(provider, err)
70
+ }
71
+ return toHTTPError(err)
72
+ }
73
+
74
+ func (h *Handler) unlinkIdentity(c *gin.Context) {
75
+ if err := h.svc.UnlinkIdentity(c.Request.Context(), currentUserID(c), c.Param("provider")); err != nil {
76
+ c.Error(toHTTPError(err))
77
+ return
78
+ }
79
+ c.Status(http.StatusNoContent)
80
+ }
@@ -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
@@ -4,6 +4,7 @@ import (
4
4
  "context"
5
5
  "crypto/sha256"
6
6
  "encoding/base64"
7
+ "errors"
7
8
  "net/http"
8
9
  "net/http/httptest"
9
10
  "net/url"
@@ -12,6 +13,8 @@ import (
12
13
  "time"
13
14
 
14
15
  "{{goModule}}/internal/app/user/application"
16
+ "{{goModule}}/internal/app/user/domain"
17
+ "{{goModule}}/internal/shared/apperror"
15
18
  "{{goModule}}/internal/shared/middleware"
16
19
 
17
20
  "github.com/gin-gonic/gin"
@@ -151,6 +154,37 @@ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
151
154
  }
152
155
  }
153
156
 
157
+ func TestHandler_SessionRoutesAreProtected(t *testing.T) {
158
+ router := newOAuthTestRouter(t)
159
+ for _, route := range []struct{ method, path string }{
160
+ {http.MethodGet, "/users/me/identities"},
161
+ {http.MethodPost, "/users/me/identities/fake/link"},
162
+ {http.MethodPost, "/users/me/identities/fake/link/exchange"},
163
+ {http.MethodDelete, "/users/me/identities/fake"},
164
+ {http.MethodGet, "/users/me/sessions"},
165
+ {http.MethodDelete, "/users/me/sessions/00000000-0000-0000-0000-000000000001"},
166
+ } {
167
+ response := httptest.NewRecorder()
168
+ router.ServeHTTP(response, httptest.NewRequest(route.method, route.path, nil))
169
+ if response.Code != http.StatusUnauthorized {
170
+ t.Fatalf("%s %s status = %d, want 401", route.method, route.path, response.Code)
171
+ }
172
+ }
173
+ }
174
+
175
+ func TestHandler_IdentityLinkErrorPreservesConflictStatus(t *testing.T) {
176
+ h := NewHandler(stubService{}, "test-secret", time.Hour, true, "strict", allowAllLimiter{})
177
+ mapped := h.identityLinkError("fake", domain.Rule("AUTH_IDENTITY_CONFLICT", "identity conflict", domain.ErrConflict))
178
+
179
+ var appErr *apperror.AppError
180
+ if !errors.As(mapped, &appErr) {
181
+ t.Fatalf("mapped error = %T, want *apperror.AppError", mapped)
182
+ }
183
+ if appErr.HTTPStatus != http.StatusConflict || appErr.Code != "AUTH_IDENTITY_CONFLICT" {
184
+ t.Fatalf("mapped error = status %d code %q, want 409 AUTH_IDENTITY_CONFLICT", appErr.HTTPStatus, appErr.Code)
185
+ }
186
+ }
187
+
154
188
  func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
155
189
  router := newOAuthTestRouter(t)
156
190
  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)