@nakedev/go-scaffold 0.5.2 → 0.5.4

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 (53) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/auth.js +25 -13
  3. package/dist/commands/method.js +2 -0
  4. package/dist/templates/auth-manifest.js +3 -0
  5. package/dist/utils/auth-patcher.js +10 -1
  6. package/dist/utils/hexagonal-method-patcher.js +12 -1
  7. package/package.json +1 -1
  8. package/templates/add/auth/docs/schemas.yaml.hbs +45 -3
  9. package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
  10. package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
  11. package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
  12. package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
  13. package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
  14. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +45 -3
  15. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
  16. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +107 -0
  17. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +21 -0
  18. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
  19. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
  20. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +253 -53
  21. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
  22. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +4 -2
  23. package/templates/add/auth/internal/app/user/application/dto.go.hbs +14 -0
  24. package/templates/add/auth/internal/app/user/application/errors.go.hbs +20 -0
  25. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +47 -18
  26. package/templates/add/auth/internal/app/user/application/identities.go.hbs +135 -0
  27. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +102 -0
  28. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
  29. package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
  30. package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
  31. package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
  32. package/templates/add/auth/internal/app/user/application/service.go.hbs +16 -0
  33. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
  34. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
  35. package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
  36. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -1
  37. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +4 -1
  38. package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
  39. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
  40. package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
  41. package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
  42. package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
  43. package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
  44. package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
  45. package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
  46. package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
  47. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
  48. package/templates/create/base/AGENTS.md.hbs +14 -0
  49. package/templates/create/base/README.md.hbs +7 -0
  50. package/templates/create/features/docs/architecture.md.hbs +5 -5
  51. package/templates/create/features/docs/techstack.md.hbs +1 -1
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +0 -15
package/README.md CHANGED
@@ -478,6 +478,10 @@ Auth adds:
478
478
  - JWT access tokens and refresh-token rotation with reuse detection
479
479
  - registration, login, logout, refresh, password reset, and email verification
480
480
  - generic provider OAuth routes, with Google as the first adapter
481
+ - account identity storage split into `users`, `user_emails`,
482
+ `password_credentials`, and `external_identities`; a Google-only user can
483
+ add a password through `POST /users/me/identities/local` without creating a
484
+ second account
481
485
  - failed-login lockout and user-session management
482
486
  - MFA endpoints and configuration hooks
483
487
  - internal/app/user, auth middleware, cmd/seed, migrations, and OpenAPI
@@ -34,6 +34,11 @@ 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/local", file: "./auth/users-me-identity-local-link.yaml" },
39
+ { urlPath: "/users/me/identities/{provider}/link", file: "./auth/users-me-identity-link.yaml" },
40
+ { urlPath: "/users/me/identities/{provider}/link/exchange", file: "./auth/users-me-identity-link-exchange.yaml" },
41
+ { urlPath: "/users/me/identities/{provider}", file: "./auth/users-me-identity.yaml" },
37
42
  { urlPath: "/users/me/resend-verification", file: "./auth/users-me-resend-verification.yaml" },
38
43
  { urlPath: "/users/me/logout-all", file: "./auth/users-me-logout-all.yaml" },
39
44
  { urlPath: "/users/me/sessions", file: "./auth/users-me-sessions.yaml" },
@@ -44,8 +49,8 @@ const AUTH_OPENAPI_PATHS = [
44
49
  { urlPath: "/users/me/mfa/disable", file: "./auth/users-me-mfa-disable.yaml" },
45
50
  { urlPath: "/auth/mfa/verify", file: "./auth/mfa-verify.yaml" },
46
51
  ];
47
- // addAuth scaffolds email/password authentication: a users+identities model
48
- // pair, JWT access tokens, a selectable refresh token store with
52
+ // addAuth scaffolds email/password authentication: an account plus separate
53
+ // email, password-credential, and external-identity records, JWT access tokens, a selectable refresh token store with
49
54
  // rotation + reuse detection, register/login/refresh/logout/me, and
50
55
  // per-user session listing/revocation. No RBAC
51
56
  // (no roles/permissions) — that's a separate opt-in on top of this, since
@@ -86,20 +91,26 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
86
91
  { template: "add/auth/migrations/create_users.up.sql.hbs", output: path_1.default.join("migrations", `${usersVersion}_create_users.up.sql`) },
87
92
  { template: "add/auth/migrations/create_users.down.sql.hbs", output: path_1.default.join("migrations", `${usersVersion}_create_users.down.sql`) },
88
93
  ], {});
94
+ const emailVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
95
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
96
+ { template: "add/auth/migrations/create_user_emails.up.sql.hbs", output: path_1.default.join("migrations", `${emailVersion}_create_user_emails.up.sql`) },
97
+ { template: "add/auth/migrations/create_user_emails.down.sql.hbs", output: path_1.default.join("migrations", `${emailVersion}_create_user_emails.down.sql`) },
98
+ ], {});
99
+ const passwordVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
100
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
101
+ { template: "add/auth/migrations/create_password_credentials.up.sql.hbs", output: path_1.default.join("migrations", `${passwordVersion}_create_password_credentials.up.sql`) },
102
+ { template: "add/auth/migrations/create_password_credentials.down.sql.hbs", output: path_1.default.join("migrations", `${passwordVersion}_create_password_credentials.down.sql`) },
103
+ ], {});
104
+ const externalIdentityVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
105
+ await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
106
+ { template: "add/auth/migrations/create_external_identities.up.sql.hbs", output: path_1.default.join("migrations", `${externalIdentityVersion}_create_external_identities.up.sql`) },
107
+ { template: "add/auth/migrations/create_external_identities.down.sql.hbs", output: path_1.default.join("migrations", `${externalIdentityVersion}_create_external_identities.down.sql`) },
108
+ ], {});
89
109
  const mfaVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
90
110
  await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
91
111
  { template: "add/auth/migrations/create_mfa.up.sql.hbs", output: path_1.default.join("migrations", `${mfaVersion}_create_mfa.up.sql`) },
92
112
  { template: "add/auth/migrations/create_mfa.down.sql.hbs", output: path_1.default.join("migrations", `${mfaVersion}_create_mfa.down.sql`) },
93
113
  ], {});
94
- // identities references users(id) — must apply strictly after it. A
95
- // second newMigrationVersion() call, scanning the dir again now that the
96
- // users pair is already written, guarantees a later (or same-second,
97
- // bumped) timestamp rather than assuming +1 by hand.
98
- const identitiesVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
99
- await (0, template_renderer_1.applyTemplateEntries)(projectDir, [
100
- { template: "add/auth/migrations/create_identities.up.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.up.sql`) },
101
- { template: "add/auth/migrations/create_identities.down.sql.hbs", output: path_1.default.join("migrations", `${identitiesVersion}_create_identities.down.sql`) },
102
- ], {});
103
114
  // the failed-attempt counter is not a store choice — lockout has to survive a
104
115
  // deploy and mean the same thing on every replica whichever store holds tokens
105
116
  const throttleVersion = (0, migrations_1.newMigrationVersion)(migrationsDir);
@@ -181,8 +192,9 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
181
192
  console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
182
193
  "GET /auth/{provider}/login, POST /auth/{provider}/exchange, GET /users/me, and " +
183
194
  "POST /users/me/{resend-verification,logout-all,mfa/setup,mfa/confirm,mfa/disable}, " +
184
- "GET /users/me/sessions, DELETE /users/me/sessions/{id}, " +
185
- "GET /users/me/mfa, and POST /auth/mfa/verify in cmd/api/wiring.go" +
195
+ "GET /users/me/{identities,sessions,mfa}, POST /users/me/identities/local, POST /users/me/identities/{provider}/{link,link/exchange}, " +
196
+ "DELETE /users/me/identities/{provider}, DELETE /users/me/sessions/{id}, " +
197
+ "POST /auth/mfa/verify in cmd/api/wiring.go" +
186
198
  docsMessage);
187
199
  console.log(picocolors_1.default.dim("\nnext: go mod tidy, then apply the new migrations with `make migrate-up` before production\n" +
188
200
  "seed an admin: SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed"));
@@ -84,6 +84,8 @@ async function generateHexagonalMethod(config, naming, methodNameArg, opts, proj
84
84
  ? {
85
85
  repositoryModelType: "User",
86
86
  repositoryToDomain: "toDomainUser",
87
+ repositoryToDomainCall: "r.toDomainUser(ctx, &row)",
88
+ repositoryToDomainCallReturnsError: true,
87
89
  repositoryErrorMapper: "persistenceError",
88
90
  repositoryStubReceiver: "f *fakeRepo",
89
91
  handlerErrorMapper: "toHTTPError",
@@ -17,12 +17,14 @@ const SHARED = [
17
17
  { template: "add/auth/internal/app/user/application/dto.go.hbs", output: "internal/app/user/application/dto.go" },
18
18
  { template: "add/auth/internal/app/user/application/errors.go.hbs", output: "internal/app/user/application/errors.go" },
19
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" },
20
21
  { template: "add/auth/internal/app/user/application/jwt.go.hbs", output: "internal/app/user/application/jwt.go" },
21
22
  { template: "add/auth/internal/app/user/application/local_auth.go.hbs", output: "internal/app/user/application/local_auth.go" },
22
23
  { template: "add/auth/internal/app/user/application/mfa_service.go.hbs", output: "internal/app/user/application/mfa_service.go" },
23
24
  { template: "add/auth/internal/app/user/application/mfa_service_test.go.hbs", output: "internal/app/user/application/mfa_service_test.go" },
24
25
  { template: "add/auth/internal/app/user/application/oauth.go.hbs", output: "internal/app/user/application/oauth.go" },
25
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" },
26
28
  { template: "add/auth/internal/app/user/application/recovery.go.hbs", output: "internal/app/user/application/recovery.go" },
27
29
  { template: "add/auth/internal/app/user/application/recovery_service.go.hbs", output: "internal/app/user/application/recovery_service.go" },
28
30
  { template: "add/auth/internal/app/user/application/service.go.hbs", output: "internal/app/user/application/service.go" },
@@ -39,6 +41,7 @@ const SHARED = [
39
41
  { template: "add/auth/internal/app/user/adapters/inbound/http/handler_recovery.go.hbs", output: "internal/app/user/adapters/inbound/http/handler_recovery.go" },
40
42
  { template: "add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs", output: "internal/app/user/adapters/inbound/http/handler_test.go" },
41
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" },
42
45
  { template: "add/auth/internal/app/user/adapters/inbound/http/session_cookie.go.hbs", output: "internal/app/user/adapters/inbound/http/session_cookie.go" },
43
46
  { template: "add/auth/internal/app/user/adapters/outbound/password/bcrypt.go.hbs", output: "internal/app/user/adapters/outbound/password/bcrypt.go" },
44
47
  { template: "add/auth/internal/app/user/adapters/outbound/password/bcrypt_test.go.hbs", output: "internal/app/user/adapters/outbound/password/bcrypt_test.go" },
@@ -122,6 +122,9 @@ function patchMainGoForAuth(mainGoPath, w) {
122
122
  'if cfg.IsProd() && cfg.JWTSecret == "dev-secret-change-me" {',
123
123
  '\treturn errors.New("JWT_SECRET is still the dev default — set a real secret before deploying with APP_ENV=production")',
124
124
  "}",
125
+ 'if cfg.IsProd() && len([]byte(cfg.JWTSecret)) < 32 {',
126
+ '\treturn errors.New("JWT_SECRET must be at least 32 bytes before deploying with APP_ENV=production")',
127
+ "}",
125
128
  ].join("\n");
126
129
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, CONFIG_CHECKS_MARKER, checkBlock, "JWT_SECRET is still the dev default");
127
130
  // Without SMTP the mail client logs the message instead of sending it — a
@@ -164,7 +167,13 @@ function patchMainGoForAuth(mainGoPath, w) {
164
167
  "}",
165
168
  ].join("\n");
166
169
  content = (0, marker_patch_1.insertBeforeMarkerOnce)(content, SCHEMA_MARKER, schemaBlock, "CREATE SCHEMA IF NOT EXISTS user_svc");
167
- const migrateLines = ["&usermodel.User{},", "&usermodel.Identity{},", "&usermodel.LoginThrottle{},"];
170
+ const migrateLines = [
171
+ "&usermodel.User{},",
172
+ "&usermodel.UserEmail{},",
173
+ "&usermodel.PasswordCredential{},",
174
+ "&usermodel.ExternalIdentity{},",
175
+ "&usermodel.LoginThrottle{},",
176
+ ];
168
177
  // Recovery tokens are always durable in Postgres, even when refresh tokens
169
178
  // live in Redis, so reset/verification can share the user transaction.
170
179
  migrateLines.push("&usermodel.AuthToken{},");
@@ -299,14 +299,25 @@ function patchHexagonalMethod(paths, naming, method, opts, goModule) {
299
299
  const column = (0, naming_1.toDbName)(opts.field);
300
300
  const modelType = paths.repositoryModelType ?? `${naming.pascalName}Model`;
301
301
  const toDomain = paths.repositoryToDomain ?? "toDomain";
302
+ const toDomainCall = paths.repositoryToDomainCall ?? `${toDomain}(&row)`;
303
+ const toDomainCallReturnsError = paths.repositoryToDomainCallReturnsError ?? false;
302
304
  const errorMapper = paths.repositoryErrorMapper ?? "mapDatabaseError";
305
+ const mapping = toDomainCallReturnsError
306
+ ? [
307
+ `\tm, err := ${toDomainCall}`,
308
+ `\tif err != nil {`,
309
+ `\t\treturn nil, ${errorMapper}(err)`,
310
+ `\t}`,
311
+ `\treturn m, nil`,
312
+ ]
313
+ : [`\treturn ${toDomainCall}, nil`];
303
314
  adapter = insert(adapter, REPOSITORY_METHODS_MARKER, [
304
315
  `func (r *Repository) FindBy${fieldPascal}(ctx context.Context, ${fieldParam} string) (*domain.${naming.pascalName}, error) {`,
305
316
  `\tvar row ${modelType}`,
306
317
  `\tif err := tx.From(ctx, r.db).WithContext(ctx).First(&row, "${column} = ?", ${fieldParam}).Error; err != nil {`,
307
318
  `\t\treturn nil, ${errorMapper}(err)`,
308
319
  `\t}`,
309
- `\treturn ${toDomain}(&row), nil`,
320
+ ...mapping,
310
321
  `}`,
311
322
  "",
312
323
  ].join("\n"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,7 +3,7 @@ RegisterInput:
3
3
  required: [email, password, name]
4
4
  properties:
5
5
  email: { type: string, format: email }
6
- password: { type: string, format: password, minLength: 8 }
6
+ password: { type: string, format: password, minLength: 8, maxLength: 72 }
7
7
  name: { type: string }
8
8
 
9
9
  LoginInput:
@@ -11,7 +11,7 @@ LoginInput:
11
11
  required: [email, password]
12
12
  properties:
13
13
  email: { type: string, format: email }
14
- password: { type: string, format: password }
14
+ password: { type: string, format: password, maxLength: 72 }
15
15
 
16
16
  ForgotPasswordInput:
17
17
  type: object
@@ -24,7 +24,7 @@ ResetPasswordInput:
24
24
  required: [token, new_password]
25
25
  properties:
26
26
  token: { type: string }
27
- new_password: { type: string, format: password, minLength: 8 }
27
+ new_password: { type: string, format: password, minLength: 8, maxLength: 72 }
28
28
 
29
29
  VerifyEmailInput:
30
30
  type: object
@@ -133,3 +133,45 @@ SessionsResponse:
133
133
  sessions:
134
134
  type: array
135
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 }
171
+
172
+ LocalIdentityLinkInput:
173
+ type: object
174
+ required: [password]
175
+ additionalProperties: false
176
+ properties:
177
+ password: { type: string, format: password, minLength: 8, maxLength: 72 }
@@ -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,16 @@
1
+ post:
2
+ summary: Add an email/password login to the current user
3
+ description: Creates a password credential for the authenticated account. It does not create another account, even when the account originally signed in with Google.
4
+ operationId: linkMyLocalIdentity
5
+ tags: [users]
6
+ security: [{ bearerAuth: [] }]
7
+ requestBody:
8
+ required: true
9
+ content:
10
+ application/json:
11
+ schema: { $ref: './schemas.yaml#/LocalIdentityLinkInput' }
12
+ responses:
13
+ "204": { description: email/password login linked }
14
+ "400": { $ref: '../common/responses.yaml#/ValidationError' }
15
+ "401": { $ref: '../common/responses.yaml#/UnauthorizedError' }
16
+ "409": { description: email/password login is already linked }
@@ -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 }
@@ -13,13 +13,13 @@ import (
13
13
  // transport-neutral inputs from handler mapping code.
14
14
  type registerInput struct {
15
15
  Email string `json:"email" binding:"required,email"`
16
- Password string `json:"password" binding:"required,min=8"`
16
+ Password string `json:"password" binding:"required,min=8,max=72"`
17
17
  Name string `json:"name" binding:"required"`
18
18
  }
19
19
 
20
20
  type loginInput struct {
21
21
  Email string `json:"email" binding:"required,email"`
22
- Password string `json:"password" binding:"required"`
22
+ Password string `json:"password" binding:"required,max=72"`
23
23
  }
24
24
 
25
25
  type loginExchangeInput struct {
@@ -62,7 +62,7 @@ type forgotPasswordInput struct {
62
62
 
63
63
  type resetPasswordInput struct {
64
64
  Token string `json:"token" binding:"required"`
65
- NewPassword string `json:"new_password" binding:"required,min=8"`
65
+ NewPassword string `json:"new_password" binding:"required,min=8,max=72"`
66
66
  }
67
67
 
68
68
  type verifyEmailInput struct {
@@ -116,6 +116,36 @@ type sessionsResponse struct {
116
116
  Sessions []sessionResponse `json:"sessions"`
117
117
  }
118
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 localIdentityLinkInput struct {
132
+ Password string `json:"password" binding:"required,min=8,max=72"`
133
+ }
134
+
135
+ type identityLinkStartResponse struct {
136
+ AuthorizationURL string `json:"authorization_url"`
137
+ }
138
+
139
+ type identityResponse struct {
140
+ ID uuid.UUID `json:"id"`
141
+ Provider string `json:"provider"`
142
+ CreatedAt time.Time `json:"created_at"`
143
+ }
144
+
145
+ type identitiesResponse struct {
146
+ Identities []identityResponse `json:"identities"`
147
+ }
148
+
119
149
  func toSessionResponse(session userapp.Session) sessionResponse {
120
150
  return sessionResponse{
121
151
  ID: session.ID, UserAgent: session.UserAgent, CreatedAt: session.CreatedAt,
@@ -123,6 +153,18 @@ func toSessionResponse(session userapp.Session) sessionResponse {
123
153
  }
124
154
  }
125
155
 
156
+ func toIdentityLinkStartInput(in identityLinkStartInput) userapp.LoginStartInput {
157
+ return userapp.LoginStartInput{State: in.State, CodeChallenge: in.CodeChallenge, CodeChallengeMethod: in.CodeChallengeMethod}
158
+ }
159
+
160
+ func toIdentityLinkExchangeInput(in identityLinkExchangeInput) userapp.LoginExchangeInput {
161
+ return userapp.LoginExchangeInput{Code: in.Code, State: in.State, CodeVerifier: in.CodeVerifier}
162
+ }
163
+
164
+ func toIdentityResponse(identity userapp.IdentityResponse) identityResponse {
165
+ return identityResponse{ID: identity.ID, Provider: identity.Provider, CreatedAt: identity.CreatedAt}
166
+ }
167
+
126
168
  func toCookieResponse(auth *userapp.AuthResponse) authCookieResponse {
127
169
  return authCookieResponse{AccessToken: auth.AccessToken, TokenType: auth.TokenType, ExpiresIn: auth.ExpiresIn}
128
170
  }
@@ -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)
@@ -138,6 +139,7 @@ func (h *Handler) Register(rg gin.IRouter) {
138
139
  usersGroup.POST("/me/mfa/setup", mfaSetupLimit, h.setupMFA)
139
140
  usersGroup.POST("/me/mfa/confirm", mfaConfirmLimit, h.confirmMFA)
140
141
  usersGroup.POST("/me/mfa/disable", mfaDisableLimit, h.disableMFA)
142
+ h.registerIdentityRoutes(usersGroup, identityLinkLimit)
141
143
  // Admin user routes are enabled only when the optional RBAC authorizer is
142
144
  // supplied by the composition root. Auth-only projects keep the endpoint
143
145
  // surface limited to authentication and the current-user operations.
@@ -207,10 +209,14 @@ func toHTTPError(err error) error {
207
209
  switch ruleErr.Code {
208
210
  case "USER_NOT_FOUND":
209
211
  status = http.StatusNotFound
210
- 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":
211
213
  status = http.StatusConflict
214
+ case "AUTH_IDENTITY_NOT_FOUND":
215
+ status = http.StatusNotFound
212
216
  case "AUTH_INVALID_CREDENTIALS", "AUTH_INVALID_TOKEN", "AUTH_MFA_INVALID":
213
217
  status = http.StatusUnauthorized
218
+ case "AUTH_INVALID_PASSWORD":
219
+ status = http.StatusBadRequest
214
220
  case "AUTH_MFA_UNAVAILABLE":
215
221
  status = http.StatusServiceUnavailable
216
222
  case "AUTH_TOO_MANY_ATTEMPTS":
@@ -0,0 +1,107 @@
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/local", linkLimiter, h.linkLocalIdentity)
18
+ usersGroup.POST("/me/identities/:provider/link", linkLimiter, h.beginIdentityLink)
19
+ usersGroup.POST("/me/identities/:provider/link/exchange", linkLimiter, h.exchangeIdentityLink)
20
+ usersGroup.DELETE("/me/identities/:provider", h.unlinkIdentity)
21
+ }
22
+
23
+ func (h *Handler) linkLocalIdentity(c *gin.Context) {
24
+ setNoStoreHeaders(c)
25
+ if !h.requireBrowserOrigin(c) {
26
+ return
27
+ }
28
+ var in localIdentityLinkInput
29
+ if err := c.ShouldBindJSON(&in); err != nil {
30
+ c.Error(httpx.BindErr(err))
31
+ return
32
+ }
33
+ if err := h.svc.LinkLocalIdentity(c.Request.Context(), currentUserID(c), in.Password); err != nil {
34
+ c.Error(toHTTPError(err))
35
+ return
36
+ }
37
+ c.Status(http.StatusNoContent)
38
+ }
39
+
40
+ func (h *Handler) listIdentities(c *gin.Context) {
41
+ setNoStoreHeaders(c)
42
+ items, err := h.svc.ListIdentities(c.Request.Context(), currentUserID(c))
43
+ if err != nil {
44
+ c.Error(toHTTPError(err))
45
+ return
46
+ }
47
+ out := make([]identityResponse, len(items))
48
+ for i := range items {
49
+ out[i] = toIdentityResponse(items[i])
50
+ }
51
+ c.JSON(http.StatusOK, identitiesResponse{Identities: out})
52
+ }
53
+
54
+ func (h *Handler) beginIdentityLink(c *gin.Context) {
55
+ setNoStoreHeaders(c)
56
+ if !h.requireBrowserOrigin(c) {
57
+ return
58
+ }
59
+ var in identityLinkStartInput
60
+ if err := c.ShouldBindJSON(&in); err != nil {
61
+ c.Error(httpx.BindErr(err))
62
+ return
63
+ }
64
+ authorization, err := h.svc.BeginIdentityLink(c.Request.Context(), currentUserID(c), c.Param("provider"), toIdentityLinkStartInput(in))
65
+ if err != nil {
66
+ c.Error(h.oauthAppError(c.Param("provider"), err))
67
+ return
68
+ }
69
+ c.JSON(http.StatusOK, identityLinkStartResponse{AuthorizationURL: authorization.URL})
70
+ }
71
+
72
+ func (h *Handler) exchangeIdentityLink(c *gin.Context) {
73
+ setNoStoreHeaders(c)
74
+ if !h.requireBrowserOrigin(c) {
75
+ return
76
+ }
77
+ var in identityLinkExchangeInput
78
+ if err := c.ShouldBindJSON(&in); err != nil {
79
+ c.Error(h.oauthAppError(c.Param("provider"), userapp.NewOAuthError(userapp.OAuthFailed, err)))
80
+ return
81
+ }
82
+ identity, err := h.svc.ExchangeIdentityLink(c.Request.Context(), currentUserID(c), c.Param("provider"), toIdentityLinkExchangeInput(in))
83
+ if err != nil {
84
+ c.Error(h.identityLinkError(c.Param("provider"), err))
85
+ return
86
+ }
87
+ c.JSON(http.StatusOK, toIdentityResponse(*identity))
88
+ }
89
+
90
+ func (h *Handler) identityLinkError(provider string, err error) error {
91
+ var oauthErr *userapp.OAuthError
92
+ if errors.As(err, &oauthErr) {
93
+ return h.oauthAppError(provider, err)
94
+ }
95
+ return toHTTPError(err)
96
+ }
97
+
98
+ func (h *Handler) unlinkIdentity(c *gin.Context) {
99
+ if !h.requireBrowserOrigin(c) {
100
+ return
101
+ }
102
+ if err := h.svc.UnlinkIdentity(c.Request.Context(), currentUserID(c), c.Param("provider")); err != nil {
103
+ c.Error(toHTTPError(err))
104
+ return
105
+ }
106
+ c.Status(http.StatusNoContent)
107
+ }
@@ -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"
@@ -154,6 +157,11 @@ func TestHandler_ProviderExchangeReturnsJSONAndSetsRefreshCookie(t *testing.T) {
154
157
  func TestHandler_SessionRoutesAreProtected(t *testing.T) {
155
158
  router := newOAuthTestRouter(t)
156
159
  for _, route := range []struct{ method, path string }{
160
+ {http.MethodGet, "/users/me/identities"},
161
+ {http.MethodPost, "/users/me/identities/local"},
162
+ {http.MethodPost, "/users/me/identities/fake/link"},
163
+ {http.MethodPost, "/users/me/identities/fake/link/exchange"},
164
+ {http.MethodDelete, "/users/me/identities/fake"},
157
165
  {http.MethodGet, "/users/me/sessions"},
158
166
  {http.MethodDelete, "/users/me/sessions/00000000-0000-0000-0000-000000000001"},
159
167
  } {
@@ -165,6 +173,19 @@ func TestHandler_SessionRoutesAreProtected(t *testing.T) {
165
173
  }
166
174
  }
167
175
 
176
+ func TestHandler_IdentityLinkErrorPreservesConflictStatus(t *testing.T) {
177
+ h := NewHandler(stubService{}, "test-secret", time.Hour, true, "strict", allowAllLimiter{})
178
+ mapped := h.identityLinkError("fake", domain.Rule("AUTH_IDENTITY_CONFLICT", "identity conflict", domain.ErrConflict))
179
+
180
+ var appErr *apperror.AppError
181
+ if !errors.As(mapped, &appErr) {
182
+ t.Fatalf("mapped error = %T, want *apperror.AppError", mapped)
183
+ }
184
+ if appErr.HTTPStatus != http.StatusConflict || appErr.Code != "AUTH_IDENTITY_CONFLICT" {
185
+ t.Fatalf("mapped error = status %d code %q, want 409 AUTH_IDENTITY_CONFLICT", appErr.HTTPStatus, appErr.Code)
186
+ }
187
+ }
188
+
168
189
  func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
169
190
  router := newOAuthTestRouter(t)
170
191
  callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
@@ -53,7 +53,7 @@ func TestPostgresMFAStore_ConsumesChallengesAndRecoveryCodesOnce(t *testing.T) {
53
53
  store := NewPostgresMFAStore(db)
54
54
  ctx := context.Background()
55
55
  userID := uuid.New()
56
- if err := db.Create(&User{ID: userID, Email: "mfa-store-" + uuid.NewString() + "@example.com"}).Error; err != nil {
56
+ if err := db.Create(&User{ID: userID}).Error; err != nil {
57
57
  t.Fatalf("create test user: %v", err)
58
58
  }
59
59
  t.Cleanup(func() { _ = db.Delete(&User{}, "id = ?", userID).Error })