fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica

- Every JWT carries a jti. The SSO completion token and the MFA challenge
  (once a code was accepted) are recorded as used in ConsumedToken; a
  replayed request or copied cookie cannot open a second session. Used
  ids are cleaned up after they expire.
- The token-validity check (active user, active role, password change)
  reads fresh on every request and refreshes the permission cache, so a
  deactivation or role change made on another replica applies to the very
  next request instead of up to 30 s later.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:24:28 +02:00
1 parent fc6af8737b
commit 45efb08369
16 files changed
+166 -23

No files matched your search

@@ -7,6 +7,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
import { createTestApp } from './utils/create-test-app';
import { OneTimeTokenService } from '../src/auth/one-time-token.service';
/**
* Account-level brute-force protection and one-time TOTP codes, against a
@@ -91,4 +92,14 @@ describe('Account guard (e2e)', () => {
.send({ mfaToken: (second.body as { mfaToken: string }).mfaToken, code })
.expect(401);
});
it('lets a one-time token be used exactly once', async () => {
const oneTimeTokens = app.get(OneTimeTokenService);
const claims = {
jti: `jti-${suffix}`,
exp: Math.floor(Date.now() / 1000) + 60,
};
await expect(oneTimeTokens.consume(claims)).resolves.toBeUndefined();
await expect(oneTimeTokens.consume(claims)).rejects.toThrow();
});
});