Skip to content

🔁 fix: Recover Concurrent First SAML, LDAP and Social Logins - #16779

Merged
danny-avila merged 2 commits into
devfrom
danny-avila/login-first-insert-race
Oct 5, 2026
Merged

danny-avila merged 2 commits into
devfrom
danny-avila/login-first-insert-race

Conversation

@danny-avila

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

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

SAML, LDAP and social (Google, GitHub, Discord, Facebook, Apple) logins have the same race #16778 fixes for OpenID. Each one looks the user up, and when there is no account, inserts one with nothing in between. Two first logins for the same new identity both miss the lookup and both insert; the unique email or provider-identity index rejects the second, and that login fails with E11000 duplicate key error even though the account now exists.

Every strategy now inserts first-login users through createUserOnce in packages/api, on top of #16778's createUserIfAbsent. When the insert reports user_exists, the request repeats the strategy's own lookup, with its provider and identity checks, and continues as the account the other request created once that request finished provisioning it (when the config new users are created under sets a start balance, the balance must exist; #16778 explains why). That account is admitted exactly as if the first lookup had found it (a tenant account resolves its tenant config and that config's email-domain policy applies), and gets the same refresh any account found at login gets: SAML claims the NameID binding and updates the profile through claimSamlIdentity, LDAP overwrites provider, ldapId, email, username and name from the directory, and social runs handleExistingUser (avatar and email). If the lookup rejects the account (another provider took the email, or a different NameID holds it), the login fails with the strategy's normal AUTH_FAILED response; if the account's policy rejects the email, with its normal Email domain not allowed response. If the lookup finds nothing that explains the conflict, the login fails with an error, as an unexpected duplicate did before.

Stacked on #16778 (danny-avila/librechat-issue-16766-a94100); merge that first.

Related to #16766

How it works

Each strategy's lookup and the whole create / recover / refresh decision live in packages/api. The CJS strategies pass their models and services and map the result to passport's done/cb as before:

packages/api/src/auth/
├── provision.ts   # createUserOnce + plain contracts (FindUserByFields, CreateUserIfAbsent, FindBalanceByUser, GetBalanceConfig, LoginUserResolution)
├── saml.ts        # + findSamlUser (NameID, then email; provider + NameID checks), provisionSamlUser
├── ldap.ts        # findLdapUser (ldapId; provider check), provisionLdapUser
├── social.ts      # findSocialUser / resolveSocialUser (provider id, then email), provisionSocialUser
└── openid.ts      # createOpenIDUser now goes through createUserOnce
samlStrategy callback
  findSamlUser(...)                        # error -> done(null, false, AUTH_FAILED)
  policy checks (tenant config, allowed domains, existingUsersOnly)
  provisionSamlUser({ user, ..., findUser, createUserIfAbsent, claimSamlIdentity })
    found account -> claimSamlIdentity                      # unchanged path
    no account    -> createUserOnce
                       ok          -> new user
                       user_exists -> findSamlUser again, start balance present,
                                      tenant config + allowedDomains -> claimSamlIdentity
  error -> done(null, false, AUTH_FAILED)

provisionLdapUser overwrites provider, ldapId, email, username and name from the directory for found and recovered accounts alike, and creates the deployment's first user as ADMIN. provisionSocialUser hands a new account to finishNewUser (the existing avatar step, moved unchanged into finishNewSocialUser in process.js) and a recovered one to refreshExistingUser (handleExistingUser), and throws the coded AUTH_FAILED that socialLogin passes to its callback. After a lost race, createUserOnce reads the recovered account's tenant config and its start balance in parallel. All contracts are plain: lookups take FindUserByFields rather than UserMethods['findUser'], and accounts are UserRecord rather than the IUser document type.

Type of change

  • Bug fix

Testing

Tested environments/configuration:

  • Database: mongodb-memory-server for createUserOnce; strategy callbacks with mocked models

Automated tests:

  • packages/api/src/auth/provision.spec.ts (real MongoDB). createUserOnce: two concurrent first logins create one account and resolve to it (created true for one, false for the other); a lookup that rejects the account returns its error; an account found before its winner credited the start balance returns AUTH_FAILED, and one whose balance exists recovers; inside a tenant context, a recovered tenant account whose config disallows the email returns Email domain not allowed, one whose tenant config alone sets a start balance continues (login balance sync initializes it insert-only), and one that admits it continues under the tenant's config; a conflict the lookup cannot explain throws; a validation failure throws without repeating the lookup. provisionSamlUser claims the account a concurrent login created with the losing login's username and name, and fails on an other-provider email. provisionLdapUser creates the first user as ADMIN and carries the losing login's directory values onto the recovered account. provisionSocialUser refreshes a recovered account instead of finishing it as new, and throws the coded AUTH_FAILED on an other-provider email.
  • api/strategies/samlStrategy.spec.js › concurrent first login: the losing callback claims the winner's account with its own username and name through claimSamlIdentity; a concurrent other-provider account fails with AUTH_FAILED and nothing is written.
  • api/strategies/ldapStrategy.spec.js › concurrent first login: the losing login updates the winner's account with its LDAP values and keeps the stored role; a recovered account owned by another provider fails with AUTH_FAILED.
  • samlStrategy.spec.js, ldapStrategy.spec.js, process.test.js: a recovered tenant account whose config allows only another domain fails with each strategy's Email domain not allowed response.
  • api/strategies/process.test.js › createSocialUser › concurrent first login: the recovered account goes through handleExistingUser (a changed email is written) and is returned without new-user processing; a rejected lookup throws the coded AUTH_FAILED that socialLogin passes to the callback.
  • api/strategies/socialLogin.test.js: createSocialUser receives the same lookup parameters the login's first search used.
  • Fix removal: with createUserOnce failing on user_exists instead of recovering, 13 tests in packages/api and all 9 strategy race tests fail. With each provisioner's refresh of a recovered account removed, exactly the SAML, LDAP and social refresh tests fail in both provision.spec.ts and the strategy specs. With the provisioning check removed from createUserOnce, the missing-balance tests in provision.spec.ts, openid.spec.ts and openidStrategy.spec.js fail. With recovery keeping the base config, the 4 tenant-policy tests in provision.spec.ts and openid.spec.ts and the SAML, LDAP and createSocialUser tenant tests fail.
  • Focused set (codegraph depth 1): openid.spec.ts, saml.spec.ts, provision.spec.ts, remoteAgentAuth.spec.ts, web/agent.spec.ts, domain.spec.ts (435 passed); AuthController.spec.js, appleStrategy.test.js, ldapStrategy.spec.js, openIdJwtStrategy.spec.js, openidStrategy.spec.js, process.test.js, samlStrategy.spec.js, socialLogin.test.js (389 passed).

Screenshots / recordings

No user-facing change.

Risk / compatibility

Low. The normal path still does one lookup and one insert per first login; recovery only runs on user_exists. The moved lookups keep their queries, checks and log lines. LDAP now creates through createUserIfAbsent, which returns the created record rather than an id, so the follow-up updateUser receives the full record, as it already does for every returning LDAP user. Start balances are credited once, with the creating request's value, through #16778's creditStartBalance.

samlStrategy.spec.js now mocks only the six fs/path functions it stubs (existsSync, statSync, readFileSync, isAbsolute, join, normalize, each defaulting to the real implementation) instead of automocking both modules, because loading the real @librechat/api helpers under fully mocked fs and path fails inside the AWS SDK.

Same shape, not changed here: ensurePrincipalExists (packages/api/src/acl/principals.ts) and PermissionService create placeholder users for Entra ID principals during sharing with a lookup-then-insert. That is not a login path.

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 — depends on 🚪 fix: Let Concurrent First OpenID Logins Continue as the New User #16778
  • Required documentation PR: N/A

@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:40:03.766241Z 4ddf500 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.

@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

@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: 8532f3bbf5

ℹ️ 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/process.js Outdated
Comment on lines +104 to +108
const result = await createUserOnce({
lookup,
strategyName: `${provider}Login`,
create: () => createUserIfAbsent(update, 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.

P1 Badge Move duplicate-recovery orchestration into packages/api

This adds the social provisioning contract—calling the creation service, translating its failure, and deciding whether to refresh an existing user—directly to the legacy CJS layer. The same pattern is also added to the LDAP and SAML strategies, but /api is restricted to Express wiring; move this orchestration into a TypeScript auth service and leave these strategy files to pass dependencies and invoke it.

AGENTS.md reference: AGENTS.md:L77-L81

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 bb50ce2. The create / recover / refresh decision and its failure translation now live in TypeScript: provisionSamlUser (packages/api/src/auth/saml.ts), provisionLdapUser (ldap.ts) and provisionSocialUser (social.ts), all on top of createUserOnce. The strategies pass their models and services and keep only the existing mapping of a result to passport's done/cb. createSocialUser is now a single call that hands provisionSocialUser its services: handleExistingUser for a recovered account, and the new-user avatar step, moved unchanged into finishNewSocialUser.

Tests: provision.spec.ts covers the three provisioners against a real MongoDB (stale-lookup recovery, refresh with the losing login's values, other-provider conflict, first-user ADMIN for LDAP). With each provisioner's refresh of a recovered account removed, exactly its refresh test fails in provision.spec.ts and in its strategy spec. With createUserOnce's recovery removed, 13 packages/api tests and all 9 strategy race tests fail.

Comment on lines +6 to +8
export type SocialUserLookup = {
findUser: UserMethods['findUser'];
provider: string;

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 filter types out of auth exports

The exported SocialUserLookup contract exposes UserMethods['findUser'], whose argument is FilterQuery<IUser>, thereby widening the existing Mongoose API leak into this new auth module; the new LDAP and SAML helpers repeat the same dependency. Define a plain lookup interface or put the concrete query behind a data-schemas method so callers of packages/api do not depend on the storage engine.

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 bb50ce2 and 286b582. The new lookups and provisioners take FindUserByFields ((fields: Record<string, string | undefined>) => Promise<UserRecord | null>) and CreateUserIfAbsent, both plain function types in provision.ts, instead of UserMethods['findUser']; countUsers and claimSamlIdentity are plain function types as well. They return UserRecord (the plain stored user from #16778) rather than the IUser document type. data-schemas' findUser still satisfies FindUserByFields, which tsc checks through provision.spec.ts, where the real methods are passed in. No UserMethods or FilterQuery remains in saml.ts, ldap.ts, social.ts or provision.ts.

const created = await create();
if (created.ok) return { user: created.value, error: null, created: true };

const resolution = await 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 Wait for winner provisioning before recovering the login

When two first logins race with balances enabled, this retry can return the inserted user while the winning createUserIfAbsent is still awaiting creditStartBalance (packages/data-schemas/src/methods/user.ts inserts at line 366 but credits at line 374). The recovered LDAP or social request can then reach setBalanceConfig, create the still-missing balance with startBalance, and later have the winner's $inc add startBalance again, so the concurrency path can double-credit the account; coordinate completion or make balance initialization idempotent before returning the recovered user.

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.

Valid, and it applied to #16778's OpenID recovery too, so it is fixed there: a62f59a, then 18f5c30 after the next round there. creditStartBalance writes tokenCredits with $set (and only user with $setOnInsert). When login balance sync initializes the record first, the creating request's configured start balance replaces it rather than being added on top. If the creator's write lands first, sync finds the record and leaves it. This branch is rebased onto 18f5c30.

Test (#16778, user.methods.spec.ts): a balance record with 300 already exists for the new user's id; createUserIfAbsent with startBalance: 500 leaves exactly one record with 500. On the original $inc it would be 800, and with an insert-only credit 300; both variants fail the test.

@danny-avila
danny-avila force-pushed the danny-avila/login-first-insert-race branch from 8532f3b to 286b582 Compare October 5, 2026 14:20
@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. Chef's kiss.

Reviewed commit: 286b582a7d

ℹ️ 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 force-pushed the danny-avila/login-first-insert-race branch 3 times, most recently from 31f54eb to f2ba7f7 Compare October 5, 2026 15:13
@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: f2ba7f7558

ℹ️ 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 force-pushed the danny-avila/login-first-insert-race branch from f2ba7f7 to b64695b Compare October 5, 2026 15:43
@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. You're on a roll.

Reviewed commit: b64695b02d

ℹ️ 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 force-pushed the danny-avila/login-first-insert-race branch from b64695b to cc21cbd Compare October 5, 2026 16:08
@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: cc21cbdb77

ℹ️ 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 force-pushed the danny-avila/login-first-insert-race branch from cc21cbd to 7d0694e Compare October 5, 2026 16:19
@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: 7d0694e551

ℹ️ 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/provision.ts Outdated
Comment on lines +100 to +102
const accountConfig = resolution.user.tenantId
? await resolveAppConfigForUser(getAppConfig, resolution.user)
: appConfig;

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 Start recovery reads in parallel

When a tenanted concurrent login loses the insert race and the base config enables a start balance, this waits for the tenant config lookup before findBalanceByUser at line 110 can begin, even though both reads are independent once resolution.user is known. On an uncached config this adds a full database round trip to the successful SAML, LDAP, social, and OpenID recovery path; start the config and conditional balance reads together, then await their results before applying the checks.

AGENTS.md reference: AGENTS.md:L186-L188

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 4ddf500. With the barrier now reading the creation config (#16778's 336434f), the balance read no longer depends on the tenant config, so createUserOnce starts both with Promise.all: the tenant config resolution (for a tenant account) and the balance read (when the creation config sets a start balance). The checks then run in the same order as before: email-domain policy first, then the provisioning barrier.

Test (provision.spec.ts, real MongoDB in a tenant context): with a slow getAppConfig, the balance read starts before the tenant config resolves. With the reads made serial again, the test fails. #16778's own createOpenIDUser has the same two reads serially; this PR replaces that body with createUserOnce, so the OpenID path gets the parallel version here.

@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. Chef's kiss.

Reviewed commit: 4ddf500316

ℹ️ 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
Base automatically changed from danny-avila/librechat-issue-16766-a94100 to dev October 5, 2026 21:57
SAML, LDAP and social logins looked the user up and then inserted it
with nothing in between, so two first logins for one new identity both
inserted and the unique index failed the second with E11000.

Every strategy now provisions first-login users in packages/api:
createUserOnce inserts through createUserIfAbsent and, on user_exists,
repeats the strategy's own lookup and resolves to the account that won,
once its creator finished provisioning it (its start balance exists when
one is configured), admitted exactly as a found account (a tenant
account resolves its tenant config and that config's email-domain policy
applies). provisionSamlUser, provisionLdapUser and provisionSocialUser
own the create / recover / refresh decision, so a recovered account gets
the same refresh as any account the login finds (claimSamlIdentity, the
LDAP directory overwrite, handleExistingUser), and the CJS strategies
only pass their models and services and map the result to passport.
The lookups take a plain FindUserByFields and the contracts carry
UserRecord and NewUserData. createOpenIDUser now goes through
createUserOnce.

Related to #16766
After losing the insert race, createUserOnce resolved the tenant config
and only then read the start balance, although the balance check now
depends only on the creation config. Both reads start together.
@danny-avila
danny-avila force-pushed the danny-avila/login-first-insert-race branch from 4ddf500 to eaa1d8d Compare October 5, 2026 21:57
@danny-avila
danny-avila marked this pull request as ready for review October 5, 2026 21:57
@danny-avila
danny-avila merged commit 8b58ebc into dev Oct 5, 2026
24 checks passed
@danny-avila
danny-avila deleted the danny-avila/login-first-insert-race 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