@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.
- package/README.md +4 -0
- package/dist/commands/auth.js +25 -13
- package/dist/commands/method.js +2 -0
- package/dist/templates/auth-manifest.js +3 -0
- package/dist/utils/auth-patcher.js +10 -1
- package/dist/utils/hexagonal-method-patcher.js +12 -1
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +45 -3
- package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
- package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
- package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
- package/templates/add/auth/docs/users-me-identity-local-link.yaml.hbs +16 -0
- package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +45 -3
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +7 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +107 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +21 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs +1 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +40 -17
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +253 -53
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +11 -8
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +4 -2
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +14 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +20 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +47 -18
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +135 -0
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +102 -0
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/oauth.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/application/provider_test.go.hbs +2 -0
- package/templates/add/auth/internal/app/user/application/recovery.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/application/service.go.hbs +16 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
- package/templates/add/auth/internal/app/user/application/user_query.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/domain/entity.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +4 -1
- package/templates/add/auth/internal/platform/authprovider/google/google.go.hbs +1 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +7 -1
- package/templates/add/auth/migrations/create_external_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_external_identities.up.sql.hbs +12 -0
- package/templates/add/auth/migrations/create_password_credentials.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_password_credentials.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_user_emails.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_user_emails.up.sql.hbs +14 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +0 -11
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +14 -0
- package/templates/create/base/README.md.hbs +7 -0
- package/templates/create/features/docs/architecture.md.hbs +5 -5
- package/templates/create/features/docs/techstack.md.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +0 -1
- 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
|
package/dist/commands/auth.js
CHANGED
|
@@ -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:
|
|
48
|
-
//
|
|
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,
|
|
185
|
-
"
|
|
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"));
|
package/dist/commands/method.js
CHANGED
|
@@ -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 = [
|
|
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
|
-
|
|
320
|
+
...mapping,
|
|
310
321
|
`}`,
|
|
311
322
|
"",
|
|
312
323
|
].join("\n"));
|
package/package.json
CHANGED
|
@@ -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)
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store_test.go.hbs
CHANGED
|
@@ -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
|
|
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 })
|