@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.
- package/dist/commands/auth.js +11 -2
- package/dist/index.js +1 -1
- package/dist/templates/auth-manifest.js +4 -0
- package/dist/utils/rbac-patcher.js +12 -4
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +54 -0
- 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.yaml.hbs +15 -0
- package/templates/add/auth/docs/users-me-session.yaml.hbs +14 -0
- package/templates/add/auth/docs/users-me-sessions.yaml.hbs +13 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +62 -0
- 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 +80 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_local.go.hbs +6 -2
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_mfa.go.hbs +9 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_oauth.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +34 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_user.go.hbs +30 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/mfa_store.go.hbs +3 -3
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +6 -1
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +43 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +46 -7
- package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore.go.hbs +75 -0
- package/templates/add/auth/internal/app/user/adapters/outbound/redis/tokenstore_test.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/application/dto.go.hbs +32 -0
- package/templates/add/auth/internal/app/user/application/errors.go.hbs +16 -0
- package/templates/add/auth/internal/app/user/application/external_login.go.hbs +42 -13
- package/templates/add/auth/internal/app/user/application/identities.go.hbs +97 -0
- package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +69 -0
- package/templates/add/auth/internal/app/user/application/jwt.go.hbs +5 -2
- package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +2 -2
- package/templates/add/auth/internal/app/user/application/mfa_service.go.hbs +13 -4
- package/templates/add/auth/internal/app/user/application/service.go.hbs +6 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +107 -5
- package/templates/add/auth/internal/app/user/application/sessions.go.hbs +62 -8
- package/templates/add/auth/internal/app/user/application/tokenstore_ports.go.hbs +1 -0
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +19 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +9 -3
- package/templates/add/auth/internal/shared/middleware/auth_test.go.hbs +48 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +5 -1
- package/templates/add/auth/migrations/create_mfa.up.sql.hbs +2 -0
- 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 +9 -0
- package/templates/create/features/docs/architecture.md.hbs +5 -5
- package/templates/create/features/docs/techstack.md.hbs +1 -1
package/dist/commands/auth.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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 = (
|
|
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 = (
|
|
76
|
-
content = (
|
|
77
|
-
content = (
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|