@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.
Files changed (28) hide show
  1. package/dist/commands/auth.js +7 -2
  2. package/dist/templates/auth-manifest.js +3 -0
  3. package/package.json +1 -1
  4. package/templates/add/auth/docs/schemas.yaml.hbs +35 -0
  5. package/templates/add/auth/docs/users-me-identities.yaml.hbs +13 -0
  6. package/templates/add/auth/docs/users-me-identity-link-exchange.yaml.hbs +26 -0
  7. package/templates/add/auth/docs/users-me-identity-link.yaml.hbs +25 -0
  8. package/templates/add/auth/docs/users-me-identity.yaml.hbs +15 -0
  9. package/templates/add/auth/internal/app/user/adapters/inbound/http/dto.go.hbs +38 -0
  10. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +5 -1
  11. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_identity.go.hbs +80 -0
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler_test.go.hbs +20 -0
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +43 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/tokenstore_pg.go.hbs +4 -2
  15. package/templates/add/auth/internal/app/user/application/dto.go.hbs +14 -0
  16. package/templates/add/auth/internal/app/user/application/errors.go.hbs +16 -0
  17. package/templates/add/auth/internal/app/user/application/external_login.go.hbs +42 -13
  18. package/templates/add/auth/internal/app/user/application/identities.go.hbs +97 -0
  19. package/templates/add/auth/internal/app/user/application/identities_test.go.hbs +69 -0
  20. package/templates/add/auth/internal/app/user/application/service.go.hbs +4 -0
  21. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +75 -4
  22. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +3 -1
  23. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +3 -0
  24. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +9 -0
  25. package/templates/create/base/AGENTS.md.hbs +8 -0
  26. package/templates/create/base/README.md.hbs +5 -0
  27. package/templates/create/features/docs/architecture.md.hbs +5 -5
  28. package/templates/create/features/docs/techstack.md.hbs +1 -1
@@ -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, DELETE /users/me/sessions/{id}, " +
185
- "GET /users/me/mfa, and POST /auth/mfa/verify in cmd/api/wiring.go" +
188
+ "GET /users/me/{identities,sessions,mfa}, POST /users/me/identities/{provider}/{link,link/exchange}, " +
189
+ "DELETE /users/me/identities/{provider}, DELETE /users/me/sessions/{id}, " +
190
+ "POST /auth/mfa/verify in cmd/api/wiring.go" +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nakedev/go-scaffold",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Scaffold Gin + GORM + Postgres Go backend projects with a consistent domain-module standard",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 {
@@ -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": uuid.Nil,
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 nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("provider %q is not configured", providerName))
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 nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
97
+ return ExternalIdentity{}, NewOAuthError(OAuthStateInvalid, fmt.Errorf("state and code verifier are required"))
65
98
  }
66
99
  if !validOAuthValue(in.Code) {
67
- return nil, NewOAuthError(OAuthFailed, fmt.Errorf("authorization code is missing"))
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 nil, NewOAuthError(OAuthProviderUnavailable, fmt.Errorf("consume oauth transaction: %w", err))
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 nil, NewOAuthError(OAuthStateInvalid, fmt.Errorf("oauth state or PKCE verifier is invalid"))
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 nil, mapProviderError(err)
113
+ return ExternalIdentity{}, mapProviderError(err)
81
114
  }
82
115
  if identity.Provider != providerName {
83
- return nil, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
116
+ return ExternalIdentity{}, NewOAuthError(OAuthFailed, fmt.Errorf("provider identity name does not match the requested provider"))
84
117
  }
85
- u, err := s.findOrCreateExternalUser(ctx, identity)
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
- func (f *fakeRepo) FindIdentity(_ context.Context, userID uuid.UUID, _ domain.Provider) (*domain.Identity, error) {
391
- if f.identity != nil && f.identity.UserID == userID {
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 &copy, nil
397
+ }
398
+ }
399
+ if f.identity != nil && f.identity.UserID == userID && f.identity.Provider == provider {
392
400
  copy := *f.identity
393
401
  return &copy, nil
394
402
  }
395
403
  return nil, domain.ErrNotFound
396
404
  }
397
- func (f *fakeRepo) FindIdentityByProviderUID(context.Context, domain.Provider, string) (*domain.Identity, error) {
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 &copy, 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 &copy, 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 { return nil }
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 = errors.New("unknown role")
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 and Google OAuth/OIDC
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 and
77
- provider ports; RBAC owns role policy and authorization; worker owns queue and
78
- mail adapters; observability owns metrics/tracing setup. They do not create a
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