From e0bd7b20956854877f02e7c072ba35b30279dafb Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 01:14:02 +0200 Subject: [PATCH] fix(auth): accept only access tokens as bearer credentials (MFA bypass) All JWTs (access, MFA setup, MFA challenge, SSO completion) were signed with the same secret and the access-token strategy checked only signature and expiry. The MFA challenge token returned after the password step therefore worked as a full access token: a password alone was enough to use the API, skipping the second factor. Every token now carries a pinned issuer, algorithm (HS256) and a per-purpose audience; the strategy accepts only the access audience and rejects any payload carrying a helper-token purpose. Existing access tokens (no audience) stop working; clients renew them via the refresh cookie. Co-Authored-By: Claude Opus 5.5 (1M context) --- ApiCore-Backend/src/auth/auth.service.ts | 6 +- ApiCore-Backend/src/auth/mfa/mfa.service.ts | 23 +++-- ApiCore-Backend/src/auth/sso/sso.service.ts | 17 ++-- .../src/auth/strategies/jwt.strategy.ts | 14 ++- ApiCore-Backend/src/auth/util/jwt-claims.ts | 47 ++++++++++ .../test/token-confusion.e2e-spec.ts | 89 +++++++++++++++++++ 6 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 ApiCore-Backend/src/auth/util/jwt-claims.ts create mode 100644 ApiCore-Backend/test/token-confusion.e2e-spec.ts diff --git a/ApiCore-Backend/src/auth/auth.service.ts b/ApiCore-Backend/src/auth/auth.service.ts index 42edbe4..ee4f9f5 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 { parseDurationToMs } from './util/duration'; +import { TOKEN_AUDIENCE, jwtSignOptions } from './util/jwt-claims'; interface AuthTokens { accessToken: string; @@ -72,10 +73,7 @@ export class AuthService { ); return this.jwtService.sign( { sub: user.id, email: user.email }, - { - secret: process.env.JWT_ACCESS_SECRET, - expiresIn: expiresInSeconds, - }, + jwtSignOptions(TOKEN_AUDIENCE.access, expiresInSeconds), ); } diff --git a/ApiCore-Backend/src/auth/mfa/mfa.service.ts b/ApiCore-Backend/src/auth/mfa/mfa.service.ts index 4680f56..19bad0a 100644 --- a/ApiCore-Backend/src/auth/mfa/mfa.service.ts +++ b/ApiCore-Backend/src/auth/mfa/mfa.service.ts @@ -3,6 +3,11 @@ import { JwtService } from '@nestjs/jwt'; import * as QRCode from 'qrcode'; import { decryptMfaSecret, encryptMfaSecret } from './mfa-crypto.util'; import { buildTotpUri, generateTotpSecret, verifyTotpCode } from './totp.util'; +import { + TOKEN_AUDIENCE, + jwtSignOptions, + jwtVerifyOptions, +} from '../util/jwt-claims'; const SETUP_TOKEN_TTL = '10m'; const CHALLENGE_TOKEN_TTL = '5m'; @@ -41,7 +46,7 @@ export class MfaService { }); const setupToken = this.jwtService.sign( { sub: userId, secret, purpose: SETUP_TOKEN_PURPOSE }, - { secret: process.env.JWT_ACCESS_SECRET, expiresIn: SETUP_TOKEN_TTL }, + jwtSignOptions(TOKEN_AUDIENCE.mfaSetup, SETUP_TOKEN_TTL), ); return { secret, qrCodeSvg, otpauthUri, setupToken }; } @@ -55,9 +60,10 @@ export class MfaService { ): Promise<{ encryptedSecret: string }> { let payload: SetupTokenPayload; try { - payload = this.jwtService.verify(setupToken, { - secret: process.env.JWT_ACCESS_SECRET, - }); + payload = this.jwtService.verify( + setupToken, + jwtVerifyOptions(TOKEN_AUDIENCE.mfaSetup), + ); } catch { throw new UnauthorizedException( 'Setup-Token ungültig oder abgelaufen, MFA-Einrichtung neu starten', @@ -76,16 +82,17 @@ export class MfaService { signChallengeToken(userId: string): string { return this.jwtService.sign( { sub: userId, purpose: CHALLENGE_TOKEN_PURPOSE }, - { secret: process.env.JWT_ACCESS_SECRET, expiresIn: CHALLENGE_TOKEN_TTL }, + jwtSignOptions(TOKEN_AUDIENCE.mfaChallenge, CHALLENGE_TOKEN_TTL), ); } verifyChallengeToken(challengeToken: string): { userId: string } { let payload: ChallengeTokenPayload; try { - payload = this.jwtService.verify(challengeToken, { - secret: process.env.JWT_ACCESS_SECRET, - }); + payload = this.jwtService.verify( + challengeToken, + jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge), + ); } catch { throw new UnauthorizedException( 'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden', diff --git a/ApiCore-Backend/src/auth/sso/sso.service.ts b/ApiCore-Backend/src/auth/sso/sso.service.ts index 6b7b84d..ea1cadf 100644 --- a/ApiCore-Backend/src/auth/sso/sso.service.ts +++ b/ApiCore-Backend/src/auth/sso/sso.service.ts @@ -7,6 +7,11 @@ import { import { JwtService } from '@nestjs/jwt'; import * as client from 'openid-client'; import { UsersService } from '../../users/users.service'; +import { + TOKEN_AUDIENCE, + jwtSignOptions, + jwtVerifyOptions, +} from '../util/jwt-claims'; const SSO_COMPLETE_TOKEN_TTL = '60s'; const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete'; @@ -111,10 +116,7 @@ export class SsoService { } const ssoCode = this.jwtService.sign( { sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE }, - { - secret: process.env.JWT_ACCESS_SECRET, - expiresIn: SSO_COMPLETE_TOKEN_TTL, - }, + jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL), ); return { status: 'success', ssoCode }; } @@ -122,9 +124,10 @@ export class SsoService { verifySsoCode(ssoCode: string): { userId: string } { let payload: SsoCompleteTokenPayload; try { - payload = this.jwtService.verify(ssoCode, { - secret: process.env.JWT_ACCESS_SECRET, - }); + payload = this.jwtService.verify( + ssoCode, + jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete), + ); } catch { throw new UnauthorizedException( 'SSO-Anmeldung abgelaufen, bitte erneut versuchen', diff --git a/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts b/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts index 7902ead..ac43edc 100644 --- a/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts +++ b/ApiCore-Backend/src/auth/strategies/jwt.strategy.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { AuthenticatedUser } from '../types/authenticated-user'; +import { JWT_ALGORITHM, JWT_ISSUER, TOKEN_AUDIENCE } from '../util/jwt-claims'; interface AccessTokenPayload { sub: string; @@ -10,6 +11,8 @@ interface AccessTokenPayload { // reject tokens issued before the user's last password change (see // PermissionResolverService.isTokenValid). iat: number; + // Set only on the helper tokens (MFA setup/challenge, SSO completion). + purpose?: string; } @Injectable() @@ -19,10 +22,19 @@ export class JwtStrategy extends PassportStrategy(Strategy) { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: process.env.JWT_ACCESS_SECRET as string, + // Only access tokens: the MFA challenge token (issued after the + // password step, before the second factor) shares the secret and must + // never open a session on its own. + algorithms: [JWT_ALGORITHM], + issuer: JWT_ISSUER, + audience: TOKEN_AUDIENCE.access, }); } validate(payload: AccessTokenPayload): AuthenticatedUser { + if (payload.purpose !== undefined || typeof payload.sub !== 'string') { + throw new UnauthorizedException(); + } return { id: payload.sub, email: payload.email, diff --git a/ApiCore-Backend/src/auth/util/jwt-claims.ts b/ApiCore-Backend/src/auth/util/jwt-claims.ts new file mode 100644 index 0000000..8cf39a6 --- /dev/null +++ b/ApiCore-Backend/src/auth/util/jwt-claims.ts @@ -0,0 +1,47 @@ +import type { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; + +/** + * Every JWT ApiCore issues is signed with JWT_ACCESS_SECRET, so the token's + * audience - not the secret - is what tells an access token apart from the + * short-lived helper tokens (MFA setup, MFA challenge, SSO completion). + * Without a pinned audience, the MFA challenge token handed out after the + * password step would pass the access-token strategy and skip the second + * factor entirely. + * + * Algorithm and issuer are pinned as well, so a token is only ever accepted + * in the exact shape ApiCore itself produces. + */ +export const JWT_ISSUER = 'apicore'; +export const JWT_ALGORITHM = 'HS256'; + +export const TOKEN_AUDIENCE = { + access: 'apicore:access', + mfaSetup: 'apicore:mfa-setup', + mfaChallenge: 'apicore:mfa-challenge', + ssoComplete: 'apicore:sso-complete', +} as const; + +export type TokenAudience = + (typeof TOKEN_AUDIENCE)[keyof typeof TOKEN_AUDIENCE]; + +export function jwtSignOptions( + audience: TokenAudience, + expiresIn: JwtSignOptions['expiresIn'], +): JwtSignOptions { + return { + secret: process.env.JWT_ACCESS_SECRET, + algorithm: JWT_ALGORITHM, + issuer: JWT_ISSUER, + audience, + expiresIn, + }; +} + +export function jwtVerifyOptions(audience: TokenAudience): JwtVerifyOptions { + return { + secret: process.env.JWT_ACCESS_SECRET, + algorithms: [JWT_ALGORITHM], + issuer: JWT_ISSUER, + audience, + }; +} diff --git a/ApiCore-Backend/test/token-confusion.e2e-spec.ts b/ApiCore-Backend/test/token-confusion.e2e-spec.ts new file mode 100644 index 0000000..e77b9a9 --- /dev/null +++ b/ApiCore-Backend/test/token-confusion.e2e-spec.ts @@ -0,0 +1,89 @@ +import { INestApplication } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +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'; + +interface MfaRequiredBody { + mfaRequired: true; + mfaToken: string; +} + +/** + * All ApiCore JWTs share one secret. Only an access token may open a + * session - the helper tokens (MFA challenge after the password step, MFA + * setup, SSO completion) and hand-made tokens without the pinned claims + * must be refused everywhere. + */ +describe('Token confusion (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + let userId: string; + const email = `token-confusion-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + + const me = (token: string) => + request(app.getHttpServer()) + .get('/api/v1/auth/me') + .set('Authorization', `Bearer ${token}`); + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + const user = await prisma.user.create({ + data: { + email, + passwordHash: await bcrypt.hash(password, 12), + name: 'MFA user', + mfaEnabled: true, + mfaSecret: encryptMfaSecret(generateTotpSecret()), + role: { connect: { key: 'ADMIN' } }, + }, + }); + userId = user.id; + }); + + afterAll(async () => { + await app.close(); + }); + + it('does not accept the MFA challenge token as an access token', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/auth/login') + .send({ email, password }) + .expect(200); + const body = response.body as MfaRequiredBody; + expect(body.mfaRequired).toBe(true); + + await me(body.mfaToken).expect(401); + await request(app.getHttpServer()) + .get('/api/v1/users') + .set('Authorization', `Bearer ${body.mfaToken}`) + .expect(401); + }); + + it('does not accept a token without the pinned audience and issuer', async () => { + const legacy = new JwtService().sign( + { sub: userId, email }, + { secret: process.env.JWT_ACCESS_SECRET, expiresIn: 60 }, + ); + await me(legacy).expect(401); + }); + + it('does not accept a helper token even with the access audience', async () => { + const forged = new JwtService().sign( + { sub: userId, email, purpose: 'mfa-challenge' }, + { + secret: process.env.JWT_ACCESS_SECRET, + expiresIn: 60, + issuer: 'apicore', + audience: 'apicore:access', + }, + ); + await me(forged).expect(401); + }); +});