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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 01:14:02 +02:00
1 parent fac66882fa
commit e0bd7b2095
6 files changed
+176 -20

No files matched your search

+2 -4
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 { 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),
);
}
+15 -8
View File
@@ -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<SetupTokenPayload>(setupToken, {
secret: process.env.JWT_ACCESS_SECRET,
});
payload = this.jwtService.verify<SetupTokenPayload>(
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<ChallengeTokenPayload>(challengeToken, {
secret: process.env.JWT_ACCESS_SECRET,
});
payload = this.jwtService.verify<ChallengeTokenPayload>(
challengeToken,
jwtVerifyOptions(TOKEN_AUDIENCE.mfaChallenge),
);
} catch {
throw new UnauthorizedException(
'Anmeldung abgelaufen, bitte erneut mit E-Mail und Passwort anmelden',
+10 -7
View File
@@ -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<SsoCompleteTokenPayload>(ssoCode, {
secret: process.env.JWT_ACCESS_SECRET,
});
payload = this.jwtService.verify<SsoCompleteTokenPayload>(
ssoCode,
jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete),
);
} catch {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
@@ -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,
@@ -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,
};
}