Skip to content

feat(auth): add multi-provider account binding#73

Open
XDfield wants to merge 1 commit into
mainfrom
feat/casdoor-identity-normalization
Open

feat(auth): add multi-provider account binding#73
XDfield wants to merge 1 commit into
mainfrom
feat/casdoor-identity-normalization

Conversation

@XDfield
Copy link
Copy Markdown
Owner

@XDfield XDfield commented Apr 27, 2026

Summary

  • add explicit multi-provider account binding with user_auth_identities, bind/unbind APIs, and provider-priority profile aggregation
  • backfill auth identity records from existing users and harden bind callback state validation with signature and expiry checks
  • document the binding design and track implementation progress under docs/ and todo/

Testing

  • targeted auth handler tests for bind state, bind callback success/failure, identities listing, unbind guard, and auth me
  • go test ./server/internal/user ./server/cmd/migrate
  • go run .\cmd\migrate\main.go

Summary by CodeRabbit

  • New Features

    • Multi-provider account binding: Users can now bind and manage multiple authentication providers to a single account.
    • Identity management: New endpoints to list all bound identities and unbind provider accounts with safeguards preventing removal of the last identity.
    • Automatic primary identity selection based on provider ranking.
  • Documentation

    • Added comprehensive design specification for multi-provider account binding system.
    • Added implementation progress tracking documentation.

Introduce user auth identities, explicit bind/unbind flows, and provider-priority profile aggregation so multiple Casdoor login methods can resolve to one local account safely.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

Introduces multi-provider account binding with a new user_auth_identities table to persist external authentication identities linked to users. Adds API endpoints for initiating binding, handling OAuth callbacks with signed state validation, listing bound identities, and unbinding. Service layer implements identity management with provider-based primary identity selection and profile aggregation from ranked identities.

Changes

Cohort / File(s) Summary
Design & Documentation
docs/proposals/MULTI_PROVIDER_ACCOUNT_BINDING_DESIGN.md, todo/MULTI_PROVIDER_ACCOUNT_BINDING_PROGRESS.md, docs/proposals/casdoor-identity-normalization/05-service-and-flow.md
Added comprehensive design documentation for explicit multi-provider binding with provider ranking, primary identity upgrade rules, binding state/callback flow, conflict handling, and phased implementation plan. Updated example phone value in semantic validation documentation.
Database Schema & Migration
server/cmd/migrate/main.go, server/cmd/migrate/main_test.go, server/internal/handlers/registry_test.go
Adds migration backfill for user_auth_identities table, deriving external_key from legacy user fields and Casdoor metadata with fallback logic. Includes tests verifying backfill count and schema creation for identity persistence.
Data Model
server/internal/models/models.go
Introduces UserAuthIdentity struct with indexed ExternalKey (unique), UserSubjectID (indexed), provider/issuer metadata, identity attributes, IsPrimary flag, and soft-delete support.
API Route Registration
server/cmd/api/main.go
Registers four new authenticated routes: identity listing (GET /api/auth/identities), binding initiation (POST /api/auth/bind/start), callback handler (GET /api/auth/bind/callback), and unbinding (POST /api/auth/identities/:id/unbind).
Handler Implementation
server/internal/handlers/handlers.go, server/internal/handlers/handlers_test.go
Implements binding flow with HMAC-SHA256 signed state encoding/decoding and expiry validation. Adds StartBindAuth (generates signed state, returns provider login URL), callback bind interception (validates expiry/session/provider consistency), ListBoundIdentities, and UnbindIdentity with last-identity protection. Injects OAuth exchange/userinfo functions for testability. Includes comprehensive test coverage for state validation, provider mismatch rejection, and duplicate-binding prevention.
Service Layer
server/internal/user/service.go, server/internal/user/service_test.go
Adds identity lifecycle methods: ListUserIdentities, BindIdentityToUser (transaction-based with provider-ranked primary selection), UnbindIdentity (with re-selection of best primary). Implements identity-to-profile refresh recalculating user fields from ranked identities. Updates GetOrCreateUser to resolve users via UserAuthIdentity table and bind identities on creation. Includes tests verifying primary promotion rules and unbind re-selection logic.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Handler as Auth Handler
    participant Service as User Service
    participant DB as Database
    participant OAuth as OAuth Provider

    rect rgba(100, 150, 255, 0.5)
    Note over Client,OAuth: Initiate Binding (StartBindAuth)
    Client->>Handler: POST /api/auth/bind/start?provider=github&redirectUrl=...
    Handler->>Handler: Generate signed bindState (HMAC-SHA256)
    Handler->>OAuth: Build login URL with signed state
    Handler-->>Client: 302 Redirect to OAuth login
    end

    rect rgba(100, 200, 100, 0.5)
    Note over Client,OAuth: OAuth Callback
    OAuth-->>Client: Redirect to /api/auth/bind/callback?code=...&state=signed
    Client->>Handler: GET /api/auth/bind/callback?code=...&state=...
    Handler->>Handler: Decode & validate signed state (expiry, session)
    Handler->>OAuth: Exchange code for token
    OAuth-->>Handler: Access token & user info
    Handler->>Handler: Extract provider identity claims
    end

    rect rgba(200, 150, 100, 0.5)
    Note over Handler,DB: Bind Identity to User
    Handler->>Service: BindIdentityToUser(userID, claims)
    Service->>DB: Check for existing identity
    Service->>DB: Insert new UserAuthIdentity
    Service->>Service: Select primary by provider rank
    Service->>DB: Update is_primary flags
    Service->>Service: Refresh user profile from ranked identities
    Service->>DB: Update user fields (email, avatar, etc.)
    Service-->>Handler: Success
    Handler-->>Client: 302 Redirect with success
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The PR spans multiple architectural layers (models, handlers, service, migration) with significant new logic including HMAC-based state signing/validation, provider-ranked primary identity selection, identity-to-profile aggregation, and complex backfill migration with conditional key derivation from legacy fields. The handler and service implementations require careful review of security constraints, transaction boundaries, and provider ranking determinism.

Possibly related PRs

  • Feat/usage #43: Extends AuthCallback handler and integrates UserModule.GetOrCreateUser into OAuth flow, foundational work for multi-provider identity binding.

Poem

🐰 Hop-hop, identities now bind!
Multiple providers, one user to find,
With HMAC-signed states and ranks so neat,
Primary selection makes hearts complete!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(auth): add multi-provider account binding' clearly and concisely summarizes the main feature addition across the pull request—implementing explicit multi-provider account binding with new models, endpoints, and logic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/casdoor-identity-normalization
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/casdoor-identity-normalization

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.11.4)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain modules listed in go.work or their selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (9)
server/internal/handlers/handlers_test.go (1)

22-34: Cache the RSA key to avoid generating one per test call.

signHandlersTestJWT is invoked from several test cases (TestBindCallbackRejectsProviderMismatch, TestBindCallbackSuccess, TestBindCallbackRejectsIdentityAlreadyBound, often twice per test for current/bound tokens). rsa.GenerateKey(rand.Reader, 2048) is ~100–300ms each, which adds up quickly on CI. Since the signature is not asserted against a JWKS in these tests (claims are just parsed), a single shared key is sufficient.

♻️ Suggested change
+var (
+	handlersTestRSAKeyOnce sync.Once
+	handlersTestRSAKey     *rsa.PrivateKey
+)
+
 func signHandlersTestJWT(t *testing.T, claims jwt.MapClaims) string {
 	t.Helper()
-	key, err := rsa.GenerateKey(rand.Reader, 2048)
-	if err != nil {
-		t.Fatalf("generate rsa key: %v", err)
-	}
+	handlersTestRSAKeyOnce.Do(func() {
+		key, err := rsa.GenerateKey(rand.Reader, 2048)
+		if err != nil {
+			t.Fatalf("generate rsa key: %v", err)
+		}
+		handlersTestRSAKey = key
+	})
 	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
-	tokenString, err := token.SignedString(key)
+	tokenString, err := token.SignedString(handlersTestRSAKey)
 	if err != nil {
 		t.Fatalf("sign jwt: %v", err)
 	}
 	return tokenString
 }

(Don't forget to add "sync" to the imports.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/handlers/handlers_test.go` around lines 22 - 34, The test
helper signHandlersTestJWT currently generates a new RSA key on every call which
is slow; change it to use a package-level cached *rsa.PrivateKey initialized
once (e.g. via sync.Once or a sync.Mutex-protected variable) so subsequent calls
reuse the same key, add "sync" to imports, and update signHandlersTestJWT to use
that cached key instead of calling rsa.GenerateKey each invocation (keep
function name signHandlersTestJWT and the existing error handling).
docs/proposals/MULTI_PROVIDER_ACCOUNT_BINDING_DESIGN.md (1)

41-41: Markdown heading levels inconsistent for subsections.

Subsections like ## 3.1, ## 3.2, ## 4.1, ## 4.2, ## 4.3, ## 5-derived ## 5.1/## 6.1/etc., and ## 7.1/## 7.2/## 8.1-## 8.4/## 9-children/## 10.1-## 10.7/## 11.1-## 11.3/## 12.1-## 12.4 are written at H2 instead of H3. As written they appear at the same outline level as their parent sections (e.g., ## 4.1 现有 users 表定位调整 is rendered as a sibling of ## 4. 数据模型设计), which makes the TOC and document structure misleading.

Recommend bumping these subsection headings to ### so they nest under the H2 parent.

Also applies to: 81-81, 100-100, 129-129, 175-175, 193-193, 243-243, 255-255, 267-267, 291-291, 311-311, 328-328, 358-358, 365-365, 373-373, 381-381, 389-389, 397-397, 404-404, 416-416, 420-420, 442-442, 452-452, 456-456, 464-464, 472-472

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/proposals/MULTI_PROVIDER_ACCOUNT_BINDING_DESIGN.md` at line 41, Several
subsection headings are at H2 (e.g., "## 3.1 采用显式绑定", "## 4.1 现有 users 表定位调整",
and the other listed headings) but should be nested under their H2 parents;
change each of the indicated headings from "##" to "###" so they become H3
(e.g., update "## 3.1 采用显式绑定" → "### 3.1 采用显式绑定", "## 4.1 现有 users 表定位调整" → "###
4.1 现有 users 表定位调整", and similarly for the headings at the other reported
locations) to restore correct document outline and TOC nesting.
server/internal/models/models.go (1)

576-600: Note: is_primary uniqueness is enforced only at application level.

The design doc (section 4.3 #3) requires "at most one is_primary=true per user_subject_id", but the model and migration enforce this only via RefreshUserProfileFromIdentities rather than a partial unique index. That's a defensible choice (avoids transactional dance during primary swaps), but it does mean any code path that mutates is_primary outside that single function — including the new backfill in server/cmd/migrate/main.go which always writes "is_primary": true — must be careful not to create duplicates.

For the backfill specifically: since each user can only contribute one identity (it short-circuits after the first row per user), the invariant holds today. Just worth a comment near the IsPrimary field or near RefreshUserProfileFromIdentities documenting that this is the sole legitimate writer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/models/models.go` around lines 576 - 600, The IsPrimary
boolean on UserAuthIdentity is only protected by application logic
(RefreshUserProfileFromIdentities) so add a clear comment on the IsPrimary field
in models.go noting that RefreshUserProfileFromIdentities is the sole legitimate
writer and that other code must not set is_primary directly; then update the
migration/backfill in server/cmd/migrate/main.go to avoid writing "is_primary":
true blindly (either call RefreshUserProfileFromIdentities after backfilling a
user's identities or ensure the backfill only marks the first identity per user
as primary) so the one-primary-per-user invariant is preserved.
server/cmd/migrate/main_test.go (1)

128-147: Strengthen TestBackfillUserAuthIdentities assertions beyond row count.

The backfill in backfillUserAuthIdentities (server/cmd/migrate/main.go) does non-trivial work: lower-casing AuthProvider, prioritizing ExternalKey over casdoor_universal_id/casdoor_sub/casdoor_id, and marking the row is_primary=true. Asserting only count == 1 means a regression that flips is_primary, picks the wrong external_key derivation, or stores the wrong provider would still pass.

♻️ Suggested additional assertions
 	var count int64
 	if err := db.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", "u1").Count(&count).Error; err != nil {
 		t.Fatalf("count identities: %v", err)
 	}
 	if count != 1 {
 		t.Fatalf("expected 1 backfilled identity, got %d", count)
 	}
+	var got models.UserAuthIdentity
+	if err := db.Where("user_subject_id = ?", "u1").First(&got).Error; err != nil {
+		t.Fatalf("load identity: %v", err)
+	}
+	if got.Provider != "github" || got.ExternalKey != "casdoor:uuid-1" || !got.IsPrimary {
+		t.Fatalf("unexpected backfilled identity: %+v", got)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/cmd/migrate/main_test.go` around lines 128 - 147, Update
TestBackfillUserAuthIdentities to validate the actual backfilled row contents,
not just the count: after calling backfillUserAuthIdentities, SELECT the created
models.UserAuthIdentity for user_subject_id "u1" and assert that AuthProvider is
lower-cased (matches strings.ToLower of the source), ExternalKey equals the
original ExternalKey value (and if ExternalKey is nil test a case where it falls
back to CasdoorUniversalID/CasdoorSub/CasdoorID), ProviderUserID matches
ProviderUserID from the User, and IsPrimary is true; also assert there is
exactly one identity with IsPrimary=true for that user to guard against
duplicate primaries. Use the existing functions/structs
backfillUserAuthIdentities and models.UserAuthIdentity to locate the code to
change.
server/cmd/migrate/main.go (2)

187-192: Optional: expose user-auth-identities [--dry-run] as a subcommand.

Other backfills (user-subject-ids, user-external-identities) have dedicated CLI subcommands with --dry-run support, which is helpful for ops to preview impact in production. Since backfillUserAuthIdentities already accepts dryRun bool and supports errDryRunRollback, adding a subcommand here is mostly wiring.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/cmd/migrate/main.go` around lines 187 - 192, Add a new
"user-auth-identities" subcommand to the migrate CLI that mirrors the pattern
used by "user-subject-ids" and "user-external-identities": parse a --dry-run
boolean flag, call backfillUserAuthIdentities(db, dryRun), treat
errDryRunRollback specially (do not treat it as a real error when dryRun is
true), and log/exit with the same messaging and exit codes as the other backfill
subcommands; ensure the command name, help text, and flag wiring are added to
the main migrate command registration so ops can run "user-auth-identities
--dry-run" to preview changes.

358-376: Defensive: gate column access with Migrator().HasColumn for parity with backfillUserExternalIdentities.

backfillUserExternalIdentities (lines 272–289) carefully checks HasColumn for phone, auth_provider, external_key, and provider_user_id before selecting them, because they were added by ensureUserIdentityColumns and may be absent in older snapshots. backfillUserAuthIdentities selects all of these unconditionally. Today the call order in main() (line 177 → 187 → 190) makes this safe, but a future refactor that runs the backfill standalone (e.g., adding a CLI subcommand) would silently fail with a SQL error. Consider mirroring the defensive pattern, or at minimum documenting the ordering invariant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/cmd/migrate/main.go` around lines 358 - 376,
backfillUserAuthIdentities is selecting columns (phone, auth_provider,
external_key, provider_user_id) that may not exist in older schemas; mirror the
defensive pattern used in backfillUserExternalIdentities by using
db.Migrator().HasColumn checks before including each optional column in the
SELECT, or otherwise gate the whole backfill behind ensureUserIdentityColumns;
update the Select(...) construction in backfillUserAuthIdentities to
conditionally append these column names only if HasColumn reports true (refer to
Migrator().HasColumn, backfillUserExternalIdentities, and
ensureUserIdentityColumns to match behavior).
server/internal/handlers/handlers.go (2)

647-651: Use strconv.ParseUint instead of fmt.Sscanf for the path id.

fmt.Sscanf(c.Param("id"), "%d", &identityID) accepts inputs like "5abc" and parses to 5, ignoring trailing garbage. strconv.ParseUint(c.Param("id"), 10, 64) (or gin's c.GetUint) is stricter and idiomatic.

♻️ Proposed fix
-	var identityID uint
-	if _, err := fmt.Sscanf(c.Param("id"), "%d", &identityID); err != nil || identityID == 0 {
+	idVal, err := strconv.ParseUint(c.Param("id"), 10, 64)
+	if err != nil || idVal == 0 {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid identity id"})
 		return
 	}
+	identityID := uint(idVal)

(Add "strconv" to imports.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/handlers/handlers.go` around lines 647 - 651, Replace the
loose fmt.Sscanf-based id parsing with a strict strconv.ParseUint call: import
"strconv", call strconv.ParseUint(c.Param("id"), 10, 64), check the returned
error and that the parsed value is non-zero, then cast it to uint and assign to
identityID (replacing the current var identityID uint and the fmt.Sscanf check
in the handler). Ensure the invalid-path branch returns the same BadRequest JSON
when ParseUint fails or yields 0.

462-469: Prefer sentinel errors over string-comparison for binding outcomes.

err.Error() == "identity_already_bound" (here and on line 653 for "cannot unbind last identity") couples handlers to the exact wording from the service. A typo or wrapped error breaks the case silently and gives users a generic 500. Consider exporting sentinels from userpkg (e.g. userpkg.ErrIdentityAlreadyBound, userpkg.ErrCannotUnbindLastIdentity) and using errors.Is.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/handlers/handlers.go` around lines 462 - 469, The handler
currently compares error strings returned by
UserModule.Service.BindIdentityToUser (and similarly for the "cannot unbind last
identity" case) which is brittle; define and export sentinel errors in the user
package (e.g. userpkg.ErrIdentityAlreadyBound and
userpkg.ErrCannotUnbindLastIdentity), modify the service implementations to
return or wrap these sentinels, and update the handler to check errors using
errors.Is(err, userpkg.ErrIdentityAlreadyBound) (and errors.Is(...,
userpkg.ErrCannotUnbindLastIdentity)) instead of err.Error() == "...", returning
the same HTTP responses when the sentinel matches and a 500 otherwise.
server/internal/user/service.go (1)

365-371: Redundant Save on every login.

When shouldUpdate == true, line 365 saves the user. Then line 369 calls BindIdentityToUser, which (via refreshUserProfileFromIdentitiesTx, line 686) does another tx.Save(&user) — derived from identities and likely overwriting some of what was just saved on line 365. Functionally harmless, but it doubles the writes per login and the second save can clobber profile fields that were freshly synced from claims (e.g., the Email you just set on line 350 may be replaced by validEmailPtr's pick).

Consider letting refreshUserProfileFromIdentitiesTx be the single source of truth and removing the field-by-field updates above (or vice versa).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/user/service.go` around lines 365 - 371, The code currently
saves the user in the outer flow when shouldUpdate == true (s.db.Save(&user))
and then calls BindIdentityToUser which calls refreshUserProfileFromIdentitiesTx
and does another tx.Save(&user), causing redundant writes and potential
clobbering; pick one source-of-truth: either remove the field-by-field updates &
outer s.db.Save(&user) and let refreshUserProfileFromIdentitiesTx (via
BindIdentityToUser) be the single updater of profile fields, or keep the outer
updates and change refreshUserProfileFromIdentitiesTx to avoid
saving/overwriting those fields (e.g., skip Save there or only merge non-empty
fields); update the code around shouldUpdate, s.db.Save(&user),
BindIdentityToUser, and refreshUserProfileFromIdentitiesTx accordingly so only
one save occurs per login and profile merging is deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/cmd/migrate/main.go`:
- Line 260: The unique index on external_key will conflict with soft-deleted
UserAuthIdentity rows; update the index creation SQL (the statement creating
idx_user_auth_identities_external_key) to be a partial index that only enforces
uniqueness for live rows (i.e., add a WHERE deleted_at IS NULL condition) so
that soft-deleted records (gorm.DeletedAt) don’t cause unique-constraint
violations when UnbindIdentity performs soft deletes.

In `@server/internal/handlers/handlers.go`:
- Around line 422-475: The handler bindAuthCallback currently returns JSON on
all error paths which exposes raw error pages to users; change each error
response to perform an HTTP redirect to the original frontend (use
state.RedirectTo if set, otherwise defaultFrontendURL + "/settings/account")
appending query params like bind=error and reason=<short_code> (e.g.
invalid_or_expired_state, code_required, auth_required, invalid_session,
binding_session_invalid, token_exchange_failed, parse_identity_failed,
provider_mismatch, identity_already_bound) so the frontend can display a
friendly message; ensure you still log the detailed error server-side (not sent
to user) and preserve distinct status codes internally but always return an HTTP
redirect to the computed redirectURL on every failure path in bindAuthCallback.
- Around line 195-207: The current sign/verify functions (signBindStatePayload,
verifyBindStatePayload) use a hardcoded fallback bindStateSecret and reuse
Casdoor secret, enabling token forgery; remove the literal default and stop
using cfg.Casdoor.Secret as the HMAC key, instead require a dedicated non-empty
BIND_STATE_SECRET (e.g., bindStateSecret) and make signBindStatePayload error or
panic/return empty when the secret is missing; update verifyBindStatePayload to
handle that case safely (reject when secret absent); add a startup validation in
InitCookieConfig that checks cfg.InternalSecret or the dedicated
BIND_STATE_SECRET is present and fail fast (return error/exit) if not, so the
service never runs with a missing bind-state secret.
- Around line 432-446: The code currently parses unverified claims via
ParseJWTClaimsFromAccessToken and then calls UserModule.Service.GetOrCreateUser
which can create users from forged tokens; change the flow to verify the access
token signature before trusting claims (use the same validator the auth
middleware uses) or avoid creation by replacing GetOrCreateUser with a
non-creating lookup such as UserModule.Service.GetUserByID(state.UserSubjectID);
specifically, after obtaining currentToken via middleware.ExtractToken, validate
the token signature and only then call GetOrCreateUser (or, preferably, call
GetUserByID with state.UserSubjectID) so that unverified claims cannot cause new
user creation and the subsequent SubjectID check is meaningful.

In `@server/internal/user/service.go`:
- Around line 414-420: The user creation (s.db.Create(&user)) and initial bind
(s.BindIdentityToUser) must be performed inside a single DB transaction to avoid
orphaned users when the bind fails; change the flow so you begin a transaction,
create the user via the transaction handle, call BindIdentityToUser using the
same transaction context/handle, and commit only if bind succeeds (treating the
"identity_already_bound" case as non-fatal), otherwise rollback and return the
bind error; finally, fetch the user (s.GetUserByID) only after a successful
commit or return the rolled-back error. Ensure ListUserIdentities/UnbindIdentity
behavior remains unchanged by always using the committed state.
- Around line 167-205: The TOCTOU bug in UserService.UnbindIdentity arises
because the identity count is read without locking, allowing two concurrent
transactions to both see count==2 and delete both identities; fix by acquiring a
FOR UPDATE lock on the user's identity rows before counting (e.g. use
tx.Clauses(clause.Locking{Strength: "UPDATE"})/tx.Where("user_subject_id = ?",
userSubjectID).Find(...)) or alternatively perform the count after the Delete
within the same transaction while holding a row lock, ensuring the check and
delete are serialized; update the logic around the tx.Where(...).Count(&count)
and tx.Delete(&identity)/refreshUserProfileFromIdentitiesTx to use the chosen
pessimistic lock so a concurrent UnbindIdentity cannot remove the last identity.

---

Nitpick comments:
In `@docs/proposals/MULTI_PROVIDER_ACCOUNT_BINDING_DESIGN.md`:
- Line 41: Several subsection headings are at H2 (e.g., "## 3.1 采用显式绑定", "## 4.1
现有 users 表定位调整", and the other listed headings) but should be nested under their
H2 parents; change each of the indicated headings from "##" to "###" so they
become H3 (e.g., update "## 3.1 采用显式绑定" → "### 3.1 采用显式绑定", "## 4.1 现有 users
表定位调整" → "### 4.1 现有 users 表定位调整", and similarly for the headings at the other
reported locations) to restore correct document outline and TOC nesting.

In `@server/cmd/migrate/main_test.go`:
- Around line 128-147: Update TestBackfillUserAuthIdentities to validate the
actual backfilled row contents, not just the count: after calling
backfillUserAuthIdentities, SELECT the created models.UserAuthIdentity for
user_subject_id "u1" and assert that AuthProvider is lower-cased (matches
strings.ToLower of the source), ExternalKey equals the original ExternalKey
value (and if ExternalKey is nil test a case where it falls back to
CasdoorUniversalID/CasdoorSub/CasdoorID), ProviderUserID matches ProviderUserID
from the User, and IsPrimary is true; also assert there is exactly one identity
with IsPrimary=true for that user to guard against duplicate primaries. Use the
existing functions/structs backfillUserAuthIdentities and
models.UserAuthIdentity to locate the code to change.

In `@server/cmd/migrate/main.go`:
- Around line 187-192: Add a new "user-auth-identities" subcommand to the
migrate CLI that mirrors the pattern used by "user-subject-ids" and
"user-external-identities": parse a --dry-run boolean flag, call
backfillUserAuthIdentities(db, dryRun), treat errDryRunRollback specially (do
not treat it as a real error when dryRun is true), and log/exit with the same
messaging and exit codes as the other backfill subcommands; ensure the command
name, help text, and flag wiring are added to the main migrate command
registration so ops can run "user-auth-identities --dry-run" to preview changes.
- Around line 358-376: backfillUserAuthIdentities is selecting columns (phone,
auth_provider, external_key, provider_user_id) that may not exist in older
schemas; mirror the defensive pattern used in backfillUserExternalIdentities by
using db.Migrator().HasColumn checks before including each optional column in
the SELECT, or otherwise gate the whole backfill behind
ensureUserIdentityColumns; update the Select(...) construction in
backfillUserAuthIdentities to conditionally append these column names only if
HasColumn reports true (refer to Migrator().HasColumn,
backfillUserExternalIdentities, and ensureUserIdentityColumns to match
behavior).

In `@server/internal/handlers/handlers_test.go`:
- Around line 22-34: The test helper signHandlersTestJWT currently generates a
new RSA key on every call which is slow; change it to use a package-level cached
*rsa.PrivateKey initialized once (e.g. via sync.Once or a sync.Mutex-protected
variable) so subsequent calls reuse the same key, add "sync" to imports, and
update signHandlersTestJWT to use that cached key instead of calling
rsa.GenerateKey each invocation (keep function name signHandlersTestJWT and the
existing error handling).

In `@server/internal/handlers/handlers.go`:
- Around line 647-651: Replace the loose fmt.Sscanf-based id parsing with a
strict strconv.ParseUint call: import "strconv", call
strconv.ParseUint(c.Param("id"), 10, 64), check the returned error and that the
parsed value is non-zero, then cast it to uint and assign to identityID
(replacing the current var identityID uint and the fmt.Sscanf check in the
handler). Ensure the invalid-path branch returns the same BadRequest JSON when
ParseUint fails or yields 0.
- Around line 462-469: The handler currently compares error strings returned by
UserModule.Service.BindIdentityToUser (and similarly for the "cannot unbind last
identity" case) which is brittle; define and export sentinel errors in the user
package (e.g. userpkg.ErrIdentityAlreadyBound and
userpkg.ErrCannotUnbindLastIdentity), modify the service implementations to
return or wrap these sentinels, and update the handler to check errors using
errors.Is(err, userpkg.ErrIdentityAlreadyBound) (and errors.Is(...,
userpkg.ErrCannotUnbindLastIdentity)) instead of err.Error() == "...", returning
the same HTTP responses when the sentinel matches and a 500 otherwise.

In `@server/internal/models/models.go`:
- Around line 576-600: The IsPrimary boolean on UserAuthIdentity is only
protected by application logic (RefreshUserProfileFromIdentities) so add a clear
comment on the IsPrimary field in models.go noting that
RefreshUserProfileFromIdentities is the sole legitimate writer and that other
code must not set is_primary directly; then update the migration/backfill in
server/cmd/migrate/main.go to avoid writing "is_primary": true blindly (either
call RefreshUserProfileFromIdentities after backfilling a user's identities or
ensure the backfill only marks the first identity per user as primary) so the
one-primary-per-user invariant is preserved.

In `@server/internal/user/service.go`:
- Around line 365-371: The code currently saves the user in the outer flow when
shouldUpdate == true (s.db.Save(&user)) and then calls BindIdentityToUser which
calls refreshUserProfileFromIdentitiesTx and does another tx.Save(&user),
causing redundant writes and potential clobbering; pick one source-of-truth:
either remove the field-by-field updates & outer s.db.Save(&user) and let
refreshUserProfileFromIdentitiesTx (via BindIdentityToUser) be the single
updater of profile fields, or keep the outer updates and change
refreshUserProfileFromIdentitiesTx to avoid saving/overwriting those fields
(e.g., skip Save there or only merge non-empty fields); update the code around
shouldUpdate, s.db.Save(&user), BindIdentityToUser, and
refreshUserProfileFromIdentitiesTx accordingly so only one save occurs per login
and profile merging is deterministic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e013fb59-71f2-4216-a7b5-f6aa6bcc8225

📥 Commits

Reviewing files that changed from the base of the PR and between 55504f6 and 2190940.

📒 Files selected for processing (12)
  • docs/proposals/MULTI_PROVIDER_ACCOUNT_BINDING_DESIGN.md
  • docs/proposals/casdoor-identity-normalization/05-service-and-flow.md
  • server/cmd/api/main.go
  • server/cmd/migrate/main.go
  • server/cmd/migrate/main_test.go
  • server/internal/handlers/handlers.go
  • server/internal/handlers/handlers_test.go
  • server/internal/handlers/registry_test.go
  • server/internal/models/models.go
  • server/internal/user/service.go
  • server/internal/user/service_test.go
  • todo/MULTI_PROVIDER_ACCOUNT_BINDING_PROGRESS.md

updated_at timestamptz,
deleted_at timestamptz
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key)`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern $'type UserAuthIdentity struct {
  $$$
}'
rg -nP -C2 'UserAuthIdentity\b' --type=go -g '!**/*_test.go'

Repository: XDfield/costrict-web

Length of output: 12394


Unique index on external_key will conflict with soft-deleted rows.

The UserAuthIdentity model includes DeletedAt gorm.DeletedAt (line 595 in server/internal/models/models.go), confirming GORM soft-delete is in use. The UnbindIdentity function performs soft deletes, leaving rows physically present with deleted_at set. Subsequent attempts to bind the same external_key will fail with a unique-constraint violation since the index does not filter out soft-deleted rows.

Make the index partial to enforce uniqueness only on live rows:

Proposed fix
-		`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key)`,
+		`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key) WHERE deleted_at IS NULL`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_auth_identities_external_key ON user_auth_identities(external_key) WHERE deleted_at IS NULL`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/cmd/migrate/main.go` at line 260, The unique index on external_key
will conflict with soft-deleted UserAuthIdentity rows; update the index creation
SQL (the statement creating idx_user_auth_identities_external_key) to be a
partial index that only enforces uniqueness for live rows (i.e., add a WHERE
deleted_at IS NULL condition) so that soft-deleted records (gorm.DeletedAt)
don’t cause unique-constraint violations when UnbindIdentity performs soft
deletes.

Comment on lines +195 to +207
func signBindStatePayload(payload string) string {
key := strings.TrimSpace(bindStateSecret)
if key == "" {
key = "costrict-bind-state-default"
}
h := hmac.New(sha256.New, []byte(key))
_, _ = h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}

func verifyBindStatePayload(payload, sig string) bool {
return hmac.Equal([]byte(signBindStatePayload(payload)), []byte(sig))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: hardcoded fallback HMAC key allows bind-state forgery if no secret is configured.

If both cfg.InternalSecret and cfg.Casdoor.Secret are empty, signBindStatePayload falls back to the literal "costrict-bind-state-default" (line 198). Because that constant is in source, anyone can forge a valid bind-state token, bypassing expiry/nonce/user checks and binding arbitrary identities to arbitrary users in misconfigured deployments. The bind flow should fail closed when no key is available.

Additionally, reusing the Casdoor OAuth client secret as the HMAC key (line 82) couples two unrelated cryptographic concerns; prefer a dedicated BIND_STATE_SECRET and require it to be non-empty at boot.

🛡️ Proposed fix
 func signBindStatePayload(payload string) string {
 	key := strings.TrimSpace(bindStateSecret)
 	if key == "" {
-		key = "costrict-bind-state-default"
+		// Fail closed: refuse to sign without a configured secret.
+		// signBindStatePayload should never be reached if InitCookieConfig
+		// validated configuration at boot. Returning an unguessable random
+		// signature here ensures verifyBindStatePayload always rejects.
+		return ""
 	}
 	h := hmac.New(sha256.New, []byte(key))
 	_, _ = h.Write([]byte(payload))
 	return hex.EncodeToString(h.Sum(nil))
 }
 
 func verifyBindStatePayload(payload, sig string) bool {
+	if strings.TrimSpace(bindStateSecret) == "" {
+		return false
+	}
 	return hmac.Equal([]byte(signBindStatePayload(payload)), []byte(sig))
 }

And add a startup check in InitCookieConfig:

 func InitCookieConfig(cfg *config.Config) {
 	cookieSecure = cfg.CookieSecure
 	bindStateSecret = cfg.InternalSecret
 	if strings.TrimSpace(bindStateSecret) == "" {
-		bindStateSecret = cfg.Casdoor.Secret
+		// Fall back only with a clear log; ideally require a dedicated secret.
+		bindStateSecret = cfg.Casdoor.Secret
+	}
+	if strings.TrimSpace(bindStateSecret) == "" {
+		log.Fatalf("bind-state secret is required (set INTERNAL_SECRET or CASDOOR_SECRET)")
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func signBindStatePayload(payload string) string {
key := strings.TrimSpace(bindStateSecret)
if key == "" {
key = "costrict-bind-state-default"
}
h := hmac.New(sha256.New, []byte(key))
_, _ = h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
func verifyBindStatePayload(payload, sig string) bool {
return hmac.Equal([]byte(signBindStatePayload(payload)), []byte(sig))
}
func signBindStatePayload(payload string) string {
key := strings.TrimSpace(bindStateSecret)
if key == "" {
// Fail closed: refuse to sign without a configured secret.
// signBindStatePayload should never be reached if InitCookieConfig
// validated configuration at boot. Returning an unguessable random
// signature here ensures verifyBindStatePayload always rejects.
return ""
}
h := hmac.New(sha256.New, []byte(key))
_, _ = h.Write([]byte(payload))
return hex.EncodeToString(h.Sum(nil))
}
func verifyBindStatePayload(payload, sig string) bool {
if strings.TrimSpace(bindStateSecret) == "" {
return false
}
return hmac.Equal([]byte(signBindStatePayload(payload)), []byte(sig))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/handlers/handlers.go` around lines 195 - 207, The current
sign/verify functions (signBindStatePayload, verifyBindStatePayload) use a
hardcoded fallback bindStateSecret and reuse Casdoor secret, enabling token
forgery; remove the literal default and stop using cfg.Casdoor.Secret as the
HMAC key, instead require a dedicated non-empty BIND_STATE_SECRET (e.g.,
bindStateSecret) and make signBindStatePayload error or panic/return empty when
the secret is missing; update verifyBindStatePayload to handle that case safely
(reject when secret absent); add a startup validation in InitCookieConfig that
checks cfg.InternalSecret or the dedicated BIND_STATE_SECRET is present and fail
fast (return error/exit) if not, so the service never runs with a missing
bind-state secret.

Comment on lines +422 to +475
func bindAuthCallback(c *gin.Context, state bindState) {
if state.ExpiresAt == 0 || time.Now().Unix() > state.ExpiresAt {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_or_expired_state"})
return
}
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "code is required"})
return
}
currentToken := middleware.ExtractToken(c)
if currentToken == "" || UserModule == nil || UserModule.Service == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
currentClaims, err := userpkg.ParseJWTClaimsFromAccessToken(currentToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid current session"})
return
}
currentUser, err := UserModule.Service.GetOrCreateUser(currentClaims)
if err != nil || currentUser == nil || currentUser.SubjectID != state.UserSubjectID {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid binding session"})
return
}

tokenResp, err := exchangeCodeForTokenFunc(code, state.CallbackURL)
if err != nil || tokenResp.AccessToken == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to exchange code for token: %v", err)})
return
}
claims, err := userpkg.ParseJWTClaimsFromAccessToken(tokenResp.AccessToken)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse bound identity"})
return
}
if state.Provider != "" && !strings.EqualFold(claims.Provider, state.Provider) {
c.JSON(http.StatusConflict, gin.H{"error": "provider_mismatch"})
return
}
if err := UserModule.Service.BindIdentityToUser(currentUser.SubjectID, claims); err != nil {
if err.Error() == "identity_already_bound" {
c.JSON(http.StatusConflict, gin.H{"error": "identity_already_bound", "message": "该登录方式已绑定其他账号"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to bind identity"})
return
}
redirectURL := state.RedirectTo
if redirectURL == "" {
redirectURL = defaultFrontendURL + "/settings/account?bind=success"
}
c.Redirect(http.StatusFound, redirectURL)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Browser-redirect callback returns JSON on errors; users will see raw error blobs.

Every error path in bindAuthCallback (invalid_or_expired_state, code is required, Authentication required, provider_mismatch, identity_already_bound, etc.) responds with c.JSON(...). Since this endpoint is reached via a browser redirect from Casdoor, the user lands on a JSON page rather than the originating UI. On the success path you redirect to state.RedirectTo (line 474), so consider redirecting on failure too with an error=... query parameter, e.g. redirectURL + "?bind=error&reason=invalid_state", so the frontend can surface a friendly message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/handlers/handlers.go` around lines 422 - 475, The handler
bindAuthCallback currently returns JSON on all error paths which exposes raw
error pages to users; change each error response to perform an HTTP redirect to
the original frontend (use state.RedirectTo if set, otherwise defaultFrontendURL
+ "/settings/account") appending query params like bind=error and
reason=<short_code> (e.g. invalid_or_expired_state, code_required,
auth_required, invalid_session, binding_session_invalid, token_exchange_failed,
parse_identity_failed, provider_mismatch, identity_already_bound) so the
frontend can display a friendly message; ensure you still log the detailed error
server-side (not sent to user) and preserve distinct status codes internally but
always return an HTTP redirect to the computed redirectURL on every failure path
in bindAuthCallback.

Comment on lines +432 to +446
currentToken := middleware.ExtractToken(c)
if currentToken == "" || UserModule == nil || UserModule.Service == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
currentClaims, err := userpkg.ParseJWTClaimsFromAccessToken(currentToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid current session"})
return
}
currentUser, err := UserModule.Service.GetOrCreateUser(currentClaims)
if err != nil || currentUser == nil || currentUser.SubjectID != state.UserSubjectID {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid binding session"})
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm ParseUnverifiedTokenClaims really skips signature verification
ast-grep --pattern $'func ParseUnverifiedTokenClaims($_) ($_, $_) {
  $$$
}'
# Find how /api/auth/bind/callback is wired (middleware applied?)
rg -nP -C3 'auth/bind/callback|bind/callback|StartBindAuth|bindAuthCallback' --type=go

Repository: XDfield/costrict-web

Length of output: 5140


🏁 Script executed:

# Check the middleware applied to the auth router group
grep -n -B 10 -A 5 'auth.GET.*bind/callback' server/cmd/api/main.go | head -50

# Also check the full auth router setup to see what middleware is applied
sed -n '210,230p' server/cmd/api/main.go

# Check if there's any middleware wrapping the auth group
sed -n '200,240p' server/cmd/api/main.go

Repository: XDfield/costrict-web

Length of output: 3355


🏁 Script executed:

# Get the full bindAuthCallback implementation (starting at line 422)
sed -n '422,460p' server/internal/handlers/handlers.go

# Find GetOrCreateUser to see if it creates users
rg -n 'func.*GetOrCreateUser' --type=go -A 15

Repository: XDfield/costrict-web

Length of output: 7168


🏁 Script executed:

# Get the full GetOrCreateUser function
sed -n '230,280p' server/internal/user/service.go

Repository: XDfield/costrict-web

Length of output: 1843


🏁 Script executed:

# Continue reading GetOrCreateUser to see the user creation logic
sed -n '280,320p' server/internal/user/service.go

Repository: XDfield/costrict-web

Length of output: 1218


🏁 Script executed:

# Continue to see user creation logic
sed -n '320,360p' server/internal/user/service.go

Repository: XDfield/costrict-web

Length of output: 1696


🏁 Script executed:

# Continue to see user creation logic
sed -n '360,410p' server/internal/user/service.go

Repository: XDfield/costrict-web

Length of output: 1817


Verify the access token signature before parsing and trusting claims in /api/auth/bind/callback.

The endpoint calls ParseJWTClaimsFromAccessToken() which uses unverified JWT parsing (see server/internal/authidentity/normalize.go:30parser.ParseUnverified()), allowing a forged token to reach GetOrCreateUser(). Since GetOrCreateUser() creates new users when claims don't match existing identities, a crafted token can create phantom user records. The SubjectID equality check happens after user creation, so the side effect occurs regardless of the subsequent validation failure.

Recommended fix: Either verify the token signature using the same validator the auth middleware uses, or replace GetOrCreateUser() with GetUserByID(state.UserSubjectID) to prevent user creation from unverified claims. At minimum, ensure user lookup—not creation—occurs before trusting unverified token identifiers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/handlers/handlers.go` around lines 432 - 446, The code
currently parses unverified claims via ParseJWTClaimsFromAccessToken and then
calls UserModule.Service.GetOrCreateUser which can create users from forged
tokens; change the flow to verify the access token signature before trusting
claims (use the same validator the auth middleware uses) or avoid creation by
replacing GetOrCreateUser with a non-creating lookup such as
UserModule.Service.GetUserByID(state.UserSubjectID); specifically, after
obtaining currentToken via middleware.ExtractToken, validate the token signature
and only then call GetOrCreateUser (or, preferably, call GetUserByID with
state.UserSubjectID) so that unverified claims cannot cause new user creation
and the subsequent SubjectID check is meaningful.

Comment on lines +167 to +205
func (s *UserService) UnbindIdentity(userSubjectID string, identityID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var identity models.UserAuthIdentity
if err := tx.Where("id = ? AND user_subject_id = ?", identityID, userSubjectID).Take(&identity).Error; err != nil {
return err
}

var count int64
if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Count(&count).Error; err != nil {
return err
}
if count <= 1 {
return fmt.Errorf("cannot unbind last identity")
}

wasPrimary := identity.IsPrimary
if err := tx.Delete(&identity).Error; err != nil {
return err
}

if wasPrimary {
var remaining []*models.UserAuthIdentity
if err := tx.Where("user_subject_id = ?", userSubjectID).Find(&remaining).Error; err != nil {
return err
}
best := selectBestPrimary(remaining)
if best != nil {
if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Update("is_primary", false).Error; err != nil {
return err
}
if err := tx.Model(&models.UserAuthIdentity{}).Where("id = ?", best.ID).Update("is_primary", true).Error; err != nil {
return err
}
}
}

return s.refreshUserProfileFromIdentitiesTx(tx, userSubjectID)
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Concurrency: TOCTOU race can leave a user with zero identities.

The count <= 1 guard (line 178) and the subsequent tx.Delete (line 183) are read/write within a transaction, but the count query is not pessimistically locked. With Postgres' default Read Committed isolation, two concurrent UnbindIdentity calls on the same user (when count == 2) can both observe count = 2, both pass the guard, and both delete their respective rows — leaving the user with 0 identities, which the invariant explicitly forbids. The user would then be unable to log in via any provider.

Lock the user's identity rows for update before counting, or count after delete inside the transaction:

🔒️ Proposed fix (pessimistic lock)
 	return s.db.Transaction(func(tx *gorm.DB) error {
 		var identity models.UserAuthIdentity
-		if err := tx.Where("id = ? AND user_subject_id = ?", identityID, userSubjectID).Take(&identity).Error; err != nil {
+		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+			Where("user_subject_id = ?", userSubjectID).
+			Find(&[]models.UserAuthIdentity{}).Error; err != nil {
+			return err
+		}
+		if err := tx.Where("id = ? AND user_subject_id = ?", identityID, userSubjectID).Take(&identity).Error; err != nil {
 			return err
 		}

 		var count int64
 		if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Count(&count).Error; err != nil {
 			return err
 		}
 		if count <= 1 {
 			return fmt.Errorf("cannot unbind last identity")
 		}

(clause is already imported in this file.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (s *UserService) UnbindIdentity(userSubjectID string, identityID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var identity models.UserAuthIdentity
if err := tx.Where("id = ? AND user_subject_id = ?", identityID, userSubjectID).Take(&identity).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Count(&count).Error; err != nil {
return err
}
if count <= 1 {
return fmt.Errorf("cannot unbind last identity")
}
wasPrimary := identity.IsPrimary
if err := tx.Delete(&identity).Error; err != nil {
return err
}
if wasPrimary {
var remaining []*models.UserAuthIdentity
if err := tx.Where("user_subject_id = ?", userSubjectID).Find(&remaining).Error; err != nil {
return err
}
best := selectBestPrimary(remaining)
if best != nil {
if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Update("is_primary", false).Error; err != nil {
return err
}
if err := tx.Model(&models.UserAuthIdentity{}).Where("id = ?", best.ID).Update("is_primary", true).Error; err != nil {
return err
}
}
}
return s.refreshUserProfileFromIdentitiesTx(tx, userSubjectID)
})
}
func (s *UserService) UnbindIdentity(userSubjectID string, identityID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
var identity models.UserAuthIdentity
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_subject_id = ?", userSubjectID).
Find(&[]models.UserAuthIdentity{}).Error; err != nil {
return err
}
if err := tx.Where("id = ? AND user_subject_id = ?", identityID, userSubjectID).Take(&identity).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Count(&count).Error; err != nil {
return err
}
if count <= 1 {
return fmt.Errorf("cannot unbind last identity")
}
wasPrimary := identity.IsPrimary
if err := tx.Delete(&identity).Error; err != nil {
return err
}
if wasPrimary {
var remaining []*models.UserAuthIdentity
if err := tx.Where("user_subject_id = ?", userSubjectID).Find(&remaining).Error; err != nil {
return err
}
best := selectBestPrimary(remaining)
if best != nil {
if err := tx.Model(&models.UserAuthIdentity{}).Where("user_subject_id = ?", userSubjectID).Update("is_primary", false).Error; err != nil {
return err
}
if err := tx.Model(&models.UserAuthIdentity{}).Where("id = ?", best.ID).Update("is_primary", true).Error; err != nil {
return err
}
}
}
return s.refreshUserProfileFromIdentitiesTx(tx, userSubjectID)
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/user/service.go` around lines 167 - 205, The TOCTOU bug in
UserService.UnbindIdentity arises because the identity count is read without
locking, allowing two concurrent transactions to both see count==2 and delete
both identities; fix by acquiring a FOR UPDATE lock on the user's identity rows
before counting (e.g. use tx.Clauses(clause.Locking{Strength:
"UPDATE"})/tx.Where("user_subject_id = ?", userSubjectID).Find(...)) or
alternatively perform the count after the Delete within the same transaction
while holding a row lock, ensuring the check and delete are serialized; update
the logic around the tx.Where(...).Count(&count) and
tx.Delete(&identity)/refreshUserProfileFromIdentitiesTx to use the chosen
pessimistic lock so a concurrent UnbindIdentity cannot remove the last identity.

Comment on lines 414 to +420
}
if err := s.BindIdentityToUser(user.SubjectID, claims); err != nil && err.Error() != "identity_already_bound" {
return nil, err
}
if refreshed, err := s.GetUserByID(user.SubjectID); err == nil {
return refreshed, nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Orphaned user on bind failure after Create.

s.db.Create(&user) on line 396 commits immediately. If BindIdentityToUser then fails (line 415), we return an error to the caller but a users row exists with no user_auth_identities row. Subsequent logins for this user via the same external_key will succeed (GetOrCreateUser re-finds the user) and re-attempt the bind, so this is self-healing — but until then, ListUserIdentities returns empty, and UnbindIdentity would refuse with "cannot unbind last identity". Wrap user creation + initial bind in a single transaction so partial state isn't possible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/internal/user/service.go` around lines 414 - 420, The user creation
(s.db.Create(&user)) and initial bind (s.BindIdentityToUser) must be performed
inside a single DB transaction to avoid orphaned users when the bind fails;
change the flow so you begin a transaction, create the user via the transaction
handle, call BindIdentityToUser using the same transaction context/handle, and
commit only if bind succeeds (treating the "identity_already_bound" case as
non-fatal), otherwise rollback and return the bind error; finally, fetch the
user (s.GetUserByID) only after a successful commit or return the rolled-back
error. Ensure ListUserIdentities/UnbindIdentity behavior remains unchanged by
always using the committed state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant