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

@@ -0,0 +1,8 @@
-- One-time helper tokens (SSO completion, MFA challenge) are recorded once
-- used; a second use of the same jti is refused.
CREATE TABLE "ConsumedToken" (
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ConsumedToken_pkey" PRIMARY KEY ("jti")
);
CREATE INDEX "ConsumedToken_expiresAt_idx" ON "ConsumedToken"("expiresAt");
+9
View File
@@ -157,6 +157,15 @@ model ApiKey {
@@index([revokedAt])
}
// Einmal-Tokens (SSO-Abschluss, MFA-Zwischenschritt), die schon benutzt
// wurden. Ein zweiter Versuch mit derselben jti wird abgelehnt.
model ConsumedToken {
jti String @id
expiresAt DateTime
@@index([expiresAt])
}
model RefreshToken {
id String @id @default(cuid())
userId String
+2 -1
View File
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { OneTimeTokenService } from './one-time-token.service';
import { MfaModule } from './mfa/mfa.module';
import { SsoModule } from './sso/sso.module';
import { SsoController } from './sso/sso.controller';
@@ -18,6 +19,6 @@ import { SsoController } from './sso/sso.controller';
SsoModule,
],
controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy],
providers: [AuthService, JwtStrategy, OneTimeTokenService],
})
export class AuthModule {}
+12 -2
View File
@@ -6,6 +6,7 @@ import { UsersService } from '../users/users.service';
import { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
describe('AuthService', () => {
let authService: AuthService;
@@ -28,6 +29,7 @@ describe('AuthService', () => {
signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock;
};
let oneTimeTokens: { consume: jest.Mock };
let accountGuard: {
isLocked: jest.Mock;
recordFailure: jest.Mock;
@@ -67,6 +69,7 @@ describe('AuthService', () => {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
verifyChallengeToken: jest.fn(),
};
oneTimeTokens = { consume: jest.fn().mockResolvedValue(undefined) };
accountGuard = {
isLocked: jest.fn().mockReturnValue(false),
recordFailure: jest.fn().mockResolvedValue(undefined),
@@ -82,6 +85,7 @@ describe('AuthService', () => {
permissionResolver as never,
mfaService as unknown as MfaService,
accountGuard as unknown as AccountGuardService,
oneTimeTokens as unknown as OneTimeTokenService,
);
});
@@ -214,7 +218,10 @@ describe('AuthService', () => {
};
it('issues tokens and records LOGIN when the code is valid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
mfaService.verifyChallengeToken.mockReturnValue({
userId: mfaUser.id,
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
usersService.findById.mockResolvedValue(mfaUser);
accountGuard.consumeTotp.mockResolvedValue(true);
prisma.refreshToken.create.mockResolvedValue({});
@@ -233,7 +240,10 @@ describe('AuthService', () => {
});
it('throws and records LOGIN_FAILED when the code is invalid', async () => {
mfaService.verifyChallengeToken.mockReturnValue({ userId: mfaUser.id });
mfaService.verifyChallengeToken.mockReturnValue({
userId: mfaUser.id,
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
usersService.findById.mockResolvedValue(mfaUser);
accountGuard.consumeTotp.mockResolvedValue(false);
+5 -1
View File
@@ -8,6 +8,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
@@ -64,6 +65,7 @@ export class AuthService {
private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService,
private readonly oneTimeTokens: OneTimeTokenService,
) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -208,7 +210,7 @@ export class AuthService {
}
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
const { userId, claims } = this.mfaService.verifyChallengeToken(mfaToken);
const user = await this.usersService.findById(userId);
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar');
@@ -230,6 +232,8 @@ export class AuthService {
});
throw new UnauthorizedException('Code ungültig');
}
// A challenge that already opened a session cannot open another one.
await this.oneTimeTokens.consume(claims);
await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = {
id: user.id,
@@ -139,10 +139,13 @@ describe('MfaService', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'mfa-challenge',
jti: 'jti-1',
exp: 1_900_000_000,
});
expect(mfaService.verifyChallengeToken('token')).toEqual({
userId: 'user-1',
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
});
+13 -7
View File
@@ -9,6 +9,7 @@ import {
verifyTotpStep,
} from './totp.util';
import {
OneTimeTokenClaims,
TOKEN_AUDIENCE,
jwtSignOptions,
jwtVerifyOptions,
@@ -91,13 +92,15 @@ export class MfaService {
);
}
verifyChallengeToken(challengeToken: string): { userId: string } {
let payload: ChallengeTokenPayload;
verifyChallengeToken(challengeToken: string): {
userId: string;
claims: OneTimeTokenClaims;
} {
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
try {
payload = this.jwtService.verify<ChallengeTokenPayload>(
challengeToken,
jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge),
);
payload = this.jwtService.verify<
ChallengeTokenPayload & OneTimeTokenClaims
>(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
} catch {
throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
@@ -106,7 +109,10 @@ export class MfaService {
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return { userId: payload.sub };
return {
userId: payload.sub,
claims: { jti: payload.jti, exp: payload.exp },
};
}
/** See verifyTotpStep; use AccountGuardService.consumeTotp for logins. */
@@ -0,0 +1,38 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '../../generated/prisma';
import { OneTimeTokenClaims } from './util/jwt-claims';
/**
* Makes a short-lived helper token usable exactly once (SSO completion,
* MFA challenge after a successful code): its id is recorded, and a
* second presentation - a replayed request, a copied cookie - is refused.
* Works across replicas because the record lives in the database.
*/
@Injectable()
export class OneTimeTokenService {
constructor(private readonly prisma: PrismaService) {}
async consume(claims: OneTimeTokenClaims): Promise<void> {
if (!claims.jti || !claims.exp) {
throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut anmelden',
);
}
try {
await this.prisma.consumedToken.create({
data: { jti: claims.jti, expiresAt: new Date(claims.exp * 1000) },
});
} catch (error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new UnauthorizedException(
'Diese Anmeldung wurde bereits abgeschlossen, bitte erneut anmelden',
);
}
throw error;
}
}
}
@@ -165,15 +165,34 @@ describe('PermissionResolverService', () => {
);
});
it('shares the cached entry with getPermissions instead of re-querying', async () => {
it('reads fresh every time and refreshes the cache for getPermissions', async () => {
prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'),
);
await resolver.getPermissions('user-1');
await resolver.isTokenValid('user-1', nowSeconds);
await resolver.getPermissions('user-1');
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
// One read for the first getPermissions, one fresh read for the
// validity check - the second getPermissions uses that fresh entry.
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
});
it('sees a deactivation made elsewhere on the next request', async () => {
prisma.user.findUnique.mockResolvedValueOnce(
activeUserWithPermissions('colonies:read'),
);
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
true,
);
prisma.user.findUnique.mockResolvedValueOnce({
...activeUserWithPermissions('colonies:read'),
isActive: false,
});
await expect(resolver.isTokenValid('user-1', nowSeconds)).resolves.toBe(
false,
);
});
});
@@ -37,11 +37,18 @@ export class PermissionResolverService {
return entry.permissions;
}
/**
* Always read fresh (one primary-key lookup) and stored as the cache
* entry the PermissionsGuard then uses: a deactivation, role change or
* password change made on ANOTHER replica takes effect on the very next
* request here, not after the cache window.
*/
async isTokenValid(
userId: string,
tokenIssuedAtSeconds: number,
): Promise<boolean> {
const entry = await this.getEntry(userId);
const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
if (!entry.isActive) {
return false;
}
@@ -17,6 +17,7 @@ import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { OneTimeTokenService } from '../one-time-token.service';
import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier';
@@ -40,6 +41,7 @@ export class SsoController {
constructor(
private readonly ssoService: SsoService,
private readonly authService: AuthService,
private readonly oneTimeTokens: OneTimeTokenService,
) {}
private frontendLoginUrl(): string {
@@ -135,7 +137,8 @@ export class SsoController {
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
const { userId } = this.ssoService.verifySsoCode(ssoCode);
const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
await this.oneTimeTokens.consume(claims);
const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken };
@@ -263,8 +263,13 @@ describe('SsoService', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'sso-complete',
jti: 'jti-1',
exp: 1_900_000_000,
});
expect(ssoService.verifySsoCode('token')).toEqual({
userId: 'user-1',
claims: { jti: 'jti-1', exp: 1_900_000_000 },
});
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
});
it('throws UnauthorizedException when verification fails', () => {
+13 -7
View File
@@ -8,6 +8,7 @@ import { JwtService } from '@nestjs/jwt';
import * as client from 'openid-client';
import { UsersService } from '../../users/users.service';
import {
OneTimeTokenClaims,
TOKEN_AUDIENCE,
jwtSignOptions,
jwtVerifyOptions,
@@ -178,13 +179,15 @@ export class SsoService {
);
}
verifySsoCode(ssoCode: string): { userId: string } {
let payload: SsoCompleteTokenPayload;
verifySsoCode(ssoCode: string): {
userId: string;
claims: OneTimeTokenClaims;
} {
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
try {
payload = this.jwtService.verify<SsoCompleteTokenPayload>(
ssoCode,
jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete),
);
payload = this.jwtService.verify<
SsoCompleteTokenPayload & OneTimeTokenClaims
>(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
} catch {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
@@ -193,6 +196,9 @@ export class SsoService {
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return { userId: payload.sub };
return {
userId: payload.sub,
claims: { jti: payload.jti, exp: payload.exp },
};
}
}
@@ -1,4 +1,5 @@
import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
import { randomUUID } from 'node:crypto';
/**
* Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
@@ -34,9 +35,17 @@ export function jwtSignOptions(
issuer: JWT_ISSUER,
audience,
expiresIn,
// Unique id, so a one-time token can be marked as used.
jwtid: randomUUID(),
};
}
/** What a verified one-time token carries for OneTimeTokenService. */
export interface OneTimeTokenClaims {
jti?: string;
exp?: number;
}
export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
return {
secret: process.env.JWT_ACCESS_SECRET,
@@ -24,6 +24,10 @@ export class IdempotencyCleanupService {
if (count > 0) {
this.logger.log(`Removed ${count} expired idempotency record(s)`);
}
// Used one-time tokens only matter until the token itself expires.
await this.prisma.consumedToken.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
}
}
@@ -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();
});
});