Skip to content

🚪 fix: Let Concurrent First OpenID Logins Continue as the New User - #16778

Merged
danny-avila merged 2 commits into
devfrom
danny-avila/librechat-issue-16766-a94100
Oct 5, 2026
Merged

danny-avila merged 2 commits into
devfrom
danny-avila/librechat-issue-16766-a94100

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

When two requests complete an OpenID login for the same user who has no LibreChat account yet, both run findOpenIDUser, both miss, and both call createUser. The unique email_1_tenantId_1 index (or the openidId/openidIssuer/tenantId index) rejects the second insert, so that login fails with E11000 duplicate key error. The account itself is created correctly and the next attempt succeeds, so the user sees a one-off failed first login. Two browser tabs finishing the callback together trigger it, and so does any deployment that authenticates parallel requests through processOpenIDAuth, where the SPA's first parallel requests hit it every time.

processOpenIDAuth now creates first-login users through createOpenIDUser in packages/api, which inserts through a new data-schemas method, createUserIfAbsent. That method reports a lost insert race as a typed user_exists result instead of a database error. On user_exists, the request repeats the same lookup and continues as the user the other request created, admitted and refreshed exactly as if its first lookup had found that account: a tenant account resolves its tenant config and that config's email-domain policy applies, and the account carries this callback's claims (email, verification state, username, name). That lookup applies the usual provider, sub and issuer checks. If it rejects the match (for example, a different provider registered the email in the meantime), the login fails with the normal AUTH_FAILED response instead of a raw database error. If the lookup finds nothing that explains the conflict, the login fails with an error, as an unexpected duplicate did before. The start balance is still credited once, with the creating request's value: createUserIfAbsent initializes it under the new user's id before inserting the user, so the account is never visible without it, the recovered request's login balance sync always finds it, and an insert a unique index rejects removes the balance it initialized. A recovered login continues only once the account's creator finished provisioning it: when the config new users are created under sets a start balance, that balance must exist, otherwise that login fails as it did before this change (only possible while a creator on the previous release sits between its insert and its credit during a rolling deploy).

Related to #16766

How it works

processOpenIDAuth
  findOpenIDUser(lookup)                  # both requests miss
  createOpenIDUser({ lookup, profile, create })
    createUserIfAbsent(newUser)           # data-schemas: A gets { ok: true }, B gets user_exists
    findOpenIDUser(lookup)                # B only: same provider/sub/issuer checks
      error  -> throw AUTH_FAILED         # mapped by createOpenIDCallback as before
      user   -> start balance exists?     # always, unless an old-release winner is mid-provisioning
                  no  -> throw AUTH_FAILED
                  yes -> tenant config + allowedDomains, as for a found account
                         applyOpenIDProfile   # B's claims, as for any found account
                         continue             # role sync, avatar, updateUser as usual
      none   -> throw

Storage knowledge stays in packages/data-schemas: createUserIfAbsent returns { ok: true, value } | { ok: false, error: { code: 'user_exists' } }, and createUser and createUserIfAbsent share the start-balance credit through creditStartBalance. The new contracts are storage-neutral: they take NewUserData and return UserRecord (the user's own fields plus _id: { toString(): string }), not the IUser document type or the Document-derived CreateUserRequest. The strategy passes the same lookup object to both lookups, so the retry cannot drift from the first one. The claims-to-account mapping moved into applyOpenIDProfile, which runs for both a found account and a recovered one:

-  const result = await findOpenIDUser({ findUser, email, openidId, ... });
+  const lookup = { findUser, email, openidId, ... };
+  const result = await findOpenIDUser(lookup);
 ...
+  const profile = { openidId, openidIssuer, username, name, email, emailVerified, idOnTheSource };
   if (!user) {
-    user = await createUser({ provider: 'openid', ... }, balanceConfig, true, true);
+    user = await createOpenIDUser({
+      lookup,
+      profile,
+      create: (newUser) => createUserIfAbsent(newUser, balanceConfig),
+    });
   } else {
-    user.provider = 'openid';
-    user.openidId = userinfo.sub;
-    ...
+    user = applyOpenIDProfile(user, profile);
   }

createOpenIDUser returns { user, appConfig }: the config this request resolved for a user it created, or the recovered account's own config (tenant config via resolveAppConfigForUser for a tenant account), so the rest of the login runs under the same config an ordinary login to that account would. findOpenIDUser keeps its existing parameter contract; createOpenIDUser takes the same shape.

Type of change

  • Bug fix

Testing

The bug reproduces against a real MongoDB with the real createUser and the real user indexes. Two findOpenIDUser calls for a new identity both return null, then two concurrent createUser calls reject one insert with E11000 duplicate key error collection: test.users index: email_1_tenantId_1, which is the error in the issue.

Tested environments/configuration:

  • Database: mongodb-memory-server with the User and Balance models and indexes synced

Automated tests:

  • packages/data-schemas/src/methods/user.methods.spec.ts, new createUserIfAbsent block against a real MongoDB. One of two concurrent inserts reports user_exists, the collection holds one user, and the start balance is credited once. An account holding the same OpenID identity under another email reports user_exists. The balance write happens before the user save; after a lost race only the winner's balance remains; a non-duplicate save failure keeps the balance (the insert may have committed); a balance write that commits but reports a failure leaves no balance and no user; a validation failure writes no balance. A validation failure still throws.
  • packages/api/src/auth/openid.spec.ts, new createOpenIDUser block against a real MongoDB. Concurrent first logins resolve to one user, and the collection holds exactly one document. When the losing request's claims differ, the recovered user carries the loser's email, name and verification state. With startBalance: 500, concurrent first logins leave one balance of 500. If a local-provider user takes the email between lookup and insert, the login is rejected with AUTH_FAILED. An account found without its start balance (as a previous-release winner leaves it mid-provisioning) fails the recovered login with AUTH_FAILED, and recovers when no start balance is configured. Inside a tenant context, a recovered tenant account whose config disallows the email fails with Email domain not allowed, and one whose config admits it continues under the tenant's config; the balance barrier follows the start balance new users are created with, not the tenant's (only the tenant sets one: continues, since login balance sync initializes it insert-only; the base sets one and it is missing: fails). A conflict the lookup cannot explain throws. A validation failure throws without a second lookup.
  • api/strategies/openidStrategy.spec.js, new concurrent first login block. The losing request logs in as the winner's _id and updateUser targets that user. When the winner stored older claims, updateUser receives the current token's email, emailVerified, username and name. A concurrent other-provider account, or a recovered account whose start balance does not exist yet, maps to done(null, false, { message: AUTH_FAILED }).
  • Fix removal: with the recovery removed, the race tests in openid.spec.ts and the strategy race tests fail. With recovery returning the raw recovered user, both claim-refresh tests fail. With createUserIfAbsent rethrowing the duplicate instead of reporting user_exists, both user_exists tests fail. With the credit moved back after the insert, the ordering test fails; without the cleanup, the race and orphan-balance tests fail; with cleanup on every save failure, the non-duplicate failure test fails; without cleanup on a failed balance write, that test fails; without the provisioning check, the missing-balance recovery test fails; with recovery keeping the base config, both tenant-policy tests fail.
  • Focused set (codegraph depth 1): data-schemas user, role, userGroup, agent, trigger and MCP-authority method specs (824 passed); 50 packages/api specs that import the user methods, including openid.spec.ts (2343 passed, 4 skipped); openidStrategy.spec.js, openIdJwtStrategy.spec.js, AuthController.spec.js, OpenIDSessionRefresh.spec.js, OboTokenService.spec.js, GraphApiService.spec.js (437 passed).

Screenshots / recordings

No user-facing change.

Risk / compatibility

Low. Recovery only runs when the first-login insert reports user_exists, so the normal path still does one lookup and one insert. createUser keeps its signature and order; its balance credit moved into a shared helper and is now insert-only, which only differs from the old $inc when a balance record for the brand-new user id already exists (the old code added to it). No schema, config or wire changes.

Mixed versions: during a rolling deploy, a previous-release worker still inserts the user before crediting it. A recovered login on this release that finds such an account before its balance exists fails, which is the pre-change behavior, rather than letting login balance sync initialize the record ahead of the old $inc.

SAML (samlStrategy.js), LDAP (ldapStrategy.js) and social logins (createSocialUser in api/strategies/process.js) have the same lookup-then-insert shape and are not changed here; #16779, stacked on this branch, covers them.

Checklist

  • I reviewed my own changes
  • Relevant tests have been added or updated
  • Existing relevant tests pass
  • The change does not introduce new warnings or errors
  • User-facing or complex behavior is documented where necessary
  • Required dependency changes have been merged/published
  • Required documentation PR: N/A

@codegraph-librechat codegraph-librechat Bot added 🗺️ Backend Infra codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review labels Oct 5, 2026
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-05T16:26:20.447515Z 336434f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: add4cc20e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread api/strategies/openidStrategy.js Outdated
Comment on lines +694 to +697
user = await createOpenIDUser({
lookup,
create: () => createUser(newUser, balanceConfig, true, true),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize the recovered user as an existing account

When concurrent callbacks for the same subject carry different profile claims—for example, after the IdP email or display name changes—the losing callback receives the winner here but remains inside the if (!user) branch, so it skips the existing-user normalization at lines 698–711. The subsequent updateUser therefore persists and returns the winner's stale email, username, name, idOnTheSource, and verification state instead of applying the current callback's claims; route recovered users through the same normalization used for an initially found account.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 22c4ad6. The claims-to-account mapping now lives in applyOpenIDProfile (packages/api/src/auth/openid.ts). The strategy applies it to an account found by the first lookup, and createOpenIDUser applies it to the account it recovers after a duplicate key, so the losing callback's email, verification state, username, name and idOnTheSource are what updateUser persists. createOpenIDUser also builds the inserted user from the same profile, so the create path and the refresh path read one mapping.

Tests: openid.spec.ts › "refreshes the recovered user with the losing request's claims" (real MongoDB: the winner is stored with one email and name, the loser's callback carries another, and the returned user has the loser's values). openidStrategy.spec.js › "writes this callback's claims over the ones the other request stored" (updateUser receives the current token's email, emailVerified, username and name). With recovery returning the raw recovered user instead, both tests fail.

The recovered account keeps the config the winner was admitted under (base config, which every new user gets per resolveAppConfigForUser) rather than re-resolving tenant overrides; both requests carried the same identity and passed the same base checks.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22c4ad6280

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/openid.ts Outdated
Comment on lines +314 to +315
function isDuplicateKeyError(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 11000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Move duplicate-key detection behind data-schemas

The new API-layer helper identifies MongoDB conflicts by inspecting the storage-specific 11000 error code, although its create dependency otherwise presents a generic callback contract. Any repository wrapper or alternate storage implementation that normalizes uniqueness errors will silently bypass recovery and reintroduce failed concurrent first logins. Classify the insert conflict in packages/data-schemas and expose a typed, storage-neutral result or error to this service.

AGENTS.md reference: AGENTS.md:L73-L75

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in e3f3e71. The conflict is now classified in packages/data-schemas: createUserIfAbsent returns { ok: true, value } or { ok: false, error: { code: 'user_exists' } } (CreateUserIfAbsentResult), and only the insert that creates the user credits the start balance (creditStartBalance, shared with createUser). createOpenIDUser takes a create that returns that result and no longer inspects storage errors. The OpenID strategy creates through createUserIfAbsent. A conflict the repeated lookup cannot account for now throws a plain error rather than rethrowing the database error.

Tests: user.methods.spec.ts › createUserIfAbsent (real MongoDB): one of two concurrent inserts reports user_exists and the balance is credited once; an account holding the same OpenID identity under another email reports user_exists; a validation failure still throws. With the classification removed, the two user_exists tests fail. The createOpenIDUser (real MongoDB) and strategy race tests now run on the typed result.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: e3f3e71788

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila marked this pull request as ready for review October 5, 2026 13:08
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a62f59a092

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/openid.ts Outdated
Comment on lines +352 to +355
create: (user: CreateUserRequest) => Promise<CreateUserIfAbsentResult>;
lookup: OpenIDUserLookup;
profile: OpenIDProfile;
}): Promise<IUser> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Mongoose documents out of the exported auth contract

Change this newly exported service signature to use a plain user DTO: IUser extends Mongoose Document, and both CreateUserIfAbsentResult and the return type expose it through @librechat/api. This widens the existing storage leak and forces any alternate repository implementation or TypeScript caller to emulate Mongoose document members even though the function only needs plain account fields.

AGENTS.md reference: AGENTS.md:L83-L88

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18f5c30. UserRecord (packages/data-schemas/src/types/user.ts) is the stored user as plain data, the user's own fields plus _id, without Document members. It follows the same plain/Document split as IChatProject/IChatProjectDocument. CreateUserIfAbsentResult returns it (user.toObject() as UserRecord), createOpenIDUser returns Promise<UserRecord>, and applyOpenIDProfile is bounded by UserRecord rather than IUser. IUser itself and the existing contracts that already return it (findOpenIDUser, findUser) are unchanged, per AGENTS.md's "stop widening it rather than rewriting what exists". npx tsc --noEmit is clean for the touched files in packages/api and packages/data-schemas.


await Balance.findOneAndUpdate(
{ _id: userId },
{ ...update, $setOnInsert: { user: userId, tokenCredits: balanceConfig.startBalance } },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the creator's configured start balance

When concurrent first-login callbacks run on separate workers and the insert winner pauses before this upsert, the recovered callback can complete authentication and let /openid/callback's setBalanceConfig create the balance first. That middleware resolves configuration from the post-role-sync user, whereas this path received the base configuration used to admit a new user; because tokenCredits is now insert-only, role-specific configurations with different start balances make the initial credit depend on timing instead of retaining the creator's configured value as before. Make the creation configuration authoritative or reconcile the competing initialization paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18f5c30. creditStartBalance now writes tokenCredits with $set (refill settings with it) and only user with $setOnInsert. When login balance sync initializes the record first from the role-resolved config, the creating request's configured start balance overwrites it, which is the same outcome as the no-race path, where sync finds the record already credited and leaves it alone. Because it is a $set rather than an $inc, it is never added on top.

Test: user.methods.spec.ts › "keeps the creator's start balance over one login balance sync initialized first" (real MongoDB: a balance record with 300 exists for the new user's id; createUserIfAbsent with startBalance: 500 leaves exactly one record with 500). With tokenCredits back on $setOnInsert, the test fails (300). The concurrent-insert test still credits once.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18f5c30e76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/openid.ts Outdated
Comment on lines +353 to +356
create: (user: CreateUserRequest) => Promise<CreateUserIfAbsentResult>;
lookup: OpenIDUserLookup;
profile: OpenIDProfile;
}): Promise<UserRecord> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use a storage-neutral DTO for the auth contract

The follow-up still leaves this newly exported packages/api contract coupled to Mongoose: CreateUserRequest extends Partial<IUser> (and therefore Document), while the returned UserRecord hard-codes mongoose.Types.ObjectId. Consequently, an alternate repository implementation or consumer of @librechat/api must retain Mongoose types for both sides of this callback; define a plain request/response DTO with a storage-neutral identifier instead. The fresh evidence beyond the resolved comment is that the new aliases remove document methods from the result but still retain Types.ObjectId and reuse the document-derived request type.

AGENTS.md reference: AGENTS.md:L83-L87

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0e86a25. UserRecord now types its id as _id: { toString(): string } (the user's own fields plus an id any storage can satisfy), not Types.ObjectId. NewUserData (Partial of those plain fields plus email) replaces the Document-derived CreateUserRequest on both sides of the callback: createUserIfAbsent(data: NewUserData, ...) and createOpenIDUser({ create: (user: NewUserData) => ... }). Neither contract now references Document or Types.ObjectId; tsc --noEmit is clean for the touched files in both workspaces.

Comment on lines +316 to +319
await Balance.findOneAndUpdate(
{ _id: userId },
{ $set: set, $setOnInsert: { user: userId } },
{ upsert: true, new: true },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid overwriting credits after the recovered login starts

When the winning first-login request pauses after User.create, the recovered callback can finish authentication, let setBalanceConfig initialize the balance, and return a session that begins spending credits before the winner resumes. This unconditional $set then restores startBalance and erases that intervening debit, granting the spent credits again. This is a fresh consequence of the $set fix discussed in the existing balance thread; use an initialization compare-and-set/marker or otherwise complete balance initialization before the account becomes usable so only competing initialization is replaced, not later account activity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0e86a25 by removing the window rather than arbitrating it. createUserIfAbsent validates the new user, initializes its start balance under the user's (freshly generated) id, and only then inserts the user. The account is never visible without its balance, so login balance sync for a recovered login always finds the record and never initializes it, and no later activity can be overwritten. creditStartBalance is insert-only again ($setOnInsert) and never replaces an existing balance. An insert that fails (user_exists or any other error) removes the balance it initialized for its never-used id; a validation failure writes nothing.

Tests (user.methods.spec.ts, real MongoDB): the balance write is invoked before the user save; after a lost race only the winner's balance remains; concurrent inserts leave one balance of the start amount; a validation failure leaves no balance. Moving the credit back after the insert fails the ordering test; removing the cleanup fails the race and orphan tests.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0e86a25be4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +371 to +372
} catch (error) {
await discardStartBalance(user._id, balanceConfig);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve balances after ambiguous user-save failures

When user.save() commits the insert but rejects because the connection drops before the acknowledgement reaches the caller, this catch treats the rejection as proof that no user exists and deletes that real user's balance. A concurrent callback may already have found the committed account and started using its credits, so this cleanup can erase the starting balance or intervening debits; restrict deletion to a confirmed failed insert, or verify the generated user ID before removing its balance.

AGENTS.md reference: AGENTS.md:L44-L47

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cf924bf. createUserIfAbsent now removes the balance it initialized only when a unique index rejected the insert (code === 11000), which proves no user was written. Any other save failure rethrows and keeps the balance, since an unacknowledged insert may still have committed; at worst that leaves an unused balance under an id that never became a user.

Test (user.methods.spec.ts): a save that rejects with a non-duplicate error leaves the start balance in place. With cleanup on every failure, the test fails.

if (created.ok) return created.value;

const strategyName = lookup.strategyName ?? 'openid';
const resolution = await findOpenIDUser(lookup);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make recovery safe with old balance writers

During a rolling deployment, an old worker can insert the user and pause before its previous $inc-based balance write; a new worker then loses the user insert, recovers the now-visible account here, and lets setBalanceConfig create its starting balance before the old worker resumes and increments that balance again. This grants the account the start balance twice, or adds the full start balance after intervening spending. Fresh evidence beyond the resolved same-version balance thread is that the pre-insert initialization only protects users created by new workers, while old workers still publish the user before crediting it; stage a backward-compatible initialization change or prevent recovery from exposing an old-created account until its balance is initialized.

AGENTS.md reference: AGENTS.md:L44-L47

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cf924bf, by one invariant rather than another ordering tweak: a recovered login continues only once the account's creator has finished provisioning it. With a start balance configured (enabled and startBalance, the condition data-schemas credits under), createOpenIDUser requires the recovered account's balance to exist (findBalanceByUser, passed in by the strategy). If it doesn't, the login fails with AUTH_FAILED, as it did before recovery existed. Creators on this release always pass immediately, because createUserIfAbsent writes the balance before the user. A previous-release creator still between its insert and its $inc makes the recovered login fail instead of letting balance sync initialize the record, so the old $inc lands on an absent record and credits once.

Tests: openid.spec.ts (real MongoDB): an account inserted without a balance, as an old-release winner would leave it mid-flight, fails the recovered login with AUTH_FAILED and leaves no balance behind; with startBalance: 0 the same account recovers. openidStrategy.spec.js: with a start balance configured and no balance found, the strategy checks the winner's id and fails with AUTH_FAILED without updating the user. With the balance check removed, the openid.spec.ts test fails.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf924bfb4c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


const user = new User(userData);
await user.validate();
await creditStartBalance(user._id, balanceConfig);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clean up balances when pre-insert provisioning fails

If creditStartBalance commits its upsert but rejects because the database connection drops before acknowledgment, this await exits before user.save(), leaving a funded balance whose generated user ID will never exist. Every retry generates another ID, so these orphan balances are neither reused nor removed and can accumulate during transient database failures; reconcile or clean up the provisional balance when this step fails.

AGENTS.md reference: AGENTS.md:L44-L46

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5e7786a. A balance write that rejects now removes the provisional balance, best effort, before rethrowing. The user cannot exist yet, because its insert has not run, so deleting the balance under that never-used id is safe. If the cleanup itself fails (the connection is still down), it logs and the original error propagates.

Test (user.methods.spec.ts): the balance write commits and then rejects; createUserIfAbsent rethrows and leaves no balance and no user. Without the cleanup, one balance remains and the test fails.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5e7786a37b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/openid.ts Outdated

export type OpenIDUserLookup = {
openidId: string;
findUser: UserMethods['findUser'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Export a storage-neutral lookup dependency

Replace UserMethods['findUser'] in this newly exported API type with a plain lookup contract. That method's signature accepts Mongoose FilterQuery<IUser> and returns the Document-derived IUser, so every consumer of OpenIDUserLookup must implement a storage-specific Mongoose contract despite this service needing only ordinary identity fields and a plain user record. This widens the database abstraction leak the repository explicitly requires new packages/api signatures to avoid.

AGENTS.md reference: AGENTS.md:L83-L87

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in e7054e5 by not exporting a new type. OpenIDUserLookup is gone, and findOpenIDUser keeps the inline parameter type it had before this PR (findUser: UserMethods['findUser'], unchanged from dev). createOpenIDUser takes Parameters<typeof findOpenIDUser>[0], so it passes through exactly the contract that already exists rather than widening it.

Replacing that contract with a plain equality lookup is not a drop-in change here: findOpenIDUser builds Mongo operator conditions ({ openidIssuer: { $exists: false } } and the other legacy-issuer filters in getIssuerBoundConditions) that a field-equality lookup cannot express. Moving that query behind a data-schemas method would rewrite the existing lookup, and AGENTS.md asks to stop widening that leak rather than rewrite what exists, which is out of scope for this bug fix. The new contracts this PR adds (createUserIfAbsent, UserRecord, NewUserData) are storage-neutral.

Comment thread api/strategies/openidStrategy.js Outdated
Comment on lines +699 to +703
user = await createOpenIDUser({
lookup,
profile,
balanceConfig: getBalanceConfig(appConfig),
createUserIfAbsent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck tenant policy after recovering the user

When the initial lookup misses but a tenant-scoped OpenID account is provisioned by another flow before this insert, appConfig was fixed to the base config and its allowedDomains check already ran before the recovered user was known. If role sync leaves the recovered account's role unchanged, the later post-sync check is skipped, so this callback can authenticate an email that an ordinary login to the same account would reject under its tenant/role configuration; resolve and enforce the recovered user's config before continuing.

AGENTS.md reference: AGENTS.md:L186-L189

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in e7054e5. A recovered account is now admitted exactly as if the first lookup had found it. If it has a tenantId, createOpenIDUser resolves its config through resolveAppConfigForUser (the same call the strategy makes for a found tenant user) and enforces that config's allowedDomains, throwing 'Email domain not allowed', which createOpenIDCallback already maps to done(null, false, { message }). It returns { user, appConfig }, and the strategy continues under the recovered account's config for the rest of the login (role sync, avatar strategy).

Tests (openid.spec.ts, real MongoDB inside a tenant context, so the insert collides on the tenant-scoped indexes): a tenant account whose config allows only other.example fails the recovered login with 'Email domain not allowed', after resolving the config with that tenantId; a tenant config that admits the email continues, and the returned appConfig is the tenant's. With recovery keeping the base config, both tests fail.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7054e5fa9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/api/src/auth/openid.ts Outdated
}

const userId = resolution.user._id.toString();
if (hasStartBalance(balanceConfig) && !(await findBalanceByUser(userId))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Resolve account config before checking its balance

When tenant provisioning races this callback, balanceConfig still comes from the pre-recovery base config, even though the winner discovered below may have a different tenant/role balance policy. If the base enables a start balance but the tenant disables it, the recovered login is incorrectly rejected for lacking a balance; conversely, if only the tenant enables it, this skips the mixed-version barrier and lets downstream balance initialization race an older creator's credit write. Resolve accountConfig first and test the balance configuration that actually applies to the recovered account.

AGENTS.md reference: AGENTS.md:L44-L47

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 144acc7. createOpenIDUser now resolves the recovered account's config first (tenant config via resolveAppConfigForUser for a tenant account), enforces its email-domain policy, and only then applies the provisioning barrier, using the start balance that config sets: the config login balance sync initializes from. It takes getBalanceConfig as a dependency: the strategy passes the @librechat/api export, and importing it from app/config would close a cycle through utils/identity.

Tests (openid.spec.ts, real MongoDB in a tenant context): with a base start balance of 500 and a tenant config that sets none, a recovered tenant account without a balance continues; with no base start balance and a tenant start balance of 500, the same account fails with AUTH_FAILED. Checking the base config's policy instead fails both tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correction to my earlier reply: the change in 144acc7 was wrong, and 336434f reverts it. The barrier guards against a previous-release creator's $inc landing on a balance that login balance sync already initialized. That creator credits the base config's start balance only, so:

  • "tenant enables, base does not": there is no old $inc to race, because tenant balances are initialized insert-only by balance sync.
  • "base enables, tenant disables": a creator on this release writes the base start balance before the user exists, so the recovered login finds it.

Keying the barrier on the tenant config instead failed every concurrent first login in tenants that set a start balance the base config does not (the next review round's finding). The barrier now reads the config new users are created under; see the reply on that thread for the tests.

@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Two requests for the same not-yet-created OpenID identity both missed
findOpenIDUser and both called createUser; the unique email and OpenID
indexes rejected the second insert, and that login failed with E11000.

createUserIfAbsent (data-schemas) inserts a first-login user and reports
a lost race as { ok: false, error: { code: 'user_exists' } }. It
initializes the start balance under the new user's id before inserting
the user, so the account is never visible without it, and removes that
balance when the write fails or a unique index rejects the insert.
createUser shares the insert-only credit (creditStartBalance).

createOpenIDUser (packages/api) creates through it and, on user_exists,
repeats findOpenIDUser with its provider and issuer checks and continues
as the account that won, admitted and refreshed exactly as if the first
lookup had found it: the account's own config (tenant config for a
tenant account) and its email-domain policy apply, and applyOpenIDProfile
writes this callback's claims. It continues only once the winner
finished provisioning the account: when that config sets a start
balance, the balance must exist, which a previous-release creator
credits only after its insert. UserRecord and NewUserData are the plain,
storage-neutral contracts.

Related to #16766
@danny-avila
danny-avila force-pushed the danny-avila/librechat-issue-16766-a94100 branch from 144acc7 to cc5ff47 Compare October 5, 2026 16:03
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cc5ff47aa5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

idOnTheSource: profile.idOnTheSource,
openidIssuer: profile.openidIssuer,
},
getBalanceConfig(appConfig) ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Initialize with the tenant's balance policy

When concurrent first logins create a tenant-scoped account and the base config has no start balance while the tenant config does, this call provisions the winner using the base appConfig, so the user becomes visible without a balance. The losing request then resolves the tenant config and fails the new barrier at line 418 before the winner reaches setBalanceConfig; thus the one-off first-login failure remains timing-dependent for this supported configuration. Resolve the new account's tenant configuration before provisioning, or otherwise let the barrier distinguish a same-version creator whose tenant balance initialization is still pending.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 336434f, by reverting the barrier to the config new users are created under rather than adding a tenant-aware creation path. The barrier exists for one hazard: a previous-release creator adding the start balance with $inc after login balance sync initialized the record. Previous-release creators only credit the base config's start balance (getBalanceConfig(baseConfig) for every new user), so the barrier now applies only when appConfig, the config the account is created under, sets a start balance. A start balance that only the tenant config sets is initialized by login balance sync with an insert-only write (upsertBalanceFields with insertOnly), which nothing adds on top of, so it no longer holds the recovered login back.

Tests (openid.spec.ts, real MongoDB in a tenant context): no base start balance and a tenant start balance of 500, with the recovered account lacking a balance, continues (your scenario). A base start balance of 500 and a tenant config without one, with no balance, fails with AUTH_FAILED. Keying the barrier on the tenant config again fails both tests.

The provisioning barrier read the recovered account's tenant config.
With a tenant start balance the base config does not set, every
concurrent first login in that tenant failed again, because the winner
creates the user under the base config and login balance sync only
initializes the tenant balance afterwards.

The barrier guards against a previous-release creator's $inc landing on
a balance login balance sync already initialized, and that creator only
credits the base config's start balance. createOpenIDUser now holds the
recovered login only when the config new users are created under sets a
start balance; a tenant-only start balance is initialized insert-only by
login balance sync and cannot be added twice.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 336434f1cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danny-avila
danny-avila added this pull request to stack #16795 October 5, 2026 21:56
@danny-avila
danny-avila merged commit f556fc0 into dev Oct 5, 2026
37 checks passed
@danny-avila
danny-avila deleted the danny-avila/librechat-issue-16766-a94100 branch October 5, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🗺️ Backend Infra codegraph: the taxonomy area this belongs to (classifier, confidence ≥ 0.9) 🛡️ security review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant