@nakedev/go-scaffold 0.5.2 → 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 +7 -2
- package/dist/templates/auth-manifest.js +3 -0
- package/package.json +1 -1
- package/templates/add/auth/docs/schemas.yaml.hbs +35 -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/internal/app/user/adapters/inbound/http/dto.go.hbs +38 -0
- package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +5 -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_test.go.hbs +20 -0
- 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 +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 +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/service.go.hbs +4 -0
- package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
- package/templates/add/auth/internal/app/user/domain/errors.go.hbs +3 -1
- package/templates/add/auth/internal/app/user/ports/repository.go.hbs +3 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
- package/templates/create/base/AGENTS.md.hbs +8 -0
- package/templates/create/base/README.md.hbs +5 -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,6 +34,10 @@ 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" },
|
|
39
43
|
{ urlPath: "/users/me/sessions", file: "./auth/users-me-sessions.yaml" },
|
|
@@ -181,8 +185,9 @@ async function addAuth(store = "postgres", projectDir = process.cwd(), browserTo
|
|
|
181
185
|
console.log("registered POST /auth/{register,login,refresh,logout,forgot-password,reset-password,verify-email}, " +
|
|
182
186
|
"GET /auth/{provider}/login, POST /auth/{provider}/exchange, GET /users/me, and " +
|
|
183
187
|
"POST /users/me/{resend-verification,logout-all,mfa/setup,mfa/confirm,mfa/disable}, " +
|
|
184
|
-
"GET /users/me/sessions,
|
|
185
|
-
"
|
|
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" +
|
|
186
191
|
docsMessage);
|
|
187
192
|
console.log(picocolors_1.default.dim("\nnext: go mod tidy, then apply the new migrations with `make migrate-up` before production\n" +
|
|
188
193
|
"seed an admin: SEED_ADMIN_EMAIL=... SEED_ADMIN_PASSWORD=... make seed"));
|
|
@@ -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" },
|
package/package.json
CHANGED
|
@@ -133,3 +133,38 @@ 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 }
|
|
@@ -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 }
|
|
@@ -116,6 +116,32 @@ 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 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
|
+
|
|
119
145
|
func toSessionResponse(session userapp.Session) sessionResponse {
|
|
120
146
|
return sessionResponse{
|
|
121
147
|
ID: session.ID, UserAgent: session.UserAgent, CreatedAt: session.CreatedAt,
|
|
@@ -123,6 +149,18 @@ func toSessionResponse(session userapp.Session) sessionResponse {
|
|
|
123
149
|
}
|
|
124
150
|
}
|
|
125
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
|
+
|
|
126
164
|
func toCookieResponse(auth *userapp.AuthResponse) authCookieResponse {
|
|
127
165
|
return authCookieResponse{AccessToken: auth.AccessToken, TokenType: auth.TokenType, ExpiresIn: auth.ExpiresIn}
|
|
128
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)
|
|
@@ -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,8 +209,10 @@ 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
|
|
214
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
|
+
}
|
|
@@ -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,10 @@ 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/fake/link"},
|
|
162
|
+
{http.MethodPost, "/users/me/identities/fake/link/exchange"},
|
|
163
|
+
{http.MethodDelete, "/users/me/identities/fake"},
|
|
157
164
|
{http.MethodGet, "/users/me/sessions"},
|
|
158
165
|
{http.MethodDelete, "/users/me/sessions/00000000-0000-0000-0000-000000000001"},
|
|
159
166
|
} {
|
|
@@ -165,6 +172,19 @@ func TestHandler_SessionRoutesAreProtected(t *testing.T) {
|
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
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
|
+
|
|
168
188
|
func TestHandler_ProviderCallbackIsFrontendOwnedAndErrorsDoNotRedirect(t *testing.T) {
|
|
169
189
|
router := newOAuthTestRouter(t)
|
|
170
190
|
callback := httptest.NewRequest(http.MethodGet, "/auth/fake/callback?error=access_denied&error_description=do-not-leak", nil)
|
|
@@ -12,6 +12,7 @@ import (
|
|
|
12
12
|
|
|
13
13
|
"github.com/google/uuid"
|
|
14
14
|
"gorm.io/gorm"
|
|
15
|
+
"gorm.io/gorm/clause"
|
|
15
16
|
)
|
|
16
17
|
|
|
17
18
|
type Repository struct {
|
|
@@ -68,6 +69,20 @@ func (r *Repository) FindIdentity(ctx context.Context, userID uuid.UUID, provide
|
|
|
68
69
|
return toDomainIdentity(&row), nil
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
func (r *Repository) ListIdentities(ctx context.Context, userID uuid.UUID) ([]domain.Identity, error) {
|
|
73
|
+
var rows []Identity
|
|
74
|
+
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
75
|
+
Where("user_id = ?", userID).
|
|
76
|
+
Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
|
77
|
+
return nil, persistenceError(err)
|
|
78
|
+
}
|
|
79
|
+
items := make([]domain.Identity, len(rows))
|
|
80
|
+
for i := range rows {
|
|
81
|
+
items[i] = *toDomainIdentity(&rows[i])
|
|
82
|
+
}
|
|
83
|
+
return items, nil
|
|
84
|
+
}
|
|
85
|
+
|
|
71
86
|
func (r *Repository) FindIdentityByProviderUID(ctx context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
|
|
72
87
|
var row Identity
|
|
73
88
|
if err := tx.From(ctx, r.db).WithContext(ctx).
|
|
@@ -88,6 +103,34 @@ func (r *Repository) UpdateIdentity(ctx context.Context, identity *domain.Identi
|
|
|
88
103
|
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Save(&row).Error)
|
|
89
104
|
}
|
|
90
105
|
|
|
106
|
+
// DeleteIdentity locks the user's identity rows before checking the count so
|
|
107
|
+
// two concurrent unlink requests cannot both remove the last login method.
|
|
108
|
+
func (r *Repository) DeleteIdentity(ctx context.Context, userID uuid.UUID, provider domain.Provider) error {
|
|
109
|
+
return persistenceError(tx.From(ctx, r.db).WithContext(ctx).Transaction(func(db *gorm.DB) error {
|
|
110
|
+
var rows []Identity
|
|
111
|
+
if err := db.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
112
|
+
Where("user_id = ?", userID).
|
|
113
|
+
Order("created_at ASC, id ASC").Find(&rows).Error; err != nil {
|
|
114
|
+
return err
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
var target *Identity
|
|
118
|
+
for i := range rows {
|
|
119
|
+
if rows[i].Provider == string(provider) {
|
|
120
|
+
target = &rows[i]
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if target == nil {
|
|
125
|
+
return domain.ErrNotFound
|
|
126
|
+
}
|
|
127
|
+
if len(rows) <= 1 {
|
|
128
|
+
return domain.ErrLastIdentity
|
|
129
|
+
}
|
|
130
|
+
return db.Delete(&Identity{}, "id = ? AND user_id = ?", target.ID, userID).Error
|
|
131
|
+
}))
|
|
132
|
+
}
|
|
133
|
+
|
|
91
134
|
// CreateUserWithIdentity inserts the profile and its first login method in
|
|
92
135
|
// one transaction. A user without an identity cannot authenticate.
|
|
93
136
|
func (r *Repository) CreateUserWithIdentity(ctx context.Context, user *domain.User, identity *domain.Identity) error {
|
package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs
CHANGED
|
@@ -176,6 +176,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
|
|
|
176
176
|
}
|
|
177
177
|
row := AuthToken{
|
|
178
178
|
TokenHash: stateHash,
|
|
179
|
+
UserID: transaction.UserID,
|
|
179
180
|
Kind: kindOAuthState,
|
|
180
181
|
ExpiresAt: transaction.ExpiresAt,
|
|
181
182
|
Provider: transaction.Provider,
|
|
@@ -185,7 +186,7 @@ func (s *PgTokenStore) SetLoginTransaction(ctx context.Context, stateHash string
|
|
|
185
186
|
return tx.From(ctx, s.db).WithContext(ctx).
|
|
186
187
|
Where("token_hash = ?", stateHash).
|
|
187
188
|
Assign(map[string]any{
|
|
188
|
-
"user_id":
|
|
189
|
+
"user_id": transaction.UserID,
|
|
189
190
|
"kind": kindOAuthState,
|
|
190
191
|
"expires_at": transaction.ExpiresAt,
|
|
191
192
|
"provider": transaction.Provider,
|
|
@@ -199,8 +200,9 @@ func (s *PgTokenStore) ConsumeLoginTransaction(ctx context.Context, stateHash st
|
|
|
199
200
|
err := tx.From(ctx, s.db).WithContext(ctx).Raw(
|
|
200
201
|
`DELETE FROM user_svc.auth_tokens
|
|
201
202
|
WHERE token_hash = ? AND kind = ? AND expires_at > now()
|
|
202
|
-
RETURNING provider, code_challenge, nonce, expires_at`,
|
|
203
|
+
RETURNING user_id, provider, code_challenge, nonce, expires_at`,
|
|
203
204
|
stateHash, kindOAuthState).Row().Scan(
|
|
205
|
+
&transaction.UserID,
|
|
204
206
|
&transaction.Provider,
|
|
205
207
|
&transaction.CodeChallenge,
|
|
206
208
|
&transaction.Nonce,
|
|
@@ -43,6 +43,20 @@ type Session struct {
|
|
|
43
43
|
Current bool
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// IdentityResponse is the safe public view of a login identity. Provider
|
|
47
|
+
// subjects and password hashes never cross the application/HTTP boundary.
|
|
48
|
+
type IdentityResponse struct {
|
|
49
|
+
ID uuid.UUID
|
|
50
|
+
Provider string
|
|
51
|
+
CreatedAt time.Time
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func ToIdentityResponse(identity domain.Identity) IdentityResponse {
|
|
55
|
+
return IdentityResponse{
|
|
56
|
+
ID: identity.ID, Provider: string(identity.Provider), CreatedAt: identity.CreatedAt,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
type AuthResponse struct {
|
|
47
61
|
AccessToken string
|
|
48
62
|
RefreshToken string
|
|
@@ -60,3 +60,19 @@ func errMFAConfig() error {
|
|
|
60
60
|
func errUnknownRole() error {
|
|
61
61
|
return domain.Rule("USER_UNKNOWN_ROLE", "unknown role code", domain.ErrUnknownRole)
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
func errIdentityAlreadyLinked() error {
|
|
65
|
+
return domain.Rule("AUTH_IDENTITY_ALREADY_LINKED", "this login method is already linked", domain.ErrIdentityAlreadyLinked)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func errIdentityConflict() error {
|
|
69
|
+
return domain.Rule("AUTH_IDENTITY_CONFLICT", "this provider account is linked to another user", domain.ErrConflict)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func errIdentityNotFound() error {
|
|
73
|
+
return domain.Rule("AUTH_IDENTITY_NOT_FOUND", "login method not found", domain.ErrNotFound)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func errLastIdentity() error {
|
|
77
|
+
return domain.Rule("AUTH_LAST_IDENTITY", "you cannot remove your last login method", domain.ErrLastIdentity)
|
|
78
|
+
}
|
|
@@ -12,6 +12,8 @@ import (
|
|
|
12
12
|
|
|
13
13
|
"{{goModule}}/internal/app/user/domain"
|
|
14
14
|
"{{goModule}}/internal/shared/id"
|
|
15
|
+
|
|
16
|
+
"github.com/google/uuid"
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
// BeginLogin accepts the browser client's state and S256 PKCE challenge and
|
|
@@ -19,6 +21,21 @@ import (
|
|
|
19
21
|
// The server persists a hashed state transaction binding provider, challenge,
|
|
20
22
|
// and OIDC nonce before the callback reaches ExchangeLogin.
|
|
21
23
|
func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginStartInput) (*Authorization, error) {
|
|
24
|
+
return s.beginProviderLogin(ctx, providerName, in, uuid.Nil)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// BeginIdentityLink starts an OAuth transaction that is bound to the
|
|
28
|
+
// authenticated user. It is deliberately separate from BeginLogin: the
|
|
29
|
+
// public login flow may resolve any user, while a link flow must never be
|
|
30
|
+
// allowed to attach an identity to whichever account the provider returns.
|
|
31
|
+
func (s *Service) BeginIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginStartInput) (*Authorization, error) {
|
|
32
|
+
if userID == uuid.Nil {
|
|
33
|
+
return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
|
|
34
|
+
}
|
|
35
|
+
return s.beginProviderLogin(ctx, providerName, in, userID)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func (s *Service) beginProviderLogin(ctx context.Context, providerName string, in LoginStartInput, userID uuid.UUID) (*Authorization, error) {
|
|
22
39
|
provider, ok := s.providers.Lookup(providerName)
|
|
23
40
|
if !ok {
|
|
24
41
|
return nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
|
|
@@ -46,6 +63,7 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginS
|
|
|
46
63
|
}
|
|
47
64
|
if err := s.oauthTransactions.SetLoginTransaction(ctx, hashToken(in.State), LoginTransaction{
|
|
48
65
|
Provider: providerName,
|
|
66
|
+
UserID: userID,
|
|
49
67
|
CodeChallenge: in.CodeChallenge,
|
|
50
68
|
Nonce: nonce,
|
|
51
69
|
ExpiresAt: clock().Add(s.config.OAuthStateTTL),
|
|
@@ -56,37 +74,48 @@ func (s *Service) BeginLogin(ctx context.Context, providerName string, in LoginS
|
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
func (s *Service) ExchangeLogin(ctx context.Context, providerName string, in LoginExchangeInput) (*AuthResult, error) {
|
|
77
|
+
identity, err := s.exchangeExternalIdentity(ctx, providerName, in, uuid.Nil)
|
|
78
|
+
if err != nil {
|
|
79
|
+
return nil, err
|
|
80
|
+
}
|
|
81
|
+
u, err := s.findOrCreateExternalUser(ctx, identity)
|
|
82
|
+
if err != nil {
|
|
83
|
+
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
|
|
84
|
+
}
|
|
85
|
+
return s.completeLogin(ctx, u, in.Session)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// exchangeExternalIdentity consumes the one-time transaction before asking
|
|
89
|
+
// the provider to complete the code exchange. The transaction's UserID is
|
|
90
|
+
// zero for public login and is the authenticated caller for identity linking.
|
|
91
|
+
func (s *Service) exchangeExternalIdentity(ctx context.Context, providerName string, in LoginExchangeInput, userID uuid.UUID) (ExternalIdentity, error) {
|
|
59
92
|
provider, ok := s.providers.Lookup(providerName)
|
|
60
93
|
if !ok {
|
|
61
|
-
return
|
|
94
|
+
return ExternalIdentity{}, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
|
|
62
95
|
}
|
|
63
96
|
if !validOAuthValue(in.State) || !validPKCEValue(in.CodeVerifier) {
|
|
64
|
-
return
|
|
97
|
+
return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
|
|
65
98
|
}
|
|
66
99
|
if !validOAuthValue(in.Code) {
|
|
67
|
-
return
|
|
100
|
+
return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
transaction, ok, err := s.oauthTransactions.ConsumeLoginTransaction(ctx, hashToken(in.State))
|
|
71
104
|
if err != nil {
|
|
72
|
-
return
|
|
105
|
+
return ExternalIdentity{}, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
|
|
73
106
|
}
|
|
74
|
-
if !ok || transaction.Provider != providerName || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
|
|
75
|
-
return
|
|
107
|
+
if !ok || transaction.Provider != providerName || transaction.UserID != userID || !validPKCEVerifier(in.CodeVerifier, transaction.CodeChallenge) {
|
|
108
|
+
return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
identity, err := provider.Complete(ctx, LoginCompleteInput{Code: in.Code, CodeVerifier: in.CodeVerifier, Nonce: transaction.Nonce})
|
|
79
112
|
if err != nil {
|
|
80
|
-
return
|
|
113
|
+
return ExternalIdentity{}, mapProviderError(err)
|
|
81
114
|
}
|
|
82
115
|
if identity.Provider != providerName {
|
|
83
|
-
return
|
|
116
|
+
return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
|
|
84
117
|
}
|
|
85
|
-
|
|
86
|
-
if err != nil {
|
|
87
|
-
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("resolve external identity: %w", err))
|
|
88
|
-
}
|
|
89
|
-
return s.completeLogin(ctx, u, in.Session)
|
|
118
|
+
return identity, nil
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
func validPKCEVerifier(verifier, challenge string) bool {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/user/domain"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func (s *Service) ListIdentities(ctx context.Context, userID uuid.UUID) ([]IdentityResponse, error) {
|
|
15
|
+
items, err := s.repo.ListIdentities(ctx, userID)
|
|
16
|
+
if err != nil {
|
|
17
|
+
return nil, fmt.Errorf("list login identities: %w", err)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
out := make([]IdentityResponse, len(items))
|
|
21
|
+
for i := range items {
|
|
22
|
+
out[i] = ToIdentityResponse(items[i])
|
|
23
|
+
}
|
|
24
|
+
return out, nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func (s *Service) ExchangeIdentityLink(ctx context.Context, userID uuid.UUID, providerName string, in LoginExchangeInput) (*IdentityResponse, error) {
|
|
28
|
+
if userID == uuid.Nil {
|
|
29
|
+
return nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("authenticated user is required to link an identity"))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
identity, err := s.exchangeExternalIdentity(ctx, providerName, in, userID)
|
|
33
|
+
if err != nil {
|
|
34
|
+
return nil, err
|
|
35
|
+
}
|
|
36
|
+
return s.linkExternalIdentity(ctx, userID, identity)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func (s *Service) linkExternalIdentity(ctx context.Context, userID uuid.UUID, info ExternalIdentity) (*IdentityResponse, error) {
|
|
40
|
+
provider := domain.Provider(strings.TrimSpace(info.Provider))
|
|
41
|
+
if userID == uuid.Nil || provider == "" || strings.TrimSpace(info.Subject) == "" {
|
|
42
|
+
return nil, NewOAuthError(OAuthFailed, fmt.Errorf("external identity is incomplete"))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Replaying the same provider account from the same user is idempotent.
|
|
46
|
+
// A provider subject already owned by another user is a conflict and must
|
|
47
|
+
// never be silently moved between accounts.
|
|
48
|
+
if existing, err := s.repo.FindIdentityByProviderUID(ctx, provider, info.Subject); err == nil {
|
|
49
|
+
if existing.UserID != userID {
|
|
50
|
+
return nil, errIdentityConflict()
|
|
51
|
+
}
|
|
52
|
+
out := ToIdentityResponse(*existing)
|
|
53
|
+
return &out, nil
|
|
54
|
+
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
55
|
+
return nil, fmt.Errorf("find linked identity: %w", err)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The database permits one identity per provider for each user. Return a
|
|
59
|
+
// controlled conflict instead of exposing a persistence duplicate error.
|
|
60
|
+
if _, err := s.repo.FindIdentity(ctx, userID, provider); err == nil {
|
|
61
|
+
return nil, errIdentityAlreadyLinked()
|
|
62
|
+
} else if !errors.Is(err, domain.ErrNotFound) {
|
|
63
|
+
return nil, fmt.Errorf("check existing provider identity: %w", err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
providerUID := info.Subject
|
|
67
|
+
identity := &domain.Identity{
|
|
68
|
+
ID: uuid.New(), UserID: userID, Provider: provider, ProviderUID: &providerUID,
|
|
69
|
+
}
|
|
70
|
+
if err := s.repo.CreateIdentity(ctx, identity); err != nil {
|
|
71
|
+
if errors.Is(err, domain.ErrConflict) {
|
|
72
|
+
return nil, errIdentityAlreadyLinked()
|
|
73
|
+
}
|
|
74
|
+
return nil, fmt.Errorf("link identity: %w", err)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
out := ToIdentityResponse(*identity)
|
|
78
|
+
return &out, nil
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func (s *Service) UnlinkIdentity(ctx context.Context, userID uuid.UUID, providerName string) error {
|
|
82
|
+
provider := domain.Provider(strings.TrimSpace(providerName))
|
|
83
|
+
if userID == uuid.Nil || provider == "" {
|
|
84
|
+
return errIdentityNotFound()
|
|
85
|
+
}
|
|
86
|
+
if err := s.repo.DeleteIdentity(ctx, userID, provider); err != nil {
|
|
87
|
+
switch {
|
|
88
|
+
case errors.Is(err, domain.ErrLastIdentity):
|
|
89
|
+
return errLastIdentity()
|
|
90
|
+
case errors.Is(err, domain.ErrNotFound):
|
|
91
|
+
return errIdentityNotFound()
|
|
92
|
+
default:
|
|
93
|
+
return fmt.Errorf("unlink identity: %w", err)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return nil
|
|
97
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package application
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/user/domain"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestService_IdentityLinkBindsOAuthTransactionToCaller(t *testing.T) {
|
|
14
|
+
provider, svc := validFakeProvider(t)
|
|
15
|
+
userID := uuid.New()
|
|
16
|
+
otherUserID := uuid.New()
|
|
17
|
+
|
|
18
|
+
if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
|
|
19
|
+
t.Fatalf("begin identity link: %v", err)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_, err := svc.ExchangeIdentityLink(context.Background(), otherUserID, provider.Name(), validLoginExchange())
|
|
23
|
+
if got := oauthErrorCode(t, err); got != OAuthStateInvalid {
|
|
24
|
+
t.Fatalf("oauth code = %q, want %q", got, OAuthStateInvalid)
|
|
25
|
+
}
|
|
26
|
+
if provider.completeCnt != 0 {
|
|
27
|
+
t.Fatal("provider must not receive a transaction bound to another user")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestService_IdentityLinkIsIdempotentForSameProviderSubject(t *testing.T) {
|
|
32
|
+
provider, svc := validFakeProvider(t)
|
|
33
|
+
userID := uuid.New()
|
|
34
|
+
|
|
35
|
+
if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
|
|
36
|
+
t.Fatalf("begin first identity link: %v", err)
|
|
37
|
+
}
|
|
38
|
+
first, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
|
|
39
|
+
if err != nil {
|
|
40
|
+
t.Fatalf("exchange first identity link: %v", err)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if _, err := svc.BeginIdentityLink(context.Background(), userID, provider.Name(), validLoginStart()); err != nil {
|
|
44
|
+
t.Fatalf("begin repeated identity link: %v", err)
|
|
45
|
+
}
|
|
46
|
+
second, err := svc.ExchangeIdentityLink(context.Background(), userID, provider.Name(), validLoginExchange())
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatalf("exchange repeated identity link: %v", err)
|
|
49
|
+
}
|
|
50
|
+
if first.ID != second.ID || first.Provider != second.Provider {
|
|
51
|
+
t.Fatalf("repeated link returned a different identity: first=%+v second=%+v", first, second)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func TestService_UnlinkIdentityRejectsRemovingLastLoginMethod(t *testing.T) {
|
|
56
|
+
userID := uuid.New()
|
|
57
|
+
providerUID := "local-user"
|
|
58
|
+
repo := &fakeRepo{}
|
|
59
|
+
repo.identities = append(repo.identities, domain.Identity{
|
|
60
|
+
ID: uuid.New(), UserID: userID, Provider: domain.ProviderLocal, ProviderUID: &providerUID,
|
|
61
|
+
})
|
|
62
|
+
svc := newTestService(repo, newFakeTokenStore())
|
|
63
|
+
|
|
64
|
+
err := svc.UnlinkIdentity(context.Background(), userID, string(domain.ProviderLocal))
|
|
65
|
+
var ruleErr *domain.RuleError
|
|
66
|
+
if !errors.As(err, &ruleErr) || ruleErr.Code != "AUTH_LAST_IDENTITY" {
|
|
67
|
+
t.Fatalf("unlink last identity error = %v, want AUTH_LAST_IDENTITY", err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -118,6 +118,10 @@ func (s *Service) SetRole(ctx context.Context, userID uuid.UUID, roleCode string
|
|
|
118
118
|
// ServicePort is the inbound application boundary. HTTP, jobs, and future
|
|
119
119
|
// transports depend on this capability set instead of the concrete service.
|
|
120
120
|
type ServicePort interface {
|
|
121
|
+
ListIdentities(context.Context, uuid.UUID) ([]IdentityResponse, error)
|
|
122
|
+
BeginIdentityLink(context.Context, uuid.UUID, string, LoginStartInput) (*Authorization, error)
|
|
123
|
+
ExchangeIdentityLink(context.Context, uuid.UUID, string, LoginExchangeInput) (*IdentityResponse, error)
|
|
124
|
+
UnlinkIdentity(context.Context, uuid.UUID, string) error
|
|
121
125
|
// go-scaffold:service-interface
|
|
122
126
|
Register(context.Context, RegisterInput) (*AuthResult, error)
|
|
123
127
|
Login(context.Context, LoginInput) (*AuthResult, error)
|
|
@@ -334,6 +334,7 @@ func (f *fakeMFAStore) ConsumeRecoveryCode(_ context.Context, userID uuid.UUID,
|
|
|
334
334
|
type fakeRepo struct {
|
|
335
335
|
user *domain.User
|
|
336
336
|
identity *domain.Identity
|
|
337
|
+
identities []domain.Identity
|
|
337
338
|
updateIdentityErr error
|
|
338
339
|
updateUserErr error
|
|
339
340
|
failures map[string]int
|
|
@@ -387,20 +388,61 @@ func (f *fakeRepo) UpdateUser(_ context.Context, u *domain.User) error {
|
|
|
387
388
|
return nil
|
|
388
389
|
}
|
|
389
390
|
func (f *fakeRepo) FindAll(context.Context, int, int) ([]domain.User, error) { return nil, nil }
|
|
390
|
-
|
|
391
|
-
|
|
391
|
+
|
|
392
|
+
func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) (*domain.Identity, error) {
|
|
393
|
+
for i := range f.identities {
|
|
394
|
+
if f.identities[i].UserID == userID && f.identities[i].Provider == provider {
|
|
395
|
+
copy := f.identities[i]
|
|
396
|
+
return ©, nil
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider {
|
|
392
400
|
copy := *f.identity
|
|
393
401
|
return ©, nil
|
|
394
402
|
}
|
|
395
403
|
return nil, domain.ErrNotFound
|
|
396
404
|
}
|
|
397
|
-
func (f *fakeRepo)
|
|
405
|
+
func (f *fakeRepo) ListIdentities(_ context.Context, userID uuid.UUID) ([]domain.Identity, error) {
|
|
406
|
+
out := make([]domain.Identity, 0, len(f.identities)+1)
|
|
407
|
+
for _, identity := range f.identities {
|
|
408
|
+
if identity.UserID == userID {
|
|
409
|
+
out = append(out, identity)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if f.identity != nil && f.identity.UserID == userID {
|
|
413
|
+
found := false
|
|
414
|
+
for _, identity := range out {
|
|
415
|
+
if identity.ID == f.identity.ID {
|
|
416
|
+
found = true
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if !found {
|
|
421
|
+
out = append(out, *f.identity)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return out, nil
|
|
425
|
+
}
|
|
426
|
+
func (f *fakeRepo) FindIdentityByProviderUID(_ context.Context, provider domain.Provider, providerUID string) (*domain.Identity, error) {
|
|
427
|
+
for i := range f.identities {
|
|
428
|
+
if f.identities[i].Provider == provider && f.identities[i].ProviderUID != nil && *f.identities[i].ProviderUID == providerUID {
|
|
429
|
+
copy := f.identities[i]
|
|
430
|
+
return ©, nil
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if f.identity != nil && f.identity.Provider == provider && f.identity.ProviderUID != nil && *f.identity.ProviderUID == providerUID {
|
|
434
|
+
copy := *f.identity
|
|
435
|
+
return ©, nil
|
|
436
|
+
}
|
|
398
437
|
return nil, domain.ErrNotFound
|
|
399
438
|
}
|
|
400
439
|
func (f *fakeRepo) CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error {
|
|
401
440
|
return nil
|
|
402
441
|
}
|
|
403
|
-
func (f *fakeRepo) CreateIdentity(context.Context, *domain.Identity) error {
|
|
442
|
+
func (f *fakeRepo) CreateIdentity(_ context.Context, identity *domain.Identity) error {
|
|
443
|
+
f.identities = append(f.identities, *identity)
|
|
444
|
+
return nil
|
|
445
|
+
}
|
|
404
446
|
func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
|
|
405
447
|
if f.updateIdentityErr != nil {
|
|
406
448
|
return f.updateIdentityErr
|
|
@@ -408,6 +450,35 @@ func (f *fakeRepo) UpdateIdentity(_ context.Context, i *domain.Identity) error {
|
|
|
408
450
|
f.identity = i
|
|
409
451
|
return nil
|
|
410
452
|
}
|
|
453
|
+
func (f *fakeRepo) DeleteIdentity(_ context.Context, userID uuid.UUID, provider domain.Provider) error {
|
|
454
|
+
count := 0
|
|
455
|
+
target := -1
|
|
456
|
+
for i := range f.identities {
|
|
457
|
+
if f.identities[i].UserID != userID {
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
count++
|
|
461
|
+
if f.identities[i].Provider == provider {
|
|
462
|
+
target = i
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
legacyTarget := f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider
|
|
466
|
+
if legacyTarget {
|
|
467
|
+
count++
|
|
468
|
+
}
|
|
469
|
+
if target < 0 && !legacyTarget {
|
|
470
|
+
return domain.ErrNotFound
|
|
471
|
+
}
|
|
472
|
+
if count <= 1 {
|
|
473
|
+
return domain.ErrLastIdentity
|
|
474
|
+
}
|
|
475
|
+
if target >= 0 {
|
|
476
|
+
f.identities = append(f.identities[:target], f.identities[target+1:]...)
|
|
477
|
+
} else {
|
|
478
|
+
f.identity = nil
|
|
479
|
+
}
|
|
480
|
+
return nil
|
|
481
|
+
}
|
|
411
482
|
|
|
412
483
|
// go-scaffold:user-fake-repo-methods
|
|
413
484
|
// go-scaffold:repository-stub-methods
|
|
@@ -15,7 +15,9 @@ var (
|
|
|
15
15
|
ErrMFAAlreadyEnabled = errors.New("MFA is already enabled")
|
|
16
16
|
ErrMFANotEnrolled = errors.New("MFA is not enrolled")
|
|
17
17
|
ErrMFASetupRequired = errors.New("MFA setup is required")
|
|
18
|
-
ErrUnknownRole
|
|
18
|
+
ErrUnknownRole = errors.New("unknown role")
|
|
19
|
+
ErrIdentityAlreadyLinked = errors.New("login identity is already linked")
|
|
20
|
+
ErrLastIdentity = errors.New("cannot remove the last login identity")
|
|
19
21
|
)
|
|
20
22
|
|
|
21
23
|
type RuleError struct {
|
|
@@ -16,10 +16,12 @@ type UserRepository interface {
|
|
|
16
16
|
UpdateUser(context.Context, *domain.User) error
|
|
17
17
|
FindAll(context.Context, int, int) ([]domain.User, error)
|
|
18
18
|
FindIdentity(context.Context, uuid.UUID, domain.Provider) (*domain.Identity, error)
|
|
19
|
+
ListIdentities(context.Context, uuid.UUID) ([]domain.Identity, error)
|
|
19
20
|
FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error)
|
|
20
21
|
CreateUserWithIdentity(context.Context, *domain.User, *domain.Identity) error
|
|
21
22
|
CreateIdentity(context.Context, *domain.Identity) error
|
|
22
23
|
UpdateIdentity(context.Context, *domain.Identity) error
|
|
24
|
+
DeleteIdentity(context.Context, uuid.UUID, domain.Provider) error
|
|
23
25
|
LoginLockedUntil(context.Context, string) (time.Time, error)
|
|
24
26
|
RecordLoginFailure(context.Context, string, int, time.Duration) error
|
|
25
27
|
ClearLoginFailures(context.Context, string) error
|
|
@@ -65,6 +67,7 @@ type RefreshTokenStore interface {
|
|
|
65
67
|
|
|
66
68
|
type LoginTransaction struct {
|
|
67
69
|
Provider string `json:"provider"`
|
|
70
|
+
UserID uuid.UUID `json:"user_id"`
|
|
68
71
|
CodeChallenge string `json:"code_challenge"`
|
|
69
72
|
Nonce string `json:"nonce"`
|
|
70
73
|
ExpiresAt time.Time `json:"expires_at"`
|
|
@@ -260,6 +260,15 @@ boundary. For browser OAuth, preserve this client-owned callback contract:
|
|
|
260
260
|
`Pragma: no-cache`.
|
|
261
261
|
Never accept a request-supplied redirect destination or place an access token,
|
|
262
262
|
refresh token, authorization code, or state in a URI.
|
|
263
|
+
- Authenticated identity management is a separate account flow, not another
|
|
264
|
+
login endpoint: list safe metadata at `GET /users/me/identities`, start and
|
|
265
|
+
finish linking at `POST /users/me/identities/:provider/link` and
|
|
266
|
+
`POST /users/me/identities/:provider/link/exchange`, and unlink with
|
|
267
|
+
`DELETE /users/me/identities/:provider`. Bind the server-side transaction to
|
|
268
|
+
the caller's user ID, never issue or replace a session during link exchange,
|
|
269
|
+
keep one identity per provider per user, and reject unlinking the last login
|
|
270
|
+
method. Provider subjects and password hashes must never cross the HTTP
|
|
271
|
+
boundary.
|
|
263
272
|
- Map provider and validation failures to controlled public codes only:
|
|
264
273
|
`oauth_denied`, `oauth_state_invalid`, `oauth_provider_unavailable`, and
|
|
265
274
|
`oauth_failed`. Do not expose raw provider descriptions or technical causes.
|
|
@@ -186,6 +186,14 @@ approval before changing their contract.
|
|
|
186
186
|
rotation; responses expose only User-Agent and lifecycle timestamps, never
|
|
187
187
|
refresh tokens or token hashes. The current access token carries the session
|
|
188
188
|
ID as `sid` so the adapter can mark the current device.
|
|
189
|
+
- Authenticated users can manage login identities through
|
|
190
|
+
`GET /users/me/identities`, `POST /users/me/identities/:provider/link`,
|
|
191
|
+
`POST /users/me/identities/:provider/link/exchange`, and
|
|
192
|
+
`DELETE /users/me/identities/:provider`. The link transaction is bound to
|
|
193
|
+
the current user as well as provider/state/PKCE/nonce; it can never create a
|
|
194
|
+
session or silently move a provider account between users. Return only safe
|
|
195
|
+
provider metadata, allow at most one identity per provider per user, and
|
|
196
|
+
refuse to unlink the last remaining login method.
|
|
189
197
|
- Password reset and email verification consume a one-time token in the same
|
|
190
198
|
retry-safe transaction as the user/identity update. A failed post-commit
|
|
191
199
|
session revocation must not make a successful reset impossible to retry.
|
|
@@ -198,6 +198,11 @@ Authenticated users can list active device sessions with
|
|
|
198
198
|
`GET /users/me/sessions` and revoke an individual session with
|
|
199
199
|
`DELETE /users/me/sessions/:id`; only User-Agent and session lifecycle
|
|
200
200
|
timestamps are returned.
|
|
201
|
+
They can also list safe login-provider metadata with
|
|
202
|
+
`GET /users/me/identities`, link a provider through the authenticated
|
|
203
|
+
`/users/me/identities/:provider/link` start/exchange flow, and unlink a
|
|
204
|
+
provider with `DELETE /users/me/identities/:provider`; the last login method
|
|
205
|
+
cannot be removed.
|
|
201
206
|
For `cross-site`, configure HTTPS origins, `COOKIE_SAMESITE=none`,
|
|
202
207
|
`COOKIE_SECURE=true`, and an exact allowed Origin; the API applies an Origin
|
|
203
208
|
guard independently of CORS.
|
|
@@ -60,7 +60,7 @@ internal/
|
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Installed process and platform additions:
|
|
63
|
-
{{#if auth}}- `cmd/seed` and `platform/authprovider/google/` for user bootstrap
|
|
63
|
+
{{#if auth}}- `cmd/seed` and `platform/authprovider/google/` for user bootstrap, Google OAuth/OIDC login, and authenticated identity linking
|
|
64
64
|
{{/if}}{{#if worker}}- `cmd/worker` and `platform/{mail,queue}/` for queue/SMTP jobs{{#if (eq queue "asynq")}}, plus `platform/cache/` for Redis{{/if}}
|
|
65
65
|
{{/if}}{{#if observability}}- `platform/telemetry/` for the OTLP exporter
|
|
66
66
|
{{/if}}{{#if rbac}}- `shared/middleware/authz.go` and `app/role/` for RBAC policy
|
|
@@ -73,10 +73,10 @@ a real external system.
|
|
|
73
73
|
add a `shared/utils` or similarly generic package — name it after what it
|
|
74
74
|
actually does.
|
|
75
75
|
|
|
76
|
-
Optional feature boundaries are explicit: auth owns user authentication
|
|
77
|
-
provider ports; RBAC owns role policy and authorization;
|
|
78
|
-
mail adapters; observability owns metrics/tracing setup.
|
|
79
|
-
second domain architecture.
|
|
76
|
+
Optional feature boundaries are explicit: auth owns user authentication,
|
|
77
|
+
identity linking, and provider ports; RBAC owns role policy and authorization;
|
|
78
|
+
worker owns queue and mail adapters; observability owns metrics/tracing setup.
|
|
79
|
+
They do not create a second domain architecture.
|
|
80
80
|
|
|
81
81
|
## 3. API Style
|
|
82
82
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
| Logging | `log/slog`, JSON handler |
|
|
16
16
|
| Migrations | [golang-migrate](https://github.com/golang-migrate/migrate) |
|
|
17
17
|
| Testing | stdlib `testing`, real Postgres for integration tests |
|
|
18
|
-
| Authentication | {{#if auth}}JWT access/refresh sessions, password recovery, email verification, provider login, and MFA{{else}}optional via `go-scaffold add auth`{{/if}} |
|
|
18
|
+
| Authentication | {{#if auth}}JWT access/refresh sessions, password recovery, email verification, provider login/linking, and MFA{{else}}optional via `go-scaffold add auth`{{/if}} |
|
|
19
19
|
| Authorization | {{#if rbac}}RBAC role/permission middleware{{else}}optional RBAC via `go-scaffold add rbac`{{/if}} |
|
|
20
20
|
| Background jobs | {{#if worker}}{{queue}} queue backend with SMTP mail and `cmd/worker`{{else}}optional via `go-scaffold add worker`{{/if}} |
|
|
21
21
|
|