From 45efb0836977c2b8f8a45275ee95dd533eb234d8 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 07:24:28 +0200 Subject: [PATCH] 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) --- .../migration.sql | 8 ++++ ApiCore-Backend/prisma/schema.prisma | 9 +++++ ApiCore-Backend/src/auth/auth.module.ts | 3 +- ApiCore-Backend/src/auth/auth.service.spec.ts | 14 ++++++- ApiCore-Backend/src/auth/auth.service.ts | 6 ++- .../src/auth/mfa/mfa.service.spec.ts | 3 ++ ApiCore-Backend/src/auth/mfa/mfa.service.ts | 20 ++++++---- .../src/auth/one-time-token.service.ts | 38 +++++++++++++++++++ .../permission-resolver.service.spec.ts | 23 ++++++++++- .../permission-resolver.service.ts | 9 ++++- .../src/auth/sso/sso.controller.ts | 5 ++- .../src/auth/sso/sso.service.spec.ts | 7 +++- ApiCore-Backend/src/auth/sso/sso.service.ts | 20 ++++++---- ApiCore-Backend/src/auth/util/jwt-claims.ts | 9 +++++ .../idempotency-cleanup.service.ts | 4 ++ .../test/account-guard.e2e-spec.ts | 11 ++++++ 16 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261001130000_consumed_tokens/migration.sql create mode 100644 ApiCore-Backend/src/auth/one-time-token.service.ts diff --git a/ApiCore-Backend/prisma/migrations/20261001130000_consumed_tokens/migration.sql b/ApiCore-Backend/prisma/migrations/20261001130000_consumed_tokens/migration.sql new file mode 100644 index 0000000..ae3a084 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261001130000_consumed_tokens/migration.sql @@ -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"); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 8ec0ce7..e2bc51d 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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 diff --git a/ApiCore-Backend/src/auth/auth.module.ts b/ApiCore-Backend/src/auth/auth.module.ts index 45d2f82..44e2889 100644 --- a/ApiCore-Backend/src/auth/auth.module.ts +++ b/ApiCore-Backend/src/auth/auth.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/auth/auth.service.spec.ts b/ApiCore-Backend/src/auth/auth.service.spec.ts index 8391f5c..bb5d4b9 100644 --- a/ApiCore-Backend/src/auth/auth.service.spec.ts +++ b/ApiCore-Backend/src/auth/auth.service.spec.ts @@ -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); diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index 98521f4..687ed54 100644 --- a/ApiCore-Backend/src/auth/auth.service.ts +++ b/ApiCore-Backend/src/auth/auth.service.ts @@ -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 { @@ -208,7 +210,7 @@ export class AuthService { } async verifyMfaAndLogin(mfaToken: string, code: string): Promise { - 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, diff --git a/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts b/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts index a5bd36f..d9ac18d 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa.service.spec.ts @@ -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 }, }); }); diff --git a/ApiCore-Backend/src/auth/mfa/mfa.service.ts b/ApiCore-Backend/src/auth/mfa/mfa.service.ts index caeb79e..3aaaadd 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa.service.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa.service.ts @@ -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( - 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. */ diff --git a/ApiCore-Backend/src/auth/one-time-token.service.ts b/ApiCore-Backend/src/auth/one-time-token.service.ts new file mode 100644 index 0000000..3c75c59 --- /dev/null +++ b/ApiCore-Backend/src/auth/one-time-token.service.ts @@ -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 { + 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; + } + } +} diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts index 0ebf42f..81b4be9 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.spec.ts @@ -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, + ); }); }); diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts index 8cb4703..8b74e62 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.service.ts @@ -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 { - const entry = await this.getEntry(userId); + const entry = await this.loadEntry(userId); + this.cache.set(userId, entry); if (!entry.isActive) { return false; } diff --git a/ApiCore-Backend/src/auth/sso/sso.controller.ts b/ApiCore-Backend/src/auth/sso/sso.controller.ts index 37d3664..09f2ab8 100644 --- a/ApiCore-Backend/src/auth/sso/sso.controller.ts +++ b/ApiCore-Backend/src/auth/sso/sso.controller.ts @@ -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 }; diff --git a/ApiCore-Backend/src/auth/sso/sso.service.spec.ts b/ApiCore-Backend/src/auth/sso/sso.service.spec.ts index 7e33e7c..fa180a4 100644 --- a/ApiCore-Backend/src/auth/sso/sso.service.spec.ts +++ b/ApiCore-Backend/src/auth/sso/sso.service.spec.ts @@ -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', () => { diff --git a/ApiCore-Backend/src/auth/sso/sso.service.ts b/ApiCore-Backend/src/auth/sso/sso.service.ts index 152cc21..26b897c 100644 --- a/ApiCore-Backend/src/auth/sso/sso.service.ts +++ b/ApiCore-Backend/src/auth/sso/sso.service.ts @@ -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( - 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 }, + }; } } diff --git a/ApiCore-Backend/src/auth/util/jwt-claims.ts b/ApiCore-Backend/src/auth/util/jwt-claims.ts index 8cf39a6..095d1b5 100644 --- a/ApiCore-Backend/src/auth/util/jwt-claims.ts +++ b/ApiCore-Backend/src/auth/util/jwt-claims.ts @@ -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, diff --git a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts index def40e1..7ae9bc8 100644 --- a/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts +++ b/ApiCore-Backend/src/common/idempotency/idempotency-cleanup.service.ts @@ -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; } } diff --git a/ApiCore-Backend/test/account-guard.e2e-spec.ts b/ApiCore-Backend/test/account-guard.e2e-spec.ts index 8681c25..0045fdb 100644 --- a/ApiCore-Backend/test/account-guard.e2e-spec.ts +++ b/ApiCore-Backend/test/account-guard.e2e-spec.ts @@ -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(); + }); });