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]) @@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 { model RefreshToken {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
+2 -1
View File
@@ -5,6 +5,7 @@ import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { OneTimeTokenService } from './one-time-token.service';
import { MfaModule } from './mfa/mfa.module'; import { MfaModule } from './mfa/mfa.module';
import { SsoModule } from './sso/sso.module'; import { SsoModule } from './sso/sso.module';
import { SsoController } from './sso/sso.controller'; import { SsoController } from './sso/sso.controller';
@@ -18,6 +19,6 @@ import { SsoController } from './sso/sso.controller';
SsoModule, SsoModule,
], ],
controllers: [AuthController, SsoController], controllers: [AuthController, SsoController],
providers: [AuthService, JwtStrategy], providers: [AuthService, JwtStrategy, OneTimeTokenService],
}) })
export class AuthModule {} 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 { AuditLogService } from '../audit/audit-log.service';
import { MfaService } from './mfa/mfa.service'; import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service'; import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
describe('AuthService', () => { describe('AuthService', () => {
let authService: AuthService; let authService: AuthService;
@@ -28,6 +29,7 @@ describe('AuthService', () => {
signChallengeToken: jest.Mock; signChallengeToken: jest.Mock;
verifyChallengeToken: jest.Mock; verifyChallengeToken: jest.Mock;
}; };
let oneTimeTokens: { consume: jest.Mock };
let accountGuard: { let accountGuard: {
isLocked: jest.Mock; isLocked: jest.Mock;
recordFailure: jest.Mock; recordFailure: jest.Mock;
@@ -67,6 +69,7 @@ describe('AuthService', () => {
signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'), signChallengeToken: jest.fn().mockReturnValue('mfa-challenge-token'),
verifyChallengeToken: jest.fn(), verifyChallengeToken: jest.fn(),
}; };
oneTimeTokens = { consume: jest.fn().mockResolvedValue(undefined) };
accountGuard = { accountGuard = {
isLocked: jest.fn().mockReturnValue(false), isLocked: jest.fn().mockReturnValue(false),
recordFailure: jest.fn().mockResolvedValue(undefined), recordFailure: jest.fn().mockResolvedValue(undefined),
@@ -82,6 +85,7 @@ describe('AuthService', () => {
permissionResolver as never, permissionResolver as never,
mfaService as unknown as MfaService, mfaService as unknown as MfaService,
accountGuard as unknown as AccountGuardService, 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 () => { 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); usersService.findById.mockResolvedValue(mfaUser);
accountGuard.consumeTotp.mockResolvedValue(true); accountGuard.consumeTotp.mockResolvedValue(true);
prisma.refreshToken.create.mockResolvedValue({}); prisma.refreshToken.create.mockResolvedValue({});
@@ -233,7 +240,10 @@ describe('AuthService', () => {
}); });
it('throws and records LOGIN_FAILED when the code is invalid', async () => { 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); usersService.findById.mockResolvedValue(mfaUser);
accountGuard.consumeTotp.mockResolvedValue(false); 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 { PermissionResolverService } from './permissions/permission-resolver.service';
import { MfaService } from './mfa/mfa.service'; import { MfaService } from './mfa/mfa.service';
import { AccountGuardService } from './mfa/account-guard.service'; import { AccountGuardService } from './mfa/account-guard.service';
import { OneTimeTokenService } from './one-time-token.service';
import { parseDurationToMs } from './util/duration'; import { parseDurationToMs } from './util/duration';
import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims'; import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims';
@@ -64,6 +65,7 @@ export class AuthService {
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
private readonly mfaService: MfaService, private readonly mfaService: MfaService,
private readonly accountGuard: AccountGuardService, private readonly accountGuard: AccountGuardService,
private readonly oneTimeTokens: OneTimeTokenService,
) {} ) {}
async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> { async getCurrentUserProfile(userId: string): Promise<CurrentUserProfile> {
@@ -208,7 +210,7 @@ export class AuthService {
} }
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> { 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); const user = await this.usersService.findById(userId);
if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) { if (!user.isActive || !user.mfaEnabled || !user.mfaSecret) {
throw new UnauthorizedException('MFA nicht verfügbar'); throw new UnauthorizedException('MFA nicht verfügbar');
@@ -230,6 +232,8 @@ export class AuthService {
}); });
throw new UnauthorizedException('Code ungültig'); 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); await this.accountGuard.recordSuccess(user.id);
const authenticatedUser = { const authenticatedUser = {
id: user.id, id: user.id,
@@ -139,10 +139,13 @@ describe('MfaService', () => {
jwtService.verify.mockReturnValue({ jwtService.verify.mockReturnValue({
sub: 'user-1', sub: 'user-1',
purpose: 'mfa-challenge', purpose: 'mfa-challenge',
jti: 'jti-1',
exp: 1_900_000_000,
}); });
expect(mfaService.verifyChallengeToken('token')).toEqual({ expect(mfaService.verifyChallengeToken('token')).toEqual({
userId: 'user-1', userId: 'user-1',
claims: { jti: 'jti-1', exp: 1_900_000_000 },
}); });
}); });
+13 -7
View File
@@ -9,6 +9,7 @@ import {
verifyTotpStep, verifyTotpStep,
} from './totp.util'; } from './totp.util';
import { import {
OneTimeTokenClaims,
TOKEN_AUDIENCE, TOKEN_AUDIENCE,
jwtSignOptions, jwtSignOptions,
jwtVerifyOptions, jwtVerifyOptions,
@@ -91,13 +92,15 @@ export class MfaService {
); );
} }
verifyChallengeToken(challengeToken: string): { userId: string } { verifyChallengeToken(challengeToken: string): {
let payload: ChallengeTokenPayload; userId: string;
claims: OneTimeTokenClaims;
} {
let payload: ChallengeTokenPayload & OneTimeTokenClaims;
try { try {
payload = this.jwtService.verify<ChallengeTokenPayload>( payload = this.jwtService.verify<
challengeToken, ChallengeTokenPayload & OneTimeTokenClaims
jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge), >(challengeToken, jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge));
);
} catch { } catch {
throw new UnauthorizedException( throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden', 'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
@@ -106,7 +109,10 @@ export class MfaService {
if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) { if (payload.purpose !== CHALLENGE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token'); 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. */ /** 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( prisma.user.findUnique.mockResolvedValue(
activeUserWithPermissions('colonies:read'), activeUserWithPermissions('colonies:read'),
); );
await resolver.getPermissions('user-1'); await resolver.getPermissions('user-1');
await resolver.isTokenValid('user-1', nowSeconds); 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; 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( async isTokenValid(
userId: string, userId: string,
tokenIssuedAtSeconds: number, tokenIssuedAtSeconds: number,
): Promise<boolean> { ): Promise<boolean> {
const entry = await this.getEntry(userId); const entry = await this.loadEntry(userId);
this.cache.set(userId, entry);
if (!entry.isActive) { if (!entry.isActive) {
return false; return false;
} }
@@ -17,6 +17,7 @@ import { MfaRequiredResponse } from '../auth.controller';
import { AccessTokenDto } from '../dto/access-token.dto'; import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle'; import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie'; import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { OneTimeTokenService } from '../one-time-token.service';
import { SsoService } from './sso.service'; import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier'; const PKCE_COOKIE = 'oidcPkceVerifier';
@@ -40,6 +41,7 @@ export class SsoController {
constructor( constructor(
private readonly ssoService: SsoService, private readonly ssoService: SsoService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly oneTimeTokens: OneTimeTokenService,
) {} ) {}
private frontendLoginUrl(): string { private frontendLoginUrl(): string {
@@ -135,7 +137,8 @@ export class SsoController {
'SSO-Anmeldung abgelaufen, bitte erneut versuchen', '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); const result = await this.authService.issueTokensForSsoUser(userId);
if (result.mfaRequired) { if (result.mfaRequired) {
return { mfaRequired: true, mfaToken: result.mfaToken }; return { mfaRequired: true, mfaToken: result.mfaToken };
@@ -263,8 +263,13 @@ describe('SsoService', () => {
jwtService.verify.mockReturnValue({ jwtService.verify.mockReturnValue({
sub: 'user-1', sub: 'user-1',
purpose: 'sso-complete', 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', () => { 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 * as client from 'openid-client';
import { UsersService } from '../../users/users.service'; import { UsersService } from '../../users/users.service';
import { import {
OneTimeTokenClaims,
TOKEN_AUDIENCE, TOKEN_AUDIENCE,
jwtSignOptions, jwtSignOptions,
jwtVerifyOptions, jwtVerifyOptions,
@@ -178,13 +179,15 @@ export class SsoService {
); );
} }
verifySsoCode(ssoCode: string): { userId: string } { verifySsoCode(ssoCode: string): {
let payload: SsoCompleteTokenPayload; userId: string;
claims: OneTimeTokenClaims;
} {
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
try { try {
payload = this.jwtService.verify<SsoCompleteTokenPayload>( payload = this.jwtService.verify<
ssoCode, SsoCompleteTokenPayload & OneTimeTokenClaims
jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete), >(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
);
} catch { } catch {
throw new UnauthorizedException( throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen', 'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
@@ -193,6 +196,9 @@ export class SsoService {
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) { if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token'); 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 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 * Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's
@@ -34,9 +35,17 @@ export function jwtSignOptions(
issuer: JWT_ISSUER, issuer: JWT_ISSUER,
audience, audience,
expiresIn, 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 { export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions {
return { return {
secret: process.env.JWT_ACCESS_SECRET, secret: process.env.JWT_ACCESS_SECRET,
@@ -24,6 +24,10 @@ export class IdempotencyCleanupService {
if (count > 0) { if (count > 0) {
this.logger.log(`Removed ${count} expired idempotency record(s)`); 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; return count;
} }
} }
@@ -7,6 +7,7 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util'; import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
import { generateTotpSecret } from '../src/auth/mfa/totp.util'; import { generateTotpSecret } from '../src/auth/mfa/totp.util';
import { createTestApp } from './utils/create-test-app'; 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 * 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 }) .send({ mfaToken: (second.body as { mfaToken: string }).mfaToken, code })
.expect(401); .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();
});
}); });