RocketChat / RocketChat/Rocket.Chat
SAML login fails when user has TOTP 2FA enabled: credential token is deleted before the 2FA retry
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 46.1k
- Forks
- 13.9k
- Avg merge
- 3d 3h
- Merged PRs (30d)
- 130
Description
Description:
Users who have Rocket.Chat TOTP 2FA enabled cannot log in via SAML. The SAML login handler deletes the one-time SAML credential token before the global 2FA onValidateLogin callback runs. When that callback throws totp-required, the client prompts for the authenticator code and retries the login with the same credentialToken — which no longer exists — so the second attempt fails with No matching login attempt found. The TOTP modal then relabels that error as "Invalid two factor code", which sends admins down the wrong path of debugging their IdP/SAML configuration.
Rocket.Chat already works around this exact problem for OAuth by extending the pending credential's lifetime by two minutes (// Work-around to make the credentials reusable for 2FA), but no equivalent protection exists for SAML.
This is likely the root cause of #41394 and #29388 (both report SAML + 2FA failing, working again once 2FA is disabled).
Sequence:
SAML IdP redirects back to Rocket.Chat with valid assertion
↓
SAML login handler: retrieveCredential(token) → OK
↓
SAML login handler: CredentialTokens.removeById(token) ← token deleted
↓
onValidateLogin 2FA callback: user has TOTP → throws "totp-required"
↓
Client shows TOTP modal, user enters code
↓
Client retries: { totp: { login: { saml: true, credentialToken }, code } }
↓
'totp' handler → _runLoginHandlers → SAML handler → retrieveCredential(token)
↓
Token no longer exists → "No matching login attempt found"
↓
TOTP modal displays "Invalid two factor code" (misleading)
Relevant code:
Server — SAML login handler deletes the token immediately after reading it (apps/meteor/server/lib/saml/loginHandler.ts#L28-L35):
const loginResult = await SAML.retrieveCredential(loginRequest.credentialToken);
await CredentialTokens.removeById(loginRequest.credentialToken);
SAMLUtils.log({ msg: 'RESULT', loginResult });
if (!loginResult) {
return makeError('No matching login attempt found');
}
Server — the 2FA validator does not exclude saml from 2FA enforcement, and the totp handler simply re-runs the inner login (in this case the SAML handler) (apps/meteor/server/lib/2fa/loginHandler.ts#L17-L50):
Accounts.registerLoginHandler('totp', function (options) {
if (!options.totp?.code) {
return;
}
return Accounts._runLoginHandlers(this, options.totp.login);
});
callbacks.add('onValidateLogin', async (login) => {
if (
!login.user ||
login.type === 'resume' ||
login.type === 'proxy' ||
login.type === 'cas' ||
...
) {
return login;
}
// ...
await checkCodeForUser({ user: login.user, code: totp?.code, options: { disablePasswordFallback: true } });
Server — the OAuth equivalent already extends the credential's lifetime so it survives the 2FA retry (apps/meteor/server/lib/2fa/loginHandler.ts#L94-L107):
// Work-around to make the credentials reusable for 2FA
const future = new Date();
future.setMinutes(future.getMinutes() + 2);
await OAuth._pendingCredentials.updateAsync(
{ _id: pendingCredential._id },
{ $set: { createdAt: future } },
);
Client — the TOTP retry reuses the same credentialToken (apps/meteor/client/meteor/login/saml.ts#L115-L130):
const loginWithSamlTokenAndTOTP = (credentialToken: string, code: string) =>
callLoginMethod({
methodArguments: [
{
totp: {
login: {
saml: true,
credentialToken,
},
code,
},
},
],
});
Meteor.loginWithSamlToken = handleLogin(loginWithSamlToken, loginWithSamlTokenAndTOTP);
Steps to reproduce:
- Configure a working SAML provider (e.g. SimpleSAMLphp) and confirm SAML login works for a user without 2FA enabled.
- Enable
Accounts_TwoFactorAuthentication_EnabledandAccounts_TwoFactorAuthentication_By_TOTP_Enabled. - For the SAML-linked user, enable TOTP in Account → Security and confirm with a valid code.
- Log out, open a fresh incognito window, click "Login with SAML" and authenticate at the IdP.
- Rocket.Chat shows the TOTP modal. Enter a valid, current code from the authenticator app.
- Observe "Invalid two factor code". Retry with a fresh code — same result. Repeat from step 4 — same result every time.
- Disable TOTP for the same user and repeat step 4 — login succeeds immediately.
Expected behavior:
After a successful SAML assertion, a user with TOTP enabled should be prompted for their code, and a valid code should complete the login — same as password login + TOTP, and same as OAuth login + TOTP.
Actual behavior:
The second login attempt (with the TOTP code) always fails because the SAML credential token was removed during the first attempt. The user sees "Invalid two factor code" regardless of whether the code is correct. SAML + TOTP login is impossible; the only workaround is to disable 2FA for the user.
Server Setup Information:
- Version of Rocket.Chat Server: 8.8.0 (code path confirmed still present on
develop) - License Type: Community
- Number of Users:
- Operating System:
- Deployment Method: docker
- Number of Running Instances: 1
- DB Replicaset Oplog:
- NodeJS Version:
- MongoDB Version:
Client Setup Information
- Desktop App or Browser Version: Browser (any)
- Operating System:
Additional context
Suggested fix
Mirror the OAuth work-around: do not remove the SAML credential token in the login handler on first use. Instead, extend its expiration briefly (e.g. 2 minutes) so a TOTP retry can reuse it, and remove it only after the login has been fully validated (or let the existing TTL/expiration clean it up). Conceptually:
const loginResult = await SAML.retrieveCredential(loginRequest.credentialToken);
if (!loginResult) {
return makeError('No matching login attempt found');
}
// Retain the credential briefly so a 2FA (TOTP) retry can reuse it,
// mirroring the OAuth work-around in server/lib/2fa/loginHandler.ts.
await CredentialTokens.extendExpiration(loginRequest.credentialToken, 2 * 60 * 1000);
Alternatives: remove the token in an onValidateLogin/afterValidateLogin hook after 2FA has passed, or skip removal when the login was invoked via the totp wrapper.
Secondary issue — misleading error message
The 2FA modal catches any error from onConfirm() and displays Invalid_two_factor_code without checking that the server actually returned totp-invalid. The real server error (No matching login attempt found) is swallowed. Surfacing the underlying error (or only showing Invalid_two_factor_code for totp-invalid) would have made this much faster to diagnose.
Diagnostic confirmation without a rebuild: enable SAML debug logging and reproduce once. The log should show a valid RESULT on the first attempt, followed by No matching login attempt found when the TOTP code is submitted.
Related issues: #41394, #29388, #21533 (same class of problem for custom OAuth).
Relevant logs:
Expected server log sequence with SAML debug enabled:
[SAML] RESULT { loginResult: { profile: { ... } } } ← first attempt, token deleted here
Exception while invoking method login Error: TOTP Required [totp-required]
[SAML] RESULT { loginResult: undefined } ← retry with TOTP code, token gone
Error: No matching login attempt found
Browser shows: Invalid two factor code
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with apps/meteor/server/lib/saml/loginHandler.ts and apps/meteor/server/lib/2fa/loginHandler.ts, comparing SAML credential removal with the OAuth pending-credential workaround; then inspect apps/meteor/client/meteor/login/saml.ts for the retry flow. Reproduce with SAML and TOTP enabled, and consider the relevant login tests or debug logs; done means a valid TOTP retry completes SAML login without the token-missing error.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- authentication
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 62/100